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
+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.