skill-ups announce again, and the stack split fills its window
- a real skill-up prints its line; the lines the client keeps silent — class specs, racials, mounts — stay silent, off the game's own flag - the stack-split dialog draws whole: its plate fills the window - the four bag-bar buttons speak the reference's names all the way down, so Bagnon's overrides catch them and the backpack button lights while its window is open - regions anchor at creation — the client's implicit-anchor law replaces a pile of draw-time fallbacks Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <OnClick> 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
|
||||
</OnLoad>
|
||||
<OnClick>BenillaBagToggle_OnClick()</OnClick>
|
||||
<!-- The undo repairs the CheckButton widget's own pre-handler flip; from there the
|
||||
checked state belongs to the windows' OnShow/OnHide writes (ours and any
|
||||
addon's) — the stated divergence at BenillaBagToggle_OnClick's comment. -->
|
||||
<OnClick>
|
||||
self:SetChecked(not self:GetChecked())
|
||||
BenillaBagToggle_OnClick()
|
||||
</OnClick>
|
||||
<OnEnter>BenillaBagToggle_OnEnter(self)</OnEnter>
|
||||
<OnLeave>BenillaBagBarButton_OnLeave()</OnLeave>
|
||||
</Scripts>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -161,7 +161,14 @@
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<!-- ref l.9-15: the parchment plate, 256×32, TexCoords 0-0.671875 / 0-0.75. -->
|
||||
<!-- ref l.9-15 verbatim: the plate, authored 256×32 with NO anchors. The size is
|
||||
VESTIGIAL and stays that way by the byte-verified law (decision 1310): an
|
||||
anchor-less region gets the creation-path implicit SetAllPoints(parent), whose
|
||||
two corners pin all four edges and leave the authored size unread — the
|
||||
TexCoords crop exactly 172×96 out of the 256×128 art, the frame's own size,
|
||||
drawn 1:1 filling the dialog. 1308 dropped this size to dodge the old centered
|
||||
fallback (B180's squashed bar); with the fallback corrected the ref's own text
|
||||
is back, and the bag test pins the render either way. -->
|
||||
<Texture file="Interface\MoneyFrame\UI-MoneyFrame">
|
||||
<Size><AbsDimension x="256" y="32"/></Size>
|
||||
<TexCoords left="0" right="0.671875" top="0" bottom="0.75"/>
|
||||
|
||||
@@ -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")
|
||||
),
|
||||
|
||||
@@ -121,16 +121,25 @@ impl Plugin for UiCharPlugin {
|
||||
/// `None` = nothing announced yet.
|
||||
type SkillBlock = Option<(u64, std::collections::HashMap<u16, u16>)>;
|
||||
|
||||
/// 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<NonSendMut<UiScript>>,
|
||||
self_guid: Res<crate::net::SelfGuid>,
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -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::<bool>("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::<bool>("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<f32> = 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::<bool>("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::<bool>("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());
|
||||
}
|
||||
|
||||
@@ -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<N>` 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::<bool>("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::<bool>("return Bagnon:IsVisible() or false")
|
||||
.unwrap(),
|
||||
"the slot click must reach Bagnon's ToggleBag override and close its window"
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>("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::<bool>("return Bagnon:IsVisible() or false")
|
||||
.unwrap(),
|
||||
"the second click re-opens Bagnon through the same override"
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>("return BenillaBagFrame2:IsVisible() or false")
|
||||
.unwrap(),
|
||||
"the native window stays shut on the re-open too"
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>("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::<bool>("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::<bool>("return Bagnon:IsVisible() or false")
|
||||
.unwrap(),
|
||||
"the click must toggle Bagnon closed through its override"
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>("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::<bool>("return Bagnon:IsVisible() or false")
|
||||
.unwrap(),
|
||||
"the second click re-opens Bagnon"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return MainMenuBarBackpackButton:GetChecked() and true or false")
|
||||
.unwrap(),
|
||||
"open ⇒ lit (Bagnon's OnShow write is the last word)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
+32
-323
@@ -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<SkillLineCatalog> {
|
||||
}
|
||||
|
||||
#[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<u32> = [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<u32> = [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<u32> = [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<u32> = [6603].into_iter().collect();
|
||||
assert_eq!(cat.rank_successor(6603), None);
|
||||
assert_eq!(cat.highest_known_rank(6603, &attack), Some(6603));
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -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<u32> = [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<u32> = [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<u32> = [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<u32> = [6603].into_iter().collect();
|
||||
assert_eq!(cat.rank_successor(6603), None);
|
||||
assert_eq!(cat.highest_known_rank(6603, &attack), Some(6603));
|
||||
}
|
||||
@@ -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
|
||||
// `<Size>`/`<Anchors>`/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 `<FontString>` 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
|
||||
|
||||
@@ -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 `<Texture>` with zero anchors gets
|
||||
/// SetAllPoints(parent) right after its LoadXML — two corner anchors that pin all four
|
||||
/// edges, so an authored `<Size>` 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#"<Ui>
|
||||
<Frame name="Plate">
|
||||
<Size><AbsDimension x="172" y="96"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="10" y="10"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Layers><Layer level="BACKGROUND">
|
||||
<Texture file="Interface\Panel">
|
||||
<Size><AbsDimension x="256" y="32"/></Size>
|
||||
</Texture>
|
||||
</Layer></Layers>
|
||||
</Frame>
|
||||
</Ui>"#,
|
||||
);
|
||||
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 `<Size>` 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#"<Ui>
|
||||
<Frame name="Page">
|
||||
<Size><AbsDimension x="200" y="100"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Layers><Layer level="ARTWORK">
|
||||
<FontString name="SeatLeft" justifyH="LEFT" text="body">
|
||||
<Size><AbsDimension x="80" y="20"/></Size>
|
||||
</FontString>
|
||||
</Layer></Layers>
|
||||
</Frame>
|
||||
</Ui>"#,
|
||||
);
|
||||
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 `<Anchors>` on a
|
||||
/// `<NormalTexture>` 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#"<Ui>
|
||||
<Button name="ScopedBtn">
|
||||
<Size><AbsDimension x="36" y="36"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="100" y="100"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<NormalTexture file="Interface\Scoped">
|
||||
<Size><AbsDimension x="24" y="24"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"><Offset><AbsDimension x="2" y="-2"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</NormalTexture>
|
||||
</Button>
|
||||
</Ui>"#,
|
||||
);
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
// `<Anchors>` 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::<Table>("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 `<Anchors>`/justify apply
|
||||
// (so a `<ButtonText justifyH="LEFT">` 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 (`<ButtonText name="$parentText">`) —
|
||||
// the ref kit addresses tab/button labels by exactly this global
|
||||
// (PanelTemplates_TabResize's `getglobal(tabName.."Text")`).
|
||||
|
||||
@@ -173,6 +173,14 @@ fn ensure_slot(lua: &Lua, this: &Table, slot: Slot) -> mlua::Result<u32> {
|
||||
},
|
||||
);
|
||||
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 `<Anchors>` (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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
/// `<Size>` 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 `<Size>` 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* `<Anchors>` 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::<Model>().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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 `<Anchor point="CENTER">`): 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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
"#,
|
||||
|
||||
@@ -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 `<Size>`); `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 `<Size>`); `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 `<Anchors>` 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<Anchor>,
|
||||
/// `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
|
||||
|
||||
Reference in New Issue
Block a user