diff --git a/crates/benilla-app/assets/ui/BagFrame.xml b/crates/benilla-app/assets/ui/BagFrame.xml index 6ff3470f..64331b06 100644 --- a/crates/benilla-app/assets/ui/BagFrame.xml +++ b/crates/benilla-app/assets/ui/BagFrame.xml @@ -440,8 +440,7 @@ -- directly to count backpack repaints, e.g. delete_item_tests.rs's stuck-darkening -- regression check) — and BenillaBagWindow_Update generically for every other window. Every -- runtime repaint trigger (a slot click, an event, OpenAllBags' pre-show paint) goes - -- through this; BenillaBagBarSlot_OnClick calls BenillaBagWindow_Update directly (it only ever - -- targets bags 1..4). + -- through this (BenillaBagBarSlot_OnClick reaches it through the ToggleBag global too). function BenillaBagWindow_Repaint(frame) if frame:GetID() == 0 then BenillaBagFrame_Update() @@ -597,18 +596,31 @@ if anyOpen then CloseAllBags() else OpenAllBags() end end - -- The backpack button (MainMenuBarBackpackButton) and the 'B' binding: if any bag window is open, - -- close them all; otherwise open every existing bag (ref ToggleBackpack — "if numOpen > 0 - -- then CloseAllBags() else OpenAllBags()"). + -- The backpack button (MainMenuBarBackpackButton): if any bag window is open, close them + -- all; otherwise open every existing bag (ref ToggleBackpack — "if numOpen > 0 then + -- CloseAllBags() else OpenAllBags()"). + -- + -- ONE STATED DIVERGENCE from ref BackpackButton_OnClick (MainMenuBarBagButtons.lua + -- l.55-69). The ref's tail re-derives the checked state from a scan of its NATIVE + -- ContainerFrames — and OnShow/OnHide dispatch is SYNCHRONOUS in the reference (wow-re + -- `onshow-onhide-dispatch-order.md`: Show binder 0x775750 → transition 0x76ae10 → notify + -- 0x76b260 → lua_pcall, every hop a direct call), so when an addon owns the bags its + -- OnShow's `SetChecked(1)` (Bagnon.lua l.33) lands FIRST and the ref tail stomps it: on + -- the real client the backpack button ends UNLIT after clicking it with Bagnon, and + -- lights only via the key binding (whose body is the bare `ToggleBackpack()`, no tail). + -- That click-path outcome contradicts the addon's evident intent and the director's call, + -- so ours swaps the scan for the auto-toggle UNDO below: checked belongs to the WINDOWS' + -- OnShow/OnHide writes — ours (BenillaBagFrame_OnShow/_OnHide) and any addon's alike — + -- and this handler only repairs what the CheckButton widget flipped before it ran. Every + -- stock outcome is identical to the ref's (the transition writes land inside + -- ToggleBackpack and are the last word); the addon case ends lit instead of stomped. + -- The widget-flip UNDO itself lives in the button's XML wrapper — the one seat + -- where the CheckButton auto-toggle has just happened — so every DIRECT caller of this + -- function (the tests, a macro) gets pure toggle semantics with no checked bookkeeping + -- to corrupt. function BenillaBagToggle_OnClick() - -- Through the GLOBAL, so an addon that replaced `ToggleBackpack` gets the click. This - -- one line is the whole Bagnon fix; the rest below is our own button's checked art, - -- which is ours to keep whatever the bag window turns out to be. + -- Through the GLOBAL, so an addon that replaced `ToggleBackpack` gets the click. ToggleBackpack() - -- The auto-toggle is provisional (ref BackpackButton_OnClick l.58-67): the button - -- lights for the BACKPACK window specifically, whatever the all-toggle just did. - local backpack = getglobal(BENILLA_BAG_WINDOWS[1]) - getglobal("MainMenuBarBackpackButton"):SetChecked(backpack and backpack:IsShown()) end -- The window's bar button (ref ContainerFrame_OnShow/OnHide: bag 0 → @@ -778,23 +790,28 @@ BenillaBagBarSlot_Update(button) end + -- ref BagSlotButton_OnClick (MainMenuBarBagButtons.lua l.3-21): the cursor arm first (the + -- ref's PutItemInBag; ours is the equip/swap PickupInventoryItem arm, decision 0208 1b), + -- otherwise the toggle goes THROUGH THE GLOBAL `ToggleBag` — the exact name + -- Bagnon_Core/Overrides.lua replaces — with the ref's own button sound on that arm only. + -- Toggling the window directly here (what this did before) made every bag addon's override + -- silently inert for the four slot buttons while the backpack button honoured it: the + -- 517-line rule above ("the reference's names are the real entry points") applied to only + -- one of the five buttons, and the director met the other four. function BenillaBagBarSlot_OnClick(button) if CursorHasItem() then BenillaBagBarSlot_Place(button) -- drop/equip the carried bag onto this slot else - local frame = getglobal("BenillaBagFrame" .. button.bagId) - if frame then - if frame:IsShown() then - frame:Hide() - else - BenillaBagWindow_Update(frame) - frame:Show() - end - end + ToggleBag(button.bagId) + PlaySound("BAGMENUBUTTONPRESS") end - -- The click's own CheckButton auto-toggle is provisional — re-derive from the - -- window on every branch, the ref's exact tail (BagSlotButton_OnClick l.12-20: - -- SetChecked(isVisible) whether the click toggled, placed a bag, or did nothing). + -- The click's own CheckButton auto-toggle is provisional — re-derive from the NATIVE + -- window on every branch, the ref's exact tail (BagSlotButton_OnClick l.12-20 scans + -- ContainerFrame1..N: SetChecked(isVisible) whether the click toggled, placed a bag, + -- or did nothing). Reading the WINDOW rather than what ToggleBag did is load-bearing + -- both ways: stock, the flag is already set when the tail runs; with an addon holding + -- the bags, the native stays hidden and the slot button stays unlit — the reference's + -- own behaviour (its scan is equally blind to addon frames). local frame = getglobal("BenillaBagFrame" .. button.bagId) button:SetChecked(frame and frame:IsShown()) end @@ -1717,7 +1734,13 @@ self.icon = getglobal("MainMenuBarBackpackButtonIconTexture") if BenillaActionBarArt_SeatAbove then BenillaActionBarArt_SeatAbove(self) end - BenillaBagToggle_OnClick() + + + self:SetChecked(not self:GetChecked()) + BenillaBagToggle_OnClick() + BenillaBagToggle_OnEnter(self) BenillaBagBarButton_OnLeave() diff --git a/crates/benilla-app/assets/ui/SkillFrame.xml b/crates/benilla-app/assets/ui/SkillFrame.xml index bb0b739b..aa5547c9 100644 --- a/crates/benilla-app/assets/ui/SkillFrame.xml +++ b/crates/benilla-app/assets/ui/SkillFrame.xml @@ -397,7 +397,12 @@ -- The description body hangs under the detail bar — anchored here, not in XML, -- because the bar is a child frame declared after the page's layers and anchors -- resolve at SetPoint time (an XML forward reference silently lands on the page; - -- the MailFrame error-text OnLoad precedent, ref MailFrame.xml l.1040). + -- the MailFrame error-text OnLoad precedent, ref MailFrame.xml l.1040). Cleared + -- first: the load-time implicit anchor (decision 1310) seated this justifyH="LEFT" + -- string at the page's LEFT, and SetPoint replaces only its own point -- without the + -- clear both anchors stand and the left/center edges resolve off the page, not the + -- bar. (The ref needs no clear on ITS file only because its XML anchors the string.) + SkillDetailDescriptionText:ClearAllPoints() SkillDetailDescriptionText:SetPoint("TOPLEFT", "SkillDetailBar", "BOTTOMLEFT", -2, -10) end diff --git a/crates/benilla-app/assets/ui/StackSplit.xml b/crates/benilla-app/assets/ui/StackSplit.xml index bf58d844..c6372709 100644 --- a/crates/benilla-app/assets/ui/StackSplit.xml +++ b/crates/benilla-app/assets/ui/StackSplit.xml @@ -161,7 +161,14 @@ - + diff --git a/crates/benilla-app/src/bindings/commands.rs b/crates/benilla-app/src/bindings/commands.rs index 7cd516ac..e4c28af9 100644 --- a/crates/benilla-app/src/bindings/commands.rs +++ b/crates/benilla-app/src/bindings/commands.rs @@ -559,10 +559,16 @@ pub(crate) static SPECS: &[Spec] = &[ // 1.12 splits TOGGLEBACKPACK (B, F12) from OPENALLBAGS (SHIFT-B); benilla's one bag knob is // the open-all toggle that already lived on B — so OPENALLBAGS ships with both defaults // (recorded divergence, 0997). + // + // The body is the bare GLOBAL, exactly the ref's own Bindings.xml (`ToggleBackpack();`) — + // NOT the backpack button's OnClick handler it used to run: the handler carries the button's + // checked bookkeeping, and routing the key through it dragged that onto a path the reference + // keeps clean (with a bag addon holding `ToggleBackpack`, the key press is the path that + // lights the backpack button on the real client — the addon's OnShow write is the last word). spec!( "OPENALLBAGS", INTERFACE, - Kind::Edge("BenillaBagToggle_OnClick()"), + Kind::Edge("ToggleBackpack()"), Some("B"), Some("SHIFT-B") ), diff --git a/crates/benilla-app/src/ui_char.rs b/crates/benilla-app/src/ui_char.rs index 3cb4c997..a4aebf18 100644 --- a/crates/benilla-app/src/ui_char.rs +++ b/crates/benilla-app/src/ui_char.rs @@ -121,16 +121,25 @@ impl Plugin for UiCharPlugin { /// `None` = nothing announced yet. type SkillBlock = Option<(u64, std::collections::HashMap)>; -/// The client-generated skill-up feedback (decision 0437, landed with phase 2): the server sends -/// NO skill-up message at all — skills mutate only as `PLAYER_SKILL_INFO` descriptor deltas -/// (verified at the vmangos source: no chat send anywhere in `UpdateSkill*`/`SetSkill`) — so the -/// client diff-watches its own skill block, prints the byte-exact 1.12 line -/// (`SKILL_RANK_UP`, GlobalStrings.lua:3485: "Your skill in %s has increased to %d.") on the -/// Skill chat channel, and fires `SKILL_LINES_CHANGED` on ANY block change (the skills pane's -/// repaint event). The login fill (empty → populated) and a character switch (self guid change) -/// re-seed silently — the event fires, the lines don't. INTERIM (pinned to the in-flight wow-re -/// §5, 0437 TU-E): no `SKILL_FLAG_NO_SKILLUP_MESSAGE` gate yet, and the exact chat channel of the -/// real emitter is unconfirmed (Skill is the `ChatTypeInfo` family's own key for it). +/// The client-generated skill-up feedback (decision 0437, landed with phase 2; gate corrected by +/// 1309): the server sends NO skill-up message at all — skills mutate only as +/// `PLAYER_SKILL_INFO` descriptor deltas (verified at the vmangos source: no chat send anywhere +/// in `UpdateSkill*`/`SetSkill`) — so the client diff-watches its own skill block exactly as the +/// real one does (the rank-field UpdateField watcher `0x5de180` → message facility `0x496720`, +/// tokens `ERR_SKILL_UP_SI` / `ERR_SKILL_GAINED_S` — wow-re tradeskill TU-E, correcting 0437's +/// "SKILL_RANK_UP" token guess), prints the GlobalStrings lines on the Skill chat channel, and +/// fires `SKILL_LINES_CHANGED` on ANY block change (the skills pane's repaint event; TU-E: the +/// real client fires it from a separate skill-manager TU on add/remove only — ours riding the +/// same watcher is a benign coarsening, the pane just repaints). **The message gate** (B19/B245, +/// decisions 1309/1314): both lines are skipped when the line's `SkillRaceClassInfo.flags` +/// carries `0x402` (`SkillRaceClass::skill_up_silent`) — the class spec lines, racials, +/// `GENERIC (DND)`, `Dual Wield` and the mount lines — **or when no row admits this race/class +/// at all** (the resolve's own empty branch, `0x5de352`) — which is why a real 1.12 ding +/// announces no skill at all (the level-up movers are all flagged) while weapon/profession +/// skill-ups still print. The +/// login fill (empty → populated) and a character switch (self guid change) re-seed silently — +/// the event fires, the lines don't. Still open: the exact chat channel of the real emitter +/// (TU-E left `0x496720`'s routing untraced; Skill is the `ChatTypeInfo` family's own key). fn watch_skill_ups( script: Option>, self_guid: Res, @@ -176,21 +185,42 @@ fn watch_skill_ups( if *prev_map == cur { return; } + // The 0x402 message gate resolves through the player's own race/class row, the same + // lookup the pane's display law makes ([`skills_row`]). + let (race, class) = ( + store.0.unit_race().unwrap_or(0), + store.0.unit_class().unwrap_or(0), + ); for (&id, &value) in &cur { + let line_id = u32::from(id); let name = || { skill_lines .as_ref() - .and_then(|s| s.catalog.line(u32::from(id))) + .and_then(|s| s.catalog.line(line_id)) .map(|l| l.name.clone()) }; + // The fn doc's message gate; `false` with no catalog (nothing to name it by + // either — the line stays silent, as before the catalog loads). + let announces = || { + skill_lines + .as_ref() + .is_some_and(|s| s.catalog.announces_skill_ups(line_id, race, class)) + }; match prev_map.get(&id) { // A rank-up: the ERR_SKILL_UP_SI line (GlobalStrings.lua:1838). Some(&old) if value > old => { if let Some(name) = name() { - chat.push_event(crate::ui_chat::ChatEvent::text_only( - crate::ui_chat::ChatEventKind::Skill, - format!("Your skill in {name} has increased to {value}."), - )); + // Both verdicts are logged — the retest's instrument: a moved line + // either announces or names the gate that held it. + if announces() { + debug!("chat: skill-up announced ({name} {old}→{value})"); + chat.push_event(crate::ui_chat::ChatEvent::text_only( + crate::ui_chat::ChatEventKind::Skill, + format!("Your skill in {name} has increased to {value}."), + )); + } else { + debug!("chat: skill-up silenced ({name} {old}→{value}, the 0x402 gate)"); + } } } // A NEW line: the ERR_SKILL_GAINED_S line (GlobalStrings.lua:1837) — the @@ -198,10 +228,17 @@ fn watch_skill_ups( // both, first-gain vs rank-up). None => { if let Some(name) = name() { - chat.push_event(crate::ui_chat::ChatEvent::text_only( - crate::ui_chat::ChatEventKind::Skill, - format!("You have gained the {name} skill."), - )); + if announces() { + debug!("chat: skill-gain announced ({name} at {value})"); + chat.push_event(crate::ui_chat::ChatEvent::text_only( + crate::ui_chat::ChatEventKind::Skill, + format!("You have gained the {name} skill."), + )); + } else { + debug!( + "chat: skill-gain silenced ({name} at {value}, the 0x402 gate)" + ); + } } } _ => {} diff --git a/crates/benilla-app/src/ui_script/bag_tests.rs b/crates/benilla-app/src/ui_script/bag_tests.rs index c2b84d19..26eea7f7 100644 --- a/crates/benilla-app/src/ui_script/bag_tests.rs +++ b/crates/benilla-app/src/ui_script/bag_tests.rs @@ -73,7 +73,8 @@ fn bag_bar_icons_draw_above_the_action_bar_art() { /// The backpack open/close kits (ContainerFrame.lua ContainerFrame_OnShow/OnHide, l.140 / l.120): /// showing the window queues igBackPackOpen, hiding it queues igBackPackClose — and nothing queues /// at load (the frame is authored hidden="true", so it never transitions on startup). Driven through -/// `BenillaBagToggle_OnClick`, the exact path both the bag button and the 'B' binding use. +/// `BenillaBagToggle_OnClick` — the toggle body the bag button's click wrapper calls (the 'B' +/// binding runs the bare `ToggleBackpack()`, the same one hop deeper). #[test] fn backpack_toggle_plays_open_and_close_kits() { let mut s = UiScript::new().unwrap(); @@ -1002,6 +1003,60 @@ fn shift_click_on_a_stack_opens_the_split_frame() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } +/// B180 — the split dialog's parchment plate fills the whole 172×96 frame. The reference authors +/// the plate 256×32 with NO anchors, a vestigial size the real client never renders (the TexCoords +/// crop exactly 172×96 out of the 256×128 art — the frame's own size, a complete panel drawn 1:1 +/// over it). Our sized-anchor-less fallback draws CENTERED at the authored size instead, which +/// squashed the dialog to a 32-tall bar: the count and arrows floated above it and Okay/Cancel +/// straddled its bottom edge. The plate's authored size is dropped (StackSplit.xml says why); this +/// pins the render so the vestigial size cannot come back. +#[test] +fn the_split_frame_plate_fills_the_dialog() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + let (x, y) = open_backpack_with_a_five_stack(&mut s); + + s.set_modifiers(true, false, false); + s.mouse_button(x, y, "LeftButton", true); + s.mouse_button(x, y, "LeftButton", false); + s.set_modifiers(false, false, false); + s.resolve(); + + let (left, right, top, bottom) = s + .eval::<(f64, f64, f64, f64)>( + "return StackSplitFrame:GetLeft(), StackSplitFrame:GetRight(), \ + StackSplitFrame:GetTop(), StackSplitFrame:GetBottom()", + ) + .unwrap(); + assert!( + (right - left - 172.0).abs() < 0.5 && (top - bottom - 96.0).abs() < 0.5, + "the dialog frame is 172×96, got {}×{}", + right - left, + top - bottom + ); + let plate = s + .extract() + .into_iter() + .find(|q| { + matches!(&q.content, QuadContent::Texture { path: Some(p), .. } + if p.contains("UI-MoneyFrame")) + }) + .expect("the plate is on screen"); + let r = plate.rect.expect("the plate has a rect"); + for (edge, got, want) in [ + ("left", r.left, left as f32), + ("right", r.right, right as f32), + ("top", r.top, top as f32), + ("bottom", r.bottom, bottom as f32), + ] { + assert!( + (got - want).abs() < 0.5, + "plate {edge} = {got}, frame {edge} = {want} — the plate must fill the dialog" + ); + } + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + /// Okay in the split spinner only picks the split carry up (ref/cursor.rs `SplitContainerItem` — /// a pickup, not a self-contained move); a SUBSEQUENT placement is what actually queues the /// `ContainerMove` with `count: Some(n)`, drained the same way any other container move is. @@ -1834,3 +1889,73 @@ fn the_bag_slots_carry_the_references_names_and_icon_names() { "Bagnon captures this OnClick to replace the bag behaviour" ); } + +/// The backpack button's checked law after the stated divergence (BenillaBagToggle_OnClick's +/// comment): checked belongs to the WINDOWS' OnShow/OnHide writes, and the button's XML click +/// wrapper only undoes the CheckButton widget's pre-handler flip. The killer case is the click +/// that never touches the backpack window — backpack shut, another bag's window open → +/// ToggleBackpack sees an open window and CLOSES ALL; the backpack never transitions, nothing +/// writes its button, and only the wrapper's undo keeps the widget flip from leaving it +/// lit-while-shut (the job the ref's native-window scan tail used to do). +#[test] +fn a_close_others_click_leaves_the_backpack_button_dark() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + for file in [ + "Fonts.xml", + "UiPanels.xml", + "MerchantFrame.xml", + "Cooldown.xml", + "ActionBar.xml", + "BagFrame.xml", + ] { + load_xml(&s, file); + } + s.set_money(0); + s.set_container( + 1, + Some(benilla_ui::script::ContainerState { + name: Some("Pouch".into()), + num_slots: 6, + slots: std::collections::HashMap::new(), + }), + ); + let checked = |s: &mut UiScript| { + s.eval::("return MainMenuBarBackpackButton:GetChecked() and true or false") + .unwrap() + }; + // Bag 1's window alone is open; the backpack window and its button are dark. + s.run("ToggleBag(1)").unwrap(); + assert!(!s.eval::("return BenillaBagFrame:IsShown()").unwrap()); + assert!(!checked(&mut s)); + + // A REAL click on the backpack button (the widget flip fires only on real input): close-all. + s.resolve(); + let r: Vec = s + .eval( + "local f = MainMenuBarBackpackButton \ + return { f:GetLeft() + f:GetWidth() / 2, f:GetBottom() + f:GetHeight() / 2 }", + ) + .unwrap(); + s.mouse_move(r[0], r[1]); + s.mouse_button(r[0], r[1], "LeftButton", true); + s.mouse_button(r[0], r[1], "LeftButton", false); + assert!( + !s.eval::("return BenillaBagFrame1:IsShown()").unwrap(), + "the click closes the other bag's window (ToggleBackpack's close-all arm)" + ); + assert!( + !checked(&mut s), + "no backpack transition happened, so the button must stay dark — the widget flip undone" + ); + + // The second real click opens the backpack (plus the equipped bag): its OnShow writes the light. + s.mouse_button(r[0], r[1], "LeftButton", true); + s.mouse_button(r[0], r[1], "LeftButton", false); + assert!(s.eval::("return BenillaBagFrame:IsShown()").unwrap()); + assert!( + checked(&mut s), + "open ⇒ lit, written by the window's OnShow" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} diff --git a/crates/benilla-app/src/ui_script/bagnon_render_tests.rs b/crates/benilla-app/src/ui_script/bagnon_render_tests.rs index 650ba31d..d2eaf804 100644 --- a/crates/benilla-app/src/ui_script/bagnon_render_tests.rs +++ b/crates/benilla-app/src/ui_script/bagnon_render_tests.rs @@ -1391,3 +1391,158 @@ fn bagnon_forevers_records_survive_the_logout_boundary() { ); assert_eq!(item, "4496", "the logout boundary must not erase the slot"); } + +/// **Bug 6 — the director's "smaller bags open their own little bags".** Bagnon's whole +/// interception model is the global override (`Bagnon_Core/core/Overrides.lua`: `ToggleBag = …`) +/// plus a `SetScript("OnClick")` wrap on our bar buttons whose replacement calls the ORIGINAL +/// handler back for a plain click. Both layers end at whatever the original OnClick does — and +/// ours toggled `BenillaBagFrame` directly instead of calling `ToggleBag`, so every slot-button +/// click walked straight past both of Bagnon's hooks and opened the native window (the backpack +/// button was fixed in an earlier pass; the four slots were not). The ref's own +/// `BagSlotButton_OnClick` (MainMenuBarBagButtons.lua l.3-21) calls the GLOBAL `ToggleBag`, and +/// its checked tail scans only the NATIVE ContainerFrames — so with Bagnon holding the bags a +/// slot click toggles Bagnon's one window and the slot button stays unlit. Both halves pinned +/// here with a real mouse click, an equipped bag in slot 2, and Bagnon fully hooked. +#[test] +fn a_bag_slot_click_toggles_bagnon_not_the_native_window() { + let root = corpus_or_skip!(); + let mut s = open_bagnon(&root); + + // An equipped bag behind CharacterBag1Slot (bagId 2): the inventory surface (the bag ITEM in + // inv slot 21) and its container — both fed before any click, the schedule's own order. + let mut inv: benilla_ui::script::InventorySlots = Default::default(); + inv[21] = Some(benilla_ui::script::InvSlotView { + item_id: 4497, + count: 1, + quality: 1, + name: Some("Small Green Pouch".into()), + link: Some("|cffffffff|Hitem:4497:0:0:0|h[Small Green Pouch]|h|r".into()), + icon: Some("Interface\\Icons\\INV_Misc_Bag_10_Green".into()), + ..Default::default() + }); + s.set_inventory_slots(inv); + s.set_container( + 2, + Some(ContainerState { + name: Some("Small Green Pouch".into()), + num_slots: 6, + slots: Default::default(), + }), + ); + s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(2)]); + s.resolve(); + + // Bagnon is open (the fixture); a REAL click on the slot button must CLOSE Bagnon — the + // toggle routed through the override — and must never show the native window. + // Both interception layers must be live, and the click exercises both: the SetScript wrap + // (Bagnon.lua's Bagnon_AddBagHooks — its replacement calls our CAPTURED original back with + // no args, so `self` inside the XML wrapper must still resolve on the re-entrant call) and + // the global ToggleBag override the original then reaches. + assert!( + s.eval::("return CharacterBag1Slot:GetScript('OnClick') == BagnonBlizBag_OnClick") + .unwrap_or(false), + "Bagnon's SetScript OnClick wrap must be in place on the slot button" + ); + let (x, y) = centre_of(&mut s, "CharacterBag1Slot"); + s.mouse_move(x, y); + s.mouse_button(x, y, "LeftButton", true); + s.mouse_button(x, y, "LeftButton", false); + assert!( + !s.eval::("return Bagnon:IsVisible() or false") + .unwrap(), + "the slot click must reach Bagnon's ToggleBag override and close its window" + ); + assert!( + !s.eval::("return BenillaBagFrame2:IsVisible() or false") + .unwrap(), + "the native bag window must NOT open — the click belongs to the addon's override" + ); + + // Click again: Bagnon back open, native still shut, and the slot button unlit — the ref's + // checked tail reads only the native window (its ContainerFrame scan), which Bagnon never + // shows. + s.mouse_button(x, y, "LeftButton", true); + s.mouse_button(x, y, "LeftButton", false); + for _ in 0..2 { + s.tick(0.1); + } + assert!( + s.eval::("return Bagnon:IsVisible() or false") + .unwrap(), + "the second click re-opens Bagnon through the same override" + ); + assert!( + !s.eval::("return BenillaBagFrame2:IsVisible() or false") + .unwrap(), + "the native window stays shut on the re-open too" + ); + assert!( + !s.eval::("return CharacterBag1Slot:GetChecked() and true or false") + .unwrap(), + "the slot button stays unlit with an addon holding the bags (the ref scan is native-only)" + ); + let raised = s.errors(); + let unexplained: Vec<&String> = raised + .iter() + .filter(|e| !e.contains("PutItemIn") && !e.contains("PickupBagFromSlot")) + .collect(); + assert!( + unexplained.is_empty(), + "the click round-trip raised: {unexplained:#?}" + ); +} + +/// **Bug 7 — the director's unlit backpack button.** Bagnon lights `MainMenuBarBackpackButton` +/// from its window's OnShow/OnHide (`Bagnon.lua:33/39` — `SetChecked(1/0)`), and OnShow dispatch +/// is synchronous (wow-re `onshow-onhide-dispatch-order.md`), so whoever writes checked AFTER the +/// toggle wins. Two of our seats overwrote it: the button's old click tail re-derived checked +/// from the NATIVE backpack window (the ref's own scan — which leaves the button unlit on the +/// real client's click path too, a ref wart our stated divergence closes), and the `B` binding +/// ran the BUTTON handler instead of the ref's bare `ToggleBackpack()` body, dragging that tail +/// onto the one path the real client lights. Now: a real click on the button ends lit while +/// Bagnon's window is open and unlit when it closes, and the bare-global path (the binding's +/// body) does the same. +#[test] +fn the_backpack_button_lights_while_bagnon_holds_the_bags() { + let root = corpus_or_skip!(); + let mut s = open_bagnon(&root); + + // The fixture opened Bagnon through the bare global (the binding's own body): OnShow's + // SetChecked(1) must be standing — nothing runs after it on that path. + assert!( + s.eval::("return MainMenuBarBackpackButton:GetChecked() and true or false") + .unwrap(), + "the bare ToggleBackpack() path (the B binding) must leave the button lit" + ); + + // A REAL click on the button: auto-toggle flips, the wrapper's undo repairs it, Bagnon's + // OnHide writes the truth — closed and unlit. + let (x, y) = centre_of(&mut s, "MainMenuBarBackpackButton"); + s.mouse_move(x, y); + s.mouse_button(x, y, "LeftButton", true); + s.mouse_button(x, y, "LeftButton", false); + assert!( + !s.eval::("return Bagnon:IsVisible() or false") + .unwrap(), + "the click must toggle Bagnon closed through its override" + ); + assert!( + !s.eval::("return MainMenuBarBackpackButton:GetChecked() and true or false") + .unwrap(), + "closed ⇒ unlit (Bagnon's OnHide write is the last word)" + ); + + // And the second click: open again, lit again — the director's report, closed. + s.mouse_button(x, y, "LeftButton", true); + s.mouse_button(x, y, "LeftButton", false); + assert!( + s.eval::("return Bagnon:IsVisible() or false") + .unwrap(), + "the second click re-opens Bagnon" + ); + assert!( + s.eval::("return MainMenuBarBackpackButton:GetChecked() and true or false") + .unwrap(), + "open ⇒ lit (Bagnon's OnShow write is the last word)" + ); +} diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index e997c315..f4f63ed2 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -826,6 +826,7 @@ mod clip_plumb_tests { frame:SetScrollChild(child) local marker = child:CreateTexture(nil, "ARTWORK") marker:SetTexture(1, 0, 0) -- pathless colored quad: no BLP asset needed + marker:SetAllPoints() -- templateless Lua region: no implicit anchor (1310) "#, ) .unwrap(); @@ -911,6 +912,7 @@ mod clip_plumb_tests { plain:SetSize(50, 50) local m = plain:CreateTexture(nil, "ARTWORK") m:SetTexture(0, 1, 0) + m:SetAllPoints() -- templateless Lua region: no implicit anchor (1310) "#, ) .unwrap(); @@ -970,6 +972,7 @@ mod extract_gate_tests { plain:SetSize(50, 50) marker = plain:CreateTexture(nil, "ARTWORK") marker:SetTexture(1, 0, 0) + marker:SetAllPoints() -- templateless Lua region: no implicit anchor (1310) "#, ) .unwrap(); diff --git a/crates/benilla-formats/src/skill_lines.rs b/crates/benilla-formats/src/skill_lines/mod.rs similarity index 64% rename from crates/benilla-formats/src/skill_lines.rs rename to crates/benilla-formats/src/skill_lines/mod.rs index f9913409..8e9ba591 100644 --- a/crates/benilla-formats/src/skill_lines.rs +++ b/crates/benilla-formats/src/skill_lines/mod.rs @@ -129,7 +129,8 @@ const SKILL_FLAG_ALWAYS_DISPLAY: u32 = 0x1; /// (`4d2d9f test dl,0x2`, wow-re `skillframe-display-list.md`). mangos names this bit /// `SKILL_FLAG_NO_SKILLUP_MESSAGE` from a different call site; in the display list it is a hide /// bit, and it is what keeps `Dual Wield`, the racial lines, the per-mount riding lines and -/// `GENERIC (DND)` off the real client's pane. +/// `GENERIC (DND)` off the real client's pane. mangos's "different call site" is the skill-up +/// message watcher — the bit is half of [`SkillRaceClass::skill_up_silent`]'s `0x402` mask. const SKILL_FLAG_HIDDEN: u32 = 0x2; /// `SkillRaceClassInfo.flags` bit `0x4` — an untrained (rank 0) line becomes visible once the @@ -211,6 +212,24 @@ impl SkillRaceClass { self.flags & SKILL_FLAG_MONO_VALUE != 0 } + /// Whether a change to this line prints **no** skill-up chat line — the real client's + /// rank-watcher gate (`0x5de180`, wow-re tradeskill TU-E): the message is skipped when the + /// resolved row's flag word at `+0x10` carries `0x402` = + /// [`SKILL_FLAG_MONO_VALUE`]`|`[`SKILL_FLAG_HIDDEN`]. On the real build-5875 data the mask + /// cuts the table exactly along the historically attested line: every class spec line + /// (`0x410`), the racials / `Dual Wield` / `GENERIC (DND)` / the per-mount riding lines + /// (`0x492`) and `Fist Weapons` (`0x082`) are silent — which is why a real 1.12 ding + /// announces no skill at all, the level-up movers being all flagged — while the other weapon + /// lines, `Defense`, the armor proficiencies, professions/secondary, `Lockpicking`, + /// `Poisons` and the languages (`0x080`/`0x0a0`) announce. The table identity is settled at + /// the bytes (1309's dispatch, a unanimous wow-re §5 — the corrected TU-E carries the full + /// chain): the flag test `0x5de358` reads the **untouched return of the `0x6ddf90` + /// SkillRaceClassInfo resolve**, never a `SkillLine.dbc` field — TU-E's original prose had + /// conflated the two adjacent row pointers. + pub fn skill_up_silent(self) -> bool { + self.flags & (SKILL_FLAG_MONO_VALUE | SKILL_FLAG_HIDDEN) != 0 + } + /// Whether the line can be unlearned — `flags & 0x20` ([`SKILL_FLAG_UNLEARNABLE`]). The /// client ANDs this with a nonzero skill **step**; that half lives with the descriptor, at /// the feed. @@ -402,6 +421,17 @@ impl SkillLineCatalog { .is_some_and(SkillRaceClass::mono) } + /// Whether a rank change in `line_id` prints the skill-up / skill-gained chat line for a + /// character of `race`/`class` (1-based unit bytes) — [`SkillRaceClass::skill_up_silent`], + /// inverted. `false` with no admitting row: the real watcher skips the message when its + /// `SkillRaceClassInfo` resolve comes back empty too (`0x5de352 je`, the same taken branch as + /// the flag test — 1309's §5 chain, decision 1314), so a line this character can't legally + /// hold stays silent however it got into the block. + pub fn announces_skill_ups(&self, line_id: u32, race: u8, class: u8) -> bool { + self.race_class(line_id, race, class) + .is_some_and(|rc| !rc.skill_up_silent()) + } + /// A skill line's display (name + tab icon), by id. pub fn line(&self, line_id: u32) -> Option<&SkillLineInfo> { self.lines.get(&line_id) @@ -601,325 +631,4 @@ pub fn load_skill_line_catalog(chain: &mut Chain) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - /// Resolve real spells to their real skill lines on the build-5875 data, cross-checked - /// against vmangos `SharedDefines.h`'s `SkillType` enum (Frost=6, Fire=8 — the module doc's - /// own citation). Skips without client data. - #[test] - fn real_skill_line_catalog_resolves_known_spells() { - let data = crate::wow_data_or_skip!(); - let mut chain = crate::open_chain(&data).expect("open chain"); - let cat = load_skill_line_catalog(&mut chain).expect("load SkillLine/SkillLineAbility"); - assert!( - cat.len() > 50, - "a real skill-line table has hundreds of rows" - ); - - // Fireball (133) -> the Fire line (SKILL_FIRE = 8). - let fire_line = cat.spell_to_line(133).expect("Fireball has a skill line"); - assert_eq!(fire_line, 8); - let fire = cat.line(fire_line).expect("the Fire line resolves"); - assert_eq!(fire.name, "Fire"); - assert!(fire.icon.is_some(), "the Fire tab has an icon"); - - // Frost Armor (168) -> the Frost line (SKILL_FROST = 6). - let frost_line = cat - .spell_to_line(168) - .expect("Frost Armor has a skill line"); - assert_eq!(frost_line, 6); - let frost = cat.line(frost_line).expect("the Frost line resolves"); - assert_eq!(frost.name, "Frost"); - assert!(frost.icon.is_some(), "the Frost tab has an icon"); - - // An unknown spell id has no line. - assert_eq!(cat.spell_to_line(0), None); - - // The description column (12): a profession line carries the flavor sentence, a weapon - // line is blank — a column slip lands on another locale (empty for enUS data) or the - // name flags word (a parse error) and fails loudly. - let smithing = cat.line(164).expect("Blacksmithing resolves"); - assert!( - smithing.description.to_lowercase().contains("blacksmith"), - "Blacksmithing's description names the trade: {:?}", - smithing.description - ); - let swords = cat.line(43).expect("Swords resolves"); - assert_eq!( - swords.description, "Higher weapon skill increases your chance to hit.", - "the weapon lines' shared byte-exact sentence" - ); - } - - /// The General collapse on real build-5875 `SkillRaceClassInfo.dbc` (decision 0228), traced on - /// concrete spells for a human warrior (race 1, class 1) vs. a human mage (race 1, class 8): - /// class-native combat lines keep their own tab, racials collapse to General, and a cross-class - /// spell (a warrior's cheated Fireball) collapses to General while the SAME spell keeps its Fire - /// tab for a mage — the class/race dependence, the whole point of the routing. Skips without - /// client data. - #[test] - fn real_spell_tab_collapses_general_by_race_and_class() { - let data = crate::wow_data_or_skip!(); - let mut chain = crate::open_chain(&data).expect("open chain"); - let cat = load_skill_line_catalog(&mut chain).expect("load skill lines"); - const HUMAN: u8 = 1; - const WARRIOR: u8 = 1; - const MAGE: u8 = 8; - - // Warrior class abilities keep their own class-line tabs (flag clear for a warrior): - // Charge/Heroic Strike/Rend on Arms (26), Battle Shout on Fury (256). - for (id, line) in [(100u32, 26u32), (78, 26), (772, 26), (6673, 256)] { - assert_eq!(cat.spell_to_line(id), Some(line)); - assert_eq!( - cat.spell_tab(id, HUMAN, WARRIOR), - line, - "warrior class ability {id} keeps its own tab {line}" - ); - } - - // A human racial (Perception, line 754 "Racial - Human") collapses to General (0). - assert_eq!(cat.spell_to_line(20600), Some(754)); - assert_eq!( - cat.spell_tab(20600, HUMAN, WARRIOR), - 0, - "a human racial routes to General" - ); - - // Fireball (Fire line 8): class-dependent. A warrior has no Fire race/class row → General; - // a mage has one, flag clear → its own Fire tab. Same spell, different tab by class. - assert_eq!(cat.spell_to_line(133), Some(8)); - assert_eq!( - cat.spell_tab(133, HUMAN, WARRIOR), - 0, - "a warrior's cross-class Fireball collapses to General" - ); - assert_eq!( - cat.spell_tab(133, HUMAN, MAGE), - 8, - "a mage's Fireball keeps its Fire tab" - ); - - // Unknown character (race/class 0): the collapse is skipped — the raw line stands. - assert_eq!(cat.spell_tab(133, 0, 0), 8); - } - - /// The `SlaInfo` columns on the real build-5875 `SkillLineAbility.dbc` (0437) — expected - /// values read straight off the raw file's rows this session (a struct-unpack dump, module - /// doc): recipes carry the trivial ranks, the profession openers carry zeros. A column slip - /// fails loudly. Skips without client data. - #[test] - fn real_skill_line_ability_reads_requirement_and_trivial_ranks() { - let data = crate::wow_data_or_skip!(); - let mut chain = crate::open_chain(&data).expect("open chain"); - let cat = load_skill_line_catalog(&mut chain).expect("load skill lines"); - - // Recipes: (spell, line, req, trivial_low, trivial_high) — raw rows, module doc. - // 2963 Bolt of Linen Cloth (Tailoring 197), 2330 Minor Healing Potion (Alchemy 171), - // 2538 Charred Wolf Meat (Cooking 185), 3920 Crafted Light Shot (Engineering 202). - for (spell, line, req, low, high) in [ - (2963u32, 197u32, 1u32, 25u32, 50u32), - (2330, 171, 1, 55, 95), - (2538, 185, 1, 45, 85), - (3920, 202, 1, 30, 60), - ] { - let sla = cat.ability(spell).expect("recipe has an SLA row"); - assert_eq!( - ( - sla.skill_id, - sla.req_skill_value, - sla.trivial_low, - sla.trivial_high - ), - (line, req, low, high), - "spell {spell}" - ); - } - - // The openers (effect-47 window spells) sit on their line with zero trivial ranks: - // 3908 Tailoring → 197, 7411 Enchanting → 333. - for (spell, line) in [(3908u32, 197u32), (7411, 333)] { - let sla = cat.ability(spell).expect("opener has an SLA row"); - assert_eq!( - (sla.skill_id, sla.trivial_low, sla.trivial_high), - (line, 0, 0) - ); - } - } - - /// `SkillLineCategory.dbc` + the line→category join on the real build-5875 files (0437 phase - /// 4) — expected values read straight off the raw dump (module doc). Skips without client - /// data. - #[test] - fn real_skill_categories_name_and_order_the_pane_groups() { - let data = crate::wow_data_or_skip!(); - let mut chain = crate::open_chain(&data).expect("open chain"); - let cat = load_skill_line_catalog(&mut chain).expect("load skill lines"); - - // The raw rows: (id, name, displayOrder). - assert_eq!(cat.category(7), Some(("Class Skills", 2))); - assert_eq!(cat.category(11), Some(("Professions", 3))); - assert_eq!(cat.category(9), Some(("Secondary Skills", 4))); - assert_eq!(cat.category(6), Some(("Weapon Skills", 5))); - assert_eq!(cat.category(10), Some(("Languages", 7))); - // Category 12 is a real row like any other — NOT a hide bucket: the client's list build - // drops `GENERIC (DND)` by its `flags & 0x2`, never by its category (decision 1091). - assert_eq!(cat.category(12), Some(("Not Displayed", 8))); - assert_eq!(cat.category(0), None); - - // The join: Tailoring (197) is a Profession; First Aid (129) is Secondary; the Fire - // school (8) is a Class Skill; Common (98) is a Language. - for (line, category) in [(197u32, 11u32), (129, 9), (8, 7), (98, 10)] { - assert_eq!( - cat.line(line).map(|l| l.category_id), - Some(category), - "line {line}" - ); - } - } - - /// `SkillRaceClassInfo.flags & 0x20` on the real build-5875 file: professions and secondary - /// skills are abandonable, class/weapon/language lines are not — the unlearn button's real - /// data split (a human warrior, race 1 / class 1). Skips without client data. - #[test] - fn real_abandonable_split_professions_yes_weapons_no() { - let data = crate::wow_data_or_skip!(); - let mut chain = crate::open_chain(&data).expect("open chain"); - let cat = load_skill_line_catalog(&mut chain).expect("load skill lines"); - - // PRIMARY professions carry 0xA0 (0x80 sort | 0x20 unlearnable) — the red circle-slash: - // Blacksmithing, Tailoring, Engineering, Alchemy, Enchanting, Leatherworking, Skinning. - for line in [164u32, 197, 202, 171, 333, 165, 393] { - assert!(cat.abandonable(line, 1, 1), "line {line} is abandonable"); - } - // SECONDARY skills (First Aid/Fishing/Cooking) are 0x80 only — famously NOT droppable in - // 1.12 — and class school (Fire: no human-warrior row at all) / weapon / Defense / - // language / riding lines aren't either. - for line in [129u32, 356, 185, 8, 43, 95, 98, 762] { - assert!(!cat.abandonable(line, 1, 1), "line {line} is not"); - } - // Unknown race/class → no button (the conservative arm). - assert!(!cat.abandonable(164, 0, 0)); - } - - /// `SkillRaceClassInfo.flags & 0x400` on the real build-5875 file (a night-elf hunter, race 4 - /// / class 3): the class talent lines, Dual Wield, the racial and the per-mount riding lines - /// are MONO — the client reports their `skillMaxRank` as 1 and the pane draws them as gray - /// proficiencies — while every weapon line, armor proficiency, language, `Riding` and - /// profession keeps its real rank. Skips without client data. - #[test] - fn real_mono_value_split_class_lines_yes_weapons_no() { - let data = crate::wow_data_or_skip!(); - let mut chain = crate::open_chain(&data).expect("open chain"); - let cat = load_skill_line_catalog(&mut chain).expect("load skill lines"); - - // Beast Mastery / Survival / Marksmanship (0x410), Dual Wield / Night Elf Racial / the - // Tiger Riding mount line / GENERIC (DND) (0x492). - for line in [50u32, 51, 163, 118, 126, 150, 183] { - assert!(cat.mono_value(line, 4, 3), "line {line} is single-rank"); - } - // Weapon lines (Axes/Bows/Daggers/Crossbows/Unarmed/Defense), armor proficiencies - // (Cloth/Leather/Mail), languages (Common/Darnassian), Riding, First Aid. - for line in [ - 44u32, 45, 173, 226, 162, 95, 415, 414, 413, 98, 113, 762, 129, - ] { - assert!(!cat.mono_value(line, 4, 3), "line {line} keeps its rank"); - } - // Unknown race/class → keep the server's numbers (the conservative arm). - assert!(!cat.mono_value(50, 0, 0)); - } - - /// `SkillRaceClassInfo.flags & 0x2` on the real build-5875 file — the bit that keeps a line - /// off the Skills tab entirely (decision 1091; wow-re `0x4d2cb0`'s `4d2d9f test dl,0x2`), plus - /// the `reqLevel` column the untrained gate reads. A night-elf hunter, race 4 / class 3. - /// Skips without client data. - #[test] - fn real_hidden_lines_are_the_ones_the_reference_client_never_lists() { - let data = crate::wow_data_or_skip!(); - let mut chain = crate::open_chain(&data).expect("open chain"); - let cat = load_skill_line_catalog(&mut chain).expect("load skill lines"); - let row = |line: u32| cat.race_class(line, 4, 3).expect("an admitting row"); - - // Dual Wield, Night Elf Racial, Tiger Riding, GENERIC (DND) — all 0x492, all absent from - // the real client's pane however high the server ranks them. - for line in [118u32, 126, 150, 183] { - assert!(row(line).hidden(), "line {line} never gets a row"); - } - // Everything the ref does list: the class lines, weapons, armor, languages, Riding. - for line in [50u32, 51, 163, 44, 95, 162, 413, 414, 415, 98, 762, 129] { - assert!(!row(line).hidden(), "line {line} is listed"); - } - - // reqLevel (column 5) reads for real: Mail 40, Dual Wield 20. - assert_eq!(row(413).min_level, 40, "Mail"); - assert_eq!(row(118).min_level, 20, "Dual Wield"); - // The untrained gate is all but inert on this build's data, and the test says so: NO row - // in the whole file carries 0x1, and exactly ONE carries 0x4 (line 493, reqLevel 0 — a - // line `SkillLine.dbc` doesn't even have). So a rank-0 line is simply never listed; a - // `reqLevel` on its own does nothing without 0x4. - assert!( - !row(413).displays_untrained(60), - "Mail at rank 0 stays off the pane at any level — 0x80 carries neither gate bit" - ); - assert!( - row(493).displays_untrained(0), - "the single 0x4 row shows from level 0 (its reqLevel is 0)" - ); - // No admitting row at all → the caller drops the line (the client's own `!srci → continue`). - assert_eq!(cat.race_class(118, 0, 0), None); - } - - /// The `forward_spellid` rank graph on the real build-5875 file — the action bar's - /// rank-normalization source (decision 0883). Skips without client data. - #[test] - fn real_rank_chains_resolve_the_highest_known_rank() { - let data = crate::wow_data_or_skip!(); - let mut chain = crate::open_chain(&data).expect("open chain"); - let cat = load_skill_line_catalog(&mut chain).expect("load skill lines"); - - // Sinister Strike ranks 1..8 — the chain the bug report walked (a level-60 rogue whose - // saved bar still held rank 1 while the book held only rank 8). - const SS: [u32; 8] = [1752, 1757, 1758, 1759, 1760, 8621, 11293, 11294]; - for pair in SS.windows(2) { - assert_eq!( - cat.rank_successor(pair[0]), - Some(pair[1]), - "Sinister Strike {} → {}", - pair[0], - pair[1] - ); - } - assert_eq!(cat.rank_successor(11294), None, "rank 8 tops the chain"); - // Warrior Charge (100 → 6178 → 11578) and Heroic Strike's 9 ranks — the two physical - // families the director named. - assert_eq!(cat.rank_successor(100), Some(6178)); - assert_eq!(cat.rank_successor(6178), Some(11578)); - assert_eq!(cat.rank_successor(11567), Some(25286)); - - // From ANY rank, knowing only rank 8, the answer is rank 8 — the walk starts at the - // chain head, so it works downward as well as upward. - let top: std::collections::HashSet = [11294].into_iter().collect(); - for id in SS { - assert_eq!(cat.highest_known_rank(id, &top), Some(11294), "from {id}"); - } - // Knowing an intermediate rank resolves to it, not to the top of the chain. - let mid: std::collections::HashSet = [1759].into_iter().collect(); - assert_eq!(cat.highest_known_rank(1752, &mid), Some(1759)); - assert_eq!(cat.highest_known_rank(11294, &mid), Some(1759)); - // No rank of the chain known → no answer (the caller leaves the slot alone). - assert_eq!(cat.highest_known_rank(1752, &Default::default()), None); - - // Caster nukes carry NO forward link — every rank stays known and castable, which is what - // makes vanilla down-ranking work, and what keeps normalization off them. Fireball r1/r2, - // Frostbolt r1, Healing Touch r1, Renew r1, Immolate r1, Lightning Bolt r1. - for id in [133u32, 143, 116, 5185, 139, 348, 403] { - assert_eq!(cat.rank_successor(id), None, "spell {id} is not chained"); - let known: std::collections::HashSet = [id].into_iter().collect(); - assert_eq!(cat.highest_known_rank(id, &known), Some(id)); - } - // A spell with no `SkillLineAbility` row at all (the auto-attack) is its own chain. - let attack: std::collections::HashSet = [6603].into_iter().collect(); - assert_eq!(cat.rank_successor(6603), None); - assert_eq!(cat.highest_known_rank(6603, &attack), Some(6603)); - } -} +mod tests; diff --git a/crates/benilla-formats/src/skill_lines/tests.rs b/crates/benilla-formats/src/skill_lines/tests.rs new file mode 100644 index 00000000..97bb415f --- /dev/null +++ b/crates/benilla-formats/src/skill_lines/tests.rs @@ -0,0 +1,387 @@ +//! The catalog's tests — the real-data pins (skip without client data) and the +//! synthetic rank-chain cases. Split from `mod.rs` for size only (one concern, two files). + +use super::*; + +/// Resolve real spells to their real skill lines on the build-5875 data, cross-checked +/// against vmangos `SharedDefines.h`'s `SkillType` enum (Frost=6, Fire=8 — the module doc's +/// own citation). Skips without client data. +#[test] +fn real_skill_line_catalog_resolves_known_spells() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_skill_line_catalog(&mut chain).expect("load SkillLine/SkillLineAbility"); + assert!( + cat.len() > 50, + "a real skill-line table has hundreds of rows" + ); + + // Fireball (133) -> the Fire line (SKILL_FIRE = 8). + let fire_line = cat.spell_to_line(133).expect("Fireball has a skill line"); + assert_eq!(fire_line, 8); + let fire = cat.line(fire_line).expect("the Fire line resolves"); + assert_eq!(fire.name, "Fire"); + assert!(fire.icon.is_some(), "the Fire tab has an icon"); + + // Frost Armor (168) -> the Frost line (SKILL_FROST = 6). + let frost_line = cat + .spell_to_line(168) + .expect("Frost Armor has a skill line"); + assert_eq!(frost_line, 6); + let frost = cat.line(frost_line).expect("the Frost line resolves"); + assert_eq!(frost.name, "Frost"); + assert!(frost.icon.is_some(), "the Frost tab has an icon"); + + // An unknown spell id has no line. + assert_eq!(cat.spell_to_line(0), None); + + // The description column (12): a profession line carries the flavor sentence, a weapon + // line is blank — a column slip lands on another locale (empty for enUS data) or the + // name flags word (a parse error) and fails loudly. + let smithing = cat.line(164).expect("Blacksmithing resolves"); + assert!( + smithing.description.to_lowercase().contains("blacksmith"), + "Blacksmithing's description names the trade: {:?}", + smithing.description + ); + let swords = cat.line(43).expect("Swords resolves"); + assert_eq!( + swords.description, "Higher weapon skill increases your chance to hit.", + "the weapon lines' shared byte-exact sentence" + ); +} + +/// The General collapse on real build-5875 `SkillRaceClassInfo.dbc` (decision 0228), traced on +/// concrete spells for a human warrior (race 1, class 1) vs. a human mage (race 1, class 8): +/// class-native combat lines keep their own tab, racials collapse to General, and a cross-class +/// spell (a warrior's cheated Fireball) collapses to General while the SAME spell keeps its Fire +/// tab for a mage — the class/race dependence, the whole point of the routing. Skips without +/// client data. +#[test] +fn real_spell_tab_collapses_general_by_race_and_class() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_skill_line_catalog(&mut chain).expect("load skill lines"); + const HUMAN: u8 = 1; + const WARRIOR: u8 = 1; + const MAGE: u8 = 8; + + // Warrior class abilities keep their own class-line tabs (flag clear for a warrior): + // Charge/Heroic Strike/Rend on Arms (26), Battle Shout on Fury (256). + for (id, line) in [(100u32, 26u32), (78, 26), (772, 26), (6673, 256)] { + assert_eq!(cat.spell_to_line(id), Some(line)); + assert_eq!( + cat.spell_tab(id, HUMAN, WARRIOR), + line, + "warrior class ability {id} keeps its own tab {line}" + ); + } + + // A human racial (Perception, line 754 "Racial - Human") collapses to General (0). + assert_eq!(cat.spell_to_line(20600), Some(754)); + assert_eq!( + cat.spell_tab(20600, HUMAN, WARRIOR), + 0, + "a human racial routes to General" + ); + + // Fireball (Fire line 8): class-dependent. A warrior has no Fire race/class row → General; + // a mage has one, flag clear → its own Fire tab. Same spell, different tab by class. + assert_eq!(cat.spell_to_line(133), Some(8)); + assert_eq!( + cat.spell_tab(133, HUMAN, WARRIOR), + 0, + "a warrior's cross-class Fireball collapses to General" + ); + assert_eq!( + cat.spell_tab(133, HUMAN, MAGE), + 8, + "a mage's Fireball keeps its Fire tab" + ); + + // Unknown character (race/class 0): the collapse is skipped — the raw line stands. + assert_eq!(cat.spell_tab(133, 0, 0), 8); +} + +/// The skill-up message gate on the real build-5875 `SkillRaceClassInfo.dbc` (decision 1309, +/// bugs B19/B245): `flags & 0x402` silences exactly the lines the 1.11/1.12 archives show +/// silent. Expected flags read straight off the raw file this session (a struct-unpack +/// dump): the B245 shot's announced-but-shouldn't-be lines for a night-elf hunter, the +/// announce controls, and the class dependence. Skips without client data. +#[test] +fn real_skill_up_announce_gate_matches_the_archives() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_skill_line_catalog(&mut chain).expect("load skill lines"); + const HUMAN: u8 = 1; + const NIGHT_ELF: u8 = 4; + const WARRIOR: u8 = 1; + const HUNTER: u8 = 3; + + // The B245 shot, verbatim — GENERIC (DND), Survival, Night Elf Racial and Marksmanship + // all announced on one ding — plus the hunter's other 0x402-flagged rows (raw flags + // 0x492 hidden / 0x410 mono). + for (id, name) in [ + (183u32, "GENERIC (DND)"), + (51, "Survival"), + (126, "Night Elf Racial"), + (163, "Marksmanship"), + (50, "Beast Mastery"), + (118, "Dual Wield"), + ] { + assert!( + !cat.announces_skill_ups(id, NIGHT_ELF, HUNTER), + "{name} ({id}) must be silent" + ); + } + // The lines skill-ups exist FOR (raw flags 0x080/0x0a0): weapons, Defense, secondary. + for (id, name) in [ + (43u32, "Swords"), + (95, "Defense"), + (185, "Cooking"), + (129, "First Aid"), + (356, "Fishing"), + ] { + assert!( + cat.announces_skill_ups(id, NIGHT_ELF, HUNTER), + "{name} ({id}) must announce" + ); + } + // The class dependence: a warrior's own spec line is silent too (Arms 26, raw 0x410) — + // B19's "magic skill-ups" generalized to every class's spec lines. + assert!( + !cat.announces_skill_ups(26, HUMAN, WARRIOR), + "Arms must be silent" + ); + // Fist Weapons (473, raw 0x082): the one silent WEAPON line — the 0x2 half of the mask + // alone, corroborated by vmangos giving exactly this line SKILL_RANGE_MONO. + assert!( + !cat.announces_skill_ups(473, HUMAN, WARRIOR), + "Fist Weapons must be silent" + ); + // No admitting row (a mage-only line for a warrior): the real watcher's empty resolve takes + // the same skip branch as the flag test (`0x5de352` — decision 1314). + assert!( + !cat.announces_skill_ups(6, HUMAN, WARRIOR), + "a row-less line is silent" + ); +} + +/// The `SlaInfo` columns on the real build-5875 `SkillLineAbility.dbc` (0437) — expected +/// values read straight off the raw file's rows this session (a struct-unpack dump, module +/// doc): recipes carry the trivial ranks, the profession openers carry zeros. A column slip +/// fails loudly. Skips without client data. +#[test] +fn real_skill_line_ability_reads_requirement_and_trivial_ranks() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_skill_line_catalog(&mut chain).expect("load skill lines"); + + // Recipes: (spell, line, req, trivial_low, trivial_high) — raw rows, module doc. + // 2963 Bolt of Linen Cloth (Tailoring 197), 2330 Minor Healing Potion (Alchemy 171), + // 2538 Charred Wolf Meat (Cooking 185), 3920 Crafted Light Shot (Engineering 202). + for (spell, line, req, low, high) in [ + (2963u32, 197u32, 1u32, 25u32, 50u32), + (2330, 171, 1, 55, 95), + (2538, 185, 1, 45, 85), + (3920, 202, 1, 30, 60), + ] { + let sla = cat.ability(spell).expect("recipe has an SLA row"); + assert_eq!( + ( + sla.skill_id, + sla.req_skill_value, + sla.trivial_low, + sla.trivial_high + ), + (line, req, low, high), + "spell {spell}" + ); + } + + // The openers (effect-47 window spells) sit on their line with zero trivial ranks: + // 3908 Tailoring → 197, 7411 Enchanting → 333. + for (spell, line) in [(3908u32, 197u32), (7411, 333)] { + let sla = cat.ability(spell).expect("opener has an SLA row"); + assert_eq!( + (sla.skill_id, sla.trivial_low, sla.trivial_high), + (line, 0, 0) + ); + } +} + +/// `SkillLineCategory.dbc` + the line→category join on the real build-5875 files (0437 phase +/// 4) — expected values read straight off the raw dump (module doc). Skips without client +/// data. +#[test] +fn real_skill_categories_name_and_order_the_pane_groups() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_skill_line_catalog(&mut chain).expect("load skill lines"); + + // The raw rows: (id, name, displayOrder). + assert_eq!(cat.category(7), Some(("Class Skills", 2))); + assert_eq!(cat.category(11), Some(("Professions", 3))); + assert_eq!(cat.category(9), Some(("Secondary Skills", 4))); + assert_eq!(cat.category(6), Some(("Weapon Skills", 5))); + assert_eq!(cat.category(10), Some(("Languages", 7))); + // Category 12 is a real row like any other — NOT a hide bucket: the client's list build + // drops `GENERIC (DND)` by its `flags & 0x2`, never by its category (decision 1091). + assert_eq!(cat.category(12), Some(("Not Displayed", 8))); + assert_eq!(cat.category(0), None); + + // The join: Tailoring (197) is a Profession; First Aid (129) is Secondary; the Fire + // school (8) is a Class Skill; Common (98) is a Language. + for (line, category) in [(197u32, 11u32), (129, 9), (8, 7), (98, 10)] { + assert_eq!( + cat.line(line).map(|l| l.category_id), + Some(category), + "line {line}" + ); + } +} + +/// `SkillRaceClassInfo.flags & 0x20` on the real build-5875 file: professions and secondary +/// skills are abandonable, class/weapon/language lines are not — the unlearn button's real +/// data split (a human warrior, race 1 / class 1). Skips without client data. +#[test] +fn real_abandonable_split_professions_yes_weapons_no() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_skill_line_catalog(&mut chain).expect("load skill lines"); + + // PRIMARY professions carry 0xA0 (0x80 sort | 0x20 unlearnable) — the red circle-slash: + // Blacksmithing, Tailoring, Engineering, Alchemy, Enchanting, Leatherworking, Skinning. + for line in [164u32, 197, 202, 171, 333, 165, 393] { + assert!(cat.abandonable(line, 1, 1), "line {line} is abandonable"); + } + // SECONDARY skills (First Aid/Fishing/Cooking) are 0x80 only — famously NOT droppable in + // 1.12 — and class school (Fire: no human-warrior row at all) / weapon / Defense / + // language / riding lines aren't either. + for line in [129u32, 356, 185, 8, 43, 95, 98, 762] { + assert!(!cat.abandonable(line, 1, 1), "line {line} is not"); + } + // Unknown race/class → no button (the conservative arm). + assert!(!cat.abandonable(164, 0, 0)); +} + +/// `SkillRaceClassInfo.flags & 0x400` on the real build-5875 file (a night-elf hunter, race 4 +/// / class 3): the class talent lines, Dual Wield, the racial and the per-mount riding lines +/// are MONO — the client reports their `skillMaxRank` as 1 and the pane draws them as gray +/// proficiencies — while every weapon line, armor proficiency, language, `Riding` and +/// profession keeps its real rank. Skips without client data. +#[test] +fn real_mono_value_split_class_lines_yes_weapons_no() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_skill_line_catalog(&mut chain).expect("load skill lines"); + + // Beast Mastery / Survival / Marksmanship (0x410), Dual Wield / Night Elf Racial / the + // Tiger Riding mount line / GENERIC (DND) (0x492). + for line in [50u32, 51, 163, 118, 126, 150, 183] { + assert!(cat.mono_value(line, 4, 3), "line {line} is single-rank"); + } + // Weapon lines (Axes/Bows/Daggers/Crossbows/Unarmed/Defense), armor proficiencies + // (Cloth/Leather/Mail), languages (Common/Darnassian), Riding, First Aid. + for line in [ + 44u32, 45, 173, 226, 162, 95, 415, 414, 413, 98, 113, 762, 129, + ] { + assert!(!cat.mono_value(line, 4, 3), "line {line} keeps its rank"); + } + // Unknown race/class → keep the server's numbers (the conservative arm). + assert!(!cat.mono_value(50, 0, 0)); +} + +/// `SkillRaceClassInfo.flags & 0x2` on the real build-5875 file — the bit that keeps a line +/// off the Skills tab entirely (decision 1091; wow-re `0x4d2cb0`'s `4d2d9f test dl,0x2`), plus +/// the `reqLevel` column the untrained gate reads. A night-elf hunter, race 4 / class 3. +/// Skips without client data. +#[test] +fn real_hidden_lines_are_the_ones_the_reference_client_never_lists() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_skill_line_catalog(&mut chain).expect("load skill lines"); + let row = |line: u32| cat.race_class(line, 4, 3).expect("an admitting row"); + + // Dual Wield, Night Elf Racial, Tiger Riding, GENERIC (DND) — all 0x492, all absent from + // the real client's pane however high the server ranks them. + for line in [118u32, 126, 150, 183] { + assert!(row(line).hidden(), "line {line} never gets a row"); + } + // Everything the ref does list: the class lines, weapons, armor, languages, Riding. + for line in [50u32, 51, 163, 44, 95, 162, 413, 414, 415, 98, 762, 129] { + assert!(!row(line).hidden(), "line {line} is listed"); + } + + // reqLevel (column 5) reads for real: Mail 40, Dual Wield 20. + assert_eq!(row(413).min_level, 40, "Mail"); + assert_eq!(row(118).min_level, 20, "Dual Wield"); + // The untrained gate is all but inert on this build's data, and the test says so: NO row + // in the whole file carries 0x1, and exactly ONE carries 0x4 (line 493, reqLevel 0 — a + // line `SkillLine.dbc` doesn't even have). So a rank-0 line is simply never listed; a + // `reqLevel` on its own does nothing without 0x4. + assert!( + !row(413).displays_untrained(60), + "Mail at rank 0 stays off the pane at any level — 0x80 carries neither gate bit" + ); + assert!( + row(493).displays_untrained(0), + "the single 0x4 row shows from level 0 (its reqLevel is 0)" + ); + // No admitting row at all → the caller drops the line (the client's own `!srci → continue`). + assert_eq!(cat.race_class(118, 0, 0), None); +} + +/// The `forward_spellid` rank graph on the real build-5875 file — the action bar's +/// rank-normalization source (decision 0883). Skips without client data. +#[test] +fn real_rank_chains_resolve_the_highest_known_rank() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_skill_line_catalog(&mut chain).expect("load skill lines"); + + // Sinister Strike ranks 1..8 — the chain the bug report walked (a level-60 rogue whose + // saved bar still held rank 1 while the book held only rank 8). + const SS: [u32; 8] = [1752, 1757, 1758, 1759, 1760, 8621, 11293, 11294]; + for pair in SS.windows(2) { + assert_eq!( + cat.rank_successor(pair[0]), + Some(pair[1]), + "Sinister Strike {} → {}", + pair[0], + pair[1] + ); + } + assert_eq!(cat.rank_successor(11294), None, "rank 8 tops the chain"); + // Warrior Charge (100 → 6178 → 11578) and Heroic Strike's 9 ranks — the two physical + // families the director named. + assert_eq!(cat.rank_successor(100), Some(6178)); + assert_eq!(cat.rank_successor(6178), Some(11578)); + assert_eq!(cat.rank_successor(11567), Some(25286)); + + // From ANY rank, knowing only rank 8, the answer is rank 8 — the walk starts at the + // chain head, so it works downward as well as upward. + let top: std::collections::HashSet = [11294].into_iter().collect(); + for id in SS { + assert_eq!(cat.highest_known_rank(id, &top), Some(11294), "from {id}"); + } + // Knowing an intermediate rank resolves to it, not to the top of the chain. + let mid: std::collections::HashSet = [1759].into_iter().collect(); + assert_eq!(cat.highest_known_rank(1752, &mid), Some(1759)); + assert_eq!(cat.highest_known_rank(11294, &mid), Some(1759)); + // No rank of the chain known → no answer (the caller leaves the slot alone). + assert_eq!(cat.highest_known_rank(1752, &Default::default()), None); + + // Caster nukes carry NO forward link — every rank stays known and castable, which is what + // makes vanilla down-ranking work, and what keeps normalization off them. Fireball r1/r2, + // Frostbolt r1, Healing Touch r1, Renew r1, Immolate r1, Lightning Bolt r1. + for id in [133u32, 143, 116, 5185, 139, 348, 403] { + assert_eq!(cat.rank_successor(id), None, "spell {id} is not chained"); + let known: std::collections::HashSet = [id].into_iter().collect(); + assert_eq!(cat.highest_known_rank(id, &known), Some(id)); + } + // A spell with no `SkillLineAbility` row at all (the auto-attack) is its own chain. + let attack: std::collections::HashSet = [6603].into_iter().collect(); + assert_eq!(cat.rank_successor(6603), None); + assert_eq!(cat.highest_known_rank(6603, &attack), Some(6603)); +} diff --git a/crates/benilla-ui/src/loader/regions.rs b/crates/benilla-ui/src/loader/regions.rs index 6930d4ad..8d3c305f 100644 --- a/crates/benilla-ui/src/loader/regions.rs +++ b/crates/benilla-ui/src/loader/regions.rs @@ -70,6 +70,17 @@ impl Loader<'_> { self.apply_fontstring_font(region, ®ion_wrapper, dbg); } self.apply_region_visual(region, ®ion_wrapper, is_texture, dbg); + // The creation-path implicit anchor (decision 1310): AFTER the authored + // ``/``/justify are on the region, exactly where the real + // engine runs it (`0x7701c0`/`0x771480`, immediately after the region's + // LoadXML returns). A region the XML anchored is untouched. + if let Err(e) = + crate::script::implicit_creation_anchor_lua(self.lua, ®ion_wrapper) + { + self.report + .errors + .push(format!("{dbg}: implicit anchor: {e}")); + } } } } @@ -109,6 +120,15 @@ impl Loader<'_> { self.apply_region_layout(region, ®ion_wrapper, parent_name, dbg); self.apply_fontstring_font(region, ®ion_wrapper, dbg); self.apply_region_visual(region, ®ion_wrapper, false, dbg); + // The creation-path implicit anchor (decision 1310), as in `apply_layers`. For the + // EditBox adopt below it is moot — `write_inset_anchors` replaces the anchor set + // wholesale — but a plain special FontString (a chat window's) gets the same + // justify-point the real engine gives it. + if let Err(e) = crate::script::implicit_creation_anchor_lua(self.lua, ®ion_wrapper) { + self.report + .errors + .push(format!("{dbg}: implicit anchor: {e}")); + } // The engine's LoadXML slot assignment: an EditBox's direct-child `` IS // its text region — typed text renders in its font, the box's insets anchor it. // Assigned, never searched (a find-first adoption once grabbed the chat header out of diff --git a/crates/benilla-ui/src/loader/tests.rs b/crates/benilla-ui/src/loader/tests.rs index 5ee54812..25fca7e2 100644 --- a/crates/benilla-ui/src/loader/tests.rs +++ b/crates/benilla-ui/src/loader/tests.rs @@ -583,6 +583,132 @@ mod loader_tests { ); } + /// The creation-path implicit anchor, XML half (decision 1310; wow-re + /// `region-implicit-anchor.md`, §5 VERIFIED): a `` with zero anchors gets + /// SetAllPoints(parent) right after its LoadXML — two corner anchors that pin all four + /// edges, so an authored `` is structurally unread. This is B180's engine shape: the + /// reference stack-split plate authors a vestigial 256×32 and renders 172×96, the frame. + #[test] + fn sized_anchorless_layer_texture_fills_its_frame() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(800.0, 600.0); + let doc = parse( + r#" + + + + + + + + + + + + "#, + ); + let report = load(&s, &doc, &no_files); + assert!(report.errors.is_empty(), "errors: {:?}", report.errors); + s.resolve(); + let plate = s + .extract() + .into_iter() + .find(|q| { + matches!(&q.content, + QuadContent::Texture { path: Some(p), .. } if p == "Interface\\Panel") + }) + .expect("the plate draws"); + let r = plate.rect.expect("the plate resolved"); + assert_eq!( + (r.left, r.right, r.bottom, r.top), + (10.0, 182.0, 10.0, 106.0), + "implicit SetAllPoints fills the 172×96 frame; the 256×32 size is unread" + ); + } + + /// The FontString half of the same law: zero anchors ⇒ ONE middle-row SetPoint chosen by the + /// live justify word (`&7`: 1→LEFT, 4→RIGHT, else CENTER) — and the authored `` stays + /// LIVE (single anchor + W/H sizes the opposite edges), unlike the texture's. + #[test] + fn anchorless_fontstring_seats_at_its_justify_point() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(800.0, 600.0); + let doc = parse( + r#" + + + + + + + + + + + + "#, + ); + let report = load(&s, &doc, &no_files); + assert!(report.errors.is_empty(), "errors: {:?}", report.errors); + s.resolve(); + // LEFT→LEFT at (0,0): left edge at the page's left, vertically on the page's centerline + // (middle-row point), 80×20 from the size. Page [0,0]..[200,100] → [0,40]..[80,60]. + let (l, r, t, b) = s + .eval::<(f64, f64, f64, f64)>( + "return SeatLeft:GetLeft(), SeatLeft:GetRight(), SeatLeft:GetTop(), SeatLeft:GetBottom()", + ) + .unwrap(); + assert_eq!( + (l, r, b, t), + (0.0, 80.0, 40.0, 60.0), + "justifyH=LEFT seats the implicit single anchor at the page's LEFT, size live" + ); + } + + /// The XML state-texture ordering (decision 1310's loader half): an authored `` on a + /// `` wins outright — the materializing setter's implicit SetAllPoints is + /// cleared before the authored layout applies, so no implicit corner survives to weld the + /// region to the button (the merchant row's icon-scoped highlight is the live consumer). + #[test] + fn anchored_state_texture_keeps_its_authored_anchors() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(800.0, 600.0); + let doc = parse( + r#" + + "#, + ); + let report = load(&s, &doc, &no_files); + assert!(report.errors.is_empty(), "errors: {:?}", report.errors); + s.resolve(); + let scoped = s + .extract() + .into_iter() + .find(|q| { + matches!(&q.content, + QuadContent::Texture { path: Some(p), .. } if p == "Interface\\Scoped") + }) + .expect("the state texture draws"); + let r = scoped.rect.expect("resolved"); + // Button [100,100]..[136,136]; TOPLEFT+2,-2 with 24×24 → [102,110]..[126,134]. + assert_eq!( + (r.left, r.right, r.bottom, r.top), + (102.0, 126.0, 110.0, 134.0), + "authored anchors + size hold; no implicit corner welds it to the button" + ); + } + #[test] fn button_xml_extras_apply() { let mut s = UiScript::new().unwrap(); diff --git a/crates/benilla-ui/src/loader/widgets.rs b/crates/benilla-ui/src/loader/widgets.rs index d0847f79..84ce32ec 100644 --- a/crates/benilla-ui/src/loader/widgets.rs +++ b/crates/benilla-ui/src/loader/widgets.rs @@ -110,7 +110,20 @@ impl Loader<'_> { if let Some(mode) = t.attr("alphaMode") { this.call_region(®ion, "SetBlendMode", mode.to_string(), dbg); } + // The setter above MATERIALIZED the region with the runtime path's implicit + // SetAllPoints (decision 1310) — but this is the XML path, where the real + // engine routes state textures through the region adder (`0x778903`…: authored + // `` first, the implicit step after). Reproduce that order: clear, + // apply the authored layout, then re-run the conditional step — a same-point + // SetPoint over a live implicit corner would otherwise leave the OTHER corner + // standing (the slot law) and weld an anchored state texture to the button. + this.call_region(®ion, "ClearAllPoints", (), dbg); this.apply_region_layout(t, ®ion, self_name, dbg); + if let Err(e) = crate::script::implicit_creation_anchor_lua(this.lua, ®ion) { + this.report + .errors + .push(format!("{dbg}: implicit anchor: {e}")); + } if let Some(tc) = tex_coords_of(t) { this.call_region(®ion, "SetTexCoord", tc, dbg); } @@ -155,7 +168,18 @@ impl Loader<'_> { }; self.call(wrapper, "SetText", label, dbg); if let Ok(region) = wrapper.call_method::("GetFontString", ()) { + // Same clear→layout→implicit order as the state textures above (decision 1310): + // SetText materialized the label with the runtime path's implicit CENTER anchor; + // the XML path re-derives it AFTER the element's own ``/justify apply + // (so a `` with no anchors seats LEFT, as the real + // FontString post-step `0x771480` would). + self.call_region(®ion, "ClearAllPoints", (), dbg); self.apply_region_layout(bt, ®ion, self_name, dbg); + if let Err(e) = crate::script::implicit_creation_anchor_lua(self.lua, ®ion) { + self.report + .errors + .push(format!("{dbg}: implicit anchor: {e}")); + } // Publish the label under its resolved name (``) — // the ref kit addresses tab/button labels by exactly this global // (PanelTemplates_TabResize's `getglobal(tabName.."Text")`). diff --git a/crates/benilla-ui/src/script/button.rs b/crates/benilla-ui/src/script/button.rs index 42e91a6d..9403fdf6 100644 --- a/crates/benilla-ui/src/script/button.rs +++ b/crates/benilla-ui/src/script/button.rs @@ -173,6 +173,14 @@ fn ensure_slot(lua: &Lua, this: &Table, slot: Slot) -> mlua::Result { }, ); model.touch_layout(); // a region entered the layout gate's read set (decision 0740) + // A freshly built slot region gets the creation-path implicit anchor (decision 1310): + // the reference's C++ string setters SetAllPoints a fresh state texture outright + // (`0x778f9d`/`0x7790db` — fresh means zero anchors, so the conditional form is + // equivalent), and ButtonText creation runs the FontString post-step (`0x778b96` → + // `0x771480`), which seats a fresh label CENTER. The XML loader re-derives after + // applying authored `` (see `loader/widgets.rs`); an EXISTING slot region is + // never touched here — the get half of get-or-create changes no geometry. + super::region::implicit_creation_anchor(&mut model, rh); if let Some(frame) = model.arena.frame_mut(h) { if let KindState::Button(bs) = &mut frame.kind_state { slot.set(bs, rh); diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs index 286ce63f..3cb83ba2 100644 --- a/crates/benilla-ui/src/script/extract.rs +++ b/crates/benilla-ui/src/script/extract.rs @@ -287,11 +287,16 @@ impl UiScript { if let Some(c) = state_color { data.vertex_color = Some(c); } - // Region-rect precedence (decision 0068 v1): anchored regions resolved in - // [`resolve`] → their owner-relative rect; else an explicitly-sized region draws - // *centered* on its owner (the state-texture overhang); else it fills the owner - // (`rect` already = owner). The bar-fill region keeps its fraction geometry. - // The fill CROPS its texture (wow-re `nameplate-vkey.md`, VERIFIED): every + // A region draws at its RESOLVED rect, and nothing else (decision 1310, + // superseding 0068 v1's centered/fill-the-owner fallbacks): every drawable + // region carries real anchors — authored, or the creation-path implicit anchor + // (`region::implicit_creation_anchor`) — and the real resolver has no + // zero-anchor fallback (a failed resolve latches unresolvable, `0x768d55`). A + // region with no rect here is a templateless Lua region nobody anchored, and + // the reference draws it nowhere. The bar-fill/thumb regions keep their own + // fraction geometry (computed off the owner rect above) — they never carry + // anchors and skip the resolver entirely, like the reference's own bar path. + // The bar-fill CROPS its texture (wow-re `nameplate-vkey.md`, VERIFIED): every // `SetValue` rewrites the region's 4-corner UV block with `u1 = fraction` and // recomputes the quad as `right = left + frac·width`. So the art is sliced, never // squeezed — a bar texture with a horizontal ramp (`UI-StatusBar` brightens 124→166 @@ -300,17 +305,7 @@ impl UiScript { data.tex_coords = Some(bar_fill_uv(data.tex_coords, sb)); } if bar_fill.is_none() && !thumb_fill { - if let Some(rr) = model.region_resolved.get(&rh) { - rect = Some(*rr); - } else if let (Some((w, h)), Some(r)) = (data.size, rect) { - let (cx, cy) = ((r.left + r.right) * 0.5, (r.bottom + r.top) * 0.5); - rect = Some(Rect::new( - cy - h * 0.5, - cx - w * 0.5, - cy + h * 0.5, - cx + w * 0.5, - )); - } + rect = model.region_resolved.get(&rh).copied(); } let is_text = matches!( region.map(|r| r.kind), diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 1f0c0d5e..f27e6e5e 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -157,7 +157,7 @@ pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo}; pub use pet::{PetActionView, PetStats}; pub use quest::{QuestAction, QuestItemView, QuestPanel, QuestSelect, QuestState}; pub use quest_log::{QuestLogDetail, QuestLogEntryView, QuestLogObjectiveView, QuestLogState}; -pub(crate) use region::apply_font_parts; +pub(crate) use region::{apply_font_parts, implicit_creation_anchor_lua}; pub use reputation::{FactionEntry, ReputationSend, ReputationState}; pub use session::SessionRequest; pub use shapeshift::ShapeshiftFormView; diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index e4151109..ba129791 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -532,6 +532,71 @@ pub(super) fn region_owner_id(model: &mut Model, rh: RegionHandle) -> u32 { } } +/// The client's **creation-path implicit anchor** (wow-re `system/ui/scratch/` +/// `region-implicit-anchor.md`, §5 VERIFIED; decision 1310): a per-region-type post-step the real +/// engine runs immediately after a region's LoadXML returns (`0x7701c0` texture / `0x771480` +/// fontstring — the same two fire from the Button state-texture and ButtonText paths, and from Lua +/// `CreateTexture`/`CreateFontString` only on a template-registry hit). Condition: the region has a +/// parent AND every one of its nine anchor slots is empty — any anchor from any source suppresses +/// it. Then: +/// +/// - a **Texture** gets `SetAllPoints(parent)` — two corner anchors, TOPLEFT→TOPLEFT and +/// BOTTOMRIGHT→BOTTOMRIGHT at (0,0). Two opposing corners pin all four edges, so an authored +/// `` is **structurally unread** (the resolver law) — which is why the reference's +/// stack-split plate authors a vestigial 256×32 and renders 172×96 (B180). +/// - a **FontString** gets ONE middle-row `SetPoint` chosen by its live justify word +/// (`[this+0x120] & 7`: 1 → LEFT→LEFT, 4 → RIGHT→RIGHT, else CENTER→CENTER, offsets (0,0)) — +/// and its `` stays live (single anchor + W/H sizes the opposite edges). +/// [`RegionData::justify`] *is* that word — `SetJustifyH` writes it and a font-object link +/// merges into it behind the explicit mask — so reading it here is reading `+0x120`. +/// - a **Title region** gets nothing (verified negative), and a templateless Lua region is never +/// routed here at all: it stays rect-less and does not render. +/// +/// These are ordinary anchors once installed: a later same-point `SetPoint` replaces only its own +/// slot and the other implicit corner survives (verified) — callers that re-apply authored anchors +/// over a materialized region must `ClearAllPoints` first, exactly as the reference's XML path +/// avoids the mix by running this step *after* `` load. +pub(crate) fn implicit_creation_anchor(model: &mut Model, rh: RegionHandle) { + let Some((kind, owner)) = model.arena.region(rh).map(|r| (r.kind, r.owner)) else { + return; + }; + let owner_id = model.frame_id(owner); + let data = model.region_data.entry(rh).or_default(); + if !data.anchors.is_empty() { + return; + } + use crate::layout::Point; + match kind { + RegionKind::Texture => { + data.anchors = vec![ + Anchor::new(Point::TopLeft, owner_id, Point::TopLeft, 0.0, 0.0), + Anchor::new(Point::BottomRight, owner_id, Point::BottomRight, 0.0, 0.0), + ]; + } + RegionKind::FontString => { + // The exact byte compare chain: `& 7` then equality against LEFT (1) and RIGHT (4) — + // every other value, the CENTER bit and the cleared axis included, falls to CENTER. + let point = match data.justify.0 & crate::justify::H_MASK { + 0x01 => Point::Left, + 0x04 => Point::Right, + _ => Point::Center, + }; + data.anchors = vec![Anchor::new(point, owner_id, point, 0.0, 0.0)]; + } + RegionKind::Title => return, + } + model.touch_layout(); +} + +/// [`implicit_creation_anchor`] behind a wrapper table — the loader-facing form (the loader holds +/// region wrappers, not handles), same seam as [`apply_font_parts`]. +pub(crate) fn implicit_creation_anchor_lua(lua: &Lua, wrapper: &Table) -> mlua::Result<()> { + let rh = region_handle_of(lua, wrapper)?; + let mut model = lua.app_data_mut::().expect("model app_data"); + implicit_creation_anchor(&mut model, rh); + Ok(()) +} + /// Resolve a `SetPoint`/`SetAllPoints` `relativeTo` argument (a frame/region wrapper table, a frame /// name, or nil) to a layout id, defaulting to `owner` when absent/unresolved. pub(super) fn resolve_target(model: &mut Model, target: &Value, owner: u32) -> u32 { diff --git a/crates/benilla-ui/src/script/region/layout.rs b/crates/benilla-ui/src/script/region/layout.rs index d6305db6..f62e3817 100644 --- a/crates/benilla-ui/src/script/region/layout.rs +++ b/crates/benilla-ui/src/script/region/layout.rs @@ -14,7 +14,8 @@ use super::{ /// Populate `m`'s layout methods (see the module doc). pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { - // Region explicit size (drawn centered on the owner; region anchors come later). + // Region explicit size — fills the axes the region's anchors don't pin (unread under an + // implicit SetAllPoints's two corners; decision 1310). m.set( "SetWidth", lua.create_function(|lua, (this, w): (Table, f32)| { @@ -74,8 +75,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { )?; // GetLeft/GetRight/GetTop/GetBottom — the region's RESOLVED edges (y-up UI units; frame twin - // in object.rs). An anchored region reads its own resolved rect; an unanchored one has no - // rect of its own (it draws relative to its owner at extract) → nil, same as pre-resolve. + // in object.rs). Every drawable region carries anchors (authored or the creation-path + // implicit anchor, decision 1310) and reads its resolved rect; a templateless Lua region + // nobody anchored never resolves → nil, same as pre-resolve. for (name, pick) in [ ("GetLeft", 0u8), ("GetRight", 1u8), diff --git a/crates/benilla-ui/src/script/tests/button.rs b/crates/benilla-ui/src/script/tests/button.rs index 06c0a068..38a34d8f 100644 --- a/crates/benilla-ui/src/script/tests/button.rs +++ b/crates/benilla-ui/src/script/tests/button.rs @@ -332,7 +332,7 @@ fn down_registration_fires_on_press_and_toggles_checked_once() { } #[test] -fn highlight_is_additive_and_sized_regions_center() { +fn highlight_is_additive_and_state_textures_fill_then_anchor() { let mut s = script(); s.set_screen_size(800.0, 600.0); s.run( @@ -360,8 +360,34 @@ fn highlight_is_additive_and_sized_regions_center() { &find("Interface\\Hi.blp").content, QuadContent::Texture { additive: true, .. } )); - // The sized ring centers on the 36px button: 100..136 → 64px box at 86..150. + // A fresh state texture gets the creation-path implicit SetAllPoints (decision 1310 — the + // reference's string setters anchor a freshly built texture to the button outright), whose + // two corners pin all four edges: the later SetSize(64) is structurally unread and the ring + // FILLS the 36px button. let r = find("Interface\\Ring.blp").rect.unwrap(); + assert_eq!( + (r.left, r.right, r.bottom, r.top), + (100.0, 136.0, 100.0, 136.0) + ); + // The real quickslot-overhang idiom is an ANCHOR, not an anchorless size (ActionButton's + // 66×66 UI-Quickslot2 authors ``): one CENTER point replaces only its + // own slot, but clearing first leaves the single anchor + the 64px size → the centered + // overhang, 100..136 → 86..150. + s.run( + r#" + local n = AddBtn:GetNormalTexture() + n:ClearAllPoints() + n:SetPoint("CENTER", AddBtn, "CENTER", 0, 0) + "#, + ) + .unwrap(); + s.resolve(); + let quads = s.extract(); + let ring = quads + .iter() + .find(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == "Interface\\Ring.blp")) + .unwrap(); + let r = ring.rect.unwrap(); assert_eq!( (r.left, r.right, r.bottom, r.top), (86.0, 150.0, 86.0, 150.0) diff --git a/crates/benilla-ui/src/script/tests/end_to_end.rs b/crates/benilla-ui/src/script/tests/end_to_end.rs index 65f2b1d0..1fcfd680 100644 --- a/crates/benilla-ui/src/script/tests/end_to_end.rs +++ b/crates/benilla-ui/src/script/tests/end_to_end.rs @@ -15,17 +15,22 @@ fn end_to_end_two_frame_tree_extracts_in_zkey_order() { local parent = CreateFrame("Frame", "Root") parent:SetPoint("TOPLEFT", 0, 0) -- anchored to the screen root parent:SetSize(400, 300) + -- SetAllPoints on each region: a templateless Lua region gets NO implicit anchor + -- (decision 1310 — rect-less, never drawn), so real addon code anchors it, and so do we. local pbg = parent:CreateTexture(nil, "BACKGROUND") pbg:SetTexture("Interface\\Parent.blp") + pbg:SetAllPoints() local child = CreateFrame("Frame", "Leaf", parent) child:SetPoint("TOPLEFT", parent, "TOPLEFT", 10, -10) child:SetSize(100, 50) local cbg = child:CreateTexture(nil, "ARTWORK") cbg:SetTexture("Interface\\Child.blp") + cbg:SetAllPoints() local ctext = child:CreateFontString(nil, "OVERLAY") ctext:SetText("Hello") ctext:SetVertexColor(1, 0, 0, 1) + ctext:SetAllPoints() "#, ) .unwrap(); @@ -52,7 +57,7 @@ fn end_to_end_two_frame_tree_extracts_in_zkey_order() { ] ); - // Region rects are their owner frame's resolved rect (v1). Parent → Rect(300,0,600,400); + // SetAllPoints resolves each region to its owner frame's rect. Parent → Rect(300,0,600,400); // child (TOPLEFT+10,-10 of parent, 100×50) → Rect(540,10,590,110). let child_text = quads .iter() diff --git a/crates/benilla-ui/src/script/tests/regions.rs b/crates/benilla-ui/src/script/tests/regions.rs index da8f7a82..d164323d 100644 --- a/crates/benilla-ui/src/script/tests/regions.rs +++ b/crates/benilla-ui/src/script/tests/regions.rs @@ -95,7 +95,12 @@ fn region_fontstring_unpinned_edge_collapses_until_measured() { } #[test] -fn region_sized_no_anchor_centers_on_owner() { +fn templateless_lua_region_without_anchors_never_draws() { + // Decision 1310 (wow-re `region-implicit-anchor.md`, VERIFIED): the creation-path implicit + // anchor fires from Lua CreateTexture only on a template-registry hit — a templateless region + // gets NOTHING, stays rect-less (the resolver has no zero-anchor fallback), and never renders, + // its explicit size notwithstanding. This replaced the old draws-centered-at-its-size + // fallback, which was refuted at the bytes (it was B180's squashed stack-split dialog). let mut s = script(); s.set_screen_size(800.0, 600.0); s.run( @@ -105,16 +110,18 @@ fn region_sized_no_anchor_centers_on_owner() { f:SetSize(100, 50) local t = f:CreateTexture("Tex", "ARTWORK") t:SetTexture("Interface\\Ring") - t:SetSize(24, 24) -- no anchors ⇒ centered on the owner + t:SetSize(24, 24) -- no anchors, and no template ⇒ no rect, no draw + assert(t:GetLeft() == nil, "a rect-less region reads nil edges") "#, ) .unwrap(); s.resolve(); - // center (50, 25), 24×24 → [bottom 13, left 38, top 37, right 62]. - assert_eq!( - region_tex_rect(&s, "Interface\\Ring"), - Rect::new(13.0, 38.0, 37.0, 62.0) - ); + let drawn = s.extract().iter().any(|q| { + matches!(&q.content, + QuadContent::Texture { path: Some(p), .. } if p.contains("Interface\\Ring")) + && q.rect.is_some() + }); + assert!(!drawn, "a templateless anchor-less region must not draw"); } #[test] diff --git a/crates/benilla-ui/src/script/tests/scrollframe.rs b/crates/benilla-ui/src/script/tests/scrollframe.rs index a11d37c2..0e49b1e8 100644 --- a/crates/benilla-ui/src/script/tests/scrollframe.rs +++ b/crates/benilla-ui/src/script/tests/scrollframe.rs @@ -88,6 +88,7 @@ fn scroll_child_top_tracks_vertical_scroll_and_clamps_then_restores_on_clear() { child:SetSize(300, 600) local marker = child:CreateTexture(nil, "ARTWORK") marker:SetTexture("marker:child") + marker:SetAllPoints() -- templateless Lua regions carry no implicit anchor (decision 1310) frame:SetScrollChild(child) "#, diff --git a/crates/benilla-ui/src/script/types.rs b/crates/benilla-ui/src/script/types.rs index e40c6356..e57c44f8 100644 --- a/crates/benilla-ui/src/script/types.rs +++ b/crates/benilla-ui/src/script/types.rs @@ -431,17 +431,19 @@ pub(crate) struct RegionData { /// WoW `ADD` blend (`SetBlendMode("ADD")` / XML `alphaMode` — the shared enum `0x811aa8`). /// Highlight state textures default to it (the client's `SetHighlightTexture` contract). pub(crate) additive: bool, - /// Explicit region size (`SetWidth`/`SetHeight`/XML ``); `None` = derive. When the region - /// carries [`RegionData::anchors`], size fills the axis the anchors don't pin (the client's "0 = - /// derive"); with **no** anchors, a sized region draws *centered* on its owner (the common - /// state-texture overhang, e.g. the 64×64 quickslot ring on a 36×36 button). + /// Explicit region size (`SetWidth`/`SetHeight`/XML ``); `None` = derive. Size fills the + /// axis the anchors don't pin (the client's "0 = derive") — so under a texture's implicit + /// SetAllPoints (two corners pin everything) an authored size is structurally unread, which is + /// how the reference stack-split plate authors 256×32 and renders 172×96 (decision 1310, B180). pub(crate) size: Option<(f32, f32)>, /// Region anchors (`SetPoint`/XML `` on a Texture/FontString). An anchor's /// `relative_to` defaults to the owner frame and may name a frame or a **sibling region** /// (resolved via [`Model::region_names`]; the fixpoint in [`UiScript::resolve`] orders the - /// sibling chain). Empty ⇒ the size/centered/fill fallback in [`UiScript::extract`]; - /// non-empty ⇒ resolved in [`UiScript::resolve`], any edge the anchors leave unset inherited - /// from the owner frame's rect. + /// sibling chain). Every drawable region carries at least one — authored, or the + /// creation-path implicit anchor ([`super::region::implicit_creation_anchor`], decision + /// 1310); empty means a templateless Lua region nobody anchored, which never resolves and + /// never draws. Non-empty ⇒ resolved in [`UiScript::resolve`], any edge the anchors leave + /// unset inherited from the owner frame's rect. pub(crate) anchors: Vec, /// `Texture:SetDesaturated(flag)` — the shader desaturation state (`0x79c1e0`, verified in /// wow-re's ledger). **Stored; the renderer does not yet honour it**, which is exactly why the