Files
benilla-octo/crates/benilla-app/src/ui_script/bottom_hud_tests.rs
T
SamandClaude Fable 5.1 c3555b4eea the cooldown goes stock, early-Z comes back, and lists reach the end
- a size-less `<Model>` pane's rect is its file's box in layout units: the reference's geometry overrides (`0x76d080`/`0x76d0d0`) answer the bbox extent whenever no size is authored, `bboxExtent · 768·√(a²+1) · layoutScale` FrameXML units (`1280 · extent` at 4:3), written into the pane's layout input when the file's facts land, re-derived on an aspect change, and ended for good by any authored size; `SetPosition` is the same unit, which is how `0x4a7b20` centres the arrow at `½·GetWidth, ½·GetHeight` through the override — so the map arrow's sprite arm and its hand-sized 33.6 px footprint go (its rect is `0.0262 × 0.0263` layout units, 33.5 × 33.7 FrameXML units at 4:3, 41.0 × 41.2 at 16:9), and the stock `ItemAnim` pane plays the item push unadapted (`ReplaceIconTexture` on the type-14 batch, the 1000 ms clamp, the stock `OnAnimFinished`), `ContainerFrameAdapters.xml` losing 0887's Lua card and its hand-measured 42.41 × 124.72 rect — the implicit one at 16:9 — and keeping the keyring fork and the three bag verbs (2015); and the cooldown is the reference's own model: the texture-transform law `uv' = R_q((uv + t − p) ⊙ s) + p`, `p = (½, ½)`, the raw quaternion component-lerped and never normalised, is one function (`uv_transform`) with the shader's UV fold as its twin — rotation and scale tracks bake per file sequence slot beside the translation, the mat-anim table gains an affine row `[cos − 1, sin, sx − 1, sy − 1]` on `anim_slots.z` whose identity is the zero row so every static batch reads exactly what it did, and a UI tile clones the twin material per animated batch and writes both rows every frame off the pane's play head, never the world clock; the stock `Cooldown.xml` runs off the chain on 2007's two handlers, the file's four quadrant quads turned by their own rotation tracks (`0 → −90°` over each quadrant's 250 ms window, clockwise from 12 o'clock) and its alpha tracks revealing the star for the flash; retired: the native `Cooldown` widget and `SetCooldown` (an Era API 1.12 never had — `CreateFrame("Cooldown")` is the reference's unknown-type refusal now), the loader's `Model → Cooldown` substitution, the app's hand-built pie and star, and our `Cooldown.xml`; `assets/ui` is eight files (2019)
- `UpdateMapHighlight` gets its zone-level exits (B360): at zone zoom ours returned the nil tail for every cursor position, so hovering Deadwind Pass from Duskwood showed no label where 1.12 shows "Deadwind Pass"; the reference's `0x4a7fa0` runs two lookups at zone level and answers a NAME only — first the current zone's exploration-gated overlay list (the builder at `0x4a6ad9` gating on the explored byte), each row's HitRect scaled by 1/1002 and 1/668 against the normalized cursor, edges inclusive, the first hit naming its Area1, then the continent grid re-windowed through the displayed zone's own rect, naming the neighbouring zone or city and nothing for the displayed zone itself — and only the name-only tail is reachable from there, fileName nil and the six coords zero, so the stock frame hides the highlight quad, as the director's own 1.12 shot shows; and `SetVerticalScroll` stores the offset as given (B370, B363): the reference's `0x786db0` compares the new offset against the old and writes it to `[+0x328]` verbatim, `0x787100` re-anchors the scroll child off it, and the range at `[+0x320]` is never read on that path — every clamp the reference exhibits is FrameXML's, through the scroll bar's `[min, max]`; ours clamped into `[0, GetVerticalScrollRange()]`, the child's overflow past the FRAME, which is 40 px short of where `FauxScrollFrame_Update` sends the bar on the 220-tall skills list (three rows lost) and 15 px short on the 287-tall who list (one); the setter fires `OnVerticalScroll` only on a change, as the bytes do, `BenillaScroll_Step` bounds its wheel in Lua where the reference's handler does, and the ScrollFrame kind's prose stops calling its offset unpinned (2017)
- overdraw's cheap half, the sky first: since 0588 every sky element wrote `frag_depth = 0.0`, which costs a pipeline its early-Z, so on an outdoor frame every full-screen dome fragment under the ground and the buildings was shaded in full (the gradient's `asin`, `atan2` and eight table lookups at dawn and dusk) and thrown away; one shared `sky_vertex.wgsl` — bevy's mesh vertex verbatim plus `out.position.z = 0.0` — is now the vertex stage of the gradient dome, the cloud dome, the star patches and the celestial discs and glares, and the model lane's `WOW_SKY_DEPTH` branch moves from its fragment to its vertex, so no sky fragment declares `frag_depth` and the rasterizer rejects the covered screen before the shader runs; every sky `specialize` zeroes the rasterizer bias constant the `sky_order` rung had been leaking into `DepthBiasState::constant`, harmless only while the fragment overwrote its depth, and the prose beside the pin follows it; then the retained `static_gx` pass draws BEFORE bevy's opaque pass (`StartMainPass → static_gx → MainOpaquePass`) — buildings and doodads are the frame's best occluders and a terrain fragment its dearest, six samples a pixel — so walls reject the terrain behind them instead of the reverse, the pass taking the first-use clear with it and the dev depth probe ordered after it; measured on 16 capture scenarios: 14 bit-identical, `northshire-dusk` 1 px and `water-night` 11 px, attributed to exact depth ties on static/terrain silhouettes and last-bit interpolation noise; a depth prepass and near-to-far bins are declined until a journal says where the time goes (2016)
- the grass cut goes per pixel because of a quarter-mip: `0x6813d0` sets `D3DSAMP_MIPMAPLODBIAS = +0.25` on stage 0 (`0x6813f4`) once per frame before the detail-doodad pass — capture-confirmed on all 29 doodad batches, and outside the `[0x6b1000, 0x6b3200)` scan 2004 had called complete; it decides the whole look because the `clutter_cut` probe finds 6 of 31 shipped detail atlases carry a BINARY alpha pyramid (mip 0 has DXT3's 16 levels, every level below exactly two, both starting zones among them), so `a × ramp` degenerates to a global on/off and every texel of a leaf crosses at the same depth — Elwynn's survivor coverage is flat at 47.6 % across the whole 52.5–61.1 yd band, then 0.0 %, while the probe's box-filtered control erodes 47.7 → 20.9 over the same band, so the art has the gradient the shipped pyramid threw away, and `clutter_size` puts the median tuft at 0.92 yd tall, 1.03 yd deep, a full yard for the boundary plane to sweep; the escape is bilinear: full alpha needs a 2×2 all-opaque texel neighbourhood, and those run out between mip 2 and 3 (179/56/10/0/0/0 on the dominant Elwynn cell), so the reference's λ ≈ 2.9–3.3 sits past the cliff and the tuft erodes continuously — 2.2 % of a tuft's pixels leave in the last half-yard with the bias, 11.6 % without, 24.2 % one mip sharper; wgpu has no sampler LOD bias, so the clutter lane re-samples base colour at `view.mip_bias + 0.25`, keeping the sampler every other model batch shares at the reference's 0; ruled out on the way: our BLP decoder (all nine uploaded levels byte-identical against the archive's 52 793 BLP2s), mip regeneration, the `has_mipmaps` byte's value 2 (a plain boolean, an authoring marker), anisotropy and geometry; what it does not fix is resolution-dependent, and so is the reference's own look — λ with the bias is 3.22 at the capture's 648p but 2.48 at 1080p and 1.48 at 2160p, so at a modern resolution the reference itself pops leaf by leaf, and scaling the bias with the viewport is one reversible line left for a look call (2021)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:35:31 +07:00

528 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! **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();
// A sanity floor for the walk, not a census: `assets/ui` retires file by file (1751), so
// the floor sits well under the count rather than one step above it (1956).
assert!(out.len() >= 6, "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 == r"Interface\FrameXML\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",
),
("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() {
// The pass is the stock `UIParent.lua`'s since 1988 — read off the player's chain.
let _data = benilla_formats::wow_data_or_skip!();
let pass = String::from_utf8_lossy(
&super::reference_ui::read(r"Interface\FrameXML\UIParent.lua")
.expect("the stock UIParent.lua off the chain"),
)
.into_owned();
for f in MANAGED_FRAMES {
assert!(
pass.contains(f),
"{f} is listed here as managed but does not appear in UIParent.lua'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);
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 (the stock UIParent.lua's table, since 1988), or add \
it to BOTTOM_EXEMPT here with the reason it needs no row (decision 1499):\n{}",
unaccounted.join("\n")
);
}
/// 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);
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
// into its label inside its own OnLoad. A manifest load with no player is a state the client
// never reaches (decision 1848).
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probefour".into()),
level: 60,
..Default::default()
}),
);
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;
// **Through the saved globals, not a bare Show.** The stock pass reads
// `SHOW_MULTI_ACTIONBAR_1`/`_2` for its bottom-bar flags (`UIParent.lua:1598-1606`),
// never the frames' shown state, so raising a bar by hand and running the pass is a
// state the client never reaches — and it clears nothing (1988; our retired copy of
// the pass read `IsShown`, which is what let this drive work before).
let global = match *bar {
"MultiBarBottomLeft" => Some("SHOW_MULTI_ACTIONBAR_1"),
"MultiBarBottomRight" => Some("SHOW_MULTI_ACTIONBAR_2"),
_ => None,
};
if let Some(g) = global {
s.run(&format!("{g} = {}", if on { "1" } else { "nil" }))
.unwrap();
}
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);
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
// into its label inside its own OnLoad. A manifest load with no player is a state the client
// never reaches (decision 1848).
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probefour".into()),
level: 60,
..Default::default()
}),
);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
// The card is the stock `<Model>` since 2015, sized by its file's box once the facts land
// (`ForcedBackpackItem.m2`: one 1000 ms clamp; the box 0.02707 × 0.07962 model units).
s.set_model_facts(
r"Interface\ItemAnimations\ForcedBackpackItem.mdx",
benilla_ui::widget::ModelFileFacts {
sequences: vec![benilla_ui::widget::SequenceFacts {
anim_id: 0,
duration_ms: 1000,
looping: false,
}],
bbox: ([0.0, 0.0, 0.0], [0.02707, 0.07962, 0.0]),
},
);
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, "MainMenuBarBackpackButtonItemAnim"),
"the card plays"
);
// The pane's rect is the file's box in layout units at 16:9 — the card's whole travel band,
// 42.41 × 124.72 hung off the button's BOTTOMRIGHT (−10, 0) — and the card the file's keys
// put inside it at its opaque peak is the reference's 48.9..93.1 above the screen floor
// (0887 measured that quad by hand; it sits inside this band).
let card = rect(&s, "MainMenuBarBackpackButtonItemAnim");
let bar = rect(&s, "MultiBarBottomRight");
assert!(
(card.2 - card.0 - 42.41).abs() < 0.05 && (card.3 - card.1 - 124.72).abs() < 0.05,
"the pane's rect is the file's box: {card:?}"
);
assert!(
card.1 <= 48.9 && card.3 >= 93.1,
"the reference's card peak 48.9..93.1 lies inside the pane's band: {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"
);
}