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>
This commit is contained in:
Sam
2026-09-06 19:35:31 +07:00
co-authored by Claude Fable 5.1
parent c92ca1b8ec
commit c3555b4eea
93 changed files with 2423 additions and 1643 deletions
@@ -14,7 +14,7 @@ fn action_ids(s: &mut UiScript) -> Vec<u32> {
fn shipped_action_bar_drives_end_to_end() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
super::test_ui::load_ui(&s, "Cooldown.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Cooldown.xml");
// `load_ui` returns the same `report.frames` the disk reader asserted on, so this
// count is the one that always stood here — moved, not re-derived.
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
@@ -180,7 +180,7 @@ fn shipped_action_bar_drives_end_to_end() {
}
fn load_action_bar(s: &UiScript) {
super::test_ui::load_ui(s, "Cooldown.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\Cooldown.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\TextStatusBar.lua");
super::test_ui::load_ui(s, "Interface\\FrameXML\\TextStatusBar.xml");
@@ -251,17 +251,18 @@ fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() {
}),
);
s.fire_event("ACTIONBAR_UPDATE_COOLDOWN", vec![]);
// The stock machine (decision 2019): `CooldownFrame_SetTimer` arms sequence 0 and shows the
// pane; the next paint's `OnUpdateModel` scrubs it to `(GetTime() − start) / duration`.
super::test_ui::cooldown_facts(&mut s);
s.tick(0.0);
s.resolve();
let sweep = s.extract().into_iter().find_map(|q| match q.content {
QuadContent::Cooldown { fraction, flash } => Some((fraction, flash)),
_ => None,
});
let (fraction, flash) = sweep.expect("the button's Cooldown widget is showing");
assert!(
(fraction - 0.4).abs() < 1e-3,
"6 s of 10 s left ⇒ the sweep sits at 40%, got {fraction}"
let play = super::test_ui::cooldown_play(&s, "ActionButton1Cooldown")
.expect("the button's cooldown pane is showing, sequence 0 armed");
assert_eq!(
play,
(0, 400),
"6 s of 10 s left ⇒ the sweep sits at 40 %: sequence 0 at 400 ms"
);
assert_eq!(flash, None);
// The checked ring on the current action (the transcribed UpdateState).
s.set_action_state(
@@ -351,7 +352,11 @@ fn the_cooldown_sweep_paints_over_the_buttons_icon_and_ring() {
&|c| matches!(c, QuadContent::Texture { path: Some(p), .. } if p.contains("UI-Quickslot2")),
)
.expect("the NormalTexture ring quad");
let sweep = pos(&|c| matches!(c, QuadContent::Cooldown { .. })).expect("the sweep quad");
let sweep = pos(&|c| {
matches!(c, QuadContent::ModelPane { model: Some(m), .. }
if m.eq_ignore_ascii_case(super::test_ui::COOLDOWN_MODEL))
})
.expect("the sweep pane's quad");
assert!(
icon < sweep,
"the sweep (index {sweep}) must paint over the icon (index {icon})"
@@ -829,7 +834,7 @@ fn shipped_bag_frame_drives_end_to_end() {
let mut bar_frames = 0;
for file in BAG_UI {
let frames = load_ui(&s, file);
if *file == "Cooldown.xml" {
if *file == "Interface\\FrameXML\\Cooldown.xml" {
load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
@@ -1351,7 +1356,7 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
// Fonts first: the pane's check-box labels colour from `RED_FONT_COLOR` in their own OnLoad.
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -1517,7 +1522,7 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
fn bonus_bar_slides_up_with_sound_and_down_without() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
super::test_ui::load_ui(&s, "Cooldown.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Cooldown.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
@@ -1739,7 +1744,7 @@ fn bonus_bar_slides_up_with_sound_and_down_without() {
fn bonus_bar_turnaround_continues_from_position() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
super::test_ui::load_ui(&s, "Cooldown.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Cooldown.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
@@ -1821,7 +1826,7 @@ fn bonus_bar_turnaround_continues_from_position() {
fn the_page_arrows_do_not_steal_each_other_s_clicks() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
super::test_ui::load_ui(&s, "Cooldown.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Cooldown.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
+114 -199
View File
@@ -1394,19 +1394,15 @@ fn bag_slot_cooldown_sweeps_through_the_xml() {
s.set_container(0, Some(backpack(Some((52_000, 60_000, true)))));
s.run("OpenAllBags()").unwrap();
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(0)]);
// The stock machine (decision 2019): the slot's cooldown pane, sequence 0 scrubbed by the
// next paint's `OnUpdateModel` to the elapsed fraction.
super::test_ui::cooldown_facts(&mut s);
s.tick(0.0);
s.resolve();
let sweep = |s: &UiScript| {
s.extract().iter().find_map(|q| match q.content {
QuadContent::Cooldown { fraction, .. } => Some(fraction),
_ => None,
})
};
let fraction = sweep(&s).expect("the bag slot sweeps");
assert!(
(fraction - 0.8).abs() < 1e-3,
"48 of 60 s elapsed: fraction {fraction}"
);
let sweep = |s: &UiScript| super::test_ui::cooldown_play_any(s);
let play = sweep(&s).expect("the bag slot sweeps");
assert_eq!(play, (0, 800), "48 of 60 s elapsed: sequence 0 at 800 ms");
// The cooldown clears (a CLEAR_COOLDOWN, or it simply ran out before the re-push): the
// refresh event re-reads the now-cold triple and hides the widget.
@@ -1835,16 +1831,16 @@ fn a_key_dropped_on_the_button_files_itself() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The item-push drop animation (decision 0887): `ITEM_PUSH(container, icon)` runs the pushed item's
/// icon down into **that** container's bag-bar button and nobody else's, along the curves read out of
/// `ForcedBackpackItem.m2` — pop in, hang, fall, shrink away — and hides itself at the end.
///
/// This is the whole observable contract of `BenillaItemPushAnim_*`: the routing (which button), the
/// motion (starts a fall above the button, lands centred on it), the fade/scale shape (the file's
/// keys), and the CLAMP end (one play, then gone). It is also the regression net for the OnUpdate
/// gate — an anim frame left shown would keep ticking forever.
/// The item-push drop animation is the reference's own `<Model>` now (decisions 2013/2015):
/// `ITEM_PUSH(container, icon)` reaches the stock `ItemAnim_OnEvent`, which puts the icon on the
/// pane whose parent button owns that inventory slot (`ReplaceIconTexture`), arms sequence 0 at
/// 0 and shows it; the pane's clock runs the file's 1000 ms clamp; `ItemAnim_OnAnimFinished`
/// hides it. The pane authored no size, so its rect is the file's own bounding box in layout
/// units — the 42.41 × 124.72 the 0887 card measured by hand at 16:9.
#[test]
fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
use benilla_ui::widget::{ModelFileFacts, SequenceFacts};
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
@@ -1853,42 +1849,50 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
}
load_xml(&s, "ScrollTemplates.xml"); // our window tab template, before the window that inherits it (1988)
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// `ForcedBackpackItem.m2` (`m2seq`/`m2batch`): one sequence, id 0, 1000 ms, clamp; the
// header box spans the card's whole travel band — 0.02707 × 0.07962 model units, which at
// 16:9 (a layout unit is 768·√(a²+1) = 1566.4 FrameXML units) is the 42.41 × 124.72 rect
// 0887's card measured.
const CARD: &str = r"Interface\ItemAnimations\ForcedBackpackItem.mdx";
s.set_model_facts(
CARD,
ModelFileFacts {
sequences: vec![SequenceFacts {
anim_id: 0,
duration_ms: 1000,
looping: false,
}],
bbox: ([0.0, 0.0, 0.0], [0.02707, 0.07962, 0.0]),
},
);
s.resolve();
let shown = |s: &UiScript, name: &str| {
s.eval::<bool>(&format!("return {name}BenillaItemPush:IsShown()"))
s.eval::<bool>(&format!("return {name}ItemAnim:IsShown()"))
.unwrap()
};
// The card's centre relative to its button's centre, in screen px (y-up): (dx, dy).
let offset = |s: &UiScript, name: &str| -> (f32, f32) {
s.eval::<(f32, f32)>(&format!(
"local a, b = {name}BenillaItemPush, {name} \
local ax, ay = a:GetLeft() + a:GetWidth() / 2, a:GetBottom() + a:GetHeight() / 2 \
local bx, by = b:GetLeft() + b:GetWidth() / 2, b:GetBottom() + b:GetHeight() / 2 \
return ax - bx, ay - by"
))
.unwrap()
};
let size = |s: &UiScript, name: &str| {
s.eval::<f32>(&format!("return {name}BenillaItemPush:GetWidth()"))
.unwrap()
};
let alpha = |s: &UiScript, name: &str| {
s.eval::<f32>(&format!("return {name}BenillaItemPush:GetAlpha()"))
.unwrap()
};
// The card's centre relative to its button's BOTTOMRIGHT corner — the reference's OWN frame of
// reference for this widget (it anchors the `<Model>`'s BOTTOMRIGHT to the button's), and the
// only one the numbers are stable in: the card's placement depends on the anchor offset and
// the M2 bounding box, NOT on how big the button is. Measuring against the button's centre
// instead is what let 0887's placement look plausible.
let corner = |s: &UiScript, name: &str| -> (f32, f32) {
s.eval::<(f32, f32)>(&format!(
"local a, b = {name}BenillaItemPush, {name} \
local ax, ay = a:GetLeft() + a:GetWidth() / 2, a:GetBottom() + a:GetHeight() / 2 \
return ax - b:GetRight(), ay - b:GetBottom()"
))
.unwrap()
// The shown card panes, as the renderer sees them: `(icon, play head)` per visible pane.
type Card = (Option<String>, Option<(u16, u32)>);
let cards = |s: &mut UiScript| -> Vec<Card> {
let heads = s.visible_model_panes();
s.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::ModelPane {
handle,
model: Some(m),
icon,
..
} if m == CARD => Some((
icon,
heads
.iter()
.find(|p| p.handle == handle)
.and_then(|p| p.play.map(|ph| (ph.anim_id, ph.cursor_ms))),
)),
_ => None,
})
.collect()
};
// Nothing is animating until a push arrives.
@@ -1899,11 +1903,11 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
] {
assert!(!shown(&s, b), "{b}'s card starts hidden");
}
assert!(cards(&mut s).is_empty());
// A push into equipped bag 2. `arg1` is the reference's own vocabulary since 1751 window 3 —
// the button's INVENTORY-slot id, which is what `ItemAnim_OnEvent` compares against — so bag 2
// is `CharacterBag1Slot`'s 21, not the container id 2 this used to send (`ui_loot.rs`
// `push_container`, the `0x491bb5` selector).
// is `CharacterBag1Slot`'s 21.
s.fire_event(
"ITEM_PUSH",
vec![
@@ -1911,159 +1915,70 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
benilla_ui::script::ScriptValue::Str("Interface\\Icons\\INV_Misc_Bag_08".into()),
],
);
assert!(
shown(&s, "CharacterBag1Slot"),
"the card that took it plays"
);
assert!(
!shown(&s, "MainMenuBarBackpackButton") && !shown(&s, "KeyRingButton"),
"…and nobody else's"
);
s.resolve();
assert!(shown(&s, "CharacterBag1Slot"), "bag 2's card plays");
for b in [
"MainMenuBarBackpackButton",
"CharacterBag0Slot",
"KeyRingButton",
] {
assert!(!shown(&s, b), "{b} took nothing, so {b} animates nothing");
}
// The pushed icon reaches the RENDERER, not just the Lua state — exactly one quad carries it.
let drawn = |s: &UiScript, icon: &str| {
s.extract()
.iter()
.filter(
|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == icon),
)
.count()
};
let live = cards(&mut s);
assert_eq!(
drawn(&s, "Interface\\Icons\\INV_Misc_Bag_08"),
1,
"the card wears the pushed item's icon (ITEM_PUSH's arg2)"
live,
vec![(
Some(r"Interface\Icons\INV_Misc_Bag_08".to_string()),
Some((0, 0))
)],
"one pane, the pushed icon on it, sequence 0 at 0"
);
// The implicit rect: the file's box in layout units, hung off the button's BOTTOMRIGHT
// (−10, 0) as the stock template anchors it.
let (w, h): (f32, f32) = s
.eval("return CharacterBag1SlotItemAnim:GetWidth(), CharacterBag1SlotItemAnim:GetHeight()")
.unwrap();
assert!(
(w - 42.41).abs() < 0.05 && (h - 124.72).abs() < 0.05,
"the card's rect is the file's box: {w} × {h}"
);
let (dx, dy): (f32, f32) = s
.eval(
"local a, b = CharacterBag1SlotItemAnim, CharacterBag1Slot \
return a:GetRight() - b:GetRight(), a:GetBottom() - b:GetBottom()",
)
.unwrap();
assert!(
(dx + 10.0).abs() < 0.01 && dy.abs() < 0.01,
"BOTTOMRIGHT (−10, 0) off the button: ({dx}, {dy})"
);
// Every figure below is the REFERENCE's, from wow-re's §5-cross-checked
// `modelframe-implicit-size-law.md` §3 — not a ratio we chose. The three it publishes
// (t = 0, 0.133, 1.0) are asserted to a tenth of a pixel; the rest assert the SHAPE of the
// motion, which is where 0887's authored placement actually went wrong.
//
// What changed from 0887, and why the old numbers looked right: it measured against the
// button's CENTRE and drove both axes off one shared 0..1 "drop" curve, which happens to land
// within ~1 px of the truth in y for a 36 px button. In x it was wrong in DIRECTION — the card
// drifts RIGHT as it falls, and the old code had it starting left and ending centred.
// t=0: invisible, full size, parked high above the button. The .m2's alpha key (0.000, 0.0),
// scale key (0.000, 1.0), translation (0.000, (0,0)).
let (cx, cy) = corner(&s, "CharacterBag1Slot");
assert!(
(cx + 30.14).abs() < 0.1 && (cy - 68.99).abs() < 0.1,
"starts at the ref's (-30.14, 68.99) from the button's bottom-right: got ({cx}, {cy})"
);
assert!(
(size(&s, "CharacterBag1Slot") - 36.86).abs() < 0.1,
"the quad is 0.0288 model units, not the 0.03 that 0887 rounded it to: got {}",
size(&s, "CharacterBag1Slot")
);
assert!(
alpha(&s, "CharacterBag1Slot") < 0.01,
"fades in from nothing"
);
// t=0.133: fully faded in, at the 1.2x swell.
s.tick(0.133);
s.resolve();
assert!(
(alpha(&s, "CharacterBag1Slot") - 1.0).abs() < 0.02,
"opaque by the alpha track's second key"
);
assert!(
(size(&s, "CharacterBag1Slot") - 44.24).abs() < 0.1,
"swollen to the ref's 44.24 px peak: got {}",
size(&s, "CharacterBag1Slot")
);
// The pop moves the centre by a twentieth of a pixel, and that tiny drift is the tell that the
// bone's PIVOT is not the quad's centre — scaling up pushes the centre away from the pivot,
// scaling down pulls it in, one mechanism at both ends. Folding the pivot into the centre (the
// obvious simplification) loses this and misses the landing point by a fifth of a pixel.
let (px, py) = corner(&s, "CharacterBag1Slot");
assert!(
(px + 30.18).abs() < 0.02 && (py - 68.98).abs() < 0.02,
"the 1.2x pop drifts the centre off the pivot by the ref's (-0.046, -0.015): got ({px}, {py})"
);
// t=0.267: back to the icon's own size, still opaque, still parked.
s.tick(0.134);
s.resolve();
assert!(
(size(&s, "CharacterBag1Slot") - 36.86).abs() < 0.1,
"settled back at the scale track's third key"
);
let (_, cy) = corner(&s, "CharacterBag1Slot");
assert!(
(cy - 68.99).abs() < 0.1,
"has not started falling: got {cy}"
);
// t=0.5: STILL PARKED — the translation track's second key is (0,0), so the whole first half
// second is a hang. The other two tracks are already running down, though: the three curves
// keep different schedules, which is the thing a "hold everything then drop" reading would get
// wrong (scale/alpha are 0.687/0.682 here, on the 0.267→1.000 ramps).
s.tick(0.233);
s.resolve();
let (_, cy) = corner(&s, "CharacterBag1Slot");
assert!(
(cy - 69.0).abs() < 0.1,
"has not moved yet at the half second: got {cy}"
);
assert!(
(size(&s, "CharacterBag1Slot") - 25.31).abs() < 0.2,
"but is already dwindling: got {}",
size(&s, "CharacterBag1Slot")
);
assert!(
(alpha(&s, "CharacterBag1Slot") - 0.682).abs() < 0.02,
"and already fading: got {}",
alpha(&s, "CharacterBag1Slot")
);
// t=0.75: halfway through the fall — and moving RIGHT as it drops, which is the axis 0887 had
// backwards. The translation is per-axis (+0.0104, -0.0402 model units), not one shared curve.
s.tick(0.25);
s.resolve();
let (cx, cy) = corner(&s, "CharacterBag1Slot");
assert!(
(cx + 23.34).abs() < 0.2,
"drifted RIGHT by half the 13.3 px total: got {cx}"
);
assert!(
(cy - 43.33).abs() < 0.2,
"half of the 51.4 px fall travelled by t=0.75: got {cy}"
);
// Just short of the end: down on the button, shrunk to nothing, faded out.
s.tick(0.24);
s.resolve();
let (cx, cy) = corner(&s, "CharacterBag1Slot");
assert!(
(cx + 16.87).abs() < 0.2 && (cy - 18.68).abs() < 0.2,
"arrives at the ref's landing point: got ({cx}, {cy})"
);
// Which is also, within a couple of pixels, the button's own centre — the reference's card
// really does drop INTO the bag button, and that is the one thing 0887 got right for the wrong
// reason. The corner-relative assertion above is the exact one; this is the sanity check, and
// its tolerance is a button-size term, not slack: the landing point is fixed relative to the
// button's BOTTOMRIGHT, so the offset from its CENTRE moves by half of any size change. The
// bar's buttons are 37x37 now — `ItemButtonTemplate`'s own size, inherited through the
// reference's `PaperDollItemSlotButtonTemplate` — where the deleted BagFrame.xml authored 36.
let (dx, dy) = offset(&s, "CharacterBag1Slot");
assert!(
dx.abs() < 2.0 && dy.abs() < 2.0,
"lands on the button it went into: got ({dx}, {dy})"
);
assert!(
size(&s, "CharacterBag1Slot") < 2.0,
"collapsed to the scale track's 0.0144"
);
assert!(alpha(&s, "CharacterBag1Slot") < 0.05, "faded out");
// Past 1.000s the CLAMP sequence is over — the ref's OnAnimFinished → Hide.
s.tick(0.05);
// The clock runs the file's clamp: half a second in, the play head is at 500 ms…
s.tick(0.5);
assert_eq!(cards(&mut s)[0].1, Some((0, 500)));
// …and past 1000 ms the completion fires `ItemAnim_OnAnimFinished` → `Hide()`.
s.tick(0.55);
assert!(
!shown(&s, "CharacterBag1Slot"),
"one play, then gone (and no OnUpdate left running)"
"one play, then gone (the stock OnAnimFinished hides it)"
);
assert!(cards(&mut s).is_empty());
// A second push RESTARTS the card (SetSequenceTime(0, 0) on a fresh arm).
s.fire_event(
"ITEM_PUSH",
vec![
benilla_ui::script::ScriptValue::Int(21),
benilla_ui::script::ScriptValue::Str("Interface\\Icons\\INV_Misc_Bag_09".into()),
],
);
s.resolve();
assert_eq!(
cards(&mut s),
vec![(
Some(r"Interface\Icons\INV_Misc_Bag_09".to_string()),
Some((0, 0))
)]
);
// The keyring is a real destination, not a rounding of the backpack.
@@ -474,6 +474,19 @@ fn the_item_push_card_shares_the_band_with_a_raised_bar_exactly_as_the_reference
);
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();
@@ -488,15 +501,23 @@ fn the_item_push_card_shares_the_band_with_a_raised_bar_exactly_as_the_reference
s.tick(0.133); // the opaque peak — the instant the card is most visible
s.resolve();
assert!(
shown(&s, "MainMenuBarBackpackButtonBenillaItemPush"),
shown(&s, "MainMenuBarBackpackButtonItemAnim"),
"the card plays"
);
let card = rect(&s, "MainMenuBarBackpackButtonBenillaItemPush");
// 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.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:?}"
(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),
@@ -42,7 +42,7 @@ fn harness() -> UiScript {
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
@@ -36,7 +36,7 @@ fn chat_ui() -> UiScript {
"Interface\\FrameXML\\BasicControls.xml",
// `FCF_ValidateChatFramePosition` (a tab-drag stop) reads `MainMenuBar:GetHeight()`; the
// bar's own load-time chain precedes it, as in the action-bar harness.
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -34,7 +34,7 @@ fn chat_frame() -> UiScript {
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
// `FCF_ValidateChatFramePosition` (a tab-drag stop) reads `MainMenuBar:GetHeight()`; the bar's
// own load-time chain precedes it, as in the action-bar harness.
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
@@ -72,7 +72,7 @@ fn load_room(s: &UiScript) {
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
] {
load_xml(s, file);
}
@@ -121,7 +121,7 @@ fn an_error_toast_draws_over_an_open_panel_window() {
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\UIErrorsFrame.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "ScrollTemplates.xml"); // our window tab template, before the window that inherits it (1988)
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // BenillaMoney_* — QuestLogDetail's reward money row
// pane's UIPanelScrollFrameTemplate). A MISSING template is a loader *warning*, so an
@@ -22,7 +22,7 @@ fn exp_bar_harness() -> UiScript {
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
@@ -1,287 +0,0 @@
//! The Cooldown widget's pixels (decision 0137 phase 4): the radial dark wipe + the finish
//! flash, rebuilt natively from the byte-read `UI-Cooldown-Indicator.m2`. Split from [`super`]
//! purely for size — [`cooldown_quads`] is the only face, called from the extract loop's
//! `QuadContent::Cooldown` arm.
use bevy::prelude::*;
use crate::ui_pass::UiQuad;
use benilla_assets::WorldAssets;
/// The cooldown wipe's paint: flat black at α = 0x99/255 — the byte content of
/// `Interface\Cooldown\cooldown.blp` (a uniform 32² black texel field at alpha 0x99, with only a
/// transparent clamp column for the sweep edge), constant through the sweep (the model's
/// quadrant color-alpha tracks hold 1.0 for all of sequence 0 — the m2anim key dump).
const COOLDOWN_WIPE_ALPHA: f32 = 153.0 / 255.0;
/// The finish-flash star's bone-scale pulse — sequence 1's 5 keys, byte-read off
/// `UI-Cooldown-Indicator.m2` with the `m2anim` bone-scale dump (uniform XYZ, so one scalar per
/// key): grow to 1.85× with the alpha rise, then a shrinking double-bounce while it fades.
const STAR_SCALE_KEYS: [(f32, f32); 5] = [
(0.0, 1.0),
(0.333, 1.853),
(0.666, 1.305),
(0.833, 1.605),
(1.0, 1.155),
];
/// Linear sample of [`STAR_SCALE_KEYS`] at flash progress `t` (the M2 linear-track read).
fn star_scale(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
for w in STAR_SCALE_KEYS.windows(2) {
let ((t0, v0), (t1, v1)) = (w[0], w[1]);
if t <= t1 {
return v0 + (v1 - v0) * ((t - t0) / (t1 - t0));
}
}
STAR_SCALE_KEYS[4].1
}
/// The Cooldown widget's pixels (decision 0137 phase 4): the radial dark wipe + the finish flash,
/// rebuilt natively from the byte-read `UI-Cooldown-Indicator.m2`.
///
/// **The wipe.** The model draws 4 quadrant quads whose UVs rotate over a clamp-edged
/// half-transparent texture — 2001's way of sweeping a straight edge through each quadrant; with
/// a *flat* texture behind it (`cooldown.blp` is uniform black at α 0x99), the composed result is
/// exactly a uniform dark pie whose bright sector grows **clockwise from 12 o'clock** as the
/// cooldown elapses. Rebuilt as ≤4 quads: one full-dark rect per still-covered quadrant, plus
/// the active quadrant's **wedge** — an exact convex fan (`c → ray exit → [outer corner] →
/// quadrant-end edge midpoint`) through [`UiQuad::corners`]. Pixel-equivalent, no scissor
/// interplay. (The wedge bypasses the scroll clip — no cooldown sits in a ScrollFrame; the
/// full-dark rects still carry it.)
///
/// **The flash.** The model's sequence 1 (authored 1.000 s): the additive `star4` burst whose
/// alpha is the byte-read texture-weight ramp — linear 0→1 over the first third, hold 1 to the
/// half, linear 1→0 over the back half — scaled by the star bone's byte-read 5-key pulse
/// ([`STAR_SCALE_KEYS`], the `m2anim` bone-scale dump): grow to 1.85× with the alpha rise, then
/// a shrinking double-bounce as it fades.
#[allow(clippy::too_many_arguments)] // one draw arm's full input set
pub(super) fn cooldown_quads(
rect: Rect,
z_key: u64,
frame_alpha: f32,
fraction: f32,
flash: Option<f32>,
clip: Option<Rect>,
assets: &mut Option<ResMut<WorldAssets>>,
images: &mut Assets<Image>,
out: &mut Vec<UiQuad>,
) {
let c = rect.center();
let dark = [0.0, 0.0, 0.0, COOLDOWN_WIPE_ALPHA * frame_alpha];
if let Some(progress) = flash {
// The finish flash: the byte-read weight ramp (keys 0→1 over 0..⅓, hold to ½, →0 at 1).
let ramp = if progress < 1.0 / 3.0 {
progress * 3.0
} else if progress < 0.5 {
1.0
} else {
(1.0 - progress) * 2.0
};
let handle = assets
.as_mut()
.and_then(|a| a.sprite_texture("Interface\\Cooldown\\star4", images));
if let Some(handle) = handle {
let s = star_scale(progress);
let half = rect.half_size() * s;
out.push(UiQuad {
rect: Rect::from_center_half_size(c, half),
z_key,
texture: Some(handle),
color: [1.0, 1.0, 1.0, ramp * frame_alpha],
additive: true,
clip,
..default()
});
}
return;
}
// The sweep: bright sector [0, θ) clockwise from 12 o'clock; quadrants wholly past the edge
// are full-dark rects, the active one carries the exact wedge.
let theta = fraction.clamp(0.0, 1.0) * std::f32::consts::TAU;
let active = ((theta / std::f32::consts::FRAC_PI_2) as usize).min(3);
// Clockwise from 12 in y-down screen space: top-right, bottom-right, bottom-left, top-left.
let quadrant = |k: usize| match k {
0 => Rect::new(c.x, rect.min.y, rect.max.x, c.y),
1 => Rect::new(c.x, c.y, rect.max.x, rect.max.y),
2 => Rect::new(rect.min.x, c.y, c.x, rect.max.y),
_ => Rect::new(rect.min.x, rect.min.y, c.x, c.y),
};
for k in (active + 1)..4 {
out.push(UiQuad {
rect: quadrant(k),
z_key,
color: dark,
clip,
..default()
});
}
// The active quadrant's wedge. The sweep ray from c at angle θ has direction
// d(θ) = (sin θ, −cos θ) (y-down); per quadrant it can exit through two edges — the "first"
// one (adjacent to the sweep's entry axis, whose crossing keeps the quadrant's outer corner
// dark) or the "second" (past the corner). Vertices fan from c, clockwise:
// c → E (ray exit) → [K (outer corner) if the ray exits the first edge] → M (the
// quadrant-end axis point). A three-vertex wedge repeats M (the fan's degenerate second
// triangle draws nothing).
let d = Vec2::new(theta.sin(), -theta.cos());
// Per active quadrant: (first-edge t, second-edge t, K, M). A division by ±0 yields ±inf,
// which the min-pick handles (the boundary angles resolve to the finite edge).
let (t_first, t_second, k_corner, m_point) = match active {
0 => (
(rect.min.y - c.y) / d.y,
(rect.max.x - c.x) / d.x,
Vec2::new(rect.max.x, rect.min.y),
Vec2::new(rect.max.x, c.y),
),
1 => (
(rect.max.x - c.x) / d.x,
(rect.max.y - c.y) / d.y,
Vec2::new(rect.max.x, rect.max.y),
Vec2::new(c.x, rect.max.y),
),
2 => (
(rect.max.y - c.y) / d.y,
(rect.min.x - c.x) / d.x,
Vec2::new(rect.min.x, rect.max.y),
Vec2::new(rect.min.x, c.y),
),
_ => (
(rect.min.x - c.x) / d.x,
(rect.min.y - c.y) / d.y,
Vec2::new(rect.min.x, rect.min.y),
Vec2::new(c.x, rect.min.y),
),
};
let e_point = c + d * t_first.min(t_second);
let corners = if t_first <= t_second {
[c, e_point, k_corner, m_point]
} else {
[c, e_point, m_point, m_point]
};
out.push(UiQuad {
rect: quadrant(active),
z_key,
color: dark,
corners: Some(corners),
..default()
});
}
#[cfg(test)]
mod cooldown_quad_tests {
use super::{cooldown_quads, star_scale, STAR_SCALE_KEYS};
use bevy::prelude::*;
/// Emit the sweep for `fraction` on a unit-friendly 40×40 button at (0,0)..(40,40).
fn sweep(fraction: f32) -> Vec<crate::ui_pass::UiQuad> {
let mut out = Vec::new();
cooldown_quads(
Rect::new(0.0, 0.0, 40.0, 40.0),
7,
1.0,
fraction,
None,
None,
&mut None,
&mut Assets::<Image>::default(),
&mut out,
);
out
}
/// Total dark coverage of the emitted quads (rect quads + the corner wedge's shoelace area).
fn dark_area(quads: &[crate::ui_pass::UiQuad]) -> f32 {
quads
.iter()
.map(|q| match q.corners {
None => q.rect.width() * q.rect.height(),
Some(c) => {
// Shoelace over the fan's two triangles (0,1,2) + (0,2,3).
let tri = |a: Vec2, b: Vec2, d: Vec2| ((b - a).perp_dot(d - a) / 2.0).abs();
tri(c[0], c[1], c[2]) + tri(c[0], c[2], c[3])
}
})
.sum()
}
/// The square-cornered pie's exact per-phase geometry (a square pie's area is not linear in
/// the swept angle, so the checkpoints below are the analytically known shapes: full, the
/// 45° corner ray, the 180° half, the end sliver).
#[test]
fn cooldown_sweep_geometry_is_the_exact_square_pie() {
// Fraction 0: everything dark — 3 full quadrant rects + a wedge covering the fourth.
let q0 = sweep(0.0);
assert_eq!(q0.len(), 4);
assert!(
(dark_area(&q0) - 1600.0).abs() < 1e-2,
"fully dark at start"
);
// Fraction 1/8 (45° — the ray through quadrant 0's outer corner): the bright half of
// quadrant 0 is gone; the wedge is the triangle {center, corner, right-edge midpoint}.
let q = sweep(0.125);
assert_eq!(q.len(), 4);
assert!(
(dark_area(&q) - (1600.0 - 200.0)).abs() < 1.0,
"45°: half of one 400px² quadrant is bright, got {}",
dark_area(&q)
);
// Fraction 0.5 (180°): the right half is bright — two full-dark quadrants + a wedge
// that spans exactly quadrant 2 (t ties pick the corner-inclusive fan; area 400).
let q = sweep(0.5);
assert!((dark_area(&q) - 800.0).abs() < 1.0, "half bright at 180°");
// Fraction ~1: a sliver — near-zero dark area, no full quadrants left.
let q = sweep(0.999);
assert!(dark_area(&q) < 20.0, "almost done — a sliver remains");
assert_eq!(q.len(), 1, "only the active quadrant's wedge remains");
// The wedge always fans from the button center.
for f in [0.05, 0.3, 0.6, 0.9] {
let q = sweep(f);
let wedge = q
.iter()
.find_map(|u| u.corners)
.expect("a wedge each phase");
assert_eq!(wedge[0], Vec2::new(20.0, 20.0), "fan apex at the center");
}
}
/// The star pulse samples its byte-read keys exactly and lerps between them — the ⅓ peak
/// coincides with the alpha ramp's own peak.
#[test]
fn star_scale_follows_the_five_key_curve() {
for (t, v) in STAR_SCALE_KEYS {
assert!((star_scale(t) - v).abs() < 1e-4, "key at {t}");
}
// Midway up the rise: halfway between 1.0 and 1.853.
let mid = star_scale(0.333 / 2.0);
assert!((mid - (1.0 + 1.853) / 2.0).abs() < 1e-2, "got {mid}");
// Clamped outside the band.
assert!((star_scale(-1.0) - 1.0).abs() < 1e-4);
assert!((star_scale(2.0) - 1.155).abs() < 1e-4);
}
/// The flash phase draws no dark pie — only the additive star (skipped here: no assets in a
/// unit test — the pie's absence is the machine-checkable half).
#[test]
fn cooldown_flash_phase_emits_no_dark_pie() {
let mut out = Vec::new();
cooldown_quads(
Rect::new(0.0, 0.0, 40.0, 40.0),
7,
1.0,
1.2,
Some(0.4),
None,
&mut None,
&mut Assets::<Image>::default(),
&mut out,
);
assert!(out.is_empty(), "no assets → no star; and never a dark quad");
}
}
@@ -16,9 +16,7 @@ use crate::ui_text::UiFontAtlas;
use benilla_assets::WorldAssets;
mod colorselect;
mod cooldown;
mod text;
use cooldown::cooldown_quads;
/// `WOW_UI_COST=1` — the untraced per-frame cost meter for this system's phases (the premise
/// instrument for the UI epoch-gate lane, 0730's warm slice): one `[ui-cost]` line per frame with
@@ -94,7 +92,6 @@ fn report_ui_pick(eq: &benilla_ui::script::ExtractedQuad, rect: Rect, at: Vec2,
let what = match &eq.content {
QuadContent::Frame => "frame-slot".to_string(),
QuadContent::Minimap { .. } => "minimap".to_string(),
QuadContent::Cooldown { .. } => "cooldown".to_string(),
QuadContent::Texture {
path,
color,
@@ -118,7 +115,6 @@ fn content_kind(c: &QuadContent) -> &'static str {
match c {
QuadContent::Frame => "frame",
QuadContent::Minimap { .. } => "minimap",
QuadContent::Cooldown { .. } => "cooldown",
QuadContent::ModelPane { .. } => "modelpane",
QuadContent::Texture { .. } => "texture",
QuadContent::ColorWheel => "colorwheel",
@@ -354,7 +350,6 @@ fn span_bounds(spans: &[u32], i: usize) -> (usize, usize) {
fn splice_simple(eq: &benilla_ui::script::ExtractedQuad) -> bool {
match &eq.content {
QuadContent::Frame
| QuadContent::Cooldown { .. }
| QuadContent::Backdrop { .. }
// A model pane writes at most one quad and one idempotent tile request (decision 2008)
// — and the map's arrow changes facing on every turn while the map is open.
@@ -1310,14 +1305,6 @@ fn convert_entry(
alpha: eq.alpha,
});
}
// The Cooldown widget's pie wipe + finish flash (decision 0137 phase 4) — the
// byte-pinned look of `UI-Cooldown-Indicator.m2`, rebuilt natively (see
// [`cooldown_quads`]).
QuadContent::Cooldown { fraction, flash } => {
cooldown_quads(
rect, eq.z, eq.alpha, fraction, flash, clip, assets, images, out,
);
}
// A `<Model>`/`<PlayerModel>` pane's content: the off-screen body bake its window keeps,
// sampled square edge to edge. The pane→booth join is
// [`crate::portrait::model_pane_booth`], whose doc says why it is a table of frame names.
@@ -1328,8 +1315,9 @@ fn convert_entry(
// own file, which declares a bare `<PlayerModel>` and no Texture at all — so the widget
// itself has to draw, or the character sheet's paper doll is an empty rectangle.
//
// A pane with no name, or one no window claims, draws nothing (`SetModel` panes included:
// this engine holds their scene and renders no M2 into it). Nothing is stubbed white.
// A FILE pane is a tile (2013; the map arrow among them since 2015); a UNIT pane joins
// the booth its window keeps. A pane with no name, or one no window claims, draws
// nothing. Nothing is stubbed white.
QuadContent::ModelPane {
handle,
name,
@@ -1341,44 +1329,6 @@ fn convert_entry(
icon,
} => {
use crate::portrait::PortraitSource;
// The world-map arrow (decision 1980): the stock `WorldMapFrame.lua` /
// `Blizzard_BattlefieldMinimap.lua` create an anonymous `Model` child, `SetModel` it to
// the minimap arrow, and steer it with `SetFacing`/`SetPosition` every update. benilla
// draws that one file as the minimap's own arrow sprite — the same art the minimap
// ring draws (`crate::minimap::blips`), turned the same way (`-facing`: the M2's yaw
// is CCW-positive, the screen's is CW), filling the pane the engine sized to the
// reference's 33.6 px footprint times the arrow's `SetModelScale`. No 3D pane needs
// to exist for a two-triangle arrow.
if model.as_deref() == Some(benilla_ui::script::ARROW_MODEL) {
use crate::minimap::blips::{
PLAYER_ARROW_OFFSET_PX, PLAYER_ARROW_QUAD_PX, PLAYER_ARROW_TEXTURE,
};
let Some(handle) = assets
.as_mut()
.and_then(|a| a.sprite_texture(PLAYER_ARROW_TEXTURE, images))
else {
return;
};
// The M2's single quad sits off its origin by an authored offset that turns with
// the facing — the minimap's arrow applies the same one at its 33.6 px basis, and
// the pane the engine sized (`worldmap_arrow.rs`: the footprint times the
// `SetModelScale`) scales it here. `model_scale` is already in the rect.
let rotation = -facing;
let (sin, cos) = rotation.sin_cos();
let off = PLAYER_ARROW_OFFSET_PX * (rect.width() / PLAYER_ARROW_QUAD_PX);
let off = Vec2::new(off.x * cos - off.y * sin, off.x * sin + off.y * cos);
let rect = Rect::from_center_size(rect.center() + off, rect.size());
out.push(UiQuad {
rect,
z_key: eq.z,
texture: Some(handle),
color: [1.0, 1.0, 1.0, eq.alpha],
rotation,
clip,
..default()
});
return;
}
// A FILE pane (decision 2008): publish what the tile renderer needs — the pane's
// device-pixel size and the render law's unit ladder off it — and draw the tile's
// atlas cell when the renderer has one. The request is idempotent, so the memoized
@@ -329,11 +329,11 @@ fn dragging_the_bar_steps_the_offset_by_rows_and_repaints() {
/// on `FauxScrollFrameTemplate` whose `<OnVerticalScroll>` calls `FauxScrollFrame_OnVerticalScroll`,
/// driven by a drag of the shared bar.
///
/// The `SetScrollChild` line is the gap, made visible: the reference's template declares that child
/// as `<ScrollChild><Frame name="$parentScrollChildFrame">`, our XML loader has no `<ScrollChild>`
/// element, and `SetVerticalScroll` clamps into `[0, GetVerticalScrollRange()]` — which is computed
/// from that child. Without it the range is 0, the handler fires with `arg1 = 0` and an addon's list
/// never leaves the top. With it, the whole reference path runs.
/// The `SetScrollChild` line predates 1205 (the loader's `<ScrollChild>`) and stays as the
/// addon-shaped way of seating a child by hand; the range it asserts is that child's overflow
/// (1338). The offset itself never depended on the range — the engine stores what the bar hands it
/// (decision 2017) — so what this drives is bar value → `SetVerticalScroll` → `<OnVerticalScroll>`
/// → `FauxScrollFrame_OnVerticalScroll`'s `floor(v / step + 0.5)`, the reference's path end to end.
#[test]
fn the_reference_on_vertical_scroll_path_runs_once_a_scroll_child_exists() {
let mut s = harness();
@@ -101,7 +101,6 @@ const FRAME_TAGS: &[&str] = &[
"Minimap",
"MovieFrame",
"WorldFrame",
"Cooldown",
];
/// The five `<Scripts>` handler names that auto-enable the MOUSE kind, and only those five
@@ -672,3 +672,70 @@ fn selecting_a_row_reads_back_in_the_same_tick() {
.take_social_requests()
.contains(&SocialRequest::SelectFriend(2)));
}
/// **B363 — the who list reaches its last rows.** Liho's `/who` found 49, showed 17, and the knob
/// travelled while the rows stayed. On the stock window the mechanism is a one-row loss:
/// `WhoListScrollFrame` is 287 tall (stock `FriendsFrame.xml` l.1661) against seventeen rows of
/// sixteen, so the child's overflow past the frame, `n × 16 − 287`, sits fifteen pixels under the
/// bar's `(n − 17) × 16`, and an engine that clamped `SetVerticalScroll` into that overflow
/// stopped the row offset at `n − 18`. The reference stores the bar's value as given (decision
/// 2017). Drives the bar to its end and reads the seventeenth row: the forty-ninth name.
#[test]
fn the_who_list_reaches_its_last_row_at_the_bars_end() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.run("ShowWhoPanel()").unwrap();
let who: Vec<WhoInfo> = (1..=49)
.map(|i| WhoInfo {
name: format!("Who{i:02}"),
guild: String::new(),
level: 60,
race: "Human".to_string(),
class: "Warrior".to_string(),
zone: "Elwynn Forest".to_string(),
})
.collect();
push(
&mut s,
SocialState {
who,
who_total: 49,
..Default::default()
},
"WHO_LIST_UPDATE",
);
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The reference's own numbers, and the control: the overflow is shorter than the bar.
let (_, bar_max) = s
.eval::<(f64, f64)>("return WhoListScrollFrameScrollBar:GetMinMaxValues()")
.unwrap();
assert_eq!(bar_max, 512.0, "(49 − 17) × 16");
let overflow = s
.eval::<f64>("return WhoListScrollFrame:GetVerticalScrollRange()")
.unwrap();
assert_eq!(
overflow, 497.0,
"49 × 16 − 287: the frame is taller than its rows"
);
s.run("WhoListScrollFrameScrollBar:SetValue(512)").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<i64>("return FauxScrollFrame_GetOffset(WhoListScrollFrame)")
.unwrap(),
32
);
assert_eq!(
s.eval::<String>("return WhoFrameButton1Name:GetText()")
.unwrap(),
"Who33"
);
assert_eq!(
s.eval::<String>("return WhoFrameButton17Name:GetText()")
.unwrap(),
"Who49",
"the last hit is on the last row"
);
}
@@ -582,7 +582,7 @@ fn the_world_map_cannot_open_behind_the_menu_and_gives_its_slot_back() {
r"Interface\FrameXML\UIPanelTemplates.xml",
// The stock map's OnShow/OnHide call `UpdateMicroButtons` unguarded (1980), which the
// micro menu defines over the action bar it sits in — the spellbook kit's cluster.
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -643,7 +643,7 @@ fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
r"Interface\FrameXML\UIPanelTemplates.xml",
// The action-bar cluster the micro menu sits in — the stock map's OnShow/OnHide call
// `UpdateMicroButtons` unguarded (1980).
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -704,7 +704,7 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
// BagFrame.xml precisely because most harnesses load no bar).
let mut s = bag_harness_with(
&[
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -83,7 +83,7 @@ fn setup() -> UiScript {
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml"); // PASS/NEED/GREED + item hovers
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
@@ -39,7 +39,7 @@ fn setup() -> UiScript {
// The stock file's `HelpFrame_OnShow` calls `UpdateMicroButtons()` before
// `GetGMStatus()`, so without the micro row the OnShow raises and the status ask never
// happens. Ours never called it. The row needs the bar's button kit under it.
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -32,7 +32,7 @@ fn harness() -> UiScript {
s.set_screen_size(1024.0, 768.0);
for file in [
"Interface\\FrameXML\\Fonts.xml",
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -23,7 +23,7 @@ fn load_action_bar(s: &UiScript) {
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -276,7 +276,7 @@ fn shipped_stance_bar_drives_end_to_end() {
// bottom multibar is UP to raise the flag. Since 1500 that is a player option rather than a
// given, so this test raises it; `the_stance_bar_sits_where_the_pass_puts_it` below owns the
// other state.
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
@@ -465,7 +465,7 @@ fn multibar_hover_renders_the_buttons_own_action() {
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -579,7 +579,7 @@ fn multibar_hover_renders_the_buttons_own_action() {
fn the_vertical_multibars_exist_hidden_on_the_reference_pages() {
let s = UiScript::new().unwrap();
load_action_bar(&s);
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
@@ -641,7 +641,7 @@ fn every_extra_bar_stays_down_until_its_own_toggle_is_set() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
@@ -728,7 +728,7 @@ fn raising_a_bottom_bar_moves_the_managed_bottom_stack() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
@@ -792,7 +792,7 @@ fn viewable_action_bar_pages_follow_the_bar_toggles() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
@@ -858,7 +858,7 @@ fn the_grid_option_holds_the_extra_bars_empty_wells_open() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
@@ -961,7 +961,7 @@ fn a_held_payload_ghosts_the_empty_wells_it_opens() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
@@ -1104,7 +1104,7 @@ fn a_bar_toggle_sends_the_byte_its_globals_pack_to() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
@@ -1191,7 +1191,7 @@ fn the_shipped_setter_passes_exactly_four_arguments() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
@@ -1243,7 +1243,7 @@ fn the_stance_bar_sits_where_the_pass_puts_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
@@ -1257,7 +1257,7 @@ fn the_stance_bar_sits_where_the_pass_puts_it() {
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
s.set_shapeshift_forms(vec![ShapeshiftFormView {
spell_id: 2457,
@@ -1315,7 +1315,7 @@ fn the_stance_shelf_follows_the_bottom_left_bar() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
@@ -1329,7 +1329,7 @@ fn the_stance_shelf_follows_the_bottom_left_bar() {
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
let form = |id: u32| ShapeshiftFormView {
@@ -1436,7 +1436,7 @@ fn the_stance_shelf_is_as_long_as_the_form_count() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
@@ -1450,7 +1450,7 @@ fn the_stance_shelf_is_as_long_as_the_form_count() {
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
show_bars(&s, &[]); // unraised: the shelf art is the state under test
@@ -1553,7 +1553,7 @@ fn an_extra_bars_empty_well_keeps_its_bound_hotkey_label() {
// the player binds it, as the reporter had).
s.register_bindings(&crate::bindings::registry_commands());
load_action_bar(&s);
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
@@ -775,7 +775,7 @@ fn interface_harness() -> UiScript {
// VARIABLES_LOADED fires the slider exists. A test that fires the event has to have
// loaded it too.
"Interface\\FrameXML\\ColorPickerFrame.xml",
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
@@ -850,7 +850,7 @@ fn actionbars_harness() -> UiScript {
&[
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -850,7 +850,7 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
@@ -15,7 +15,7 @@ fn harness(extra: &[&str]) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
@@ -19,7 +19,7 @@ pub(super) fn load_pet_bar(s: &UiScript) {
for file in [
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -1225,7 +1225,7 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not
s.resolve();
answer_measures(&mut s);
s.resolve(); // the measured span reaches the scroll range, and the bar, on this pass
s.resolve(); // GetVerticalScrollRange (SetVerticalScroll's clamp) reads resolved rects.
s.resolve(); // GetVerticalScrollRange (the bar's [min, max]) reads resolved rects.
// Scroll THROUGH the bar, as the wheel and the thumb do (ScrollFrameTemplate_OnMouseWheel sets
// the bar; its OnValueChanged moves the frame): a reselect's `ScrollBar:SetValue(0)` is what
@@ -760,7 +760,7 @@ mod tests {
"ScrollFrame",
"Slider", "ColorSelect", "MessageFrame", "ScrollingMessageFrame",
"SimpleHTML", "Model", "PlayerModel", "DressUpModel", "TabardModel",
"Minimap", "MovieFrame", "Cooldown",
"Minimap", "MovieFrame",
}}
for i = 1, table.getn(types) do
local ok, w = pcall(function()
@@ -1,5 +1,5 @@
//! The shipped **Skills tab** (`assets/ui/SkillFrame.xml`) driven end-to-end, engine-only (no
//! Bevy) — the per-window test module the spellbook/trainer/bank files already establish, split
//! The stock **Skills tab** (`Interface\FrameXML\SkillFrame.xml`, off the player's chain since
//! 1956) driven end-to-end, engine-only (no Bevy) — the per-window test module the spellbook/trainer/bank files already establish, split
//! out of `character_tests.rs` (which owns the paperdoll + the tab round-trip) so the skills-pane
//! paint law has a home of its own.
//!
@@ -12,10 +12,9 @@
//! hunter's `Beast Mastery` on vmangos arrives as `300/300` and must still read gray and
//! numberless, exactly as it does in the real client.
//!
//! **The window around the page is the reference's own since 1751** — `CharacterFrame.xml` and
//! `PaperDollFrame.xml` off the player's chain — so every test that opens the page loads
//! [`super::test_ui::CHARACTER_UI`] and opens with `wow_data_or_skip!()`. The page itself
//! (`assets/ui/SkillFrame.xml`) is still ours.
//! **The whole window is the reference's own** — `CharacterFrame.xml` and `PaperDollFrame.xml`
//! since 1751, the page itself since 1956 — so every test that opens the page loads
//! [`super::test_ui::CHARACTER_UI`] and opens with `wow_data_or_skip!()`.
use benilla_ui::script::{QuadContent, SkillEntry, SkillsState, UiScript, UnitState};
@@ -506,3 +505,137 @@ fn the_expand_tab_fits_its_label_at_load() {
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// **B370 — the list reaches its last rows.** MarcusAga's Skills tab stopped three rows short of
/// the end: the knob mid-track, Maces the last row shown. The reference's own
/// `FauxScrollFrame_Update` sizes the bar to `(n − 12) × 15` and the scroll child to `n × 15`, and
/// `SkillListScrollFrame` is 220 tall (stock `SkillFrame.xml` l.468) against twelve rows of
/// fifteen — so the child's overflow past the frame, `n × 15 − 220`, is forty pixels short of
/// where the bar goes. An engine that clamped `SetVerticalScroll` into that overflow stopped the
/// row offset at `n − 15`; the reference stores the bar's value as given (decision 2017).
///
/// Drives the bar to its end and reads the twelfth row: the block's last line. The control is the
/// pre-fix mechanism itself — the overflow really is shorter than the bar's range.
#[test]
fn the_list_reaches_its_last_row_at_the_bars_end() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&s, f);
}
s.set_unit(
"player",
Some(UnitState {
exists: true,
level: 60,
race: Some("Human".into()),
race_file: Some("Human".into()),
class: Some("Warrior".into()),
class_file: Some("WARRIOR".into()),
..UnitState::default()
}),
);
// A warrior's kind of block — twenty-two lines under three headers, twenty-five rows: more than
// twelve by more than the frame's slack, so the tail is only reachable past the overflow.
let mut entries = Vec::new();
for (i, name) in [
"Axes",
"Bows",
"Crossbows",
"Daggers",
"Defense",
"Guns",
"Maces",
"Polearms",
"Staves",
"Swords",
"Thrown",
"Two-Handed Axes",
"Two-Handed Maces",
"Two-Handed Swords",
"Unarmed",
]
.iter()
.enumerate()
{
entries.push(skill(
100 + i as u32,
name,
300,
300,
false,
(6, "Weapon Skills", 5),
));
}
for (i, name) in ["Cloth", "Leather", "Mail", "Plate", "Shield"]
.iter()
.enumerate()
{
entries.push(skill(
400 + i as u32,
name,
1,
1,
false,
(8, "Armor Proficiencies", 6),
));
}
for (i, name) in ["Language: Common", "Language: Dwarven"].iter().enumerate() {
entries.push(skill(
500 + i as u32,
name,
300,
300,
false,
(10, "Languages", 8),
));
}
s.set_skills(SkillsState { entries });
s.run(r#"ToggleCharacter("SkillFrame")"#).unwrap();
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let n = s.eval::<i64>("return GetNumSkillLines()").unwrap();
assert_eq!(n, 25, "22 lines under 3 headers");
let (last_name, last_is_header) = s
.eval::<(String, Option<i64>)>(&format!("local n, h = GetSkillLineInfo({n}) return n, h"))
.unwrap();
assert!(
last_is_header.is_none(),
"the tail row is a line, read off SkillRankFrame12"
);
// The reference's own numbers: the bar runs to (n − 12) × 15, the child's overflow past the
// 220-tall frame is 40 px less — the control that the reported mechanism is the real one.
let (_, bar_max) = s
.eval::<(f64, f64)>("return SkillListScrollFrameScrollBar:GetMinMaxValues()")
.unwrap();
assert_eq!(bar_max, f64::from((n as i32 - 12) * 15));
let overflow = s
.eval::<f64>("return SkillListScrollFrame:GetVerticalScrollRange()")
.unwrap();
assert_eq!(overflow, f64::from(n as i32 * 15 - 220));
assert!(
overflow < bar_max,
"the frame is taller than its twelve rows"
);
// The knob dragged to the end: bar → SetVerticalScroll → <OnVerticalScroll> →
// FauxScrollFrame_OnVerticalScroll → SkillFrame_UpdateSkills.
s.run("SkillListScrollFrameScrollBar:SetValue(select(2, SkillListScrollFrameScrollBar:GetMinMaxValues()))")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<i64>("return FauxScrollFrame_GetOffset(SkillListScrollFrame)")
.unwrap(),
n - 12,
"the row offset reaches the bar's end, not the overflow's"
);
assert_eq!(
s.eval::<String>("return SkillRankFrame12SkillName:GetText()")
.unwrap(),
last_name,
"the twelfth row is the block's last line"
);
}
@@ -90,7 +90,7 @@ pub(super) fn spellbook_ui(w: f32, h: f32) -> UiScript {
for f in [
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -268,23 +268,19 @@ fn shipped_spellbook_shows_the_cooldown_pie() {
b.slots[0].cooldown = Some((6_000, 10_000, true));
s.set_spellbook(b);
s.fire_event("SPELL_UPDATE_COOLDOWN", vec![]);
// The stock machine (decision 2019): sequence 0 armed by `CooldownFrame_SetTimer`, scrubbed
// by the next paint's `OnUpdateModel`.
super::test_ui::cooldown_facts(&mut s);
s.tick(0.0);
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let sweep = s.extract().into_iter().find_map(|q| {
if s.quad_owner_name(q.target).as_deref() != Some("SpellButton1Cooldown") {
return None;
}
match q.content {
QuadContent::Cooldown { fraction, flash } => Some((fraction, flash)),
_ => None,
}
});
let (fraction, flash) = sweep.expect("SpellButton1's Cooldown widget is showing");
assert!(
(fraction - 0.4).abs() < 1e-3,
"4 s elapsed of 10 ⇒ the sweep sits at 40%, got {fraction}"
let play = super::test_ui::cooldown_play(&s, "SpellButton1Cooldown")
.expect("SpellButton1's cooldown pane is showing");
assert_eq!(
play,
(0, 400),
"4 s elapsed of 10 ⇒ the sweep sits at 40 %: sequence 0 at 400 ms"
);
assert_eq!(flash, None);
// An on-hold triple: no sweep (CooldownFrame_SetTimer's enable gate), the icon dims to 40%.
let mut b = book();
@@ -706,7 +702,7 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
"Interface\\FrameXML\\StaticPopup.xml",
// `ShowMacroFrame` lives here since 1848.
"Interface\\FrameXML\\GameTooltip.xml",
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
// **ScrollTemplates BEFORE UIPanelTemplates, which is the manifest's own order.** Ours
// still carries dead `FauxScrollFrame_*` copies that the chain overrides by loading after
// (1846's step 3, deliberately not done); load them the other way round and OUR copies win
@@ -24,7 +24,7 @@ fn harness() -> UiScript {
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml"); // TOOLTIP_DEFAULT_* (the dropdown kit's MenuBackdrop)
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); // the unit popups' kit (TargetFrameDropDown's template)
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
+70 -7
View File
@@ -16,7 +16,7 @@
//! catch a mistake in a file somebody just edited, and `content::read`'s dev-build probe already
//! prefers disk for the same reason.
use benilla_ui::script::UiScript;
use benilla_ui::script::{QuadContent, UiScript};
/// Load one interface file into `s`, panicking on any loader error, and return how many frames it
/// materialized (`0` for a `.lua` entry, which materializes none).
@@ -31,6 +31,70 @@ pub(crate) fn load_ui(s: &UiScript, entry: &str) -> usize {
load_entry(s, entry, false, false)
}
/// The cooldown indicator's file: `Interface\Cooldown\UI-Cooldown-Indicator.mdx`, as the
/// stock `CooldownFrameTemplate` names it.
pub(super) const COOLDOWN_MODEL: &str = r"Interface\Cooldown\UI-Cooldown-Indicator.mdx";
/// The cooldown indicator's file facts (`benilla-extract m2seq`): sequence 0 = id 0, 1000 ms,
/// clamp — the sweep `CooldownFrame_OnUpdateModel` scrubs; sequence 1 = id 1, 1000 ms, clamp —
/// the finish flash, whose completion hides the frame. Handed to the engine the way the app does
/// once the asset lands (decisions 2007/2019); every pane holding the file arms its Stand.
pub(super) fn cooldown_facts(s: &mut UiScript) {
use benilla_ui::widget::{ModelFileFacts, SequenceFacts};
let seq = |anim_id, duration_ms| SequenceFacts {
anim_id,
duration_ms,
looping: false,
};
s.set_model_facts(
COOLDOWN_MODEL,
ModelFileFacts {
sequences: vec![seq(0, 1000), seq(1, 1000)],
bbox: ([0.0; 3], [0.0; 3]),
},
);
}
/// The play head `(anim_id, cursor_ms)` of the SHOWN cooldown pane `owner` names (`None` while
/// it is hidden, or has nothing armed) — what the tile renderer samples the file at. Read the way
/// the renderer reads it: the pane's quad in the extract joined to the engine's paint list.
pub(super) fn cooldown_play(s: &UiScript, owner: &str) -> Option<(u16, u32)> {
let heads = s.visible_model_panes();
s.extract().into_iter().find_map(|q| match &q.content {
QuadContent::ModelPane {
handle,
model: Some(m),
..
} if m.eq_ignore_ascii_case(COOLDOWN_MODEL)
&& s.quad_owner_name(q.target).as_deref() == Some(owner) =>
{
heads
.iter()
.find(|p| p.handle == *handle)
.and_then(|p| p.play.map(|ph| (ph.anim_id, ph.cursor_ms)))
}
_ => None,
})
}
/// [`cooldown_play`] for whichever cooldown pane is shown — the bag windows number their slots
/// from the far end (`ContainerFrame_GenerateFrame`: `Item{j}` carries id `size − j + 1`), so a
/// test that seats one item asks for "the" sweep rather than a name.
pub(super) fn cooldown_play_any(s: &UiScript) -> Option<(u16, u32)> {
let heads = s.visible_model_panes();
s.extract().into_iter().find_map(|q| match &q.content {
QuadContent::ModelPane {
handle,
model: Some(m),
..
} if m.eq_ignore_ascii_case(COOLDOWN_MODEL) => heads
.iter()
.find(|p| p.handle == *handle)
.and_then(|p| p.play.map(|ph| (ph.anim_id, ph.cursor_ms))),
_ => None,
})
}
/// [`load_ui`], and **a missing template is a failure too**.
///
/// A frame that inherits a template nothing declares is a loader *warning*, not an error: the frame
@@ -404,7 +468,7 @@ pub(super) const CHARACTER_UI: &[&str] = &[
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml", // Model_OnLoad/_Rotate*/_OnUpdate — the model panes' turntable
"Interface\\FrameXML\\GameTooltip.xml",
"Cooldown.xml", // CooldownFrameTemplate + CooldownFrame_SetTimer, per equipment slot
"Interface\\FrameXML\\Cooldown.xml", // CooldownFrameTemplate + CooldownFrame_SetTimer, per equipment slot
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\StaticPopup.xml",
@@ -470,7 +534,7 @@ pub(super) const SOCIAL_UI: &[&str] = &[
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\BasicControls.xml",
r"Interface\FrameXML\UIParent.xml",
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -540,7 +604,7 @@ pub(super) const BAG_UI: &[&str] = &[
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\GameTooltip.xml",
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
// The bag BAR declares `parent="MainMenuBarArtFrame"`, resolved at LOAD — so without this the
// six buttons fall back to UIParent and sit at a level no production run ever puts them at.
// It also carries `MainMenuBar_UpdateKeyRing`, which is what puts the keyring on the bar.
@@ -588,9 +652,8 @@ pub(super) const BAG_UI: &[&str] = &[
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
// Our adapters over the reference's container files — the keyring tooltip wrapper, the three
// bag verbs 0561 shadows (`OpenBackpack`/`CloseBackpack`/`CloseAllBags`), and the item-push
// card the reference draws with a `<Model>` this engine does not render (0887). It has to be
// Our adapters over the reference's container files — the keyring tooltip wrapper and the
// three bag verbs 0561 shadows (`OpenBackpack`/`CloseBackpack`/`CloseAllBags`). It has to be
// AFTER `ContainerFrame.xml` and after the bar, which is why it is here and not up with
// UiPanels.xml.
"ContainerFrameAdapters.xml",
@@ -241,7 +241,7 @@ fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() {
#[test]
fn action_button_hover_takes_the_default_corner() {
let mut s = harness(&[
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -282,7 +282,7 @@ fn action_button_hover_takes_the_default_corner() {
#[test]
fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
let mut s = harness(&[
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -362,7 +362,7 @@ fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
#[test]
fn ubertooltips_off_seats_stance_plates_beside_the_button() {
let mut s = harness(&[
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -415,7 +415,7 @@ fn ubertooltips_off_seats_stance_plates_beside_the_button() {
#[test]
fn buff_hover_hangs_below_left_of_the_button() {
let mut s = harness(&[
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -68,7 +68,7 @@ fn load_tot() -> UiScript {
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\UnitPopup.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
@@ -49,7 +49,7 @@ fn trainer_script_base() -> UiScript {
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
r"Interface\FrameXML\UIParent.xml",
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -506,7 +506,7 @@ fn load_pet_menu_frames(s: &UiScript) {
"Interface\\FrameXML\\PartyFrame.xml",
"Interface\\FrameXML\\TargetFrame.xml",
"Interface\\FrameXML\\PetFrame.xml",
"Cooldown.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
@@ -51,6 +51,24 @@ fn harness() -> UiScript {
/// The stock per-frame update, driven the way its `OnUpdate` script runs it: `this` is the map
/// button and the elapsed time its one argument. Everything on the sheet — the arrow, the party
/// and raid blips, the battleground teammates and flags, the corpse — is seated here.
/// The map arrow's file facts (`MinimapArrow.m2`: one looping 3.333 s Stand keying no bone; the
/// header box `x ∈ [−0.0127, 0.0135]`, `y ∈ [−0.0118, 0.0145]` — render law §2), handed to the
/// engine the way the app does once the asset lands (2007/2015).
fn arrow_facts(s: &mut UiScript) {
use benilla_ui::widget::{ModelFileFacts, SequenceFacts};
s.set_model_facts(
ARROW_MODEL,
ModelFileFacts {
sequences: vec![SequenceFacts {
anim_id: 0,
duration_ms: 3333,
looping: true,
}],
bbox: ([-0.0127, -0.0118, 0.0], [0.0135, 0.0145, 0.0]),
},
);
}
fn update(s: &mut UiScript) {
s.run("this = WorldMapButton WorldMapButton_OnUpdate(0.1) this = nil")
.unwrap();
@@ -336,6 +354,7 @@ fn the_player_arrow_is_the_stock_model_pane_seated_and_turned_by_the_update() {
);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
arrow_facts(&mut s);
s.resolve();
s.run("ShowUIPanel(WorldMapFrame)").unwrap();
s.resolve();
@@ -354,9 +373,20 @@ fn the_player_arrow_is_the_stock_model_pane_seated_and_turned_by_the_update() {
pane.content
);
let rect = pane.rect.expect("…with a resolved rect");
// The implicit rect (2015): the file's box in layout units — at 16:9 a layout unit is
// 768·√((16/9)²+1) = 1566.4 FrameXML units, so 0.0262 × 0.0263 reads 41.0 × 41.2.
assert!(
rect.right > rect.left && rect.top > rect.bottom,
"sized: {rect:?}"
(rect.right - rect.left - 0.0262 * 1566.4).abs() < 0.2
&& (rect.top - rect.bottom - 0.0263 * 1566.4).abs() < 0.2,
"sized by the file's box: {rect:?}"
);
// …and the model is re-centred on it every update (`0x4a7b20`'s ½·GetWidth, ½·GetHeight in
// layout units = half the box).
assert!(
matches!(&pane.content, QuadContent::ModelPane { position, .. }
if (position.0 - 0.0131).abs() < 1e-4 && (position.1 - 0.01315).abs() < 1e-4),
"{:?}",
pane.content
);
// The seat, read back in Lua: the arrow is anonymous, so it is found among the map's
// children by kind and compared against the mouseover button the update seats at the same
@@ -615,6 +645,7 @@ fn the_player_arrow_draws_over_the_zones_explored_overlays() {
);
assert!(super::load_default_ui(&s).is_empty());
s.resolve();
arrow_facts(&mut s);
s.run("ShowUIPanel(WorldMapFrame)").unwrap();
s.resolve();
s.set_world_map_feed(None, Some((0.5, 0.5)), 0.0, None, Vec::new(), Vec::new());