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:
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user