Files
benilla-octo/crates/benilla-app/src/ui_script/bottom_hud_tests.rs
T
SamandClaude Fable 5.1 0f88324605 the action bars' empty slots go ghost, and a rooted creature stays put
- picking a spell up off the bar no longer blacks out every empty slot: `UI-Quickslot` is a FILLED 60%-black plate, not a hollow ring (decoded off the shipped BLPs — Quickslot2's interior is RGBA 0,0,0,0, Quickslot's 0,0,0,153), and the reference dims it to half alpha in `ActionButton_ShowGrid`, so an empty well now ghosts at 30% black where it read as a hole; the wow-re cross-check then found the reference never UN-dims it (a 3-argument `SetVertexColor` preserves the current alpha at `0x79abd0`, and `SetNormalTexture` reuses the region), so in the real client the first drag leaves every button's border at half alpha for the rest of the session — benilla ends the dim on purpose and records it as the divergence it is (1782)
- the bag bar is the reference's own `MainMenuBarBagButtons.xml` (window 3 of the stock-first migration), and the swap found a real bug rather than a move: the reference's `ItemAnim_OnEvent` matches a push against the button's inventory-slot id (20..23) and benilla emitted its own container numbering (1..4), so all four bag-push cards would have silently never played against the stock body — `push_container` now emits the reference's selector (`bag + 1`), which is what an addon expects anyway; beside it, a readiness probe that asks the LOADER, not a grep, which of the 85 stock windows load off the player's chain today (58 do; the 27 that don't name their own blocker)
- a rooted, hovering, water-walking or walking creature reached benilla as an unhandled opcode: the twelve `SMSG_SPLINE_MOVE_*` (the observer twin of the ack'd force family — bare packed guid, no ack, any unit) now decode into one MOVEMENTFLAGS word per unit, which benilla kept for our mover and for a relayed player and not at all for a creature, the one body this family is ever addressed to; ROOT wipes the direction bits so the dead-reckon stops walking a rooted body on for its next heartbeat (`0x7c7340`), HOVER clears a yard off the floor without climbing a body already inside that yard (`0x636e81`), WATERWALK elects the liquid surface against the terrain, SAFE_FALL's terminal is 7.0 not 60.148 (`0x7c5d23`), and SET_RUN_MODE's bool is inverted against the bit, folded at the parse; the inspect card names a unit's granted modes in words (`granted rooted+hover`) so a creature resting a yard off the floor reads as intended rather than broken

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 16:57:55 +07:00

474 lines
21 KiB
Rust

//! **The bottom-of-screen clearance law, enforced** (decision 1499).
//!
//! The bottom band of the screen is shared: the main bar sits on it, the extra action bars, the
//! stance/pet bar and the reputation watch bar stack above it, and the bag windows, the cast bar,
//! the chat panes and the default tooltip anchor all have to keep clear of whatever is standing.
//! `UIParent_ManageFramePositions()` (UIParent.xml) is the ONE thing that decides that — it seats
//! the managed frames and writes the managed globals — and every one of the defects below was a
//! frame that quietly opted out of it.
//!
//! Three guards, each pinning a different way to opt out:
//!
//! 1. [`no_shipped_file_declares_its_own_copy_of_a_managed_offset`] — the *shadow copy*.
//! `BagFrame.xml` kept `local BENILLA_CONTAINER_OFFSET_X/Y = 0/70`. Locals, so the pass's
//! writes could never reach them; the open-bag stack sat at the no-bars corner forever and a
//! raised bottom multibar drew straight through the lowest bag window (director-caught — the
//! screenshot that opened 1499). `GameTooltip.xml` carried the softer `X = X or 0` form of the
//! same thing. A value with two written statements has no owner.
//!
//! 2. [`every_bottom_anchored_top_level_frame_is_accounted_for`] — the *new frame nobody wired
//! up*. This is the guard that has to hold for features that do not exist yet: a top-level
//! frame anchored to the screen's bottom edge is, by construction, in the contested band, so it
//! must be a managed row, a listener consumer, or an exemption someone wrote a reason for.
//!
//! 3. [`no_bottom_band_frame_overlaps_a_raised_bar`] — the *symptom itself*, over the real shipped
//! XML: for every combination of raised bars, no bottom-band frame's rect may intersect a
//! visible bar's. This is the one that would have failed on the director's screenshot.
use benilla_ui::script::UiScript;
/// The globals `UIPARENT_MANAGED_FRAME_POSITIONS`'s `isVar` rows own. Only `UIParent.xml` may
/// write these; everyone else reads them fresh at use (falling back inline, never by assigning).
const MANAGED_GLOBALS: &[&str] = &[
"CONTAINER_OFFSET_X",
"CONTAINER_OFFSET_Y",
"PETACTIONBAR_XPOS",
"PETACTIONBAR_YPOS",
"BATTLEFIELD_TAB_OFFSET_Y",
];
fn ui_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui")
}
/// Blank out `<!-- … -->` regions, keeping every newline so line numbers still point at the file.
/// The scan below reads Lua, and an XML comment is prose: `BagFrame.xml`'s own explanation of the
/// backpack anchor says "CONTAINER_OFFSET_X=0 / CONTAINER_OFFSET_Y=70" in English, which is a
/// description of the law, not a violation of it.
fn without_xml_comments(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find("<!--") {
out.push_str(&rest[..start]);
let after = &rest[start..];
let end = after.find("-->").map(|e| e + 3).unwrap_or(after.len());
for ch in after[..end].chars() {
if ch == '\n' {
out.push('\n');
}
}
rest = &after[end..];
}
out.push_str(rest);
out
}
fn shipped_xml() -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = std::fs::read_dir(ui_dir())
.expect("assets/ui")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "xml"))
.map(|p| {
(
p.file_name().unwrap().to_string_lossy().into_owned(),
std::fs::read_to_string(&p).expect("read"),
)
})
.collect();
out.sort();
assert!(out.len() >= 40, "only {} xml files swept", out.len());
out
}
/// **Nothing outside `UIParent.xml` may ASSIGN a managed offset** — not as a local, not as a
/// global, not as an `or`-guarded default.
///
/// Reading one is fine and expected (`-CONTAINER_OFFSET_X` in the right stack, `CONTAINER_OFFSET_Y`
/// in the bag stack and the tooltip anchor). Falling back at the point of use is fine
/// (`local offsetY = CONTAINER_OFFSET_Y or 70`) — that is a read with a default, and it is what the
/// per-window harnesses that ship no `UIParent.xml` need. What is forbidden is *storing* the value
/// anywhere the pass cannot reach, because the pass recomputes these on every bar change and a
/// stored copy is stale from the moment the next bar goes up.
///
/// The rule is spelled as "an assignment statement whose left-hand side names a managed global,
/// or a name containing one" — the second half is what catches `BENILLA_CONTAINER_OFFSET_Y`, which
/// was not the global's name at all and is exactly why nothing noticed it for so long.
#[test]
fn no_shipped_file_declares_its_own_copy_of_a_managed_offset() {
let mut offences = Vec::new();
for (name, text) in shipped_xml() {
if name == "UIParent.xml" {
continue; // the owner: its var rows are where these numbers are defined
}
for (n, line) in without_xml_comments(&text).lines().enumerate() {
let code = line.split("--").next().unwrap_or("");
let Some((lhs, _)) = code.split_once('=') else {
continue;
};
// `==`, `<=`, `>=`, `~=` are comparisons, not assignments.
if code[lhs.len() + 1..].starts_with('=')
|| lhs.ends_with(['<', '>', '~', '=', '!'])
|| lhs.contains("--")
{
continue;
}
let lhs = lhs.trim().trim_start_matches("local ").trim();
if MANAGED_GLOBALS.iter().any(|g| lhs.contains(g)) {
offences.push(format!("{name}:{}: {}", n + 1, line.trim()));
}
}
}
assert!(
offences.is_empty(),
"a managed offset is owned by UIParent.xml's manage pass and may only be READ elsewhere \
(fall back inline at the point of use — `local y = CONTAINER_OFFSET_Y or 70` — never by \
assigning a copy, which goes stale the moment a bar is raised; decision 1499):\n{}",
offences.join("\n")
);
}
/// Frames whose seat the manage pass owns — the `UIPARENT_MANAGED_FRAME_POSITIONS` keys that name
/// a frame, plus the two the pass's custom tail seats by hand. Kept here as literal text so this
/// test and `UIParent.xml` cannot drift apart silently: the next guard asserts every one of these
/// really appears in the shipped pass.
const MANAGED_FRAMES: &[&str] = &[
"MultiBarBottomLeft",
"GroupLootFrame1",
"TutorialFrameParent",
"FramerateLabel",
"CastingBarFrame",
"ChatFrame1",
"ChatFrame2",
"ShapeshiftBarFrame",
"PetActionBarFrame",
"QuestTimerFrame",
"DurabilityFrame",
"QuestWatchFrame",
];
/// Top-level frames that ARE anchored to the screen's bottom edge and are deliberately not managed.
/// Every entry carries the reason, because "it was already like that" is how the bag stack got its
/// shadow copy. Adding a frame here is a decision; adding one silently is what this test forbids.
const BOTTOM_EXEMPT: &[(&str, &str)] = &[
(
"MainMenuBar",
"the base itself — the bar everything else measures its clearance FROM, so it has no \
clearance of its own to compute",
),
(
"WorldFrame",
"a full-screen named handle, not a HUD frame (UIParent.xml) — setAllPoints, renders \
nothing",
),
("UIParent", "the full-screen root itself"),
(
"ZoneTextFrame",
"a mid-screen splash at BOTTOM +512 — nowhere near the contested band",
),
("SubZoneTextFrame", "as ZoneTextFrame"),
(
"MultiBarRight",
"a BAR, not a tenant of the band — it is one of the things the others clear. Its own seat \
is the reference's (MultiActionBars.xml: BOTTOMRIGHT -7,+98, with MultiBarLeft riding its \
TOPLEFT), and it sets the rightLeft/rightRight flags CONTAINER_OFFSET_X reads",
),
(
"ItemRefTooltip",
"the reference's own answer, matched exactly (ItemRef.xml l.4): frameStrata HIGH + \
toplevel + movable at BOTTOM +80. A linked-item window lands ABOVE the HIGH bars rather \
than clearing them (decision 1318), and the player drags it where they want",
),
(
"ChatFrame3",
"undocked chat panes are user-placed, and the reference manages only ChatFrame1/2 in \
UIPARENT_MANAGED_FRAME_POSITIONS. All five ship hidden at a placeholder BOTTOMLEFT",
),
("ChatFrame4", "as ChatFrame3"),
("ChatFrame5", "as ChatFrame3"),
("ChatFrame6", "as ChatFrame3"),
("ChatFrame7", "as ChatFrame3"),
(
"BlackoutWorld",
"the world map's full-screen cover (WorldMapFrame.xml) — TOPLEFT+BOTTOMRIGHT is how it \
fills the screen, not a seat in the contested band, and clearance is the opposite of \
what it wants: a blackout lifted over the action bars would leave a strip of world \
showing under the map. It joins this population at all only because decision 1757 took \
its parent=UIParent away, which is what lets it survive the UIParent:Hide() that \
showing the map performs",
),
];
/// **A top-level frame anchored to the screen's bottom edge is either managed, or exempt with a
/// stated reason.** There is no third option, and this is the guard that covers features nobody
/// has written yet.
///
/// The band at the bottom of the screen is contested by construction: the main bar owns it, and
/// every optional bar stacks upward into it. So the moment a new window anchors `BOTTOM`,
/// `BOTTOMLEFT` or `BOTTOMRIGHT` to the screen root, it has taken on a clearance problem — and the
/// only correct answers are to join `UIPARENT_MANAGED_FRAME_POSITIONS`, to seat itself from a
/// managed global and register a listener (the bag stack's answer), or to say out loud why it does
/// not need to. Failing here is not a bug report; it is a prompt to pick one.
#[test]
fn every_bottom_anchored_top_level_frame_is_accounted_for() {
let pass = std::fs::read_to_string(ui_dir().join("UIParent.xml")).expect("UIParent.xml");
for f in MANAGED_FRAMES {
assert!(
pass.contains(f),
"{f} is listed here as managed but does not appear in UIParent.xml's pass — this \
list has drifted from the file it mirrors"
);
}
let mut unaccounted = Vec::new();
for (file, text) in shipped_xml() {
let doc = benilla_ui::framexml::parse(&text).unwrap_or_else(|e| panic!("{file}: {e}"));
for (name, anchors) in bottom_anchored_top_level(&doc) {
let known = MANAGED_FRAMES.contains(&name.as_str())
|| BOTTOM_EXEMPT.iter().any(|(n, _)| *n == name)
|| registers_a_listener(&text, &name);
if !known {
unaccounted.push(format!("{file}: {name} ({anchors})"));
}
}
}
assert!(
unaccounted.is_empty(),
"these top-level frames anchor to the screen's bottom edge but nothing decides their \
clearance over the action bars. Give each one a row in \
UIPARENT_MANAGED_FRAME_POSITIONS (UIParent.xml), or seat it from a managed global and \
register UIParent_RegisterManagedPositionListener, or add it to BOTTOM_EXEMPT here with \
the reason it needs neither (decision 1499):\n{}",
unaccounted.join("\n")
);
}
/// A file that registers a managed-position listener is seating something itself; the frames it
/// declares are covered by that registration. Coarse on purpose — the listener seats a whole
/// family (the bag stack is five windows), and naming each one here would be the same drift trap
/// `MANAGED_FRAMES` guards against.
fn registers_a_listener(text: &str, _frame: &str) -> bool {
text.contains("UIParent_RegisterManagedPositionListener")
}
/// Every top-level INSTANCE (a `<Frame>`/`<Button>`/… that is not `virtual` and carries no
/// `parent` attribute) whose own `<Anchors>` name a BOTTOM point against the screen root — an
/// anchor with no `relativeTo`, or one naming `UIParent`/`WorldFrame`, all three of which resolve
/// to the same full-screen rect.
///
/// Only the frame's OWN anchors, not its children's: a child that anchors BOTTOM to its parent is
/// stating a position inside a window, which is nobody's clearance problem.
fn bottom_anchored_top_level(doc: &benilla_ui::framexml::ParsedDocument) -> Vec<(String, String)> {
let mut out = Vec::new();
for item in &doc.items {
let benilla_ui::framexml::TopLevel::Instance(el) = item else {
continue;
};
if el.attr("parent").is_some() {
continue;
}
let Some(name) = el.name() else { continue };
if name.contains("$parent") {
continue;
}
let hits: Vec<String> = el
.children
.iter()
.filter(|c| c.tag.eq_ignore_ascii_case("Anchors"))
.flat_map(|anchors| anchors.children.iter())
.filter(|a| a.tag.eq_ignore_ascii_case("Anchor"))
.filter(|a| {
a.attr("point")
.is_some_and(|p| p.to_ascii_uppercase().starts_with("BOTTOM"))
&& a.attr("relativeTo")
.is_none_or(|r| r == "UIParent" || r == "WorldFrame")
})
.filter_map(|a| a.attr("point").map(|p| p.to_string()))
.collect();
if !hits.is_empty() {
out.push((name.to_string(), hits.join("/")));
}
}
out
}
/// Every bar a player can raise into the band the tenants below have to share.
///
/// The two VERTICAL bars are in here deliberately, even though they are the only pair that moves
/// the tenants sideways rather than upward: `CONTAINER_OFFSET_X`'s `rightLeft = 90` /
/// `rightRight = 45` arithmetic has never once run in a shipped session, because until the
/// visibility options (1500) both bars were unconditionally hidden and the flags could not be set.
/// Half of the managed table was dead arithmetic nothing exercised. It runs here.
const RAISABLE_BARS: &[&str] = &[
"MultiBarBottomLeft",
"MultiBarBottomRight",
"ShapeshiftBarFrame",
"MultiBarRight",
"MultiBarLeft",
];
/// **The symptom, pinned: with any combination of bars raised, nothing in the bottom band overlaps
/// a raised bar.**
///
/// This is the assertion that would have failed on the screenshot that opened 1499 — an open bag
/// window drawn straight through a bottom multibar's buttons. It runs against the REAL shipped
/// XML, drives the bars' visibility directly (so it keeps holding however the toggles are
/// eventually wired), and re-runs the pass between combinations exactly as a live bar change does.
#[test]
fn no_bottom_band_frame_overlaps_a_raised_bar() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
// Open every bag window. The stack only exists once something is in it, and an EMPTY stack is
// exactly the state that hid this defect from every earlier test — `bag_tests` never loads
// `MultiBars.xml` and the multibar tests never open a bag, so no suite had both on screen at
// once. Shown through the real OnShow, so the stack layout runs the way a B keypress runs it.
s.run(
"for _, f in ipairs({BenillaBagFrame, BenillaBagFrame1, BenillaBagFrame2, \
BenillaBagFrame3, BenillaBagFrame4}) do if f then f:Show() end end",
)
.unwrap();
// Everything that sits in the band and must give way. Each is checked only while shown, so a
// frame that never appears contributes nothing — which is why the pair counter below matters.
const TENANTS: &[&str] = &[
"BenillaBagFrame",
"BenillaBagFrame1",
"BenillaBagFrame2",
"BenillaBagFrame3",
"BenillaBagFrame4",
"CastingBarFrame",
"ChatFrame1",
];
let mut pairs = 0usize;
for mask in 0..(1u32 << RAISABLE_BARS.len()) {
let mut raised = Vec::new();
for (i, bar) in RAISABLE_BARS.iter().enumerate() {
let on = mask & (1 << i) != 0;
s.run(&format!(
"if {bar} then {bar}:{}() end",
if on { "Show" } else { "Hide" }
))
.unwrap();
if on {
raised.push(*bar);
}
}
s.run("UIParent_ManageFramePositions()").unwrap();
s.resolve();
for bar in &raised {
if !shown(&s, bar) {
continue;
}
let bar_rect = rect(&s, bar);
for victim in TENANTS {
if !shown(&s, victim) {
continue;
}
let v = rect(&s, victim);
pairs += 1;
assert!(
!overlaps(bar_rect, v),
"with {raised:?} raised, {victim} {v:?} overlaps {bar} {bar_rect:?} — \
something in the bottom band is not clearing the bars. Its seat must come \
from UIParent_ManageFramePositions, read fresh (decision 1499)."
);
}
}
}
// Never let this pass by testing nothing: a renamed bag frame or a bar that fails to show
// would otherwise turn the whole guard green while checking zero pairs. With three raisable
// bars over seven tenants the real number is in the dozens.
assert!(
pairs >= 24,
"only {pairs} bar/tenant pairs were actually compared — the sweep is not exercising the \
band it claims to guard"
);
}
fn shown(s: &UiScript, name: &str) -> bool {
s.eval::<bool>(&format!(
"return {name} and {name}:IsShown() and true or false"
))
.unwrap_or(false)
}
/// `(left, bottom, right, top)` in screen pixels.
fn rect(s: &UiScript, name: &str) -> (f32, f32, f32, f32) {
s.eval::<(f32, f32, f32, f32)>(&format!(
"return {name}:GetLeft(), {name}:GetBottom(), {name}:GetRight(), {name}:GetTop()"
))
.unwrap_or_else(|e| panic!("rect of {name}: {e}"))
}
/// Touching edges do not overlap — the stack seats windows flush against each other by design.
fn overlaps(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> bool {
a.0 < b.2 && b.0 < a.2 && a.1 < b.3 && b.1 < a.3
}
/// **The item-push card's travel band overlaps a raised bottom bar — and so does the reference's.**
///
/// The director reported the looted-item card as "hidden behind" the extra action bar in the same
/// breath as the bag overlap, so this measures it instead of reasoning about it. The answer is not
/// the one the bag half had:
///
/// ```text
/// card at its opaque peak x 1253.7..1298.0 y 48.9..93.1
/// MultiBarBottomRight x 806.0..1306.0 y 57.0..95.0
/// ```
///
/// The card is MEDIUM (a child of a MEDIUM bag button); the bars are `frameStrata="HIGH"`
/// (`MultiActionBars.xml` l.36/151/266/381). **The reference has exactly this arrangement** — its
/// bag buttons are `parent="MainMenuBarArtFrame"` under a `MainMenuBar` with no strata at all, and
/// its `MultiBarBottomRight` runs from `MultiBarBottomLeft`'s right edge +10 for 500 px, which puts
/// its last buttons directly over the bag bar at every resolution. So a push into a bag draws
/// behind that bar's twelfth button whenever that slot is filled, in the real 1.12.1 client too.
/// It goes unnoticed because a multibar's EMPTY wells are hidden, so most players have nothing
/// there; the director's screenshot has a chicken in exactly that slot.
///
/// This is therefore a **finding test, not a guard**: it records what the reference's own geometry
/// produces so that changing either side has to come and edit these numbers on purpose. If we ever
/// diverge deliberately — raising the card's strata, or lifting its band clear of the bars — this
/// is the test that says so out loud rather than the change slipping through green.
#[test]
fn the_item_push_card_shares_the_band_with_a_raised_bar_exactly_as_the_reference_does() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
s.run("MultiBarBottomLeft:Show() MultiBarBottomRight:Show() UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
s.fire_event(
"ITEM_PUSH",
vec![
benilla_ui::script::ScriptValue::Int(0),
benilla_ui::script::ScriptValue::Str("Interface\\Icons\\INV_Misc_Bag_08".into()),
],
);
s.tick(0.133); // the opaque peak — the instant the card is most visible
s.resolve();
assert!(
shown(&s, "MainMenuBarBackpackButtonBenillaItemPush"),
"the card plays"
);
let card = rect(&s, "MainMenuBarBackpackButtonBenillaItemPush");
let bar = rect(&s, "MultiBarBottomRight");
assert!(
(card.1 - 48.9).abs() < 0.5 && (card.3 - 93.1).abs() < 0.5,
"the card's band is the reference's 48.9..93.1 above the screen floor: got {card:?}"
);
assert!(
overlaps(bar, card),
"the reference's own geometry puts the card inside the bar's band ({bar:?} vs {card:?}) — \
if this stops being true, benilla has diverged from it and the divergence needs a record"
);
}