grass fades on a plane, model panes render, and the FPS journal ships

- the grass fade is a plane across the view, not a ring around the player: `0x6b2b80` texgens the camera-space position through `T(0,0,−52.5)·Ry(π/2)·S(1/17.5)`, so the fade coordinate is view-space depth, `u = (z_eye − 52.5)/17.5` — we measured `distance(eye, fragment)`, a sphere, which meets the ground as a circle centred on the player that slides as they walk and bites a piece out of every tuft it crosses, and that was the hard border; the ramp is `0x6b2320`'s quantised 64×8 table, `alpha = 4·(63 − col)`, read bilinearly as `(254 − 256·u)/255` and capped at `252/255` — plateau to 52.64 yd, zero at 69.86 yd, near grass 98.8 % opaque and never 100 — where we had a clean 1 → 0 line; the product `tex0.a × ramp` is what the 128/255 `detailDoodadAlpha` cutout tests (both stages MODULATE, `[0xc7b2fc]`, confirmed verbatim in all four apitrace captures), so the tuft eroding out at 61.11 yd is the reference's own behaviour and the "fix" that would have removed it never landed; the pass owns its render state — blend mode 2, ALPHAREF 128, cull off, depth-write on, the per-slot draw `0x6b2d60` binding nothing but the texture — where we passed the batch's authored blend and cull through, and the `clutter_state` probe counted the cost: of 322 detail batches, 2 author Opaque (no cutout, their margin drawn as a solid card) and 5 single-sided; the build gate measures the chunk's box against the frustum corner ray derived from the live projection (≈1.31× at 16:9, ≈1.45× at 21:9), not its centre plus a 24 yd margin that popped whole chunks in on relief (2004)
- a `<Model>` widget's animation state is Lua-observable and lives in the engine: a private scene clock its own `OnUpdate` advances by `trunc(elapsed·1000)` ms on visible frames only (`0x76d7f0`), an arm with an anchor the sampler re-reads every frame (`0x7121a0`), `OnUpdateModel` at the top of every paint (`0x76d1a0`) and `OnAnimFinished` from the completion callback (`0x76cdc0`); the file's sequences and bounds are facts the host hands over, `SetModel` seeds Stand now or when they land (`0x70ebd0`) with a queued arm replaying after the seed, and the XML `scale=` on a model pane is the model's scale (`CSimpleModel::LoadXML` `0x76cac0`), which the loader had never read (2007); and the pane renders its M2 — every visible pane with a file gets a cell of one render-target atlas at its device size through one orthographic camera, the root at `1280·modelScale·layoutScale` FrameXML units per model unit, the tile's `AnimationPlayer` paused and seeked to the engine's play head, the light black, particles in their own unit (`768·√(a²+1)` per model unit, neither scale), composited as one premultiplied quad at the callback rank; two corrections off the bytes: `OnUpdateModel` fires only with a model installed (`[widget+0x318] ≠ 0`) and `OnAnimFinished` fires once per arm for a looping sequence too (`0x719370`); the loader now applies a `<Model>`'s `file=` at all, which every XML-declared pane had lacked; retired: the minimap ping's byte-measured sprite, since `MiniMapPing` and `WorldMapPing` draw their own file on the pane's clock (2013)
- the stance feed fires `UPDATE_SHAPESHIFT_FORMS` only for a list change: the reference fires it at `0x4b28ff`/`0x4b2e43` from learn, unlearn and rank-replace alone, while ours fired on any change of the pushed view, and every fire runs the stock `ShapeshiftBar_Update`, which shows `ShapeshiftBarMiddle` unconditionally past two forms and `Show()`s an already-shown frame — no `OnShow`, no manage pass — so a stance switch or the category-47 cooldown arming and expiring left the middle strip drawn alone over a raised bottom-left bar; castability now rides `SPELL_UPDATE_USABLE` (`0x4b31c0`), active/texture/cooldown are pushed silently and repainted on `PLAYER_AURAS_CHANGED` and `SPELL_UPDATE_COOLDOWN`, the feed ordered before both handler sets so they read the fresh list (2009); and the loot-roll frame waits for its item template (B371): `GroupLootFrame_OnShow` paints once and nothing repaints it, and we fired `START_LOOT_ROLL` on the frame the packet arrived, against a snapshot whose name, texture and quality were still `None` — the reference's `0x61b310` ends in the item-template cache lookup `0x55ba30`, a hit firing the event there and a miss firing it from the arrival callback `0x61b460`, so the feed holds a fresh roll's event until the template answers, with two releases that are ours: a negative answer (a frame that never opens cannot even be passed on) and a 2 s deadline paid out of the roll's minute; `arg2` stays the wire's countdown, as `0x61b430` pushes it (2010)
- the FPS journal ships to players: it had registered inside the dev-only `DevToolsPlugin` and been armed by an environment variable, so nothing we shipped could have written a row on the one GPU-bound frame in the corpus, a Steam Deck's at 94 % busy; `/console fpsJournal 1` — a CVar of our own, persisted like every CVar — opens `benilla-config/Diagnostics/fps-journal.csv` and appends one row a second, `0` closes it mid-run, and `WOW_FPS_JOURNAL=<path>` stays as the harness lever; eight GPU columns appended, `gpu_ms` then `gpu_opaque, gpu_static, gpu_transp, gpu_glow, gpu_post, gpu_ui, gpu_other`, read off bevy's `RenderDiagnosticsPlugin`, which times passes where the device offers `TIMESTAMP_QUERY_INSIDE_PASSES` (Vulkan and DX12) and leaves the cells empty on an Apple GPU, empty being the honest value; `static_gx`, the four `ffx_glow_*` passes and `ui_gamma_decode` open diagnostic spans so a city's own draw is named in the split; a fresh file opens with the adapter, backend, driver and `gpu_spans yes|no` line; and a probe's `/console` line, which skips the stock `ChatEdit_ParseText` and had landed in a parser arm that knew only `reloadUI`, now forwards to `ConsoleExec` and writes a CVar the way the typed line does (2008)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-06 19:33:58 +07:00
co-authored by Claude Fable 5.1
parent 6b812913f4
commit 73ead4e4d2
58 changed files with 4127 additions and 729 deletions
@@ -357,6 +357,9 @@ fn splice_simple(eq: &benilla_ui::script::ExtractedQuad) -> bool {
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.
| QuadContent::ModelPane { .. }
// Both colour-picker arms write one quad and nothing else — and they change on every
// step of a drag, which is exactly the traffic the splice exists for.
| QuadContent::ColorWheel
@@ -615,6 +618,8 @@ pub(super) fn drive_script(
// the atlas's bake generation, which followed the same two terms; with nothing to re-bake, the
// honest thing to watch is the term itself.)
let dpi = window.scale_factor();
// The tile renderer sizes its cells in device pixels off this (decision 2008).
booths.tiles.dpi = dpi;
let generation = font_atlas.as_deref().map(|a| a.generation);
let seam_moved = *last_seam != s || *last_dpi != dpi;
if seam_moved {
@@ -1346,10 +1351,14 @@ fn convert_entry(
// 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.
QuadContent::ModelPane {
handle,
name,
model,
facing,
model_scale: _,
model_scale,
position,
own_alpha,
icon,
} => {
use crate::portrait::PortraitSource;
// The world-map arrow (decision 1980): the stock `WorldMapFrame.lua` /
@@ -1390,6 +1399,58 @@ fn convert_entry(
});
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
// conversion may re-publish it freely; which panes draw is the engine's paint list.
if let Some(path) = model.as_deref() {
let tiles = &mut booths.tiles;
let dpi = tiles.dpi.max(0.01);
let aspect = if h > 0.0 { w / h } else { 4.0 / 3.0 };
let diag = (aspect * aspect + 1.0).sqrt();
let layout = eq.scale;
let size_px = UVec2::new(
(rect.width() * dpi).round().max(1.0) as u32,
(rect.height() * dpi).round().max(1.0) as u32,
);
tiles.requests.insert(
handle,
crate::ui_models::TileRequest {
path: path.to_string(),
size_px,
// `1 model unit = 1280 · modelScale · layoutScale` FrameXML units (§3).
px_per_unit: 1280.0 * model_scale * layout * s * dpi,
// `SetPosition` is in layout units: `768 · √(a²+1)` FrameXML per unit.
pos_px_per_unit: 768.0 * diag * layout * s * dpi,
// A particle's half-extent: eye space, neither scale (§6).
star_px_per_unit: 768.0 * diag * s * dpi,
facing,
position: Vec3::new(position.0, position.1, position.2),
icon: icon.clone(),
},
);
if let (Some(cell), Some(atlas)) = (tiles.cells.get(&handle), tiles.atlas.clone()) {
let a = tiles.atlas_size.as_vec2();
let (u0, v0) = (cell.origin.x as f32 / a.x, cell.origin.y as f32 / a.y);
let (u1, v1) = (
(cell.origin.x + cell.size.x) as f32 / a.x,
(cell.origin.y + cell.size.y) as f32 / a.y,
);
out.push(UiQuad {
rect,
z_key: eq.z,
texture: Some(atlas),
uv: UvRect::from_tex_coords([u0, u1, v0, v1]),
// The instance draws at the widget's OWN alpha (render law §4.4).
color: [1.0, 1.0, 1.0, own_alpha],
// A render target: premultiplied by construction (`UiQuad` doc).
premultiplied: true,
clip,
..default()
});
}
return;
}
let Some(slot) = name.as_deref().and_then(crate::portrait::model_pane_booth) else {
return;
};
@@ -1998,6 +2059,7 @@ mod clip_plumb_tests {
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::autocast_shine::ShineSites>();
app.init_resource::<crate::ui_script::UiFrameCost>();
@@ -2085,6 +2147,7 @@ mod clip_plumb_tests {
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::autocast_shine::ShineSites>();
app.init_resource::<crate::ui_script::UiFrameCost>();
@@ -2292,6 +2355,7 @@ mod extract_gate_tests {
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::autocast_shine::ShineSites>();
app.init_resource::<crate::ui_script::UiFrameCost>();
@@ -445,28 +445,29 @@ fn in_flight_roll_does_not_error_and_falls_back() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The production ordering** — the one every other test in this file quietly assumes away.
/// **The chain frame paints once and never again — which is exactly why the app holds the event.**
///
/// `feed_loot_rolls` drains `rolls.opened` in the *same pass* that builds the snapshot, so the model
/// a `START_LOOT_ROLL` OnShow reads can never already contain the roll that just opened: the entry
/// is added to `LootRolls::active` and to `opened` in one call. Every other test here calls
/// `set_loot_rolls` **first** and so paints from a model the app would not have had yet — which is
/// exactly how a roll dialog that shipped "green" reached the director showing a `?` icon and a
/// blank name.
/// This is the Lua half of decision 2010's fix (bug B371). It drives the sequence the app used to
/// produce — `START_LOOT_ROLL` first, against a model that does not carry the roll yet — and pins
/// that *nothing repairs it*: the snapshot landing afterwards paints no name, no icon and no
/// decoration, for the life of the dialog. Our retired frame did repair it, from a benilla-only
/// `UPDATE_LOOT_ROLL(rollID)`; 1838 migrated to the stock frame, which has no such seam and never
/// listened, and 1883 removed the event.
///
/// What this pins now is the DEFECT, deliberately: a roll whose template lands after the frame is
/// up keeps its `?`. Our retired frame repaired it from an `UPDATE_LOOT_ROLL(rollID)`; the stock
/// frame has no such seam and never listened, so 1883 stopped firing an event the 1.12 client does
/// not have. Decision 1838 carries the real fix — hold the roll until its template resolves, which
/// is the reference's own invariant. This test goes green-to-red the day that lands, which is the
/// point of writing it against the current behaviour rather than deleting it.
/// The reference is in the same position and answers it upstream, in C: `0x61b310` fires
/// `START_LOOT_ROLL` from the item-template cache's *arrival callback* (`0x61b460`) when the record
/// is not resident. So `feed_loot_rolls` holds the event until the template is in — pinned
/// app-side in `ui_loot_roll.rs`'s `the_roll_waits_for_its_item_template`, which is the test that
/// fails if the hold is lost. This one is its premise: it says what that hold is protecting
/// against, and it must keep reading blank — a repaint appearing here would mean a seam the 1.12
/// client does not have had grown back.
#[test]
fn a_roll_that_opens_before_its_snapshot_stays_blank() {
fn nothing_repaints_a_frame_that_opened_before_its_snapshot() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
load_group_loot(&s);
// The app's order: the event first, against a model that has no such roll at all.
// The order the app must never produce: the event first, against a model with no such roll.
s.fire_event(
"START_LOOT_ROLL",
vec![ScriptValue::Int(7), ScriptValue::Int(42_000)],
@@ -481,25 +482,23 @@ fn a_roll_that_opens_before_its_snapshot_stays_blank() {
s.eval::<Option<String>>("return GroupLootFrame1Name:GetText()")
.unwrap(),
None,
"nothing to paint yet — this is the state the director saw"
"nothing to paint yet — this is the state B371 reported"
);
// ...and then the snapshot carrying it lands.
s.set_loot_rolls(rolls());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// **The repaint does not happen, and this is the migration's one real loss.** Our retired file
// split the paint out of `OnShow` precisely so an `UPDATE_LOOT_ROLL` could re-enter it — an
// event the 1.12 client does not have, which 1883 stopped firing once the stock frame proved
// nothing listened for it. The
// reference has no such seam, because its `GetLootRollItemInfo` reads live C state that is
// already populated when `START_LOOT_ROLL` fires. Ours reads a pushed snapshot, and while
// `feed_loot_rolls` does push it BEFORE firing, the item template can still be in flight.
// **The repaint does not happen — there is no path for it.** Our retired file split the paint
// out of `OnShow` precisely so an `UPDATE_LOOT_ROLL` could re-enter it; that is an event the
// 1.12 client does not have, and 1883 stopped firing it once the stock frame proved nothing
// listened. An adapter was tried before the hold and does not work either (1838): calling
// `GroupLootFrame_OnShow()` from inside a handler repaints nothing, and the `Hide()`/`Show()`
// round trip that does repaint only does so from a plain chunk.
//
// The fix is app-side ordering, not a Lua shim: hold the roll until its template resolves.
// Decision 1838 carries that, and the engine question an adapter ran into on the way — a
// `Hide()`/`Show()` round trip re-fires `OnShow` from a plain chunk but not from inside an
// event handler.
// So the ordering is the app's to get right, and it now is: `feed_loot_rolls` holds
// `START_LOOT_ROLL` until `Items::template` answers, which is the reference's own gate
// (decision 2010).
assert_eq!(
s.eval::<Option<String>>("return GroupLootFrame1Name:GetText()")
.unwrap(),
@@ -350,7 +350,7 @@ fn get_ping_position_answers_two_numbers_always() {
// The stock lifetime, end to end: MINIMAP_PING shows the model frame and starts the 5 s
// timer; OnUpdate re-seats it from GetPingPosition every frame; past 5 s it "fades" and
// hides. The app's sprite follows this frame (`minimap/ping.rs`).
// hides. The frame's own file draws in it (`crate::ui_models`, decision 2008).
assert!(
!s.eval::<bool>("return MiniMapPing:IsVisible()").unwrap(),
"hidden until a ping"
@@ -449,3 +449,173 @@ fn the_meeting_stone_icon_follows_the_queue_across_meetingstone_changed() {
s.fire_event("MEETINGSTONE_CHANGED", vec![]);
assert!(!vis(&s), "area 0 hides it again");
}
/// **The ping's pixels are the stock `<Model>`'s own file** (decision 2008): shown by
/// `MINIMAP_PING` with the file's facts landed, the extract publishes ONE tile request for the
/// pane — at its device size and the render law's unit ladder — and, once the renderer has
/// handed back a cell, draws that cell as one premultiplied quad over the pane's rect. Drives the
/// real `drive_script` in the headless harness the clip-plumb tests use.
#[test]
fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use benilla_ui::widget::{ModelFileFacts, SequenceFacts};
use crate::ui_models::{Cell, UiModelTiles};
use crate::ui_pass::UiQuads;
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run("function GetMinimapZoneText() return '' end")
.unwrap();
s.run("function PlaySound() end").unwrap();
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
// The file's facts, as `benilla-extract m2seq` reads them off the install: the ping's
// `SetSequence(0)` from its OnLoad was queued behind them and replays now.
const PING: &str = r"Interface\MiniMap\Ping\MinimapPing.mdx";
let seq = |anim_id, duration_ms, looping| SequenceFacts {
anim_id,
duration_ms,
looping,
};
s.set_model_facts(
PING,
ModelFileFacts {
sequences: vec![seq(127, 1333, false), seq(0, 833, true), seq(1, 333, false)],
bbox: ([0.0; 3], [0.0; 3]),
},
);
assert!(
s.visible_model_panes().is_empty(),
"hidden until a ping: nothing to paint"
);
let ping_at = |s: &mut UiScript, nx: f64, ny: f64| {
s.fire_event(
"MINIMAP_PING",
vec![
benilla_ui::script::ScriptValue::Str("player".into()),
benilla_ui::script::ScriptValue::Number(nx),
benilla_ui::script::ScriptValue::Number(ny),
],
);
};
ping_at(&mut s, 0.25, -0.125);
let panes = s.visible_model_panes();
assert_eq!(panes.len(), 1, "the ping pane is on the paint list");
let pane = panes[0];
assert_eq!(
pane.play.map(|p| (p.anim_id, p.cursor_ms)),
Some((0, 0)),
"the OnLoad's SetSequence(0) replayed over the seed: Stand, at 0"
);
let mut app = App::new();
app.insert_non_send_resource(s);
app.init_resource::<UiQuads>();
app.init_resource::<Assets<Image>>();
app.init_resource::<crate::portrait::PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::autocast_shine::ShineSites>();
app.init_resource::<crate::ui_script::UiFrameCost>();
app.init_resource::<crate::ui_script::UiCostWanted>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<crate::ui_script::UiClock>();
app.init_resource::<crate::ui_script::UiScaleCvar>();
app.world_mut().spawn((
Window {
resolution: UVec2::new(1024, 768).into(),
..default()
},
PrimaryWindow,
));
app.add_systems(Update, super::extract::drive_script);
app.update();
let premultiplied = |app: &App| {
app.world()
.resource::<UiQuads>()
.quads
.iter()
.filter(|q| q.premultiplied)
.cloned()
.collect::<Vec<_>>()
};
{
let tiles = app.world().resource::<UiModelTiles>();
let req = tiles
.requests
.get(&pane.handle)
.expect("one request for the ping pane");
assert_eq!(req.path, PING);
// `<Size>50×50</Size>` at s = 1 (768-tall window), DPI 1: 50 device px a side.
assert_eq!(req.size_px, UVec2::new(50, 50));
// `scale="0.4"`: 1280 · 0.4 = 512 px per model unit — 1596's own "1 model unit at 512 px".
assert!(
(req.px_per_unit - 512.0).abs() < 1e-3,
"{}",
req.px_per_unit
);
// A layout unit, and a particle's unit: 768 · √((4/3)² + 1) = 1280 at 4:3.
assert!(
(req.pos_px_per_unit - 1280.0).abs() < 0.1,
"{}",
req.pos_px_per_unit
);
assert!(
(req.star_px_per_unit - 1280.0).abs() < 0.1,
"{}",
req.star_px_per_unit
);
assert_eq!(req.icon, None);
assert!(premultiplied(&app).is_empty(), "no cell yet: nothing drawn");
}
// The renderer hands a cell back; a fresh ping moves the pane, so the memoized conversion
// runs again and the arm draws the cell.
{
let atlas = app
.world_mut()
.resource_mut::<Assets<Image>>()
.add(Image::default());
let mut tiles = app.world_mut().resource_mut::<UiModelTiles>();
tiles.atlas = Some(atlas);
tiles.atlas_size = UVec2::splat(512);
tiles.cells.insert(
pane.handle,
Cell {
origin: UVec2::new(2, 2),
size: UVec2::new(50, 50),
},
);
}
{
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
// The stock handler seats the frame from `GetPingPosition()`, not the event's args.
script.set_minimap_ping((-0.125, 0.25));
ping_at(&mut script, -0.125, 0.25);
}
app.update();
let drawn = premultiplied(&app);
assert_eq!(drawn.len(), 1, "the cell, once");
let q = &drawn[0];
assert!((q.rect.width() - 50.0).abs() < 1e-3 && (q.rect.height() - 50.0).abs() < 1e-3);
let [tl, _, br, _] = q.uv.corners;
assert!((tl[0] - 2.0 / 512.0).abs() < 1e-6 && (tl[1] - 2.0 / 512.0).abs() < 1e-6);
assert!((br[0] - 52.0 / 512.0).abs() < 1e-6 && (br[1] - 52.0 / 512.0).abs() < 1e-6);
assert_eq!(q.color, [1.0, 1.0, 1.0, 1.0], "the frame's own alpha");
assert!(q.texture.is_some());
}
@@ -1716,3 +1716,211 @@ fn the_middle_strip_tiles_along_its_length_only() {
assert_eq!(wraps_of(&s, "ShapeshiftBarMiddle"), vec![(false, false)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The director's picture (2026-09-05, decision 2009)**: a three-stance warrior with the
/// bottom-left bar up, and a silver plate around Defensive Stance — `ShapeshiftBarMiddle` drawn
/// alone, both end caps down.
///
/// The reference fires `UPDATE_SHAPESHIFT_FORMS` for exactly one thing: the form LIST changed
/// (learn / unlearn / rank — wow-re `shapeshift-bar-api.md`, the fires at `0x4b28ff`/`0x4b2e43`).
/// Our feed fired it for any change in the pushed view — a stance switch, a castable flip, the
/// shared 1 s category cooldown arming and then EXPIRING. Every fire runs the stock
/// `ShapeshiftBar_Update` (BonusActionBarFrame.lua l.170): `ShapeshiftBarMiddle:Show()`
/// unconditionally past two forms, then `ShapeshiftBarFrame:Show()` on a frame already shown —
/// no OnShow, so no manage pass, and the strip stays up over the raised bar (UIParent.lua
/// l.1706-1712 is the only thing that takes it down). 2000 and 2001 each fixed a real thing in
/// this band and neither touched this, which is the director's "no change from before".
///
/// Driven through the feed's own diff ([`crate::ui_shapeshift::push_forms`]), so what fails here
/// is the feed's rule, not a hand-fired event. A form's STATE rides the reference's state events,
/// which the feeds owning those transitions fire (`PLAYER_AURAS_CHANGED` for the form aura's
/// slot, `SPELL_UPDATE_COOLDOWN` for the store's generation edge) — checked below by firing the
/// one the switch really carries and reading the checked ring.
#[test]
fn a_forms_state_change_leaves_the_shelf_down_over_the_raised_bar() {
use crate::ui_shapeshift::{push_forms, FormsEdge, StanceMemory};
use benilla_ui::script::ShapeshiftFormView;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
// A recorder for the two events a push may announce.
s.run(
"STANCE_LOG = {} local f = CreateFrame('Frame') \
f:RegisterEvent('UPDATE_SHAPESHIFT_FORMS') f:RegisterEvent('SPELL_UPDATE_USABLE') \
f:SetScript('OnEvent', function() table.insert(STANCE_LOG, event) end)",
)
.unwrap();
let log = |s: &UiScript| {
s.eval::<String>("local l = table.concat(STANCE_LOG, ',') STANCE_LOG = {} return l")
.unwrap()
};
let shown = |s: &UiScript, region: &str| {
s.eval::<bool>(&format!("return {region}:IsShown()"))
.unwrap()
};
let shelf = [
"ShapeshiftBarLeft",
"ShapeshiftBarMiddle",
"ShapeshiftBarRight",
];
let ring = |s: &UiScript| {
s.eval::<f64>("return ShapeshiftButton1NormalTexture:GetWidth()")
.unwrap()
};
let checked = |s: &UiScript, i: u32| {
s.eval::<bool>(&format!("return ShapeshiftButton{i}:GetChecked() == 1"))
.unwrap()
};
let stance = |id: u32, active: bool, castable: bool, cooldown| ShapeshiftFormView {
spell_id: id,
texture: Some(format!("Interface\\Icons\\Stance_{id}")),
name: format!("Stance {id}"),
active,
castable,
cooldown,
};
let mut memory = StanceMemory::default();
// Login: the list arrives — the reference's learn edge — with Battle Stance active.
assert_eq!(
push_forms(
&mut s,
&mut memory,
vec![
stance(2457, true, true, None),
stance(71, false, true, None),
stance(2458, false, true, None),
],
),
FormsEdge::List
);
assert_eq!(log(&s), "UPDATE_SHAPESHIFT_FORMS");
assert!(checked(&s, 1) && !checked(&s, 2));
// The bottom-left bar up, the way the Options row raises it: the pass seats the stance bar a
// row higher and takes the whole shelf down (UIParent.lua l.1706-1716).
show_bars(&s, &[1]);
for region in shelf {
assert!(!shown(&s, region), "{region} is down under a raised bar");
}
assert_eq!(ring(&s), 50.0);
// A stance switch: the form byte flips to Defensive and category 47 arms for a second on all
// three. State, not list — the reference announces it through the aura and cooldown events.
let cd = Some((0, 1000, true));
let edge = push_forms(
&mut s,
&mut memory,
vec![
stance(2457, false, true, cd),
stance(71, true, true, cd),
stance(2458, false, true, cd),
],
);
for region in shelf {
assert!(
!shown(&s, region),
"{region}: the director's plate — the shelf re-shown over the raised bar"
);
}
assert_eq!(ring(&s), 50.0);
assert_eq!(edge, FormsEdge::Silent);
assert_eq!(log(&s), "", "a state move fires no list edge");
// …and the checked ring follows on the event the switch really carries.
assert!(
checked(&s, 1) && !checked(&s, 2),
"no repaint before the state event"
);
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
assert!(
!checked(&s, 1) && checked(&s, 2),
"the aura event repaints the ring"
);
for region in shelf {
assert!(
!shown(&s, region),
"{region}: a state repaint never touches the shelf"
);
}
// The cooldown running out is the same silent edge — the widget hides itself, the reference's
// store fires nothing at expiry, and neither do we.
assert_eq!(
push_forms(
&mut s,
&mut memory,
vec![
stance(2457, false, true, None),
stance(71, true, true, None),
stance(2458, false, true, None),
],
),
FormsEdge::Silent
);
assert_eq!(log(&s), "");
assert!(!shown(&s, "ShapeshiftBarMiddle"));
// A castable flip is a usability move — the reference's own SPELL_UPDATE_USABLE — and the
// stock bar greys the icon on it without touching the shelf.
assert_eq!(
push_forms(
&mut s,
&mut memory,
vec![
stance(2457, false, false, None),
stance(71, true, true, None),
stance(2458, false, true, None),
],
),
FormsEdge::Usable
);
assert_eq!(log(&s), "SPELL_UPDATE_USABLE");
let grey = s
.eval::<f64>("local r = ShapeshiftButton1Icon:GetVertexColor() return r")
.unwrap();
assert!(
(grey - 0.4).abs() < 1e-6,
"not castable: the 0.4 grey, got {grey}"
);
assert!(!shown(&s, "ShapeshiftBarMiddle"));
// Nothing moved: nothing pushed, nothing fired.
assert_eq!(
push_forms(
&mut s,
&mut memory,
vec![
stance(2457, false, false, None),
stance(71, true, true, None),
stance(2458, false, true, None),
],
),
FormsEdge::Unchanged
);
assert_eq!(log(&s), "");
// The control — a fourth stance LEARNED is the list edge, and the reference's own Update
// shows the strip across the raised bar until the next pass (the sibling test pins that).
assert_eq!(
push_forms(
&mut s,
&mut memory,
vec![
stance(2457, false, false, None),
stance(71, true, true, None),
stance(2458, false, true, None),
stance(768, false, true, None),
],
),
FormsEdge::List
);
assert_eq!(log(&s), "UPDATE_SHAPESHIFT_FORMS");
assert!(
shown(&s, "ShapeshiftBarMiddle"),
"the learn edge: Update shows the strip"
);
s.run("UIParent_ManageFramePositions()").unwrap();
assert!(!shown(&s, "ShapeshiftBarMiddle"));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}