raid tab art is back, set bonuses fit, and 12 voices bound the mix
- the mix is bounded the way the reference's is: FMOD gave 1.12 exactly twelve software channels and nothing else limits a priest buffing five people — ported as a 12-voice ceiling (a probe capture read 42 live voices at +13 dBFS, the limiter pumping 13 dB to hold it), plus same-kit copies started in one frame capped at two, since five sample-aligned Fortitudes are one sound +14 dB; `$WOW_SOUND_PROBE` records the mix before and after the limiter with a timeline, F9 stamps the moment - the raid tab draws its window art again — its four corner textures were written with single backslashes, which Lua drops silently, and a sweep now reads every archive path a shipped Lua chunk names; a drag whose release never arrives (pointer left the window, a second press) ends instead of gluing the frame to the cursor for the rest of the session, and `WOW_PROBE_DRAG` drives any UI drag headlessly in a live client - the item tooltip fits its set bonuses again: an emptied line kept its last measured height forever (a pooled cell with no way to say "empty"), and the two blank rows above the set block are the reference's own literal (a space and a newline) — a whole row each, not the empty string - an equipped BoE or BoP piece reads Soulbound: the bind line reads the item's own flags through the client's bound predicate instead of the template's bonding, one predicate shared with the enchant cursor's bind confirm Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -742,6 +742,7 @@ fn shipped_bag_frame_drives_end_to_end() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
|
||||
@@ -429,6 +429,7 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_ThrowingKnife_02".into()),
|
||||
@@ -508,6 +509,7 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Sword_04".into()),
|
||||
@@ -615,6 +617,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Pelt_Wolf_01".into()),
|
||||
@@ -634,6 +637,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
|
||||
slots.insert(
|
||||
2,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Key_03".into()),
|
||||
@@ -827,6 +831,7 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
@@ -914,6 +919,7 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Gem_01".into()),
|
||||
@@ -1043,6 +1049,7 @@ fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
@@ -1497,6 +1504,7 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
|
||||
// SetInventoryItem arm. Bar slot 1 is inventory slot 20 (Bag0Slot).
|
||||
let mut inv: benilla_ui::script::InventorySlots = Default::default();
|
||||
inv[20] = Some(benilla_ui::script::InvSlotView {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
flags: 0,
|
||||
|
||||
@@ -82,6 +82,7 @@ fn combat_stats() -> UnitCombatStats {
|
||||
fn inventory_with_head_item() -> InventorySlots {
|
||||
let mut slots: InventorySlots = Default::default();
|
||||
slots[1] = Some(InvSlotView {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
flags: 0,
|
||||
@@ -106,6 +107,7 @@ fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState {
|
||||
slots.insert(
|
||||
1,
|
||||
benilla_ui::script::ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Helmet_02".into()),
|
||||
|
||||
@@ -310,6 +310,7 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
|
||||
@@ -32,6 +32,7 @@ fn one_item_backpack() -> ContainerState {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
|
||||
@@ -25,6 +25,7 @@ fn one_item_backpack() -> ContainerState {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
@@ -227,6 +228,7 @@ fn escape_closes_an_open_stack_split_frame() {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
|
||||
@@ -690,6 +690,7 @@ fn backpack() -> ContainerState {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
|
||||
@@ -32,6 +32,10 @@ pub(super) struct PointerFeed<'w> {
|
||||
/// TOGGLEUI ([`crate::ui_hide::UiHidden`]): a hidden UI takes no mouse at all — it rides here
|
||||
/// because that is precisely the pointer half of this pass.
|
||||
hidden: Res<'w, crate::ui_hide::UiHidden>,
|
||||
/// A headless probe is driving the pointer itself ([`super::SyntheticPointer`]) — the real
|
||||
/// cursor must not fight it. Same arm as `hidden` below, for the same reason: the mouse half
|
||||
/// of this pass is not ours this frame.
|
||||
synthetic: Res<'w, super::SyntheticPointer>,
|
||||
}
|
||||
|
||||
impl PointerFeed<'_> {
|
||||
@@ -86,6 +90,11 @@ pub(super) fn feed_ui_input(
|
||||
let (keyboard, keys, capture, clipboard) = (&mut kbd.0, &kbd.1, &mut kbd.2, &mut kbd.3);
|
||||
let world_pick = pointer.world_pick();
|
||||
let ui_hidden = pointer.hidden.0;
|
||||
// A headless probe is driving the pointer itself this frame (the drag probe). It takes the
|
||||
// WHOLE mouse half out — including the else-arm below, which is the half that matters: a
|
||||
// `pointer_left_window` between the synthetic press and the synthetic release would disarm
|
||||
// the gesture the probe is in the middle of.
|
||||
let synthetic = pointer.synthetic.0;
|
||||
let (hover, click_consumed, payload_held) = (
|
||||
&mut pointer.hover,
|
||||
&mut pointer.click_consumed,
|
||||
@@ -120,7 +129,10 @@ pub(super) fn feed_ui_input(
|
||||
// nobody can see must not eat the click or arm a tooltip, and the else-arm below is exactly the
|
||||
// "no pointer here" bookkeeping (leave the hovered frame once, disarm any press/drag every
|
||||
// frame) that keeps a stale gesture from firing when the UI comes back.
|
||||
if let Some(cursor) = window.cursor_position().filter(|_| !ui_hidden) {
|
||||
if let Some(cursor) = window
|
||||
.cursor_position()
|
||||
.filter(|_| !ui_hidden && !synthetic)
|
||||
{
|
||||
// Window cursor is logical px, y-down from top-left; the UI is y-up 768-virtual units
|
||||
// (decisions 0582 + 0584's uiScale dial) — flip through the window height, then ÷s into
|
||||
// the VM's space (the inverse of the extract seam's ×s).
|
||||
@@ -189,7 +201,7 @@ pub(super) fn feed_ui_input(
|
||||
if scroll.delta.y != 0.0 {
|
||||
script.mouse_wheel(x, y, scroll.delta.y);
|
||||
}
|
||||
} else {
|
||||
} else if !synthetic {
|
||||
// The OS pointer left the window: leave whatever frame was hovered (once, on the
|
||||
// Some→None transition) and — every frame it stays outside — clear any armed press/drag
|
||||
// (`UiScript::pointer_left_window`), since no release is ever fed to end it and a stale
|
||||
|
||||
@@ -65,6 +65,19 @@ pub(crate) use manifest::{load_font_registry, load_ingame_ui};
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct PointerOverUi(pub(crate) bool);
|
||||
|
||||
/// **A synthetic pointer owns the mouse this frame** — set by the headless drag probe
|
||||
/// ([`crate::capture::ProbeDragPlugin`]) while it drives a gesture through the real pointer path.
|
||||
///
|
||||
/// [`input::feed_ui_input`] skips its whole mouse half while this is set, and that is the ONLY
|
||||
/// thing it does. Without it a scripted gesture cannot exist: the OS cursor is wherever the
|
||||
/// director left it (usually outside a backgrounded probe window), so every frame between the
|
||||
/// synthetic press and the synthetic release would feed the real position — dragging the frame to
|
||||
/// the wrong place at best, and at worst calling `pointer_left_window`, which disarms the very
|
||||
/// gesture the probe just armed. The keyboard half is untouched: a probe driving the mouse has no
|
||||
/// business swallowing keys.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct SyntheticPointer(pub(crate) bool);
|
||||
|
||||
/// The egui dev overlay's half of the pointer arbitration, written each egui pass by the debug
|
||||
/// panel's `track_pointer_over_ui`. **Defined here, with the arbiter that reads it** (decision
|
||||
/// 1174 finishing 0026): the type has to exist in a build with no dev overlays compiled in, and
|
||||
@@ -277,6 +290,7 @@ impl Plugin for UiScriptPlugin {
|
||||
.init_resource::<UiFrameCost>()
|
||||
.init_resource::<UiCostWanted>()
|
||||
.init_resource::<PointerOverUi>()
|
||||
.init_resource::<SyntheticPointer>()
|
||||
.init_resource::<EguiPointerOver>()
|
||||
.init_resource::<InspectMode>()
|
||||
.init_resource::<PlayerUiHover>()
|
||||
|
||||
@@ -845,3 +845,150 @@ fn the_geometry_matches_both_reference_files() {
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
/// **A drag the cursor carries off the window edge ENDS — it does not glue the row to the mouse
|
||||
/// for the rest of the session** (B310).
|
||||
///
|
||||
/// The defect this pins is not in this file at all; it is the engine's, and the raid grid is
|
||||
/// simply where it bites hardest. No release is fed once the OS pointer is outside the window, so
|
||||
/// `UiScript::pointer_left_window` used to drop the gesture silently: `OnDragStop` never ran, this
|
||||
/// pane's `MOVING_RAID_MEMBER` stayed set, the engine's one `StartMoving` slot stayed taken, and
|
||||
/// the row followed the cursor around the screen from then on — swallowing every press aimed at
|
||||
/// any row underneath it. "I moved one member and then could not move any other" is what that
|
||||
/// looks like from a hand on the mouse.
|
||||
///
|
||||
/// The three things this asserts are the three the bug broke, in order of how visible they are:
|
||||
/// the row goes home, the pane forgets it, and the next row still drags.
|
||||
#[test]
|
||||
fn a_drag_carried_off_the_window_edge_ends_instead_of_gluing_the_row_to_the_cursor() {
|
||||
let mut s = setup();
|
||||
open_raid_tab(&mut s);
|
||||
push_raid(&mut s, twelve());
|
||||
|
||||
let left_of =
|
||||
|s: &UiScript, f: &str| -> f32 { s.eval(&format!("return {f}:GetLeft()")).unwrap() };
|
||||
let centre = |s: &UiScript, f: &str| -> (f32, f32) {
|
||||
let l: f32 = s.eval(&format!("return {f}:GetLeft()")).unwrap();
|
||||
let r: f32 = s.eval(&format!("return {f}:GetRight()")).unwrap();
|
||||
let t: f32 = s.eval(&format!("return {f}:GetTop()")).unwrap();
|
||||
let b: f32 = s.eval(&format!("return {f}:GetBottom()")).unwrap();
|
||||
((l + r) / 2.0, (t + b) / 2.0)
|
||||
};
|
||||
|
||||
let home = left_of(&s, "RaidGroupButton7");
|
||||
let (fx, fy) = centre(&s, "RaidGroupButton7");
|
||||
s.mouse_button(fx, fy, "LeftButton", true);
|
||||
s.mouse_move(fx + 40.0, fy + 40.0); // past the threshold ⇒ OnDragStart ⇒ StartMoving
|
||||
s.tick(0.016);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return MOVING_RAID_MEMBER:GetName()")
|
||||
.unwrap(),
|
||||
"RaidGroupButton7",
|
||||
"the drag is in flight"
|
||||
);
|
||||
|
||||
s.pointer_left_window();
|
||||
s.tick(0.016);
|
||||
assert!(
|
||||
s.eval::<bool>("return MOVING_RAID_MEMBER == nil").unwrap(),
|
||||
"the pane's own drag state is cleared by the OnDragStop the abandon fires"
|
||||
);
|
||||
assert_eq!(
|
||||
left_of(&s, "RaidGroupButton7"),
|
||||
home,
|
||||
"the row springs back to its slot — a drop on nothing is not a move"
|
||||
);
|
||||
|
||||
// The row is no longer following anything.
|
||||
s.mouse_move(400.0, 300.0);
|
||||
s.tick(0.016);
|
||||
s.mouse_move(500.0, 200.0);
|
||||
s.tick(0.016);
|
||||
assert_eq!(
|
||||
left_of(&s, "RaidGroupButton7"),
|
||||
home,
|
||||
"…and it stays there however far the cursor travels"
|
||||
);
|
||||
|
||||
// And the next row drags normally, which is the thing the director actually lost.
|
||||
let (gx, gy) = centre(&s, "RaidGroupButton8");
|
||||
let (tx, ty) = centre(&s, "RaidGroup5Slot1");
|
||||
s.mouse_button(gx, gy, "LeftButton", true);
|
||||
s.mouse_move(gx + 10.0, gy + 10.0);
|
||||
s.tick(0.016);
|
||||
s.mouse_move(tx, ty);
|
||||
s.tick(0.016);
|
||||
s.mouse_button(tx, ty, "LeftButton", false);
|
||||
assert_eq!(
|
||||
s.take_party_requests(),
|
||||
vec![PartyRequest::SetSubgroup { index: 8, group: 5 }],
|
||||
"the row after the abandoned one still moves"
|
||||
);
|
||||
assert!(
|
||||
s.errors().is_empty(),
|
||||
"and nothing raised: {:?}",
|
||||
s.errors()
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole gesture, through the REAL pointer path, three times over — press, cross the
|
||||
/// threshold, travel, release — with the roster echo in between, exactly as `/partytest raid`
|
||||
/// feeds it.
|
||||
///
|
||||
/// Its sibling `dragging_a_row_moves_swaps_or_springs_back` calls `RaidGroupButton_OnDragStop`
|
||||
/// with the globals set by hand, which is the *landing* logic and nothing else: it never fires
|
||||
/// `OnDragStart`, never calls `StartMoving`, never runs the hover sweep, and — the part that
|
||||
/// mattered — never drags a SECOND row. This one does all four.
|
||||
#[test]
|
||||
fn one_drag_does_not_cost_the_next_one() {
|
||||
let mut s = setup();
|
||||
open_raid_tab(&mut s);
|
||||
let mut raid = vec![row("Me", 2, 1, "Warrior", true, false)];
|
||||
for i in 1..25u32 {
|
||||
raid.push(row(
|
||||
&format!("Member{i}"),
|
||||
0,
|
||||
i / 5 + 1,
|
||||
"Priest",
|
||||
true,
|
||||
false,
|
||||
));
|
||||
}
|
||||
push_raid(&mut s, raid.clone());
|
||||
|
||||
let centre = |s: &UiScript, f: &str| -> (f32, f32) {
|
||||
let l: f32 = s.eval(&format!("return {f}:GetLeft()")).unwrap();
|
||||
let r: f32 = s.eval(&format!("return {f}:GetRight()")).unwrap();
|
||||
let t: f32 = s.eval(&format!("return {f}:GetTop()")).unwrap();
|
||||
let b: f32 = s.eval(&format!("return {f}:GetBottom()")).unwrap();
|
||||
((l + r) / 2.0, (t + b) / 2.0)
|
||||
};
|
||||
|
||||
for (row_index, group) in [(7u32, 6u32), (8, 6), (9, 7)] {
|
||||
let from = format!("RaidGroupButton{row_index}");
|
||||
let seat = if group == 6 && row_index == 8 { 2 } else { 1 };
|
||||
let to = format!("RaidGroup{group}Slot{seat}");
|
||||
let (fx, fy) = centre(&s, &from);
|
||||
let (tx, ty) = centre(&s, &to);
|
||||
s.mouse_button(fx, fy, "LeftButton", true);
|
||||
s.mouse_move(fx + 10.0, fy + 10.0);
|
||||
s.tick(0.016);
|
||||
s.mouse_move(tx, ty);
|
||||
s.tick(0.016);
|
||||
s.mouse_button(tx, ty, "LeftButton", false);
|
||||
s.tick(0.016);
|
||||
assert_eq!(
|
||||
s.take_party_requests(),
|
||||
vec![PartyRequest::SetSubgroup {
|
||||
index: row_index,
|
||||
group
|
||||
}],
|
||||
"drag {row_index} onto group {group}"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{from}: {:?}", s.errors());
|
||||
// The sandbox echo `/partytest raid` supplies, so the next drag starts from a repainted
|
||||
// grid rather than a frozen one.
|
||||
raid[row_index as usize - 1].subgroup = group - 1;
|
||||
push_raid(&mut s, raid.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,6 +347,212 @@ fn every_shipped_texture_path_resolves_in_the_client_archives() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **Every archive path a shipped LUA chunk names survives its own escaping** — the mirror image
|
||||
/// of the doubled-separator check above, and the tripwire for the defect that left the raid tab
|
||||
/// with no window art at all (the `FriendsFrame_Update` arm was written
|
||||
/// `"Interface\PaperDollInfoFrame\…"`, one backslash, so Lua ate both separators and
|
||||
/// `SetTexture` was handed `InterfacePaperDollInfoFrameUI-Character-General-TopLeft`).
|
||||
///
|
||||
/// The two halves of the same trap, and they pull in OPPOSITE directions, which is exactly why
|
||||
/// neither check can stand in for the other:
|
||||
///
|
||||
/// - an **XML attribute** is not a Lua string, so `file="Interface\Buttons\X"` is right and a
|
||||
/// doubled separator there is the bug (the sweep above);
|
||||
/// - a **Lua literal** is, so `SetTexture("Interface\\Buttons\\X")` is right and a single
|
||||
/// separator here is the bug — and it fails SILENTLY: `\P` is not an escape Lua rejects, it is
|
||||
/// one Lua drops the backslash from, so the string is well-formed, the call succeeds, and the
|
||||
/// only symptom is a texture that never appears.
|
||||
///
|
||||
/// The shape half runs without client data; the resolution half needs the archives and skips
|
||||
/// without them, the same posture as its sibling.
|
||||
#[test]
|
||||
fn every_archive_path_a_shipped_lua_chunk_names_survives_its_own_escaping() {
|
||||
use benilla_ui::framexml::{Element, ScriptRef, TopLevel};
|
||||
|
||||
// The archive roots a path literal can start with. A prefix list rather than "has a
|
||||
// backslash", because Lua strings legitimately carry `\n`/`\124` and those are not paths.
|
||||
const ROOTS: [&str; 7] = [
|
||||
"interface",
|
||||
"textures",
|
||||
"world",
|
||||
"sound",
|
||||
"character",
|
||||
"item",
|
||||
"spells",
|
||||
];
|
||||
|
||||
/// Every `"…"`/`'…'` literal in one Lua chunk, as its RAW source text (escapes uninterpreted —
|
||||
/// what this sweep is looking for is an escape Lua would have eaten, so interpreting them
|
||||
/// first would destroy the evidence).
|
||||
///
|
||||
/// **Comments and long strings are skipped, not scanned.** A `--` line quoting a path (this
|
||||
/// house writes plenty: "the file is `Interface\\Foo\\Bar`") is prose, and a prose backslash
|
||||
/// is nobody's bug — flagging one would train the next session to weaken this test rather
|
||||
/// than read it.
|
||||
fn literals(chunk: &str) -> Vec<(String, bool)> {
|
||||
let src: Vec<char> = chunk.chars().collect();
|
||||
let at = |i: usize, s: &str| src[i..].starts_with(&s.chars().collect::<Vec<_>>()[..]);
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < src.len() {
|
||||
if at(i, "--") {
|
||||
i = if at(i + 2, "[[") {
|
||||
src[i..]
|
||||
.windows(2)
|
||||
.position(|w| w == [']', ']'])
|
||||
.map_or(src.len(), |k| i + k + 2)
|
||||
} else {
|
||||
src[i..]
|
||||
.iter()
|
||||
.position(|&c| c == '\n')
|
||||
.map_or(src.len(), |k| i + k + 1)
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if at(i, "[[") {
|
||||
i = src[i + 2..]
|
||||
.windows(2)
|
||||
.position(|w| w == [']', ']'])
|
||||
.map_or(src.len(), |k| i + 2 + k + 2);
|
||||
continue;
|
||||
}
|
||||
let quote = src[i];
|
||||
if quote != '"' && quote != '\'' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let (mut raw, mut j, mut closed) = (String::new(), i + 1, false);
|
||||
while j < src.len() {
|
||||
let c = src[j];
|
||||
if c == '\\' && j + 1 < src.len() {
|
||||
raw.push(c);
|
||||
raw.push(src[j + 1]);
|
||||
j += 2;
|
||||
continue;
|
||||
}
|
||||
// A newline before the closing quote means this was never a literal (an
|
||||
// apostrophe in a comment, most often) — drop it and resume at the next char.
|
||||
if c == '\n' {
|
||||
break;
|
||||
}
|
||||
if c == quote {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
raw.push(c);
|
||||
j += 1;
|
||||
}
|
||||
if closed {
|
||||
// Is this the WHOLE path, or a fragment? A literal with `..` against either side
|
||||
// is being concatenated onto, and one carrying a `%` spec is a `format` template —
|
||||
// in both cases the string in the source is a prefix and resolving it would fail
|
||||
// by construction. Read off the SITE rather than guessed from the text (a
|
||||
// "ends with a dash" rule would be a list to feed forever), so a fragment that
|
||||
// later becomes whole starts being resolved on its own.
|
||||
let before = src[..i].iter().rposition(|c| !c.is_whitespace());
|
||||
let after = src[j + 1..].iter().position(|c| !c.is_whitespace());
|
||||
let joined = before.is_some_and(|k| k >= 1 && src[k] == '.' && src[k - 1] == '.')
|
||||
|| after.is_some_and(|k| {
|
||||
src.get(j + 1 + k) == Some(&'.') && src.get(j + 2 + k) == Some(&'.')
|
||||
});
|
||||
// …and the two fragments a `..` cannot see, because they are assigned to a
|
||||
// variable first and joined somewhere else: a literal ending in the SEPARATOR is a
|
||||
// directory, and one ending in a DASH is a family prefix (`"…\\MageFire-"` .. rank).
|
||||
// Neither is a file name — no texture in the chain ends in either character — so
|
||||
// this excludes fragments without excluding anything real.
|
||||
let fragment = raw.ends_with('\\') || raw.ends_with('-');
|
||||
let whole = !joined && !fragment && !raw.contains('%');
|
||||
out.push((raw, whole));
|
||||
i = j + 1;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// Every Lua chunk in the file: the top-level `<Script>` blocks and every element body (the
|
||||
// `<OnLoad>`-family handlers). Through the PARSER, not a text scan — an attribute value is
|
||||
// also inside double quotes, and a text scan cannot tell the two apart, which is the whole
|
||||
// distinction this test exists to make.
|
||||
fn bodies(el: &Element, out: &mut Vec<String>) {
|
||||
if !el.body.trim().is_empty() {
|
||||
out.push(el.body.clone());
|
||||
}
|
||||
for child in &el.children {
|
||||
bodies(child, out);
|
||||
}
|
||||
}
|
||||
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
|
||||
let mut paths: Vec<(String, String, bool)> = Vec::new();
|
||||
for entry in std::fs::read_dir(&dir).expect("assets/ui").flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_none_or(|e| e != "xml") {
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).expect("read");
|
||||
let file = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
let mut chunks = Vec::new();
|
||||
for item in &benilla_ui::framexml::parse(&text).expect("parses").items {
|
||||
match item {
|
||||
TopLevel::Script(ScriptRef::Inline { body, .. }) => chunks.push(body.clone()),
|
||||
TopLevel::Font(el) | TopLevel::Template(el) | TopLevel::Instance(el) => {
|
||||
bodies(el, &mut chunks);
|
||||
}
|
||||
TopLevel::Script(ScriptRef::File(_)) | TopLevel::Include(_) => {}
|
||||
}
|
||||
}
|
||||
for (raw, whole) in chunks.iter().flat_map(|c| literals(c)) {
|
||||
let lower = raw.to_ascii_lowercase();
|
||||
if ROOTS.iter().any(|r| lower.starts_with(r)) && raw.contains('\\') {
|
||||
paths.push((file.to_string(), raw, whole));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Never let the sweep pass by matching nothing.
|
||||
assert!(
|
||||
paths.len() >= 20,
|
||||
"only {} archive paths swept out of the shipped Lua",
|
||||
paths.len()
|
||||
);
|
||||
|
||||
// The shape half: after collapsing every `\\` pair, no backslash may remain — one that does
|
||||
// is a separator Lua is about to eat.
|
||||
for (file, raw, _) in &paths {
|
||||
assert!(
|
||||
!raw.replace("\\\\", "").contains('\\'),
|
||||
"{file}: the Lua literal \"{raw}\" has SINGLE separators — Lua drops the backslash \
|
||||
from every one of them, so the path arrives with its folders run together, the \
|
||||
archive lookup misses, and the texture silently never appears. Double them."
|
||||
);
|
||||
}
|
||||
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let missing: Vec<String> = paths
|
||||
.iter()
|
||||
// `sprite_candidates` answers for TEXTURES; a sound or a model path is a different
|
||||
// resolver's business, so only the two texture roots take the resolution half — and only
|
||||
// a WHOLE path, never a concatenation fragment.
|
||||
.filter(|(_, raw, whole)| {
|
||||
let lower = raw.to_ascii_lowercase();
|
||||
*whole && (lower.starts_with("interface") || lower.starts_with("textures"))
|
||||
})
|
||||
.filter(|(_, raw, _)| {
|
||||
let real = raw.replace("\\\\", "\\");
|
||||
!benilla_assets::sprite_candidates(&real)
|
||||
.iter()
|
||||
.any(|c| chain.contains(c))
|
||||
})
|
||||
.map(|(file, raw, _)| format!("{file}: \"{raw}\""))
|
||||
.collect();
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"archive paths a shipped Lua chunk names that resolve to nothing: {missing:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Every `text=` in the shipped UI is answerable against the REAL `GlobalStrings.lua`** — the
|
||||
/// tripwire for the defect that put "CREATE_MACROS" across the macro window's title bar (0991).
|
||||
///
|
||||
|
||||
@@ -54,6 +54,7 @@ fn harness() -> UiScript {
|
||||
fn seed_items(s: &mut UiScript) {
|
||||
let mut inv: InventorySlots = Default::default();
|
||||
inv[1] = Some(InvSlotView {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
flags: 0,
|
||||
@@ -98,6 +99,7 @@ fn seed_items(s: &mut UiScript) {
|
||||
slots.insert(
|
||||
1,
|
||||
ContainerSlot {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Helmet_02".into()),
|
||||
@@ -255,6 +257,7 @@ fn doll_hover_renders_the_live_instance_and_never_self_compares() {
|
||||
// Break the equipped helm: instance pair (0, 40); the template stays authored-full.
|
||||
let mut inv: InventorySlots = Default::default();
|
||||
inv[1] = Some(InvSlotView {
|
||||
already_bound: false,
|
||||
bar_placeable: true,
|
||||
durability: Some((0, 40)),
|
||||
flags: 0,
|
||||
|
||||
Reference in New Issue
Block a user