diff --git a/crates/benilla-app/src/ui_models/mod.rs b/crates/benilla-app/src/ui_models/mod.rs index 073dc3db..4b5e6913 100644 --- a/crates/benilla-app/src/ui_models/mod.rs +++ b/crates/benilla-app/src/ui_models/mod.rs @@ -1,123 +1,13 @@ -//! **UI model tiles** (decision 2008) — the renderer for a `` widget's M2: the cooldown -//! sweep, the autocast shine, the minimap and world-map pings, the item-push card, the map's -//! arrow, and whatever an addon parks in a `CreateFrame("Model")`. +//! UI model tiles: the renderer for a `` widget's M2, from the cooldown sweep, the autocast +//! shine, the pings, the item-push card and the map arrow to an addon's `CreateFrame("Model")`. //! -//! ## The law -//! -//! A `` **whose file supplies a camera** draws its scene through it: the widget holds a raw -//! camera index (ctor 0, `SetCamera(n)`), and the record's `lookAt(eye, target, up)` plus the -//! client's diagonal-FOV projection at the pane's own width/height frame the model (`0x76cec0`, -//! `0x7ac640`). The authored eye and target are carried through the model's root transform, so -//! `SetModelScale` and `SetPosition` cancel for framing on that leg and bite only on the record's -//! unscaled near/far. An index past the file's camera count installs a NULL camera, which is the -//! other leg: -//! -//! A `` with no M2 camera picked draws its scene **orthographically over the frame's -//! rect**: origin at the rect's bottom-left, `+X` right, `+Y` up, `Z` depth only, with the root -//! matrix `T(pos · layoutScale) · R(facing, +Z) · S(G48 · 5/3 · modelScale · layoutScale)` — so one -//! model unit is `1280 · modelScale · layoutScale` FrameXML units, aspect-independent (`0x7ad7f0`, -//! `0x76d1a0`). Every batch draws once, LEQUAL over a depth buffer cleared for the widget's own -//! rect, straight into the back buffer (`0x76d240`, `0x70b360`); every in-game UI M2 is UNLIT on -//! every material (measured over the shipped files); the animator is the world's, on the widget's -//! private clock (`0x714260`, `0x76d7f0`). A particle's half-extent is the one quantity outside the -//! unit law — added in eye space, it maps at `768 · √(a²+1)` FrameXML units per model unit and -//! carries neither scale (`0x7b2a50`). -//! -//! ## The shape here: tiles in one atlas, composited at the callback rank -//! -//! The reference draws into the back buffer between two 2-D batches; this engine's UI is one -//! quad pass, so a scene becomes a **tile**: every visible pane holding a file renders into its -//! own cell of one shared render-target atlas, at the pane's device-pixel size, through ONE -//! orthographic camera whose view is the atlas plane — each tile's model root is placed at its -//! cell, scaled to pixels per model unit, and the camera never moves. [`compose_tiles`] then -//! draws every cell as a premultiplied quad over its pane's rect at `ZKey::callback(Artwork)` -//! (1995's rank — after every texture and font string of the pane's layer), which is the same -//! picture the reference's callback drain produces: the cell is cleared to transparent like the -//! reference clears its depth, the 2-D layers under it stay under it, and the ones over it stay -//! over it. Cells never overlap, so one depth buffer serves every tile. -//! -//! **The composite is this renderer's per-frame output, never the extract's** (decision 2023). -//! The extract's `ModelPane` arm publishes the request — the pane's rect, paint key, alpha and -//! clip beside the unit ladder — and pushes no quad; the quad is appended in the -//! [`UiQuadAppend`] lane (the minimap fill's lane) from THIS frame's cells. The first shape had -//! the arm draw the cell it found in the bridge, which is last frame's at best and, because the -//! conversion is memoized on the engine's list, usually never: a cooldown armed on a quiet -//! interface extracted once (no cell yet), the cell arrived a frame later, and nothing ever -//! re-ran the conversion — the sweep drew only while the interface happened to be churning (the -//! stance bar at UI load), and never on an action press. -//! -//! The pipeline is the booths' (`crate::portrait`): the same HDR view shape, the same -//! `FfxGlow::UI_PANE` decode, the same material twin with only the light storage swapped, the -//! same collapsed rig lane and palette mirror, the same effect lane. What is new is the -//! orthographic preset, the atlas packing, and the clock: a tile samples the file at the play -//! head the ENGINE holds (decision 2007 — `UiScript::visible_model_panes`, read once per frame), -//! so the `AnimationPlayer` is paused and seeked rather than advanced, and every per-sequence -//! material track is sampled off that same cursor. -//! -//! ## The two legs in this shape -//! -//! The **orthographic** leg is the atlas's one camera: every pane on it shares that camera and its -//! layer, and its cell is where its root is placed. The **perspective** leg cannot share a camera -//! with anything — the projection and the view are the pane's own — so a perspective pane takes a -//! slot of a small pool ([`UI_MODEL_CAM_LAYERS`]) and gets a camera whose **viewport is its cell of -//! the same atlas** and whose layer is its own. That is the reference's own structure: one target, -//! one viewport per widget, depth cleared per widget. The orthographic camera runs first and is -//! the one that CLEARS the atlas; the perspective cameras load into it and clear only depth. And -//! because the perspective root sits in model space rather than at a cell, each of those cameras -//! needs a layer of its own or it would draw every other perspective pane over its own cell. -//! -//! ## What a tile's light is -//! -//! ``'s embedded light is DISABLED (ctor `0x76c8e0`), and a LIT batch under no light renders -//! black — which never shows on the shipped UI M2s because all of them are unlit, and which is the -//! faithful answer for an addon's lit one. So the DEFAULT tile light buffer is a black light (no -//! ambient, no diffuse, fog off) and unlit batches bypass it. -//! -//! A pane that arms one — `SetLight(1, …)`, or fog through `SetFogColor` — takes a slot of the -//! light pool instead ([`TileRig`]): its own buffer, its own material twins, its scene folded the -//! way the reference's collector folds it (a directional light into probe slot 0's SH, a point -//! light into the point table, the fog into rows 4/5 with the batch's authored `UNFOGGED` bit left -//! to do its own work). Nothing in the shipped interface arms either, so the pool normally holds -//! exactly the one buffer the tiles have always had. -//! -//! ## Texture transforms (decision 2019) -//! -//! A batch whose texture transform animates gets a material of its own per tile — a clone of -//! the twin with two mat-anim rows: the translation delta (the world's lane, `anim_slots.x`) -//! and the **affine** row (`anim_slots.z`: rotation and scale as deltas from the identity), both -//! sampled here off the pane's play head at the sequence's file slot, never off the world clock. -//! The shader composes them as the reference does — `uv' = R((uv + t − p) ⊙ s) + p` — which is -//! how the cooldown indicator's four quadrant quads turn their mask into the clockwise sweep. -//! -//! ## What a pane that stops drawing costs: nothing (decision 2046) -//! -//! A pane that leaves the engine's paint list keeps its tile for [`TILE_LINGER_FRAMES`] so a -//! cooldown that re-arms every few seconds, a ping, or a bag that reopens keeps its tree — but -//! the tree has to cost NOTHING while it waits, and hiding the root was never enough. Two of a -//! tile's three per-frame costs do not travel down the scene graph at all: an emitter entity is -//! a world root the particle lane walks directly, and the global-sequence bone channels are -//! written by a driver that reads no `Visibility`. So a hidden tile is **parked**, explicitly, -//! on the frame it stops drawing — [`AnimParked`] on the root (which holds the pose evaluation, -//! the compose, the palette write and the global-sequence writes) and -//! `ParticleEmitter::set_frozen` on every emitter (pool + age held, no quads). -//! -//! The emitter half is not a nicety. The particle lane's own freeze asks a CAMERA whether its -//! scene is drawn, which is exact for a booth (one camera, one scene) and cannot be asked here: -//! every orthographic pane on the sheet shares one camera, and that camera stays active whenever -//! ANY cell is packed, because it is the camera that clears the atlas. Before the park, a hidden -//! autocast-shine pane's four spline emitters kept integrating and kept pushing quads for the -//! whole linger, at the root's last-written cell — which after a repack belongs to another pane. -//! -//! ## What is deliberately NOT here -//! -//! - The **character panes** (`PlayerModel`/`DressUpModel`/`TabardModel`) keep their booths -//! (`crate::portrait`): the reference frames those through raw camera 1 **frozen** at load, or -//! a fixed fallback camera, which is a different selection and a different lifecycle from this -//! widget's live one. -//! - A pane's fog does not reach its **particles**: the effect lane carries its own fog enum and -//! reads its span from a render-world params uniform rather than from the light buffer, so a -//! fogged pane's cloud draws unfogged. No shipped UI M2 with an emitter is fogged (the shine -//! and the pings are UNFOGGED or unlit anyway), and the honest fix is on the effect lane. +//! The reference draws a pane straight into the back buffer, every batch once and LEQUAL over a +//! depth buffer cleared for the widget's rect (`0x76d240`, `0x70b360`), through the file camera +//! the widget names or, with none, orthographically over the frame's rect. Here each pane renders +//! into its own cell of one shared atlas, through the sheet's one orthographic camera or a pooled +//! perspective one, and [`compose_tiles`] draws each cell as a quad at the pane's callback rank. +//! `PlayerModel`, `DressUpModel` and `TabardModel` are booths instead (`crate::portrait`): the +//! reference frames them through camera 1 frozen at load. use std::collections::HashMap; use std::sync::Arc; @@ -153,100 +43,78 @@ use crate::portrait::{ }; use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuads, UvRect}; -/// `WOW_TILE_TRACE=1` — the tile probe: one `tile-trace:` line per pane per frame from the -/// renderer (the request, the cell, the play head, the sampled alphas and the rows written) and -/// one from the extract's composite arm (the quad's rect, rank and alpha, or "no cell yet"). -/// A pane that is on the engine's paint list but draws nothing names the gate it stopped at. -/// The `test_ui` cooldown tests prove the engine scrubs; this is the instrument for the half -/// they cannot reach — whether the tile exists, where it is, and what it sampled. Read once. +/// `WOW_TILE_TRACE=1`: a `tile-trace:` line per pane per frame from the renderer and the +/// extract's arm, naming the gate a listed pane that draws nothing stopped at. pub(crate) fn trace_on() -> bool { static ON: std::sync::LazyLock = std::sync::LazyLock::new(|| std::env::var("WOW_TILE_TRACE").as_deref() == Ok("1")); *ON } -/// One pane's request for a tile this frame — what the extract knows about the widget: its -/// size on the device, the unit ladder `0x76d1a0` derives from it, and the Lua-set scene. -/// Published by the extract's `ModelPane` arm (keyed by the pane's frame handle, overwritten on -/// every conversion), read by [`sync_tiles`]. +/// One pane's tile request, published by the extract's `ModelPane` arm: its device size, the unit +/// ladder `0x76d1a0` derives from it, and the Lua-set scene. #[derive(Clone, Debug, PartialEq)] pub(crate) struct TileRequest { /// The `SetModel` path, as written. pub path: String, - /// The pane's rect on the device, whole pixels — the tile's cell size. + /// The pane's rect in whole device pixels: the tile's cell size. pub size_px: UVec2, - /// Device pixels per **model unit** for the geometry: `1280 · modelScale · layoutScale` - /// FrameXML units per unit, times the seam scale, times the DPI. + /// Device px per model unit: `1280 · modelScale · layoutScale` FrameXML units × seam × DPI. pub px_per_unit: f32, - /// Device pixels per **layout unit** — `SetPosition`'s space (`T(pos · layoutScale)`): - /// `768 · √(a²+1) · layoutScale` FrameXML units per unit, times seam and DPI. + /// Device px per `SetPosition` unit: `768 · √(a²+1) · layoutScale` FrameXML units × seam × DPI. pub pos_px_per_unit: f32, - /// Device pixels per model unit for a **particle's half-extent** — eye space, no scale: - /// `768 · √(a²+1)` FrameXML units per unit, times seam and DPI. + /// Device px per model unit of a particle's half-extent, which carries no scale. pub star_px_per_unit: f32, /// `SetFacing`, radians about the screen normal (CCW positive, the reference's `+Z`). pub facing: f32, /// `SetPosition`, layout units. pub position: Vec3, - /// The **perspective** leg's root scale (decision 2027): `s = G48 · (5/3) · modelScale · - /// layoutScale`, a pure number — that leg has no pixels-per-unit, because its projection is - /// the file's own camera and its viewport is the cell. Both this and [`Self::root_pos`] - /// cancel for framing (the camera is carried through the same matrix) and bite only on the - /// record's unscaled near/far. + /// The perspective leg's root scale, `G48 · (5/3) · modelScale · layoutScale`. pub root_scale: f32, /// The perspective leg's root translation: `SetPosition · layoutScale`, model units. pub root_pos: Vec3, - /// **Which leg.** The installed camera as a raw index into the file's table, or `None` for - /// the NULL camera — the orthographic leg, which is every shipped in-game pane. Resolved by - /// the engine against the file's camera count, exactly where `0x76cec0` resolves it. + /// The installed camera index (ctor 0, `SetCamera(n)`); `None` is the NULL camera an index + /// past the file's cameras installs (`0x76cec0`), the orthographic leg of every shipped pane. pub camera: Option, - /// The pane's embedded `CGLight` — disabled on a fresh ``, which is what makes a LIT - /// batch draw black. + /// The pane's embedded `CGLight`, disabled on a fresh ``, so a lit batch draws black. pub light: ModelLight, - /// The pane's fog, only when armed. pub fog: Option, - /// `ReplaceIconTexture`'s path — the type-14 batches' texture. + /// `ReplaceIconTexture`'s path, the texture of the file's type-14 batches. pub icon: Option, - /// The pane's rect on the window — y-down logical px, the quad pass's space — where the - /// cell composites. + /// Where the cell composites: the pane's rect in the quad pass's space, y-down logical px. pub rect: Rect, - /// The pane's paint key: `ZKey::callback(Artwork)` (1995), the composite's rank. + /// `ZKey::callback(Artwork)`: after every texture and font string of the pane's layer. pub z_key: u64, - /// The frame's OWN alpha (`0x76d120`): the composite draws at it. + /// The frame's own alpha, which the composite draws at (`0x76d120`). pub alpha: f32, - /// The enclosing ScrollFrame clip, if any (decision 0112), in the quad pass's space. + /// The enclosing ScrollFrame's clip, in the quad pass's space. pub clip: Option, } -/// Where a tile sits in the atlas — texel space, `y` down — for the composite quad. +/// Where a tile sits in the atlas, in texels, `y` down. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct Cell { pub origin: UVec2, pub size: UVec2, } -/// The extract ↔ renderer bridge (the `BoothPanes` pattern): requests in, cells and the atlas -/// out. Lives on `crate::portrait::BoothBridge` so the extract reaches it through the seam it -/// already holds. +/// The extract-to-renderer bridge, reached through `crate::portrait::BoothBridge`. #[derive(Resource, Default)] pub(crate) struct UiModelTiles { - /// The last request per pane. A pane that stops being extracted keeps its stale entry - /// (the memoized conversion cannot tell the renderer it vanished); which panes DRAW is the - /// engine's paint list, never this map. + /// The last request per pane. An entry outlives its pane, since the memoized conversion never + /// says it vanished, so which panes draw is the engine's paint list, never this map. pub requests: HashMap, /// This frame's cell per tile that has something to draw. pub cells: HashMap, - /// The atlas image and its size — `None` until the first tile. + /// The atlas and its size; `None` until the first tile is packed. pub atlas: Option>, pub atlas_size: UVec2, - /// The window's DPI as the last extract saw it (device px per logical px) — the extract - /// writes it, the arm reads it to size cells. + /// Device px per logical px, written by the extract and read by its arm to size cells. pub dpi: f32, } -/// A pane's whole light input: its embedded `CGLight` and its armed fog. Two panes with the same -/// scene share one light buffer and one material-twin cache; the DEFAULT scene — a disabled light -/// and no fog — is every shipped in-game pane, and it is slot 0. +/// A pane's light input: its embedded `CGLight` and its armed fog. Panes with the same scene +/// share one light buffer and twin cache; the default, a disabled light and no fog, is slot 0. #[derive(Clone, Copy, PartialEq)] struct TileScene { light: ModelLight, @@ -254,8 +122,7 @@ struct TileScene { } impl TileScene { - /// The `` ctor's scene: white, **disabled**, unfogged. What every pane the shipped - /// interface draws holds, and what makes a LIT batch render black. + /// The `` ctor's scene, a disabled white light and no fog; every shipped UI M2 is unlit. fn default_scene() -> Self { Self { light: ModelLight::default(), @@ -263,38 +130,24 @@ impl TileScene { } } - /// The light buffer this scene wants — the reference's collector, finalized. - /// - /// The collector is zeroed every frame and gathers only what the fill callback `0x76d680` - /// stages: the fog when it is armed, and the light when it is **enabled**. - /// With the light off, the finalize writes zero ambient and zero diffuse and a LIT batch draws - /// black — the reference's answer, not a gap. With it on, the type decides which arm: - /// directional folds ambient + one diffuse lobe through the SH accumulators - /// (`0x71bc70`/`0x71bce0`), point drops into the ≤4-nearest heap and contributes **no** - /// ambient (`0x71bf90`). - /// - /// The lit lane here is the rig one, so the ambient and the lobe go into **probe slot 0** — - /// the slot every tile part's `MeshTag` names — exactly as the glue booth's own scene blob - /// does; rows 0-2 are dead on that lane. + /// The light buffer the reference's collector finalizes for this scene. It gathers only what + /// `0x76d680` stages, the fog when armed and the light when enabled: a directional light + /// folds ambient and one diffuse lobe into the SH (`0x71bc70`, `0x71bce0`), a point light + /// joins the 4-nearest heap with no ambient (`0x71bf90`). The ambient and the lobe land in + /// probe slot 0, which every tile part's `MeshTag` names; the rig lane reads no rows 0-2. fn blob(&self) -> LightBlob { let l = self.light; let (ambient, lobes, point) = match (l.enabled, l.omni) { (false, _) => ([0.0; 3], Vec::new(), None), - // Type 1 (point/omni): the position is model space, the falloff is the shared - // `1/(0.7d + 0.03d²)` the CGLight ctor's attenuation names and the point lane - // already implements — so no range gate, the falloff does the bounding. + // Type 1, point: model-space position; `1/(0.7d + 0.03d²)` falloff, no range gate. (true, true) => ( [0.0; 3], Vec::new(), Some((benilla_assets::coords::wow_to_bevy(l.vector), l.diffuse)), ), - // Type 0 (directional): `CGLight+0x24` is the direction the light **PROPAGATES**, - // and an SH lobe is centred on the TOWARD-light vector — so it goes in negated. - // That sign is `0x71bce0`'s own: the moments at `collector+0x18…+0x50` take `d` as it - // is, and then three `fchs` (`71be7c`/`71be81`/`71be86`) negate it before the nine SH - // basis terms are built from it. The two families take opposite signs off the one - // field, which is exactly how two careful readers can disagree about "the direction" - // without either misreading a byte; the lobe's is the negated one. + // Type 0, directional: `CGLight+0x24` is the direction the light travels. The SH + // lobe takes it negated (`fchs` at `0x71be7c`, `0x71be81`, `0x71be86` in `0x71bce0`), + // while the moments at `collector+0x18…+0x50` take it as is. (true, false) => ( l.ambient, vec![( @@ -315,23 +168,18 @@ impl TileScene { } } -/// One light rig of the pool: the scene it was built for, its GPU buffer, the mirror key it -/// registered under, and the material twins bound to it. +/// One slot of the light pool, with the material twins bound to its buffer. struct TileLight { scene: TileScene, buffer: Buffer, variants: HashMap, Handle>, } -/// The candidacy range packed with a pane's point light. The reference's gather has no range gate -/// at all (a sorted ≤4-nearest heap), so this is effectively unbounded and the `1/(0.7d + 0.03d²)` -/// falloff does the bounding — the same number and the same reason as the glue scene's. +/// A point light's candidacy range, unbounded: the reference's 4-nearest gather has none. const POINT_RANGE: f32 = 1.0e6; -/// The mirror keys the light pool registers under, one per slot. Static because both mirror maps -/// are keyed by `&'static str`, and monotone because a key is only ever ADDED: the mat-anim -/// upload gates on the mirror count, so a set that grew and shrank in one frame could skip an -/// upload and leave a fresh buffer's rows at the identity. +/// The light pool's mirror keys, one per slot. A key is only ever added: the mat-anim upload +/// gates on the mirror count, so a set that grew and shrank in one frame could skip an upload. const LIGHT_MIRROR_KEYS: [&str; UI_MODEL_CAM_LAYERS] = [ "ui_models", "ui_models_1", @@ -343,14 +191,9 @@ const LIGHT_MIRROR_KEYS: [&str; UI_MODEL_CAM_LAYERS] = [ "ui_models_7", ]; -/// The renderer's own fixed state: the ortho tiles' layer, and the light pool. -/// -/// **The pool is lazily grown and capped.** Slot 0 is the default scene and is built at startup; -/// a pane that arms `SetLight(1, …)` or fog takes the next free slot, and a scene past the cap -/// falls back to slot 0 (drawn as if it had armed nothing). Every slot costs a whole shared-light -/// buffer — several megabytes, most of it the palette and probe regions it must carry to be -/// bindable at all — so the pool is not a per-pane map: the shipped interface arms neither verb -/// on any pane, so in practice it holds exactly the one buffer the tiles have always had. +/// The ortho tiles' layer and the light pool, where a pane that arms `SetLight(1, …)` or +/// `SetFogColor` takes a slot. A slot costs a whole shared-light buffer, so the pool is capped +/// and a scene past the cap draws as slot 0, the default. #[derive(Resource)] struct TileRig { layer: RenderLayers, @@ -358,8 +201,7 @@ struct TileRig { } impl TileRig { - /// The pool slot for `scene`, growing the pool if it is new and there is room; `0` (the - /// default scene's) when there is not. + /// The pool slot for `scene`, grown if it is new and there is room, else slot 0. fn slot_for( &mut self, scene: TileScene, @@ -390,42 +232,36 @@ impl TileRig { } } -/// Marks the tile atlas's one **orthographic** camera — the leg every pane without a file camera -/// takes, and the camera that CLEARS the atlas (which is why it stays active whenever any cell is -/// packed, even with no ortho tile on it: the perspective cameras load, they never clear). +/// The atlas's orthographic camera, active whenever a cell is packed because it clears the atlas. #[derive(Component)] struct TileCamera; -/// One of the perspective pool's cameras — `slot` is its layer and its order offset, held for the -/// life of the app and aimed at whichever pane holds the slot this frame. +/// A perspective pool camera, aimed each frame at whichever pane holds its `slot`. #[derive(Component)] pub(crate) struct TilePerspectiveCamera { slot: usize, } -/// Marks a tile's root entity (the model root, at its cell or in its own perspective layer). +/// A tile's model root. #[derive(Component)] pub(crate) struct TileRoot; -/// One batch of a built tile whose alpha the file animates: sampled here off the pane's play -/// head (a hosted `MatAnim` would read the paused player, which has no node for a sequence -/// that keys no bone — the cooldown's sweep is exactly that). +/// A batch whose alpha the file animates, sampled off the pane's play head: a hosted `MatAnim` +/// reads the paused player, which has no node for a sequence that keys no bone, the cooldown's. struct AlphaPart { entity: Entity, anim: Arc, } -/// One batch whose texture transform animates: the tile's OWN clone of the batch's material -/// (two panes on one file must not share a row — two cooldowns at different fractions), with -/// the table rows it writes per frame off the pane's play head (decision 2019). +/// A batch whose texture transform animates, with the table rows it writes off the pane's play +/// head; the shader composes them as the reference does, `uv' = R((uv + t − p) ⊙ s) + p`. struct UvPart { - /// Held so the clone outlives its parts' handles by exactly the tile's lifetime. + /// Held so the clone lives exactly as long as the tile. #[allow(dead_code)] material: Handle, - /// The translation row: its slot, and the built seed the delta is measured from - /// (`sun_scale.zw`, the loop's sample at 0). + /// The translation row's slot and its seed (`sun_scale.zw`, the loop's sample at 0). trans: Option<(u16, [f32; 2])>, - /// The affine row's slot — rotation and scale ([`affine_row`]). + /// The affine row's slot: rotation and scale ([`affine_row`]). affine: Option, uv_anim: Option>, uv_seq: Option>>, @@ -434,9 +270,7 @@ struct UvPart { } impl UvPart { - /// Write this frame's rows for the sequence at `(seq_slot, cursor_s)` on the pane's clock - /// `gseq_s`: the translation delta (quantized like the world's lane), and the affine row - /// from the raw quaternion and the scale. + /// Write the translation delta, quantized like the world's lane, and the affine row. fn write_rows( &self, table: &mut MatAnimTable, @@ -489,42 +323,31 @@ impl UvPart { } } -/// A live tile: its entity tree and what it was built from. +/// A live tile: its entity tree and what it was built from. A new file, icon, light slot or +/// camera slot rebuilds it, since a twin binds one light buffer and every entity carries the layer. struct Tile { root: Entity, - /// The file key the tree was built for (a `SetModel` to another file rebuilds). key: String, - /// The icon override the tree was built with (a change rebuilds the materials). icon: Option, m2: Handle, - /// The tree is spawned (parts, rig, emitters) — until then the root is bare. + /// The tree (parts, rig, emitters) is spawned; until then the root is bare. built: bool, - /// The light-pool slot the tree's materials and emitters are bound to. A pane whose light or - /// fog changes lands on a different slot, and the tree is rebuilt against it — a material - /// twin points at exactly ONE light buffer. light_slot: usize, - /// The perspective pool slot this tile holds, and therefore its render layer — `None` for an - /// orthographic tile, which shares the atlas camera's one layer. Also a rebuild key: the - /// layer is on every spawned entity. + /// The perspective slot, and so the render layer, it holds; `None` on the orthographic leg. cam_slot: Option, /// The graph node per `AnimationData` id the file keys a bone for, and its file slot. clips: HashMap, - /// Which id the player is currently arming (to re-arm only on change). + /// The id the player has armed, re-armed only on change. armed: Option, - /// The mat-anim row this tile's materials read their **cell clip** from - /// (`anim_slots.w`, decision 2093): `[min.x, min.y, max.x, max.y]` in atlas texels, written - /// every frame from the cell. `None` only when the table was full at build — the tile then - /// draws unclipped, which is the pre-2093 picture rather than a missing widget. + /// The mat-anim row its materials read their cell clip from (`anim_slots.w`); `None` when the + /// table was full, and the tile draws unclipped. clip_slot: Option, alpha_parts: Vec, uv_parts: Vec, emitters: Vec, /// The last frame this tile was on the engine's paint list. last_seen: u64, - /// Parked: this tile drew no cell last frame, so its rig and its emitters are held (decision - /// 2046; the module doc's "what a pane that stops drawing costs"). A fresh tile starts - /// `false` and is parked by the same walk on its first non-drawing frame, so there is one - /// code path and no spawn-time special case. + /// It drew no cell, so its rig and emitters are held. parked: bool, } @@ -541,8 +364,7 @@ impl Tile { } } -/// Frames a tile survives off the paint list before its tree is torn down — long enough that a -/// cooldown that re-arms every few seconds, or a ping, keeps its tree. +/// Frames a tile survives off the paint list, so a re-arming cooldown or a ping keeps its tree. const TILE_LINGER_FRAMES: u64 = 600; /// The atlas edge the first tile allocates, and the cap a grown atlas stops at. @@ -552,47 +374,28 @@ const ATLAS_MAX: u32 = 4096; /// Gutter between cells (texels): a tile's bilinear edge never samples a neighbour. const GUTTER: u32 = 2; -/// The tile camera's order — after every booth (`-100 …`), before the UI camera (`1`). +/// The tile camera's order: after every booth (`-100 …`), before the UI camera (`1`). const TILE_CAMERA_ORDER: isize = -10; -/// The renderer's per-frame state that is not the bridge. -/// -/// **Most of this is about a VM, and the VM does not live for the process** (decision 1290): it -/// is built at world entry and destroyed at the character screen, and `ReloadUI()` is both edges -/// back to back. [`TileState::session`] is what keeps that honest — see [`TileState::adopt_vm`] -/// and [`TileState::answer_facts`]. +/// The renderer's state beyond the bridge, most of it one VM's: the VM is built at world entry, +/// dropped at the character screen and rebuilt by `ReloadUI()`. #[derive(Default)] struct TileState { - /// The VM these tiles and the bridge's handle-keyed maps belong to - /// ([`UiScript::session`]); `0` = none (the "no VM" branch, and a freshly built state). + /// The VM these tiles and the bridge's maps belong to ([`UiScript::session`]); 0 for none. session: u64, tiles: HashMap, /// Files the engine asked facts for, loading. pending_facts: HashMap>, - /// Files whose facts have been derived: the asset handle (which keeps the file resident for - /// the tiles) beside the facts themselves. - /// - /// **The facts are cached because they are owed to every VM, not to the process.** This map - /// is a HOST fact — "the file is loaded here" — and it stood in for the per-VM fact "this - /// VM's engine has been told about the file" until [`TileState::answer_facts`]; the module - /// doc of `crate::ui_script::session` names that class and why it has no error path. + /// Files whose facts are derived, beside the handle that keeps each resident. It records what + /// the host has loaded, not what a VM has been told: every new VM is still owed the facts. loaded: HashMap, ModelFileFacts)>, frame: u64, } impl TileState { - /// Hand the engine the facts for every file it just asked about, and answer with the keys - /// that still need loading (not resident here, and not already in flight). - /// - /// **Every ask gets an answer, whichever VM is asking.** `model_facts_wanted` DRAINS, and the - /// only thing that re-pushes a want is `SetModel` — so a want that is dropped is dropped for - /// the life of that VM. Skipping the answer because the file was already loaded *for an - /// earlier VM* is therefore permanent: [`UiScript::visible_model_panes`] drops a pane whose - /// file the engine knows nothing about, so from the second world entry on, every `` - /// widget in the game — the cooldown pie and the GCD sweep, the autocast shine, the minimap - /// and world-map pings, the item-push card, the map arrow — went dark until the process was - /// restarted. That is decision 1290's class exactly: a host memory standing in for a per-VM - /// one, failing silently, with the window simply empty. + /// Hand the engine the facts it asked for and return the keys still to load. Every ask is + /// answered, whichever VM asks: `model_facts_wanted` drains and only `SetModel` re-pushes, so + /// a skipped want keeps that VM's panes on the file off [`UiScript::visible_model_panes`]. fn answer_facts(&mut self, script: &mut UiScript) -> Vec { let mut to_load = Vec::new(); for key in script.model_facts_wanted() { @@ -603,7 +406,7 @@ impl TileState { script.set_model_facts(&key, facts.clone()); continue; } - // Already in flight: the landing below answers whichever VM is asking by then. + // Already in flight: its landing answers whichever VM is asking by then. if self.pending_facts.contains_key(&key) { continue; } @@ -612,17 +415,9 @@ impl TileState { to_load } - /// Adopt the VM `script` names — **forgetting every handle-keyed memory when it is a - /// different one than these tiles were built against**. - /// - /// A [`FrameHandle`] is a generational index into ONE VM's widget arena, and the VM is - /// rebuilt at every logout, login and `ReloadUI()` (1290/1291). The next VM starts a fresh - /// arena and reissues the SAME indices at the same generations, so a tile or a request that - /// outlives its VM is not merely stale: it names a different frame. `None` is "no VM" — a - /// session in its own right (session `0`), exactly as [`crate::ui_script::VmMemo`] treats the - /// character screen. - /// - /// **When** this runs is load-bearing: [`forget_dead_vm_tiles`], ahead of the extract. + /// Adopt the VM `script` names, forgetting every handle-keyed memory for a new one: its arena + /// reissues the same [`FrameHandle`]s, so a survivor would name a different frame. `None`, no + /// VM, is session 0. fn adopt_vm( &mut self, script: Option<&UiScript>, @@ -650,29 +445,27 @@ impl Plugin for UiModelsPlugin { app.init_resource::() .init_non_send_resource::() .add_systems(Startup, setup_tiles) - // **Before the extract**, which is where a `` pane's tile request is - // published (the UI pass's `paint_script`) — see [`forget_dead_vm_tiles`]. + // Ahead of the extract, which publishes the tile requests (`paint_script`). .add_systems( Update, forget_dead_vm_tiles.in_set(crate::ui_script::UiFeed), ) - // After the extract published this frame's requests, and before the pose/palette - // passes read the roots' transforms (they run in PostUpdate). + // After the extract publishes this frame's requests, before the pose and palette + // passes read the roots in PostUpdate. .add_systems( Update, sync_tiles .after(crate::ui_script::UiInput) .after(forget_dead_vm_tiles), ) - // The composite: this frame's cells, appended in the lane the minimap fill uses — - // after the cells are packed, before the mesh rebuild reads the lane. + // The composite, in the minimap fill's lane: after packing, before the mesh rebuild. .add_systems(Update, compose_tiles.in_set(UiQuadAppend).after(sync_tiles)) .add_systems(Update, reap_tile_variants) .add_systems(Update, dump_atlas.after(sync_tiles)); } } -/// Startup: the camera, the layer, the black light. +/// Startup: the tile cameras, the layer and the default black light. fn setup_tiles( mut commands: Commands, device: Res, @@ -685,13 +478,8 @@ fn setup_tiles( layer: layer.clone(), lights: Vec::new(), }; - // Slot 0 — the `` ctor's scene: light disabled, fog off (`0x76c8e0`). Built here - // rather than lazily because every shipped pane wants it and nothing else ever does. - // - // Tile rigs skin from a light buffer's palette region (decision 0720's mirror law), and the - // tiles' animated materials read their mat-anim rows from it too (decision 2023): a twin - // binds ITS buffer, not the world's, so every buffer the pool creates joins both mirror - // lists or the rows the tiles write every frame reach a buffer nothing in a tile samples. + // Slot 0, the `` ctor's scene (`0x76c8e0`). A twin binds its own light buffer, whose + // palette and mat-anim rows the tiles read, so every pool buffer joins both mirror lists. rig.slot_for( TileScene::default_scene(), &device, @@ -705,15 +493,12 @@ fn setup_tiles( booth_view_shape(), Camera { order: TILE_CAMERA_ORDER, - // The reference clears DEPTH for the widget's rect and leaves colour to the 2-D - // pass; a tile composites over the 2-D pass instead, so its colour clears to - // nothing — the premultiplied transparent the booth panes use (decision 1083). + // The reference clears only depth; a tile composites, so colour clears to transparent. clear_color: ClearColorConfig::Custom(Color::NONE), is_active: false, ..default() }, - // Decode, no scene glow: a UI model draws in the UI strata after the WorldFrame's - // FFX apply (decision 0638's law for the body panes, the same widget family). + // Decode, no scene glow: UI models draw after the WorldFrame's FFX apply. benilla_world::ffx_glow::FfxGlow::UI_PANE, Projection::Orthographic(OrthographicProjection { near: 0.1, @@ -728,21 +513,16 @@ fn setup_tiles( layer.clone(), TileCamera, )); - // The perspective pool: one camera per slot, each on its own layer, each parked inactive - // until a pane takes the slot. They are built once here rather than spawned on demand — a - // camera that appears mid-frame misses the render world's extract, and a pane that took its - // slot this frame would draw nothing until the next. + // The perspective pool, one inactive camera per slot on its own layer, spawned up front: a + // camera spawned mid-frame misses the render world's extract, so its pane would skip a frame. for slot in 0..UI_MODEL_CAM_LAYERS { commands.spawn(( Name::new(format!("ui model pane camera {slot}")), booth_view_shape(), Camera { order: TILE_CAMERA_ORDER + 1 + slot as isize, - // **Load, never clear.** These share the atlas with the orthographic camera, - // which runs first (a lower order) and clears the whole target; a second clear - // here would wipe the cells already drawn into it. Depth is a different matter - // and clears per camera by default, which is the reference's own per-widget - // depth clear (`76d5e1 GxClear(2)` inside the widget's viewport). + // Load, never clear: the orthographic camera clears the atlas first. Depth + // clears per camera, the reference's per-widget `GxClear(2)` (`0x76d5e1`). clear_color: ClearColorConfig::None, is_active: false, ..default() @@ -756,36 +536,15 @@ fn setup_tiles( let _ = light_buf; } -/// pipe_warm's **orthographic twin camera** (decision 2262) — the ortho leg's view key space, the -/// way [`crate::portrait::spawn_warm_booth`] is the custom-projection one (0958). -/// -/// bevy_pbr folds the view's projection **class** into `MeshPipelineKey` (`bevy_pbr-0.18.1` -/// `render/mesh.rs:397` — `Perspective | Orthographic | Custom`, emitted as the -/// `VIEW_PROJECTION_*` shader def at `:2549`), so one material is a *different pipeline* per -/// class. 0958's census closed with "the whole 3-D view space is `(samples, projection class)`, -/// and both classes of both sample counts are now warm" — true on 2026-08-04, when the only -/// classes were the world camera's Perspective and the booths' custom `WowPortraitProjection`. -/// Decision 2013 added [`setup_tiles`]' orthographic camera a month later and nothing widened the -/// warm pass, so the first UI model tile of a session — a cooldown pie, a minimap ping, an -/// item-push card — compiled its batches live, uncovered, on the render thread. -/// -/// This camera is that missing class in the tile camera's exact shape: [`booth_view_shape`] -/// (`Msaa::Off`, HDR, no tonemap) and `FfxGlow::UI_PANE`, spawned right beside the real one above -/// so the two cannot drift apart. The REAL tile camera is deliberately not borrowed for warming — -/// it is `is_active: false` until a pane packs a cell, and switching it on would draw the whole -/// menagerie into the live atlas. Only the projection's CLASS keys the pipeline, never its -/// numbers; they mirror the real camera's regardless, for the same anti-drift reason. +/// `pipe_warm`'s orthographic twin of [`setup_tiles`]' camera, kept in its shape: bevy_pbr keys +/// mesh pipelines on the projection class (`bevy_pbr-0.18.1` `render/mesh.rs:397`). The real one +/// is not borrowed, since switching it on would draw the warm models into the live atlas. pub(crate) fn spawn_warm_tile_cam( commands: &mut Commands, images: &mut Assets, ) -> (Entity, RenderLayers) { let layer = RenderLayers::layer(crate::portrait::WARM_ORTHO_LAYER); - // The atlas's own minimum size, the way `spawn_warm_booth` takes the real booths'. A render - // target's SIZE reaches no pipeline key (the combine pair is keyed on format; mesh pipelines - // specialise at queue time, before anything rasterises), so this could be tiny — and a 64² - // arm was measured against this one: 4.59 s vs 4.61 s of warm drain, i.e. nothing. The pass's - // extra cost is the wider cross it reveals, not the pixels this camera fills, so the size - // stays the one that matches the camera being warmed. + // The atlas's minimum size, like the camera it warms; size reaches no pipeline key. let image = images.add(new_target_image_sized(ATLAS_MIN, ATLAS_MIN)); let cam = commands .spawn(( @@ -813,9 +572,8 @@ pub(crate) fn spawn_warm_tile_cam( (cam, layer) } -/// The bevy-space → tile-camera-space rotation: WoW `+X` (bevy `−Z`) to the right, WoW `+Y` -/// (bevy `−X`) up, WoW `+Z` (bevy `+Y`) toward the viewer — the ortho leg's axes (`0x7ad7f0`). -/// A proper rotation (determinant +1), so winding survives. +/// Bevy space to tile-camera space: WoW `+X` right, `+Y` up, `+Z` toward the viewer, the ortho +/// leg's axes (`0x7ad7f0`). A proper rotation, so winding survives. fn wow_to_screen() -> Quat { Quat::from_mat3(&Mat3::from_cols( Vec3::new(0.0, -1.0, 0.0), @@ -824,7 +582,7 @@ fn wow_to_screen() -> Quat { )) } -/// The assets a tile build reads and writes, in one param (the 16-parameter ceiling). +/// The assets a tile build reads and writes, in one param under Bevy's 16-parameter ceiling. #[derive(bevy::ecs::system::SystemParam)] struct TileAssets<'w> { asset_server: Res<'w, AssetServer>, @@ -838,31 +596,23 @@ struct TileAssets<'w> { /// The render-side resources a tile build spends. #[derive(bevy::ecs::system::SystemParam)] struct TileRender<'w> { - /// The batch materials — and, through it, the material store the twins are added to (a - /// second `ResMut>` beside it would conflict at schedule time). + /// The batch materials, and through it the store the twins go in: a second + /// `ResMut>` beside it would conflict. mats: M2BatchMaterials<'w>, palettes: ResMut<'w, RigPalettes>, rig: ResMut<'w, TileRig>, - /// The shared mat-anim table: the tiles' animated materials own rows in it (2019). + /// The shared mat-anim table, where the tiles' materials own rows. table: ResMut<'w, MatAnimTable>, - /// The light pool grows lazily, so the device, the queue and both mirror lists have to be - /// reachable from the per-frame pass, not only from startup. + /// The light pool grows lazily, so the per-frame pass needs the device, queue and mirror lists. device: Res<'w, RenderDevice>, queue: Res<'w, RenderQueue>, mirrors: ResMut<'w, RigPaletteMirrors>, anim_mirrors: ResMut<'w, MatAnimMirrors>, } -/// **The VM edge** ([`TileState::adopt_vm`]) — its own system, and ordered **ahead of the -/// extract**, which is the whole reason it is not a first step inside [`sync_tiles`]. -/// -/// A logout, a login and a `ReloadUI()` all replace the VM in `PreUpdate`; the extract -/// (`ui_script`'s `paint_script`) then publishes the NEW tree's tile requests, and -/// `sync_tiles` reads them after that. Clearing the bridge from inside `sync_tiles` would -/// therefore throw away the new VM's very first publish — and the extract's conversion is -/// memoized on the engine's entry list, so an entry that does not change again is never converted -/// again and the request never comes back (decision 2023's defect, from the other side). Running -/// on the frame's way IN puts the clear before the publish instead of after it. +/// The VM edge ([`TileState::adopt_vm`]), ordered ahead of the extract: clearing the bridge in +/// [`sync_tiles`] would drop the new VM's first requests, and the memoized extract never re-sends +/// an unchanged one. fn forget_dead_vm_tiles( mut commands: Commands, script: Option>, @@ -928,9 +678,7 @@ fn sync_tiles( state.frame += 1; let frame = state.frame; let Some(mut script) = script else { - // No VM: nothing paints. The tiles were retired on the edge - // ([`forget_dead_vm_tiles`]); every frame after it must still composite nothing and - // leave no live camera. + // No VM: the edge retired the tiles; composite nothing and leave no camera live. bridge.cells.clear(); set_camera_active(&mut cams, false); for (mut cam, _, _, _, _) in &mut pane_cams { @@ -939,8 +687,7 @@ fn sync_tiles( return; }; - // ── 1. Facts: what the engine asked for — answered now when the file is already here - // ([`TileState::answer_facts`]), when it lands otherwise ───────────────────── + // ── 1. Facts: answered now if the file is resident, else when it lands ─────────────────── for key in state.answer_facts(&mut script) { if trace_on() { info!("tile-trace: facts for {key} wanted — loading the file"); @@ -956,13 +703,11 @@ fn sync_tiles( .collect(); for (key, handle) in landed { let Some(model) = assets.m2s.get(&handle) else { - continue; // not readable yet — stay in `pending_facts` rather than fall off the loop + continue; // not readable yet: it stays in `pending_facts` }; let facts = facts_of(model); script.set_model_facts(&key, facts.clone()); state.pending_facts.remove(&key); - // Cached beside the handle so the next VM's want is answered without re-reading the - // asset — and so the answer cannot depend on the asset still being resident. state.loaded.insert(key, (handle, facts)); } @@ -981,7 +726,7 @@ fn sync_tiles( .unwrap_or_default() ); } - continue; // not extracted yet — next frame + continue; // not extracted yet: next frame }; if req.size_px.x == 0 || req.size_px.y == 0 { if trace_on() { @@ -994,14 +739,10 @@ fn sync_tiles( } live.push((pane.handle, req.clone(), pane)); } - // Stable cell order: the engine's registry order (creation order), so a pane keeps its - // cell across frames. + // Cells go in the engine's registry (creation) order, so a pane keeps its cell. let _ = &live; // ── 2a. Which light rig, and which perspective slot ───────────────────────────────── - // - // The light pool dedups by scene, so every default pane — which is every shipped one — - // lands on slot 0 and shares one buffer and one twin cache, exactly as before 2027. let light_slots: Vec = live .iter() .map(|(_, req, _)| { @@ -1017,10 +758,7 @@ fn sync_tiles( ) }) .collect(); - // A perspective pane needs a camera of its own, and therefore a layer of its own. Slots are - // sticky: a pane that already holds one keeps it, so a tile is not rebuilt every frame for - // its layer, and the free ones go to whoever is new. A pane past the pool draws nothing — - // the same degrade as a tile that does not fit the capped atlas. + // Held slots stay put so no tile rebuilds for its layer; a pane past the pool draws nothing. let mut taken = [false; UI_MODEL_CAM_LAYERS]; let mut cam_slots: Vec> = live .iter() @@ -1051,7 +789,7 @@ fn sync_tiles( if trace_on() { info!("tile-trace: {} facts not landed (key {key})", req.path); } - continue; // facts not landed ⇒ the engine would not have listed it; defensive + continue; // facts not landed: the engine would not have listed it }; let (light_slot, cam_slot) = (light_slots[i], cam_slots[i]); let stale = state.tiles.get(handle).is_some_and(|t| { @@ -1111,14 +849,8 @@ fn sync_tiles( light_slot, &layer, ) { - // **Named by its PANE, and at debug.** A tile is per-widget by construction - // (`state.tiles` is keyed by `FrameHandle`, and 2019 requires it: two panes on - // one file must not share their `MatAnimTable` rows, or two cooldowns at - // different fractions would fight). So five cooldowns up at once legitimately - // build five tiles — which, logged at info with only the PATH, arrived as five - // byte-identical lines that read like a caching bug. The path alone also - // recurs: `TILE_LINGER_FRAMES` is ~10 s, so any longer cooldown rebuilds and - // re-logs at combat rate. + // At debug, by pane: five cooldowns build five tiles, and one longer + // than the linger rebuilds at combat rate. debug!( "ui_models: tile built for {} on pane {} — {} parts, {} emitters, \ {} animated alphas", @@ -1172,10 +904,8 @@ fn sync_tiles( .iter() .filter(|(h, r, _)| { state.tiles.get(h).is_some_and(|t| { - // A pane that wants the perspective leg and found no free camera holds no cell and - // draws nothing — the same degrade as a tile that does not fit the capped atlas. - // Falling through to the orthographic placement would draw it at that leg's pixel - // ladder, which for a creature is a wall of fur. + // A perspective pane with no free camera draws nothing: the orthographic + // ladder would draw it far too large. t.built && !(r.camera.is_some() && t.cam_slot.is_none()) }) }) @@ -1227,8 +957,6 @@ fn sync_tiles( continue; }; - // The play head: the engine's cursor drives the paused player, every alpha track, every - // material row — and, on the perspective leg, the camera's own authored path. let (armed, cursor_s, seq_slot) = match pane.play { Some(ph) => { let slot = tile.clips.get(&ph.anim_id).map(|&(_, s)| s); @@ -1238,25 +966,17 @@ fn sync_tiles( }; // ── The leg ──────────────────────────────────────────────────────────────────── - // - // PERSPECTIVE (`0x7ac640`): the file's own camera record frames the - // pane, through a camera of this tile's own whose viewport is the tile's cell. The root - // is `T(pos·layoutScale) · R(facing) · S(s)` in MODEL units — no pixel ladder, because - // the projection is the record's and the viewport is the cell — and the authored - // eye/target are carried through that same matrix, which is what `0x718960`'s publish - // does and why `SetModelScale` and `SetPosition` cancel for framing here. - // - // ORTHOGRAPHIC: unchanged since 2013 — the model laid over the cell at - // `1280 · modelScale · layoutScale` FrameXML units per model unit. + // Perspective (`0x7ac640`): the file's camera frames the pane from this tile's camera, + // viewport the cell, and the eye and target ride the model's root (`0x718960`). + // Orthographic: one model unit is `1280 · modelScale · layoutScale` FrameXML units. let perspective = tile.cam_slot.zip(req.camera).and_then(|(slot, idx)| { let cam = assets.m2s.get(&tile.m2)?.cameras.get(idx as usize)?; Some((slot, cam.clone())) }); let mut leg_trace = String::from("ortho"); if let Some((slot, cam)) = perspective { - // The camera's tracks read the file's ABSOLUTE timeline, like every other M2 track, - // while the pane's play head is a cursor inside the armed band — so the band start - // is what turns one into the other. Static on every camera a shipped pane can name. + // Camera tracks read the file's absolute timeline and the play head is a cursor in + // the armed band, so the band's start is added. let file_ms = seq_slot .and_then(|slot| assets.m2s.get(&tile.m2)?.sequences.get(slot)) .map_or(0, |seq| seq.start_ms) @@ -1270,9 +990,8 @@ fn sync_tiles( if marker.slot != slot { continue; } - // The target FIRST, and the activation only if there is one: these cameras are - // spawned with the default render target, which is the primary window — an - // active one without the atlas installed would draw the model over the game. + // The target first, and active only with one: the default target is the + // window, so an active camera without the atlas would draw over the game. let Some(atlas) = bridge.atlas.clone() else { continue; }; @@ -1311,17 +1030,15 @@ fn sync_tiles( ); } } else { - // The root: the cell's bottom-left in camera space, plus `SetPosition` in layout - // units. + // The root: the cell's bottom-left plus `SetPosition` (layout units), in camera space. let cell_bl = Vec2::new( cell.origin.x as f32, atlas_h - (cell.origin.y + cell.size.y) as f32, ); let pos = Vec2::new(req.position.x, req.position.y) * req.pos_px_per_unit; let depth = req.position.z * req.pos_px_per_unit; - // `T(pos) · R(facing about WoW +Z) · S(px per unit)`, in camera space: the facing - // turns about bevy `+Y` (WoW's `+Z` after `wow_to_bevy`), then the axis fix, then - // the scale. + // `T(pos) · R(facing about WoW +Z) · S(px per unit)`: the facing turns about bevy + // `+Y` (WoW's `+Z`), then the axis fix, then the scale. *tf = Transform { translation: Vec3::new(cell_bl.x + pos.x, cell_bl.y + pos.y, depth), rotation: wow_to_screen() * Quat::from_rotation_y(req.facing), @@ -1343,8 +1060,8 @@ fn sync_tiles( } } } - // The file slot the material tracks read: the armed sequence's, or the file's first - // when the armed id keys no bone (its slot is still in the facts' order — the cooldown). + // The file slot the material tracks read: the armed clip's, or, when the armed id keys + // no bone (the cooldown), the file's first sequence with that id. let seq_slot = seq_slot.or_else(|| { let id = armed?; let facts_slot = assets @@ -1357,14 +1074,11 @@ fn sync_tiles( facts_slot }); let gseq_s = pane.clock_ms as f64 / 1000.0; - // …and the BONE global-sequence channels read it too, not the world clock (decision - // 2046). The animation kernel's Phase B cursor is `[[model+0x2c]+0xc] − [model+0x68]` - // (`0x714260`) — the clock of the scene that OWNS the instance, minus the attach - // snapshot — and a `` widget owns a private `CM2Scene` (`CSimpleModel+0x314`) - // that only its own `OnUpdate` (`0x76d7f0`) advances. The visible case is the ping's - // 4833 ms spinner: its phase belongs to the pane, which is why "ping N resumes where - // ping N−1 stopped" needs no accumulator of ours (2013). The drive stamps its own anchor - // on its first tick, which is the attach. + // The bone global sequences read the pane's clock too: the kernel's cursor, + // `[[model+0x2c]+0xc] − [model+0x68]` (`0x714260`), is the owning scene's clock minus the + // attach snapshot, and a `` owns a private `CM2Scene` (`CSimpleModel+0x314`) that + // only its `OnUpdate` advances (`0x76d7f0`). So the ping's 4833 ms spinner resumes where + // the last ping stopped; the drive anchors on its first tick, the attach. if let Some(mut d) = drive { d.set_clock(gseq_s); } @@ -1384,8 +1098,7 @@ fn sync_tiles( trace_alphas.push(a); } if let Ok((mut tag, mut pvis)) = parts.get_mut(part.entity) { - // The `A ≤ 0` cull (`0x707b3a`): a batch the artist keyed - // off in this sequence is skipped, not drawn at zero. + // The `A ≤ 0` cull (`0x707b3a`): a batch keyed off in this sequence is skipped. let want = if a > 0.0 { Visibility::Inherited } else { @@ -1432,28 +1145,22 @@ fn sync_tiles( rows.join(", ") ); } - // A particle's half-extent is added in EYE space, so its unit is the leg's: the - // orthographic leg measures the cell in pixels and hands it `768·√(a²+1)` FrameXML units - // per model unit (`0x7b2a50`, carrying neither scale), while the perspective leg's eye - // space IS the root's, and its own projection does the conversion — the world's plain 1.0. + // A particle's half-extent is in eye space: the ortho leg's is the cell's pixels at + // `768·√(a²+1)` FrameXML units per model unit (`0x7b2a50`); the perspective leg's is 1.0. let star = if tile.cam_slot.is_some() && req.camera.is_some() { 1.0 } else { req.star_px_per_unit }; - // …and the CELL the cloud may draw in (decision 2093). A tile's particles are quads in - // the ATLAS's own space, so without this a cloud that reaches past its cell lands in the - // cell the shelf packed beside it — which the composite hands to a different widget - // (B379). `Cell::origin` is already in atlas texels, top-left origin, which is exactly - // the framebuffer coordinate the fragment tests. + // The cell the cloud may draw in, or it spills into its packed neighbour; `Cell::origin` + // is atlas texels, top-left, the framebuffer coordinate the fragment tests. let clip = Vec4::new( cell.origin.x as f32, cell.origin.y as f32, (cell.origin.x + cell.size.x) as f32, (cell.origin.y + cell.size.y) as f32, ); - // The MESH half of the same clip: the tile's own row, read by every one of its materials - // through `anim_slots.w`. + // The mesh half of the clip: the tile's row, read through `anim_slots.w`. if let Some(slot) = tile.clip_slot { render.table.set(slot, clip.to_array()); } @@ -1465,19 +1172,12 @@ fn sync_tiles( } } // ── 6. Park what is not drawing; thaw what is ────────────────────────────────────── - // - // "Drawing" is exactly "has a cell this frame" — the same set the placement loop above wrote. - // Parking is the module doc's contract: `AnimParked` holds the rig (the 0712 evaluator, the - // compose, the palette write and the global-sequence bone writes), the freeze holds every - // emitter's pool, age and quads, and the root is hidden so no batch draws. This walk replaces - // the `hidden` vector the placement loop used to `retain` out of once per drawing tile — the - // same verdict, without the quadratic. + // Drawing means holding a cell. Hiding the root stops neither the emitters nor the bone + // global sequences, so `AnimParked` holds the rig and the freeze holds each emitter. for (handle, tile) in state.tiles.iter_mut() { let park = !draws_this_frame(&bridge, handle); - // The emitter freeze is COMPARED, not edge-triggered off `tile.parked`: a tile can be - // built while it is already parked (its pane is on the paint list but its cell did not - // fit the capped atlas), and an emitter is born thawed. The comparison is a `Deref`, so - // a steady state touches no change tick. + // Compared, not edge-triggered off `tile.parked`: a tile can be built already parked, + // and an emitter is born thawed. Reading through `Deref` touches no change tick. for &e in &tile.emitters { if let Ok(mut em) = emitters.get_mut(e) { if em.is_frozen() != park { @@ -1503,9 +1203,8 @@ fn sync_tiles( } commands.entity(tile.root).insert(AnimParked); } else { - // The marker drops before `AnimationSystems` (this is `Update`, the lane is - // `PostUpdate`), so the first thawed frame evaluates and composes before anything - // reads the pose — 0739's wake law, the same as every world rig's. + // Removed in `Update`, ahead of `AnimationSystems` in `PostUpdate`, so the first + // thawed frame evaluates the pose before anything reads it. commands.entity(tile.root).remove::(); } } @@ -1515,19 +1214,17 @@ fn sync_tiles( cam.is_active = want; } } - // The ORTHOGRAPHIC camera stays on whenever anything is packed, even with no ortho tile on - // it: it is the one that clears the atlas, and every perspective camera loads. + // The orthographic camera clears the atlas for all, so it stays on while anything is packed. set_camera_active( &mut cams, !bridge.cells.is_empty() && bridge.atlas.is_some(), ); } -/// The perspective leg's rig for one pane — the pure half, so the reference's worked numbers can be -/// checked without a world. +/// The perspective leg's rig, pure so the reference's worked numbers test without a world. struct PerspectiveRig { - /// The model's root, `T(pos · layoutScale) · R(facing, +Z) · S(s)` in Bevy model space — - /// `0x76d1a0`'s `model+0xbc`, minus the pixel ladder the orthographic leg needs. + /// The model's root, `T(pos · layoutScale) · R(facing, +Z) · S(s)` in Bevy model space: + /// `0x76d1a0`'s `model+0xbc` without the orthographic leg's pixel ladder. root: Transform, /// The camera, `lookAt(eye, target, up)`, with the eye as the view origin. camera: Transform, @@ -1535,27 +1232,17 @@ struct PerspectiveRig { projection: WowPortraitProjection, } -/// Build it. +/// Build the rig. The authored eye and target ride the root transform (`0x718960` publishes +/// `eye = (position_base + posTrack) · M_root`), so `SetModelScale` and `SetPosition` cancel for +/// framing. Scale still matters: near and far are copied unscaled (`0x70ebd0`) while eye depth +/// scales with `s`, so a large scale pushes it through the far plane, a small one the near. /// -/// **The authored eye and target are carried through the root transform.** That is `0x718960`'s -/// publish — `eye_published = (position_base + posTrack) · M_root` — and it is the whole reason -/// `SetModelScale` and `SetPosition` cancel here: the camera moves with the model, so the framing -/// is invariant to both. They are still applied rather than skipped, because the record's near and -/// far are copied into the camera **unscaled** (`0x70ebd0`) while every eye-space depth scales -/// with `s`, so a LARGE `SetModelScale` drives the model through the **far** plane and a small one -/// through the near (the direction is the opposite of the obvious guess). `near`/`far` reach only -/// `m22`/`m32`: `near` cancels algebraically out of `m00`/`m11`, so the x/y screen scale is `fov` -/// and `aspect` alone. +/// `0x7ac640` builds up from `CCamera` fields the publish never writes, as +/// `(sin(a₆)·sin(roll), −cos(a₆)·sin(roll), cos(roll))`; `a₆` has no writer and stays 0, so at +/// roll 0 up is model-space `+Z`, the axis `SetFacing` turns about, and the facing cancels too. /// -/// The **up** vector does not ride the root: `0x7ac640` assembles it out of four `CCamera` fields -/// the publish never writes — `up = (sin(a₆)·sin(roll), −cos(a₆)·sin(roll), cos(roll))` in WoW -/// model space, and property `a₆` has **no writer image-wide**, so it holds its constructor `0` -/// for ever and the vector is `(0, −sin(roll), cos(roll))`. At `roll = 0` that is model-space `+Z` -/// exactly — which is the very axis `SetFacing` turns the model about, and *that* is why the facing -/// cancels here too: the eye, the target and the geometry all turn about an axis the up vector lies -/// on, so the image does not move. The reference's own one-frame publish lag — its paint `0x76d240` -/// reads the eye before it rebuilds the root, so the frame a facing CHANGES draws one step out of -/// phase — is a quirk of the ordering, not the mechanism, and is deliberately not reproduced. +/// Deviation: the reference's paint (`0x76d240`) reads the eye before it rebuilds the root, so a +/// facing change draws one frame late; not reproduced, because it is an ordering quirk. fn perspective_rig( record: &benilla_assets::PortraitCamera, req: &TileRequest, @@ -1571,8 +1258,7 @@ fn perspective_rig( m.transform_point3(record.eye), m.transform_point3(record.target), ); - // `up = (0, −sin(roll), cos(roll))`, WoW model space — the camera's own, not a roll about the - // view axis. They agree at `roll = 0`, which is every camera a `` pane can name. + // The camera's own up, not a roll about the view axis; the two agree at roll 0. let (sin_roll, cos_roll) = record.roll.sin_cos(); let up = benilla_assets::coords::wow_to_bevy([0.0, -sin_roll, cos_roll]); PerspectiveRig { @@ -1582,12 +1268,8 @@ fn perspective_rig( } } -/// Reap the material twins whose world source material died — the same law and the same event as -/// the booths' (`portrait::light::reap_dead_variants`), which the tiles were missing: a twin is -/// its own asset pinned only by this cache, so without the reap every UI M2 material a session -/// ever showed would survive a map-scope teardown (which clears the world material cache, killing -/// exactly these keys) for the life of the process. A live tile keeps its twin through its own -/// `MeshMaterial3d`; only the dedup entry drops. +/// Drop twin-cache entries whose world material died, as the booths do: the cache alone pins a +/// twin, which would outlive a map teardown; a live tile's `MeshMaterial3d` still holds its own. fn reap_tile_variants( mut events: MessageReader>, rig: Option>, @@ -1602,9 +1284,8 @@ fn reap_tile_variants( } } -/// The render layer a tile's entities live on: the atlas camera's shared one for an orthographic -/// tile, and the perspective pool's own for a tile with a camera — one layer per camera, or every -/// perspective camera would draw every other pane's model over its cell. +/// A tile's render layer: the atlas camera's for an orthographic tile, its own camera's for a +/// perspective one, or every perspective camera would draw every other pane's model. fn tile_layer(rig: &TileRig, cam_slot: Option) -> RenderLayers { match cam_slot { Some(slot) => RenderLayers::layer(UI_MODEL_CAM_LAYER_BASE + slot), @@ -1612,20 +1293,8 @@ fn tile_layer(rig: &TileRig, cam_slot: Option) -> RenderLayers { } } -/// `WOW_TILE_DUMP=:` — **shoot the tile atlas itself**, once, `secs` of app time in. -/// -/// The composited frame is the wrong place to read a `` widget: over an action button or a -/// bag slot the pane sits on the button's own art, so every measurement of what the widget drew is -/// a measurement of the icon underneath it plus a sub-pixel alignment guess. The atlas cell is the -/// widget ALONE, on transparent, at exactly the size the pane asked for — and the trace's -/// `cell=(x,y WxH)` says where each pane's is. Together they answer "what did this widget -/// actually paint", which nothing else here can (B379: the cooldown's sweep read as a filmstrip, -/// one cell per phase). -/// -/// The camera needs no waking, unlike the booths' twin (`portrait::test_bake::dump_booth_target`): -/// the orthographic tile camera is the one that CLEARS the atlas, so it is active whenever any -/// cell is packed — which is exactly when there is something to shoot. A dump that comes back -/// uniformly transparent means no pane was drawing, not a broken widget. +/// `WOW_TILE_DUMP=:`: save the tile atlas once, `secs` of app time in; an +/// all-transparent dump means no pane was drawing. fn dump_atlas( mut commands: Commands, bridge: Res, @@ -1644,7 +1313,7 @@ fn dump_atlas( return; } let Some(atlas) = bridge.atlas.clone() else { - return; // no atlas yet — wait for the first tile rather than shoot nothing + return; // no atlas yet: wait for the first tile }; *done = true; use bevy::render::view::window::screenshot::{Screenshot, ScreenshotCaptured}; @@ -1692,36 +1361,20 @@ fn set_camera_active( } } -/// The composite: one premultiplied quad per cell packed THIS frame, over its pane's rect at the -/// pane's paint key and alpha, clipped as the pane is — appended to the UI pass's overlay lane -/// every frame (the lane is cleared at the top of [`UiQuadAppend`] and diffed by the rebuild, so -/// an unchanged set costs no re-batch). A pane with a request and no cell draws nothing; a cell -/// whose request vanished (the linger reaper) draws nothing. +/// The composite, a premultiplied quad per cell packed this frame: drawn here, not by the +/// extract, whose memoized conversion would not re-run when a cell lands. pub(crate) fn compose_tiles(bridge: Res, mut quads: ResMut) { quads.overlays.extend(composite_quads(&bridge)); } -/// **Does this tile draw this frame?** — which is the park verdict inverted, and deliberately -/// ONE function beside [`composite_quads`] so the two cannot drift apart (decision 2046). -/// -/// The answer is "the bridge holds a cell for it", and that is exact rather than approximate: -/// [`composite_quads`] draws precisely the `cells ∩ requests` pairs, and [`sync_tiles`] fills -/// `cells` in the same pass that reads it — stage 4 clears the map, stage 5 inserts a cell for -/// every drawing pane the packer placed, and that insert happens BEFORE any of the loop's later -/// `continue`s. So the two cases that look as though they could strand a visible pane cannot: -/// -/// - an **atlas repack** (the 512→1024 growth) re-allocates the target image in a separate `if` -/// that does not touch the insert loop, so every pane the packer placed still gets its cell on -/// the repack frame; -/// - a pane that **did not fit the capped atlas** gets no cell at all ([`shelf_pack`] returns a -/// prefix of its input) — and therefore pushes no quad, so freezing its cloud is the right -/// answer rather than a dropped frame. +/// Whether a tile draws this frame, the park verdict inverted: it holds a cell, the set +/// [`composite_quads`] draws. [`sync_tiles`] inserts each placed pane's cell before any +/// `continue`, and a pane the capped atlas could not fit has none, so no drawing pane parks. fn draws_this_frame(bridge: &UiModelTiles, handle: &FrameHandle) -> bool { bridge.cells.contains_key(handle) } -/// [`compose_tiles`]'s pure half: the quads for every `(request, cell)` pair the bridge holds, -/// ordered by paint key so the overlay diff sees the same sequence for the same set. +/// [`compose_tiles`]'s pure half, sorted by paint key so the overlay diff sees a stable order. pub(crate) fn composite_quads(bridge: &UiModelTiles) -> Vec { let Some(atlas) = bridge.atlas.clone() else { return Vec::new(); @@ -1745,9 +1398,9 @@ pub(crate) fn composite_quads(bridge: &UiModelTiles) -> Vec { z_key: req.z_key, texture: Some(atlas.clone()), uv: UvRect::from_tex_coords([u0, u1, v0, v1]), - // The instance draws at the widget's OWN alpha (`0x76d120`). + // The widget's own alpha (`0x76d120`). color: [1.0, 1.0, 1.0, req.alpha], - // A render target: premultiplied by construction (`UiQuad` doc). + // A render target is premultiplied. premultiplied: true, clip: req.clip, ..default() @@ -1758,7 +1411,7 @@ pub(crate) fn composite_quads(bridge: &UiModelTiles) -> Vec { out } -/// The engine's facts for a resident file: its sequence table and header bounds. +/// The engine's facts for a resident file: its sequences, header bounds and camera count. fn facts_of(model: &M2Model) -> ModelFileFacts { ModelFileFacts { sequences: model @@ -1778,9 +1431,8 @@ fn facts_of(model: &M2Model) -> ModelFileFacts { } } -/// Shelf-pack `sizes` (with a gutter) into the smallest power-of-two square atlas from -/// [`ATLAS_MIN`] to [`ATLAS_MAX`] that fits; returns the cells (one per size that fit, in order) -/// and the atlas size chosen (`0×0` for no sizes). +/// Shelf-pack `sizes` into the smallest power-of-two square atlas from [`ATLAS_MIN`] to +/// [`ATLAS_MAX`] that fits: the cells that fit, in order, and the atlas size (`0×0` for none). fn pack(sizes: &[UVec2]) -> (Vec, UVec2) { if sizes.is_empty() { return (Vec::new(), UVec2::ZERO); @@ -1826,16 +1478,14 @@ fn shelf_pack(sizes: &[UVec2], edge: u32) -> Vec { /// What [`build_tile`] made. struct BuiltTile { clips: HashMap, - /// The tile's cell-clip row — see [`Tile::clip_slot`]. clip_slot: Option, alpha_parts: Vec, uv_parts: Vec, emitters: Vec, } -/// Spawn a file's parts, rig and emitters under `root` on the tile layer — the booth bake's -/// recipe (`portrait::booth::spawn_booth_model`) for a file with no unit. `None` when a material -/// is not resident yet (the caller retries next frame rather than latch a world-lit twin). +/// Spawn a file's parts, rig and emitters under `root`, the booth bake's recipe for a file with +/// no unit. `None` until the batch materials are ready; the caller retries next frame. fn build_tile( commands: &mut Commands, root: Entity, @@ -1854,19 +1504,15 @@ fn build_tile( let light = render.rig.lights.get(light_slot)?.buffer.clone(); let fogged = render.rig.lights[light_slot].scene.fog.is_some(); let layer = layer.clone(); - // The render forms, now (the booth/marker lanes' exception to the paced furnisher: one small - // model, on demand). + // The render forms now, unpaced: one small model, on demand. forms.ensure_now_rigged(handle, &model.submeshes, meshes); let built = forms.slices(handle); let (stat_forms, skin_forms) = (built.stat, built.skin.unwrap_or(&[])); - // The tile's **cell clip** row (decision 2093): one row per tile, `anim_slots.w` on every - // one of its materials, the cell rect written into it each frame. It is why a tile's batches - // are all its OWN clones rather than the shared twins — the twin is per (material, light), - // and the clip is per PANE. + // The cell-clip row, `anim_slots.w` on every material: the clip is per pane and a twin per + // (material, light), so every batch draws through a clone of its own. let clip_slot = render.table.alloc(); - // Materials first — every one must be resident before anything spawns, or a retry would - // leave half a tree behind. + // Materials first, so a `None` return leaves no half-built tree. let mut part_mats: Vec> = Vec::with_capacity(model.submeshes.len()); let mut uv_parts: Vec = Vec::new(); for (i, sub) in model.submeshes.iter().enumerate() { @@ -1876,11 +1522,8 @@ fn build_tile( sub.texture.clone() }; let world = render.mats.steady(sub, texture, (i + 1) as u16)?; - // The twin: same material, this rig's light buffer, and the batch's AUTHORED fog policy - // when the pane armed fog — which is where the per-material UNFOGGED bit does its own - // work (`0x70bb24`: a fogged pane still draws its unfogged materials unfogged). A - // pane with no fog forces it off, as every tile did before 2027. The shade selector both - // lanes flip is inert on an unlit batch, which is every shipped UI M2. + // The twin: this rig's light buffer, and when the pane armed fog the batch's authored + // fog policy, so an `UNFOGGED` material stays unfogged (`0x70bb24`); no fog forces it off. let lane = if fogged { VariantLane::RigFogged } else { @@ -1893,9 +1536,7 @@ fn build_tile( render.mats.materials(), lane, )?; - // A batch whose texture transform animates draws through a clone of its own, with its - // own table rows — the rows are written off THIS pane's play head, so two panes on one - // file cannot share them (decision 2019). + // An animated texture transform also gets its own table rows, off this pane's play head. let animated = sub.uv_anim.is_some() || sub.uv_seq.is_some() || sub.uv_rot_seq.is_some() @@ -1931,9 +1572,8 @@ fn build_tile( } } - // The rig: the collapsed pose buffer + a palette slot, when the file has bones. The tile - // camera is not the world camera, so bone billboards are left to the rest pose (none of the - // shipped UI files authors one). + // The rig, when the file has bones: the collapsed pose and a palette slot. Bone billboards + // are not applied, since the tile camera is not the world's; no shipped UI file has one. let mut pose: Option = None; let mut slot: u16 = 0; let mut clips: HashMap = HashMap::new(); @@ -1954,7 +1594,7 @@ fn build_tile( for c in &anims.clips { clips.entry(c.anim_id).or_insert((c.node, c.seq_index)); } - // A paused player: the engine's play head seeks it every frame (2007's clock). + // A paused player, seeked every frame to the engine's play head. let mut player = AnimationPlayer::default(); player.stop_all(); commands.entity(root).insert(( @@ -2004,9 +1644,9 @@ fn build_tile( } } - // The emitters: on their bone's anchor (the collapsed rig's demand-spawned entity), or the - // root for a boneless file; clocked by the root's player like any hosted cloud; lit by the - // tile's buffer; sized in the tile's pixels (`set_size_scale`, written per frame). + // The emitters: on their bone's anchor, or the root for a boneless file; clocked by the + // root's player, sized per frame, lit by the tile's buffer but not fogged by it (the effect + // lane reads its own fog uniform), so a fogged pane's cloud draws unfogged. let mut emitters = Vec::new(); for em in &model.emitters { let (owner, pivot) = match pose.as_mut() { @@ -2041,9 +1681,7 @@ fn build_tile( if let Some(p) = pose { commands.entity(root).insert((p, StageRig)); } - // `spawn_anim_host` is the world's placement recipe (variation re-rolls, the residency - // window); a widget arms exactly what Lua asked and nothing else, so it is not used here — - // named so nobody reaches for it. + // Not `spawn_anim_host`, the world's placement recipe: a widget arms exactly what Lua asked. let _ = spawn_anim_host; Some(BuiltTile { clips, @@ -2058,8 +1696,6 @@ fn build_tile( mod tests { use super::*; - /// The packer keeps every cell inside the atlas, gutters between, and answers cells in - /// request order — the composite's UVs and the roots' placements both index by it. #[test] fn the_packer_keeps_cells_apart_and_in_order() { let sizes: Vec = (0..40).map(|_| UVec2::new(72, 72)).collect(); @@ -2077,8 +1713,6 @@ mod tests { assert!(apart, "cells {i} and another overlap or touch"); } } - // Growth: a wall of big tiles needs a bigger atlas; an impossible one is capped and - // the tail simply does not fit. let big: Vec = (0..8).map(|_| UVec2::new(400, 400)).collect(); let (cells, atlas) = pack(&big); assert_eq!(cells.len(), 8); @@ -2089,13 +1723,9 @@ mod tests { assert_eq!(atlas, UVec2::splat(ATLAS_MAX)); } - /// The composite is a function of the bridge alone (decision 2023): a request with no cell - /// draws nothing, a cell draws its request's rect at the request's key and alpha with the - /// cell's texel window, and a cell whose request is gone draws nothing — no extract in the - /// loop. #[test] fn the_composite_is_the_bridges_cells_over_their_requests() { - // Two live handles off a real arena — the bridge is keyed by them, nothing more. + // Two real handles; the bridge only keys by them. let mut arena = benilla_ui::widget::WidgetArena::new(); let handle = arena.create(benilla_ui::widget::FrameKind::Frame, None, None); let stray = arena.create(benilla_ui::widget::FrameKind::Frame, None, None); @@ -2156,13 +1786,6 @@ mod tests { assert!((br[0] - 130.0 / 512.0).abs() < 1e-6 && (br[1] - 65.0 / 512.0).abs() < 1e-6); } - /// **The park verdict and the composite are the same question** (decision 2046). A tile - /// parks exactly when it pushed no quad, and `sync_tiles` reads both off `bridge.cells` in one - /// pass — so neither an atlas repack nor an atlas too full for one more pane can freeze a - /// cloud on a frame its pane is visibly drawing. This pins the two sides to each other: what - /// [`composite_quads`] draws IS the un-parked set, so a later change to either has to change - /// both. The third pane here is the one the capped atlas had no room for: it draws nothing, - /// which is exactly why parking it is right. #[test] fn the_park_verdict_is_exactly_what_the_composite_draws() { let mut arena = benilla_ui::widget::WidgetArena::new(); @@ -2218,8 +1841,7 @@ mod tests { "pane {i}: the park verdict and the composite must agree" ); } - // And the repack: growing the atlas moves every cell's texel window without changing WHO - // has one, so no pane's verdict flips on a growth frame. + // A repack moves texel windows without changing who has a cell. bridge.atlas_size = UVec2::splat(1024); let regrown: std::collections::HashSet = composite_quads(&bridge).iter().map(|q| q.z_key).collect(); @@ -2251,8 +1873,8 @@ mod tests { } } - /// One freshly built VM with a shown cooldown pane on it — the shape `Cooldown.xml` builds - /// per action button, and the shape `!OmniCC` wraps `CooldownFrame_SetTimer` around. + /// A fresh VM with a shown cooldown pane, the `CooldownFrameTemplate` (`Cooldown.xml`) every + /// action button inherits. fn vm_with_a_cooldown_pane() -> UiScript { let mut s = UiScript::new().expect("VM"); s.set_screen_size(1024.0, 768.0); @@ -2266,23 +1888,14 @@ mod tests { s } - /// **A rebuilt VM is told about a file the host already loaded** — decision 1290's class, - /// found in the tile renderer's facts cache. - /// - /// `UiScript::model_facts_wanted` DRAINS, and the only thing that re-pushes a want is - /// `SetModel`; `visible_model_panes` drops a pane whose file the engine holds no facts for. - /// So an answer skipped once is skipped for that VM's whole life. The host used to skip it - /// whenever the ASSET was resident — a fact about the process, not about the VM — and the VM - /// is rebuilt at every logout, login and `ReloadUI()`. From the second world entry on, every - /// `` widget in the game (the cooldown pie and the GCD sweep, the autocast shine, the - /// minimap and world-map pings, the item-push card, the map arrow) drew nothing at all, and - /// only restarting the client brought them back. + /// `model_facts_wanted` drains and only `SetModel` re-pushes, so a want skipped because the + /// asset is resident would leave every `` pane dark for the second VM's whole life. #[test] fn a_rebuilt_vm_is_told_about_a_file_the_host_already_loaded() { let key = benilla_ui::widget::model_key(COOLDOWN_FILE); let mut state = TileState::default(); - // Session 1 — nothing is resident, so the host is asked to load the file. + // Session 1: nothing is resident, so the host is asked to load the file. let mut first = vm_with_a_cooldown_pane(); assert!( first.visible_model_panes().is_empty(), @@ -2296,7 +1909,7 @@ mod tests { first.set_model_facts(COOLDOWN_FILE, cooldown_facts()); assert_eq!(first.visible_model_panes().len(), 1); - // Session 2 — the logout/login (or `/reload`) rebuild: a fresh VM, the same file. + // Session 2, the logout/login (or `/reload`) rebuild: a fresh VM, the same file. let mut second = vm_with_a_cooldown_pane(); assert!( second.visible_model_panes().is_empty(), @@ -2317,12 +1930,6 @@ mod tests { ); } - /// **A new VM inherits nothing keyed by a `FrameHandle`** — the same edge, its other half. - /// - /// A handle is a generational index into ONE arena; the next VM reissues the same indices at - /// the same generations, so a surviving tile or request does not go stale, it silently - /// re-attaches to a different frame. The tile's tree is despawned and its mat-anim rows go - /// back to the table, exactly as a reaped tile's do. #[test] fn a_new_vm_inherits_nothing_keyed_by_a_frame_handle() { let mut world = World::new(); @@ -2421,8 +2028,7 @@ mod tests { ); } - /// A `TileRequest` for the perspective tests: the pane's own size and root terms, nothing - /// the leg does not read. + /// A perspective-leg `TileRequest` with only the terms the leg reads. fn persp_req(size: UVec2, root_scale: f32, root_pos: Vec3, facing: f32) -> TileRequest { TileRequest { path: String::new(), @@ -2445,8 +2051,8 @@ mod tests { } } - /// `HumanMale`'s camera 1 as `benilla-extract m2cam` reads it — and as the reference records - /// it, to the digit. + /// `HumanMale`'s camera 1 as `benilla-extract m2cam` reads it, matching the reference to the + /// digit. fn human_male_cam1() -> benilla_assets::PortraitCamera { let wow = benilla_assets::coords::wow_to_bevy; benilla_assets::PortraitCamera { @@ -2459,13 +2065,10 @@ mod tests { } } - /// The perspective leg's projection is the client's `0x5c3cc0`: a **diagonal** fov, so - /// `t = tan(fovy / (2·√(aspect²+1)))`, `m11 = 1/t`, `m00 = m11/aspect` — not a vertical fov, - /// and not an aspect-independent crop. - /// - /// The numbers are the reference's own worked checks: a `318×224` pane gives - /// `θ = 0.287938 · fov` and a `233×224` pane `θ = 0.346523 · fov`. The fallback camera's check: - /// `aspect = 1.4196429`, `√(aspect²+1) = 1.7364815`, `tan(0.5/(2·1.7364815)) = 0.1449700`. + /// The client's `0x5c3cc0`, a diagonal fov: `t = tan(fovy / (2·√(aspect²+1)))`, + /// `m11 = 1/t`, `m00 = m11/aspect`. The numbers are the reference's worked checks: + /// `θ = 0.287938 · fov` at 318×224, `0.346523 · fov` at 233×224, and `t = 0.1449700` for the + /// fallback camera's `fov = 0.5`. #[test] fn the_perspective_projection_is_the_clients_diagonal_fov_matrix() { use bevy::camera::CameraProjection; @@ -2482,11 +2085,8 @@ mod tests { let aspect: f32 = 318.0 / 224.0; assert!((aspect - 1.419_642_9).abs() < 1e-6); let m = pane_projection(&cam, aspect).get_clip_from_view(); - // `m11 = 1/t` with `t = tan(θ)`; the fallback check's `t` is 0.1449700. let t = 1.0 / m.y_axis.y; assert!((t - 0.144_97).abs() < 1e-5, "t = {t}"); - // …and `m00 = m11 / aspect` — the one `aspect` doing both jobs, never 1.0 (a transplanted - // 1.0 would stretch a sphere 1.42× wider than tall in this pane). assert!( (m.x_axis.x - m.y_axis.y / aspect).abs() < 1e-6, "m00 = m11/aspect: {} vs {}", @@ -2501,14 +2101,8 @@ mod tests { } } - /// **`SetModelScale` and `SetPosition` CANCEL on the perspective leg** (`0x718960`): the - /// authored eye and target are carried through the very root transform the geometry is drawn - /// through, so the picture is invariant to both. This is the property the leg is built on, and - /// the failure mode it guards is applying the root to the geometry but not to the camera, - /// which is wrong by `1/s`. - /// - /// The check is on the pixels, not on the matrices: a model-local point's clip-space `x/w` - /// and `y/w` must be identical at any scale and any offset. + /// The eye and target ride the root the geometry is drawn through (`0x718960`), so a model + /// point's clip-space `x/w` and `y/w` do not move with scale or offset. #[test] fn scale_and_position_cancel_on_the_perspective_leg() { use bevy::camera::CameraProjection; @@ -2560,26 +2154,16 @@ mod tests { } } - // …and the ORTHOGRAPHIC leg is the opposite: its scale is a pixel ladder with no camera - // to cancel against, so `px_per_unit` is exactly what the model's size is measured in. - // (Stated as the contrast, so nobody carries the cancellation across the fork.) + // On the orthographic leg scale does not cancel: `px_per_unit` is the model's size. assert_ne!( persp_req(UVec2::new(318, 224), 1.0, Vec3::ZERO, 0.0).px_per_unit, 0.0 ); } - /// **`SetFacing` cancels too** (on ``'s FROZEN camera, `0x7acf10`, only - /// `SetFacing`/`SetRotation` show; that does not hold here). - /// - /// The reason is the up vector: `0x7ac640` builds it as `(0, −sin(roll), cos(roll))` in model - /// space out of `CCamera` fields the publish never writes, and at `roll = 0` — every camera a - /// `` pane can name — that is model-space `+Z`, the very axis `SetFacing` turns about. - /// Eye, target, geometry and up all turn together, so the image does not move. - /// - /// A re-implementation that rotated the model without rotating its camera would spin the - /// model; one that rotated both but kept a screen-space up would tilt it. Both are wrong, and - /// both look plausible, which is why this is pinned on the pixels. + /// At roll 0 the up `0x7ac640` builds is model-space `+Z`, the axis `SetFacing` turns about, so + /// eye, target, geometry and up turn together; on ``'s frozen camera (`0x7acf10`) + /// a facing does show. #[test] fn facing_cancels_on_the_perspective_leg_too() { use bevy::camera::CameraProjection; @@ -2612,18 +2196,15 @@ mod tests { ); } } - // The ORTHO leg is where a facing shows: there the model turns in the screen plane and - // nothing turns with it. Same widget field, opposite outcome — the fork is the point. + // On the orthographic leg a facing turns the model in the screen plane. let turned = wow_to_screen() * Quat::from_rotation_y(std::f32::consts::FRAC_PI_2) * benilla_assets::coords::wow_to_bevy([1.0, 0.0, 0.0]); assert!((turned - Vec3::Y).length() < 1e-5, "{turned}"); } - /// The camera's eye lands where the record says, in Bevy space, and the view looks down the - /// eye→target axis with the model's up (WoW `+Z`) up — the `lookAt` half of the leg, checked - /// against `HumanMale`'s camera 1: the eye is 4.02 model units in front of the target on the - /// model's own `+X`, at chest height. + /// `HumanMale`'s camera 1: the eye 4.02 model units in front of the target on the model's own + /// `+X`, at chest height, looking at it with WoW `+Z` up. #[test] fn the_perspective_camera_is_the_records_lookat() { let cam = human_male_cam1(); @@ -2639,16 +2220,13 @@ mod tests { let fwd = leg.camera.forward().as_vec3(); let want = (cam.target - cam.eye).normalize(); assert!((fwd - want).length() < 1e-5, "{fwd:?} vs {want:?}"); - // The distance the record authors — the whole mechanism behind "the pane looks - // normalized" (measured over the shipped models: Blizzard authored a per-model distance). + // Each shipped model's record authors its own eye distance: that is what normalizes panes. let d = (cam.target - cam.eye).length(); assert!((d - 4.0234).abs() < 1e-3, "authored eye distance {d}"); // Up is the model's own up, not the camera's roll-free default in some other frame. assert!(leg.camera.up().as_vec3().dot(Vec3::Y) > 0.9); } - /// The axis fix is a proper rotation that puts WoW `+X` right, `+Y` up, `+Z` toward the - /// viewer. #[test] fn the_axis_fix_is_the_ortho_legs_frame() { let q = wow_to_screen(); diff --git a/crates/benilla-app/src/ui_pass.rs b/crates/benilla-app/src/ui_pass.rs index 744f393b..8cc6cd25 100644 --- a/crates/benilla-app/src/ui_pass.rs +++ b/crates/benilla-app/src/ui_pass.rs @@ -1,69 +1,18 @@ -//! The player-UI quad pass (decision 0068 §2): a custom sorted-quad renderer for the WoW-engine UI, -//! **not** `bevy_ui`'s flexbox/hierarchy z-model — probe B (2026-07-02) measured `bevy_ui` fighting the -//! WoW `(stratum, level, layer, sublayer, decl)` total order and chose a dedicated pass instead. This -//! module is the render **substrate** only: [`UiQuad`]/[`UiQuads`] is the data contract the future -//! widget arena (frames, `FontString`s, the anchor resolver) builds against — nothing here knows about -//! frames, strata, or Lua. A caller flattens its own ordering into `z_key: u64` (a WoW frame's -//! `(stratum, level, layer, sublayer, decl)` tuple packs into one `u64` losslessly) and this pass just -//! draws the resulting flat list back-to-front. +//! The player-UI quad pass: a sorted-quad renderer for the WoW UI, not `bevy_ui`. Producers +//! flatten a frame's `(stratum, level, layer, sublayer, decl)` order into [`UiQuad::z_key`]. //! -//! ## Camera / compositing order (decision 0025) -//! Three cameras share the window, ordered low→high: the 3D world camera (order 0, untouched) → this -//! module's [`PlayerUiCamera`] (order 1) → the egui dev-overlay camera (bumped from 1 to 2 in -//! `debug_panel::spawn_egui_camera` — the one sanctioned edit outside this file). 0025 established -//! "dev overlays composite over a full-screen world"; this pass slots the *player* UI into that -//! ordering exactly where 0025 already reserved it ("where player-UI arbitration will live later") — -//! **dev overlays stay on top of the player UI, which stays on top of the world.** The camera renders -//! nothing from the 3D world (its own [`RenderLayers`] layer, disjoint from the world camera's default -//! layer 0); the world reaches it as the first DRAW of its main pass — the FFXGlow combine, -//! `benilla_world::ffx_glow::FfxBackdrop` ([`crate::world_backdrop`], decision 2234) — and its -//! finished frame reaches the swapchain through [`crate::ui_gamma`]'s decode directly — output -//! mode `Skip`, no blit (decision 2206). +//! [`PlayerUiCamera`] (order 1) sits over the offscreen world camera (0) and under the egui dev +//! overlay (2). Its main pass draws the world first ([`crate::world_backdrop`]), then the quads, +//! and [`crate::ui_gamma`]'s decode writes the swapchain with no blit. //! -//! ## Colour space: the UI gamma composite lane (decision 0254) -//! The reference draws its whole UI through the fixed-function device into an 8-bit backbuffer, so -//! every UI multiply and every UI blend is arithmetic on **gamma bytes**, clamped at each write. -//! This pass reproduces that: [`UiQuad::color`] rides through unconverted, `ui_quad.wgsl` puts its -//! sampled texel back into byte space and premultiplies there, and the hardware blend composes in -//! gamma. [`crate::ui_gamma`] then decodes the finished image to linear exactly once, so the sRGB -//! target's write re-encodes it to the client's byte. This is the UI sibling of decision 0161's -//! world lane, whose one decode lives in the FFXGlow combine. +//! The reference draws its UI through the fixed-function device into an 8-bit backbuffer, so every +//! UI multiply and blend is gamma-byte arithmetic, clamped at each write. So is this pass, decoded +//! to linear once at the end; composited in linear, an `ADD` quad lands at about a quarter of the +//! reference's lift. //! -//! Getting this wrong is not a subtlety: composited in linear, `alphaMode="ADD"` lands at roughly a -//! quarter of the reference's lift (sRGB's power law is superadditive, so a linear add is always -//! weaker than a byte add) — the near-invisible gossip/quest/trainer row highlight that forced 0254. -//! -//! ## Rendering approach -//! Quads are stable-sorted by `z_key` ascending (the WoW total order: lower key = further back). CPU -//! clip (an axis-aligned intersection against [`UiQuad::clip`], reprojecting UVs proportionally) is -//! applied per quad; a quad clipped to nothing emits no geometry. The sorted, clipped list is then -//! split into **contiguous runs of matching texture identity** (texture-less quads use a shared 1×1 -//! white image, so they participate in the same run-splitting as textured ones) — each run becomes one -//! `Mesh2d`/`ColorMaterial` entity. Painter's order within a run comes from vertex/index submission -//! order in the single draw call (straight-alpha blending composites strictly in submission order, no -//! depth test); painter's order **across** runs comes from each run's entity `Transform.z` — Bevy's 2D -//! transparent phase sorts draws by ascending world-space z (`bevy_sprite_render`'s -//! `mesh2d::material::queue_material2d_meshes`), so runs are placed at increasing z in the same order -//! they appear in the sorted quad list. This is what makes the *contiguous-run* choice load-bearing -//! rather than a pure optimization: batching is scoped to a run, not deduped globally by texture, so -//! the total order holds **even when two quads share a texture but something else is sandwiched -//! between them in z_key** — that in-between quad still forces a run break, so the shared texture ends -//! up in two (or more) separate draw calls rather than one. The batching consequence: UI content that -//! interleaves textures frequently in z-order (e.g. icon, text, icon, text, …) pays one draw call per -//! transition instead of the two-call minimum a global per-texture dedup would give — correctness over -//! draw-call count. A texture atlas is the standard fix (turns "different texture" into "different UV -//! rect on one atlas", collapsing runs) and is a natural extension once the widget arena needs it. -//! -//! ## Explicitly out of scope (v1) -//! - **Real scissor rects.** [`UiQuad::clip`] is a CPU stand-in (rebuild-time geometry clip); a -//! `ScrollFrame`-driven GPU scissor rect (per 0068 §2, "per-ScrollFrame scissor rects") is a later -//! render-pass change, not a data-contract change — `clip` can stay as an escape hatch for the rare -//! non-rectangular-region case even after real scissor lands. -//! - **Text.** `FontString`s render through `cosmic-text` per 0068 §2; nothing here rasterizes glyphs. -//! - **Extraction from a widget arena.** Nothing yet *produces* [`UiQuad`]s from frames/anchors/strata — -//! that's the widget-arena milestone this pass is built ahead of. (A `WOW_UI_DEMO` feeder once -//! filled [`UiQuads`] with synthetic strata to prove the pass in isolation; the widget engine has -//! been the producer since, and the feeder retired — 2335.) +//! Quads are stable-sorted by `z_key`, CPU-clipped and split into contiguous runs of one texture +//! and material state, one `Mesh2d` draw each. A run never spans a quad of another state, so the +//! total order holds and interleaved textures pay one draw per switch. use bevy::asset::RenderAssetUsages; use bevy::camera::visibility::RenderLayers; @@ -80,22 +29,12 @@ use bevy::shader::ShaderRef; use bevy::sprite_render::{Material2d, Material2dKey, Material2dPlugin}; use bevy::window::PrimaryWindow; -/// Per-corner UVs for a quad: one explicit `(u,v)` sample per **screen** corner, in the -/// [`Run::push_quad`] winding — `[top-left, top-right, bottom-right, bottom-left]`. Deliberately four -/// independent pairs, **not** a `(min,max)` rect, for two reasons that a 2-corner rect can't serve: -/// -/// 1. **Mirror/flip preservation.** A WoW `` (the PlayerFrame -/// ring's horizontal mirror) has `left > right` on purpose; a normalizing `Rect` would silently -/// un-mirror it. Corners carry `TL.u > TR.u` (and top>bottom for a vertical flip) intact. -/// 2. **Rotation.** A backdrop TOP/BOTTOM edge maps atlas-**u** to screen-**Y** and atlas-v to -/// screen-X (the slice is turned 90°). That's not expressible as `u varies only in x, v only in y` -/// — it needs a distinct `(u,v)` at each corner, which this carries and [`clip_quad`] bilinearly -/// reprojects. -/// -/// This subsumes the old `{u0,v0,u1,v1}` two-corner form (`ui: mirrored TexCoords`, commit 87c97d7). +/// One `(u, v)` per screen corner, in [`Run::push_quad`]'s winding (top-left, top-right, +/// bottom-right, bottom-left), not a `(min, max)` rect: a `` with `left > right` (the +/// PlayerFrame ring) must stay mirrored, and a backdrop top or bottom edge maps atlas u to screen +/// Y, which only per-corner UVs express. #[derive(Clone, Copy, PartialEq)] pub(crate) struct UvRect { - /// `[top-left, top-right, bottom-right, bottom-left]`, each `[u, v]` — the `push_quad` winding. pub corners: [[f32; 2]; 4], } @@ -105,159 +44,80 @@ impl UvRect { corners: [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], }; - /// From a WoW `[left, right, top, bottom]` `` tuple: an axis-aligned crop - /// (`TL=(left,top)`, `TR=(right,top)`, `BR=(right,bottom)`, `BL=(left,bottom)`). No - /// normalization — a mirrored (`left>right`) or flipped (`top>bottom`) tuple keeps its - /// orientation, which is the whole point of this type. + /// From a `` `[left, right, top, bottom]` tuple, unnormalized, so a mirror survives. pub fn from_tex_coords([left, right, top, bottom]: [f32; 4]) -> Self { Self { corners: [[left, top], [right, top], [right, bottom], [left, bottom]], } } - /// From explicit per-corner UVs in the `push_quad` winding (`[TL, TR, BR, BL]`) — the backdrop - /// edge/corner pieces, whose rotated slices are not an axis-aligned crop. + /// From explicit corners in the `push_quad` winding: the backdrop's rotated edge slices. pub fn from_corners(corners: [[f32; 2]; 4]) -> Self { Self { corners } } - /// From an already-normalized [`Rect`] (e.g. a glyph atlas cell, which is never mirrored): - /// `min→TL`, `max→BR`. + /// From an already-normalized [`Rect`], such as a glyph atlas cell: `min` to TL, `max` to BR. pub fn from_rect(r: Rect) -> Self { Self::from_tex_coords([r.min.x, r.max.x, r.min.y, r.max.y]) } } -/// One resolved, screen-space quad — the render substrate's entire input. A future widget arena -/// (frames/anchors/strata) flattens into this; nothing here is frame-shaped. +/// One resolved screen-space quad, the pass's whole input. `texture`, the flags from `additive` +/// to `uv_clamp`, and `mask` are material state: a quad differing in any of them starts a new run. #[derive(Clone, PartialEq)] pub(crate) struct UiQuad { - /// Screen pixels, **y-down**, already anchor-resolved (no relative/parent math left to do). + /// Screen pixels, y-down, anchor-resolved. pub rect: Rect, - /// Total paint order — stable-sorted ascending, so **higher draws later (on top)**. A WoW frame's - /// `(stratum, level, layer, sublayer, decl)` tuple is expected to pack into this losslessly. + /// Paint order, stable-sorted ascending: higher draws on top. pub z_key: u64, - /// `None` draws as flat-shaded (via a shared 1×1 white texture — see the module doc's batching - /// note for why texture-less quads still participate in texture-identity run-splitting). + /// `None` samples the shared 1×1 white. pub texture: Option>, - /// The texture sub-rect (atlas cell / crop) as raw corners — see [`UvRect`] for why this is not a - /// [`Rect`] (mirror preservation). Default: the full texture ([`UvRect::FULL`]). pub uv: UvRect, - /// Straight-alpha (non-premultiplied) vertex color, multiplied against the sampled texel. - /// **Client-space sRGB** — the raw FrameXML/Lua value (the client's FFP wrote these straight to - /// its 8-bit backbuffer). It reaches the shader UNCONVERTED (decision 0254): the quad pass - /// composites in gamma bytes, so this multiply *is* the client's gamma-space `tint × texel`. - /// Producers never pre-convert. + /// Straight-alpha tint in client-space sRGB, the raw FrameXML/Lua value, never converted. pub color: [f32; 4], - /// WoW `ADD` blend (EGxBlend 3, `glBlendFunc(GL_SRC_ALPHA, GL_ONE)`: highlights, glows) instead - /// of straight alpha. Splits the batch run — see [`ui_quad.wgsl`]'s premultiplied trick for how - /// one pipeline serves both. + /// WoW `ADD` blend (EGxBlend 3, `glBlendFunc(GL_SRC_ALPHA, GL_ONE)`) instead of straight alpha. pub additive: bool, - /// Mask to the quad's inscribed circle (the live unit portrait — the shader-side twin of the - /// real client's round bake stencil). Splits the run like `additive`; in practice a portrait's - /// render-target texture is unique to it, so no batching is actually lost. + /// Mask to the inscribed circle: the live unit portrait. pub circular: bool, - /// Draw the sampled texel as its **luminance** — `Texture:SetDesaturated(1)`, the greyed-out - /// icon every disabled affordance in the reference wears (decision 1327). Folded in the - /// client's GAMMA byte space and applied BEFORE the vertex-colour multiply, so the reference's - /// own `SetItemButtonDesaturated(button, 1, 0.65, 0.65, 0.65)` lands as greyscale *and* dim. - /// Splits the run like `additive`/`circular`. + /// Draw the texel's luminance (`Texture:SetDesaturated(1)`), folded in gamma bytes before the + /// tint, so `SetItemButtonDesaturated(button, 1, 0.65, 0.65, 0.65)` + /// (`Blizzard_TalentUI.lua:215`) lands grey and dim. pub desaturated: bool, - /// The sampled texture already carries **premultiplied** colour, so the shader must not weight - /// it by its own alpha a second time — the booth render targets ([`crate::portrait`]), and - /// nothing else. - /// - /// A booth that clears **transparent** (the `` body panes and the dressing room, - /// which composite over the page's own art — decision 1083) builds its target the way any - /// render-to-texture does: opaque geometry writes `a = 1`, alpha batches blend over it, and an - /// **additive particle adds light while contributing no coverage** (`wow_effect.wgsl`'s - /// `(rgb·a, 0)` under a premultiplied state). That buffer is premultiplied by construction — - /// colour is emitted light, alpha is coverage. Sampling it as *straight* alpha and applying the - /// usual `rgb·a` multiplies every effect hanging in EMPTY pane space by zero: the R14 - /// pauldrons' fire vanished outright, and a weapon glow survived only where it happened to - /// overlap the model's own opaque pixels — chopped to a hard edge at the silhouette (the - /// director's paper-doll/dressing-room report). The opaque-cleared round portraits hid it, - /// because `a = 1` everywhere makes the extra multiply a no-op — exactly the way opaque panels - /// hid the identical defect one level up, in this camera's own output blend (see - /// [`spawn_player_ui_camera`]'s note on `rgb·a²`). - /// - /// Splits the run like `additive`/`circular`; a booth target is its own texture anyway, so no - /// batching is lost. + /// The texture is already premultiplied: a booth render target ([`crate::portrait`]), where + /// an additive particle adds colour with no coverage and a second alpha weight would erase it. pub premultiplied: bool, - /// The sampled texture holds the client's **gamma bytes, undecoded** — a SKIP_DECODE upload - /// (`BlpVariant::MapTile`, the minimap tiles: the reference's tile sampler carries - /// `GL_TEXTURE_SRGB_DECODE_EXT = GL_SKIP_DECODE_EXT`, so the hardware filters authored bytes). - /// The ordinary arm's `linear_to_srgb` exists to undo the sampler's sRGB decode; on a texture - /// whose sampler did no decode it ENCODES A SECOND TIME, which is how the outdoor minimap - /// washed bright when MapTile moved off `Rgba8UnormSrgb` and only the alpha-test arm learned - /// the new contract. With this set the texel passes through as the authored byte and the - /// vertex-colour multiply lands on it directly — the fixed-function MODULATE, byte for byte. - /// The alpha-test arm ignores the flag: it decodes explicitly for the un-encoded composite - /// target. Splits the run like `additive`/`circular`. + /// The texture holds undecoded gamma bytes (`BlpVariant::MapTile`: the reference's tile + /// sampler sets `GL_TEXTURE_SRGB_DECODE_EXT = GL_SKIP_DECODE_EXT`), so the tint multiplies the + /// authored byte and the ordinary arm's `linear_to_srgb` must not encode it again. pub gamma_texel: bool, - /// **Alpha TEST** instead of alpha blend, at this reference value on the client's - /// `texel.a × vertexColour.a` — `Some(224.0 / 255.0)` is the WMO-interior minimap tile draw and - /// nothing else so far. A passing fragment writes FULLY OPAQUE (the screen mask still cuts it); - /// a failing one is discarded, so partial coverage never exists on this path. - /// - /// The reference's minimap tile draw sets EGxBlend **1**, whose applicator `glDisable`s - /// blending outright, and the `SetRenderState` id-7→id-8 cascade then arms - /// `glAlphaFunc(GL_GEQUAL, 0.87843144)` (`.data 0x85ad20[1] = 224`). Blending those tiles - /// instead leaves `(1−a)(1−b)` of the black clear at EVERY boundary where two group tiles meet - /// — up to 25% black where the two filtered edges are complementary — which is B141's "odd - /// black lines" (they recoloured with the backing quad, which is how they were caught). Splits - /// the batch run. + /// Alpha test against this value on `texel.a × tint.a` instead of blending: a pass writes + /// opaque, a fail discards. `Some(224.0 / 255.0)` is the WMO-interior minimap tile, drawn by + /// the reference with EGxBlend 1 (blending off) and `glAlphaFunc(GL_GEQUAL, 0.87843144)` + /// (`0x85ad20[1] = 224`); blended, two tiles' seam keeps `(1−a)(1−b)` of the black clear. pub alpha_test: Option, - /// **The UV window this quad may sample**, already inset by half a texel — `None` = the - /// sampler's own `ClampToEdge` is the whole story (decision 1608). - /// - /// A `SetTexCoord` crop into an ATLAS is not a texture: `CLAMP_TO_EDGE` clamps at the - /// image's edge, not the cell's, so a magnified cell's outermost row of destination pixels - /// samples half a texel PAST its crop and linear-filters in whatever the neighbouring cell - /// authored there. `Interface\Minimap\POIIcons` is the case that forced this: cell 15 (the - /// generic zone-level landmark) is fully transparent, the cell directly above it is the - /// coffin whose bottom row is OPAQUE BLACK, and the world map drew a ~24%-black hairline - /// across the top edge of every zone POI — the director's "black horizontal lines". - /// - /// Set it and the fragment clamps into `[u_min, v_min, u_max, v_max]`, which is exactly what - /// a standalone clamped texture of that cell would sample. The producer decides *whether* a - /// crop is a cell (see the extract's `uv_clamp_window`): an axis whose UVs run past `[0,1]` - /// is the reference's TILING idiom and must never be clamped, which is why the window is - /// disabled **per axis** rather than per quad. - /// - /// Splits the batch run like `additive`/`circular` — it rides the material, not the vertex. + /// The half-texel-inset window `[u_min, v_min, u_max, v_max]` sampled, so an atlas cell clamps + /// at its own edge instead of filtering in its neighbour (`ClampToEdge` stops at the image's). + /// Per axis: UVs past `[0, 1]` are the reference's tiling idiom and never clamp. pub uv_clamp: Option<[f32; 4]>, - /// CPU-clip stand-in for a real scissor rect (see the module doc). `None` = unclipped. + /// CPU clip standing in for a scissor rect. pub clip: Option, - /// Rotate the quad's corners by this many radians **clockwise on screen** about the rect's - /// center, applied at mesh build (the minimap player arrow's facing — decision 0203). The UVs - /// ride their corners, so the art rotates with the geometry. Composes with `clip` by clipping - /// FIRST in the unrotated frame (no current producer sets both; the arrow is never clipped). + /// Radians clockwise on screen about the rect centre (the minimap player arrow), the UVs + /// riding their corners; `clip` applies first, unrotated. pub rotation: f32, - /// Confine the quad to a screen-anchored alpha mask (the minimap's `MinimapMask.blp` circle — - /// decision 0203): the fragment's alpha is multiplied by the mask texture's **alpha** channel - /// (`MinimapMask.blp` is DXT3: white color, the circle ramp in its 8-bit alpha), sampled - /// where the fragment sits within [`UiQuadMask::rect`]; fragments outside the rect drop. - /// Screen-anchored — NOT quad UV space — so a world-anchored tile quad panning under a fixed - /// circular window masks correctly. Splits the run like `additive`/`circular`. + /// A screen-anchored alpha mask (the minimap's `MinimapMask.blp` circle), so a tile panning + /// under the fixed circle masks correctly; outside [`UiQuadMask::rect`] the fragment drops. pub mask: Option, - /// Explicit screen-space corners (y-down px, **fan order from corner 0**: the index pattern is - /// `(0,1,2)(0,2,3)`, so any convex 4-gon with corner 0 as the fan apex renders exactly — a - /// triangle repeats its last vertex). When `Some`, `rect`/`rotation`/`clip` are bypassed - /// (`rect` should still bound the corners for the probe/debug view). The cooldown pie's - /// partial-quadrant wedge is the producer (decision 0137 phase 4) — exact geometry computed at - /// extract, no scissor interplay to get wrong. + /// Explicit screen corners (y-down px) drawn as the fan `(0,1,2)(0,2,3)` (the cooldown pie's + /// wedge), bypassing `rotation`, `clip` and `rect`, which should still bound them. pub corners: Option<[Vec2; 4]>, } -/// A screen-anchored alpha mask over a [`UiQuad`] — see [`UiQuad::mask`]. +/// A screen-anchored alpha mask over a [`UiQuad`]. #[derive(Clone, PartialEq)] pub(crate) struct UiQuadMask { - /// The mask art (its ALPHA channel is the coverage — `MinimapMask.blp` is DXT3 with the - /// circle authored in alpha over a white color plane). + /// The mask art, whose alpha is the coverage (`MinimapMask.blp`: DXT3, the circle in alpha). pub texture: Handle, - /// Where the mask spans on screen, in the same y-down logical px as [`UiQuad::rect`] - /// (the minimap widget's rect). The mask samples 0..1 across this span. + /// The span it covers, in [`UiQuad::rect`]'s y-down logical px; it samples 0..1 across it. pub rect: Rect, } @@ -284,104 +144,67 @@ impl Default for UiQuad { } } -/// The pass's one input resource, two lanes with distinct change protocols: -/// -/// - [`Self::quads`] — the BASE lane, owned by a wholesale producer (the widget arena's extract): -/// replaced in full, and the producer sets [`Self::dirty`] only when the new content differs. -/// - [`Self::overlays`] — the APPEND lane ([`UiQuadAppend`]: the minimap fill, V-plates, combat -/// text): cleared at the top of the append window ([`clear_ui_overlays`]) and re-emitted every -/// frame; [`rebuild_ui_mesh`] itself diffs it against last frame's. Appenders never touch -/// `dirty` — when the two lanes lived in one Vec, the extract's change gate compared its fresh -/// output against a Vec still carrying last frame's appends, so it could never hold and the -/// whole UI re-batched every frame (the 0365 live-city churn). +/// The pass's input in two lanes. [`Self::quads`], the base lane, is replaced whole by the script +/// extract, which sets [`Self::dirty`] only when it changed. [`Self::overlays`], the append lane +/// ([`UiQuadAppend`]), is cleared and re-emitted every frame and diffed by [`rebuild_ui_mesh`] +/// against the last frame's; its appenders never touch `dirty`. #[derive(Resource, Default)] pub(crate) struct UiQuads { pub quads: Vec, - /// The append lane — see the struct doc. Compared by the rebuild, never flagged. pub overlays: Vec, - /// The append lane as of the last rebuild — [`rebuild_ui_mesh`]'s change gate for it. + /// The append lane as of the last rebuild. last_overlays: Vec, - /// Set by the BASE-lane producer after replacing `quads` with different content; cleared by - /// [`rebuild_ui_mesh`] once it has rebuilt the mesh batches. pub dirty: bool, } -/// The **append lane's own z bands** — the world-anchored overlays' slice of the same `z_key` -/// total order the scripted UI packs its `(stratum, level, layer, …)` tuple into -/// ([`benilla_ui::order::ZKey`]). -/// -/// The lane's three producers are three *different* client systems that all draw over the world, -/// and their relative order is a fidelity fact each one owns (the floating combat numbers are a -/// world-scene draw under all UI; the plates and the bubbles are frames). What is NOT a fidelity -/// fact is the arithmetic that keeps them apart — and while each producer picked its own small -/// integer, that arithmetic was three private conventions with nothing naming the shared law: -/// combat text at 0, the bubble's four pieces at 0..=3 *on top of it*, the plates at 4..=8. The -/// bands below make the split explicit and, more to the point, give the middle band **room** — -/// a chat bubble is one frame per speaker with its own frame level (decision 1504), which a -/// four-key allocation cannot express. Everything here stays far below the scripted UI's keys -/// (a `ZKey` region carries its is-region bit at `1 << 20`), so the whole lane still paints -/// under the player UI. +/// The append lane's z bands, far below the scripted UI's keys (a [`benilla_ui::order::ZKey`] +/// region sets `1 << 20`), so the whole lane paints under the player UI. pub(crate) mod overlay_z { - /// Floating combat/XP/honor numbers — the client draws them in the world scene, beneath - /// every UI frame ([`crate::combat_text`]). + /// Floating combat, XP and honor numbers, drawn by the client in the world scene under all UI. pub(crate) const WORLD_TEXT: u64 = 0; - /// The chat bubbles' band ([`crate::chat_bubble`]): one **frame level** per live bubble, - /// [`BUBBLE_STRIDE`] keys wide, ordered farthest-camera-distance first. + /// The chat bubbles: one frame level per live bubble, farthest from the camera first. pub(crate) const BUBBLE: u64 = 1 << 8; - /// Keys per bubble level — the frame's own four pieces (bg, edge, tail, text). + /// Keys per bubble level: its bg, edge, tail and text. pub(crate) const BUBBLE_STRIDE: u64 = 4; - /// The V-plates ([`crate::vplates`]), above every bubble. Same-unit overlap cannot happen - /// (the plate/bubble mutual exclusion), so this only orders cross-unit stacking. + /// The V-plates, over every bubble; a unit never shows both, so this orders only across units. pub(crate) const VPLATE: u64 = 1 << 16; - /// The highest bubble level the band holds before it would reach [`VPLATE`]. Far past any - /// real population (a bubble needs a speaker within 20 yd), so the clamp is a guard rail, - /// not a policy. + /// The highest bubble level below [`VPLATE`], a guard rail far past any real count (a bubble + /// needs a speaker within 20 yd). pub(crate) const BUBBLE_MAX_LEVEL: u64 = (VPLATE - BUBBLE) / BUBBLE_STRIDE - 1; } -/// Clear the append lane at the top of the [`UiQuadAppend`] window — every appender re-emits its -/// frame's quads after this; [`rebuild_ui_mesh`] then diffs the lane to decide if anything changed. +/// Clear the append lane at the top of the [`UiQuadAppend`] window, before every appender. fn clear_ui_overlays(mut quads: ResMut) { quads.overlays.clear(); } -/// The dedicated render layer this pass's camera + mesh entities share — disjoint from the world -/// camera's default layer 0 (so neither camera draws the other's content) and distinct from the egui -/// overlay camera's `RenderLayers::none()` (egui paints via its own render node, not `Mesh2d`s, so it -/// doesn't need a mesh-visible layer at all). +/// The render layer this pass's camera and batches share, disjoint from the world camera's 0. const UI_RENDER_LAYER: usize = 1; pub(crate) fn ui_render_layers() -> RenderLayers { RenderLayers::layer(UI_RENDER_LAYER) } -/// Camera order for the player-UI pass: above the (order-0) world camera, below the egui dev-overlay -/// camera (bumped to order 2 in `debug_panel::spawn_egui_camera`). See the module doc's arbitration note. +/// Above the world camera (0), below the egui dev overlay (2, `debug_panel::spawn_egui_camera`). const UI_CAMERA_ORDER: isize = 1; -/// Marker on the player-UI camera. `pub(crate)` for one writer: [`crate::world_backdrop`], which -/// points the camera's ground pass at the world camera drawing this frame. +/// Marker on the player-UI camera, whose backdrop [`crate::world_backdrop`] points at the world +/// camera drawing this frame. #[derive(Component)] pub(crate) struct PlayerUiCamera; -/// Marker on each rebuilt batch mesh entity, so [`rebuild_ui_mesh`] can despawn last frame's batches -/// before spawning the new ones. `pub(crate)` for one reader: `FPS_PROBE`'s `ui_batches=`, the -/// live count of texture-identity runs — each is a `Mesh2d` draw in the 2D pass, and that pass -/// node priced at ~0.5 ms a frame on the crowd rig's *alone* leg, ~1.2 with a raid's party -/// frames (1929's trace), so the number a UI change moves is this one, not `quads=`. +/// Marker on each pooled batch entity, one `Mesh2d` draw per run; the FPS probe's `ui_batches=` +/// counts them. #[derive(Component)] pub(crate) struct UiQuadBatch; -/// The shared 1×1 opaque-white texture flat-shaded/texture-less quads sample (see the module doc). +/// The shared 1×1 opaque white that texture-less quads sample. #[derive(Resource)] struct UiWhiteTexture(Handle); -/// The quad material: a texture + the blend-mode flag, drawn through one PREMULTIPLIED-alpha -/// pipeline (`ui_quad.wgsl`) so straight-alpha and WoW-ADD quads share the pipeline and differ -/// only per-material. Vertex colors carry the per-quad tint, and a run with ONE colour carries it -/// on the batch entity's [`MeshTag`] instead (see [`tint_tag`]) — so **nothing about a colour is -/// in here**, and a material is its identity key alone: the same texture and flags are the same -/// asset for as long as the image lives, and a steady frame re-prepares none. +/// The quad material: a texture and the [`UiQuad`] flags as uniforms, drawn through one +/// premultiplied-alpha pipeline (`ui_quad.wgsl`). It holds no colour, so it is its identity key +/// alone and a steady frame re-prepares none. #[derive(Asset, TypePath, AsBindGroup, Clone)] pub(crate) struct UiQuadMaterial { #[uniform(0)] @@ -389,58 +212,36 @@ pub(crate) struct UiQuadMaterial { #[texture(1)] #[sampler(2)] texture: Option>, - /// Mask to the inscribed circle (live unit portraits) — see [`UiQuad::circular`]. #[uniform(3)] circular: u32, - /// Fold the texel to luminance — see [`UiQuad::desaturated`]. #[uniform(7)] desaturate: u32, - /// The texel is already premultiplied (a booth bake) — see [`UiQuad::premultiplied`]. #[uniform(8)] premultiplied: u32, - /// Alpha-TEST reference on `texel.a × colour.a`; `<= 0` disables — see [`UiQuad::alpha_test`]. + /// `<= 0` disables the alpha test. #[uniform(9)] alpha_ref: f32, - /// The texel is already the client's gamma byte (a SKIP_DECODE upload — the minimap tiles), so - /// the ordinary arm skips its `linear_to_srgb` re-encode — see [`UiQuad::gamma_texel`]. #[uniform(10)] gamma_texel: u32, - /// The screen-anchored mask span in **physical framebuffer px** (`min.xy, max.xy` — the - /// fragment shader compares `@builtin(position)`, which is physical): [`UiQuadMask::rect`] - /// scaled by the window's scale factor at mesh build. `z <= x` (degenerate) disables masking. + /// The mask span in physical px (the shader compares `@builtin(position)`); `z <= x` disables. #[uniform(4)] mask_rect: Vec4, - /// The mask art — see [`UiQuadMask`]. `None` binds the fallback image (and `mask_rect` is - /// degenerate, so the shader never reads it). #[texture(5)] #[sampler(6)] mask: Option>, - /// The half-texel-inset UV window the fragment may sample — `(u_min, v_min, u_max, v_max)`, - /// **per axis**, with `min > max` on an axis disabling that axis. See [`UiQuad::uv_clamp`]. + /// `(u_min, v_min, u_max, v_max)`; `min > max` on an axis disables that axis. #[uniform(11)] uv_clamp: Vec4, } -/// **A run's one colour, packed for the batch entity's [`MeshTag`]** — the per-instance `u32` -/// bevy's Mesh2d uniform carries and re-uploads with the transform every frame, which -/// `ui_quad.wgsl`'s vertex stage unpacks into the run's tint. A run whose quads all share one -/// colour stores WHITE vertices and puts the colour here, so a colour pulse (the stock -/// PlayerFrame status glow, every frame at rest) is one component write on one entity: not an -/// `Assets` write, which arms bevy's asset-changed probes over every `Mesh3d` row in the -/// scene (1982), and not a material write, which re-creates the material's bind group and -/// uniform buffers on the render thread — 2–3 of them a frame at a parked pin priced at ~9 ms -/// on Intel's DX12 driver (2236). -/// -/// One BYTE per channel, `×255 + 0.5`: exactly how the reference packs a vertex colour -/// (`CImVector`; `SetVertexColor` at `0x79abd0`, the frame-alpha fold at `0x77fac0` in bytes), so -/// this is the precision the real client draws with, not a step down from it. Stored COMPLEMENTED: -/// an entity with no `MeshTag` reads 0 in bevy's extract, and the complement makes 0 unpack to -/// opaque white — untinted — so a Mesh2d that draws with this material and never asked for a tint -/// (the minimap's interior tiles) is right without knowing this exists. +/// A run's one colour for the batch entity's [`MeshTag`], unpacked by `ui_quad.wgsl`'s vertex +/// stage, so a colour pulse is a component write, never a mesh or material write. One byte per +/// channel, `×255 + 0.5`, as the reference packs a vertex colour (`CImVector`; `SetVertexColor` +/// at `0x79abd0`, the frame-alpha fold at `0x77fac0`). Complemented, so an entity with no tag +/// (the minimap's interior tiles) unpacks to opaque white. fn tint_tag(color: [f32; 4]) -> u32 { let byte = |v: f32| { - // Round half up on a clamped value — `0x40a2b0`'s `×255.0 + 0.5` then truncate; the - // clamp is the binding's own `[0, 1]` marshal. The cast cannot overflow after it. + // `0x40a2b0`: `×255.0 + 0.5`, truncated, after the binding's `[0, 1]` clamp; no overflow. #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] let b = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u32; b @@ -449,15 +250,9 @@ fn tint_tag(color: [f32; 4]) -> u32 { } impl UiQuadMaterial { - /// The **WMO-interior minimap tile** material (decision 1466): the texture, an alpha TEST at - /// `alpha_ref`, and nothing else — no tint, no mask, no circle, no desaturate. It is built here - /// rather than through the quad stream because these tiles do not draw at the screen at all: - /// they draw into the minimap's own 256² composite target on its own camera - /// ([`crate::minimap`]'s `composite`), one shared quad mesh per tile under a Transform. - /// - /// The forced `(One, OneMinusSrcAlpha)` blend in [`Self::specialize`] is what makes this - /// reproduce the client's *disabled* blending exactly: a fragment that passes the test emits - /// `a = 1`, so the state degenerates to a plain replace — and one that fails emits nothing. + /// The WMO-interior minimap tile material: the texture and an alpha test, nothing else, for the + /// minimap's own composite target ([`crate::minimap`]). A passing fragment emits `a = 1`, so + /// the forced blend of [`Self::specialize`] is a plain replace: the reference's blending off. pub(crate) fn interior_tile(texture: Handle, alpha_ref: f32) -> Self { Self { additive: 0, @@ -466,35 +261,24 @@ impl UiQuadMaterial { desaturate: 0, premultiplied: 0, alpha_ref, - // The alpha-test arm never reads this: it decodes explicitly (its target is the - // un-encoded composite), so the tile's SKIP_DECODE contract is already honoured there. + // The alpha-test arm decodes explicitly, so the tile's SKIP_DECODE holds without this. gamma_texel: 0, mask_rect: Vec4::new(0.0, 0.0, -1.0, -1.0), mask: None, - // A tile samples its whole image; there is no cell to stay inside of. uv_clamp: UV_CLAMP_OFF, } } } -/// The mesh every `UiQuadMaterial` quad that is **not** part of the HUD batch stream draws with: -/// a 1×1 rectangle at the origin, scaled and placed by its own Transform (decision 1466 — a -/// panning composite never rewrites a vertex buffer). -/// -/// It has ONE author because a `Material2d` pipeline is keyed on `(view key, MESH LAYOUT)`, and -/// these two quad families have different layouts: `Rectangle` gives POSITION + NORMAL + UV_0 -/// (`bevy_mesh` `primitives/dim2.rs`), the HUD's batch mesh POSITION + UV_0 + COLOR -/// ([`rebuild_ui_mesh`]). So `UiQuadMaterial` is **two** pipelines, not one — the claim pipe_warm -/// carried until decision 2262, which is why the composite's compiled live on the first step into -/// a WMO interior. `pipe_warm`'s menagerie warms this exact mesh; drifting it here would take the -/// warm rig with it. +/// The mesh every `UiQuadMaterial` quad outside the batches draws with: a 1×1 rectangle placed by +/// its Transform. Its layout (POSITION + NORMAL + UV_0) keys a second pipeline beside the batch +/// mesh's (POSITION + UV_0 + COLOR), which `pipe_warm` warms by drawing this same mesh. pub(crate) fn tile_quad_mesh() -> Mesh { Rectangle::new(1.0, 1.0).into() } impl Material2d for UiQuadMaterial { - /// Our own vertex stage, in the same file: bevy's carries no per-instance data past the - /// transform, and the run's tint rides the instance tag (see [`tint_tag`]). + /// Our own vertex stage, which unpacks the run's tint from the instance tag ([`tint_tag`]). fn vertex_shader() -> ShaderRef { "embedded://benilla_app/shaders/ui_quad.wgsl".into() } @@ -504,8 +288,7 @@ impl Material2d for UiQuadMaterial { } fn alpha_mode(&self) -> bevy::sprite_render::AlphaMode2d { - // Transparent phase (back-to-front by mesh z — the run order). The actual blend state is - // forced premultiplied in `specialize`. + // The transparent phase, sorted by mesh z (the run order); `specialize` sets the blend. bevy::sprite_render::AlphaMode2d::Blend } @@ -516,10 +299,9 @@ impl Material2d for UiQuadMaterial { ) -> Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> { if let Some(fragment) = descriptor.fragment.as_mut() { if let Some(target) = fragment.targets.first_mut().and_then(|t| t.as_mut()) { - // `(One, OneMinusSrcAlpha)` over GAMMA values (decision 0254): the shader hands us - // `(rgb·a, a)` for BLEND and `(rgb·a, 0)` for ADD, so this one state reproduces - // EGxBlend 2 and EGxBlend 3 exactly, clamped at each write like the reference's - // 8-bit backbuffer. + // `(One, OneMinusSrcAlpha)` over gamma values: the shader emits `(rgb·a, a)` for + // BLEND and `(rgb·a, 0)` for ADD, so this one state is EGxBlend 2 and EGxBlend 3, + // clamped at each write like the reference's 8-bit backbuffer. let premultiplied = BlendComponent { src_factor: BlendFactor::One, dst_factor: BlendFactor::OneMinusSrcAlpha, @@ -535,57 +317,35 @@ impl Material2d for UiQuadMaterial { } } -/// Producers that **append** to [`UiQuads`] after the script extract replaced it — the -/// world-anchored text (combat numbers, nameplates). The set lives in **Update, after -/// [`benilla_world::schedule::WorldStage::Input`]** (itself after the script extract's `UiInput`): the -/// camera controller has written this frame's camera `Transform` by then, and the producers -/// project through THAT fresh Transform (`GlobalTransform::from` — the world camera is a root -/// entity), never the stale propagated `GlobalTransform` — the fix for text dragging one frame -/// behind the render when strafing. The set MUST stay in Update: [`rebuild_ui_mesh`] respawns its -/// `Mesh2d` batches, and bevy_sprite_render cannot specialize meshes spawned in PostUpdate -/// ("missing specialization tick" — the whole UI vanishes; a PostUpdate placement was tried and -/// reverted, caught live by the director). The posed BONE transforms the producers read are last -/// frame's — sub-frame head motion, imperceptible next to camera pan. +/// Producers that append to [`UiQuads`] after the script extract. In Update after +/// [`benilla_world::schedule::WorldStage::Input`], the world-anchored ones (combat numbers, +/// nameplates) project through this frame's camera `Transform` (`GlobalTransform::from`; the +/// camera is a root entity), not the propagated one a frame behind. Never PostUpdate: +/// [`rebuild_ui_mesh`] spawns `Mesh2d` batches, and bevy_sprite_render cannot specialize one +/// spawned there (the whole UI vanishes). #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) struct UiQuadAppend; -/// Project a world point for a world-anchored overlay, **with the reference projector's own accept -/// verdict** — `None` means "this one does not draw", and the caller must honour it. -/// -/// `0x483ee0` returns a boolean. It rejects a point behind the near plane (`483f7a`) **and** a -/// point outside the viewport — the four tests at `484075`/`484086`/`484096`/`4840a6`, against the -/// **WorldFrame**'s own region (`[0xb4b2bc]`, mirrored ÷G44/÷G48 at `0x483970`), so the accept -/// region is exactly the viewport, inclusive. Both plate and worldtext callers **destroy** the -/// thing they were about to place when it comes back false. -/// -/// **The trap this exists to remove:** on the viewport rejections the projector has *already -/// written* the out-param (`484065`/`484067`), so a caller that ignores the verdict reads a -/// perfectly plausible off-screen coordinate and sails on — which is exactly how benilla ended up -/// clamping the plates of units nobody can see onto the screen border (1341). Returning `Option` -/// makes the verdict unignorable at the call site. -/// -/// The chat bubble deliberately does **not** call this: the reference's bubble seat is the one -/// caller of the three that ignores the boolean, so a bubble simply slides off the edge with its -/// speaker. Bevy's own `Err` covers only the behind-camera half, which is why the viewport test -/// has to be here rather than left to it. +/// Project a world point for a world-anchored overlay, with the reference projector's verdict: +/// `None` does not draw. `0x483ee0` rejects a point behind the near plane (`0x483f7a`) or outside +/// the WorldFrame's region (`[0xb4b2bc]`; `0x484075`, `0x484086`, `0x484096`, `0x4840a6`), which +/// `0x483970` mirrors ÷G44/÷G48 so the aspect cancels: exactly the viewport, inclusive. A viewport +/// rejection has already written a plausible out-param (`0x484065`, `0x484067`), so the verdict +/// must be honoured; plate and world-text callers destroy what they were placing. The chat bubble +/// does not call this: its seat ignores the verdict, and a bubble slides off with its speaker. pub(crate) fn project_overlay( cam: &Camera, cam_tf: &GlobalTransform, world: Vec3, viewport: Vec2, ) -> Option { - // Bevy's `Err` is the near-plane half (`483f7a`); `accepts` is the viewport half. + // Bevy's `Err` is the near-plane half (`0x483f7a`); `accepts` is the viewport half. let p = cam.world_to_viewport(cam_tf, world).ok()?; accepts(p, viewport).then_some(p) } -/// The accept REGION: exactly the viewport, **inclusive**. `[0xb4b2bc]` is the WorldFrame, and its -/// region rect is mirrored ÷G44/÷G48 into the compare fields (`0x483970`), so the aspect factors -/// cancel exactly and the test is against the raw screen box. -/// `WOW_PROBE_UI_ONE_TEX=1` — a PRICING lever, never a look: the run split ignores texture -/// identity, so runs break on state flags alone and every run draws with its first quad's -/// texture. What it measures is the draw-count ceiling a UI texture atlas would reach (the -/// module doc's "standard fix"), before that atlas is built. +/// `WOW_PROBE_UI_ONE_TEX=1`, for pricing only (it draws wrong): runs split on state flags alone, +/// each drawn with its first quad's texture, the draw count a UI texture atlas would reach. fn one_texture_probe() -> bool { static ON: std::sync::OnceLock = std::sync::OnceLock::new(); *ON.get_or_init(|| std::env::var_os("WOW_PROBE_UI_ONE_TEX").is_some()) @@ -595,8 +355,7 @@ fn accepts(p: Vec2, viewport: Vec2) -> bool { (0.0..=viewport.x).contains(&p.x) && (0.0..=viewport.y).contains(&p.y) } -/// Owns the player-UI camera + the per-frame quad→mesh rebuild. See the module doc for the -/// camera-order arbitration and the batching/ordering approach. +/// Owns the player-UI camera and the per-frame quad-to-mesh rebuild. pub(crate) struct PlayerUiPlugin; impl Plugin for PlayerUiPlugin { @@ -606,9 +365,9 @@ impl Plugin for PlayerUiPlugin { .init_resource::() .add_plugins(( Material2dPlugin::::default(), - // Owned here, not in main.rs: the lane's decode is not optional (see its doc). + // The lane's decode is not optional, so this plugin owns it. crate::ui_gamma::UiGammaPlugin, - // The 2D opaque pass skipped when empty — which on this camera is always (2225). + // Skips the 2D opaque pass when empty, which on this camera is always. crate::opaque2d::SkipEmptyOpaque2dPlugin, )) .add_systems(Startup, (spawn_ui_camera, init_white_texture)) @@ -622,71 +381,29 @@ impl Plugin for PlayerUiPlugin { } } -/// A full-window overlay camera that composites the player-UI quad pass above the 3D world and below -/// the egui dev overlays. Mirrors `debug_panel::spawn_egui_camera`'s pattern (own render layer, higher -/// order, alpha-blend `Write` output, no clear) — see the module doc for the order arbitration. +/// The full-window player-UI camera: the world, then the quads, then the gamma decode. fn spawn_ui_camera(mut commands: Commands) { commands.spawn(( PlayerUiCamera, Name::new("player-UI camera"), Camera2d, - // **No MSAA — named, because silence here means 4×.** `bevy_render`'s `CameraPlugin` - // registers `Camera` → `Msaa` as a required component (`bevy_render/src/camera.rs:56`) and - // `Msaa::default()` is `Sample4`, so a camera that never mentions MSAA does not get "none", - // it gets four samples. Every other non-world camera in the tree says `Msaa::Off` out loud - // (the portrait booths, the minimap composite); this one simply never did, and paid for it. - // - // There is nothing here for multisampling to resolve. The world arrives already resolved — - // since decision 1603 the world camera renders offscreen, and since 2234 this camera's main - // pass draws that finished image straight in first ([`crate::world_backdrop`]), so this - // camera's samples would only re-average a picture whose own MSAA is long since done. What - // it draws itself is - // axis-aligned rects, and the Bevy UI trees riding this camera (decision 0541) antialias - // their own edges analytically in-shader. What it *costs* is a full-window 4× sampled - // colour texture, a full-window 4× multisampled Core2d depth texture (Bevy sizes that one - // at `msaa.samples()` unconditionally — `core_2d::prepare_core_2d_depth_textures` — even - // though `AlphaMode2d::Blend` puts every quad in `Transparent2d`, which never writes it), - // 4× the fill on every quad, and a resolve. Decision 1628. + // No MSAA, named because silence means 4×: `Camera` requires `Msaa`, whose default is + // `Sample4`. The world arrives resolved, and the quads are axis-aligned. bevy::render::view::Msaa::Off, - // The gamma composite lane's mandatory decode (decision 0254) — without it the UI presents - // ~2.2× bright, since the quad pass leaves gamma values in the target. + // The lane's mandatory decode: the quad pass leaves gamma bytes in the target. crate::ui_gamma::UiGammaLane::default(), - // The world is the first draw of this camera's main pass (decision 2234): the FFXGlow - // combine of the world camera [`crate::world_backdrop`] points it at, rendered into this - // target ahead of the quads. `source` is `None` until a world camera draws. + // The world as this pass's first draw; `source` is `None` until a world camera draws. benilla_world::ffx_glow::FfxBackdrop::default(), - // Every Bevy UI tree renders HERE (decision 0541) — the glue screens and the loading screen. - // Without the marker, Bevy UI picks the highest-order camera targeting the window, which is - // the egui dev overlay (order 2): the glue screens rode the dev camera, outside the gamma - // lane, and composited in linear — the washed-out login boxes the director caught. Bevy UI - // and egui never contend for it (egui paints through its own render node, not UI nodes). + // Bevy UI (the glue and loading screens) renders here, in the gamma lane; unmarked, it + // would take the highest-order camera, the egui overlay. bevy::ui::IsDefaultUiCamera, ui_render_layers(), Camera { order: UI_CAMERA_ORDER, - // **No output blit at all** (decision 2206, [`benilla_world::final_pass`]): the - // lane's final pass — [`crate::ui_gamma`]'s decode — renders straight into the - // swapchain, so bevy's `upscaling` copy of the finished frame is skipped. - // - // It could already have been a plain copy, because this camera carries the world - // too ([`crate::world_backdrop`]) and its target is a whole opaque frame. That was - // the point of the backdrop: the blend that used to happen HERE, against the sRGB - // swapchain view, was the frame's one linear composite, and the only one that mixed - // UI with world; moving the world into the UI's own byte buffer moved that blend - // into this camera's gamma target, where every other UI blend already lives. It - // also retired the hazard 0254 patched around here: `ui_quad.wgsl` writes - // PREMULTIPLIED colour, so the original `ALPHA_BLENDING`'s `SrcAlpha` factor - // weighted it by alpha twice (`rgb·a²`), and a pure-additive quad (a = 0) over the - // world was multiplied clean away. With nothing to blend against, neither factor can - // be wrong — and with no blit, there is no pass left to get it wrong in. + // No output blit ([`benilla_world::final_pass`]): the decode writes the swapchain. output_mode: CameraOutputMode::Skip, - // An overlay must composite ONLY its own pixels. `ClearColorConfig::None` made this - // camera depend on `MsaaWriteback::Auto`, which (for any non-first camera on a target) - // COPIES the world camera's already-final output into this camera's MSAA texture and - // re-emits it through this camera's SDR blit+blend — a whole-image re-encode round-trip - // that tinted the world (a fixed blue film on WMO surfaces, the regression the director - // caught live; it never reproduced in captures). Clear to transparent instead and turn - // writeback off: this camera never touches the world image at all. + // Clear to transparent, writeback off: `MsaaWriteback::Auto` would re-emit an earlier + // camera's output through this one, a re-encode round trip that tints the world. clear_color: ClearColorConfig::Custom(Color::NONE), msaa_writeback: bevy::camera::MsaaWriteback::Off, ..default() @@ -694,8 +411,7 @@ fn spawn_ui_camera(mut commands: Commands) { )); } -/// Build the shared 1×1 white texture texture-less quads sample (see the module doc's batching note on -/// why they still go through a real texture bind rather than a "no texture" material variant). +/// Build the shared 1×1 white texture that texture-less quads sample. fn init_white_texture(mut commands: Commands, mut images: ResMut>) { let image = Image::new_fill( Extent3d { @@ -711,9 +427,7 @@ fn init_white_texture(mut commands: Commands, mut images: ResMut>) commands.insert_resource(UiWhiteTexture(images.add(image))); } -/// Clip `rect`/`uv` to `clip`, reprojecting the UV sub-rect proportionally to the clipped edges (an -/// axis-aligned CPU scissor stand-in — see the module doc). Returns `None` if the clip leaves nothing -/// to draw (empty intersection, or a degenerate zero-size source rect). +/// Clip `rect` to `clip`, reprojecting the UVs to the clipped edges; `None` when nothing is left. fn clip_quad(rect: Rect, uv: UvRect, clip: Rect) -> Option<(Rect, UvRect)> { let clipped = rect.intersect(clip); if clipped.is_empty() { @@ -723,13 +437,8 @@ fn clip_quad(rect: Rect, uv: UvRect, clip: Rect) -> Option<(Rect, UvRect)> { if w <= 0.0 || h <= 0.0 { return None; } - // Fraction along each axis the clipped edges sit at within the original rect, then reproject the - // UV corners by the same fractions — this keeps the texture mapping correct on the visible - // remainder instead of carrying the original (now-too-large) UV rect through unclipped. The - // fractions come from the (always-normalized) screen rect. The reprojection is a **bilinear** - // sample of the four corner UVs at (fx, fy): for an axis-aligned crop it reduces to the old - // separable `u0→u1`/`v0→v1` lerp (so a mirrored UV stays mirrored), and for a rotated backdrop - // edge (u tied to screen-Y) it correctly carries the rotation through the clip. + // A bilinear sample of the four corner UVs at the clipped edges' fractions: an axis-aligned + // crop stays a separable lerp (a mirror survives), and a rotated backdrop edge keeps its turn. let t_min_x = (clipped.min.x - rect.min.x) / w; let t_max_x = (clipped.max.x - rect.min.x) / w; let t_min_y = (clipped.min.y - rect.min.y) / h; @@ -742,16 +451,15 @@ fn clip_quad(rect: Rect, uv: UvRect, clip: Rect) -> Option<(Rect, UvRect)> { [lerp(top[0], bot[0], fy), lerp(top[1], bot[1], fy)] }; let new_uv = UvRect::from_corners([ - bilerp(t_min_x, t_min_y), // top-left - bilerp(t_max_x, t_min_y), // top-right - bilerp(t_max_x, t_max_y), // bottom-right - bilerp(t_min_x, t_max_y), // bottom-left + bilerp(t_min_x, t_min_y), + bilerp(t_max_x, t_min_y), + bilerp(t_max_x, t_max_y), + bilerp(t_min_x, t_max_y), ]); Some((clipped, new_uv)) } -/// One texture-identity run: contiguous quads (in sorted z_key order) sharing the same texture handle. -/// See the module doc for why runs are contiguous rather than globally deduped by texture. +/// One run: contiguous quads, in `z_key` order, sharing a texture and every material flag. struct Run { texture: Handle, additive: bool, @@ -769,8 +477,7 @@ struct Run { } impl Run { - /// A fresh run carrying `q`'s material identity (every flag that splits a run) under the - /// already-resolved `texture` (the quad's own, or the shared white fallback). + /// A fresh run with `q`'s material flags under the resolved `texture` (its own, or the white). fn new(texture: Handle, q: &UiQuad) -> Self { Self { texture, @@ -789,14 +496,9 @@ impl Run { } } - /// Append one screen-space quad (already clipped) as two triangles, in a fixed - /// top-left/top-right/bottom-right/bottom-left winding — cross-quad order within the run is - /// entirely a function of *when* this is called (append order = paint order; see the module doc). - /// `rotation` spins the corners clockwise about the rect center in screen space (see - /// [`UiQuad::rotation`]) — the UVs stay with their corners, so the art rotates in place. - /// `to_world` maps a y-down screen point to the camera's y-up, origin-centred world space. - /// The client-space sRGB `color` rides through unconverted — the quad pass composites in gamma - /// bytes (see [`UiQuad::color`] and decision 0254). + /// Append one clipped quad as two triangles in the TL, TR, BR, BL winding; append order is + /// paint order within the run. `rotation` spins the corners clockwise about the rect centre, + /// UVs riding along; `to_world` maps y-down screen px into the camera's y-up, centred space. fn push_quad( &mut self, rect: Rect, @@ -807,13 +509,13 @@ impl Run { ) { let [tl, tr, br, bl] = uv.corners; let mut corners = [ - (Vec2::new(rect.min.x, rect.min.y), Vec2::from(tl)), // top-left - (Vec2::new(rect.max.x, rect.min.y), Vec2::from(tr)), // top-right - (Vec2::new(rect.max.x, rect.max.y), Vec2::from(br)), // bottom-right - (Vec2::new(rect.min.x, rect.max.y), Vec2::from(bl)), // bottom-left + (Vec2::new(rect.min.x, rect.min.y), Vec2::from(tl)), + (Vec2::new(rect.max.x, rect.min.y), Vec2::from(tr)), + (Vec2::new(rect.max.x, rect.max.y), Vec2::from(br)), + (Vec2::new(rect.min.x, rect.max.y), Vec2::from(bl)), ]; if rotation != 0.0 { - // Clockwise on a y-down screen = the standard CCW rotation matrix in y-down coords. + // Clockwise on a y-down screen is the standard CCW matrix in y-down coordinates. let (sin, cos) = rotation.sin_cos(); let center = (rect.min + rect.max) * 0.5; for (p, _) in &mut corners { @@ -824,8 +526,7 @@ impl Run { self.push_corners(corners, color, to_world); } - /// Append four explicit screen-space corners (fan order from corner 0 — [`UiQuad::corners`]) - /// with their UVs, as the standard `(0,1,2)(0,2,3)` fan. + /// Append four screen corners with their UVs as the fan `(0,1,2)(0,2,3)`. fn push_corners( &mut self, corners: [(Vec2, Vec2); 4], @@ -844,15 +545,10 @@ impl Run { } } -/// `WOW_UI_COST=1` also counts the frame's [`UiQuadMaterial`] asset events. bevy re-extracts and -/// re-prepares a material — one bind group and one uniform buffer, created on the render thread — -/// for exactly each `Added`/`Modified` event, and nothing else re-prepares one (2236: two or three -/// such events a frame at a parked pin cost 9.5 traced ms on Intel's DX12 driver, which prices one -/// re-prepared material in milliseconds there). So this count is the render side's material work -/// for the frame, read where it is caused — and since a colour left the material for the -/// instance tag ([`tint_tag`]) it reads ZERO on a parked frame, pulsing glow or not: a line here -/// after the interface has settled is a material being built or rebuilt for something other than -/// a new texture, and that is a regression to find, not a cost to price. +/// `WOW_UI_COST=1` also logs the frame's [`UiQuadMaterial`] asset events: bevy re-prepares a +/// material (a bind group and a uniform buffer, on the render thread) for exactly each `Added` or +/// `Modified`. It reads zero on a settled frame, pulsing glow or not; a line there is a material +/// rebuilt for something other than a new texture, a regression. fn count_material_events( mut events: MessageReader>, materials: Res>, @@ -891,40 +587,27 @@ fn count_material_events( } } -/// The rebuild's reused GPU-facing state, kept across frames so a rebuild pays for what CHANGED, -/// never for existence (the 0353 demand-price law, UI lane): batch entities and their mesh assets -/// are pools reused by run index (geometry rewritten in place — no allocator or archetype churn), -/// and materials are cached by their full identity key (same texture/blend/mask ⇒ the same -/// material asset forever, so `prepare_assets` re-prepares nothing on a steady frame). Before -/// this, every rebuild despawned every batch and allocated fresh meshes + materials — ~9 ms/frame -/// of render-side churn in a live city (0365). +/// The rebuild's state across frames, so a rebuild pays only for what changed: batch entities and +/// meshes pooled by run index and rewritten in place, materials cached by their identity key. #[derive(Default)] struct BatchPools { entities: Vec, meshes: Vec>, - /// Each slot's last-written MESH content (base positions, before [`Self::offsets`]) — the - /// per-run skip gate (decision 1361): a slot whose run is bit-identical to what its pooled - /// mesh already holds is not rewritten, so one animating quad no longer drags every batch - /// through the GPU mesh allocator. + /// Each slot's last-written mesh (base positions): an unchanged run is not rewritten. stored: Vec, - /// Each slot's current XY translation from its stored base, carried by the batch entity's - /// `Transform` instead of the mesh (decision 1463): a run that only *panned* — the minimap - /// tile was ~74% of all moving-regime rebuild triggers — moves without an `Assets` - /// write, because ONE Modified event per frame arms `AssetChanged` probes over every - /// `Mesh3d` row in the scene (1370's all-or-nothing fast path, ~0.8 ms/frame at 1461's - /// Goldshire pin). + /// Each slot's pan from its base, on the entity's `Transform`: a run that only moved (the + /// minimap tiles) costs no `Assets` write, whose `AssetChanged` probe sweeps every + /// `Mesh3d` in the scene. offsets: Vec, materials: std::collections::HashMap>, - /// Per slot: the material the batch entity currently carries. + /// Per slot: the material the batch entity carries. bound: Vec>>, - /// Per slot: the [`MeshTag`] the batch entity currently carries — the run's one colour - /// ([`tint_tag`]), rewritten only when it moves. + /// Per slot: the batch entity's [`MeshTag`], the run's one colour ([`tint_tag`]). tags: Vec>, } -/// One pooled batch slot's full identity: the mesh bytes plus everything the entity was last -/// written with (material key, z). Equality is exact — see the skip gate's comment for why a -/// hash was rejected. +/// One pooled slot's full identity: the mesh bytes and what the entity was last written with +/// (material key, z). Compared in full, never hashed: a collision would draw a stale batch. #[derive(PartialEq)] struct StoredRun { positions: Vec<[f32; 3]>, @@ -936,13 +619,9 @@ struct StoredRun { } impl StoredRun { - /// The candidate's uniform XY offset from this base: `Some(d)` when every vertex is this - /// run's vertex plus one constant delta and everything else is bit-equal — `Some(ZERO)` is - /// exactly the old bit-identical skip case, so this subsumes 1361's gate. `None` means the - /// run genuinely changed shape and must be rebaked. The comparison is exact float - /// arithmetic on purpose: a miss only falls back to the rewrite path, so a widget whose - /// pan doesn't survive `b + d == n` bit-exactly loses the optimization, never correctness. - /// Why [`Self::translation_from`] missed — the pan-gate diagnostic (`WOW_UI_DIFF=1`). + /// Why [`Self::translation_from`] missed, for `WOW_UI_DIFF=1`. That gate is `Some(d)` when + /// every vertex moved by one XY delta and all else is bit-equal (`Some(ZERO)`: unchanged); its + /// exact float compare can only cost a rewrite, never correctness. fn translation_miss_reason(&self, new: &StoredRun) -> &'static str { if self.key != new.key { "key" @@ -984,8 +663,7 @@ impl StoredRun { } } -/// A material's full identity: texture, blend/shape flags, mask texture + mask rect (as bits, so -/// NaN/-0.0 can't split cache entries byte-equal materials would share). +/// A material's full identity, floats by their bits so byte-equal materials share one entry. type MatKey = ( AssetId, bool, @@ -999,31 +677,22 @@ type MatKey = ( [u32; 4], ); -/// The [`UiQuadMaterial::uv_clamp`] that clamps NEITHER axis — `min > max` on an axis is the -/// shader's per-axis "off", so `(1,1)` against `(0,0)` disables both. +/// The [`UiQuadMaterial::uv_clamp`] that clamps neither axis: `min > max` turns an axis off. const UV_CLAMP_OFF: Vec4 = Vec4::new(1.0, 1.0, 0.0, 0.0); -/// Retire every pooled batch entity (blank frame / no drawable content). Mesh handles and the -/// material cache stay — assets referenced only by the pool cost nothing to keep and come back -/// for free. +/// Despawn every pooled batch entity; mesh handles and the material cache stay, free to keep. fn retire_batches(pools: &mut BatchPools, commands: &mut Commands) { for entity in pools.entities.drain(..) { commands.entity(entity).despawn(); } - // The skip gate must forget with them (decision 1361): a retired slot's entity is gone, so - // "content unchanged" must not skip the respawn when the UI returns. The pan gate's - // offsets ride the same lifecycle (they index the same slots). + // The skip and pan gates forget with them: a retired slot's entity is gone, so unchanged + // content must not skip the respawn when the UI returns. pools.stored.clear(); pools.offsets.clear(); } -/// Per frame: when the BASE lane flagged a change ([`UiQuads::dirty`]) or the APPEND lane's -/// content differs from last frame's, stable-sort both lanes by `z_key`, CPU-clip, split into -/// texture-identity runs, and write the runs into the pooled batches ([`BatchPools`]). See the -/// module doc for the full approach (painter's order within/across runs) and its batching -/// consequence. -/// The two asset stores [`rebuild_ui_mesh`] writes, plus the image-removal stream its material -/// cache has to hear — one bundle, because the rebuild is already at clippy's argument ceiling. +/// The two asset stores [`rebuild_ui_mesh`] writes and the image-removal stream its material +/// cache hears, bundled under clippy's argument ceiling. type RebuildStores<'w, 's> = ( ResMut<'w, Assets>, ResMut<'w, Assets>, @@ -1035,46 +704,26 @@ fn ui_mesh_frozen() -> bool { *ON.get_or_init(|| std::env::var_os("WOW_FREEZE_UI_MESH").is_some()) } -/// `WOW_UI_DIFF` presence — launch-time knob, read once (the rebuild path re-ran getenv per frame). static UI_DIFF: std::sync::LazyLock = std::sync::LazyLock::new(|| std::env::var_os("WOW_UI_DIFF").is_some()); -/// `WOW_UI_PROBE=1` — launch-time knob, read once (the per-run check re-ran getenv ~90×/frame). static UI_PROBE: std::sync::LazyLock = std::sync::LazyLock::new(|| std::env::var("WOW_UI_PROBE").as_deref() == Ok("1")); -/// **What a `quads.dirty` frame costs on the RENDER side** — the meter this pass never had -/// (decision 1625's residual, found the hard way). -/// -/// `[ui-cost]` and the hover recorder split the UI *script* pass into tick/resolve/measure/extract/ -/// convert, and stop there. Everything below — sorting every quad in the interface, clipping and -/// run-splitting them, and regenerating each run's vertex data — runs in a DIFFERENT system, on -/// exactly the frames a tooltip content change dirties the lane, and no instrument had a clock on -/// it. So the recorder built for the hover symptom could report "the UI phases are flat" on a frame -/// that was doing a full mesh rebuild, which is worse than silence: it reads as an alibi. -/// -/// Published as its own resource rather than a field of `UiFrameCost` because the two systems are -/// unordered within `Update` — a field there would be wiped by whichever ran second. +/// A rebuild's render-side cost, phases in µs: the sort, clip, split and mesh writes that the +/// script-side `[ui-cost]` phases do not see. Its own resource, not a `UiFrameCost` field: the two +/// systems are unordered in `Update`, and whichever ran second would wipe a shared field. #[derive(Resource, Default, Clone)] pub(crate) struct UiMeshCost { - /// Did the rebuild actually run this frame, or did the early-out take it? pub(crate) rebuilt: bool, - /// Total µs across the whole rebuild. pub(crate) total: u128, - /// Collect + stable sort over every quad in both lanes. pub(crate) sort: u128, - /// Clip + split into texture-identity runs. pub(crate) split: u128, - /// Vertex-data regeneration, mesh writes, material lookups, batch entity churn. pub(crate) write: u128, - /// How many quads were sorted, and how many runs came out — the counts behind the µs. pub(crate) quads: usize, pub(crate) runs: usize, - /// How many pooled batch meshes were REWRITTEN this rebuild, rather than left alone or moved - /// by a translation-only nudge (1361's skip gate). This is the number that reaches Bevy: each - /// rewrite re-extracts in `RenderExtractApp`, which is where a hover's real cost turned out to - /// live (decision 1634). `rewrites == runs` every frame means the gate is being defeated for - /// every batch at once, which is what a z coupled to the run count did. + /// Pooled meshes rewritten rather than left or panned, each re-extracted in + /// `RenderExtractApp`; `rewrites == runs` every frame means the skip gate is defeated. pub(crate) rewrites: usize, } @@ -1085,29 +734,17 @@ fn rebuild_ui_mesh( mut pools: Local, white: Option>, windows: Query<&Window, With>, - // The hide binding and the cost meter's pair, bundled (clippy's argument ceiling — the same - // reason `stores` is a tuple). + // The hide binding and the cost meter's pair, bundled under clippy's argument ceiling. mut hide_and_meter: ( Res, ResMut, Res, ), ) { - // **Forget the materials of images that no longer exist** — first, before any early return, - // because a missed read is a leak rather than a stale frame. - // - // A cached material holds a strong `Handle`, and its PREPARED form holds the whole GPU - // texture behind a bind group Bevy never re-prepares. So an entry keyed on a retired asset - // pins that texture for as long as the cache holds the key. The producer that taught this was - // the world backdrop — a full-window `Rgba16Float` image (46 MB at 3200×1800) retired on every - // resize, once a frame while a window is being dragged (decision 1647); since 2234 no quad - // samples the world's target at all, and the hygiene stays for every other image a quad names - // and then loses. - // - // `Removed` only, not `Unused`: `Unused` fires when the last strong handle drops, and this - // cache IS a strong handle, so for a cached texture it can never fire. A producer that retires - // an image removes the asset explicitly (`world_backdrop::track_render_size` still does) - // precisely so this can hear it. + // Forget the materials of removed images first, before any early return, since a missed read + // leaks: a cached material holds a strong image handle and, prepared, its GPU texture. + // `Removed`, not `Unused`: this cache is itself a strong handle, so `Unused` never fires for a + // cached image, and a producer retiring an image removes the asset explicitly. let retired: Vec> = stores .2 .read() @@ -1121,8 +758,7 @@ fn rebuild_ui_mesh( } let (hidden, mesh_cost, cost_wanted) = (&hide_and_meter.0, &mut hide_and_meter.1, &hide_and_meter.2); - // The meter is off unless something asked (the hover recorder, `WOW_UI_COST=1`) — an unmetered - // rebuild pays one bool test, not six clock reads. + // The meter is off unless asked (the hover recorder, `WOW_UI_COST=1`): one bool test. let cost_on = cost_wanted.0 || crate::ui_script::extract::ui_cost_enabled(); **mesh_cost = UiMeshCost::default(); let t_rebuild = cost_on.then(std::time::Instant::now); @@ -1137,37 +773,26 @@ fn rebuild_ui_mesh( }; let (meshes, materials) = (&mut stores.0, &mut stores.1); let q = quads.as_mut(); - // TOGGLEUI hides at the *draw*, not at the producers: both lanes keep filling, so the UI comes - // back exactly as it was (see [`crate::ui_hide::UiHidden`]). - // - // **It hides the two LANES, and the world is in neither.** The world reaches the screen as - // the first draw of this camera's main pass ([`crate::world_backdrop`], decision 2234), not - // as a batch, so - // retiring every batch while hidden leaves exactly what the binding's stated point is: "the - // world and nothing else". (Between 1603 and 2234 the world was this pass's first QUAD, and - // the dark path had to keep one batch alive to keep the screen from going black.) While dark - // we still swallow each frame's change flag and keep the append-lane mirror current, so - // neither lane can hand the rebuild a stale "nothing changed" the moment the UI returns. The - // edge is the resource's own change tick (`UiHidden` is written only by the binding and the - // world-exit reset), not a `Local` mirror. + // TOGGLEUI hides at the draw, not the producers: both lanes keep filling, so the UI returns as + // it was ([`crate::ui_hide::UiHidden`]). The world is in neither lane (it is this camera's + // first draw), so retiring every batch leaves the world. While dark, the change flag is still + // swallowed and the append-lane mirror kept current, so neither lane reports a stale + // "unchanged" on return. The edge is `UiHidden`'s own change tick. if hidden.is_changed() { q.dirty = true; } let lanes_hidden = hidden.0; if lanes_hidden { q.last_overlays.clone_from(&q.overlays); - // Nothing either lane produces can move a pixel while dark, so only the toggle edge - // reaches the rebuild below. + // Nothing moves a pixel while dark, so only the toggle edge rebuilds. if !q.dirty { return; } } else if !q.dirty && q.overlays == q.last_overlays { return; } - // `WOW_UI_DIFF=1` — WHO re-triggers the rebuild? Names the first differing overlay quad (or - // says the BASE lane's dirty flag did it), once a second. The instrument behind the - // Stormwind mesh-churn hunt: a widget re-emitting a moving/jittering quad every frame drags - // every batch through a full rebuild and the GPU allocator through ~90 frees+reallocs. + // `WOW_UI_DIFF=1`: names what re-triggered the rebuild, the base lane's dirty flag or the + // first differing overlay quad. if *UI_DIFF { if q.dirty { eprintln!("[ui-diff] BASE lane dirty"); @@ -1202,9 +827,8 @@ fn rebuild_ui_mesh( q.last_overlays.clone_from(&q.overlays); let (Ok(window), Some(white)) = (windows.single(), white) else { - // No window to size against, or the white-texture Startup system hasn't run yet — nothing to - // draw this frame; the resource stays dirty-cleared, matching "we tried and there was nothing - // to show" rather than spinning forever. + // No window, or the white texture not built yet: nothing to draw, and the flag stays + // cleared rather than spinning. retire_batches(&mut pools, &mut commands); return; }; @@ -1216,15 +840,12 @@ fn rebuild_ui_mesh( return; } - // Screen px (y-down, origin top-left) → the 2D camera's world space (y-up, origin screen-centre) — - // matches the default `OrthographicProjection` (`ScalingMode::WindowSize`, scale 1.0 ⇒ 1 world - // unit = 1 logical px) the camera spawns with. + // Screen px (y-down, top-left origin) to the camera's world space (y-up, centred): the default + // `OrthographicProjection`, one unit per logical px. let (half_w, half_h) = (window.width() * 0.5, window.height() * 0.5); let to_world = move |p: Vec2| Vec2::new(p.x - half_w, half_h - p.y); - // Stable sort by z_key: the WoW-style total order. Stable so equal-z_key quads keep the producer's - // original relative order (their own decl-order tiebreak, if any). Base lane first, append lane - // after — the same relative order the one-Vec era produced. + // Stable, so equal keys keep producer order, the base lane before the append lane. let mut sorted: Vec<&UiQuad> = if lanes_hidden { Vec::new() } else { @@ -1234,11 +855,8 @@ fn rebuild_ui_mesh( let n_sorted = sorted.len(); let us_sort = lap(); - // Geometry probe (`WOW_UI_PROBE=1`): dump each textured quad's screen rect once — the - // capture-harness companion for diagnosing extracted-vs-rendered geometry by data instead of - // by eyeballing PNGs. + // `WOW_UI_PROBE=1`: every quad's screen rect on each rebuild; the last block is current. if *UI_PROBE { - // Fire on every rebuild (rebuilds are change-driven and rare); read the LAST block. { info!( "ui probe: window {}x{} logical", @@ -1265,13 +883,11 @@ fn rebuild_ui_mesh( } } - // Split into contiguous texture-identity runs, clipping each quad on the way in. A quad clipped to - // nothing is simply never pushed — it does NOT break a run (an invisible quad has no texture to - // conflict with its neighbours). + // Split into contiguous runs, clipping each quad on the way in; a quad clipped to nothing is + // never pushed and does not break a run. let mut runs: Vec = Vec::new(); for q in sorted { - // Explicit-corner quads (the cooldown pie's wedge) carry exact geometry — no clip, no - // rotation, no UV reprojection to apply (see [`UiQuad::corners`]). + // Explicit corners (the cooldown pie's wedge) are exact: no clip, rotation or reprojection. let plain = if q.corners.is_none() { match q.clip { Some(clip) => match clip_quad(q.rect, q.uv, clip) { @@ -1318,23 +934,12 @@ fn rebuild_ui_mesh( } } - // Cross-run order: ascending world-space z (see the module doc — `bevy_sprite_render` sorts its - // `Transparent2d` phase by ascending mesh z, so later runs drawing on top is exactly "higher z"). - // Spread runs across a z window comfortably inside the camera's default near/far (±1000) regardless - // of run count, so this never depends on how many runs a given frame happens to produce. - // - // NB (decision 1634): this z DOES move when the run count moves, and `translation_from` bails on - // `z_bits` first — so it looks like a hover (which adds a run: the ButtonHilight is additive with - // its own texture and can never merge) would defeat 1361's skip gate for every batch at once. - // It was tried, with a constant denominator, and MEASURED: no change to the hover cost, because - // the gate is not in fact being defeated — `mesh_rewrites` reads 2.2 of 96.6 runs on a hover - // sweep. Left exactly as it was; the note is here so the next reader does not re-run the - // experiment. + // Across runs, ascending mesh z (`bevy_sprite_render`'s `Transparent2d` sort), spread over + // ±450, inside the camera's ±1000 near/far whatever the run count. let us_split = lap(); let run_count = runs.len().max(1) as f32; - // An unbounded material key set (a window resize moves every mask rect) resets the cache; - // materials on live batches survive via the entities' own handle clones and simply re-enter - // on their next miss. + // The key set is unbounded (a resize moves every mask rect), so it resets; live batches keep + // their materials through their own handles, which re-enter on the next miss. if pools.materials.len() > 256 { pools.materials.clear(); } @@ -1345,8 +950,6 @@ fn rebuild_ui_mesh( continue; } let z = -450.0 + (i as f32 / run_count) * 900.0; - // The mask span converts to physical framebuffer px here (the shader compares - // `@builtin(position)`); a maskless run gets a degenerate rect, which disables the branch. let scale = window.scale_factor(); let (mask_rect, mask) = match &run.mask { Some(m) => ( @@ -1380,17 +983,9 @@ fn rebuild_ui_mesh( mask_rect.to_array().map(f32::to_bits), uv_clamp.to_array().map(f32::to_bits), ); - // The per-slot skip gate (decision 1361). A rebuild fires for the WHOLE quad stream the - // moment anything differs — and one continuously-animating quad (the resting blink on - // the player frame, in every city) fires it every frame. The run that quad lives in is - // the only one whose bytes moved; the other ~90 slots are bit-identical to what their - // pooled mesh already holds, and rewriting them anyway put every UI batch through the - // GPU mesh allocator's free+realloc each frame (0.86 ms/frame at the Stormwind pin). - // Full content compare, not a hash — a stale batch drawn over a collision would be a - // rendering bug no one could reproduce. - // A run whose quads all share ONE colour stores white vertices and carries the colour on - // the batch entity's tag ([`tint_tag`]) — so a colour pulse, or a whole string fading, - // is a component write: never a mesh write, never a material write. + // The per-slot skip gate: a rebuild covers the whole stream, but a slot whose run matches + // its pooled mesh in full is not rewritten. A one-colour run stores white vertices and + // puts the colour on the batch's tag ([`tint_tag`]), so a pulse or a fade skips too. let one_colour = run .colors .first() @@ -1431,11 +1026,9 @@ fn rebuild_ui_mesh( }) }) .clone(); - // The pan gate (1463): a run that matches its slot's base up to one constant XY delta - // moves on the batch entity's `Transform` — no mesh write, no `AssetChanged` arming. - // `Some(ZERO)` is the bit-identical case (1361's old skip gate), where even the - // `Transform` write is skipped unless a previous pan is being undone. The colour rides - // the same way: a moved tag is one component write on the slot's entity. + // The pan gate: a run matching its slot's base up to one XY delta moves on the entity's + // `Transform`, with no mesh write; the unchanged case, `Some(ZERO)`, writes it only to + // undo an earlier pan. A changed material or tag is one component write. let pan = pools .stored .get(used) @@ -1469,8 +1062,7 @@ fn rebuild_ui_mesh( continue; } n_rewrites += 1; - // Pan-gate miss diagnostic (`WOW_UI_DIFF=1`, ≤3 lines/s so a startup burst can't - // exhaust it): names the check that sent this slot to the rewrite path. + // `WOW_UI_DIFF=1`, at most 3 lines a second: the check that sent this slot to a rewrite. if *UI_DIFF { use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; static SHOWN: AtomicU32 = AtomicU32::new(0); @@ -1495,8 +1087,7 @@ fn rebuild_ui_mesh( ); } } - // MAIN_WORLD too (not RENDER_WORLD-only): the pool rewrites this asset in place next - // rebuild, so the main-world copy must survive extraction. + // Main world too, not render world only: the pool rewrites this asset in place. let mut mesh = Mesh::new( PrimitiveTopology::TriangleList, RenderAssetUsages::default(), @@ -1510,8 +1101,7 @@ fn rebuild_ui_mesh( } else { pools.stored.push(stored); } - // A rebaked slot's mesh holds absolute positions again — its pan resets with it (the - // rewrite arm below writes `Transform::from_xyz(0, 0, z)`). + // A rebaked mesh holds absolute positions, so its pan resets (the entity gets `(0, 0, z)`). if pools.offsets.len() > used { pools.offsets[used] = Vec2::ZERO; } else { @@ -1519,13 +1109,8 @@ fn rebuild_ui_mesh( } let mesh_handle = match pools.meshes.get(used) { Some(handle) => { - // `WOW_FREEZE_UI_MESH=1` — never rewrite a pooled batch mesh in place (the UI - // freezes at its first-built frame). An EXPERIMENT knob (the 1370 bracket): the - // resting blink's ~2 rewrites/frame are one-asset `Assets` mutations, and - // bevy 0.18's `AssetChanged` fast path is all-or-nothing — one modified - // mesh arms a hash probe over every `Mesh3d` row in the scene (~44k at the SW - // pin) in three PostUpdate walks. This lever prices that arming; `WOW_MESH_EVENTS` - // confirms it (events/s → 0). First build (the `None` arm) is untouched. + // `WOW_FREEZE_UI_MESH=1` freezes the UI at its first build, pricing the hash probe + // one modified mesh arms over every `Mesh3d` row (bevy 0.18's `AssetChanged`). if !ui_mesh_frozen() { let _ = meshes.insert(handle.id(), mesh); } @@ -1539,8 +1124,6 @@ fn rebuild_ui_mesh( }; pools.bound[used] = Some(material_handle.id()); pools.tags[used] = Some(tag); - // Reuse the batch entity at this slot — same Mesh2d handle (the pooled asset), a material - // handle that only changes when the run's identity does, the run's colour tag, a fresh z. match pools.entities.get(used) { Some(&entity) => { commands.entity(entity).insert(( @@ -1566,8 +1149,7 @@ fn rebuild_ui_mesh( } used += 1; } - // Retire the surplus: batch entities beyond this frame's run count despawn, and their pooled - // mesh assets drop with the truncation (nothing references them anymore). + // Entities past this frame's run count despawn; their meshes drop with the truncation. for entity in pools.entities.drain(used..) { commands.entity(entity).despawn(); } @@ -1593,15 +1175,13 @@ fn rebuild_ui_mesh( mod tests { use super::*; - /// The rebuild in isolation: real resources, no renderer (the batches are plain entities until - /// something draws them). + /// The rebuild in isolation: real resources, no renderer. fn rebuild_app() -> App { let mut app = App::new(); app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default())) .init_asset::() .init_asset::() - // The pass binds images and listens for their removal (the material cache sweep), so - // an app without the Image asset is an under-provisioned harness, not a lighter one. + // The pass binds images and hears their removal, so the harness needs the Image asset. .init_asset::() .init_resource::() .init_resource::() @@ -1610,7 +1190,6 @@ mod tests { .insert_resource(UiWhiteTexture(Handle::default())) .add_systems(Update, rebuild_ui_mesh); app.world_mut().spawn((Window::default(), PrimaryWindow)); - // One flat quad in the BASE lane — enough content for exactly one batch. let mut quads = app.world_mut().resource_mut::(); quads.quads.push(UiQuad { rect: Rect::new(0.0, 0.0, 64.0, 64.0), @@ -1620,12 +1199,8 @@ mod tests { app } - /// The projector's accept region is **exactly the viewport, inclusive** — the WorldFrame's own - /// region mirrored ÷G44/÷G48 (`0x483970`), so the aspect factors cancel. A point on the edge - /// draws (the seat's clamp, not this, is what keeps the rect fully inside); a point outside it - /// does not, however plausible the coordinate the projector already wrote looks. The last row - /// is the measured phantom that bought this: a unit two thirds of a screen below the bottom, - /// whose plate used to be dragged up onto the border (1341). + /// The WorldFrame's region mirrored ÷G44/÷G48 (`0x483970`) is the viewport, inclusive. The + /// last row is a measured unit two thirds of a screen below the bottom edge. #[test] fn the_accept_region_is_the_viewport_inclusive() { let vp = Vec2::new(1440.0, 810.0); @@ -1654,10 +1229,8 @@ mod tests { app.world_mut().resource_mut::().0 = hidden; } - /// TOGGLEUI at the draw: hiding retires the batches, and un-hiding brings the SAME content back - /// — the case the two lanes' change protocol would otherwise swallow, since nothing about the - /// quads themselves changed while the UI was dark (`dirty` false, the append lane identical), - /// so the rebuild would have taken its early-out and left the screen bare. + /// Un-hiding brings back the same content, though neither lane changed while dark and the + /// rebuild would otherwise take its early-out. #[test] fn toggleui_retires_the_batches_and_restores_them() { let mut app = rebuild_app(); @@ -1667,8 +1240,7 @@ mod tests { set_hidden(&mut app, true); app.update(); assert_eq!(batches(&mut app), 0, "hidden ⇒ nothing drawn"); - // Still nothing after further frames: the producers keep filling both lanes, and the - // rebuild must keep taking its dark path rather than re-spawning on the next flagged frame. + // A flagged frame while dark still takes the dark path. app.world_mut().resource_mut::().dirty = true; app.update(); assert_eq!(batches(&mut app), 0, "hidden stays hidden"); @@ -1682,17 +1254,7 @@ mod tests { ); } - /// **A removed image takes its cached material with it** (decision 1647). - /// - /// The cache holds a strong `Handle` per entry and its prepared form holds the whole GPU - /// texture behind a bind group Bevy never re-prepares — so an entry keyed on an asset that no - /// longer exists pins that texture indefinitely. Nothing here notices until something retires - /// images in bulk, and the world backdrop did exactly that while it was a quad (1603–2234): - /// one full-window `Rgba16Float` image per resize, once a frame while a window was dragged. - /// - /// The removal is seen a few frames late by construction (the loop below names the three - /// lags). That is fine for memory hygiene and would NOT be fine for correctness — which is - /// exactly why the correctness half of 1647 is a new `AssetId` rather than an invalidation. + /// The cache's strong handle would otherwise pin the image and its GPU texture for good. #[test] fn a_removed_image_does_not_keep_its_material_alive() { let mut app = rebuild_app(); @@ -1726,8 +1288,7 @@ mod tests { "the textured quad must have produced a material naming that image" ); - // Retire it exactly the way the world backdrop retires a resized target: drop every quad - // that names it, then remove the asset. Only the cache is left holding it. + // Drop every quad that names it, then remove the asset: only the cache still holds it. drop(art); { let mut q = app.world_mut().resource_mut::(); @@ -1737,12 +1298,9 @@ mod tests { app.world_mut() .resource_mut::>() .remove(art_id); - // Three separate one-frame lags stand between the removal and an observably dead material: - // `Assets::asset_events` writes the `Removed` message in `PostUpdate` while the rebuild - // reads it in `Update`; the retired batch entity's own handle drops at that frame's command - // flush; and a dropped handle is only reclaimed by `track_assets` in the NEXT `PreUpdate`. - // None of that matters for the freeze — the correctness half is a new AssetId, not an - // invalidation — so the loop simply spends the frames rather than pretending to be exact. + // Three one-frame lags: `Removed` is written in `PostUpdate` and read in `Update`, the + // retired batch's handle drops at that frame's command flush, and `track_assets` reclaims + // it the next `PreUpdate`. for _ in 0..4 { app.update(); } @@ -1762,15 +1320,8 @@ mod tests { .any(|(_, m)| m.texture.as_ref().is_some_and(|t| t.id() == id)) } - /// **TOGGLEUI hides the UI, not the world — and the world is not a batch.** The world is the - /// first draw of the UI camera's main pass (`benilla_world::ffx_glow::FfxBackdrop`, 2234), not a - /// quad in either lane, so the dark path is what it was before 1603 put a backdrop quad in - /// here: retire every batch. That leaves "the world and nothing else", which is the binding's - /// stated point, and the lanes come back on top of it exactly as they were. - /// - /// (Between 1603 and 2234 this test held ONE batch alive while dark — the backdrop's — because - /// zero batches was then a black screen. A future version that keeps a batch alive while dark - /// is drawing something the binding says must not be drawn.) + /// The world is the UI camera's first draw (`benilla_world::ffx_glow::FfxBackdrop`), not a + /// batch, so dark leaves the world and nothing else; a batch alive while dark is a bug. #[test] fn toggleui_retires_every_batch_and_the_world_is_none_of_them() { let mut app = rebuild_app(); @@ -1795,18 +1346,12 @@ mod tests { ); } - /// **The desaturation flag reaches the MATERIAL, and splits the run** (decision 1327). - /// - /// Everything upstream of here can be right — the Lua sets it, extract carries it, the quad - /// holds it — and the screen still not change, because the greyscale lives in a shader uniform - /// and a uniform only exists per material. Two quads that differ ONLY in `desaturated` must - /// therefore batch apart; if they merged, whichever of the two came first would decide the look - /// of both, and the visible symptom would be a talent tree that greys in blocks. + /// The greyscale is a per-material uniform: merged, the first quad would decide both (a talent + /// tree greying in blocks). #[test] fn desaturation_splits_the_run_and_lands_on_the_material() { let mut app = rebuild_app(); - // Same texture (the shared white), same blend, adjacent in z — everything that batches - // agrees, so `desaturated` is the only thing that can split them. + // Same texture (the shared white), same blend, adjacent in z: only `desaturated` differs. let mut quads = app.world_mut().resource_mut::(); quads.quads.push(UiQuad { rect: Rect::new(64.0, 0.0, 128.0, 64.0), @@ -1836,10 +1381,7 @@ mod tests { ); } - /// **The tag is the reference's byte colour, complemented.** `SetVertexColor` quantises - /// `×255 + 0.5` into one byte per channel (`0x79abd0`), and the - /// complement is what makes bevy's untagged 0 read as opaque white (the minimap's interior - /// tiles never set one). + /// `SetVertexColor` quantises `×255 + 0.5` into one byte per channel (`0x79abd0`). #[test] fn tint_tag_is_the_reference_byte_colour_complemented() { assert_eq!(tint_tag([1.0; 4]), 0, "white is the untagged default"); @@ -1848,8 +1390,8 @@ mod tests { u32::MAX, "transparent black is a real colour, not a missing tag" ); - // Gold at half alpha: 255, 224 (224.9 truncated), 64 (64.25), 128 (128.0) — the bytes the - // reference packs, r in the low byte the way `unpack4x8unorm` reads them back. + // Gold at half alpha: 255, 224 (224.9 truncated), 64 (64.25), 128 (128.0), r in the low + // byte as `unpack4x8unorm` reads it. assert_eq!( tint_tag([1.0, 0.88, 0.25, 0.5]), !(255 | 224 << 8 | 64 << 16 | 128 << 24) @@ -1861,27 +1403,20 @@ mod tests { ); } - /// **A colour pulse is one component write** — no `Assets` write and no material - /// write (decision 2236's gate: the `WOW_UI_COST=1` material-event count must be ZERO on a - /// parked frame with a pulsing element, not merely cheaper). The stock PlayerFrame status - /// glow pulses its alpha every frame at rest; on Intel's DX12 driver each material it - /// re-prepared cost milliseconds, and as a vertex colour it had cost an asset-changed sweep - /// over every mesh in the scene. So the colour goes on the batch entity's `MeshTag`, the - /// mesh stays white and untouched, and the material — its identity alone — is never - /// rewritten. + /// A colour pulse (the stock PlayerFrame status glow, every frame at rest) must leave the + /// `WOW_UI_COST=1` material-event count at zero: no mesh write, no material write. #[test] fn a_colour_pulse_writes_the_tag_and_neither_asset() { let mut app = rebuild_app(); - // The rebuild's own meter is off unless asked (a player's frame pays one bool for it); - // this test reads its `rewrites`, so it asks. + // The meter is off unless asked, and this test reads `rewrites`. app.world_mut() .resource_mut::() .0 = true; app.update(); assert_eq!(batches(&mut app), 1); - // Read through a cursor, not the "current update" buffer: `TimePlugin` only lets the - // message buffers swap after a FIXED step (`signal_message_update_system`), and this - // harness ticks faster than one, so frame 1's events are still "current" on frame 2. + // A cursor, not the current buffer: `TimePlugin` swaps message buffers only after a fixed + // step (`signal_message_update_system`), and this harness ticks faster, so frame 1's + // events are still current on frame 2. let mut cursor = bevy::ecs::message::MessageCursor::>::default(); let mut material_events = |app: &App| { let (mut added, mut modified) = (0, 0); @@ -1913,7 +1448,6 @@ mod tests { "a white quad carries the white tag" ); - // The pulse: the same quad, another colour. let gold = [1.0, 0.88, 0.25, 0.5]; { let mut q = app.world_mut().resource_mut::(); @@ -1960,13 +1494,11 @@ mod tests { ); } - // The mirror-preservation guarantee, and the reason [`UvRect`] exists instead of - // `bevy::math::Rect`: PlayerFrameTexture's `` - // (ref-PlayerFrame.xml l.55-57) must reach the vertex buffer with `u0 > u1`. `Rect::from_corners` - // normalizes min/max and would silently un-mirror the ring art. + // PlayerFrameTexture's `` (`PlayerFrame.xml:56`) must + // reach the vertex buffer mirrored; a normalizing `Rect` would un-mirror the ring. #[test] fn mirrored_tex_coords_emit_unnormalized_corners() { - // [left, right, top, bottom] as extraction hands them over — left > right (horizontal mirror). + // `[left, right, top, bottom]` as the extract hands them over. let uv = UvRect::from_tex_coords([1.0, 0.09375, 0.0, 0.78125]); let [tl, tr, br, bl] = uv.corners; assert!( @@ -1975,22 +1507,16 @@ mod tests { tl[0], tr[0] ); - // The top-LEFT screen vertex samples u=1.0 and the top-RIGHT samples u=0.09375 — i.e. the - // texture is flipped horizontally on the quad (push_quad maps corners[0]→TL, corners[1]→TR). assert_eq!(tl, [1.0, 0.0]); assert_eq!(tr, [0.09375, 0.0]); assert_eq!(br, [0.09375, 0.78125]); assert_eq!(bl, [1.0, 0.78125]); } - // clip_quad reprojects UVs by the clipped screen-edge fractions. The bilinear reduces to the - // separable corner lerp for an axis-aligned crop, so a mirrored source UV must survive clipping - // still mirrored (the pin's "verify, don't assume"). #[test] fn clip_preserves_mirrored_uv() { let uv = UvRect::from_tex_coords([1.0, 0.09375, 0.0, 0.78125]); let rect = Rect::new(0.0, 0.0, 100.0, 100.0); - // Clip to the left half of the quad. let clip = Rect::new(0.0, 0.0, 50.0, 100.0); let (crect, cuv) = clip_quad(rect, uv, clip).expect("left half is a non-empty intersection"); @@ -2002,32 +1528,25 @@ mod tests { tl[0], tr[0] ); - // Left half of a mirrored range [1.0 .. 0.09375] samples [1.0 .. midpoint]. assert_eq!(tl[0], 1.0); assert!((tr[0] - 0.546_875).abs() < 1e-6); // lerp(1.0, 0.09375, 0.5) - // The un-clipped vertical axis is untouched. assert_eq!(tl[1], 0.0); assert_eq!(cuv.corners[3][1], 0.78125); // BL.v } - // A rotated backdrop TOP-edge UV (atlas-u tied to screen-Y, atlas-v to screen-X reversed — - // `0x77f0c0`): clipping to the left half must reproject the *v* axis (screen-X), - // leaving *u* (screen-Y) untouched. This is the case the old separable-lerp clip could not do - // and the four-corner bilinear must. + // A backdrop top edge maps atlas u to screen Y and v to screen X reversed (`0x77f0c0`), so + // clipping the left half reprojects v and leaves u. #[test] fn clip_reprojects_rotated_uv() { - // TOP edge with widthRun = 4: TL=(0.25,4) TR=(0.25,0) BR=(0.375,0) BL=(0.375,4). + // Top edge with widthRun = 4. let uv = UvRect::from_corners([[0.25, 4.0], [0.25, 0.0], [0.375, 0.0], [0.375, 4.0]]); let rect = Rect::new(0.0, 0.0, 100.0, 16.0); - // Clip to the left half in screen-X (v axis), full height (u axis untouched). let clip = Rect::new(0.0, 0.0, 50.0, 16.0); let (crect, cuv) = clip_quad(rect, uv, clip).expect("left half intersects"); assert_eq!(crect, Rect::new(0.0, 0.0, 50.0, 16.0)); let [tl, tr, br, bl] = cuv.corners; - // u (screen-Y) unchanged: top row still 0.25, bottom row still 0.375. assert!((tl[0] - 0.25).abs() < 1e-6 && (tr[0] - 0.25).abs() < 1e-6); assert!((bl[0] - 0.375).abs() < 1e-6 && (br[0] - 0.375).abs() < 1e-6); - // v (screen-X, reversed): left edge still 4.0; right edge now the midpoint 2.0. assert!((tl[1] - 4.0).abs() < 1e-6, "left v kept: {}", tl[1]); assert!((tr[1] - 2.0).abs() < 1e-6, "right v halved: {}", tr[1]); } diff --git a/crates/benilla-app/src/ui_script/action_bar_tests.rs b/crates/benilla-app/src/ui_script/action_bar_tests.rs index f8086997..1a7fe5a8 100644 --- a/crates/benilla-app/src/ui_script/action_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/action_bar_tests.rs @@ -1,23 +1,17 @@ use benilla_ui::script::{ActionSlot, QuadContent, ScriptValue, UiScript}; -/// The queued action ids alone — `take_action_uses` carries `UseAction`'s self-cast modifier -/// beside the id since 1745, and every assertion in this file is about the id. +/// The queued `UseAction` ids, without the self-cast flag `take_action_uses` carries beside them. fn action_ids(s: &mut UiScript) -> Vec { s.take_action_uses().into_iter().map(|u| u.action).collect() } -/// Load the stock `Interface\FrameXML\ActionBarFrame.xml` into a bare engine and -/// drive it with a synthetic action snapshot — the slice-1 chain minus Bevy: template -/// expansion over 12 instances, the vanilla bonus-page formula, icon paint on events, empty -/// slots drawing no icon, and a physical click queuing the right UseAction id. +/// The stock main bar without Bevy: a stance's bonus page, its icons, a click and a key. #[test] fn shipped_action_bar_drives_end_to_end() { benilla_formats::wow_data_or_skip!(); let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); 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"); super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua"); super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml"); @@ -41,19 +35,14 @@ fn shipped_action_bar_drives_end_to_end() { overlay frame, no performance-bar button" ); - // `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code, - // safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since - // 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too - // or the tick raises on its first event. - // In manifest order: the fonts its check-box labels colour from, the panel templates those - // boxes inherit through, then the pane. + // `ExhaustionTick_Update` reads `ReputationWatchBar`, which ReputationFrame.xml declares. super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml"); super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); super::test_ui::load_ui(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml"); super::test_ui::load_ui(&s, r"Interface\FrameXML\ReputationFrame.xml"); - // A warrior in battle stance: offset 1 ⇒ the bar shows actions 73..84. + // A warrior in Battle Stance: bonus offset 1, actions 73..84. s.set_bonus_bar_offset(1); s.set_action( 73, @@ -76,20 +65,15 @@ fn shipped_action_bar_drives_end_to_end() { }), ); s.fire_event("PLAYER_ENTERING_WORLD", vec![]); - // The app fires this on the offset's edge (ui_action/feed.rs); the stock bonus frame shows on - // it and slides up over the main bar for BONUSACTIONBAR_SLIDETIME (0.15 s) — one OnUpdate - // paints the start, the next lands it. + // Fired on the offset's edge; the bonus frame's slide takes two OnUpdates: start, then land. s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]); s.tick(10.0); s.tick(0.2); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); - // The stance page is painted by BonusActionButton1/2, not by the main bar (1897/1938). The - // 1024-wide bar centers at BOTTOM of the 1024-wide screen ⇒ bar left edge = 0; the bonus - // frame lands at the bar's BOTTOMLEFT + (BONUSACTIONBAR_XPOS 4, BONUSACTIONBAR_YPOS 43), is - // 43 high, and its button 1 sits at its own BOTTOMLEFT + (5, 4), 36×36 ⇒ x[9,45] y[4,40] — - // one pixel right of the main bar's (8, 4). The reference's own geometry, off - // BonusActionBarFrame.xml:54-96; the chain stride is 36 + 6 = 42. + // The stance page is painted by `BonusActionButton1/2`. The 1024-wide bar's left edge is 0; + // the 43-high bonus frame's TOPLEFT lands at the bar's BOTTOMLEFT + (4, 43), and button 1 at + // (5, 4), 36x36: x[9,45] y[4,40], stride 36 + 6 (BonusActionBarFrame.xml:54-105). s.resolve(); let quads = s.extract(); let icon = |path: &str| { @@ -102,8 +86,6 @@ fn shipped_action_bar_drives_end_to_end() { assert_eq!((r.left, r.bottom, r.right, r.top), (9.0, 4.0, 45.0, 40.0)); let r2 = icon("Interface\\Icons\\Ability_Rogue_Ambush").expect("bonus button 2 icon"); assert_eq!(r2.left, 9.0 + 42.0); // bonus button 2 left = 51 - // Only an OCCUPIED button is shown — an empty one hides while showgrid == 0 - // (ActionButton.lua:69-70) — so two rings, two icons. let rings = quads .iter() .filter(|q| { @@ -124,16 +106,12 @@ fn shipped_action_bar_drives_end_to_end() { .count(); assert_eq!(icons, 2, "empty slots draw no icon quad"); - // A physical click at (26, 22) lands on BonusActionButton1 (9..45 × 4..40, two frame levels - // above the hidden main slot) and queues UseAction(73) — the stance page's id, not 1. + // A click at (26, 22) lands on `BonusActionButton1` and queues its paged id, 73. s.mouse_button(26.0, 22.0, "LeftButton", true); s.mouse_button(26.0, 22.0, "LeftButton", false); assert_eq!(action_ids(&mut s), vec![73]); - // The keybinding entry (the app's key feed runs `ActionButtonDown/Up(i)` on the two - // key edges — the ref's ACTIONBUTTONn binding, ActionButton.lua:15-45): UP fires UseAction - // directly with no checkCursor (a keybind never places, decision 0216 §7) — but ONLY from - // the PUSHED state a DOWN set, so a stray release with no press is the ref's own no-op. + // `ACTIONBUTTONn` runs `ActionButtonDown/Up(n)` on the key's two edges (Bindings.xml:121-127). s.run("ActionButtonUp(2)").unwrap(); assert!( s.take_action_uses().is_empty(), @@ -160,11 +138,9 @@ fn shipped_action_bar_drives_end_to_end() { assert_eq!(action_ids(&mut s), vec![74], "key '2' fires action 74"); assert_eq!(depressed(&s), 0, "key UP restores the normal state"); - // Stance drops (offset 0): the bar re-pages to actions 1..12 — all empty here, icons clear. s.set_bonus_bar_offset(0); s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]); - // The overlay descends carrying the old form's page (`lastBonusBar`) and hides at the end - // of its slide — one OnUpdate more than BONUSACTIONBAR_SLIDETIME (paint, then advance). + // The overlay descends and hides one OnUpdate past the slide time. s.tick(0.2); s.tick(0.01); s.resolve(); @@ -195,26 +171,19 @@ fn load_action_bar(s: &UiScript) { super::test_ui::load_ui(s, "Interface\\FrameXML\\ActionBarFrame.xml"); super::test_ui::load_ui(s, "Interface\\FrameXML\\BonusActionBarFrame.xml"); - // `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code, - // safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since - // 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too - // or the tick raises on its first event. - // In manifest order: the fonts its check-box labels colour from, the panel templates those - // boxes inherit through, then the pane. + // `ExhaustionTick_Update` reads `ReputationWatchBar`, which ReputationFrame.xml declares. super::test_ui::load_ui(s, "Interface\\FrameXML\\Fonts.xml"); super::test_ui::load_ui(s, r"Interface\FrameXML\UIPanelTemplates.lua"); super::test_ui::load_ui(s, r"Interface\FrameXML\UIPanelTemplates.xml"); super::test_ui::load_ui(s, r"Interface\FrameXML\OptionsFrameTemplates.xml"); super::test_ui::load_ui(s, r"Interface\FrameXML\ReputationFrame.xml"); - // The dialog engine — the keybindings page registers its two confirms into its table (1960). + // StaticPopup.xml: the keybindings page adds its two confirm dialogs to `StaticPopupDialogs`. super::test_ui::load_ui(s, r"Interface\FrameXML\BasicControls.xml"); // `TEXT` super::test_ui::load_ui(s, r"Interface\FrameXML\LocaleProperties.lua"); // `GetText` super::test_ui::load_ui(s, r"Interface\FrameXML\StaticPopup.xml"); super::test_ui::load_ui(s, "Interface\\FrameXML\\UIDropDownMenu.xml"); - // `LOCK_ACTIONBAR` and `ALWAYS_SHOW_MULTIBARS` are declared by `UIOptionsFrame_Init` — the - // reference's own home for them, and off the chain since 2115 (they were our - // `OptionsFrame.xml`'s from 1938 until then). The manifest loads this before the bars and - // before our window, whose Action Bars rows capture the value as their Defaults; so does this. + // `UIOptionsFrame_Init` declares `LOCK_ACTIONBAR` and `ALWAYS_SHOW_MULTIBARS` + // (UIOptionsFrame.lua:93-107), before our window, whose rows take their defaults from them. super::test_ui::load_ui(s, r"Interface\FrameXML\OptionsFrame.lua"); super::test_ui::load_ui(s, r"Interface\FrameXML\UIOptionsFrame.xml"); super::test_ui::load_ui(s, "ScrollTemplates.xml"); @@ -222,9 +191,7 @@ fn load_action_bar(s: &UiScript) { super::test_ui::load_ui(s, "OptionsFrame.xml"); } -/// The state/feedback layer (decision 0137 phase 4) through the REAL shipped XML: a pushed -/// cooldown + `ACTIONBAR_UPDATE_COOLDOWN` arms the button's Cooldown widget, `IsCurrentAction` + -/// `ACTIONBAR_UPDATE_STATE` checks the ring, and `IsUsableAction`'s OOM pair blue-tints the icon. +/// The state events through the stock bar: the cooldown, the checked ring and the usable tint. #[test] fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() { benilla_formats::wow_data_or_skip!(); @@ -246,7 +213,7 @@ fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() { s.fire_event("PLAYER_ENTERING_WORLD", vec![]); s.tick(10.0); // a nonzero GetTime epoch - // A running 10 s cooldown with 6 s left + the update event: the widget shows mid-sweep. + // A 10 s cooldown with 6 s left: the pane shows mid-sweep. s.set_action_state( 1, Some(ActionState { @@ -256,8 +223,7 @@ 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`. + // `CooldownFrame_SetTimer` arms sequence 0; the next paint's `OnUpdateModel` scrubs it. super::test_ui::cooldown_facts(&mut s); s.tick(0.0); s.resolve(); @@ -269,7 +235,7 @@ fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() { "6 s of 10 s left ⇒ the sweep sits at 40 %: sequence 0 at 400 ms" ); - // The checked ring on the current action (the transcribed UpdateState). + // The checked ring on the current action (`ActionButton_UpdateState`). s.set_action_state( 1, Some(ActionState { @@ -282,7 +248,7 @@ fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() { s.fire_event("ACTIONBAR_UPDATE_STATE", vec![]); assert!(s.eval::("return ActionButton1:GetChecked()").unwrap()); - // The OOM blue tint (the transcribed UpdateUsable): usable=false + notEnoughMana=true. + // Out of power (not usable, not enough mana): the blue tint. s.set_action_state( 1, Some(ActionState { @@ -309,10 +275,8 @@ fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() { "the ref's out-of-power blue-grey" ); - // The PLAIN grey — `usable = false` with `notEnoughMana = false`, which is what an unusable - // item reads (food in combat, the whole point of the ITEM arm's spell walk). Stock - // `ActionButton_UpdateUsable`'s `else` dims the ICON to 0.4 and leaves the normal texture - // at full strength, so the ring keeps its colour under a dead icon. + // Unusable for another reason (food in combat): the `else` arm greys the icon to 0.4 and + // leaves the ring at full strength (ActionButton.lua:279-281). s.set_action_state( 1, Some(ActionState { @@ -346,15 +310,8 @@ fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// **Why a restarted `GetTime` clock reads on the bar as "no cooldown at all"** (decision 2116), -/// through the shipped `Cooldown.lua`: `CooldownFrame_SetTimer`'s only gate is -/// `start > 0 and duration > 0 and enable > 0`, and its `else` branch is `this:Hide()`. So the -/// SAME running cooldown draws or vanishes purely on which clock its start was converted against. -/// -/// This is the observable half of the relog bug: the store held the cooldown (the press was still -/// refused), the feed pushed a triple every frame, and the button showed nothing — because the VM -/// had been rebuilt and its clock had gone back to zero, putting every already-running cooldown's -/// start behind the new epoch. +/// `CooldownFrame_SetTimer` hides the pane unless `start > 0 and duration > 0 and enable > 0` +/// (Cooldown.lua:3): a running cooldown whose start predates a restarted `GetTime` never draws. #[test] fn a_start_behind_the_clocks_epoch_hides_the_stock_sweep() { benilla_formats::wow_data_or_skip!(); @@ -374,7 +331,7 @@ fn a_start_behind_the_clocks_epoch_hides_the_stock_sweep() { }), ); s.fire_event("PLAYER_ENTERING_WORLD", vec![]); - s.tick(10.0); // GetTime == 10 — a clock that restarted ten seconds ago + s.tick(10.0); // GetTime is 10: a clock that restarted ten seconds ago // A 10-minute cooldown armed 30 s ago, converted against that restarted clock: start = −20 s. s.set_action_state( @@ -396,8 +353,7 @@ fn a_start_behind_the_clocks_epoch_hides_the_stock_sweep() { button shows nothing" ); - // The same cooldown on a clock that never restarted: GetTime 100, armed at 70. The sweep is - // exactly where it belongs. + // The same cooldown on a clock that never restarted: GetTime 100, armed at 70. s.tick(90.0); s.set_action_state( 1, @@ -420,11 +376,8 @@ fn a_start_behind_the_clocks_epoch_hides_the_stock_sweep() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// The pie paints OVER its button's art. The Cooldown child is born at button-level+1, and the -/// draw key's LEVEL term outranks 0884's bucket-wide layer term — so the sweep quad must sort -/// after the icon (BACKGROUND) and after the button's own special textures. A regression here is -/// invisible to every store/feed instrument (the triple still pushes; only the pixels vanish -/// under the icon), which is exactly why the order is pinned end-to-end through the real XML. +/// The cooldown child sits at its button's level + 1 and frame level outranks draw layer, so the +/// sweep paints over the button's icon and ring. #[test] fn the_cooldown_sweep_paints_over_the_buttons_icon_and_ring() { benilla_formats::wow_data_or_skip!(); @@ -481,23 +434,7 @@ fn the_cooldown_sweep_paints_over_the_buttons_icon_and_ring() { ); } -/// **A cooldown-count addon's hook leaves the sweep exactly where it was.** -/// -/// `!OmniCC` 6.8.30 — the cooldown addon on the director's screen — is a single wrap of the -/// FrameXML global: it captures `CooldownFrame_SetTimer` in an upvalue, replaces the global with -/// a function that calls through, and hangs a `Frame` + `FontString` off the button for its own -/// countdown, keeping the handle on a field of the cooldown widget itself (`cd.textFrame`). The -/// shape is transcribed here — the widget verbs it uses, not its source — because that shape -/// touches everything the pie's paint depends on: the global the bar calls, the widget's own -/// Lua fields (`start`/`duration`/`stopping`, which the stock `Cooldown.lua` writes and its -/// `OnUpdateModel` reads back), and the button's frame-level stack. -/// -/// It came in as "with `!OmniCC` installed the pie and the GCD sweep are gone", and this is the -/// half that answers whether the addon's hook itself is what breaks them. It is not: the engine -/// reports the same paint list, the same armed sequence and the same scrub with the wrap in -/// place as without it. (What did break them is the tile renderer's per-VM state — enabling an -/// addon costs a logout and a login, and the pane's model facts did not survive that; see -/// `ui_models`' own tests.) +/// A cooldown-count addon's wrap of `CooldownFrame_SetTimer` leaves the sweep as it was. #[test] fn a_cooldown_count_addons_hook_leaves_the_sweep_running() { benilla_formats::wow_data_or_skip!(); @@ -506,8 +443,7 @@ fn a_cooldown_count_addons_hook_leaves_the_sweep_running() { let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); load_action_bar(&s); - // The addon loads after FrameXML (`!` sorts it first among addons, all of which run after the - // interface), so the global it captures is the stock one. + // Addons load after FrameXML, so the hook captures the stock global. s.run(COOLDOWN_COUNT_HOOK) .expect("the addon's hook installs"); @@ -550,7 +486,6 @@ fn a_cooldown_count_addons_hook_leaves_the_sweep_running() { Some((0, 400)), "the wrap calls through, so the pane is on the paint list with sequence 0 at 40 %" ); - // And it goes cold the reference's way when the cooldown is cleared. s.set_action_state( 1, Some(ActionState { @@ -569,11 +504,8 @@ fn a_cooldown_count_addons_hook_leaves_the_sweep_running() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// `!OmniCC` 6.8.30's hook, as shape: it captures `CooldownFrame_SetTimer` in an upvalue, replaces -/// the global with a function that calls through, and hangs its countdown off the BUTTON — a -/// `CreateFrame` parented there lands at `button + 1`, then its own `+ 1` puts it at `button + 2`, -/// one over where it trusts the cooldown to sit. The handle rides a field of the cooldown widget -/// itself (`cd.textFrame`). +/// `!OmniCC` 6.8.30's hook in shape: it wraps `CooldownFrame_SetTimer` and hangs its countdown +/// off the button at `button + 2`, one level over the cooldown, kept in `cd.textFrame`. const COOLDOWN_COUNT_HOOK: &str = r#" local original = CooldownFrame_SetTimer seen = 0 @@ -607,14 +539,9 @@ const COOLDOWN_COUNT_HOOK: &str = r#" end "#; -/// **On the bonus bar the countdown draws over the sweep, as it does on every other bar** -/// (decision 2189). It came in as "the cooldown counter on action bar 1 is hidden behind the pie"; -/// a warrior in a stance — a druid in a form, a rogue in stealth — sees the bonus bar there. -/// -/// Stock `BonusActionButtonTemplate`'s `OnLoad` raises the button `+2` and then its cooldown `+2` -/// **by hand**, which is only one level of separation because a script level change carries no -/// children (`0x774560` → `set_frame_level(…, propagate=0)`). Our binding carried them, so the -/// cooldown came out at `button + 3` — over the count text the hook hangs at `button + 2`. +/// `BonusActionButtonTemplate`'s OnLoad raises the button by 2, then its cooldown by 2 +/// (BonusActionBarFrame.xml:13-15); a script level change carries no children (`0x774560`), so +/// the cooldown sits one level over its button, under the countdown. #[test] fn a_cooldown_count_draws_over_the_bonus_bars_sweep() { benilla_formats::wow_data_or_skip!(); @@ -659,7 +586,7 @@ fn a_cooldown_count_draws_over_the_bonus_bars_sweep() { ); s.fire_event("ACTIONBAR_UPDATE_COOLDOWN", vec![]); super::test_ui::cooldown_facts(&mut s); - // The addon writes its digits from its OnUpdate; the transcription's is a no-op. + // The real addon writes its digits from OnUpdate; this shape's OnUpdate is empty. s.run(r#"BonusActionButton1Cooldown.textFrame.text:SetText("27")"#) .expect("the hook hung its countdown off the bonus button"); s.tick(0.0); @@ -684,14 +611,8 @@ fn a_cooldown_count_draws_over_the_bonus_bars_sweep() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// An action button is a TWO-button button (decision 0908; director's report B200: "I can't right -/// click food on my bar to eat it or right click spells"). The ref's `ActionButton_OnLoad` -/// registers `("LeftButtonUp", "RightButtonUp")` (ActionButton.lua:109) and its OnClick body reads -/// no `arg1` — so either button runs the same fork and right-click USES the action. The widget -/// default is `{"LeftButtonUp"}` (`benilla_ui::script::button::wants_click`), so without the -/// explicit registration the input path silently swallowed every right-click on the bar. Driven -/// through the real shipped XML and the real `mouse_button` path, which is the only place the -/// registration set is consulted. +/// `ActionButton_OnLoad` registers left and right clicks (ActionButton.lua:109) over a button's +/// default of left only, and the OnClick reads no `arg1`: a right-click uses the action too. #[test] fn a_right_click_on_an_action_button_uses_the_action() { benilla_formats::wow_data_or_skip!(); @@ -703,7 +624,7 @@ fn a_right_click_on_an_action_button_uses_the_action() { 1, Some(ActionSlot { texture: Some("Interface\\Icons\\INV_Misc_Food_11".into()), - kind: 0x80, // an ITEM action — the food/mount case the report is about + kind: 0x80, // an ITEM action: food action: 4540, count: 5, consumable: false, @@ -712,7 +633,6 @@ fn a_right_click_on_an_action_button_uses_the_action() { s.fire_event("PLAYER_ENTERING_WORLD", vec![]); s.resolve(); - // Button 1's center (geometry as in the tests above): (26, 22). s.mouse_button(26.0, 22.0, "RightButton", true); s.mouse_button(26.0, 22.0, "RightButton", false); assert_eq!( @@ -721,8 +641,7 @@ fn a_right_click_on_an_action_button_uses_the_action() { "right-click queues the same UseAction a left-click does" ); - // The middle button is registered by neither the ref nor us: it stays swallowed, which is what - // proves the assertion above is the REGISTRATION and not the gate having been removed. + // The middle button is not registered, so the click gate is still in force. s.mouse_button(26.0, 22.0, "MiddleButton", true); s.mouse_button(26.0, 22.0, "MiddleButton", false); assert!( @@ -730,7 +649,7 @@ fn a_right_click_on_an_action_button_uses_the_action() { "an unregistered button still reaches nothing" ); - // Shift+right-click picks up, exactly as shift+left does — the OnClick fork is button-blind. + // Shift+right-click picks up like shift+left: the OnClick fork ignores the button. s.set_modifiers(true, false, false); s.mouse_button(26.0, 22.0, "RightButton", true); s.mouse_button(26.0, 22.0, "RightButton", false); @@ -743,8 +662,7 @@ fn a_right_click_on_an_action_button_uses_the_action() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// Decision 0216 §7 (byte-verified 0218 §4) driven through the REAL shipped XML, not the engine -/// unit tests directly — the modifier-key mirror gating `PickupAction` vs `UseAction`, end to end. +/// Shift-click runs `PickupAction`, a plain click `UseAction` (ActionBarFrame.xml:12-22). #[test] fn shift_click_picks_up_not_uses() { benilla_formats::wow_data_or_skip!(); @@ -765,7 +683,6 @@ fn shift_click_picks_up_not_uses() { s.fire_event("PLAYER_ENTERING_WORLD", vec![]); s.resolve(); - // Button 1's center (unchanged geometry from the test above): (26, 22). s.set_modifiers(true, false, false); // IsShiftKeyDown() true s.mouse_button(26.0, 22.0, "LeftButton", true); s.mouse_button(26.0, 22.0, "LeftButton", false); @@ -786,8 +703,7 @@ fn shift_click_picks_up_not_uses() { "picking up queues the clear-the-slot send" ); - // A PLAIN click while holding routes through checkCursor=1 to a place — closes the loop back - // onto the same (now empty) slot: no shift needed once something is already held. + // A plain click while holding places (`UseAction(id, 1)`, ActionBarFrame.xml:19). s.mouse_button(26.0, 22.0, "LeftButton", true); s.mouse_button(26.0, 22.0, "LeftButton", false); assert!(s.take_action_uses().is_empty(), "routed to place, not use"); @@ -797,14 +713,8 @@ fn shift_click_picks_up_not_uses() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// **Lock ActionBars** (decision 1136) — the `LOCK_ACTIONBAR` uvar the Options window's Action Bars -/// row and the `TOGGLEACTIONBARLOCK` binding both write, guarding the two drag ends the way the -/// reference does (ActionBarFrame.xml:23-38). -/// -/// The teeth are the second half: the reference leaves the shift-click pick-up in `OnClick` -/// UNGUARDED (l.12-22), so a locked bar still yields to the deliberate gesture. Guarding it too -/// would be a "sensible" tightening that silently diverges — and would leave a locked bar with no -/// way to rearrange it at all. +/// `LOCK_ACTIONBAR` guards both drag ends (ActionBarFrame.xml:23-38) but not the shift-click +/// pickup in OnClick (l.12-22), so a locked bar still yields to shift-click. #[test] fn the_action_bar_lock_stops_the_drag_and_leaves_shift_click_alone() { benilla_formats::wow_data_or_skip!(); @@ -846,7 +756,6 @@ fn the_action_bar_lock_stops_the_drag_and_leaves_shift_click_alone() { "and nothing is sent to the server" ); - // Shift-click still picks up — the reference's unguarded fork, and the way out of a locked bar. s.set_modifiers(true, false, false); s.mouse_button(26.0, 22.0, "LeftButton", true); s.mouse_button(26.0, 22.0, "LeftButton", false); @@ -856,14 +765,12 @@ fn the_action_bar_lock_stops_the_drag_and_leaves_shift_click_alone() { "shift-click is not what the lock stops" ); - // The receiving end is guarded too: the held action cannot be dropped back by a drag… s.run("this = ActionButton1 ActionButton1:GetScript(\"OnReceiveDrag\")()") .unwrap(); assert!( s.cursor_payload().is_some(), "a locked slot refuses the drop" ); - // …and unlocking makes both ends live again. s.run(r#"LOCK_ACTIONBAR = "0""#).unwrap(); s.run("this = ActionButton1 ActionButton1:GetScript(\"OnReceiveDrag\")()") .unwrap(); @@ -877,9 +784,7 @@ fn the_action_bar_lock_stops_the_drag_and_leaves_shift_click_alone() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// A physical drag from button 1 onto OCCUPIED button 2: the byte-verified action-bar hop (0218 -/// §4) — the displaced action lands on the cursor, TWO independent `action_sets` entries across -/// the one gesture (0218 §4: "a drag-swap is two sends, never atomic"). +/// A drag onto an occupied button puts the displaced action on the cursor, in two sends. #[test] fn drag_drop_onto_another_button_hops_the_displaced_action() { benilla_formats::wow_data_or_skip!(); @@ -910,7 +815,6 @@ fn drag_drop_onto_another_button_hops_the_displaced_action() { s.fire_event("PLAYER_ENTERING_WORLD", vec![]); s.resolve(); - // Button 1 center (26, 22), button 2 center (68, 22) — same geometry as the end-to-end test. s.mouse_button(26.0, 22.0, "LeftButton", true); s.mouse_move(40.0, 22.0); // past the 4px drag-start threshold let consumed = s.mouse_button(68.0, 22.0, "LeftButton", false); @@ -940,19 +844,9 @@ fn drag_drop_onto_another_button_hops_the_displaced_action() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// The Count fontstring, on the reference's own gate (`ActionButton_UpdateCount`, ref -/// ActionButton.lua:285-292): **`IsConsumableAction`**, never "count > 0". The director's report — -/// a mount on the bar wearing a stack number "1" — is the non-consumable case: a mount holds an -/// on-use spell with zero charges and `InventoryType` 0, so `IsConsumableAction 0x4e5250` answers -/// false and the ref paints nothing at all. It repaints on `ACTIONBAR_SLOT_CHANGED` alongside the -/// icon (the same event the identity resolve fires). -/// -/// **The gate rides the SLOT, not the state map** (decision 1301). It used to be pushed through -/// `set_action_state`, and this test set that up *before* the repaint — the opposite of the -/// runtime order, where the identity feed fires `ACTIONBAR_SLOT_CHANGED` a whole system before the -/// state feed writes anything. That inversion is why a passing test sat over a fresh character -/// whose food showed no stack number at all. Every push here is now one `set_action`, which is -/// the only order the runtime can produce. +/// The count shows only for `IsConsumableAction` (ActionButton.lua:285-292), false for a mount's +/// item: zero charges, `InventoryType` 0 (`0x4e5250`). The flag rides the slot, not the state +/// map: `ACTIONBAR_SLOT_CHANGED` repaints before the state feed writes. #[test] fn count_fontstring_follows_is_consumable_action_not_the_bag_count() { benilla_formats::wow_data_or_skip!(); @@ -960,16 +854,14 @@ fn count_fontstring_follows_is_consumable_action_not_the_bag_count() { s.set_screen_size(1024.0, 768.0); load_action_bar(&s); - // Multi-digit counts, deliberately: the static HotKey labels are single characters - // ("1".."9","0","-","="), so a single-digit count could false-positive match an unrelated - // button's hotkey text rather than the Count fontstring actually under test. + // Multi-digit counts: a single digit could match a button's one-character HotKey label. s.set_action( 1, Some(ActionSlot { texture: Some("Interface\\Icons\\Spell_A".into()), kind: 0x00, // SPELL action: 111, - count: 42, // the app never actually sets this for a spell — proves the XML, not the feed + count: 42, // the app never sets this for a spell; the XML must ignore it consumable: false, }), ); @@ -977,14 +869,13 @@ fn count_fontstring_follows_is_consumable_action_not_the_bag_count() { 2, Some(ActionSlot { texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()), - kind: 0x80, // ITEM — a stack of food + kind: 0x80, // ITEM: a stack of food action: 117, count: 15, consumable: true, }), ); - // The report's own shape: an ITEM action the player holds eleven of, which is NOT consumable - // (a mount). The count is fed all the same; the gate is what must suppress it. + // A mount: an ITEM held eleven times that is not consumable. s.set_action( 3, Some(ActionSlot { @@ -998,8 +889,7 @@ fn count_fontstring_follows_is_consumable_action_not_the_bag_count() { s.fire_event("PLAYER_ENTERING_WORLD", vec![]); s.resolve(); - // Read the fontstrings by name, not by scanning painted text: a single-character count - // ("0") is indistinguishable from a neighbouring button's static HotKey label by content. + // Read the fontstrings by name: a one-character count reads like a HotKey label. let count_of = |s: &UiScript, n: u32| { s.eval::(&format!("return ActionButton{n}Count:GetText() or \"\"")) .unwrap() @@ -1019,7 +909,6 @@ fn count_fontstring_follows_is_consumable_action_not_the_bag_count() { "", "B201: a NON-consumable ITEM (a mount) shows no stack number, whatever the count says" ); - // …and it really is painted, not just set (the multi-digit value is unambiguous in the quads). assert!( s.extract() .iter() @@ -1027,9 +916,7 @@ fn count_fontstring_follows_is_consumable_action_not_the_bag_count() { "the consumable's count reaches the screen" ); - // Eat the stack down to nothing. A *consumable* keeps its fontstring and reads a literal "0" - // — the ref's `SetText(GetActionCount(...))` is unconditional inside the gate, and 0216 §7's - // `count > 0` blank was ours, not the reference's. + // A spent consumable reads "0": no count test guards the `SetText` (ActionButton.lua:288). s.set_action( 2, Some(ActionSlot { @@ -1050,11 +937,8 @@ fn count_fontstring_follows_is_consumable_action_not_the_bag_count() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// The macro-name line (ref `ActionButton_Update:236-238`, "Update Macro Text") through the REAL -/// shipped XML: a MACRO slot's button reads its macro's name under the icon, a SPELL slot's reads -/// nothing, and a macro slot that empties loses the name through the same unconditional write. -/// B340 (decision 1636): the template declared `$parentName` and nothing ever set it, so every -/// macro on the bar was nameless. +/// `ActionButton_Update` writes `GetActionText` into the `$parentName` line unconditionally +/// (ActionButton.lua:236-238): a macro slot shows its name, a spell slot or an emptied one none. #[test] fn macro_name_line_follows_get_action_text_through_the_xml() { benilla_formats::wow_data_or_skip!(); @@ -1095,7 +979,7 @@ fn macro_name_line_follows_get_action_text_through_the_xml() { s.fire_event("PLAYER_ENTERING_WORLD", vec![]); s.resolve(); - // Read the fontstrings by name (the count test's reason: painted text is ambiguous). + // Read the fontstrings by name: painted text is ambiguous. let name_of = |s: &UiScript, n: u32| { s.eval::(&format!("return ActionButton{n}Name:GetText() or \"\"")) .unwrap() @@ -1113,7 +997,6 @@ fn macro_name_line_follows_get_action_text_through_the_xml() { "the name reaches the screen" ); - // The slot empties: the ref's write is unconditional, so a nil clears the line. s.set_action(1, None); s.fire_event("ACTIONBAR_SLOT_CHANGED", vec![ScriptValue::Int(1)]); s.resolve(); @@ -1121,20 +1004,8 @@ fn macro_name_line_follows_get_action_text_through_the_xml() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// The **bag BAR** — stock `Interface\FrameXML\MainMenuBarBagButtons.xml` — materialized frame -/// for frame, and then driven end to end: the bar's own backpack toggle opens the backpack -/// window, the fed stack paints in its slot's well, the slot's clicks queue the right intents, -/// and the toggle shuts it again. It lives in this file because the bar seats on -/// `MainMenuBarArtFrame` (`ActionBarFrame.xml`) — the toggle's anchor arithmetic below is the -/// reason. -/// -/// **What decision 1751 changed.** This asserted `report.frames == 259` over a breakdown that -/// counted five bag WINDOWS and a keyring window (37 + 4×42 + 42, plus the bar's own handful). -/// Those windows are gone: the live ones are the reference's `ContainerFrame1..12`, executed off -/// the player's own patch chain, and the bar is the reference's own -/// `MainMenuBarBagButtons.xml` (1783). So the count is recounted from what that file declares, -/// and the drive reaches the reference's window through the bar's own button rather than showing -/// one of ours by name. +/// The stock bag bar (`MainMenuBarBagButtons.xml`), which seats on `MainMenuBarArtFrame`: its +/// toggle opens the backpack, the stack paints in its slot, and the slot's clicks queue intents. #[test] fn shipped_bag_frame_drives_end_to_end() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1143,17 +1014,10 @@ fn shipped_bag_frame_drives_end_to_end() { let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); - // [`BAG_UI`] is `benilla.toc`'s own order for everything a bag window needs. Three files join - // it at the positions the manifest gives them: - // * ActionBar.xml straight after Cooldown.xml — the bag bar is anchored INTO - // MainMenuBarArtFrame, so the bar must exist before the bag bar loads or the toggle's - // cross-file `relativeTo` silently falls back to the screen root (which would land in the - // right place here anyway, by the 1024-wide coincidence: screen BOTTOMRIGHT == the - // full-width bar's art-frame BOTTOMRIGHT — so the failure would be invisible); - // * StackSplit.xml and MerchantFrame.xml after the bags — the reference's - // `ContainerFrameItemButton_OnClick` reads `StackSplitFrame` on both arms and - // `MerchantFrame:IsShown()` on the right one, so a slot click raises without them. That - // dependency is the reference's, not ours. + // `BAG_UI` is `benilla.toc`'s order for a bag window. The bag buttons are parented to + // `MainMenuBarArtFrame`, so the main bar's files follow Cooldown.xml, and + // `ContainerFrameItemButton_OnClick` reads `StackSplitFrame` and `MerchantFrame` + // (ContainerFrame.lua:581, 586), so both load after the bags. let mut bar_frames = 0; for file in BAG_UI { let frames = load_ui(&s, file); @@ -1163,7 +1027,7 @@ fn shipped_bag_frame_drives_end_to_end() { load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml"); load_ui(&s, "Interface\\FrameXML\\Fonts.xml"); load_ui(&s, r"Interface\FrameXML\UIParent.xml"); - load_ui(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon + load_ui(&s, "ScrollTemplates.xml"); // our scroll kits load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua"); load_ui(&s, "Interface\\FrameXML\\MainMenuBar.xml"); load_ui(&s, "Interface\\FrameXML\\ActionBarFrame.xml"); @@ -1222,7 +1086,6 @@ fn shipped_bag_frame_drives_end_to_end() { s.fire_event("BAG_UPDATE", vec![ScriptValue::Int(0)]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); - // Nothing open at load: no jerky on screen. s.resolve(); let jerky_visible = |quads: &[benilla_ui::script::ExtractedQuad]| { quads.iter().any(|q| { @@ -1232,12 +1095,9 @@ fn shipped_bag_frame_drives_end_to_end() { }; assert!(!jerky_visible(&s.extract()), "no bag window at load"); - // Click the toggle → the backpack window opens and slot 1 paints the jerky. The toggle seats - // on the bar's art frame BOTTOMRIGHT +(-6,2), 37×37: art frame BOTTOMRIGHT is the bar's - // (full-width, bottom-anchored) corner (1024,0) ⇒ toggle x[981,1018] y[2,39], center - // (999.5,20.5). That arithmetic is THIS file's — the button is `MainMenuBarBagButtons.xml`'s - // and its seat is `ActionBarFrame.xml`'s — so the click stays at literal coordinates rather - // than going through `centre_of`: hitting them is part of what is being tested. + // The toggle seats at the art frame's BOTTOMRIGHT + (-6, 2), 37x37 + // (MainMenuBarBagButtons.xml:54-64), and that corner is the bar's (1024, 0): x[981,1018] + // y[2,39]. The click stays at literal coordinates because hitting them is under test. s.mouse_button(999.0, 20.0, "LeftButton", true); s.mouse_button(999.0, 20.0, "LeftButton", false); s.resolve(); @@ -1252,18 +1112,14 @@ fn shipped_bag_frame_drives_end_to_end() { }) .and_then(|q| q.rect) .expect("slot 1 icon visible after toggle"); - // WHERE inside the window that well sits is the reference's arithmetic, not ours - // (`ContainerFrame_GenerateFrame` + `updateContainerFrameAnchors`), so it is asked of the - // button rather than pinned to numbers this tree no longer owns — and asked by GetID, since - // the reference numbers its buttons backwards (`…Item1` is the bag's LAST slot). The property - // is what it always was: the fed stack paints in the well that says it is game slot 1. + // Ask the slot's button by GetID: the buttons are numbered backwards, `…Item1` is the bag's + // last slot (ContainerFrame.lua:425-428). let button = bag_slot_button(&s, 0, 1); let (bx, by) = centre_of(&mut s, &button); assert!( icon.left <= bx && bx <= icon.right && icon.bottom <= by && by <= icon.top, "the jerky icon {icon:?} is not painted on slot 1's button ({button} at {bx},{by})" ); - // The stack count renders as text. assert!( quads .iter() @@ -1271,8 +1127,7 @@ fn shipped_bag_frame_drives_end_to_end() { "stack count shows" ); - // LEFT-click picks the item up onto the cursor — a local drag, no wire use queued until a - // place (ref ContainerFrameItemButton_OnClick's left arm: PickupContainerItem). + // Left-click picks the item up (`PickupContainerItem`, ContainerFrame.lua:580). s.mouse_button(bx, by, "LeftButton", true); s.mouse_button(bx, by, "LeftButton", false); assert!( @@ -1288,14 +1143,8 @@ fn shipped_bag_frame_drives_end_to_end() { "a pickup alone queues no move" ); - // RIGHT-click while holding. **This assertion inverted with 1751, and the inversion is the - // point of recording it.** Our own `BenillaBagSlot_OnClick` had a cursor-cancel arm — a - // benilla divergence — so this used to assert that the pickup was cancelled and nothing sent. - // The reference's right arm has no cursor test at all: it falls through to - // `UseContainerItem(bag, slot)` like any other right-click, and the cursor keeps its payload. - // Pinned as what it now IS rather than dropped, so the swap is visible here. Whether the host - // should treat a use-while-holding as a place is a fidelity question for the bag arc, not - // this file's to answer. + // Right-click while holding: the right arm has no cursor test (ContainerFrame.lua:583-599). + // What the reference client does with a use while the cursor holds an item is untraced. s.mouse_button(bx, by, "RightButton", true); s.mouse_button(bx, by, "RightButton", false); assert_eq!( @@ -1309,12 +1158,10 @@ fn shipped_bag_frame_drives_end_to_end() { ); s.run("ClearCursor()").unwrap(); - // RIGHT-click with an empty cursor uses slot 1 (UseContainerItem → the app's use/equip fork). s.mouse_button(bx, by, "RightButton", true); s.mouse_button(bx, by, "RightButton", false); assert_eq!(s.take_container_uses(), vec![(0, 1)]); - // Toggle again → shut. s.mouse_button(999.0, 20.0, "LeftButton", true); s.mouse_button(999.0, 20.0, "LeftButton", false); s.resolve(); @@ -1323,12 +1170,8 @@ fn shipped_bag_frame_drives_end_to_end() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// The empty-wells regression (director-reported, 2026-07-10): the bar-level event fan ran -/// UpdateUsable on EMPTY buttons, whose `IsUsableAction` answers (nil, nil) — the 0.4 grey -/// `SetVertexColor` landed on the texture-less icon region and drew a solid grey plate over every -/// empty well. The fix is the ref's own HasAction gate on the fan (ActionButton.lua registers the -/// state handlers only while the button has an action). This drives the exact failing sequence — -/// a usable/cooldown event with empties on the bar — and asserts no empty well gains a solid. +/// `ActionButton_Update` registers the state events only while the button has an action +/// (ActionButton.lua:175-212), so an empty well's texture-less icon never takes a tint. #[test] fn state_events_leave_empty_wells_untinted() { benilla_formats::wow_data_or_skip!(); @@ -1357,15 +1200,13 @@ fn state_events_leave_empty_wells_untinted() { }), ); s.fire_event("PLAYER_ENTERING_WORLD", vec![]); - // The failing edges: the fanned usable/cooldown/state events with empties on the bar. s.fire_event("ACTIONBAR_UPDATE_USABLE", vec![]); s.fire_event("ACTIONBAR_UPDATE_COOLDOWN", vec![]); s.fire_event("ACTIONBAR_UPDATE_STATE", vec![]); s.fire_event("PLAYER_TARGET_CHANGED", vec![]); s.resolve(); - // No texture-less colored quad anywhere on the button row (the icon regions of empty wells - // must stay path-None + color-None); the occupied icon still carries its OOM blue. + // No well-sized texture-less coloured quad; the occupied icon keeps its out-of-power blue. let mut oom_icon = None; for q in s.extract() { match &q.content { @@ -1393,13 +1234,8 @@ fn state_events_leave_empty_wells_untinted() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// The white-buttons regression (director-reported 2026-08-07, decision 1108): a slot that was -/// OCCUPIED — UpdateUsable painted its icon's 1/1/1 usable tint — then goes EMPTY (the feed's -/// character-switch diff: `set_action(None)` + `ACTIONBAR_SLOT_CHANGED`) kept the tint on the -/// now-artless icon region and drew it as a solid WHITE square. Two laws close it, both asserted -/// here: the empty arm HIDES the icon (ref ActionButton.lua l.168), and the engine emits nothing -/// for a texture-less region whatever its surviving tint (`0x7706e0` — the draw gate is `+0xcc`, -/// never the colour). +/// A slot going empty draws no plate: the empty arm hides the icon (ActionButton.lua:168), and a +/// texture-less region never draws, whatever its tint (`0x7706e0` gates on `+0xcc`). #[test] fn an_occupied_slot_going_empty_leaves_no_white_plate() { benilla_formats::wow_data_or_skip!(); @@ -1426,7 +1262,7 @@ fn an_occupied_slot_going_empty_leaves_no_white_plate() { }), ); s.fire_event("PLAYER_ENTERING_WORLD", vec![]); - // The usable pass paints the occupied icon's 1/1/1 usable tint — the tint that survives. + // The usable pass tints the occupied icon 1/1/1, a tint that outlives the emptying. s.fire_event("ACTIONBAR_UPDATE_USABLE", vec![]); s.resolve(); assert!( @@ -1452,7 +1288,7 @@ fn an_occupied_slot_going_empty_leaves_no_white_plate() { color: Some(c), .. } if q.rect.is_some_and(|r| r.right - r.left <= 40.0) => { - // Well-sized only, as in the sibling test: page-wide solids are legitimate. + // Well-sized only: page-wide solids are legitimate. panic!("the emptied slot draws its surviving tint as a solid plate: {c:?}") } _ => {} @@ -1461,18 +1297,8 @@ fn an_occupied_slot_going_empty_leaves_no_white_plate() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// **The bonus action bar exists and stays hidden** — the same posture 1219 gave the vertical -/// multibars, and the largest single session-start row in the corpus. -/// -/// `ref-BonusActionBarFrame.xml` l.54 instantiates `BonusActionBarFrame` as a real -/// `parent="MainMenuBar"` frame carrying `hidden="true"`, with `BonusActionButton1..12` inside. -/// benilla models the bonus page by re-paging the MAIN bar, so we never show this one — but four -/// addons died at `CT_BarMod\CT_BarModOptions.lua:154`, -/// `getglobal("BonusActionButton" .. i):ClearAllPoints()`, which is pure layout and needs only -/// that the buttons be there. -/// -/// The last assertion is the one that keeps it honest: declaring a hidden bar must not change what -/// the visible bar shows. +/// `BonusActionBarFrame` is declared hidden (BonusActionBarFrame.xml:54); addons lay out its +/// buttons before it ever shows (`CT_BarModOptions.lua:154`). #[test] fn the_bonus_action_bar_exists_hidden_and_takes_layout_calls() { benilla_formats::wow_data_or_skip!(); @@ -1505,9 +1331,8 @@ fn the_bonus_action_bar_exists_hidden_and_takes_layout_calls() { .unwrap(); } - // A hidden bar changes nothing about the visible one. (An EMPTY main-bar button is itself - // hidden under the reference — `ActionButton_Update` hides a slot with no action while - // `showgrid == 0`, ActionButton.lua:69-70 — so slot 1 is occupied first.) + // The hidden bar leaves the main bar alone. An empty main-bar button hides while + // `showgrid == 0` (ActionButton.lua:214-215), so slot 1 is occupied first. s.set_action( 1, Some(ActionSlot { @@ -1526,16 +1351,7 @@ fn the_bonus_action_bar_exists_hidden_and_takes_layout_calls() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// **The reference's action-bar constants are real globals, not comments.** -/// -/// `ActionButton.lua:1-9` defines them; this file and `MultiBars.xml` cited them in comments and -/// defined none. An addon reading one got nil — `zBar.lua:40` is the shape, -/// `to = to or value.max or NUM_ACTIONBAR_BUTTONS` feeding a numeric `for`, which raises -/// `'for' limit must be a number`. Only the use-probe could find it: nothing else touches anything. -/// -/// `CURRENT_ACTIONBAR_PAGE` is asserted ABSENT on purpose. It is the reference's mutable page -/// cursor and benilla does not page the main bar that way; a frozen 1 would be silently wrong -/// forever, where nil fails loudly. Pinned so a later "completeness" pass cannot quietly add it. +/// ActionButton.lua:1-9's constants are globals (`zBar.lua:40` loops to `NUM_ACTIONBAR_BUTTONS`). #[test] fn the_reference_action_bar_constants_are_defined() { benilla_formats::wow_data_or_skip!(); @@ -1557,7 +1373,7 @@ fn the_reference_action_bar_constants_are_defined() { ); } - // zBar's exact expression, which raised before these existed. + // zBar's expression. assert_eq!( s.eval::("local to = nil or nil or NUM_ACTIONBAR_BUTTONS local n = 0 for i = 1, to do n = n + 1 end return n") .unwrap(), @@ -1565,10 +1381,7 @@ fn the_reference_action_bar_constants_are_defined() { "zBar.lua:40's numeric for must have a limit" ); - // `CURRENT_ACTIONBAR_PAGE` was asserted ABSENT here, on the grounds that a frozen 1 lies where - // nil fails loudly. That objection is discharged, not overruled: the bar pages now, so the - // global is live state the paged-id formula reads rather than a frozen number. It is therefore - // asserted as state — present, and MOVING — instead of as one of the constants above. + // `CURRENT_ACTIONBAR_PAGE` is live state that `ActionButton_GetPagedID` reads, so it must move. assert_eq!(s.eval::("return CURRENT_ACTIONBAR_PAGE").unwrap(), 1); s.run("ActionBar_PageUp()").unwrap(); assert_eq!( @@ -1579,19 +1392,8 @@ fn the_reference_action_bar_constants_are_defined() { s.run("ActionBar_PageDown()").unwrap(); } -/// **The reference's two-level action-button split, both halves inheritable by name.** -/// -/// `ActionButtonTemplate` (ref ActionButtonTemplate.xml:3) is regions only and carries NO scripts; -/// `ActionBarButtonTemplate` (ref ActionBarFrame.xml:4) inherits it and adds the handlers. Ours -/// conflated them under one `Benilla*` name, so an addon inheriting either reference name got a -/// bare frame — no art, no regions, and no error (1203's silent shape). -/// -/// `zBar.xml:7` is the corpus shape: it inherits `ActionBarButtonTemplate`, wires its own OnLoad, -/// and then reads `getglobal(button:GetName().."NormalTexture")` — a derived name it only has -/// because the template declares `$parentNormalTexture`. That read is where it died. -/// -/// The last assertion is the one that keeps our own bars safe: the alias must still resolve to the -/// full thing, or 48 `inherits=` sites across four files silently lose their handlers. +/// `ActionButtonTemplate` is regions only (ActionButtonTemplate.xml:3); `ActionBarButtonTemplate` +/// adds the handlers (ActionBarFrame.xml:4), and `zBar.xml:7` inherits it. #[test] fn both_reference_action_button_templates_are_inheritable() { benilla_formats::wow_data_or_skip!(); @@ -1641,8 +1443,7 @@ fn both_reference_action_button_templates_are_inheritable() { ); } - // The base half carries NO handlers, exactly as the reference's does — an addon inheriting it - // wires its own, and must not silently receive ours. + // The base half carries no handlers: an addon inheriting it wires its own. assert!( !s.eval::("return BareLikeButton:GetScript(\"OnClick\") ~= nil") .unwrap(), @@ -1654,7 +1455,7 @@ fn both_reference_action_button_templates_are_inheritable() { "ActionBarButtonTemplate carries the handler set" ); - // ...and our own alias still resolves to the full thing. + // ...and the stock bar's own buttons carry both. assert!( s.eval::("return ActionButton1NormalTexture ~= nil and ActionButton1:GetScript(\"OnClick\") ~= nil") .unwrap(), @@ -1662,28 +1463,15 @@ fn both_reference_action_button_templates_are_inheritable() { ); } -/// Main-bar paging — `CURRENT_ACTIONBAR_PAGE` and the three verbs around it. -/// -/// The data was always there (the app owns all 120 action slots); only the selector was missing, -/// and its absence was visible on screen as page arrows with no `OnClick`. `Bartender2.lua:686` -/// died on the nil `ChangeActionBarPage` at session start. -/// -/// Two things are asserted that a reconstruction would get wrong. **A bonus page outranks the -/// paged one** — the reference's own `ActionButton_GetPagedID` takes the bonus branch first, so -/// paging must be the `else` arm and not an addition. And **page-up wraps to the literal page 1** -/// while page-down rescans for the last viewable page: that asymmetry is the reference's, and it -/// is observable the moment a page is blanked from `VIEWABLE_ACTION_BAR_PAGES`. -/// -/// Since 1500 the blanking is driven here the way the client drives it — by raising the two bottom -/// multibars — rather than read off a declaration. All six pages are viewable at rest now, because -/// every extra bar ships off and nothing has claimed a page yet. +/// Main-bar paging (ActionButton.lua:63-101): page-up past the last viewable page wraps to 1, +/// page-down below 1 rescans for the last viewable one. #[test] fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() { benilla_formats::wow_data_or_skip!(); let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); for file in [ - // Fonts first: the pane's check-box labels colour from `RED_FONT_COLOR` in their own OnLoad. + // Fonts first: the reputation pane's check boxes read `RED_FONT_COLOR` in their OnLoad. "Interface\\FrameXML\\Fonts.xml", r"Interface\FrameXML\UIParent.xml", "Interface\\FrameXML\\Cooldown.xml", @@ -1697,10 +1485,7 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() { "Interface\\FrameXML\\GameTooltip.xml", "Interface\\FrameXML\\ActionBarFrame.xml", "Interface\\FrameXML\\BonusActionBarFrame.xml", - // The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and - // `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own - // coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the - // two template files its check boxes inherit through (1875). + // `ExhaustionTick_Update` reads `ReputationWatchBar`, which ReputationFrame.xml declares. r"Interface\FrameXML\UIPanelTemplates.lua", r"Interface\FrameXML\UIPanelTemplates.xml", r"Interface\FrameXML\OptionsFrameTemplates.xml", @@ -1715,15 +1500,12 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() { "Interface\\FrameXML\\StaticPopup.xml", "KeyBindingsPage.xml", // `UIOptionsFrame_Init`'s uvars and `UIOptionsFrameCheckButtons`, which - // `MultiActionBars.xml` below writes into at its load — the reference's own l.21 seat, - // ahead of our window and ahead of the bars (2115). + // `MultiActionBarFrame_OnLoad` writes (MultiActionBars.lua:8-22), so ahead of the bars. r"Interface\FrameXML\OptionsFrame.lua", r"Interface\FrameXML\UIOptionsFrame.xml", "OptionsFrame.xml", "Interface\\FrameXML\\MultiActionBars.xml", ] { - // `test_ui::load_ui`, not a disk read: this list names chain entries now (the reputation - // pane and the templates it inherits through), and `assets/ui` cannot answer for those. super::test_ui::load_ui(&s, file); } @@ -1743,18 +1525,15 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() { .unwrap(), "all six pages are viewable at rest — every extra bar ships off (1500)" ); - // The NUMERAL is the only output of paging the player can actually see on the bar itself, and - // it went unwritten for as long as paging existed: the arrows worked, the twelve buttons - // repainted, and the "1" beside them was a declared literal nothing ever touched — so clicking - // them read as doing nothing at all (director, 2026-08-22). It seeds at the page's own value. + // The page numeral: `ActionBarUpButton` writes it at load and on `ACTIONBAR_PAGE_CHANGED` + // (ActionBarFrame.xml:175, 180). let page_text = |s: &UiScript| { s.eval::("return MainMenuBarPageNumber:GetText()") .unwrap() }; assert_eq!(page_text(&s), "1", "the load seed is the current page"); - // Raise the two bottom bars the way the client does, which is what takes pages 6 and 5 out of - // the cycle from here on. + // Raising both bottom bars takes pages 5 and 6 out of the cycle (MultiActionBars.lua:53, 61). s.run("SHOW_MULTI_ACTIONBAR_1 = 1 SHOW_MULTI_ACTIONBAR_2 = 1 MultiActionBar_Update()") .unwrap(); @@ -1773,7 +1552,7 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() { 24 ); - // Down again, and below page 1 it rescans to the LAST viewable page. + // Down again; below page 1 it rescans for the last viewable page. s.run("ActionBar_PageDown()").unwrap(); assert_eq!(s.eval::("return CURRENT_ACTIONBAR_PAGE").unwrap(), 1); s.run("ActionBar_PageDown()").unwrap(); @@ -1793,9 +1572,7 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() { "4", "the numeral follows a wrap, not just a step" ); - // The pages the bottom bars already display are unreachable from the main bar — which is - // exactly what MultiActionBar_Update did above. Without it, paging up lands on a duplicate of - // the twelve actions already on screen below. + // The bottom bars' pages are unreachable from the main bar. assert!(s .eval::( "return VIEWABLE_ACTION_BAR_PAGES[5] == nil and VIEWABLE_ACTION_BAR_PAGES[6] == nil" @@ -1810,10 +1587,8 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() { reference's own asymmetry with page-down, which rescans instead" ); - // The bonus branch is GUARDED by the page (ActionButton.lua:447: `button.isBonus and - // CURRENT_ACTIONBAR_PAGE == 1`) — and a MAIN-bar button has no isBonus at all, so it never - // takes that branch: the bonus offset is BonusActionBarFrame's own twelve buttons' business - // (1897, adopted with 1938). Main-bar slot 1 on page 3 is action 25 whatever the offset. + // The bonus branch needs `isBonus` and page 1 (ActionButton.lua:447); a main-bar button has + // no `isBonus`, so slot 1 on page 3 is action 25 whatever the offset. s.run("CURRENT_ACTIONBAR_PAGE = 3").unwrap(); s.set_bonus_bar_offset(1); assert_eq!( @@ -1845,14 +1620,8 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() { ); } -/// The form/stance/stealth swap transition (decision 1524; ref BonusActionBarFrame.lua:1-98). -/// Entering a form slides the BonusActionBarFrame replica up over 0.15s — the main bar keeps -/// painting the OLD page underneath until the landing, which is also the one moment the sound -/// (igBonusBarOpen) plays. The overlay then STAYS shown while the form holds (the ref-visible -/// state addons read), a direct form→form swap repaints without re-sliding or re-sounding, and -/// dropping the form slides it back down carrying the old form's page (lastBonusBar), silently. -/// Keys route to the overlay's buttons from the FIRST slide frame — the swap's feel half: a key -/// pressed the instant you enter stealth already drives the stealth page. +/// The form swap (BonusActionBarFrame.lua:1-98): the bonus frame slides up and sounds on landing, +/// stays while the form holds, and slides down silently with the old page. #[test] fn bonus_bar_slides_up_with_sound_and_down_without() { benilla_formats::wow_data_or_skip!(); @@ -1872,12 +1641,7 @@ fn bonus_bar_slides_up_with_sound_and_down_without() { super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionBarFrame.xml"); super::test_ui::load_ui(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml"); - // `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code, - // safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since - // 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too - // or the tick raises on its first event. - // In manifest order: the fonts its check-box labels colour from, the panel templates those - // boxes inherit through, then the pane. + // `ExhaustionTick_Update` reads `ReputationWatchBar`, which ReputationFrame.xml declares. super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml"); super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); @@ -1892,7 +1656,7 @@ fn bonus_bar_slides_up_with_sound_and_down_without() { "no form, no overlay" ); - // ── Enter cat form: offset 0→1, the app's edge (ui_action/feed.rs) fires the event ───────── + // ── Enter a form: offset 0 to 1, fired on the app's offset edge ── s.set_bonus_bar_offset(1); s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); @@ -1917,7 +1681,8 @@ fn bonus_bar_slides_up_with_sound_and_down_without() { ); assert!(s.take_sounds().is_empty(), "no sound until the bar lands"); - // Keys route to the overlay immediately (ref ActionButton.lua:15-45's IsShown fork). + // Keys route to the overlay at once: `ActionButtonDown/Up` fork on + // `BonusActionBarFrame:IsShown()` (ActionButton.lua:16, 31). s.run("ActionButtonDown(1) ActionButtonUp(1)").unwrap(); assert_eq!( action_ids(&mut s), @@ -1925,9 +1690,8 @@ fn bonus_bar_slides_up_with_sound_and_down_without() { "a key pressed mid-slide already drives the bonus page" ); - // …and the same button with the SELF-CAST modifier. `SELFACTIONBUTTON1`-`12` (`ALT-1`…`ALT-=`) - // are `ActionButtonUp(id, 1)` and nothing else, so this is the whole of what those twelve - // bindings do that the plain twelve do not (1745). + // …and the self-cast bindings, which differ only in `ActionButtonUp(n, 1)` + // (Bindings.xml:205-211). s.run("ActionButtonDown(1) ActionButtonUp(1, 1)").unwrap(); assert_eq!( s.take_action_uses() @@ -1951,11 +1715,8 @@ fn bonus_bar_slides_up_with_sound_and_down_without() { ); s.run("BonusActionBarFrame:Show()").unwrap(); - // The slide, frame by frame. Stock `BonusActionBar_OnUpdate` paints the position the timer - // has REACHED and then advances it (BonusActionBarFrame.lua:37-49), so the first OnUpdate - // after a Show paints the start (top = 0 over the bar's bottom edge at y=0), the second the - // half-risen replica (0.5 * 43), and the one that finds the timer past BONUSACTIONBAR_SLIDETIME - // lands it. Silent until then. + // `BonusActionBar_OnUpdate` paints, then advances (BonusActionBarFrame.lua:37-52): top 0, then + // 0.5 * 43, then the landing. s.tick(0.075); let top = s .eval::("return BonusActionBarFrame:GetTop()") @@ -1977,9 +1738,8 @@ fn bonus_bar_slides_up_with_sound_and_down_without() { ); assert!(s.take_sounds().is_empty()); - // The landing edge: snap to 43 and THE sound. The main bar does NOT adopt the bonus page: - // a main-bar button has no `isBonus`, so `ActionButton_GetPagedID` keeps it on its page and the - // overlay IS the stance page (ActionButton.lua:447-456; 1897, adopted with 1938). + // Landing snaps to 43 and plays the sound (BonusActionBarFrame.lua:61-64); the main bar keeps + // its page, having no `isBonus` (ActionButton.lua:447). s.tick(0.01); assert_eq!( s.take_sounds(), @@ -2009,7 +1769,7 @@ fn bonus_bar_slides_up_with_sound_and_down_without() { s.tick(0.5); assert!(s.take_sounds().is_empty(), "a landed bar never re-sounds"); - // ── A direct form→form swap (stance dance, powershift): repaint, no slide, no sound ──────── + // ── A form to form swap: repaint, no slide, no sound ── s.set_bonus_bar_offset(3); s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]); assert_eq!( @@ -2033,7 +1793,7 @@ fn bonus_bar_slides_up_with_sound_and_down_without() { "form→form never re-slides or re-sounds" ); - // ── Drop the form: the overlay descends carrying the OLD form's page, silently ───────────── + // ── Drop the form: the overlay descends with the old page, silently ── s.set_bonus_bar_offset(0); s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]); assert_eq!( @@ -2052,13 +1812,12 @@ fn bonus_bar_slides_up_with_sound_and_down_without() { 97, "the descending overlay carries the OLD form's page (lastBonusBar)" ); - // A key mid-descent still drives the old form's page — ref GetPagedID's lastBonusBar - // stand-in while the frame is still shown. + // A key mid-descent drives the old page: `ActionButton_GetPagedID` falls back to + // `lastBonusBar` (ActionButton.lua:449-451). s.run("ActionButtonDown(1) ActionButtonUp(1)").unwrap(); assert_eq!(action_ids(&mut s), vec![97]); - // Paint-then-advance again: the frame that finds the timer past the slide time is the one - // that hides (BonusActionBarFrame.lua:51-66), so the descent takes one OnUpdate more than - // the time itself. + // The frame that finds the timer past the slide time hides it (BonusActionBarFrame.lua:53-68), + // one OnUpdate past the time. s.tick(0.2); s.tick(0.01); assert!( @@ -2073,9 +1832,8 @@ fn bonus_bar_slides_up_with_sound_and_down_without() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// A form flip mid-slide turns the bar around from where it is (our progress fraction), rather -/// than the ref's timer arithmetic, which mirror-jumps the position — the mechanism, not the -/// quirk (1524). +/// A form dropped mid-rise: `HideBonusActionBar` keeps the running timer +/// (BonusActionBarFrame.lua:90-92), and the hide arm paints `(1 - timer / 0.15) * 43` (l.48). #[test] fn bonus_bar_turnaround_continues_from_position() { benilla_formats::wow_data_or_skip!(); @@ -2095,12 +1853,7 @@ fn bonus_bar_turnaround_continues_from_position() { super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionBarFrame.xml"); super::test_ui::load_ui(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml"); - // `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code, - // safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since - // 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too - // or the tick raises on its first event. - // In manifest order: the fonts its check-box labels colour from, the panel templates those - // boxes inherit through, then the pane. + // `ExhaustionTick_Update` reads `ReputationWatchBar`, which ReputationFrame.xml declares. super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml"); super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); @@ -2109,7 +1862,7 @@ fn bonus_bar_turnaround_continues_from_position() { s.tick(10.0); let _ = s.take_sounds(); - // Up to half height, then the form drops mid-slide. + // The form drops with the rise's timer at half. s.set_bonus_bar_offset(1); s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]); s.tick(0.075); @@ -2119,11 +1872,8 @@ fn bonus_bar_turnaround_continues_from_position() { s.eval::("return BonusActionBarFrame.mode").unwrap(), "hide" ); - // A third of the way back down from the turnaround point: 43 * (0.5 - 0.03/0.15). - // `HideBonusActionBar` keeps a running timer (it resets it only when `completed`, - // BonusActionBarFrame.lua:86-88), so the descent starts where the rise had got to: the rise's - // one OnUpdate advanced the timer to 0.075 (half), and the hide arm paints (1 − 0.075/0.15) · 43 - // = 21.5 on its first frame, then 12.9 on the next — paint, then advance. + // The rise's one OnUpdate left the timer at 0.075, so the hide arm paints + // (1 - 0.075/0.15) * 43 = 21.5, then 12.9 a frame later: paint, then advance. s.tick(0.03); let top = s .eval::("return BonusActionBarFrame:GetTop()") @@ -2152,13 +1902,9 @@ fn bonus_bar_turnaround_continues_from_position() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// **The two page arrows must not sit on top of each other.** -/// -/// They are 32x32 squares stacked only 20 px apart, so their raw frame rects overlap by 12 px — -/// and the hit-test walks the draw order in reverse, so the later-declared DOWN button owned that -/// band: the bottom third of the visible UP arrow paged the bar the wrong way. The reference's -/// `` (±6 horizontal, ±7 vertical) shrink each square to the 20x18 arrow it -/// actually draws, which separates them — the insets are behaviour here, not decoration. +/// The page arrows are 32x32 squares 20 px apart, overlapping by 12 px; their `` +/// (6 each side, 7 top and bottom, ActionBarFrame.xml:170-172) shrink each hit rect to 20x18, +/// which separates them. #[test] fn the_page_arrows_do_not_steal_each_other_s_clicks() { benilla_formats::wow_data_or_skip!(); @@ -2178,22 +1924,15 @@ fn the_page_arrows_do_not_steal_each_other_s_clicks() { super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionBarFrame.xml"); super::test_ui::load_ui(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml"); - // `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code, - // safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since - // 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too - // or the tick raises on its first event. - // In manifest order: the fonts its check-box labels colour from, the panel templates those - // boxes inherit through, then the pane. + // `ExhaustionTick_Update` reads `ReputationWatchBar`, which ReputationFrame.xml declares. super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml"); super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.lua"); super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); super::test_ui::load_ui(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml"); super::test_ui::load_ui(&s, r"Interface\FrameXML\ReputationFrame.xml"); - // The post-login state, which is what a player clicks into. Without it `ExhaustionTick_Update` - // never runs, and the rested marker — DIALOG strata, declared CENTER on the XP strip, which is - // exactly where the arrows are — sits unhidden over both and eats every click. That is the - // reference's own declaration (`hidden="false"`, ref-MainMenuBar.xml l.415), hidden at runtime - // when `GetXPExhaustion()` is nil, so firing the event is the harness's job, not a fix. + // The rested marker is declared shown in DIALOG strata over the arrows (MainMenuBar.xml:415); + // `ExhaustionTick_Update` hides it on PLAYER_ENTERING_WORLD when `GetXPExhaustion()` is nil + // (MainMenuBar.lua:29-31), or it eats every click. s.fire_event("PLAYER_ENTERING_WORLD", vec![]); s.resolve(); @@ -2213,13 +1952,8 @@ fn the_page_arrows_do_not_steal_each_other_s_clicks() { ); } - // The contested band, measured rather than assumed: the two 32x32 squares are 20 px apart, so - // raw they share the 12 px above the UP button's bottom edge. 8 px up from that edge is inside - // the up arrow's own drawn art AND inside the down button's raw square — the exact pixel the - // player aims at and the exact pixel the later-declared down button used to win. - // - // With the ref's ±7 vertical insets the two hit rects become disjoint (up keeps its top 18 px, - // down its own), so this point resolves UP, which is what the arrow under the cursor says. + // 8 px above the up button's bottom edge is inside the down button's raw square but only the + // up button's hit rect. let (x, up_bottom) = s .eval::<(f64, f64)>( "return (ActionBarUpButton:GetLeft() + ActionBarUpButton:GetRight()) / 2, \ diff --git a/crates/benilla-app/src/ui_script/addons.rs b/crates/benilla-app/src/ui_script/addons.rs index 00e80e14..318952d4 100644 --- a/crates/benilla-app/src/ui_script/addons.rs +++ b/crates/benilla-app/src/ui_script/addons.rs @@ -1,50 +1,8 @@ -//! **Where interfaces come from** — the addon folder, and the walk that loads what is in it -//! (decision 1184, building 1178 step 2). -//! -//! 1178 step 1 made benilla's own interface load from a `.toc`. This is the other half of the -//! claim: the same parser, the same loader, and the same walk now run over folders that are not -//! ours. An [`Addon`] is a name, a parsed manifest, and a [`Source`] its files come from — and -//! benilla's own interface is simply the one whose source is the compiled-in tree. -//! -//! ## Where we look — ONE folder -//! -//! **`/AddOns//.toc`**, and nothing else (decision 1185). 1184 also -//! searched the WoW install's own `Interface/AddOns/` and merged the two; the director's call is -//! one root, changeable in future *by choice* but never both at once. Resolved through -//! [`crate::local_state`], the only module allowed to compute that path (0954), which is already -//! `None` under `$WOW_CAPTURE` — so a capture discovers nothing and a deterministic baseline -//! cannot depend on what somebody has installed (0008). -//! -//! What the install's folder actually holds, checked rather than assumed: twelve `Blizzard_*` -//! folders containing **only a `.pub` signature file** — their real code is inside the MPQs, which -//! we do not read for addons at all — plus whatever the machine's owner has put there. Nothing was -//! being gained by searching it. -//! -//! ## Two kinds of file in a manifest -//! -//! A `.toc` lists `.lua` **and** `.xml`, and they load differently: Lua is executed as a chunk in -//! the shared global state, FrameXML is parsed and materialized. See [`Addon::load_files`]. -//! -//! ## The load order is the reference's, not a topological sort -//! -//! `AddOn_Load 0x51f240` is **recursive**: for each addon, in order — -//! **OptionalDeps first (failures ignored)** → **RequiredDeps (a failure ABORTS this addon's -//! load)** → **its own `.toc`-listed files, in listed order**. We match that shape, because a -//! pre-sorted flat list cannot express "a missing hard dependency drops exactly this addon and -//! nothing else". -//! -//! **Our own interface is not an addon for lifecycle purposes.** In the reference, -//! *FrameXML does not go through `AddOn_Load`, so it gets no `ADDON_LOADED`* — and `benilla.toc` -//! is excluded exactly as FrameXML is. Structurally, not by a filter: it loads through -//! [`super::manifest::load_ingame_ui`], while [`Walk`] only ever walks what [`discover`] found. -//! It shares the loader; it does not share the event. -//! -//! ## Per-addon `Bindings.xml` -//! -//! Loaded right after the `.toc` files and before the saved variables (`0x51f400`) — 1188 phase 4, -//! in [`Walk::load`]. Parsed by [`benilla_ui::bindings_xml`] and registered into the same -//! key-binding table the Key Bindings window edits, so an addon's binding is a row like any -//! other; the app's dispatch runs its Lua body ([`crate::bindings`]). +//! Where interfaces come from: addon discovery under [`root`] and the load walk ([`Walk::load`]). +//! An [`Addon`] is a name, a parsed `.toc` and the [`Source`] its files come from. benilla's own +//! interface is one too, with the compiled-in tree as its source, but not for the lifecycle: like +//! FrameXML it loads outside `AddOn_Load` ([`super::manifest::load_ingame_ui`]) and gets no +//! `ADDON_LOADED`. use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -56,43 +14,28 @@ use benilla_ui::toc::Toc; use super::content; -/// The folder name under each root, and the reference's own spelling. +/// The addon folder's name, the reference's own spelling. const ADDONS_DIR: &str = "AddOns"; -/// The world-entry load's VM instruction bound, per addon (decision 1306) — and the addon -/// harness's survey bound, one number for both so the measurement stays shared. MEASURED, not -/// guessed (e463649e): across the 218-addon corpus, 214 addons execute under 1M VM instructions -/// and the heaviest legitimate one (Enchantrix) reaches 4M — this is ~50x that. An addon that -/// crosses it is not slow, it is not coming back; the raise fails that addon with a distinctive -/// message and the walk loads everyone else, where before 1306 the client sat frozen on the -/// loading screen with zero diagnostics (B271's class). +/// The VM instruction bound for one addon's load, shared with the addon harness: about 50x the +/// corpus's heaviest (4M), so an addon that crosses it is in a runaway loop and fails alone. pub(crate) const LOAD_INSTRUCTION_BUDGET: u64 = 200_000_000; /// Where one interface's files come from. -/// -/// The three arms are the same question asked of three stores, and keeping them one enum is what -/// lets [`Addon::load`] be written once: our own interface, a third-party folder and the player's -/// own install differ in where the bytes live, not in how they load. pub(super) enum Source { - /// benilla's own interface — the compiled-in tree, which a dev build shadows with `assets/ui` - /// on disk so editing FrameXML costs no recompile ([`content`], 1175). + /// benilla's own interface: the compiled-in tree, shadowed by `assets/ui` in a dev build. Builtin, - /// **The AddOns root**, not this addon's own folder (decision 1186). Paths handed to - /// [`Addon::read`] are relative to it, so `Bagnon/src/main.xml` and the - /// `BagBrother/core/core.xml` it includes are both expressible — which is the point, since a - /// shared library addon exists to be reached from its dependents. + /// The AddOns root on disk, not this addon's own folder: a request under `Interface/AddOns/` + /// maps onto it, so a dependent can reach a shared library addon beside it. Dir(PathBuf), - /// **The player's own installed patch chain** — the reference FrameXML this client sources - /// rather than shipping a copy of (decision 1751, [`super::reference_ui`]). Paths are full - /// chain-internal paths (`Interface\FrameXML\ContainerFrame.xml`), so the prefix is empty and - /// the chain resolves `/` and `\` alike, case-insensitively. + /// The player's patch chain ([`super::reference_ui`]), by full chain path, `/` and `\` alike. Chain, } /// One loadable interface: a name, its parsed manifest, and where its files come from. pub(super) struct Addon { - /// The addon's folder name — and, for the builtin, `"benilla"`. This is what - /// `GetAddOnInfo`/`IsAddOnLoaded` will key on (1188 phase 2) and what `ADDON_LOADED` carries. + /// The folder name (`"benilla"` for the builtin): what the AddOn API keys on and what + /// `ADDON_LOADED` carries. pub(super) name: String, /// The parsed `.toc`: the ordered file list plus every directive. pub(super) toc: Toc, @@ -100,14 +43,12 @@ pub(super) struct Addon { } impl Addon { - /// An interface from an explicit source — [`super::reference_ui::addon`]'s constructor, and - /// the only one that is not derived from a `.toc` on some store. + /// An interface from an explicit source, for [`super::reference_ui::addon`]. pub(super) fn new(name: String, toc: Toc, source: Source) -> Self { Addon { name, toc, source } } - /// benilla's own interface. Always present — it is in the binary (1175), so unlike a - /// discovered addon it cannot be missing. + /// benilla's own interface, which is in the binary and so cannot be missing. pub(super) fn builtin() -> Self { let toc = content::read(super::manifest::MANIFEST) .map(|t| Toc::parse(&t)) @@ -125,28 +66,10 @@ impl Addon { } } - /// The text of one file, by a path already resolved into the **source's** path space — the - /// AddOns root for a `Dir`, the flat shipped tree for the builtin. - /// - /// The caller does the relative-path arithmetic ([`benilla_ui::loader::join_ref`]); this only - /// reads. That split is decision 1186's: only the loader knows the include tree, and only the - /// source knows what a resolved path is allowed to reach. - /// - /// **The sandbox is the AddOns folder, not one addon's subfolder.** `..` that walks above the - /// root survives `join_ref` as a leading `..`, and [`read_under`] refuses it on the filesystem - /// leg — so an addon can reach a sibling library addon (which is how `Bagnon` reaches - /// `BagBrother`, and what 1184's per-addon guard wrongly blocked) but cannot reach the machine. - /// A `Dir` miss then falls through to the player's patch chain under the install's own name - /// for it ([`read_addon_file`], decision 2155), which is the single namespace the reference - /// opener has — and the reason `Auctioneer`'s reach into `Blizzard_AuctionUI` resolves at all. - /// - /// The **builtin alone** keeps a basename fallback: its tree is flat, and a transcription that - /// writes a Blizzard-style directory path should still find the file. A `Dir` source must not - /// have it — a basename fallback silently rescues exactly the escaping path the guard just - /// refused. - /// Returns **bytes**, not text (decision 1193). A `.lua` chunk is handed to Lua as it sits on - /// disk, and only an XML/`.toc` parse decodes — because a `read_to_string` here did not make a - /// cp1252 locale file lose a glyph, it made the file *not exist*. + /// One file's bytes, by a path already resolved into the source's path space. A `Dir` reads + /// under the AddOns root, then the chain ([`read_addon_file`]); only the flat builtin falls + /// back to the basename, which for a `Dir` would rescue an escaping path. Bytes, not text: a + /// `.lua` reaches Lua as it is on disk. fn read(&self, req: &str) -> Option> { match &self.source { Source::Builtin => { @@ -161,20 +84,8 @@ impl Addon { } } - /// This addon's own folder in the install's path space — the `base` its manifest entries are - /// relative to. `""` for the builtin's flat tree, `""` for the chain (whose manifest entries - /// are already full internal paths), and `Interface/AddOns/` for a `Dir`. - /// - /// **`Interface/AddOns/`, not the bare folder name** (decision 2155). It used to be the folder - /// alone, which made the addon's path space the AddOns root rather than the install — a second - /// namespace the reference does not have. The visible cost was `", ) .unwrap(); - // The sibling library addon, reached across the AddOns root — and it includes a file of - // its OWN by bare name, so the base has to follow the include down a level rather than - // staying on the includer's. + // The library includes a file of its own by bare name, so the base follows the include. std::fs::write( tmp.join("ProbeLib/core/lib.xml"), "", @@ -1943,13 +1420,6 @@ mod tests { let _ = std::fs::remove_dir_all(&tmp); } - /// An addon named in two different cases by two dependents loads **once**. - /// - /// Dependency lookup is case-insensitive (a `.toc` may spell a dependency however it likes), - /// so the loaded/failed sets have to be keyed on the addon's own spelling. Keyed on the - /// caller's, each differently-cased dependent misses the "already loaded" check and runs every - /// one of the shared addon's files again — re-registering its templates and re-materializing - /// its frames on top of themselves. #[test] fn a_dependency_named_in_another_case_loads_once() { let tmp = std::env::temp_dir().join(format!( @@ -1958,7 +1428,7 @@ mod tests { )); let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(&tmp).unwrap(); - // Shared.xml increments a counter, so "loaded twice" is observable rather than inferred. + // `Bump.xml` counts its own runs, so a second load is observable. let shared = tmp.join("Shared"); std::fs::create_dir_all(&shared).unwrap(); std::fs::write(shared.join("Shared.toc"), "## Interface: 11200\nBump.xml\n").unwrap(); @@ -2015,11 +1485,7 @@ mod tests { let _ = std::fs::remove_dir_all(&tmp); } - /// There is exactly ONE addon root and it is ours (decision 1185) — and a capture has none, - /// so a baseline cannot depend on what is installed on the machine that runs it (0008). - /// - /// The capture arm is `local_state::home()`'s, already hermetic under `$WOW_CAPTURE`, which is - /// why passing `None` here IS the capture case rather than a stand-in for it. + /// `local_state::home()` is `None` under `$WOW_CAPTURE`, so `None` here is the capture case. #[test] fn there_is_one_addon_root_and_it_is_ours() { assert_eq!( @@ -2029,10 +1495,6 @@ mod tests { assert_eq!(root_from(None), None, "a capture run has no addon root"); } - /// A manifest's `.lua` entries run as chunks; everything else is FrameXML. - /// - /// The classifier, not the load — `\` is a path separator in a `.toc` written for Windows, and - /// the extension compare is case-insensitive for the same reason discovery's is. #[test] fn lua_entries_are_told_apart_from_framexml() { assert!(is_lua("Core.lua")); @@ -2044,9 +1506,7 @@ mod tests { assert!(!is_lua("weird.lua.xml")); } - /// An addon folder with no matching `.toc` is not an addon, and one whose `.toc` differs only - /// in case still is — the reference is a case-insensitive filesystem and a real addon shipped - /// as `MyAddon/myaddon.toc` has to load on Linux too. + /// A folder with no `.toc` is not an addon; one whose `.toc` differs only in case is. #[test] fn discovery_matches_the_manifest_case_insensitively() { let tmp = @@ -2061,11 +1521,10 @@ mod tests { let _ = std::fs::remove_dir_all(&tmp); } - // ───────────────────────────── lifecycle events (1188 phase 1) ───────────────────────────── + // ───────────────────────────── lifecycle events ───────────────────────────── - /// A frame that appends `":"` to a global for every event it is registered for — - /// the witness the lifecycle tests read. Written as an addon file rather than injected into - /// the VM, so the events travel the same `` path a real addon's would. + /// A frame that appends `":"` to `EventLog` for each event it registers, loaded as + /// an addon file so the events travel a real addon's `` path. fn recorder_xml(events: &[&str]) -> String { let registers: String = events .iter() @@ -2083,20 +1542,14 @@ mod tests { ) } - /// Read the witness back as a `Vec`. fn event_log(script: &UiScript) -> Vec { script .eval::>("return EventLog") .expect("EventLog") } - /// **`ADDON_LOADED` reaches a third-party addon, carries its own name, and fires after its - /// files** — 1188 phase 1's acceptance test, and the half of 1178's falsifier that said - /// *"today it loads and reads; it does not receive"*. - /// - /// The `arg1` half is not cosmetic: every addon in the ecosystem opens with - /// `if arg1 == "MyAddon" then` and does nothing at all otherwise, so a wrong or missing arg1 - /// is indistinguishable from the event never arriving. + /// Addons guard their handler with `if arg1 == "MyAddon" then`, so a wrong `arg1` looks like + /// the event never arriving. #[test] fn addon_loaded_carries_the_addons_own_name_and_fires_after_its_files() { let tmp = @@ -2109,8 +1562,7 @@ mod tests { "## Interface: 11200\nprobe.lua\nprobe.xml\n", ) .unwrap(); - // File-scope Lua, which the reference runs BEFORE the event. Recording the marker here and - // asserting it below is what proves the ordering rather than merely the delivery. + // File-scope Lua runs before the event; its marker proves the order. std::fs::write( dir.join("probe.lua"), "EventLog = {}\ntable.insert(EventLog, \"files-ran\")\n", @@ -2137,16 +1589,8 @@ mod tests { ); } - /// **`benilla` never appears in an `ADDON_LOADED`.** - /// - /// In the reference, FrameXML does not go through `AddOn_Load` and gets no such event, and - /// our own interface is FrameXML's counterpart. An addon that watches - /// `ADDON_LOADED` to detect *another* addon would otherwise see a name no reference client - /// ever sends. - /// - /// The guard is structural — the builtin loads through [`super::manifest`] and never enters - /// [`Walk`] — so this asserts the structure holds rather than that a filter fires: it loads - /// the builtin's own manifest entries the way production does, alongside a real walk. + /// FrameXML gets no `ADDON_LOADED` in the reference, and our interface loads like it, through + /// [`super::manifest`] and never the walk; this loads both ways and reads the log. #[test] fn the_builtin_interface_never_fires_addon_loaded() { let tmp = std::env::temp_dir().join(format!( @@ -2174,7 +1618,7 @@ mod tests { }]; let mut w = Walk::default(); let _ = w.load(&mut script, &all, "EventProbe"); - // The builtin, loaded the way production loads it — through its own manifest, not the walk. + // The builtin, loaded as production loads it: its own manifest, not the walk. let builtin = Addon::builtin(); let _ = builtin.load_files(&script, builtin.toc.files.get(..1).unwrap_or_default()); @@ -2190,16 +1634,9 @@ mod tests { ); } - /// **The three UI-init events fire in the reference's order:** every addon's `ADDON_LOADED`, - /// then `VARIABLES_LOADED`, then `PLAYER_LOGIN`. - /// - /// Byte-verified straight-line order inside `UI_Init 0x48fbf0` — `0x4900a3` loads the addons - /// (each `0x51f5ad`), `0x4900b2` fires `VARIABLES_LOADED`, `0x490168` enters the cascade that - /// fires `PLAYER_LOGIN`. This drives the production functions in the production order rather - /// than re-stating it: [`super::load_third_party`] then [`super::super::finish_ui_load`]. - /// - /// It is the ordering an addon depends on — restore state on `ADDON_LOADED`, and by - /// `PLAYER_LOGIN` everything saved is in place. + /// `UI_Init 0x48fbf0` loads the addons at `0x4900a3` (each `ADDON_LOADED` at `0x51f5ad`) and + /// fires `VARIABLES_LOADED` at `0x4900b2`; `PLAYER_LOGIN` comes after, on a fresh login from + /// the player's create (`0x5deb60` → `0x4908c0`), on a `/reload` from `0x490168`. #[test] fn the_ui_init_events_fire_in_the_reference_order() { let _l = crate::local_state::test_env::ENV_LOCK @@ -2222,8 +1659,7 @@ mod tests { ) .unwrap(); - // Hermetic: point the whole state folder at the tempdir, so discovery finds exactly this - // addon and the saved-variables read cannot touch the machine's real file. + // Hermetic: the state folder is the tempdir, so discovery finds only this addon. let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let _h = crate::local_state::test_env::EnvGuard::set("BENILLA_HOME", home.to_str().unwrap()); @@ -2248,15 +1684,11 @@ mod tests { let _ = std::fs::remove_dir_all(&tmp); } - // ────────────────────────── the AddOn API + enable state (1188 phase 2) ────────────────────── + // ────────────────────────── the AddOn API and enable state ────────────────────────── - /// Write a whole AddOns root under a temp `benilla-config`, and point `BENILLA_HOME` at it. - /// Returns the guards, which must be held for the duration of the test. + /// An AddOns root under a temp `benilla-config`, `BENILLA_HOME` on it while the guard lives. fn hermetic_root(tag: &str) -> (PathBuf, crate::local_state::test_env::EnvGuard) { - // **The pid is load-bearing**, not decoration: two `benilla_app` test binaries can run at - // once (a concurrent session, or `--all-targets`), and a fixed path plus the - // `remove_dir_all` below means each wipes the other's tree mid-test. `ui_saved.rs` keys - // its temp the same way for the same reason. + // The pid keeps two test binaries running at once from wiping each other's tree. let tmp = std::env::temp_dir().join(format!("benilla-addon-api-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); @@ -2267,11 +1699,8 @@ mod tests { (home, guard) } - /// **The version gate holds the startup walk, and force-load opens it** (decision 1292): - /// the byte-verified `AddOn_CanLoad` check 6 — exact `==`, missing `## Interface` = 0 = out - /// of date, a dependent of a gated addon blocked like a dependent of a disabled one — and - /// the `checkAddonVersion` flag (the *Load out of date AddOns* checkbox inverted) loading - /// the very same folder in full. + /// `AddOn_CanLoad` check 6: exact `==`, a missing `## Interface` is 0 and out of date, and a + /// gated addon's dependent is blocked; `checkAddonVersion` off loads the same folder in full. #[test] fn the_version_gate_holds_the_walk_and_force_load_opens_it() { let _l = crate::local_state::test_env::ENV_LOCK @@ -2293,7 +1722,7 @@ mod tests { write_addon( &home, "Silent", - "main.lua\n", // no ## Interface at all — parses as 0, out of date (not "unknown") + "main.lua\n", // no ## Interface: parses as 0, out of date &[("main.lua", "SilentRan = true\n")], ); write_addon( @@ -2326,7 +1755,7 @@ mod tests { "…and so is their dependent (DEP_INTERFACE_VERSION territory)" ); - // Force-load: the same folder, the checkbox's other state — everything loads. + // Force-load: the same folder, with the checkbox's other state. let mut open = UiScript::new().unwrap(); let failures = load_third_party(&mut open, None, &[], false); assert!(failures.is_empty(), "force-load load errors: {failures:?}"); @@ -2349,7 +1778,7 @@ mod tests { } } - /// [`write_addon`] with **raw bytes**, because the files this crate must survive are not text. + /// [`write_addon`] with raw bytes, for files that are not UTF-8. fn write_addon_bytes(home: &Path, name: &str, toc: &[u8], files: &[(&str, &[u8])]) { let dir = home.join("AddOns").join(name); std::fs::create_dir_all(&dir).unwrap(); @@ -2359,39 +1788,23 @@ mod tests { } } - /// **An addon whose files are not valid UTF-8 loads anyway** (decision 1193) — the corpus's - /// single largest blocker, and one nobody would have guessed. - /// - /// Three separate failures, all of which used to be silent-ish and all of which are here: - /// - /// 1. a **cp1252 `.toc`** made the addon *invisible to discovery* — `manifest_in` read it with - /// `read_to_string`, got `None`, and a folder with no readable manifest is not an addon. 5 - /// of a real 218-addon corpus vanished this way, and the harness scored them as clean - /// passes because an unparsed manifest lists no files to fail on. - /// 2. a **BOM'd `.lua`** reached the lexer with `EF BB BF` in front and died on - /// `unexpected symbol`. 160 corpus files carry one. - /// 3. a **cp1252 `.lua`** read as absent, so the loader reported "not found" for a file that - /// was right there. `AceAddon-2.0.lua` is one, embedded in ~30 addons. - /// - /// The literal survives as **bytes**, not as decoded text: Lua 5.0 strings are byte strings - /// and the reference hands `luaL_loadbuffer` the file unmodified, so `string.len` on a cp1252 - /// literal must answer what it answers there. That assertion is the one that would fail if - /// somebody "helpfully" transcoded chunks to UTF-8 later. + /// A cp1252 `.toc` is still a manifest, a BOM'd `.lua` runs, and a cp1252 `.lua` is found. Lua + /// 5.0 strings are bytes and the reference hands `luaL_loadbuffer` the file unmodified, so a + /// cp1252 literal keeps its byte length. #[test] fn an_addon_whose_files_are_not_utf8_still_loads() { let _l = crate::local_state::test_env::ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let (home, _guard) = hermetic_root("encoding"); - // `## Notes:` holds a cp1252 a-umlaut (0xE4) — one byte, and the whole addon disappeared. + // `## Title:` holds a cp1252 a-umlaut (0xE4). let mut toc = b"## Interface: 11200\n## Title: Sch\xE4tze\nlocale.lua\nboot.lua\n".to_vec(); - toc.splice(0..0, [0xEFu8, 0xBB, 0xBF]); // ...and a BOM on the manifest too (14 in corpus). + toc.splice(0..0, [0xEFu8, 0xBB, 0xBF]); // and a BOM on the manifest too write_addon_bytes( &home, "Umlaut", &toc, &[ - // A cp1252 locale file: the German/French half of the vanilla ecosystem. ("locale.lua", &b"UmlautWord = \"Sch\xE4tze\"\n"[..]), // A BOM'd chunk: valid UTF-8, three bytes the lexer cannot start on. ( @@ -2405,11 +1818,8 @@ mod tests { let failures = load_third_party(&mut script, None, &[], true); assert!(failures.is_empty(), "load errors: {failures:?}"); - // Discovery saw it at all — the `.toc` decoded rather than read as absent. - // **One**, not one-plus-the-chain (decision 2175). The registry holds the chain's twelve - // Blizzard rows too, but the Lua index space is a different set: `SMSG_ADDON_INFO` answers - // `status = 2` for every secure addon and the array rebuild drops them, which is why the - // reference's own AddOns list shows the player's addons and none of Blizzard's. + // Discovery found it. One, not thirteen: `SMSG_ADDON_INFO` answers status 2 for the + // chain's twelve secure addons, and the Lua index space drops them. seat_stock_addon_reply(&mut script); assert_eq!(script.eval::("return GetNumAddOns()").ok(), Some(1)); assert_eq!( @@ -2419,13 +1829,12 @@ mod tests { Some("Sch\u{e4}tze".to_string()), "the manifest's cp1252 title decoded to the glyph its author typed, not to nothing" ); - // Both chunks ran. assert_eq!( script.eval::("return UmlautLoaded == true").ok(), Some(true), "a BOM'd .lua ran — the three-byte mark was stripped, not handed to the lexer" ); - // ...and the cp1252 literal is still BYTES, which is the reference's semantics. + // The cp1252 literal is still bytes, as in the reference. assert_eq!( script.eval::("return UmlautLen").ok(), Some(7), @@ -2434,12 +1843,7 @@ mod tests { ); } - /// The AddOns screen's write **merges** rather than replaces (decision 1197). - /// - /// A name in the file that is not installed right now belongs to an addon that will be again. - /// Rewriting the file from the installed list alone forgets the player's choice on every - /// uninstall — the same rule `parse_enable_state` already documents for the read side, and the - /// one that has to hold on the write side or the read's carefulness is wasted. + /// A row for an addon not installed now survives the AddOns screen's write. #[test] fn the_addons_screen_write_merges_with_what_is_already_on_disk() { let _l = crate::local_state::test_env::ENV_LOCK @@ -2469,14 +1873,8 @@ mod tests { let _ = home; } - /// What [`crate::ui_script::lifecycle::load_ingame_ui_on_world_entry`] seats before the load - /// (decision 2175): the `SMSG_ADDON_INFO` reply, hiding the secure addons the way a real - /// server's does. - /// - /// A test that registers a registry and then asks an *index* question needs this, because the - /// Lua index space does not exist until the server answers — the reference's own behaviour, - /// not a fixture convenience. The hidden set is the same list production pairs the reply - /// against, so a test and a live login agree by construction. + /// Seat the `SMSG_ADDON_INFO` reply hiding the secure addons, as world entry does: the Lua + /// index space does not exist until the server answers, as in the reference. fn seat_stock_addon_reply(script: &mut UiScript) { let hidden: Vec = benilla_protocol::messages::STOCK_SECURE_ADDONS .iter() @@ -2485,29 +1883,8 @@ mod tests { script.note_addon_info_reply(&hidden); } - /// **`GetAddOnInfo` returns the manifest's own `## Title` and `## Notes`** — 1188 phase 2's - /// first acceptance test, and the reason the registry carries directives rather than a name. - /// - /// The return shape is the **in-game** one — `name, title, notes, enabled, loadable, reason, - /// security`, SEVEN values — because that is the VM this runs in. - /// - /// **The client registers `GetAddOnInfo` twice, as two different functions**, and this test - /// used to assert the wrong one. It was written off `Interface\GlueXML\AddonList.lua`, which - /// describes glue's `0x46d460`: eight values, `url` at slot 4, `newVersion` at 8. The in-game - /// binding `0x48e390` answers seven, with **`enabled`** at slot 4 and no `url` at all. - /// - /// Its own doc comment named the failure it then failed to catch — *"a row that shifts by one - /// puts an addon's notes in its URL field, which nothing would notice"*. The row was shifted at - /// slot 4 the whole time, and the test asserted the shift. That is what a falsifier copied from - /// the wrong reference buys: it pins the bug. - /// - /// What it cost: `local name, _, _, enabled, loadable = GetAddOnInfo(major)` is AceLibrary's - /// and AceAddon's shape, reached by 70 corpus folders. They read `url` as `enabled`, and since - /// `## URL` is rare that is nil for nearly every addon, so `if enabled and loadable` refused to - /// load the dependency. Silent — a nil where a flag belongs is legal Lua. - /// - /// The whole tuple is still asserted rather than the fields the phase names, for the original - /// and still-correct reason. + /// The in-game `GetAddOnInfo` (`0x48e390`) answers seven values, `name, title, notes, enabled, + /// loadable, reason, security`; glue's (`0x46d460`) answers eight, with `url` at slot 4. #[test] fn get_addon_info_returns_the_manifests_own_title_and_notes() { let _l = crate::local_state::test_env::ENV_LOCK @@ -2524,10 +1901,7 @@ mod tests { let mut script = UiScript::new().unwrap(); let _ = load_third_party(&mut script, None, &[], true); - // **One**, not one-plus-the-chain (decision 2175). The registry holds the chain's twelve - // Blizzard rows too, but the Lua index space is a different set: `SMSG_ADDON_INFO` answers - // `status = 2` for every secure addon and the array rebuild drops them, which is why the - // reference's own AddOns list shows the player's addons and none of Blizzard's. + // One, not thirteen: the chain's twelve secure addons are hidden by the reply. seat_stock_addon_reply(&mut script); assert_eq!(script.eval::("return GetNumAddOns()").ok(), Some(1)); assert_eq!( @@ -2542,21 +1916,16 @@ mod tests { "Probe".into(), "Probe Title".into(), "What it does".into(), - "1".into(), // enabled — slot 4 in-game, NOT url (which glue's binding returns) - "1".into(), // loadable - "nil".into(), // no reason — it loads + "1".into(), // enabled: slot 4 in-game, where glue's binding has url + "1".into(), // loadable + "nil".into(), // no reason: it loads "INSECURE".into(), - // The in-game binding returns SEVEN values, so there is no eighth. Asserted as - // absent rather than trimmed off the query: glue's `newVersion` sat here, and a - // future edit that re-adds an eighth return has to face this line. + // No eighth value in-game (glue's `newVersion`). "nil".into(), ]) ); - // AceLibrary's and AceAddon's literal shape, which is how 70 corpus folders reach this - // verb: `local name, _, _, enabled, loadable = GetAddOnInfo(major)`. Asserted as the - // GUARD they actually write, not as a tuple, because the guard is what silently failed — - // with `url` in slot 4 and no `## URL` in the manifest, `enabled` was nil and Ace declined - // to load its own dependency without erroring. + // AceLibrary's guard, `local name, _, _, enabled, loadable = GetAddOnInfo(major)`: with + // `url` in slot 4, `enabled` would be nil and Ace would silently skip its dependency. assert_eq!( script .eval::( @@ -2593,12 +1962,7 @@ mod tests { let _ = std::fs::remove_dir_all(home.parent().unwrap()); } - /// **Disabling an addon in the enable-state file stops it loading** — phase 2's second - /// acceptance test. - /// - /// The file is the reference's own `AddOns.txt` format, `: enabled|disabled` per line, - /// confirmed against a real 1.12 install. It also checks the direction that matters more in - /// practice: an addon the file does not mention loads, so dropping a folder in just works. + /// An addon the enable file does not mention loads, so a dropped-in folder just works. #[test] fn a_disabled_addon_does_not_load_and_an_unlisted_one_does() { let _l = crate::local_state::test_env::ENV_LOCK @@ -2618,7 +1982,7 @@ mod tests { "## Interface: 11200\nran.lua\n", &[("ran.lua", "OnRan = true")], ); - // "On" is deliberately absent from the file. + // "On" is absent from the file. std::fs::create_dir_all(home.join("addons")).unwrap(); std::fs::write(home.join("addons/Realm-Char.txt"), "Off: disabled\n").unwrap(); @@ -2640,7 +2004,6 @@ mod tests { Some(true), "an addon the file never mentions is enabled — a dropped-in folder just works" ); - // And the API agrees with the loader about what happened. assert_eq!( script .eval::>( @@ -2653,12 +2016,7 @@ mod tests { let _ = std::fs::remove_dir_all(home.parent().unwrap()); } - /// **A LoadOnDemand addon loads on `LoadAddOn` and not before** — phase 2's third acceptance - /// test, and the one that needs the loader to run from inside a Lua binding. - /// - /// `LoadAddOn` is called *from Lua*, synchronously, and its side effects are asserted on the - /// next line — exactly how `UIParentLoadAddOn` uses it. A deferred implementation would fail - /// here, which is the point. + /// `LoadAddOn` is synchronous: `UIParentLoadAddOn` uses the addon's frames on the next line. #[test] fn a_load_on_demand_addon_loads_only_when_asked() { let _l = crate::local_state::test_env::ENV_LOCK @@ -2684,11 +2042,7 @@ mod tests { script.set_screen_size(1024.0, 768.0); let _ = load_third_party(&mut script, None, &[], true); - // Discovered and described, but NOT run. - // **One**, not one-plus-the-chain (decision 2175). The registry holds the chain's twelve - // Blizzard rows too, but the Lua index space is a different set: `SMSG_ADDON_INFO` answers - // `status = 2` for every secure addon and the array rebuild drops them, which is why the - // reference's own AddOns list shows the player's addons and none of Blizzard's. + // Discovered and described, not run; one row, the chain's twelve hidden by the reply. seat_stock_addon_reply(&mut script); assert_eq!(script.eval::("return GetNumAddOns()").ok(), Some(1)); assert_eq!( @@ -2709,7 +2063,6 @@ mod tests { Some(true) ); - // ...then loads synchronously, from Lua, and is usable on the very next statement. assert_eq!( script .eval::>( @@ -2745,18 +2098,8 @@ mod tests { let _ = std::fs::remove_dir_all(home.parent().unwrap()); } - /// **The `/msbt` shape** (decision 2102): a LoadOnDemand addon whose `## Dependencies:` names - /// an addon the STARTUP walk already loaded. - /// - /// This is the whole demand-load path a player actually meets — Mik's Scrolling Battle Text - /// ships its options as a separate `## LoadOnDemand: 1` package with - /// `## Dependencies: MikScrollingBattleText`, and `/msbt` reaches it through - /// `UIParentLoadAddOn`, whose failure message is the red - /// `Couldn't load %s: ` dialog. The dependency being **already loaded** is the - /// half nothing covered: a dependency the gate finds unloaded and not LoadOnDemand is - /// `DEP_NOT_DEMAND_LOADED`, so the startup walk's `mark_addon_loaded` stamp is what stands - /// between this and a dialog. (The addon harness answered `DEP_NOT_DEMAND_LOADED` here for - /// exactly that reason until 2102 gave it the same stamp.) + /// A LoadOnDemand options package on a dependency the startup walk loaded (`/msbt`'s shape): + /// without the walk's `mark_addon_loaded` stamp the gate answers `DEP_NOT_DEMAND_LOADED`. #[test] fn a_load_on_demand_addon_loads_on_top_of_an_already_loaded_dependency() { let _l = crate::local_state::test_env::ENV_LOCK @@ -2780,7 +2123,6 @@ mod tests { script.set_screen_size(1024.0, 768.0); let _ = load_third_party(&mut script, None, &[], true); - // The dependency loaded at startup and the registry knows it — the stamp this rests on. assert_eq!( script.eval::("return IsAddOnLoaded('Base') == 1").ok(), Some(true), @@ -2806,15 +2148,8 @@ mod tests { let _ = std::fs::remove_dir_all(home.parent().unwrap()); } - /// **A `.toc` line naming a file the package does not ship does not stop a demand load** - /// (decision 2107) — 1450's rule, applied by `LoadAddOn` and not only by the startup walk. - /// - /// The reference logs `Couldn't open %s` and carries on - /// (`0x6edaa0`). Ours sent the miss to the **script-error** channel - /// instead, and the corpus paid for it the moment `LoadAddOn` became reachable at scale: - /// FuBar's `LoadLoadOnDemandPlugins` demand-loads 55 plugins whose `.toc`s each list an - /// `AmmoFuLocale-koKR.lua` none of them ships, and every one of them scored a session - /// failure for a file the real client shrugs at. 150 → 89 on the vanilla corpus. + /// A demand load treats a `.toc` line naming a missing file as the startup walk does: the + /// reference logs `Couldn't open %s` and carries on (`0x6edaa0`). #[test] fn a_missing_manifest_file_does_not_fail_a_demand_load() { let _l = crate::local_state::test_env::ENV_LOCK @@ -2827,7 +2162,7 @@ mod tests { "Holey", "## Interface: 11200\n## Title: Holey\n## LoadOnDemand: 1\n\ Locale-koKR.lua\nreal.lua\n", - // `Locale-koKR.lua` is listed and NOT written — the FuBar plugin shape exactly. + // `Locale-koKR.lua` is listed and not written. &[("real.lua", "HoleyRan = true")], ); let mut script = UiScript::new().unwrap(); @@ -2845,9 +2180,7 @@ mod tests { Some(vec!["1".into(), "nil".into(), "true".into(), "1".into()]), "the addon loads, its remaining files run, and it reads as loaded" ); - // The miss is a WARNING on the host channel, never a script error — the harness's session - // column and `smoke.sh`'s zero-ERROR count both read the error channel, and 1450's whole - // point is that a player's broken package cannot redden either. + // A warning, never a script error: the addon harness and `smoke.sh` both count errors. assert!( script.take_errors().is_empty(), "a missing manifest entry must not reach the script-error channel" @@ -2859,7 +2192,6 @@ mod tests { .any(|w| w.contains("Holey/Locale-koKR.lua") && w.contains("not found")), "…but it must still be said out loud: {warnings:?}" ); - // …and retained where the player can read it (1495), as the walk's misses are. assert!( script .diagnostics() @@ -2870,11 +2202,8 @@ mod tests { let _ = std::fs::remove_dir_all(home.parent().unwrap()); } - /// The reason tokens are the reference's own, for the cases a caller branches on. - /// - /// They are not free-form strings: the reference splices them into - /// `getglobal("ADDON_"..reason)` to find a localized label, so a token we invent renders as a - /// nil global in somebody's addon manager. + /// The reason tokens are the reference's own: `UIParent.lua:642` looks up + /// `getglobal("ADDON_"..reason)` for the label, so an invented token renders as nil. #[test] fn load_addon_answers_with_the_references_reason_tokens() { let _l = crate::local_state::test_env::ENV_LOCK @@ -2899,9 +2228,7 @@ mod tests { for (call, want) in [ ("LoadAddOn('NoSuchAddon')", "MISSING"), ("LoadAddOn('Off')", "DISABLED"), - // Loaded at startup because it is not LoadOnDemand, so this is the success case; the - // NOT_DEMAND_LOADED arm needs an addon that is neither loaded nor LoadOnDemand, which - // cannot happen once it is enabled — asserted through the disabled one above instead. + // Loaded at startup, as it is not LoadOnDemand: the success case. ("LoadAddOn('Plain')", "nil"), ] { assert_eq!( @@ -2916,19 +2243,8 @@ mod tests { let _ = std::fs::remove_dir_all(home.parent().unwrap()); } - /// **A character with no enable file does not load what the realm has unanimously turned - /// off** — the world-entry half of the director's 2026-09-17 report (decision 2311). - /// - /// The glue list showing a new character's addons as off would be worth nothing if the world - /// then loaded them anyway, so the load walk resolves through the same [`EnableStore`]: the - /// reference's `AddOn_CanLoad` check 3 is `0x51e470(name, character, useDefault = 1)`, which - /// for a character with no explicit entry takes the explicit-only aggregate over the realm's - /// other characters. - /// - /// The control is the second addon: one character left it on, so the roster does **not** agree - /// about it, and the newcomer gets its manifest's `## DefaultState` instead — enabled. Without - /// that half the test would pass on a client that simply disabled everything for new - /// characters. + /// `AddOn_CanLoad` check 3 is `0x51e470(name, character, useDefault = 1)`. `Contested` is the + /// control: the roster disagrees about it, so the newcomer gets its `## DefaultState`. #[test] fn a_character_with_no_file_inherits_the_rosters_unanimous_disable() { let _l = crate::local_state::test_env::ENV_LOCK @@ -2948,8 +2264,7 @@ mod tests { "## Interface: 11200\nran.lua\n", &[("ran.lua", "ContestedRan = true")], ); - // Two characters who have played and saved: both turned `Shunned` off, they disagree - // about `Contested`. + // Two characters with files: both turned `Shunned` off, they disagree about `Contested`. for (who, contested) in [("Onemage", false), ("Onerogue", true)] { let id = ("Realm".to_string(), who.to_string()); write_enable_state( @@ -2963,7 +2278,6 @@ mod tests { "Freshling".to_string(), ]; - // …and the character just created, who has no file of their own. let fresh = ("Realm".to_string(), "Freshling".to_string()); assert!( !enable_state_path(Some(&fresh)).unwrap().exists(), @@ -2985,20 +2299,8 @@ mod tests { let _ = std::fs::remove_dir_all(home.parent().unwrap()); } - /// **A name the character list does not carry inherits nothing** — the third arm of - /// `0x51e470(addon, character, useDefault = 1)`, and the one 2311 got wrong (decision 2316). - /// - /// 2311 read "no explicit row" and "no node" as one case and sent both to the aggregate. The - /// reference splits them: the walk compares the name at every node and advances past each - /// mismatch (`0x51e55a jne 0x51e611`, bypassing the stop-check), so an unknown name exhausts - /// the list with `total == 0` and takes the `DefaultState ? 2 : 0` epilogue. Only a character - /// that *has* a node inherits. - /// - /// Unreachable from our own callers — every panel column is a node, and `store_nodes` seats - /// the loading character — so this is the falsifier standing in for the caller that does not - /// exist yet. `Known`, who has a node and no row, is the control that keeps the two arms - /// honestly distinguishable: both addons are unanimous, so an implementation that collapsed - /// the cases would answer `false` for `Stranger` too. + /// A name on no node passes every node (`0x51e55a jne 0x51e611`) and takes the + /// `DefaultState ? 2 : 0` epilogue; `Known`, with a node and no row, is the control. #[test] fn an_unknown_character_takes_the_manifest_default_not_the_aggregate() { let _l = crate::local_state::test_env::ENV_LOCK @@ -3006,8 +2308,7 @@ mod tests { .unwrap_or_else(std::sync::PoisonError::into_inner); let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); let (home, _h) = hermetic_root("unknown-char"); - // One unanimous `disabled`, and one addon whose manifest ships `DefaultState: disabled` - // so the epilogue's two outcomes are told apart rather than both reading `true`. + // `OptIn` ships `DefaultState: disabled`, so the epilogue's two outcomes differ. write_addon(&home, "Shunned", "## Interface: 11200\n", &[]); write_addon( &home, @@ -3033,18 +2334,15 @@ mod tests { assert!(!store.enabled_for("Shunned", true, Some("Known"))); assert!(store.enabled_for("OptIn", false, Some("Known"))); - // `Stranger` is on no node: neither aggregate reaches them — each addon answers with its - // own manifest default, which is the opposite verdict in both cases. + // `Stranger` is on no node: each addon answers its own manifest default. assert!(store.enabled_for("Shunned", true, Some("Stranger"))); assert!(!store.enabled_for("OptIn", false, Some("Stranger"))); - // …and `None` (nobody picked yet) is the same epilogue. + // `None` (nobody picked yet) is the same epilogue. assert!(store.enabled_for("Shunned", true, None)); assert!(!store.enabled_for("OptIn", false, None)); let _ = std::fs::remove_dir_all(home.parent().unwrap()); } - /// Enable state survives the session: `DisableAddOn` from Lua is written back in the - /// reference's own `AddOns.txt` format, and read back as a disable next time. #[test] fn the_enable_state_round_trips_through_the_file() { let _l = crate::local_state::test_env::ENV_LOCK @@ -3067,10 +2365,8 @@ mod tests { save_enable_state(&script, Some(&id)); let written = std::fs::read_to_string(home.join("addons/Realm-Char.txt")).unwrap(); - // **The chain's Blizzard rows first, then the folder's two** — registry order, which since - // decision 2175 is the reference's own: the archive pass registers before the loose one - // (`0x51c777` then `0x51c78f`), and the list is tail-inserted with no sort. Every registry - // row still gets a line; the format is the reference's one-line-per-addon. + // Registry order: the chain's Blizzard rows, then the folder's two (`0x51c777` before + // `0x51c78f`, tail-inserted), one line per registry row. let mut expected = String::new(); for a in chain_addons() { expected.push_str(&format!("{}: enabled\n", a.name)); @@ -3081,7 +2377,6 @@ mod tests { "the reference's own one-line-per-addon format" ); - // A fresh session reads it back and the addon stays off. let mut next = UiScript::new().unwrap(); let _ = load_third_party(&mut next, Some(&id), &[], true); assert_eq!( @@ -3092,17 +2387,10 @@ mod tests { let _ = std::fs::remove_dir_all(home.parent().unwrap()); } - // ───────────────────────────── saved variables (1188 phase 3) ───────────────────────────── + // ───────────────────────────── saved variables ───────────────────────────── - /// **An addon sets a saved variable, the session ends, a new session sees it** — 1188 phase 3's - /// acceptance test, end to end through the production path. - /// - /// It also pins the two orderings that make the mechanism work, both byte-verified: - /// *account file then per-character file* (`0x51f4b5`, `0x51f53b`), so a per-character value - /// wins; and *files → saved variables → `ADDON_LOADED`* (`0x51f5ad`), so the addon's own - /// file-scope default is assigned first, overwritten second, and read by the handler third. - /// Reverse either and the saved value can never win — which is a bug that looks exactly like - /// "settings do not persist". + /// The account file, then the per-character one (`0x51f4b5`, `0x51f53b`), then `ADDON_LOADED` + /// (`0x51f5ad`): the file-scope default is set, overwritten, then read by the handler. #[test] fn an_addons_saved_variables_survive_the_session() { let _l = crate::local_state::test_env::ENV_LOCK @@ -3116,7 +2404,6 @@ mod tests { "## Interface: 11200\n## SavedVariables: KeeperDB\n\ ## SavedVariablesPerCharacter: KeeperChar\nkeeper.lua\nkeeper.xml\n", &[ - // File-scope defaults, exactly where the reference puts them. ( "keeper.lua", "KeeperDB = { count = 0 }\nKeeperChar = 'default'\n", @@ -3174,16 +2461,9 @@ mod tests { let _ = std::fs::remove_dir_all(home.parent().unwrap()); } - /// The written bytes are the recorded grammar (decision 1128's, itself byte-verified against - /// `0x7043f0`/`0x704480`): `NAME = value`, bracketed keys, TAB indent, a trailing comma on - /// every entry, and the file split by scope. `["t"]`'s `[1] = 1,` is the reference's shape for - /// a list too — its writer emits a bracketed key for every table shape and never a bare - /// positional entry — and it reloads in index order because of what 1.12's *parser* does - /// with it (decision 2111). - /// - /// Asserted as bytes rather than by re-reading, because "it round-trips through our own - /// loader" is exactly the check that passes for a private format. The reference's own client - /// has to be able to read this. + /// The reference writer's grammar (`0x7043f0`/`0x704480`): `NAME = value`, bracketed keys, TAB + /// indent, a trailing comma on every entry, a bracketed key for a list entry too (`[1] = 1,`), + /// and the file split by scope. Asserted as bytes, not by reloading. #[test] fn the_saved_file_bytes_match_the_recorded_grammar() { let _l = crate::local_state::test_env::ENV_LOCK @@ -3221,7 +2501,6 @@ mod tests { "keys always bracketed and quoted, TAB indent per level, trailing comma on every \ entry, `\\\"` escaped — the recorded grammar\nGOT:\n{account}" ); - // Scope split: the per-character global is in the per-character file and nowhere else. assert!(!account.contains("GramChar"), "account file: {account}"); let per_char = std::fs::read_to_string(home.join("saved/Realm-Char/Gram.lua")).unwrap(); assert!( @@ -3231,12 +2510,8 @@ mod tests { let _ = std::fs::remove_dir_all(home.parent().unwrap()); } - /// `PLAYER_LOGOUT` fires **before** the writes — an addon's last chance to mutate a saved - /// global, and worthless if it fires after. - /// - /// The reference's own tail is `PLAYER_LEAVING_WORLD` → `PLAYER_LOGOUT` → the writes - /// (`0x490c2a` before `0x490c7e`/`0x490c83`). This asserts the observable consequence rather - /// than the call order: a value set *in the handler* has to reach the file. + /// `PLAYER_LOGOUT` fires before the writes (`0x490c2a` before `0x490c7e`/`0x490c83`), so a + /// value its handler sets reaches the file. #[test] fn player_logout_fires_before_the_write() { let _l = crate::local_state::test_env::ENV_LOCK @@ -3274,15 +2549,8 @@ mod tests { let _ = std::fs::remove_dir_all(home.parent().unwrap()); } - /// **The logout write MERGES, and it keeps the file's own spelling** (decision 2139). - /// - /// The reference's writer `0x51ef20` emits the in-memory enable hash, and the reader - /// `0x51ebe0` builds that hash from `AddOns.txt` — an entry per line, both states, each name - /// strdup'd as written. The installed-folder list never enters it. So a row survives an - /// uninstall, and the spelling that survives is the file's. - /// - /// Ours rebuilt the file from `addon_enable_states()` instead, and one logout erased - /// `Uninstalled: disabled` and rewrote `myaddon:` as `MyAddon:`. + /// The writer `0x51ef20` emits the enable hash the reader `0x51ebe0` built from the file, an + /// entry per line as written: a row survives an uninstall, and a name keeps its spelling. #[test] fn the_logout_write_keeps_rows_it_did_not_put_there() { let _l = crate::local_state::test_env::ENV_LOCK @@ -3309,7 +2577,6 @@ mod tests { let after = std::fs::read_to_string(&p).unwrap(); let rows = parse_enable_state(&after); - // The uninstalled addon's row is still there, still disabled — the half with teeth. assert_eq!( rows.iter() .find(|(n, _)| n.eq_ignore_ascii_case("Uninstalled")) @@ -3317,12 +2584,10 @@ mod tests { Some(false), "a row for an addon not installed this session must survive the write: {after}" ); - // The file's own spelling survives; the folder's case does not overwrite it. assert!( after.contains("myaddon:"), "the file's spelling is what the reference writes back: {after}" ); - // …and this session's toggle still landed on it. assert_eq!( rows.iter() .find(|(n, _)| n.eq_ignore_ascii_case("MyAddon")) @@ -3333,23 +2598,11 @@ mod tests { let _ = std::fs::remove_dir_all(home.parent().unwrap()); } - /// **A LoadOnDemand dependency cycle LOADS BOTH and answers `(1, nil)`** (decision 2139) — - /// and before the fix this call took the whole process down with SIGABRT. - /// - /// `AddOn_Load 0x51f240` has no re-entrancy guard: every live site of the visiting byte - /// `[UIADDON+0x2d]` is inside `AddOn_CanLoad 0x51e780`, none in the loader. What bounds the - /// recursion is the *loaded* byte, stamped **before** the dependency loops — - /// `0x51f313 mov byte [rec+0x18],1`, a flag-neutral store between `0x51f311 test eax,eax` and - /// `0x51f317 jbe`, therefore unconditional — so the re-entered frame takes the already-loaded - /// early-out at `0x51f2d6`/`0x51f2db` and returns 1. We stamped it after the recursion, so - /// nothing terminated: Rust-level recursion, stack overflow, `signal 6`, uncatchable by - /// `pcall`. - /// - /// The three assertions below are the reference's own observable consequences, and the last - /// two are the ones that would betray a guard bolted on somewhere else instead: - /// **no reason token** (a cycle produces none — the reference has no such token), - /// `ADDON_LOADED` fires **B then A**, and `IsAddOnLoaded('Ping')` answers 1 throughout Pong's - /// execution, because Ping is flagged loaded before its own files run. + /// `AddOn_Load 0x51f240` has no re-entrancy guard (the visiting byte `[UIADDON+0x2d]` lives in + /// `AddOn_CanLoad 0x51e780`) but stamps the loaded byte before the dependency loops + /// (`0x51f313 mov byte [rec+0x18],1`), so the re-entered frame exits at `0x51f2d6`/`0x51f2db` + /// with 1. A cycle loads both with no reason token, `ADDON_LOADED` fires B then A, and + /// `IsAddOnLoaded('Ping')` is 1 while Pong's files run. #[test] fn a_load_on_demand_dependency_cycle_loads_both_sides() { let _l = crate::local_state::test_env::ENV_LOCK @@ -3389,8 +2642,7 @@ mod tests { .unwrap(); let _ = load_third_party(&mut script, None, &[], true); - // Neither ran at startup — the walk skips LoadOnDemand, which is why a cycle among them - // is never observed until something demand-loads one. + // Neither ran at startup: the walk skips LoadOnDemand. assert_eq!( script .eval::("return PingRan == nil and PongRan == nil") @@ -3398,7 +2650,6 @@ mod tests { Some(true) ); - // The call that used to abort the process. assert_eq!( script .eval::>( @@ -3410,7 +2661,6 @@ mod tests { "a cycle loads and answers (1, nil) — the reference has no cycle reason token" ); - // Both sides ran, each `.toc` list exactly once. assert_eq!( script.eval::("return Order").ok().as_deref(), Some("Pong,Ping,"), @@ -3421,9 +2671,6 @@ mod tests { script.eval::("return AddonOrder").ok().as_deref(), Some("Pong,Ping,") ); - // The stamp is ahead of the files, so the cycle inverts the declared order: Ping reads as - // loaded for the whole of Pong's execution. This is the reference's behaviour and it is - // the mechanism, not a side effect. assert_eq!( script.eval::("return PingSeenLoaded").ok(), Some(1), diff --git a/crates/benilla-app/src/ui_script/bag_tests.rs b/crates/benilla-app/src/ui_script/bag_tests.rs index 1069f952..2edc37e6 100644 --- a/crates/benilla-app/src/ui_script/bag_tests.rs +++ b/crates/benilla-app/src/ui_script/bag_tests.rs @@ -7,43 +7,30 @@ use super::test_ui::{ bag_slot_button, bag_window, centre_of, click, hover, load_ui as load_xml, unhover, BAG_UI, }; -/// Is bag `id`'s window open? `IsBagOpen` is the reference's own name for the scan over -/// `ContainerFrame1..12`, and it is what these tests ask instead of naming a frame — the windows -/// are RECYCLED (1751), so which `ContainerFrame` a bag lands in is not a property to assert on. +/// Is bag `id` open? Asked of `IsBagOpen`: the twelve `ContainerFrame`s are recycled. fn open(s: &UiScript, id: i64) -> bool { super::test_ui::bag_open(s, id) } -/// The frame currently showing bag `id`, by name — for the tests that need to reach INTO the -/// window (its slots, its title, its money row). Panics if that bag is not open, which is the -/// right failure: every caller has just opened it. +/// The name of the frame showing bag `id`, which the caller has just opened. fn window(s: &UiScript, id: i64) -> String { bag_window(s, id).unwrap_or_else(|| panic!("bag {id} is not open")) } -/// The equipped-bag BAR icons must draw ABOVE the action-bar art, not under it. The bar buttons are -/// relocated onto `MainMenuBarArtFrame` but are top-level frames, so they default to a lower -/// frame level than the bar's own child-hierarchy art (the ExpBar dwarf notches + metal/well art) — -/// which would then paint over the centered icons, leaving the ring but no bag icon. The OnLoad -/// `BenillaActionBarArt_SeatAbove` seats them one level above the art (the action buttons' level). -/// This locks that: no bag-slot icon quad may be covered by a higher-z art texture at its center. +/// The stock bag buttons are `MainMenuBarArtFrame`'s children, a level above the bar's art. #[test] fn bag_bar_icons_draw_above_the_action_bar_art() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = UiScript::new().unwrap(); - // The screen the client defaults to; the action bar centers here and the bag bar lands over its - // right end, where the dwarf-notch strip overlaps — the exact geometry that reproduced the bug. + // At this size the bag bar sits over the right end of the XP bar's notched art. s.set_screen_size(1600.0, 900.0); - // `BAG_UI` carries ActionBar.xml — both the anchor target (MainMenuBarArtFrame) and the - // occluder (the ExpBar dwarf art) — in its manifest position. for file in BAG_UI { load_xml(&s, file); } s.resolve(); let quads = s.extract(); - // A bag-slot icon is occluded when any HIGHER-z textured quad (other than the button's own ring) - // covers its center — i.e. the bar art draws on top of it. + // Occluded: a higher-z textured quad other than the slot's own ring covers the icon's centre. let occluded = quads .iter() .filter(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains("UI-PaperDoll-Slot-Bag"))) @@ -63,11 +50,7 @@ fn bag_bar_icons_draw_above_the_action_bar_art() { ); } -/// The backpack open/close kits (ContainerFrame.lua ContainerFrame_OnShow/OnHide, l.140 / l.120): -/// showing the window queues igBackPackOpen, hiding it queues igBackPackClose — and nothing queues -/// at load (the frame is authored hidden="true", so it never transitions on startup). Driven through -/// `BenillaBagToggle_OnClick` — the toggle body the bag button's click wrapper calls (the 'B' -/// binding runs the bare `ToggleBackpack()`, the same one hop deeper). +/// Stock `ContainerFrame_OnShow` and `OnHide` play the sounds (`ContainerFrame.lua:140`, `:120`). #[test] fn backpack_toggle_plays_open_and_close_kits() { let _data = benilla_formats::wow_data_or_skip!(); @@ -77,7 +60,7 @@ fn backpack_toggle_plays_open_and_close_kits() { load_xml(&s, file); } s.set_money(0); - // The backpack has to have slots before `ToggleBag` will open it — its `size > 0` guard. + // `ToggleBag` opens nothing without slots (its `size > 0` guard). s.set_container( 0, Some(ContainerState { @@ -87,14 +70,12 @@ fn backpack_toggle_plays_open_and_close_kits() { }), ); - // Hidden at load: no sound queued, every window starts hidden. assert!( s.take_sounds().is_empty(), "no sound at load (never transitions)" ); assert!(!open(&s, 0)); - // Toggle open → OnShow → igBackPackOpen. s.run("MainMenuBarBackpackButton:Click()").unwrap(); assert!(open(&s, 0)); assert_eq!( @@ -103,7 +84,6 @@ fn backpack_toggle_plays_open_and_close_kits() { "opening the backpack plays igBackPackOpen" ); - // Toggle closed → OnHide → igBackPackClose. s.run("MainMenuBarBackpackButton:Click()").unwrap(); assert!(!open(&s, 0)); assert_eq!( @@ -114,17 +94,8 @@ fn backpack_toggle_plays_open_and_close_kits() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// **B / the backpack button open the BACKPACK ALONE** (ref ToggleBackpack, ContainerFrame.lua -/// l.67-82) — the director's report, and the fix in 1494. Until then both ran an all-bags toggle -/// and an equipped bag came up beside the backpack every time. -/// -/// The reference's shape is deliberately asymmetric, and all three arms are pinned here: -/// * shut → `ToggleBag(0)`: bag 0 and nothing else, however many bags are equipped; -/// * bag 0 OPEN → hide every container window there is (the close arm is the all-bags one); -/// * bag 0 shut but ANOTHER bag open → the condition reads bag 0 specifically, so this is still -/// the open arm: the backpack joins the open bag rather than closing it. -/// -/// ESC's CloseAllWindows must still sweep all of them. +/// Stock `ToggleBackpack` (`ContainerFrame.lua:67-82`): with bag 0 shut it opens bag 0 alone, even +/// beside another open bag; with bag 0 open it hides every container window. #[test] fn b_opens_the_backpack_alone_and_closes_every_bag() { let _data = benilla_formats::wow_data_or_skip!(); @@ -133,12 +104,11 @@ fn b_opens_the_backpack_alone_and_closes_every_bag() { for file in BAG_UI { load_xml(&s, file); } - load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon + load_xml(&s, "ScrollTemplates.xml"); // our scroll kit load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); s.set_money(0); - // Backpack (16) + one equipped bag in slot 2 (6). Bags 1/3/4 are left unset → 0 slots. s.set_container( 0, Some(ContainerState { @@ -156,7 +126,6 @@ fn b_opens_the_backpack_alone_and_closes_every_bag() { }), ); - // Toggle open: the BACKPACK, and only the backpack — bag 2 is equipped and stays shut. s.run("MainMenuBarBackpackButton:Click()").unwrap(); let _ = s.take_sounds(); assert!(open(&s, 0), "backpack opens"); @@ -169,8 +138,6 @@ fn b_opens_the_backpack_alone_and_closes_every_bag() { "and the empty slots have no window to show either way" ); - // The close arm IS the all-bags one: with bag 2 also open by some other path, one toggle - // takes the lot down. s.run("ToggleBag(2)").unwrap(); assert!(open(&s, 2)); s.run("MainMenuBarBackpackButton:Click()").unwrap(); @@ -180,8 +147,7 @@ fn b_opens_the_backpack_alone_and_closes_every_bag() { "bag 0 open ⇒ the toggle hides every container window" ); - // Bag 0 shut, bag 2 open: the ref's condition reads bag 0, so this is the OPEN arm — the - // backpack joins the bag already up instead of closing it. + // Bag 0 shut with bag 2 open is still the open arm: the condition reads bag 0. s.run("ToggleBag(2)").unwrap(); s.run("MainMenuBarBackpackButton:Click()").unwrap(); let _ = s.take_sounds(); @@ -190,7 +156,6 @@ fn b_opens_the_backpack_alone_and_closes_every_bag() { "with only another bag open, B opens the backpack beside it" ); - // ESC's CloseAllWindows sweeps every open bag, not just the backpack. s.run("CloseAllWindows()").unwrap(); assert!( !open(&s, 0) && !open(&s, 2), @@ -199,15 +164,8 @@ fn b_opens_the_backpack_alone_and_closes_every_bag() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// **SHIFT-B / a shift-click on the backpack button open ALL of them** (ref OpenAllBags, -/// ContainerFrame.lua l.662-700) — the other half of the split 1494 restored. It is a TOGGLE, and -/// its arms are decided by a COUNT, not by "is anything open": -/// * anything less than everything open → open the lot (the backpack plus each equipped bag); -/// * everything already open → the counting pass IS the close; -/// * `forceOpen` → always the open arm (what a vendor window would pass). -/// -/// The keyring is swept by the counting pass but never counted and never opened — the reference's -/// own `GetID() ~= KEYRING_CONTAINER` exclusion. +/// Stock `OpenAllBags` (`ContainerFrame.lua:662-700`) toggles by a count: all open closes the lot, +/// anything less opens it, `forceOpen` always opens; the keyring is swept but never counted. #[test] fn shift_b_toggles_every_bag_at_once() { let _data = benilla_formats::wow_data_or_skip!(); @@ -216,7 +174,7 @@ fn shift_b_toggles_every_bag_at_once() { for file in BAG_UI { load_xml(&s, file); } - load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon + load_xml(&s, "ScrollTemplates.xml"); // our scroll kit load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); s.set_money(0); @@ -237,7 +195,6 @@ fn shift_b_toggles_every_bag_at_once() { }), ); - // From nothing open: the backpack AND the equipped bag; the empty slots have no window. s.run("OpenAllBags()").unwrap(); let _ = s.take_sounds(); assert!(open(&s, 0) && open(&s, 2)); @@ -250,13 +207,11 @@ fn shift_b_toggles_every_bag_at_once() { "the keyring is not one of your bags — open-all never opens it" ); - // Everything open ⇒ the next one closes the lot. s.run("OpenAllBags()").unwrap(); let _ = s.take_sounds(); assert!(!open(&s, 0) && !open(&s, 2)); - // PARTIAL is the open arm, not the close arm — the count is what decides. Backpack alone up - // (1 of 2) ⇒ open-all still opens rather than closing what is there. + // One of two open is still the open arm: the count decides. s.run("ToggleBag(0)").unwrap(); s.run("OpenAllBags()").unwrap(); let _ = s.take_sounds(); @@ -265,8 +220,7 @@ fn shift_b_toggles_every_bag_at_once() { "1 of 2 open is not 'all open': the lot opens" ); - // An open keyring rides the sweep down without ever counting toward "all open" — so this - // still reads 2-of-2 and closes. + // An open keyring is swept but not counted, so this reads two of two and closes. s.run("ToggleKeyRing()").unwrap(); assert!(open(&s, -2)); s.run("OpenAllBags()").unwrap(); @@ -277,7 +231,7 @@ fn shift_b_toggles_every_bag_at_once() { ); assert!(!open(&s, -2), "…but it IS swept by the same pass"); - // forceOpen skips the close arm: from all-open, everything stays open. + // `forceOpen` skips the close arm. s.run("OpenAllBags(1)").unwrap(); s.run("OpenAllBags(1)").unwrap(); let _ = s.take_sounds(); @@ -288,9 +242,8 @@ fn shift_b_toggles_every_bag_at_once() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// An open bag LIGHTS its bar button (the CheckButton ring — ref ContainerFrame_OnShow/OnHide -/// SetChecked, l.124-131/84-95), and any close clears it: the windows are the source of truth, -/// so the ring tracks opens from every path (the all-toggle, a bar click, ESC's sweep). +/// An open bag lights its bar button and any close clears it: `ContainerFrame_OnShow` and `OnHide` +/// write the ring (`ContainerFrame.lua:124-131`, `:84-95`), so it tracks every path. #[test] fn bag_bar_buttons_light_while_their_bag_is_open() { let _data = benilla_formats::wow_data_or_skip!(); @@ -299,7 +252,7 @@ fn bag_bar_buttons_light_while_their_bag_is_open() { for file in BAG_UI { load_xml(&s, file); } - load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon + load_xml(&s, "ScrollTemplates.xml"); // our scroll kit load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); s.set_money(0); @@ -324,8 +277,6 @@ fn bag_bar_buttons_light_while_their_bag_is_open() { .unwrap() }; - // Open-all (SHIFT-B's knob since 1494 — the backpack button alone would light only its own - // ring now): the backpack button and the equipped bag's slot light; the empty slots don't. s.run("OpenAllBags()").unwrap(); assert!( checked(&mut s, "MainMenuBarBackpackButton"), @@ -337,9 +288,8 @@ fn bag_bar_buttons_light_while_their_bag_is_open() { "empty slot stays dark" ); - // ...and the rings actually EMIT (extract-level): exactly two CheckButtonHilight quads, the - // toggle's owner-sized on the 37px button at the art frame's BOTTOMRIGHT −6,2 (art right - // edge = 1024 at this screen ⇒ x[981,1018] y[2,39]). + // Exactly two rings emit; the backpack's covers its 37-wide button at the art frame's + // BOTTOMRIGHT (-6, 2): x 981..1018, y 2..39 at this size. s.resolve(); let rings: Vec<_> = s .extract() @@ -364,7 +314,6 @@ fn bag_bar_buttons_light_while_their_bag_is_open() { (981.0, 2.0, 1018.0, 39.0) ); - // Closing ONE window (its close button / any Hide path) clears just its ring. s.run("CloseBag(2)").unwrap(); assert!( !checked(&mut s, "CharacterBag1Slot"), @@ -375,11 +324,8 @@ fn bag_bar_buttons_light_while_their_bag_is_open() { "the backpack ring stays" ); - // A bar-slot click reopens bag 2 and relights it (the click auto-toggle + the ref's - // re-derive tail agree here). Driven as a RIGHT-click through the real input path: the ref's - // BagSlotButtonTemplate inherits PaperDollItemSlotButtonTemplate, whose OnLoad registers - // ("LeftButtonUp", "RightButtonUp") — PaperDollFrame.lua:86 — and BagSlotButton_OnClick reads - // no button, so either one opens the bag. Ours registered LeftButtonUp only until 0908. + // A right-click on the bar slot reopens bag 2: `PaperDollItemSlotButton_OnLoad` registers both + // buttons (`PaperDollFrame.lua:86`) and `BagSlotButton_OnClick` reads neither. let (cx, cy): (f64, f64) = s .eval( "return (CharacterBag1Slot:GetLeft() + CharacterBag1Slot:GetRight()) / 2, \ @@ -393,7 +339,6 @@ fn bag_bar_buttons_light_while_their_bag_is_open() { "a RIGHT-click on the bar slot reopens bag 2, exactly as a left one does" ); - // ESC's sweep closes everything → every ring dark. s.run("CloseAllWindows()").unwrap(); assert!( !checked(&mut s, "MainMenuBarBackpackButton") && !checked(&mut s, "CharacterBag1Slot"), @@ -403,22 +348,19 @@ fn bag_bar_buttons_light_while_their_bag_is_open() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// A slot on the RIGHT half of the screen hangs its tooltip LEFT — the ref's own screen-edge -/// answer (ContainerFrameItemButton_OnEnter, ContainerFrame.lua:602-612 side-pick), which is what -/// keeps a bag tooltip from running off the right edge (the bag lives at the bottom-right). +/// A slot in the right half of the screen hangs its tooltip left +/// (`ContainerFrameItemButton_OnEnter`, `ContainerFrame.lua:602-612`). #[test] fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); - // The stock tooltip declares no size: it sizes from its lines through the font engine, as - // the client's does (1968) — a harness that reads its rect needs one; the fixed-width - // font is that engine here. + // The stock tooltip sizes from its lines, so reading its rect needs a text measurer. s.set_text_measurer(Box::new(super::FixedWidthFont(6.0))); for file in BAG_UI { load_xml(&s, file); } - load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon + load_xml(&s, "ScrollTemplates.xml"); // our scroll kit load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); s.set_money(0); @@ -458,11 +400,8 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() { s.take_sounds(); s.resolve(); - // The engine speaks 1.12: GetScreenWidth serves the host-set root width. assert_eq!(s.eval::("return GetScreenWidth()").unwrap(), 1024.0); - // The bag window anchors bottom-right, so every slot button is in the right half. The bag - // numbers its buttons visually (reversed from container slots) — find the button showing - // container slot 1, where the fixture item lives. + // The bag anchors bottom-right, so every slot is in the right half. let btn = bag_slot_button(&s, 0, 1); let ok: bool = s .eval(&format!("return {btn}:GetRight() >= GetScreenWidth() / 2")) @@ -474,8 +413,7 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() { assert!(s.eval::("return GameTooltip:IsVisible()").unwrap()); s.resolve(); - // ANCHOR_LEFT seats the tooltip's BOTTOMRIGHT on the slot's TOPLEFT: the whole tooltip stays - // left of the slot, i.e. on-screen — never past the right edge. + // `ANCHOR_LEFT` seats the tooltip's BOTTOMRIGHT on the slot's TOPLEFT. let ok: bool = s .eval(&format!( "return GameTooltip:GetRight() <= {btn}:GetLeft() \ @@ -485,10 +423,8 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() { assert!(ok, "tooltip hangs LEFT of a right-half slot"); } -/// A tooltip opened while the item's template is still in flight repaints itself the moment the -/// stats land — no re-hover. The refresh loop is the ref's own (ContainerFrameItemButton_OnUpdate, -/// ContainerFrame.lua:645-660: re-run OnEnter every frame while `GameTooltip:IsOwned(this)`), and -/// hiding the tooltip drops ownership so the loop can never resurrect it. +/// Stock `ContainerFrameItemButton_OnUpdate` re-runs `OnEnter` while it owns the tooltip +/// (`ContainerFrame.lua:645-660`); hiding the tooltip drops ownership. #[test] fn hovered_bag_tooltip_fills_itself_when_the_stats_land() { let _data = benilla_formats::wow_data_or_skip!(); @@ -497,7 +433,7 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() { for file in BAG_UI { load_xml(&s, file); } - load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon + load_xml(&s, "ScrollTemplates.xml"); // our scroll kit load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); s.set_money(0); @@ -537,7 +473,6 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() { s.resolve(); let btn = bag_slot_button(&s, 0, 1); - // Hover with the stats store empty: the fallback one-line tooltip, and the miss recorded. hover(&mut s, &btn); assert!(s.eval::("return GameTooltip:IsVisible()").unwrap()); assert_eq!( @@ -547,8 +482,7 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() { ); assert_eq!(s.take_item_stat_asks(), vec![25], "the miss asks the app"); - // The template lands (the app's arrival-driven push) → the very next frame's OnUpdate - // re-enter repaints the OPEN tooltip with the full stat head. + // The template lands; the next frame's `OnUpdate` repaints the open tooltip. s.set_item_template( 25, ItemTemplateView { @@ -580,7 +514,6 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() { "the repaint carries the stat head's damage line" ); - // Leaving drops ownership: the loop must not resurrect the hidden tooltip. unhover(&mut s); s.tick(0.016); assert!( @@ -590,9 +523,8 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// At a vendor, a bag hover shows the engine-truth sell-price money row (SellPrice × stack, -/// 0x52b650@0x52e376) — or the ITEM_UNSELLABLE "No sell price" line — and arms -/// the pouch cursor (ShowContainerSellCursor 0x4fa460 → Buy over a Point base); +/// At a vendor a bag hover shows `SellPrice` times the stack (`0x52b650`, `0x52e376`) or the +/// `ITEM_UNSELLABLE` line, and arms the sell cursor (`ShowContainerSellCursor`, `0x4fa460`); /// leaving resets it. #[test] fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() { @@ -604,7 +536,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() { for file in BAG_UI { load_xml(&s, file); } - load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon + load_xml(&s, "ScrollTemplates.xml"); // our scroll kit load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); s.set_money(0); @@ -682,16 +614,14 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() { }, ); s.set_merchant(Some(MerchantState::default())); - // MERCHANT_SHOW already opens the bags (the window's OnShow calls `OpenBackpack`, decision - // 0561), so this needs no toggle of its own — and must not have one: with bag 0 already up, - // `ToggleBackpack` takes its close-all arm and the window this test hovers in would go away. + // `MERCHANT_SHOW` opens the bags (the window's OnShow calls `OpenBackpack`); a toggle here + // would take `ToggleBackpack`'s close-all arm. s.fire_event("MERCHANT_SHOW", vec![ScriptValue::Str("Vendor".into())]); s.take_sounds(); s.resolve(); let b1 = bag_slot_button(&s, 0, 1); let b2 = bag_slot_button(&s, 0, 2); - // The sellable stack: a money row (52c → the copper coin slot shows "52") + the pouch armed. hover(&mut s, &b1); assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors()); assert!(s @@ -706,14 +636,12 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() { "the pouch cursor is armed over a sellable item" ); - // Leaving resets the cursor and the money row dies with the tooltip. unhover(&mut s); assert_eq!(s.ui_cursor(), None, "ResetCursor on leave"); assert!(s .eval::("return not GameTooltipMoneyFrame:IsShown()") .unwrap()); - // The unsellable item: the ITEM_UNSELLABLE line, no coins. hover(&mut s, &b2); let has_line: bool = s .eval( @@ -730,9 +658,8 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// A readable bag item (a mail permanent copy — the instance carries item text) shows the -/// Inspect magnifier on hover (ref ContainerFrameItemButton_OnEnter, ContainerFrame.lua l.638: -/// `this.readable → ShowInspectCursor()`); a plain item leaves the base cursor; leaving resets. +/// A readable bag item (a letter's permanent copy) shows the inspect cursor on hover +/// (`ContainerFrame.lua:638`, `this.readable`); a plain item does not. #[test] fn readable_letter_hover_shows_the_inspect_magnifier() { benilla_formats::wow_data_or_skip!(); @@ -743,7 +670,7 @@ fn readable_letter_hover_shows_the_inspect_magnifier() { for file in BAG_UI { load_xml(&s, file); } - load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon + load_xml(&s, "ScrollTemplates.xml"); // our scroll kit load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); s.set_money(0); @@ -801,12 +728,7 @@ fn readable_letter_hover_shows_the_inspect_magnifier() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// The drag trio (decision 0216 §3): a real press-drag-release across two slot buttons routes -/// through the SAME `ContainerFrameItemButton_OnClick("LeftButton")` path a two-click pickup/place -/// does — -/// unlike every other bag test here, which calls the Lua click handler directly, this one drives -/// actual `mouse_button`/`mouse_move` so the `RegisterForDrag`/`OnDragStart`/`OnReceiveDrag` XML -/// wiring itself is under test, not just the handler body. +/// Real input, so the template's `RegisterForDrag`/`OnDragStart`/`OnReceiveDrag` wiring runs. #[test] fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() { let _data = benilla_formats::wow_data_or_skip!(); @@ -815,7 +737,7 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() { for file in BAG_UI { load_xml(&s, file); } - load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon + load_xml(&s, "ScrollTemplates.xml"); // our scroll kit load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); s.set_money(0); @@ -870,7 +792,6 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() { )) .unwrap(); - // Press on slot 1 (picks up), drag past the threshold onto slot 5, release there. s.mouse_button(x1 as f32, y1 as f32, "LeftButton", true); s.mouse_move(x5 as f32, y5 as f32); let consumed = s.mouse_button(x5 as f32, y5 as f32, "LeftButton", false); @@ -890,9 +811,6 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// A second bag window (decision 0216 slice 2): bag 1's snapshot feeds through the SAME -/// container/BenillaBagWindow_Update plumbing the backpack uses, opened via the bag-bar path -/// (`BenillaBagBarSlot_OnClick`, not the backpack toggle) and painting its own slot 1 icon. #[test] fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() { let _data = benilla_formats::wow_data_or_skip!(); @@ -901,7 +819,7 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() { for file in BAG_UI { load_xml(&s, file); } - load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon + load_xml(&s, "ScrollTemplates.xml"); // our scroll kit load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); s.set_money(0); @@ -939,8 +857,8 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() { ); assert!(!open(&s, 1), "hidden by default"); - // CharacterBag0Slot == bag id 1: the stock bar's handlers carry no `bagId` field and - // recompute `this:GetID() - CharacterBag0Slot:GetID() + 1` (see `:2119` below). + // `CharacterBag0Slot` is bag 1: the stock handlers compute + // `this:GetID() - CharacterBag0Slot:GetID() + 1`. s.run("CharacterBag0Slot:Click()").unwrap(); let _ = s.take_sounds(); assert!(open(&s, 1), "the bag-bar click opened bag 1's window"); @@ -961,14 +879,9 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// An equipped bag's window SNUG-FITS its row count (BenillaBagWindow_FitBackground) instead of the -/// backpack's fixed 5-row/260 slab. The heights are the real client's, from -/// `ContainerFrame_GenerateFrame` in the shipped `Interface\FrameXML\ContainerFrame.lua`: -/// `height = topH + ((rows-1)*41 - 9) + 10`, with `topH` = 72 for a size%4==2 bag (its own plus-two -/// top band), 86 for a single full row, else 94. The `-9` is the reference's `firstRowPixelOffset` -/// and the `10` its fixed bottom rim; both are load-bearing — dropping the offset slides the rim a -/// row-fraction low and bleeds the next row's wells in above it. This locks that arithmetic AND the -/// core fix: a small bag is far shorter than the old fixed height. +/// An equipped bag's window height follows stock `ContainerFrame_GenerateFrame`: +/// `top + ((rows - 1) * 41 - 9) + 10`, where `top` is 72 for a size % 4 == 2 bag, 86 for a single +/// full row and 94 otherwise, and a one-row bag drops the middle term. #[test] fn equipped_bag_window_snug_fits_its_row_count() { let _data = benilla_formats::wow_data_or_skip!(); @@ -977,15 +890,13 @@ fn equipped_bag_window_snug_fits_its_row_count() { for file in BAG_UI { load_xml(&s, file); } - load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon + load_xml(&s, "ScrollTemplates.xml"); // our scroll kit load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); s.set_money(0); - // (bag id, slot count, expected window height). 6 → 2 rows plus-two-top (72+32+10); 8 → 2 rows - // full-top (94+32+10); 10 → 3 rows plus-two-top (72+73+10); 20 → 5 rows full-top (94+155+10). - // The last two exercise the no-middle fork: 4 → one full row (86+0+10); 2 → one plus-two row - // (72+0+10). Bag 1 stays at 6 so the h6 assertion below still reads the pouch. + // (bag, slots, height): 6 is 72+32+10, 8 is 94+32+10, 10 is 72+73+10, 20 is 94+155+10, and the + // one-row 4 and 2 are 86+10 and 72+10. Bag 1 stays at 6: the last assertion reads it. for (bag, size, expected) in [ (1, 6, 114.0), (2, 8, 136.0), @@ -1002,8 +913,7 @@ fn equipped_bag_window_snug_fits_its_row_count() { slots: std::collections::HashMap::new(), }), ); - // The window is SIZED at generation, so re-open it after each resize: `ToggleBag` twice - // is the reference's own way of regenerating a frame for a bag whose size changed. + // The window is sized at generation, so each resize reopens it. if open(&s, bag) { s.run(&format!("ToggleBag({bag})")).unwrap(); } @@ -1020,7 +930,6 @@ fn equipped_bag_window_snug_fits_its_row_count() { s.run(&format!("ToggleBag({bag})")).unwrap(); let _ = s.take_sounds(); } - // The core regression: a 6-slot bag is much shorter than the old fixed 260-tall slab. s.run("ToggleBag(1)").unwrap(); let _ = s.take_sounds(); let w1 = window(&s, 1); @@ -1032,13 +941,12 @@ fn equipped_bag_window_snug_fits_its_row_count() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// A fixture backpack with a 5-stack of Tough Jerky in slot 1, the bag opened — shared setup for -/// the shift-click/split tests below. Returns the slot-1 button's screen center. +/// Open a backpack holding a five-stack in slot 1; returns that slot button's centre. fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) { for file in BAG_UI { load_xml(s, file); } - load_xml(s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon + load_xml(s, "ScrollTemplates.xml"); // our scroll kit load_xml(s, "Interface\\FrameXML\\CharacterFrameTemplates.xml"); load_xml(s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(s, "Interface\\FrameXML\\StackSplitFrame.xml"); @@ -1083,9 +991,7 @@ fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) { centre_of(s, &btn) } -/// The stack-split trigger — SHIFT + left-click on an unlocked stack of ≥2, the reference fork -/// verbatim (ContainerFrame.lua:567-577), driven through the `set_modifiers` mirror the cursor -/// arc landed. Nothing is picked up: the spinner opens against the still-seated stack. +/// The shift fork (`ContainerFrame.lua:567-578`) opens the split frame without a pickup. #[test] fn shift_click_on_a_stack_opens_the_split_frame() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1111,12 +1017,9 @@ fn shift_click_on_a_stack_opens_the_split_frame() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// B180 — the split dialog's parchment plate fills the whole 172×96 frame. The reference authors -/// the plate 256×32 with NO anchors, a vestigial size the real client never renders (the TexCoords -/// crop exactly 172×96 out of the 256×128 art — the frame's own size, a complete panel drawn 1:1 -/// over it). 1308 first dodged this by dropping the size; 1310 then landed the byte-verified law -/// (an anchor-less region gets an implicit SetAllPoints at creation, size unread under the two -/// corners) and restored the ref's own text — this pins the render through the real mechanism. +/// The split dialog's plate fills the 172×96 frame: stock `StackSplitFrame.xml` sizes it 256×32 +/// with no anchors, and an anchor-less region gets an implicit `SetAllPoints` at creation, its size +/// unread; the TexCoords crop 172×96 out of the 256×128 art. #[test] fn the_split_frame_plate_fills_the_dialog() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1165,13 +1068,8 @@ fn the_split_frame_plate_fills_the_dialog() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// B180's Bagnon follow-on (director, 08-14): the dialog opened UNDER an addon bag window — -/// Bagnon's windows are `frameStrata="HIGH"`, the dialog's own stratum, and our re-expression had -/// dropped the reference's `toplevel="true"`, so the dialog's Show raised nothing and lost the -/// level tie to the later-shown window (its child buttons at level+1 poked through; the plate did -/// not). With the ref's attrs restored, Show runs the verified raise (toplevel.rs: compact, then -/// top-occupied-plus-one) and the whole dialog lands above. The synthetic window stands in for -/// Bagnon: same stratum, shown after load, overlapping the dialog. +/// The split dialog raises over a same-stratum window shown before it, as an addon bag window is +/// (`frameStrata="HIGH"`, the dialog's own): stock `toplevel="true"` makes its `Show` raise it. #[test] fn the_split_frame_raises_over_a_same_stratum_window() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1212,7 +1110,6 @@ fn the_split_frame_raises_over_a_same_stratum_window() { "Show must raise the toplevel dialog over the same-stratum window \ (dialog level {dialog}, window level {bagnon})" ); - // The symptom itself: the plate paints AFTER the window's background in draw order. let order: Vec = s .extract() .iter() @@ -1233,11 +1130,8 @@ fn the_split_frame_raises_over_a_same_stratum_window() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// Typed-digit entry in the split spinner (decision 1319) — the director's ask, and the deferral -/// this file's header carried since 0216. Pins the whole chain in one go: the dialog is in the -/// keyboard walk (`enableKeyboard`), a digit reaches its `OnChar`, the first digit REPLACES the -/// seeded 1 while later digits append, an over-max entry clamps instead of being rejected, -/// BACKSPACE drops a digit, and ENTER commits exactly as Okay does. +/// Digits reach the dialog's `OnChar` (`enableKeyboard`): the first replaces the seeded 1, later +/// ones append unless they would exceed the stack, and ENTER commits as Okay does. #[test] fn typing_a_number_into_the_split_spinner_sets_the_count() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1256,7 +1150,7 @@ fn typing_a_number_into_the_split_spinner_sets_the_count() { "opens seeded at 1" ); - // A digit is consumed by the dialog — which is also what stops it firing action button 3. + // The dialog consumes the digit, so it does not also fire action button 3. assert!(s.char_input("3"), "the spinner consumed the digit"); assert_eq!( s.eval::("return StackSplitFrame.split").unwrap(), @@ -1269,22 +1163,17 @@ fn typing_a_number_into_the_split_spinner_sets_the_count() { "and the label follows" ); - // A second digit that would overflow the stack is **IGNORED**, not clamped — the reference's - // `StackSplitFrame_OnChar` writes `this.split` only inside `if split <= this.maxStack` - // (StackSplitFrame.lua l.72-85), so 37 against a 5-stack leaves 3 standing. Our own copy - // clamped to 5 instead, and this assertion was written against that; the window is the - // reference's since 1751 window 17, so the ignore is the behaviour. + // A digit that would exceed the stack is ignored, not clamped: stock `StackSplitFrame_OnChar` + // writes `this.split` only when `split <= this.maxStack` (`StackSplitFrame.lua:72`). assert!(s.char_input("7")); assert_eq!( s.eval::("return StackSplitFrame.split").unwrap(), 3, "37 against a 5-stack keeps the 3 — the ref ignores the digit, it does not clamp" ); - // BACKSPACE drops a digit off it. assert!(s.frame_key_input("BACKSPACE"), "the dialog took BACKSPACE"); assert_eq!(s.eval::("return StackSplitFrame.split").unwrap(), 1); - // Type a real value and commit with ENTER — the Okay path, so the carry is picked up. assert!(s.char_input("4")); assert_eq!(s.eval::("return StackSplitFrame.split").unwrap(), 4); assert!(s.key_input("ENTER"), "ENTER is consumed by the dialog"); @@ -1297,9 +1186,8 @@ fn typing_a_number_into_the_split_spinner_sets_the_count() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// Okay in the split spinner only picks the split carry up (ref/cursor.rs `SplitContainerItem` — -/// a pickup, not a self-contained move); a SUBSEQUENT placement is what actually queues the -/// `ContainerMove` with `count: Some(n)`, drained the same way any other container move is. +/// Okay only picks the split up (`SplitContainerItem` is a pickup); the next placement queues the +/// move with the split count. #[test] fn split_okay_then_a_placement_queues_the_split_move() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1312,7 +1200,6 @@ fn split_okay_then_a_placement_queues_the_split_move() { s.set_modifiers(false, false, false); assert!(s.eval::("return StackSplitFrame:IsShown()").unwrap()); - // Bump the spinner from 1 to 3, then Okay — the carry lands on the cursor. click(&mut s, "StackSplitRightButton", "LeftButton"); click(&mut s, "StackSplitRightButton", "LeftButton"); assert_eq!(s.eval::("return StackSplitFrame.split").unwrap(), 3); @@ -1328,7 +1215,6 @@ fn split_okay_then_a_placement_queues_the_split_move() { "no move yet — only a pickup" ); - // Place the carry on slot 5 (empty) — NOW the move queues, carrying the split count. let b5 = bag_slot_button(&s, 0, 5); click(&mut s, &b5, "LeftButton"); assert!(s.cursor_item().is_none()); @@ -1345,8 +1231,7 @@ fn split_okay_then_a_placement_queues_the_split_move() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// A plain click hides any open split frame (ref ContainerFrame.lua:581) — even a click on an -/// unrelated, empty slot. +/// A plain click hides an open split frame (`ContainerFrame.lua:581`), even on an unrelated slot. #[test] fn a_plain_click_hides_an_open_split_frame() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1368,11 +1253,8 @@ fn a_plain_click_hides_an_open_split_frame() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// Bag-slot item cooldowns through the shipped XML (decision 0263's deferral): a potion mid- -/// cooldown pushes its triple with the container snapshot; opening the bag runs the ref's -/// occupied-slot fork (`ContainerFrame_UpdateCooldown` → `GetContainerItemCooldown` → -/// `CooldownFrame_SetTimer`) and the slot grows a live sweep; a `BAG_UPDATE_COOLDOWN` refresh -/// with the cooldown gone hides it again. +/// Stock `ContainerFrame_UpdateCooldown` reads `GetContainerItemCooldown` into +/// `CooldownFrame_SetTimer`; a `BAG_UPDATE_COOLDOWN` with the cooldown gone hides the sweep. #[test] fn bag_slot_cooldown_sweeps_through_the_xml() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1381,11 +1263,11 @@ fn bag_slot_cooldown_sweeps_through_the_xml() { for file in BAG_UI { load_xml(&s, file); } - load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon + load_xml(&s, "ScrollTemplates.xml"); // our scroll kit load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); s.set_money(0); - s.tick(100.0); // a nonzero clock epoch, like the engine cooldown tests + s.tick(100.0); // a nonzero clock epoch let potion = |cooldown| ContainerSlot { durability: None, @@ -1405,12 +1287,11 @@ fn bag_slot_cooldown_sweeps_through_the_xml() { slots, } }; - // 12 s remain of the potion category's 60 s: started at GetTime 52 (absolute-start triple). + // 12 s left of the potion's 60 s: started at `GetTime` 52 (an absolute-start triple). 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. + // The next paint's `OnUpdateModel` scrubs the pane's sequence 0 to the elapsed fraction. super::test_ui::cooldown_facts(&mut s); s.tick(0.0); s.resolve(); @@ -1419,45 +1300,30 @@ fn bag_slot_cooldown_sweeps_through_the_xml() { 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. s.set_container(0, Some(backpack(None))); s.fire_event("BAG_UPDATE_COOLDOWN", vec![]); assert_eq!(sweep(&s), None, "cold again after the refresh"); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// The bar's five bag buttons had NO hover at all — the director's "the bags are missing their -/// simple tooltips". These are the ref's plain `SetText` plates (MainMenuBarBagButtons.xml l.91-99 -/// for the backpack, MainMenuBarBagButtons.lua l.86-96 for the four slots), NOT the two-line -/// newbie kind the micro buttons next to them use — so what's pinned here is the label, the -/// empty-slot fallback, and that they seat BESIDE the button rather than at the screen corner. -/// -/// Those two bodies are the reference's OWN now (1751 window 3), which changes one thing this test -/// records: **the binding-key suffix appears on the four slots too.** The reference appends -/// `GetBindingKey("TOGGLEBAG"..(4 - (this:GetID() - CharacterBag0Slot:GetID())))` — its numbering -/// runs the bar right-to-left, so the slot next to the backpack is `TOGGLEBAG4`, our F11 -/// (`bindings/commands.rs`, quoting ref Bindings.xml l.564-575). Our own handler never did this, -/// so the suffix is new here and is the reference's behaviour, not a regression. The backpack's -/// key is the reference's `TOGGLEBACKPACK` rather than the `OPENALLBAGS` ours read; both default -/// to B, so the string is unchanged and only the reason is. +/// Stock plain `SetText` tooltips (`MainMenuBarBagButtons.xml:91-99`, `.lua:86-96`) with the +/// binding key appended; `TOGGLEBAG` numbers the slots right to left, so the one beside the +/// backpack is `TOGGLEBAG4` (F11 by default). #[test] fn the_bar_bag_buttons_name_themselves_on_hover() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = UiScript::new().unwrap(); - // The suffix reads GetBindingKey live since 0997 — register the real command set the way - // the app's seed does, so the pin below is OPENALLBAGS's actual default. + // `GetBindingKey` reads the registered command set, seeded as the app seeds it. s.register_bindings(&crate::bindings::registry_commands()); s.set_screen_size(1024.0, 768.0); for file in BAG_UI { load_xml(&s, file); } - load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon + load_xml(&s, "ScrollTemplates.xml"); // our scroll kit load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); s.resolve(); - // The backpack: its label plus OPENALLBAGS's live key (default B; 0997's divergence note). hover(&mut s, "MainMenuBarBackpackButton"); let line = s .eval::("return GameTooltipTextLeft1:GetText()") @@ -1466,7 +1332,6 @@ fn the_bar_bag_buttons_name_themselves_on_hover() { line.starts_with("Backpack") && line.contains("(B)"), "the backpack names itself and its TOGGLEBACKPACK key: {line:?}" ); - // Beside the button, not the default corner — the ref's ANCHOR_LEFT. assert!( s.eval::("return GameTooltip.default == nil").unwrap(), "a bag button's plate is owner-anchored, never the default corner" @@ -1477,7 +1342,6 @@ fn the_bar_bag_buttons_name_themselves_on_hover() { "…owned by the button it opened from" ); - // An empty bag slot falls back to the ref's EQUIP_CONTAINER rather than showing nothing. hover(&mut s, "CharacterBag0Slot"); assert_eq!( s.eval::("return GameTooltipTextLeft1:GetText()") @@ -1486,8 +1350,8 @@ fn the_bar_bag_buttons_name_themselves_on_hover() { "an empty slot says what belongs in it" ); - // With a bag actually equipped there, the ref shows that BAG's own item tooltip instead — the - // SetInventoryItem arm. Bar slot 1 is inventory slot 20 (Bag0Slot). + // An equipped bag shows its own item tooltip (the `SetInventoryItem` arm); bar slot 1 is + // inventory slot 20. let mut inv: benilla_ui::script::InventorySlots = Default::default(); inv[20] = Some(benilla_ui::script::InvSlotView { duration_ms: None, @@ -1529,15 +1393,14 @@ fn the_bar_bag_buttons_name_themselves_on_hover() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// One keyring container fed as the app feeds it: container −2 sized by the level ladder, with a -/// key in the first slot. `size` is the count `keyring_size(level)` would give. +/// A keyring container (id -2) fed as the app feeds it, `size` being what `keyring_size(level)` +/// gives, with a key in slot 1 when `occupied`. fn keyring(size: u32, occupied: bool) -> ContainerState { let mut slots = std::collections::HashMap::new(); if occupied { slots.insert( 1, ContainerSlot { - // The Scarlet Key — the item the director's own character carries in keyring slot 1. item_id: 7146, count: 1, quality: Some(1), @@ -1554,12 +1417,8 @@ fn keyring(size: u32, occupied: bool) -> ContainerState { } } -/// Seat a player at `level`. **The keyring's size is the REFERENCE's own level ladder now** — -/// `GetKeyRingSize` (ContainerFrame.lua l.773-786: 4, then 8 at 40, 12 at 50, 16 above 60) reads -/// `UnitLevel("player")`, where ours read the container's fed `num_slots` back (decision 0765). -/// Both derive from the same ladder the server enforces (`Player::GetMaxKeyringSize`), so this is -/// the swap becoming FAITHFUL rather than a behaviour change — but the level is now the input, and -/// a fixture that leaves it 0 gets a 4-slot ring. +/// Seat a player at `level`: stock `GetKeyRingSize` (`ContainerFrame.lua:773-786`: 4, then 8 at +/// 40, 12 at 50, 16 above 60) reads `UnitLevel("player")`, so a fixture left at 0 gets 4 slots. fn seat_player_at_level(s: &mut UiScript, level: u32) { let mut player = benilla_ui::script::UnitState { level, @@ -1569,9 +1428,7 @@ fn seat_player_at_level(s: &mut UiScript, level: u32) { s.set_unit("player", Some(player)); } -/// How many drawn quads carry a texture path containing `needle`. The engine exposes no -/// `GetTexture`, so "which art is this frame wearing" is asked of the resolved draw list — which is -/// the stronger question anyway (it answers what would actually be on screen). +/// How many drawn quads carry a texture path containing `needle`. fn drawn_with(s: &mut UiScript, needle: &str) -> usize { s.resolve(); s.extract() @@ -1580,27 +1437,21 @@ fn drawn_with(s: &mut UiScript, needle: &str) -> usize { .count() } -/// Load the whole bar+bag surface the keyring spans: its button seats on the action bar (BAG_UI -/// carries ActionBar.xml) and its window is an ordinary `ContainerFrame` at `KEYRING_CONTAINER` -/// since decision 1751, so the reference's own file is load-bearing too. `ErrorsFrame.xml` is the -/// extra: `PutKeyInKeyRing` reports a full ring through `UIErrorsFrame`. +/// The bar and bag files the keyring spans, plus `UIErrorsFrame.xml`: `PutKeyInKeyRing` reports a +/// full ring through `UIErrorsFrame`. fn keyring_surface(s: &UiScript) { for file in BAG_UI { load_xml(s, file); } - load_xml(s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon + load_xml(s, "ScrollTemplates.xml"); // our scroll kit load_xml(s, "Interface\\FrameXML\\CharacterFrameTemplates.xml"); load_xml(s, "Interface\\FrameXML\\MerchantFrame.xml"); load_xml(s, "Interface\\FrameXML\\UIErrorsFrame.xml"); } -/// **The gate** (decision 0765): no key ⇒ no keyring anywhere on the bar — the button is hidden and -/// the bar's two right-hand strips wear the ordinary dwarf plate. The first key flips all of it: -/// the button appears, both strips swap to the keyring plate with the reference's own TexCoords, -/// and the performance meter slides from −227 to −235 to clear the new socket -/// (ref MainMenuBar_UpdateKeyRing, MainMenuBar.lua l.174-183). -/// -/// This is the director-reported symptom itself: a character holding a key saw no keyring at all. +/// The first key shows the keyring button, swaps the bar's two right-hand strips to the keyring +/// plate and slides the performance meter from -227 to -235 (`MainMenuBar_UpdateKeyRing`, +/// `MainMenuBar.lua:174-183`). #[test] fn the_first_key_puts_the_keyring_on_the_bar() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1609,7 +1460,6 @@ fn the_first_key_puts_the_keyring_on_the_bar() { keyring_surface(&s); s.set_money(0); - // Keyless: the button is hidden and the plate is the dwarf one. s.set_has_key(false); s.run("MainMenuBar_UpdateKeyRing()").unwrap(); assert!( @@ -1622,8 +1472,7 @@ fn the_first_key_puts_the_keyring_on_the_bar() { "no key ⇒ every bar strip wears the ordinary dwarf plate" ); - // A key lands. The app pushes HasKey and the wire's BAG_UPDATE reaches the button's OnEvent — - // the exact runtime path, not a direct call. + // A key lands: `HasKey`, then `BAG_UPDATE` to `MainMenuBarArtFrame`'s `OnEvent`, as at runtime. s.set_has_key(true); s.set_container(-2, Some(keyring(8, true))); s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]); @@ -1650,11 +1499,8 @@ fn the_first_key_puts_the_keyring_on_the_bar() { "{strip} keeps the reference's own band — got {t}..{b}, want {top}..{bottom}" ); } - // Geometry cross-check against the reference's own chain. The ref seats its backpack button at - // BOTTOMRIGHT (-6, 2) and steps 37px buttons left with -5 gaps, putting KeyRingButton's left - // edge at -234 from the bar's right; ours steps 36px buttons with -6 gaps and lands at -235. - // Within a pixel of the real bar — which is the check that matters, because the socket the - // button sits in is PAINTED INTO the keyring plate and cannot be nudged to meet it. + // The stock bar puts `KeyRingButton`'s left edge 234 from the bar's right (backpack at -6, + // 37-wide buttons, -5 gaps), into the socket painted on the keyring plate. let bar_right = s.eval::("return MainMenuBar:GetRight()").unwrap(); let button_left = s.eval::("return KeyRingButton:GetLeft()").unwrap(); assert!( @@ -1663,7 +1509,7 @@ fn the_first_key_puts_the_keyring_on_the_bar() { bar_right - button_left ); - // The performance meter clears the new socket (ref l.180: -227 → -235). + // The performance meter clears the new socket (`MainMenuBar.lua:180`: -227 to -235). let perf_right = s .eval::("return MainMenuBarPerformanceBarFrame:GetRight()") .unwrap(); @@ -1674,10 +1520,8 @@ fn the_first_key_puts_the_keyring_on_the_bar() { bar_right - perf_right ); - // And it does NOT revert: the reference's latch is one-way — `MainMenuBar_UpdateKeyRing` - // only ever Shows, and SHOW_KEYRING is a saved variable (stock MainMenuBar.lua:174-183, - // MainMenuBar.xml:323-336). Our file used to take the button away with the last key; that - // divergence went with the file (1938). + // The latch is one-way: `MainMenuBar_UpdateKeyRing` only ever shows the button, and + // `SHOW_KEYRING` is a saved variable (`MainMenuBar.lua:174-183`, `MainMenuBar.xml:323-336`). s.set_has_key(false); s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]); assert!( @@ -1687,9 +1531,8 @@ fn the_first_key_puts_the_keyring_on_the_bar() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// The window itself: clicking the button opens a container titled "Keyring", stitched from the -/// `-Keyring` plate, showing exactly the level-gated slot count — and it jingles rather than -/// rustling (KeyRingOpen/KeyRingClose, ref ContainerFrame.lua l.116-138). +/// The keyring button opens a container titled "Keyring", stitched from the `-Keyring` plate, with +/// the level-gated slot count and its own sounds (`ContainerFrame.lua:116-138`). #[test] fn the_keyring_button_opens_a_keyring_window() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1698,8 +1541,7 @@ fn the_keyring_button_opens_a_keyring_window() { keyring_surface(&s); s.set_money(0); s.set_has_key(true); - // A level-44 character: 8 usable slots (the ladder's 40..49 rung). The LEVEL is what decides - // now — see `seat_player_at_level` — and the feed carries the same 8 so the two agree. + // Level 44: 8 slots (the 40..49 rung). The level decides; the feed carries the same 8. seat_player_at_level(&mut s, 44); s.set_container(-2, Some(keyring(8, true))); s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]); @@ -1724,9 +1566,6 @@ fn the_keyring_button_opens_a_keyring_window() { drawn_with(&mut s, "UI-Bag-Components-Keyring") > 0, "stitched from the keyring plate, not the ordinary bag sheet" ); - // 8 usable slots: the window is GENERATED at the keyring's own size, so exactly 8 of the - // template's 36 item buttons are shown — the reference's own - // `for j = size + 1, MAX_CONTAINER_ITEMS do …:Hide() end` tail, no keyring-specific code. let shown = s .eval::(&format!( "local n = 0 for i = 1, MAX_CONTAINER_ITEMS do \ @@ -1734,7 +1573,6 @@ fn the_keyring_button_opens_a_keyring_window() { )) .unwrap(); assert_eq!(shown, 8, "only the level-unlocked keyring slots are drawn"); - // The key is in the window (slot 1 of 8 is the LAST chain button — size - physIndex + 1). assert_eq!( s.eval::("return BenillaGetContainerItemID(KEYRING_CONTAINER, 1)") .unwrap(), @@ -1750,9 +1588,8 @@ fn the_keyring_button_opens_a_keyring_window() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// Dropping a held key on the button files it in the first free slot (ref PutKeyInKeyRing), and a -/// full keyring refuses out loud — where the reference prints nothing at all, having passed a -/// GlobalStrings name that was never defined. +/// Dropping a held key on the button files it in the first free keyring slot (stock +/// `PutKeyInKeyRing`); a full keyring refuses the drop. #[test] fn a_key_dropped_on_the_button_files_itself() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1762,7 +1599,7 @@ fn a_key_dropped_on_the_button_files_itself() { s.set_money(0); s.set_has_key(true); - // A 4-slot keyring with slot 1 taken, and the key on the cursor (picked out of the backpack). + // A 4-slot keyring with slot 1 taken, and a key picked out of the backpack. s.set_container(-2, Some(keyring(4, true))); s.set_container( 0, @@ -1796,10 +1633,8 @@ fn a_key_dropped_on_the_button_files_itself() { "filed into the FIRST FREE keyring slot (1 is taken), not the backpack" ); - // Now full: the click refuses with the error line instead of queueing anything. - // Every slot needs a TEXTURE, not just an item id: the reference's `PutKeyInKeyRing` decides - // "is this slot free" from `GetContainerItemInfo`'s first return, which is the icon path - // (ContainerFrame.lua l.744-746). A slot carrying an id and no texture reads EMPTY. + // Full: each slot needs a texture, since `PutKeyInKeyRing` reads a slot as free from + // `GetContainerItemInfo`'s first return, the icon (`ContainerFrame.lua:744-746`). let mut full = keyring(4, true); for slot in 2..=4 { full.slots.insert( @@ -1814,15 +1649,8 @@ fn a_key_dropped_on_the_button_files_itself() { } s.set_container(-2, Some(full)); s.run("PickupContainerItem(0, 3)").unwrap(); - // The refusal is the REFERENCE's own `PutKeyInKeyRing`, reporting through the name its own - // line asks for — `NO_EMPTY_KEYRING_SLOTS`, which 1.12 never defines. So the drop is refused - // and **nothing is printed**: a real 1.12 bug, and faithful here on purpose. - // - // This client un-quirked it once, by assigning the string the reference plainly meant. Window - // 1 of 1751 retired that (ContainerFrameAdapters.xml's header records it), but the assignment - // itself outlived the decision in `BagFrame.xml` for a window and a half. Window 3 deleted that - // file, so the stated posture and the behaviour agree again. Restoring the repair is one line - // in the adapters if it is ever wanted; it is not silent either way. + // Stock `PutKeyInKeyRing` reports through `NO_EMPTY_KEYRING_SLOTS`, which 1.12 never defines + // (`ContainerFrame.lua:753`), so nothing prints: a 1.12 bug, kept. s.run("KeyRingButton:Click()").unwrap(); assert!( s.take_container_moves().is_empty(), @@ -1837,22 +1665,14 @@ fn a_key_dropped_on_the_button_files_itself() { "the reference's own dangling string name prints nothing — un-quirking it is a decision, \ not a default" ); - // And it does not RAISE either, which is the half this test could not assert until the bytes - // came back. `AddMessage`'s text is fetched through `lua_isstring 0x6f3510` whose failure edge - // is a silent jump to the function's own epilogue (`0x79562c`) — no line, no error. Ours took - // an mlua `String` and raised, which put a script-error dialog on an ordinary player gesture; - // the fix is in `messageframe/mod.rs::message_text` and its own test carries the full law. - // - // So a full keyring is a DEAD CLICK on a real 1.12 client, and now here. + // Nor does it raise: `AddMessage` reads its text through `lua_isstring` (`0x6f3510`), whose + // failure edge jumps to the function's epilogue (`0x79562c`). assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// The item-push drop animation is the reference's own `` 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. +/// Stock `ItemAnim_OnEvent` puts the pushed icon on the pane of the button owning that slot and +/// plays sequence 0 once; `ItemAnim_OnAnimFinished` hides it. The pane authored no size, so its +/// rect is the file's bounding box in layout units. #[test] fn an_item_push_drops_its_icon_into_the_bag_that_took_it() { use benilla_ui::widget::{ModelFileFacts, SequenceFacts}; @@ -1863,13 +1683,12 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() { for file in BAG_UI { load_xml(&s, file); } - load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon + load_xml(&s, "ScrollTemplates.xml"); // our scroll kit load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml"); 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. + // `ForcedBackpackItem.m2` (`benilla-extract m2seq`, `m2batch`): one sequence (id 0, 1000 ms, + // clamp) and a 0.02707 × 0.07962 box, which is 42.41 × 124.72 at 16:9, where a layout unit is + // 768·√(a²+1) = 1566.4 FrameXML units. const CARD: &str = r"Interface\ItemAnimations\ForcedBackpackItem.mdx"; s.set_model_facts( CARD, @@ -1913,7 +1732,6 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() { .collect() }; - // Nothing is animating until a push arrives. for b in [ "MainMenuBarBackpackButton", "CharacterBag1Slot", @@ -1923,9 +1741,8 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() { } 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. + // `arg1` is the inventory-slot id `ItemAnim_OnEvent` compares against the button's: bag 2 is + // `CharacterBag1Slot`, 21. s.fire_event( "ITEM_PUSH", vec![ @@ -1951,8 +1768,7 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() { )], "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. + // The implicit rect hangs off the button's BOTTOMRIGHT (-10, 0), as the stock template anchors. let (w, h): (f32, f32) = s .eval("return CharacterBag1SlotItemAnim:GetWidth(), CharacterBag1SlotItemAnim:GetHeight()") .unwrap(); @@ -1971,10 +1787,9 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() { "BOTTOMRIGHT (−10, 0) off the button: ({dx}, {dy})" ); - // The clock runs the file's clamp: half a second in, the play head is at 500 ms… + // The clock runs the file's 1000 ms clamp. 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"), @@ -1982,7 +1797,6 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_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![ @@ -1999,7 +1813,7 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() { )] ); - // The keyring is a real destination, not a rounding of the backpack. + // The keyring (-2) is a destination of its own. s.fire_event( "ITEM_PUSH", vec![ @@ -2016,23 +1830,8 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// **An addon that replaces `ToggleBackpack` gets the bag button's click.** -/// -/// This is how a 1.12 addon customises anything — replace a global, expect the client to call your -/// replacement. `Bagnon_Core/core/Overrides.lua` is the canonical shape: -/// -/// ```lua -/// local bToggleBackpack = ToggleBackpack -/// ToggleBackpack = function() … end -/// ``` -/// -/// It was inert here. The 'B' binding and the bag button called `BenillaBagToggle_OnClick` -/// directly, so nothing ever looked up `ToggleBackpack`; Bagnon loaded, showed up in the AddOns -/// list, replaced the globals, and the player still got the stock bags. **The director saw that and -/// no instrument here could** — the corpus survey loads addons and fires events, but never clicks. -/// -/// The class is much wider than one addon: every hook-a-global addon is silently inert wherever our -/// UI calls a benilla-named equivalent. This test pins the bag path; the rule is general. +/// An addon that replaces the global `ToggleBackpack`, as Bagnon's `Overrides.lua` does, receives +/// the backpack button's click and suppresses the stock toggle. #[test] fn an_addon_that_hooks_toggle_backpack_receives_the_click() { let _data = benilla_formats::wow_data_or_skip!(); @@ -2041,12 +1840,12 @@ fn an_addon_that_hooks_toggle_backpack_receives_the_click() { for file in BAG_UI { load_xml(&s, file); } - load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon + load_xml(&s, "ScrollTemplates.xml"); // our scroll kit load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); s.set_money(0); - // Bagnon's exact idiom: capture the original, replace the global. + // Bagnon's idiom: capture the original, replace the global. s.run( r#" HOOK_RAN = 0 @@ -2056,7 +1855,6 @@ fn an_addon_that_hooks_toggle_backpack_receives_the_click() { ) .unwrap(); - // The button's own OnClick path — what the B binding and a plain backpack-button click run. s.run("MainMenuBarBackpackButton:Click()").unwrap(); assert_eq!( @@ -2064,13 +1862,11 @@ fn an_addon_that_hooks_toggle_backpack_receives_the_click() { 1, "the click must reach the addon's replacement, not our own function" ); - // And the addon's override SUPPRESSED the stock behaviour, which is the point of hooking. assert!( !open(&s, 0), "the stock bag must not open when an addon has taken the verb over" ); - // The reference's other two names exist and are callable by an addon that wants the original. assert!( s.eval::("return type(OpenAllBags) == 'function' and type(ToggleBag) == 'function'") .unwrap(), @@ -2078,19 +1874,9 @@ fn an_addon_that_hooks_toggle_backpack_receives_the_click() { ); } -/// **The equipped-bag slots carry the REFERENCE's names, and so do their icons.** -/// -/// Ours were `BenillaBagBarSlot1..4`; the reference's are `CharacterBag0Slot..3` — 0-based, so -/// `CharacterBag0Slot` IS bag 1. Eight corpus addons index those names and found nil, including -/// **Bagnon**, which does `getglobal("CharacterBag0Slot"):GetScript("OnClick")`. -/// -/// The same class as the `ToggleBackpack` bug the director found: our own name where the reference -/// has one, so every addon reaching for it is silently inert. -/// -/// A Lua alias would not have been enough. `Bartender2/Alias.lua:352` takes -/// `CharacterBag0SlotIconTexture` — a name DERIVED from the frame's own, which only a real rename -/// produces. The icon is `$parentIconTexture` now, matching the reference's -/// `PaperDollItemSlotButtonTemplate`. +/// The bag slots carry the stock names, `CharacterBag0Slot` to `CharacterBag3Slot` (0-based, so +/// `CharacterBag0Slot` is bag 1), and their icons the derived `$parentIconTexture`: Bagnon reads +/// `CharacterBag0Slot`'s `OnClick`, and `Bartender2/Alias.lua:352` takes the icon's name. #[test] fn the_bag_slots_carry_the_references_names_and_icon_names() { let _data = benilla_formats::wow_data_or_skip!(); @@ -2099,7 +1885,7 @@ fn the_bag_slots_carry_the_references_names_and_icon_names() { for file in BAG_UI { load_xml(&s, file); } - load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon + load_xml(&s, "ScrollTemplates.xml"); // our scroll kit load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); @@ -2118,12 +1904,8 @@ fn the_bag_slots_carry_the_references_names_and_icon_names() { ); } - // 0-based: CharacterBag0Slot is bag 1, and the off-by-one is the whole trap. Asked the way the - // reference asks it, which is the only way there is now: the bar is stock - // `MainMenuBarBagButtons.xml` since 1751's third window, its buttons carry no `bagId` field, - // and every one of its handlers recomputes `this:GetID() - CharacterBag0Slot:GetID() + 1` at - // the call site. The ids themselves come from `GetInventorySlotInfo("Bag0Slot")` = 20 through - // the sourced `PaperDollItemSlotButton_OnLoad`, which is what makes the arithmetic work. + // The stock handlers translate with `this:GetID() - CharacterBag0Slot:GetID() + 1`; the ids + // are inventory slots, `GetInventorySlotInfo("Bag0Slot")` being 20. assert_eq!( s.eval::("return CharacterBag0Slot:GetID()").unwrap(), 20, @@ -2136,7 +1918,6 @@ fn the_bag_slots_carry_the_references_names_and_icon_names() { "the reference's own translation: the fourth button is bag 4" ); - // Bagnon's exact reach. assert!( s.eval::("return getglobal('CharacterBag0Slot'):GetScript('OnClick') ~= nil") .unwrap(), @@ -2144,15 +1925,8 @@ fn the_bag_slots_carry_the_references_names_and_icon_names() { ); } -/// The backpack button's checked law after the stated divergence (BenillaBagToggle_OnClick's -/// comment): checked belongs to the WINDOWS' OnShow/OnHide writes, and the button's XML click -/// wrapper only undoes the CheckButton widget's pre-handler flip. -/// -/// Driven through REAL input (the widget flip fires only on real clicks) over the case 1494 -/// changed: backpack shut with another bag's window open. That used to be the close-all arm — -/// nothing transitioned the backpack and only the undo kept the button from ending lit-while-shut. -/// It is now the OPEN arm, so the pin runs the whole cycle: the click opens bag 0 beside bag 1 and -/// its OnShow lights the button, the next click closes everything and the OnHide clears it. +/// `ContainerFrame_OnShow`/`OnHide` write the ring, and stock `BackpackButton_OnClick` re-derives +/// it after the CheckButton's own flip. #[test] fn the_backpack_buttons_ring_follows_its_own_window_through_real_clicks() { let _data = benilla_formats::wow_data_or_skip!(); @@ -2161,12 +1935,11 @@ fn the_backpack_buttons_ring_follows_its_own_window_through_real_clicks() { for file in BAG_UI { load_xml(&s, file); } - load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon + load_xml(&s, "ScrollTemplates.xml"); // our scroll kit load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml"); load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); s.set_money(0); - // The reference's `ToggleBag` opens nothing for a container of size 0, so the backpack has to - // be fed — our own windows existed statically and opened regardless. + // Stock `ToggleBag` opens nothing for a size-0 container, so the backpack is fed. s.set_container( 0, Some(benilla_ui::script::ContainerState { @@ -2187,12 +1960,11 @@ fn the_backpack_buttons_ring_follows_its_own_window_through_real_clicks() { s.eval::("return MainMenuBarBackpackButton:GetChecked() and true or false") .unwrap() }; - // Bag 1's window alone is open; the backpack window and its button are dark. s.run("ToggleBag(1)").unwrap(); assert!(!open(&s, 0)); assert!(!checked(&mut s)); - // A REAL click on the backpack button (the widget flip fires only on real input): close-all. + // A real click: the CheckButton's own flip fires only on real input. s.resolve(); let r: Vec = s .eval( @@ -2213,8 +1985,7 @@ fn the_backpack_buttons_ring_follows_its_own_window_through_real_clicks() { "open ⇒ lit, written by the window's OnShow over the widget flip" ); - // The second real click: bag 0 is open now, so this is the close-all arm — both windows go, - // and the OnHide write clears the ring. + // Bag 0 is open now, so the second click is the close-all arm. s.mouse_button(r[0], r[1], "LeftButton", true); s.mouse_button(r[0], r[1], "LeftButton", false); assert!(!open(&s, 0)); diff --git a/crates/benilla-app/src/ui_script/bagnon_render_tests.rs b/crates/benilla-app/src/ui_script/bagnon_render_tests.rs index 5033d43a..dba55106 100644 --- a/crates/benilla-app/src/ui_script/bagnon_render_tests.rs +++ b/crates/benilla-app/src/ui_script/bagnon_render_tests.rs @@ -1,27 +1,5 @@ -//! **"Only the frame header and the gold text. No bag slots at all."** — the director's Bagnon -//! report, reproduced end to end and then closed. -//! -//! ## Why this file exists -//! -//! The addon survey scored Bagnon `loaded, missing=0, session=ok, probe=ok` — every column green — -//! while the window on the director's screen was empty. Every one of those columns asks whether -//! something *raised*; none of them asks whether anything was **drawn**. Two independent faults -//! were hiding in that gap, and only one of them raises at all: -//! -//! | | what it does | does it raise? | -//! |---|---|---| -//! | `UnitName("player")` is nil at addon file scope | Bagnon reads the live bags as another character's offline cache → **0 slots**, window shown | **no** | -//! | the `SetItemButton*` family is missing | the per-slot update dies on the first slot → loop aborts, window never shown | yes, once | -//! -//! The director saw the first (their `Bagnon_Core` loaded at startup, so `currentPlayer` was -//! captured nil). Fix only that and you get the second. So the tests below pin **both**, and the -//! headline is a quad count, not an error count: [`bagnon_draws_a_slot_for_every_bag_slot`] is -//! green only when every live slot's quad actually reaches the render list — the backpack's 16 -//! and, since the reference's own `ContainerFrame.lua` runs off the chain (1751), the keyring's 12 -//! beside them (Bagnon's default bag set is `{-2, 0, 1, 2, 3, 4}`, `Bagnon.lua:25`). -//! -//! Nothing from the corpus is committed, and every test here skips cleanly on a machine without -//! it — the `ui_chat::ace_gate_tests` rule. +//! Bagnon against the addon corpus, driven as a player drives it: every live bag slot must be +//! drawn (an empty window raises nothing), and every gesture must run without raising. use std::path::Path; @@ -29,20 +7,8 @@ use benilla_formats::addon_corpus_or_skip as corpus_or_skip; use benilla_ui::script::{AddOnInfo, ContainerSlot, ContainerState, QuadContent, UiScript}; use benilla_ui::toc::Toc; -/// The corpus **and** a client install — for the tests whose subject is a global that comes from -/// the reference file this client SOURCES off the player's own patch chain rather than shipping -/// ([`super::reference_ui`], decision 1234). -/// -/// `corpus_or_skip!` alone is the wrong precondition for them. With the corpus present but no -/// install, `ContainerFrameItemButton_OnEnter`/`_OnClick` are *legitimately* nil — that is the -/// documented "no install, no file" behaviour, not a defect — and the test then fails for a reason -/// that is not a bug. `reference_ui`'s own header already says tests that need the file gate on the -/// install the way every other client-data test does; these four never got that gate when 1234 -/// added the sourcing seam and the tests in one landing. -/// -/// It stayed invisible because it only bites where the install is *not* found, which on a dev -/// machine is only `scripts/gates.sh`'s `player-tests` rung: `--no-default-features` compiles out -/// the `dev` project-folder candidate, so the `WoW` symlink beside the worktree stops being visible. +/// The corpus and a client install, for tests of a stock global sourced off the player's chain +/// ([`super::reference_ui`]), which is nil without an install. macro_rules! corpus_and_install_or_skip { () => {{ let _data = benilla_formats::wow_data_or_skip!(); @@ -57,8 +23,7 @@ fn read_toc(root: &Path, name: &str) -> Toc { )) } -/// One addon's `.toc` files through the same two arms the real loader uses — `.lua` as a chunk, -/// anything else as FrameXML — with the AddOns root as the provider's path space (decision 1186). +/// One addon's `.toc` files as the loader runs them: `.lua` as a chunk, anything else as FrameXML. fn load_addon_files(script: &UiScript, root: &Path, name: &str) -> Vec { let toc = read_toc(root, name); let provider = |req: &str| -> Option> { std::fs::read(root.join(req)).ok() }; @@ -70,7 +35,7 @@ fn load_addon_files(script: &UiScript, root: &Path, name: &str) -> Vec { continue; }; if file.to_ascii_lowercase().ends_with(".lua") { - // Named as the client names it — the corpus parses its own tracebacks. + // Named as the client names it: the corpus parses its own tracebacks. if let Err(e) = script.run_chunk_named(&bytes, &benilla_ui::script::addon_chunk_name(name, file)) { @@ -89,9 +54,8 @@ fn load_addon_files(script: &UiScript, root: &Path, name: &str) -> Vec { errors } -/// The director's exact installed set. Bagnon and Bagnon_Options are `## LoadOnDemand: 1`, so the -/// startup walk skips them and `LoadAddOn` brings them in — which is why the registry below -/// carries a real root. +/// The installed set. Bagnon and Bagnon_Options are `LoadOnDemand`: the startup walk skips them and +/// `LoadAddOn` loads them, which is why the registry carries a real root. const INSTALLED: &[&str] = &[ "!OmniCC", "Bagnon", @@ -101,19 +65,16 @@ const INSTALLED: &[&str] = &[ "MapCoords", ]; -/// Whether the VM gets its `"player"` before the addons load — i.e. whether -/// [`super::seat_from_roster`] ran. +/// Whether the VM has its `"player"` ([`super::seat_from_roster`]) before the addons load. #[derive(Clone, Copy, PartialEq, Eq)] enum Seat { - /// What the app does now: the roster row goes in ahead of `load_ingame_ui`. + /// The app's order: the roster row goes in ahead of `load_ingame_ui`. BeforeAddons, - /// What it did before the fix: nothing writes `"player"` until `ui_unit::feed_units` sees the - /// self descriptor, seconds later. + /// Nothing writes `"player"` until `ui_unit::feed_units` sees the self descriptor. AfterAddons, } -/// The roster row the director's session would carry, and the same struct the app reads — -/// so the seat under test is built by [`super::seat_from_roster`] itself, not by a copy of it. +/// A roster with a pending pick, for [`super::seat_from_roster`] itself to seat. fn roster() -> crate::char_select::Roster { let row = benilla_protocol::Character { guid: 7, @@ -143,14 +104,9 @@ fn roster() -> crate::char_select::Roster { crate::char_select::Roster::with_pending_pick(vec![row], 7) } -/// The `"player"` snapshot the self descriptor builds when it lands — what `ui_unit::feed_units` -/// pushes (its `"player"` leg) and fires `PLAYER_ENTERING_WORLD` off. It is the roster seat plus -/// the fields only the descriptor can say: the unit EXISTS, at the roster's level, with health -/// and a live object. [`super::seat_from_roster`] deliberately leaves those at nil/0 (decision -/// 2263: the reference's `UnitExists("player")` is nil and `UnitLevel("player")` is 0 before the -/// object exists), so the seat can no longer stand in for the descriptor the way it did from 1230 -/// until then — a fixture that never pushes this stays in the pre-object window for good, which -/// is a state the real client is never in when a player opens their bags. +/// The `"player"` snapshot the self descriptor builds, as `ui_unit::feed_units` pushes it: the +/// roster seat plus what only the descriptor says (the unit exists, its level, health, a live +/// object). The seat leaves those nil and 0, as the reference answers before the object exists. fn descriptor_snapshot(seat: &benilla_ui::script::UnitState) -> benilla_ui::script::UnitState { benilla_ui::script::UnitState { exists: true, @@ -166,8 +122,7 @@ fn descriptor_snapshot(seat: &benilla_ui::script::UnitState) -> benilla_ui::scri } } -/// A VM shaped like the director's session: our whole interface, their installed addons walked in -/// dependency order the way `addons::load_third_party` walks them, and a real backpack. +/// A session's VM: the whole interface, `INSTALLED` walked in dependency order, a real backpack. fn seat(root: &Path, seat: Seat) -> UiScript { let mut s = UiScript::new().expect("VM"); s.set_screen_size(1024.0, 768.0); @@ -180,10 +135,8 @@ fn seat(root: &Path, seat: Seat) -> UiScript { s.set_realm_name("Harness"); let player = super::seat_from_roster(&roster()).expect("a pending pick seats a player"); if seat == Seat::BeforeAddons { - // The real entry load seeds the player RECORD beside the seat (`lifecycle.rs`, decisions - // 2261/2263); this one push does both, by `set_unit`'s keep-in-step rule — a `"player"` - // push that carries a name seeds the record — so `UnitName("player")` is "Harness" at - // addon file scope, which is what sends Bagnon down its LIVE path. + // A `"player"` push with a name also seeds the player record, as the entry load does, so + // `UnitName("player")` answers at addon file scope and Bagnon reads the live bags. s.set_unit("player", Some(player.clone())); } let failures = super::load_default_ui(&s); @@ -192,14 +145,8 @@ fn seat(root: &Path, seat: Seat) -> UiScript { "our own FrameXML failed to load: {failures:#?}" ); - // A real backpack — 16 slots, one occupied. `GetContainerNumSlots(0)` answering 16 is what - // Bagnon's LIVE path reads; the whole bug is that it took a different path and never asked. - // - // The slot carries a real `item_id` and `link`, not just an icon: the render half only ever - // needed the texture, but the INTERACTION half rides `GameTooltip:SetBagItem` (which requires - // a resolved instance) and `PickupContainerItem` (which needs something to pick up), so a - // texture-only slot would make every hover and every click a silent no-op — a fixture that - // hides the very thing the test is for. + // A 16-slot backpack with one item, carrying an id and a link: `GameTooltip:SetBagItem` and + // `PickupContainerItem` need both, and an icon alone makes every hover and click a no-op. let mut slots = std::collections::HashMap::new(); slots.insert( 1, @@ -226,19 +173,13 @@ fn seat(root: &Path, seat: Seat) -> UiScript { for name in INSTALLED { walk(&mut s, root, name, &mut done, &mut registry); } - // Re-registered with the startup set marked loaded, so `LoadAddOn` below meets satisfied - // dependencies exactly as it does in a real session. + // Re-registered with the startup set marked loaded, so `LoadAddOn` finds its dependencies met. s.register_addons(registry, Some(root.to_path_buf()), None, None); for event in ["VARIABLES_LOADED", "PLAYER_LOGIN"] { s.fire_event(event, Vec::new()); } - // The self descriptor's arrival: `ui_unit::feed_units` pushes the snapshot the descriptor - // builds and fires PLAYER_ENTERING_WORLD off it — in BOTH arms, because that is the world - // every real Bagnon open happens in. It matters: the reference's `GetKeyRingSize` - // (`ContainerFrame.lua:773-786`) reads `UnitLevel("player")`, and a fixture left in the - // pre-object window (level 0, the roster seat's answer since 2263) sizes the keyring at 4 - // where the level-60 session sizes it at 12. In the `AfterAddons` arm this is the FIRST time - // the VM has ever had a `"player"` — which is the state this file exists to reproduce. + // The self descriptor lands in both arms: stock `GetKeyRingSize` reads `UnitLevel("player")` + // (`ContainerFrame.lua:773-786`), 12 slots at level 60 and 4 before the object exists. s.set_unit("player", Some(descriptor_snapshot(&player))); s.fire_event("PLAYER_ENTERING_WORLD", Vec::new()); for _ in 0..10 { @@ -271,13 +212,9 @@ fn walk(s: &mut UiScript, root: &Path, name: &str, done: &mut Vec, reg: ); } -/// Every item-slot quad **Bagnon's own buttons** drew — the `UI-Quickslot2` ring, which is the one -/// texture an empty slot still paints (an occupied slot adds its icon on top). -/// -/// **Attributed, not counted.** Our own `BenillaBagFrame` paints sixteen of exactly this texture, -/// so a bare total reads 16 whether Bagnon drew nothing or our window did — the "an addon replaces, -/// it does not add" trap. Every quad is charged to the nearest named frame above it -/// ([`UiScript::target_owner_name`]) and only `BagnonItem*` counts. +/// The item-slot quads Bagnon's own buttons drew: the `UI-Quickslot2` ring, which even an empty +/// slot paints. Stock item and action buttons paint it too, so each quad is charged to its nearest +/// named frame ([`UiScript::target_owner_name`]) and only `BagnonItem*` counts. fn bagnon_slot_quads(s: &mut UiScript) -> usize { s.resolve(); s.extract() @@ -293,9 +230,7 @@ fn bagnon_slot_quads(s: &mut UiScript) -> usize { .count() } -/// Bagnon's window, open, with the fixture's backpack in it — the state every interaction test -/// below starts from. `ToggleBackpack` is Bagnon's own replacement, and its first call -/// demand-loads the `Bagnon` addon (the frame does not exist before that). +/// Bagnon's window open over the fixture's backpack; its `ToggleBackpack` demand-loads the addon. fn open_bagnon(root: &Path) -> UiScript { let mut s = seat(root, Seat::BeforeAddons); s.run("ToggleBackpack()").expect("ToggleBackpack"); @@ -311,9 +246,7 @@ fn open_bagnon(root: &Path) -> UiScript { s } -/// The centre of a named frame, in the y-up UI space [`UiScript::mouse_move`] / -/// [`UiScript::mouse_button`] take — the same space `resolve`/`extract` use, so a frame's own -/// `GetLeft`/`GetBottom` read straight back as a cursor position. +/// A named frame's centre, in the y-up UI space the mouse calls take. fn centre_of(s: &mut UiScript, name: &str) -> (f32, f32) { s.resolve(); let r: Vec = s @@ -326,9 +259,7 @@ fn centre_of(s: &mut UiScript, name: &str) -> (f32, f32) { (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) } -/// The name of the Bagnon item button showing backpack slot `slot`. Bagnon lays its buttons out in -/// its own order and `SetID`s each to the game slot, so the button holding the fixture's one item -/// is found by asking, never by assuming an index. +/// The Bagnon item button showing `bag`/`slot`, found by ID: Bagnon orders its buttons itself. fn bagnon_button_for_slot(s: &UiScript, bag: i64, slot: u32) -> String { s.eval::(&format!( "for i = 1, 36 do \ @@ -343,9 +274,7 @@ fn bagnon_button_for_slot(s: &UiScript, bag: i64, slot: u32) -> String { .expect("the item-button scan") } -/// Press and release the named button over `name`, the way a player's mouse does — through -/// `mouse_button`, so the whole engine path runs (hit test, `RegisterForClicks` gate, the frame's -/// own `OnClick`), never `s.run("Handler(...)")`. +/// Press and release `button` over `name` through the engine's whole mouse path, as a player does. fn click(s: &mut UiScript, name: &str, button: &str) { let (x, y) = centre_of(s, name); s.mouse_move(x, y); @@ -353,20 +282,15 @@ fn click(s: &mut UiScript, name: &str, button: &str) { s.mouse_button(x, y, button, false); } -/// **The headline, and the director's own symptom.** Press B with Bagnon installed and every one -/// of the backpack's 16 slots must reach the render list. -/// -/// This is the test that was red before the two fixes and is green after — and the *number* is the -/// point, not the absence of an error: before, this VM raised nothing at all on the path the -/// director was on. +/// Opening the bags with Bagnon installed draws every live slot. The count is the check: a window +/// with no slots raises nothing. #[test] fn bagnon_draws_a_slot_for_every_bag_slot() { benilla_formats::wow_data_or_skip!(); let root = corpus_or_skip!(); let mut s = seat(&root, Seat::BeforeAddons); - // Bagnon's own entry point: it replaces `ToggleBackpack`, and its replacement demand-loads the - // `Bagnon` addon on the first call (the frame does not exist before that). + // Bagnon's `ToggleBackpack` demand-loads the `Bagnon` addon on its first call. s.run("ToggleBackpack()").expect("ToggleBackpack"); for _ in 0..3 { s.tick(0.1); @@ -377,11 +301,9 @@ fn bagnon_draws_a_slot_for_every_bag_slot() { .unwrap(), "the window must be on screen" ); - // 28 = the backpack's 16 + the keyring's 12. Bagnon's default bag set carries the keyring - // (`Bagnon.lua:25`, `{-2, 0, 1, 2, 3, 4}`), and its size is the reference's own - // `GetKeyRingSize` ladder off `UnitLevel("player")` (`ContainerFrame.lua:773-786`: 4, 8 at - // 40, 12 at 50, 16 only above 60) — 12 for the fixture's level-60 Warrior. The four bag slots - // hold nothing, so they add nothing. + // 28: the backpack's 16 and the keyring's 12. Bagnon's bag set includes the keyring + // (`Bagnon.lua:25`), sized by stock `GetKeyRingSize` (`ContainerFrame.lua:773-786`): 4, 8 at + // 40, 12 at 50, 16 only above 60. The four bag slots are empty. assert_eq!( s.eval::("return Bagnon.size").unwrap(), 28, @@ -403,12 +325,8 @@ fn bagnon_draws_a_slot_for_every_bag_slot() { ); } -/// **The control that makes the headline mean something: reproduce the director's screen.** -/// -/// With the player seated only *after* the addons load — benilla's own order until -/// [`super::seat_from_roster`] landed — Bagnon shows a window whose title and money frame are -/// right and which contains **not one slot**, and raises nothing doing it. If this ever starts -/// drawing slots, the headline above has stopped testing the thing it was written for. +/// The control: seated after the addons load, Bagnon shows its title and money but no slot, and +/// raises nothing. If this starts drawing slots, the test above has lost its subject. #[test] fn without_a_player_at_addon_load_bagnon_draws_an_empty_window() { benilla_formats::wow_data_or_skip!(); @@ -419,7 +337,6 @@ fn without_a_player_at_addon_load_bagnon_draws_an_empty_window() { s.tick(0.1); } - // The two things the director COULD see, exactly as reported. assert!( s.eval::("return Bagnon:IsVisible()").unwrap(), "the window is up" @@ -429,7 +346,6 @@ fn without_a_player_at_addon_load_bagnon_draws_an_empty_window() { "Harness's Inventory", "…with its header" ); - // And the thing they could not. assert_eq!( bagnon_slot_quads(&mut s), 0, @@ -441,23 +357,14 @@ fn without_a_player_at_addon_load_bagnon_draws_an_empty_window() { ); } -/// The **other** fault, pinned on its own so a later regression names itself: the reference's -/// `SetItemButton*` family (`Interface\FrameXML\ItemButtonTemplate.xml`). -/// -/// Bagnon ends every slot update with `SetItemButtonDesaturated` / `SetItemButtonTexture` / -/// `SetItemButtonCount`. While those were nil the raise landed inside `BagnonFrame_AddBag`'s -/// per-slot loop, so the FIRST slot aborted it, `frame.size` never advanced past 0 and the window -/// never reached its own `frame:Show()`. That is a different picture from the one above — nothing -/// on screen at all — and it is what the identity fix alone would have produced. +/// The stock `SetItemButton*` family (`ItemButtonTemplate.lua`), which ends every Bagnon slot +/// update: a raise there aborts `BagnonFrame_AddBag`'s slot loop and the window never shows. #[test] fn the_item_button_helpers_paint_a_slots_icon_and_count() { benilla_formats::wow_data_or_skip!(); let mut s = UiScript::new().expect("VM"); s.set_screen_size(1024.0, 768.0); - // The in-game UI materializes on world entry (1051), so a player always exists by the time the - // manifest loads — and the stock macro window's character tab formats `UnitName("player")` - // into its label inside its own OnLoad. A manifest load with no player is a state the client - // never reaches (decision 1848). + // The in-game UI loads at world entry, so a player exists before the manifest loads. s.set_unit( "player", Some(benilla_ui::script::UnitState { @@ -470,7 +377,7 @@ fn the_item_button_helpers_paint_a_slots_icon_and_count() { let failures = super::load_default_ui(&s); assert!(failures.is_empty(), "our own FrameXML: {failures:#?}"); - // A button shaped the way the family's naming contract requires, built the way an addon does. + // A button with the children the family finds by name, built as an addon builds it. s.run( r#" local b = CreateFrame("Button", "ProbeItemButton", UIParent) @@ -492,9 +399,7 @@ fn the_item_button_helpers_paint_a_slots_icon_and_count() { s.run("SetItemButtonDesaturated(ProbeItemButton, 1, 0.5, 0.5, 0.5)") .expect("SetItemButtonDesaturated"); - // Attributed to the probe button, never to the whole screen: the default UI paints its own - // "1".."0" action-bar hotkeys, so a bare "is there a quad reading 1 anywhere" would answer yes - // whatever these functions did. The `!` case below is the one that would have been fooled. + // Scoped to the probe button: the action-bar hotkeys also paint "1" to "0". let count_text = |s: &UiScript, want: &str| { s.extract().iter().any(|q| { matches!(&q.content, QuadContent::Text { text, .. } if text.as_deref() == Some(want)) @@ -512,8 +417,7 @@ fn the_item_button_helpers_paint_a_slots_icon_and_count() { count_text(&s, "5"), "a stack of 5 shows its count (>1 is the reference's own gate)" ); - // The no-shader branch: benilla has no `Texture:SetDesaturated`, so a locked slot takes the - // reference's own fixed-function fallback — a flat 0.5 grey tint on the icon. + // `SetDesaturated` answers shader support, so the icon greys and keeps the caller's 0.5 tint. let tint = s .eval::>("return {ProbeItemButtonIconTexture:GetVertexColor()}") .expect("GetVertexColor"); @@ -523,7 +427,7 @@ fn the_item_button_helpers_paint_a_slots_icon_and_count() { "a locked item greys out (ref: `elseif not r or not shaderSupported`)" ); - // A count of 1 hides the label again — the reference's gate, not a one-way switch. + // A count of 1 hides the label again (`ItemButtonTemplate.lua:12`). s.run("SetItemButtonCount(ProbeItemButton, 1)").unwrap(); s.resolve(); assert!( @@ -533,16 +437,13 @@ fn the_item_button_helpers_paint_a_slots_icon_and_count() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// The seat itself, in isolation: what the roster row actually puts in the VM, and the `None` that -/// keeps a capture (no pick, no roster) unchanged. +/// What the roster seat puts in the VM, and `None` with no pick, so a capture loads unchanged. #[test] fn the_roster_seat_names_the_character_the_addons_will_meet() { let seat = super::seat_from_roster(&roster()).expect("a pending pick seats a player"); assert_eq!(seat.name.as_deref(), Some("Harness")); - // **0, not 60 — the reference's answer, byte-verified** (decision 2263). `UnitLevel 0x517fc0` - // carries no `"player"` fast path at all: it resolves the token, misses (no object, no roster - // record for a zero GUID) and reaches `0x51813e push 0; push 0` — the NUMBER 0, one return. - // The roster's real level arrives with the descriptor, within the second. + // 0, not 60: `UnitLevel` (`0x517fc0`) has no `"player"` fast path, so with no object it pushes + // the number 0 (`0x51813e`). The level arrives with the descriptor. assert_eq!(seat.level, 0); assert_eq!(seat.race_file.as_deref(), Some("Human")); assert_eq!(seat.class_file.as_deref(), Some("WARRIOR")); @@ -552,18 +453,11 @@ fn the_roster_seat_names_the_character_the_addons_will_meet() { Some("Alliance"), "nil here is 24 corpus addons stopping on AceDB-2.0's file-scope concatenation" ); - // **`exists` is false, and that is the correction 2263 made** (it was `true` from 1230 until - // then). `UnitExists 0x515fb0` has no fast path either; its resolver reads the GUID out of the - // OBJECT (`0x515994`), so with none it holds `0:0`, and the roster fallback `0x491900` bails - // on a zero GUID at `0x4e80aa je` BEFORE fetching the active player — so the `0 == 0` that - // would answer "that's me" never runs, and `0x516001` pushes nil. - // - // Safe to correct only *because* of the record above: 1230 seated a whole unit in order to - // deliver a name at addon file scope, and the name no longer needs a unit to exist. The rest - // of the seat is untouched and still load-bearing — the faction side below above all. + // `UnitExists` (`0x515fb0`) takes the GUID off the object (`0x515994`); with none, the roster + // fallback (`0x491900`) bails on the zero GUID (`0x4e80aa`) and it pushes nil (`0x516001`). assert!(!seat.exists); assert!(seat.is_player); - // Deliberately NOT invented — the descriptor says these, within the second. + // Left for the descriptor to say. assert_eq!((seat.health, seat.max_health), (0, 0)); assert!( @@ -572,25 +466,9 @@ fn the_roster_seat_names_the_character_the_addons_will_meet() { ); } -// ───────────────────────────────────────────────────────────────────────────────────────────── -// The INTERACTION half — the layer the render half could not see -// ───────────────────────────────────────────────────────────────────────────────────────────── -// -// The tests above prove Bagnon's window is *drawn*. The director's next report was that it is -// **dead to the touch**: hover raises, click raises, and the console fills up. Every column of the -// addon survey — load, session, probe, and 1230's render column — is blind to that, for one -// structural reason: **none of them clicks**. So these do, through -// [`UiScript::mouse_move`]/[`UiScript::mouse_button`], which is the whole engine path a player's -// mouse takes (hit test → `RegisterForClicks` gate → the frame's own `OnClick`), never a direct -// `s.run("Handler(...)")` that would prove only that a function exists. -// -// `the_whole_bagnon_window_survives_being_used` below is the *enumerator*: it drives every -// gesture in one VM and asserts the error list is empty. A scan can only find what it knows to -// look for; this finds whatever is actually missing. +// ── Interaction: hover, click and drag through the engine's mouse path ── -/// **Bug 1, hover.** `Bagnon_Core/core/Item.lua:136` ends its normal-bag branch in -/// `ContainerFrameItemButton_OnEnter(item)` — the reference's own global. It was nil, so every -/// hover over every slot raised and no tooltip ever appeared. +/// `Bagnon_Core/core/Item.lua:136` ends a hover in stock `ContainerFrameItemButton_OnEnter`. #[test] fn hovering_a_bagnon_slot_shows_the_items_tooltip() { let root = corpus_and_install_or_skip!(); @@ -615,7 +493,6 @@ fn hovering_a_bagnon_slot_shows_the_items_tooltip() { "…and be on screen" ); - // …and it drops again on the way out, the template's own OnLeave. let (ex, ey) = (x, y + 400.0); s.mouse_move(ex, ey); assert!( @@ -625,9 +502,8 @@ fn hovering_a_bagnon_slot_shows_the_items_tooltip() { assert!(s.errors().is_empty(), "…without raising: {:#?}", s.errors()); } -/// **Bug 1, left click.** `Item.lua:105` routes a plain click to -/// `ContainerFrameItemButton_OnClick(mouseButton, ignoreModifiers)`, whose unmodified left arm is -/// `PickupContainerItem(this:GetParent():GetID(), this:GetID())`. The observable is the cursor. +/// `Item.lua:105` routes a plain click to the stock `ContainerFrameItemButton_OnClick`, whose left +/// arm is `PickupContainerItem(this:GetParent():GetID(), this:GetID())`. #[test] fn left_clicking_a_bagnon_slot_picks_the_item_up() { let root = corpus_and_install_or_skip!(); @@ -650,7 +526,6 @@ fn left_clicking_a_bagnon_slot_picks_the_item_up() { "the item is now on the cursor" ); - // …and clicking an empty slot puts it back down, through the same one entry point. let empty = bagnon_button_for_slot(&s, 0, 2); click(&mut s, &empty, "LeftButton"); assert_eq!( @@ -667,8 +542,7 @@ fn left_clicking_a_bagnon_slot_picks_the_item_up() { assert!(s.errors().is_empty(), "…nor the place: {:#?}", s.errors()); } -/// **Bug 1, right click.** The reference's right arm with no merchant open is -/// `UseContainerItem(bag, slot)` — use/equip the item. +/// With no merchant open, the stock right-click arm is `UseContainerItem(bag, slot)`. #[test] fn right_clicking_a_bagnon_slot_uses_the_item() { let root = corpus_and_install_or_skip!(); @@ -689,9 +563,8 @@ fn right_clicking_a_bagnon_slot_uses_the_item() { ); } -/// **Bug 1, drag.** Bagnon registers `OnDragStart`/`OnReceiveDrag` on every item button and routes -/// both to `BagnonItem_OnClick("LeftButton", 1)` → `ContainerFrameItemButton_OnClick`'s -/// `ignoreModifiers` arm. Driven as a real gesture: press, move past the threshold, release. +/// Bagnon routes `OnDragStart` and `OnReceiveDrag` to `ContainerFrameItemButton_OnClick`'s +/// `ignoreModifiers` arm; driven as press, move past the threshold, release. #[test] fn dragging_a_bagnon_slot_picks_the_item_up() { let root = corpus_and_install_or_skip!(); @@ -725,24 +598,15 @@ fn dragging_a_bagnon_slot_picks_the_item_up() { ); } -/// **Bug 2 — our own handler, called the reference's way.** Bagnon takes -/// `MainMenuBarBackpackButton:GetScript("OnEnter")` and calls it back with **no arguments** -/// (`Bagnon.lua:87`), because that is the reference's contract: an XML script body takes nothing -/// and reads the frame off the `this` global. Ours compiled to `function(self, ...)` and its body -/// passed `self` straight into `GameTooltip:SetOwner`, so the hook handed it nil: -/// -/// ```text -/// bad argument #2: error converting Lua nil to table -/// BagFrame.xml:789: in function 'BenillaBagToggle_OnEnter' -/// [string "MainMenuBarBackpackButton:OnEnter"]:2: in upvalue 'bMainBag_OnEnter' -/// ``` +/// Bagnon calls the backpack button's captured `OnEnter` back with no arguments +/// (`Bagnon.lua:87`): an XML script body reads its frame off `this`, never an argument. #[test] fn the_backpack_button_still_hovers_with_bagnon_holding_its_script() { benilla_formats::wow_data_or_skip!(); let root = corpus_or_skip!(); let mut s = open_bagnon(&root); - // Bagnon really did take the script over — otherwise this test proves nothing. + // Bagnon must hold the script, or this proves nothing. assert!( s.eval::( "return MainMenuBarBackpackButton:GetScript(\"OnEnter\") == BagnonBlizMainBag_OnEnter" @@ -766,16 +630,14 @@ fn the_backpack_button_still_hovers_with_bagnon_holding_its_script() { ); } -/// **Bug 3.** `Bagnon_Forever/database/ui.lua:61` sizes its character dropdown from -/// `button:GetTextWidth()` on a **CheckButton** — a real 1.12 Button method (`0x782290`, and -/// `GetStringWidth` is absent on Button). +/// `Bagnon_Forever/database/ui.lua:61` calls `GetTextWidth` on a CheckButton, a 1.12 Button method +/// (`0x782290`); Button has no `GetStringWidth`. #[test] fn a_button_reports_its_own_label_width() { benilla_formats::wow_data_or_skip!(); let root = corpus_or_skip!(); let mut s = open_bagnon(&root); - // Bagnon_Forever's exact shape: a CheckButton built from its own template, with a label. s.run( r#" local b = CreateFrame("CheckButton", "ProbeWidthButton", UIParent, "BagnonDBUINameBox") @@ -786,10 +648,8 @@ fn a_button_reports_its_own_label_width() { .expect("the probe button"); s.resolve(); - // Answer the host measure for the button's OWN label, and nothing else — the assertion below - // is then "the button forwarded to the right region and read the right metric", not merely - // "some number came back". A headless VM has no font atlas, so an unanswered measure is 0 and - // a `> 0` assertion would be untestable here (and unfalsifiable there). + // Answer the measure for this label alone, so the value read back proves the button forwards + // to its label; a headless VM otherwise measures 0. let req = s .fontstrings_needing_measure() .into_iter() @@ -808,9 +668,8 @@ fn a_button_reports_its_own_label_width() { .expect("Button:GetTextHeight"), 12.0 ); - // A Button with no label at all answers 0 rather than raising — the reference dereferences a - // FontString pointer here that a bare `CreateFrame("Button")` leaves null, and what it does - // then is not byte-read, so this takes the harmless number. + // A Button with no label answers 0; the reference reads a FontString pointer that a bare + // `CreateFrame("Button")` leaves null, and what it does then is untraced. s.run(r#"CreateFrame("Button", "ProbeLabellessButton", UIParent)"#) .unwrap(); assert_eq!( @@ -819,7 +678,6 @@ fn a_button_reports_its_own_label_width() { 0.0 ); - // The real consumer, end to end: Bagnon_Forever's own character list. s.run("BagnonDBUI_ShowCharacterList(Bagnon)") .expect("BagnonDBUI_ShowCharacterList"); assert!( @@ -829,9 +687,7 @@ fn a_button_reports_its_own_label_width() { ); } -/// **Bug 4.** `Bagnon_Core/core/Item.xml:34` hangs `` off every item button — the reference's own template, which -/// we had never declared, so every one of the 36 buttons warned and the child came out bare. +/// `Bagnon_Core/core/Item.xml:35` gives every item button a stock `CooldownFrameTemplate` child. #[test] fn the_reference_cooldown_template_resolves() { benilla_formats::wow_data_or_skip!(); @@ -839,13 +695,11 @@ fn the_reference_cooldown_template_resolves() { let s = open_bagnon(&root); let occupied = bagnon_button_for_slot(&s, 0, 1); - // The child exists and answers as a frame, whatever Bagnon does to it. assert!( s.eval::(&format!("return getglobal(\"{occupied}Cooldown\") ~= nil")) .unwrap(), "the template's $parentCooldown child must exist" ); - // …and the one entry point every consumer drives it through does not raise on it. s.run(&format!( "CooldownFrame_SetTimer(getglobal(\"{occupied}Cooldown\"), GetTime(), 30, 1)" )) @@ -853,23 +707,15 @@ fn the_reference_cooldown_template_resolves() { assert!(s.errors().is_empty(), "{:#?}", s.errors()); } -/// **The enumerator.** Every gesture a player makes in Bagnon's window, in one VM, with the error -/// list asserted empty at the end. -/// -/// This is the test the arc was missing. Four landings in a row fixed the errors that happened to -/// fire and left the next layer to be found by the director playing, because every instrument we -/// had asks whether something *raised at load* and none of them touches the window. A scan finds -/// what it knows to look for; this finds what is actually missing, because it does what a player -/// does. +/// Every gesture a player makes in Bagnon's window, in one VM, with no error at the end: a scan +/// finds only what it knows to look for, this finds whatever is missing. #[test] fn the_whole_bagnon_window_survives_being_used() { benilla_formats::wow_data_or_skip!(); let root = corpus_or_skip!(); let mut s = open_bagnon(&root); - // 1 · Every slot, hovered and left again — the OnEnter/OnUpdate/OnLeave loop on both an - // occupied and an empty slot, across every bag the window lays out (the keyring's slots - // come first in Bagnon's default order, the backpack's after them). + // 1 · Every slot, occupied and empty, hovered and left: OnEnter, OnUpdate and OnLeave. let size = s.eval::("return Bagnon.size").expect("Bagnon.size"); for i in 1..=size { let name = format!("BagnonItem{i}"); @@ -892,13 +738,10 @@ fn the_whole_bagnon_window_survives_being_used() { s.mouse_move(x, y); } } - click(&mut s, "BagnonShowBags", "LeftButton"); // show the bag row… + click(&mut s, "BagnonShowBags", "LeftButton"); // show the bag row - // 3 · Bagnon's OWN bag buttons (the row it just revealed — `BagnonBags0..4` plus the - // keyring's `BagnonBags-2`, from `Bagnon.xml`'s `$parent` naming): hover, click and - // drag each. `BagnonBag_OnClick` is `PutItemInBackpack`/`PutItemInBag`, `BagnonBag_OnDrag` - // is `PickupBagFromSlot` — three engine verbs that did not exist here when this test was - // written and are `script/cursor/bag_verbs.rs` now. + // 3 · Bagnon's own bag buttons, `BagnonBags0..4` and the keyring's `BagnonBags-2`: hover, + // click and drag each (`PutItemInBackpack`, `PutItemInBag`, `PickupBagFromSlot`). for id in ["0", "1", "2", "3", "4", "-2"] { let name = format!("BagnonBags{id}"); if !s @@ -918,9 +761,8 @@ fn the_whole_bagnon_window_survives_being_used() { s.mouse_button(x + 12.0, y + 12.0, "LeftButton", false); } - // 4 · The money frame's three coin buttons — `OpenCoinPickupFrame`, which Bagnon's own - // `BagnonFrameMoney_OnClick` (`Frame.lua:537-543`) calls unguarded, exactly as the - // reference's `MoneyFrame.xml` coin buttons do. + // 4 · The three coin buttons: `BagnonFrameMoney_OnClick` (`Frame.lua:537-543`) calls + // `OpenCoinPickupFrame` unguarded, as the stock `MoneyFrame.xml` coin buttons do. for coin in ["Gold", "Silver", "Copper"] { let name = format!("BagnonMoneyFrame{coin}Button"); if s.eval::(&format!( @@ -932,7 +774,7 @@ fn the_whole_bagnon_window_survives_being_used() { } } - // 5 · The bag-bar buttons Bagnon hooked on OUR bar — hover, click, and the keyring. + // 5 · The bag-bar buttons Bagnon hooks, and the keyring button: hover and click each. for name in [ "MainMenuBarBackpackButton", "CharacterBag0Slot", @@ -954,23 +796,8 @@ fn the_whole_bagnon_window_survives_being_used() { s.mouse_move(x, y + 400.0); } - // **A CLOSED LIST, not "is it empty".** Everything Bagnon reaches that benilla does not have - // is named here, so this test does two jobs at once: it holds the residual honest (each entry - // is a gap somebody decided, with a reason, not one nobody noticed), and it fails LOUDLY on - // any error class that is not on it. "Assert the list is empty" would have had to be deleted - // or `#[ignore]`d while a known gap stands, and then it would be catching nothing at all. - // - // To retire an entry: implement it and delete the line. To add one: you had better be able to - // say why, in the same sentence. - // - // **Empty, and that is the list's best state, not a reason to delete it.** Four rows sat here - // when it was written, and each retired the way the rule above says: the bag-BUTTON cursor - // trio (`PutItemInBackpack`, `PutItemInBag`, `PickupBagFromSlot` — `Bag.lua:199-215`) once - // `script/cursor/bag_verbs.rs` bound all three; and money on - // the cursor (`OpenCoinPickupFrame`, `Frame.lua:537-543`) when the money kit went stock and - // the reference's own `CoinPickupFrame.xml` joined the manifest with the money cursor under - // it (decision 1962). With nothing on the list, "unexplained" is every error, which is the - // assertion this test was always reaching for. + // A closed list of known gaps, `(name, where)`: an unlisted error fails, and so does a listed + // one that no longer raises. Each entry states its reason; empty is the list's best state. const KNOWN_GAPS: &[(&str, &str)] = &[]; let raised = s.errors(); @@ -984,7 +811,6 @@ fn the_whole_bagnon_window_survives_being_used() { unexplained.len(), unexplained ); - // …and the other direction, so a gap that quietly closes does not leave a stale entry behind. for (name, site) in KNOWN_GAPS { assert!( raised.iter().any(|e| e.contains(name)), @@ -994,9 +820,7 @@ fn the_whole_bagnon_window_survives_being_used() { } } -/// Bagnon's window with a **stacked** item in the backpack — the count FontString only paints when -/// the stack is >1 (`SetItemButtonCount`), so the plain [`open_bagnon`] fixture's single Small -/// Brown Pouch can never exercise it. +/// Bagnon's window over a stack of 200: `SetItemButtonCount` paints only above 1. fn open_bagnon_stacked(root: &Path) -> UiScript { let mut s = seat(root, Seat::BeforeAddons); let mut slots = std::collections::HashMap::new(); @@ -1027,7 +851,7 @@ fn open_bagnon_stacked(root: &Path) -> UiScript { s } -/// The resolved `QuadContent::Text` for the one named FontString, or a panic naming what was found. +/// The text quad owned by the frame named `owner`. fn text_quad(s: &mut UiScript, owner: &str) -> QuadContent { s.resolve(); s.extract() @@ -1040,18 +864,9 @@ fn text_quad(s: &mut UiScript, owner: &str) -> QuadContent { .unwrap_or_else(|| panic!("no text quad owned by {owner}")) } -/// **The director's report: "item stack size text is missing the black bg."** -/// -/// Bagnon writes `` -/// (`Bagnon_Core/core/Item.xml:14`) — the `font=` attribute, which the reference resolves against -/// the font-object registry BEFORE the filesystem (`0x783d15 call 0x783870(value, create = 0)`; -/// see [`super::super::loader`]'s `apply_fontstring_font`). We read it as a path only, so the -/// literal string `"NumberFontNormal"` became the face: no height, no colour, and no -/// `outline="NORMAL"` — and the atlas fell back silently to Friz 12. -/// -/// **The black is the OUTLINE, not a shadow.** `NumberFontNormal` carries no `` in 1.12 -/// (`Fonts.xml:226`, matching the install byte for byte), so `shadow: None` here is correct and is -/// asserted as such — a future "fix" that adds one would be a divergence, not an improvement. +/// A FontString's `font=` (`Bagnon_Core/core/Item.xml:14`) names a font object before a file: the +/// reference asks the registry first (`0x783d15` calls `0x783870`). A stack count's dark edge is +/// `NumberFontNormal`'s outline; it has no `` (`Fonts.xml:150`). #[test] fn a_stack_count_wears_the_font_object_its_font_attr_names() { benilla_formats::wow_data_or_skip!(); @@ -1072,8 +887,7 @@ fn a_stack_count_wears_the_font_object_its_font_attr_names() { }; assert_eq!(text.as_deref(), Some("200"), "the stack count is on screen"); - // Every one of these was lost while `font=` was read as a path. The face is the assertion that - // the registry was consulted at all; the outline is the one the director can SEE. + // The face proves the registry was consulted; the outline is the part that shows. assert_eq!( font.as_deref(), Some("Fonts\\ARIALN.TTF"), @@ -1097,16 +911,10 @@ fn a_stack_count_wears_the_font_object_its_font_attr_names() { ); } -/// **The director's report: the character dropdown draws as a solid WHITE box.** -/// -/// `BagnonPopupFrame` (`Bagnon_Core/core/Frame.xml:23-36`) backs itself with -/// `Interface\ChatFrame\ChatFrameBackground` — art that is white by design — and tints it -/// black→dark-grey with a ``. The loader parsed `` and `` but never -/// ``, and dropped it in silence, so the white art rendered untinted. -/// -/// `` survives beside a `file=` where `` does not, and that asymmetry is the -/// point: they land in different fields (vertex colours `+0xb8`, written by `0x77f910`, vs the -/// texture `+0xcc`, written by `0x770360`). +/// `BagnonPopupFrame` (`Bagnon_Core/core/Frame.xml:23-36`) tints white `ChatFrameBackground` art +/// with a ``; untinted, the dropdown is a white box. A `` survives beside +/// `file=` where `` does not: it writes the vertex colours (`+0xb8`, by `0x77f910`), +/// `` the texture (`+0xcc`, by `0x770360`). #[test] fn a_texture_gradient_tints_the_art_it_sits_on() { benilla_formats::wow_data_or_skip!(); @@ -1133,9 +941,8 @@ fn a_texture_gradient_tints_the_art_it_sits_on() { let QuadContent::Texture { color, .. } = bg else { unreachable!("filtered to Texture above") }; - // The two stops are (0,0,0,0.9) and (0.2,0.2,0.2,0.9); `RegionData::gradient` is folded to its - // midpoint by the paint, so this is 0.1 grey at the stops' shared alpha. `None` — or white — - // is the bug: untinted ChatFrameBackground is the white slab on the director's screen. + // The stops are (0,0,0,0.9) and (0.2,0.2,0.2,0.9). A quad carries one tint, so the paint shows + // their midpoint, 0.1 grey; the reference shades per vertex. assert_eq!( color, Some([0.1, 0.1, 0.1, 0.9]), @@ -1143,32 +950,18 @@ fn a_texture_gradient_tints_the_art_it_sits_on() { ); } -/// **The director's report: "when I first open the bags with bagnon the gold numbers are all -/// cramped up; if I close and open again it looks good."** -/// -/// Bagnon's money display is `SmallMoneyFrameTemplate`, off the chain's own `MoneyFrame.xml`. Its -/// `ShowCoin` used to size each coin with `label:GetStringWidth()`, which is served from the -/// measure round-trip and therefore reads **0 in the tick that set the text**: every coin came out -/// at exactly one icon width and the digits overlapped. The second open looked right because the -/// first open's measure had landed in the cache by then — the reopen was reading the previous open's numbers. -/// -/// The fix is the engine's font measurer answering inside the Lua call that asked -/// (`benilla_ui`'s `script::measure`): `GetStringWidth` returns a real number in the tick that set -/// the text. A digits-only stand-in — a per-digit advance feed pushed ahead from the app — shipped -/// first and was retired by the general answer (decision 1285). -/// -/// The assertion is the FIRST open, which is the half that was broken. +/// Stock `MoneyFrame_Update` sets each coin's text, then its width from `GetTextWidth()` +/// (`MoneyFrame.lua:201-208`); the font measurer answers inside that call, so the first open's +/// digits are not cramped. #[test] fn a_money_frame_is_the_right_width_on_the_first_open() { benilla_formats::wow_data_or_skip!(); let root = corpus_or_skip!(); let mut s = seat(&root, Seat::BeforeAddons); - // 12345g 67s 89c — three denominations with different digit counts, so a width that ignored - // the digits (the bug) cannot coincide with one that counts them. + // 12345g 67s 89c: three digit counts, so a width that ignores the digits cannot pass. s.set_money(123_456_789); - // The app feeds this once per atlas scale, before any window opens. A flat 8px advance makes - // the expected widths exact arithmetic rather than a font-dependent number. + // Set before any window opens, as the app does; a flat 8px advance keeps the widths exact. s.set_text_measurer(Box::new(super::FixedWidthFont(8.0))); s.run("ToggleBackpack()").expect("ToggleBackpack"); @@ -1188,7 +981,7 @@ fn a_money_frame_is_the_right_width_on_the_first_open() { ) .expect("the three coin buttons"); - // MONEY_ICON_WIDTH_SMALL is 13; the digits are 8px each. 12345 → 5, 67 → 2, 89 → 2. + // `MONEY_ICON_WIDTH_SMALL` is 13 (`MoneyFrame.lua:3`) and a digit 8px: 5, 2 and 2 digits. assert_eq!( widths, vec![5.0 * 8.0 + 13.0, 2.0 * 8.0 + 13.0, 2.0 * 8.0 + 13.0], @@ -1211,18 +1004,10 @@ fn resolved_font(s: &UiScript, lua_expr: &str) -> (String, String) { (r[0].clone(), r[1].clone()) } -/// **A Button's ``/``/`` take `font=` as well as -/// `inherits=`** — they are ``-TYPED elements, routed by `CSimpleButton::LoadXML 0x7788c0` -/// at `0x778bf4` into the same `0x783c30` a top-level `` uses, so `font=` gets the identical -/// registry-first treatment (`0x783d15` → `0x783d22 call 0x770c60`). -/// -/// We read only `inherits=`, so every corpus button declaring `font=` silently kept **no font -/// object at all**. Bagnon does it at seven sites; `BagnonDBUINameBox` asks for -/// `GameFontNormalLarge` (16px) and got nothing. Real FrameXML always writes `inherits=`, which is -/// why nothing we ship ever noticed. -/// -/// `style=` is deliberately not read — it does not exist in 1.12.1 (an isolated-token scan returns -/// zero against nine controls each returning one); it is a later-client idiom. +/// A Button's ``, `` and `` take `font=` too: they are +/// `` elements, routed by `CSimpleButton::LoadXML` (`0x7788c0`, at `0x778bf4`) into the +/// `0x783c30` a top-level `` uses, so `font=` resolves registry-first (`0x783d15`, then +/// `0x783d22` calls `0x770c60`). 1.12.1 has no `style=` attribute. #[test] fn a_button_state_font_takes_the_font_attribute_not_just_inherits() { benilla_formats::wow_data_or_skip!(); @@ -1245,18 +1030,10 @@ fn a_button_state_font_takes_the_font_attribute_not_just_inherits() { ); } -/// **A `` with no `font=` beside it is dead XML**, and so is a bare `outline=`. -/// -/// `CSimpleFontString::LoadXML`'s height/outline/monochrome block sits at `[0x77111e, 0x771254)` -/// and its ONLY predecessor is the `font=`-names-a-file miss leg. With no `font=` at all, -/// `0x7710f1`/`0x7710fa je 0x771254` jump straight past it: the attributes are never parsed. They -/// have no independent existence — they are companions of a font FILE path, nothing more. -/// -/// The reference's own FrameXML contains exactly one such site, and it is the proof: `ZoneText.xml` -/// gives `AutoFollowStatusText` `inherits="GameFontNormal"` (12px) **and** a -/// ``. The real client draws that line at 12 and Blizzard's 20 never does -/// anything. We honoured it — one of the few places benilla was drawing text at a size the -/// reference does not. +/// `CSimpleFontString::LoadXML` reads ``, `outline=` and `monochrome=` only on the leg +/// where `font=` names a file (`[0x77111e, 0x771254)`); with no `font=`, `0x7710f1`/`0x7710fa` +/// jump to `0x771254`. Stock `AutoFollowStatusText` (`ZoneText.xml:172-183`) inherits +/// `GameFontNormal` (12px) beside a dead ``. #[test] fn a_fontheight_with_no_font_attr_beside_it_is_never_read() { benilla_formats::wow_data_or_skip!(); @@ -1271,10 +1048,7 @@ fn a_fontheight_with_no_font_attr_beside_it_is_never_read() { ); } -/// A stand-in font engine for the fixture: every character `PER_CHAR` wide, one line tall. The -/// *numbers* the real engine produces are pinned by `ui_text::atlas::metrics_tests` against the -/// client's own fonts; what these tests are about is that an answer arrives **in the tick that -/// asked**, which is exactly what a fixture with head-arithmetic widths shows clearly. +/// A stand-in font engine: every character `PER_CHAR` wide, one 12px line tall. struct BlockFont; const PER_CHAR: f32 = 7.0; @@ -1286,25 +1060,15 @@ impl benilla_ui::script::TextMeasure for BlockFont { } } -/// **Bug 4 — the director's narrow dropdown.** `Bagnon_Forever/database/ui.lua:56-61` sizes its -/// character list by setting each row's text and reading the row back in the same tick: -/// -/// ```lua -/// button:SetText(player) -/// if button:GetTextWidth() + 40 > width then width = button:GetTextWidth() + 40 end -/// ``` -/// -/// With `GetTextWidth` served only by the extract-time round-trip that read **0** for every row, so -/// the frame took the `0 + 40` floor and drew 40px wide while seven full-width character names hung -/// out of its right edge — the director's screenshot. The list must now be as wide as its widest -/// name plus the addon's own 40px of checkbox and padding. +/// `Bagnon_Forever/database/ui.lua:56-62` sets each row's text and reads `GetTextWidth() + 40` back +/// in the same tick; a measure that answers a tick late leaves the list 40px wide. #[test] fn the_character_dropdown_is_as_wide_as_the_names_in_it() { benilla_formats::wow_data_or_skip!(); let root = corpus_or_skip!(); let mut s = open_bagnon(&root); s.set_text_measurer(Box::new(BlockFont)); - // Three saved characters on this realm, the longest 10 letters — Bagnon_Forever's own store. + // Three saved characters on this realm, the longest 10 letters, in Bagnon_Forever's store. s.run( "local realm = GetRealmName() \ BagnonForeverData[realm][\"Onewarrior\"] = { g = 100 } \ @@ -1325,8 +1089,7 @@ fn the_character_dropdown_is_as_wide_as_the_names_in_it() { widest, "the list sizes itself from its rows' own text width, read in the tick that set it" ); - // …and the rows are really inside it: the addon's 6px left inset plus a name, not a 140px - // template button overhanging a 40px box. + // A row's label starts 24px in (`Bagnon_Forever/database/ui.xml:36`) and must end inside. let (list_r, row_r): (f32, f32) = s .eval::>( "local l = BagnonDBUICharacterList \ @@ -1341,33 +1104,18 @@ fn the_character_dropdown_is_as_wide_as_the_names_in_it() { ); } -/// **Bug 5 — the director's stale offline bags.** Bagnon_Forever mirrors the live bags into -/// `BagnonForeverData` (its saved variable) so the dropdown can show other characters' inventories -/// offline. Its recorder does a full scan only on a character's FIRST login; afterwards it trusts -/// `BAG_UPDATE(bagID)`, and `SaveBagData` **deletes** a bag's record whenever -/// `GetContainerNumSlots(bag)` reads 0. The feed used to hand it exactly that: on the logout -/// despawn frame the self store is gone, and diffing the absence as an all-empty snapshot fired a -/// size-0 `BAG_UPDATE` for every bag — every record erased seconds before the saved-variables -/// write, leaving each recently-logged-out character money-only ("g" has no delete path) and the -/// offline view stale. Here: the login scan records the live backpack, and the record must still -/// be intact after the absent-source frame and the logout events — what the shutdown write -/// actually persists. The mechanism's own law is pinned beside the feed -/// (`ui_items::feed::tests::an_absent_self_player_is_no_source_never_an_empty_bag_burst`). +/// Bagnon_Forever scans every bag only at a character's first login, then trusts `BAG_UPDATE`, and +/// `SaveBagData` deletes a bag's record when `GetContainerNumSlots` reads 0: the logout despawn +/// frame, with no self store, must not erase the records before the saved-variables write. #[test] fn bagnon_forevers_records_survive_the_logout_boundary() { benilla_formats::wow_data_or_skip!(); let root = corpus_or_skip!(); let mut s = seat(&root, Seat::BeforeAddons); - // The first-login scan (PLAYER_LOGIN, inside `seat`) recorded the live backpack: 16 slots, - // the pouch in slot 1, in Bagnon_Forever's own short-link shape. - // - // **`16,1,` — the 1 is the reference's, not a stray.** The recorder's count is - // `GetInventoryItemCount("player", ContainerIDToInventoryID(0))`, and `ContainerIDToInventoryID` - // has no backpack arm (`0x4f94e0`: `id - 1 < 4` → `+20`), so bag 0 asks inventory slot 19 — - // the tabard slot. `GetInventoryItemCount 0x4c8680` pushes **1** for an empty slot - // (`0x4c8797`), and 1 for a - // worn tabard's stack count too, so the real client writes this row as `16,1,` either way. + // The first-login scan (PLAYER_LOGIN, in `seat`) recorded the backpack. The 1 in `16,1,` is + // the reference's: `ContainerIDToInventoryID(0)` gives slot 19, the tabard's (`0x4f94e0`), + // and `GetInventoryItemCount` (`0x4c8680`) answers 1 for it, empty or worn (`0x4c8797`). let record = "local r = BagnonForeverData[GetRealmName()][UnitName('player')] \ return tostring(r[0] and r[0].s), tostring(r[0] and r[0][1])"; let (size, item) = s @@ -1382,12 +1130,9 @@ fn bagnon_forevers_records_survive_the_logout_boundary() { "the login scan records the occupied slot's short link" ); - // An equipped bag arriving on the feed — inventory surface FIRST (the bag item in INV slot - // 20), then the container push and its `BAG_UPDATE`: the order the schedule guarantees - // (`feed_char.before(feed_containers)`, ui_char.rs). The recorder's size row asks the bag - // ITEM's own count and link (`GetInventoryItemLink("player", ContainerIDToInventoryID(1))`); - // raced the other way it recorded every equipped bag linkless — the `s = "8,0,"` rows in the - // director's data. + // An equipped bag: the inventory slot (20) first, then the container and its `BAG_UPDATE`, + // the order `feed_char.before(feed_containers)` holds. The size row reads the bag item's own + // count and link, so the other order records the bag without its link. let mut inv: benilla_ui::script::InventorySlots = Default::default(); inv[20] = Some(benilla_ui::script::InvSlotView { item_id: 4497, @@ -1417,8 +1162,7 @@ fn bagnon_forevers_records_survive_the_logout_boundary() { "an equipped bag's size row carries its own count and short link" ); - // The logout boundary: the despawn frame (no self store) and then the shutdown's own events. - // The record the write persists must be the bags the player actually had. + // The logout boundary: the despawn frame (no self store), then the shutdown events. let mut memory = crate::ui_items::feed::FeedMemory::default(); crate::ui_items::feed::apply_container_source( &mut s, @@ -1440,27 +1184,18 @@ fn bagnon_forevers_records_survive_the_logout_boundary() { assert_eq!(item, "4496", "the logout boundary must not erase the slot"); } -/// **Bug 6 — the director's "smaller bags open their own little bags".** Bagnon's whole -/// interception model is the global override (`Bagnon_Core/core/Overrides.lua`: `ToggleBag = …`) -/// plus a `SetScript("OnClick")` wrap on our bar buttons whose replacement calls the ORIGINAL -/// handler back for a plain click. Both layers end at whatever the original OnClick does — and -/// ours toggled `BenillaBagFrame` directly instead of calling `ToggleBag`, so every slot-button -/// click walked straight past both of Bagnon's hooks and opened the native window (the backpack -/// button was fixed in an earlier pass; the four slots were not). The ref's own -/// `BagSlotButton_OnClick` (MainMenuBarBagButtons.lua l.3-21) calls the GLOBAL `ToggleBag`, and -/// its checked tail scans only the NATIVE ContainerFrames — so with Bagnon holding the bags a -/// slot click toggles Bagnon's one window and the slot button stays unlit. Both halves pinned -/// here with a real mouse click, an equipped bag in slot 2, and Bagnon fully hooked. +/// Bagnon overrides the global `ToggleBag` (`Bagnon_Core/core/Overrides.lua`) and wraps each bag +/// slot's `OnClick`, calling the original back. Stock `BagSlotButton_OnClick` +/// (`MainMenuBarBagButtons.lua:3-21`) calls the global `ToggleBag`, then lights the button only for +/// a shown native container frame, so with Bagnon a slot click toggles Bagnon and stays unlit. #[test] fn a_bag_slot_click_toggles_bagnon_not_the_native_window() { - // The install too: "is a native bag window open" is asked of the reference's own `IsBagOpen` - // scan (`ContainerFrame.lua:177`, off the chain) — the windows are RECYCLED (1751), so there - // is no frame to name, and `bag_tests.rs` asks the same way. + // The install too: stock `IsBagOpen` (`ContainerFrame.lua:177`) answers whether a native + // window shows; the windows are recycled, so none can be named. let root = corpus_and_install_or_skip!(); let mut s = open_bagnon(&root); - // An equipped bag behind CharacterBag1Slot (bagId 2): the inventory surface (the bag ITEM in - // inv slot 21) and its container — both fed before any click, the schedule's own order. + // An equipped bag behind `CharacterBag1Slot` (bag 2, inventory slot 21), item then container. let mut inv: benilla_ui::script::InventorySlots = Default::default(); inv[21] = Some(benilla_ui::script::InvSlotView { item_id: 4497, @@ -1483,12 +1218,8 @@ fn a_bag_slot_click_toggles_bagnon_not_the_native_window() { s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(2)]); s.resolve(); - // Bagnon is open (the fixture); a REAL click on the slot button must CLOSE Bagnon — the - // toggle routed through the override — and must never show the native window. - // Both interception layers must be live, and the click exercises both: the SetScript wrap - // (Bagnon.lua's Bagnon_AddBagHooks — its replacement calls our CAPTURED original back with - // no args, so `self` inside the XML wrapper must still resolve on the re-entrant call) and - // the global ToggleBag override the original then reaches. + // Bagnon is open. A click must close it through both layers: the `OnClick` wrap, calling the + // captured original back with no arguments, and the `ToggleBag` override the original calls. assert!( s.eval::("return CharacterBag1Slot:GetScript('OnClick') == BagnonBlizBag_OnClick") .unwrap_or(false), @@ -1508,9 +1239,6 @@ fn a_bag_slot_click_toggles_bagnon_not_the_native_window() { "the native bag window must NOT open — the click belongs to the addon's override" ); - // Click again: Bagnon back open, native still shut, and the slot button unlit — the ref's - // checked tail reads only the native window (its ContainerFrame scan), which Bagnon never - // shows. s.mouse_button(x, y, "LeftButton", true); s.mouse_button(x, y, "LeftButton", false); for _ in 0..2 { @@ -1530,9 +1258,6 @@ fn a_bag_slot_click_toggles_bagnon_not_the_native_window() { .unwrap(), "the slot button stays unlit with an addon holding the bags (the ref scan is native-only)" ); - // Nothing is filtered out any more: the bag-button cursor trio the round-trip reaches - // (`PutItemInBag` on the click) is built (`script/cursor/bag_verbs.rs`), so a raise on it is - // a real one. let raised = s.errors(); assert!( raised.is_empty(), @@ -1540,41 +1265,23 @@ fn a_bag_slot_click_toggles_bagnon_not_the_native_window() { ); } -/// **Bug 7 — the director's unlit backpack button — and where it stands since the bag bar went -/// stock.** Bagnon lights `MainMenuBarBackpackButton` from its window's OnShow/OnHide -/// (`Bagnon.lua:33/39` — `SetChecked(1/0)`), and OnShow dispatch is synchronous -/// (`0x775750` → `0x76ae10`), so whoever writes checked AFTER the toggle wins. On the -/// reference's own click path the stock `BackpackButton_OnClick` tail -/// (`MainMenuBarBagButtons.lua:55-69`) runs last: it re-derives checked by scanning the NATIVE -/// container frames, finds none for bag 0 while an addon holds the bags, and writes 0 — so the -/// real client leaves the button unlit after a click with Bagnon, a wart of its own. Our own -/// `BagFrame.xml` once closed that wart on the director's call (Bug 7: an auto-toggle undo in -/// place of the scan, a stated divergence); decision 1783 sourced the stock file unedited and the -/// divergence went with it, unrecorded — and this suite, skipping at every land from 08-30 -/// (2329), never said so. -/// -/// What this pins is the client as it is, which is the reference: the bare `ToggleBackpack()` -/// path (the `B` binding's body) ends lit, because nothing runs after OnShow there; a real click -/// ends **unlit** on open as well as on close, because the stock tail is the last word. Decision -/// 2332 retired Bug 7's divergence on record (1751 §2 reserves shims for engine differences, and -/// this is a look); if the director ever wants it lit, the second assertion below flips back and -/// a shim in `ContainerFrameAdapters.xml` carries the why. +/// Bagnon lights `MainMenuBarBackpackButton` from its OnShow and OnHide (`Bagnon.lua:33`, `:39`), +/// and OnShow runs synchronously (`0x775750` → `0x76ae10`), so the last write wins. The `B` +/// binding's bare `ToggleBackpack()` ends lit; a click ends unlit either way, because stock +/// `BackpackButton_OnClick` (`MainMenuBarBagButtons.lua:55-68`) rescans the native frames last. #[test] fn the_backpack_button_lit_state_with_bagnon_holding_the_bags() { benilla_formats::wow_data_or_skip!(); let root = corpus_or_skip!(); let mut s = open_bagnon(&root); - // The fixture opened Bagnon through the bare global (the binding's own body): OnShow's - // SetChecked(1) must be standing — nothing runs after it on that path. + // The fixture opened Bagnon through the bare global, so OnShow's `SetChecked(1)` stands. assert!( s.eval::("return MainMenuBarBackpackButton:GetChecked() and true or false") .unwrap(), "the bare ToggleBackpack() path (the B binding) must leave the button lit" ); - // A REAL click on the button: auto-toggle flips, the wrapper's undo repairs it, Bagnon's - // OnHide writes the truth — closed and unlit. let (x, y) = centre_of(&mut s, "MainMenuBarBackpackButton"); s.mouse_move(x, y); s.mouse_button(x, y, "LeftButton", true); @@ -1590,9 +1297,6 @@ fn the_backpack_button_lit_state_with_bagnon_holding_the_bags() { "closed ⇒ unlit (Bagnon's OnHide write is the last word)" ); - // And the second click: open again — and UNLIT, the stock tail's native scan overwriting - // Bagnon's OnShow write, as the reference does. Bug 7's "lit" is the assertion this replaces; - // it comes back with the shim if the director asks for it (the doc above). s.mouse_button(x, y, "LeftButton", true); s.mouse_button(x, y, "LeftButton", false); assert!( diff --git a/crates/benilla-app/src/ui_script/bank_tests.rs b/crates/benilla-app/src/ui_script/bank_tests.rs index e7ea82b6..8e81f8f4 100644 --- a/crates/benilla-app/src/ui_script/bank_tests.rs +++ b/crates/benilla-app/src/ui_script/bank_tests.rs @@ -1,28 +1,6 @@ -//! The **bank window** driven end-to-end, engine-only (no Bevy): the reference's own -//! `Interface\FrameXML\BankFrame.xml`, executed off the player's patch chain behind the bag -//! chain — decision 0604 phase 4's machine checks, re-pointed at the real file by 1751 (window 2). -//! Split from `merchant_tests`/`bag_tests` along the folder's one-file-per-window convention. -//! -//! **What decision 1751 changed in here, in two passes.** First the bank lost six popout windows -//! of its own — `BenillaBankBagFrame1..6`, six copies of `BagFrame.xml`'s window template — because -//! a bank bag is an ordinary `ContainerFrame` at container id `NUM_BAG_SLOTS + slot` (5..10), which -//! is what the real client always did and what 0604 already said it did. Then the window itself -//! went, and every name below is the reference's: `BankFrameItem1..24`, `BankFrameBag1..6`, -//! `BankFramePurchaseButton`, `BankFrameDetailMoneyFrame`. -//! -//! Two consequences worth stating, because both are places a test could quietly assert our old -//! behaviour instead of the client's: -//! -//! · **The bag buttons' `GetID()` is the CONTAINER id, 5..10** — not a bag number. That is what -//! `ButtonInventorySlot` hands `BankButtonIDToInvSlotID`, and what `ToggleBag` takes. -//! · **Opening the bank does NOT open your bags.** Decision 0561 (a window that opens your bags -//! opens every equipped one) is ours and stays for the vendor; the bank goes faithful, and the -//! reference's `BankFrame` OnShow plays one sound and nothing else. The director's call. -//! -//! The knock-on for the fixtures: the reference generates a window only for a container that -//! exists (`OpenBag`'s `size > 0` gate), where our own `BenillaBagFrame*` were static frames that -//! `Show()` regardless. So a test that wants a bank bag open has to feed a bag at id 5 — which is -//! the honest fixture anyway: an empty bank-bag slot has no window in the real client either. +//! The bank window, engine-only: the reference's own `BankFrame.xml` off the player's patch chain. +//! A bank bag is an ordinary `ContainerFrame` at container id `NUM_BAG_SLOTS + slot` (5..10), its +//! bag button's `GetID()`; `OpenBag` opens only a container that exists, so tests feed one at 5. use benilla_ui::script::{ BankState, ContainerSlot, ContainerState, ExtractedQuad, QuadContent, ScriptValue, @@ -31,7 +9,6 @@ use benilla_ui::script::{ use super::test_ui::{bag_open, load_ui as load_xml, BAG_UI}; -/// A container fixture with no items in it — the shape every "is this window open" test wants. fn empty_bag(name: &str, num_slots: u32) -> Option { Some(ContainerState { name: Some(name.into()), @@ -40,20 +17,7 @@ fn empty_bag(name: &str, num_slots: u32) -> Option { }) } -/// The bank's own dependency chain, in `benilla.toc` order: [`BAG_UI`] carries it whole — Fonts -/// before anything with a font `inherits`, the four templates stock `ContainerFrame.xml` inherits, -/// UiPanels before any UIPanel/StaticPopup use, GameTooltip before any slot's hover, the -/// reference's own container file, our bag bar, and the reference's own `BankFrame.xml` (which -/// BAG_UI already needs, because `updateContainerFrameAnchors` measures every open bag against -/// `BankFrame:GetRight()`). -/// -/// `MerchantFrame.xml` used to load here too, for the `BenillaMoney_*` coin rig our own bank -/// window's purse and cost rows called. The reference's rows are `SmallMoneyFrameTemplate` -/// instances off `MoneyFrame.xml`, which BAG_UI already carries — so the merchant is not a bank -/// dependency any more. -/// -/// **Every caller needs client data**: `BAG_UI` names a chain entry, so each test below opens with -/// `wow_data_or_skip!`. +/// [`BAG_UI`] carries the bank's whole chain, `BankFrame.xml` included; it needs client data. fn setup() -> UiScript { let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); @@ -69,27 +33,14 @@ fn has_icon(quads: &[ExtractedQuad], needle: &str) -> bool { ) } -/// Test 1 — BANKFRAME_OPENED shows the frame (through ShowUIPanel, landing at the left slot — the -/// same NPC-session chain Merchant/Trainer use) and sets the title to the event arg. -/// -/// **It does NOT open your bags, and that is the change.** Decision 0561 — a window that opens -/// your bags opens every equipped one — is this client's, and the vendor keeps it; the bank went -/// faithful with 1751's swap (the director's call), and the reference's `BankFrame` OnShow plays -/// `igMainMenuOpen` and nothing else. So the one sound is the whole sound list, which is a -/// stronger assertion than it looks: a stray `OpenBackpack` anywhere on this path would put -/// `igBackPackOpen` in front of it. -/// -/// **And the title comes from `UnitName("npc")`, not from the event.** The reference's -/// `BankFrame_OnEvent` reads no `arg1` (BankFrame.lua:125), so the banker's name has to arrive on -/// the interaction token `crate::ui_session` points at the banker — which is why this test seats -/// an `"npc"` unit and why `feed_bank` stopped firing a name argument the reference never fires. +/// The title is `UnitName("npc")`: `BankFrame_OnEvent` reads no event argument (BankFrame.lua:127). +/// The bank does not open your bags: its OnShow plays `igMainMenuOpen` and nothing else. #[test] fn bankframe_opened_shows_and_sets_the_title() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = setup(); - // The reference gives `BankFrameTitleText` no `text=` — it is empty until an event names the - // banker (our own file used to author a "Banker" placeholder). + // The reference gives `BankFrameTitleText` no `text=`: it is empty until the bank opens. assert_eq!( s.eval::("return BankFrameTitleText:GetText() or \"\"") .unwrap(), @@ -97,7 +48,7 @@ fn bankframe_opened_shows_and_sets_the_title() { ); assert!(!s.eval::("return BankFrame:IsVisible()").unwrap()); - let _ = s.take_sounds(); // ignore anything from load (every frame is hidden; nothing should fire) + let _ = s.take_sounds(); s.set_money(0); s.set_container(0, empty_bag("Backpack", 16)); @@ -134,31 +85,21 @@ fn bankframe_opened_shows_and_sets_the_title() { ); } -/// Test 2 — Hiding the frame (BANKFRAME_CLOSED, routed through HideUIPanel) queues the CloseBankFrame -/// intent (`take_bank_close()`), closes any open bank bag with it, and plays the close -/// sound — the reference's own OnHide order (CloseBankBagFrames(); CloseBankFrame(); PlaySound). -/// -/// **"Popout" is this test's own history, not a widget any more** (1751). It was -/// `BenillaBankBagFrame1`, one of six copies of `BagFrame.xml`'s window template that our own bank -/// file owned, and the assertion was `:Show()` it then check `:IsShown()`. A bank bag is an -/// ordinary container now — id `NUM_BAG_SLOTS + 1` = 5 — so it is OPENED with the reference's own -/// `OpenBag` and asked for with `IsBagOpen`, and `CloseBankBagFrames` is now the reference's own -/// `CloseBag(5..10)` loop rather than a transcription of it. The property is unchanged and is the -/// point of the test: closing the bank must close the bank BAGS, not merely play a sound. +/// The reference's OnHide order: `CloseBankBagFrames()`, `CloseBankFrame()`, then the close sound +/// (BankFrame.xml:521-526). A "popout" is a bank bag's `ContainerFrame`. #[test] fn bankframe_closed_queues_close_closes_open_popouts_and_plays_the_close_kit() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = setup(); s.set_money(0); - // A bag in the FIRST bank bag slot — container 5, which is `BankFrameBag1`'s own `GetID()`. + // The first bank bag slot: container 5, `BankFrameBag1`'s `GetID()`. s.set_container(5, empty_bag("Bank Bag", 8)); s.set_bank(Some(BankState::default())); s.fire_event("BANKFRAME_OPENED", vec![ScriptValue::Str("Banker".into())]); let _ = s.take_sounds(); - let _ = s.take_bank_close(); // nothing queued yet — the drain would otherwise see a stale flag + let _ = s.take_bank_close(); - // A bank bag open when the main window closes (ref CloseBankBagFrames' own reason to exist) — - // opened through the reference's own verb, which is what the bag button calls. + // A bank bag left open when the bank closes: what `CloseBankBagFrames` exists for. s.run("OpenBag(5)").unwrap(); assert!(bag_open(&s, 5), "the bank bag's window is up"); let _ = s.take_sounds(); @@ -189,8 +130,6 @@ fn bankframe_closed_queues_close_closes_open_popouts_and_plays_the_close_kit() { ); } -/// Test 3 — A pushed container −1 with an item in slot 3 paints that slot button's icon; -/// PLAYERBANKSLOTS_CHANGED(3) repaints it after a later change. #[test] fn item_slot_paints_from_container_minus_one_and_repaints_on_playerbankslots_changed() { let _data = benilla_formats::wow_data_or_skip!(); @@ -229,7 +168,7 @@ fn item_slot_paints_from_container_minus_one_and_repaints_on_playerbankslots_cha "slot 3's icon painted after PLAYERBANKSLOTS_CHANGED(3)" ); - // A later change to the same slot (a stack count bump) repaints again. + // A stack-count change on the same slot repaints too. let mut slots2 = std::collections::HashMap::new(); slots2.insert( 3, @@ -258,9 +197,7 @@ fn item_slot_paints_from_container_minus_one_and_repaints_on_playerbankslots_cha ); } -/// Test 4 — Bag buttons: with `set_bank` num_purchased=2, buttons 1-2 read normal (white) and 3-6 -/// read the red tint (1.0, 0.1, 0.1 — the reference's exact tint, `UpdateBagSlotStatus` -/// BankFrame.lua:88-95); the bank-bag inventory band drives button 1's icon. +/// `UpdateBagSlotStatus` tints an unpurchased bag button (1.0, 0.1, 0.1) (BankFrame.lua:96). #[test] fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_band() { let _data = benilla_formats::wow_data_or_skip!(); @@ -270,9 +207,8 @@ fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_band() { num_purchased: 2, next_cost: 100_000, })); - // Bank bag slot 1 holds a bag: an inventory slot at live id 64, which is where the - // reference's `BankFrameItemButton_OnUpdate` reads its icon from - // (`GetInventoryItemTexture("player", ButtonInventorySlot())`). + // Bank bag slot 1 holds a bag: inventory id 64, where `BankFrameItemButton_OnUpdate` reads + // the icon (`GetInventoryItemTexture`). let mut bags: benilla_ui::script::BankBagSlots = Default::default(); bags[0] = Some(benilla_ui::script::InvSlotView { item_id: 4500, @@ -287,9 +223,7 @@ fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_band() { s.resolve(); let quads = s.extract(); - // BagFrame.xml's own bag-bar slots (CharacterBag0Slot..4) fall back to this SAME empty-slot - // texture, so the search must be scoped to each named bank button's own rect, not the path - // alone — get each button's center via eval, then read the color off the quad sitting there. + // The bag bar's slots show the same empty-slot art, so each lookup is scoped to one button. let center = |name: &str| -> (f32, f32) { let (x, y): (f64, f64) = s .eval(&format!( @@ -316,7 +250,6 @@ fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_band() { (c[0] - r).abs() < 0.01 && (c[1] - g).abs() < 0.01 && (c[2] - b).abs() < 0.15 }; - // Button 1: the live inventory slot's own bag icon, purchased -> white. let (x1, y1) = center("BankFrameBag1"); let c1 = color_at(x1, y1, "INV_Misc_Bag_08").expect("button 1 shows the banked bag's icon"); assert!( @@ -324,8 +257,8 @@ fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_band() { "button 1 is purchased: white {c1:?}" ); - // Button 2: purchased but empty -> the paper-doll empty bag art, still white. The art comes - // from `GetInventorySlotInfo(strsub("BankFrameBag2", 10))` — the DBC's own `"Bag2"` row. + // Button 2 is purchased and empty: `GetInventorySlotInfo("Bag2")`'s art, from + // `PaperDollItemFrame.dbc`. let (x2, y2) = center("BankFrameBag2"); let c2 = color_at(x2, y2, "UI-PaperDoll-Slot-Bag").expect("button 2 shows the empty-slot art"); assert!( @@ -333,7 +266,6 @@ fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_band() { "button 2 is purchased: white {c2:?}" ); - // Buttons 3..6: unpurchased -> the reference's red tint. for i in 3..=6 { let (x, y) = center(&format!("BankFrameBag{i}")); let c = color_at(x, y, "UI-PaperDoll-Slot-Bag") @@ -345,23 +277,14 @@ fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_band() { } } -/// The purse row at the bottom of the window, and the director's "the gold at bottom you see -/// 97…" report. -/// -/// Our own window painted the purse with `BenillaMoney_Set` into a single FontString, which is -/// what could run out of room. The reference's `BankFrameMoneyFrame` is an ordinary -/// `SmallMoneyFrameTemplate` — the same three-button gold/silver/copper kit every other window in -/// this client already uses — so a large amount splits across three buttons instead of -/// overflowing one string. Asserted on the DIGITS in each button, not on the picture: whether it -/// reads right on screen is the director's call, but "98765 / 43 / 21 in three separate buttons" -/// is a fact a test can hold. +/// The purse is a `SmallMoneyFrameTemplate` (BankFrame.xml:501): a large amount must not truncate. #[test] fn the_purse_row_splits_a_large_amount_across_three_coin_buttons() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = setup(); s.set_bank(Some(BankState::default())); s.fire_event("BANKFRAME_OPENED", vec![]); - // 98765g 43s 21c — wider than any single-string purse could hold. + // 98765g 43s 21c. s.set_money(987_654_321); s.fire_event("PLAYER_MONEY", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); @@ -378,15 +301,9 @@ fn the_purse_row_splits_a_large_amount_across_three_coin_buttons() { } } -/// Test 5 — The purchase flow: the Purchase button's click shows the `CONFIRM_BUY_BANK_SLOT` popup; -/// the popup's accept queues `take_bank_purchase()`; the whole row hides once `num_purchased` -/// reaches 6 (`GetNumBankSlots()`'s `full`). -/// -/// Two things this pins that only the swap made checkable. `BankFramePurchaseButton` is declared -/// `virtual="true"` INSIDE `` — a reference quirk Classic Era still carries — and only a -/// TOP-LEVEL element is ever a template, so the button really exists and really clicks. And the -/// dialog itself lives in `UiPanels.xml` now: the reference keeps it in `StaticPopup.lua`, which -/// this client replaces, so without the re-home the button would be a silent no-op. +/// `BankFramePurchaseButton` is declared `virtual="true"` inside `` (BankFrame.xml:466), +/// and only a top-level element is a template, so the button exists and clicks. The row hides once +/// `GetNumBankSlots()` reports `full`. #[test] fn purchase_flow_shows_popup_queues_the_intent_and_the_row_hides_when_full() { let _data = benilla_formats::wow_data_or_skip!(); @@ -414,9 +331,8 @@ fn purchase_flow_shows_popup_queues_the_intent_and_the_row_hides_when_full() { s.eval::("return StaticPopup1:IsVisible()").unwrap(), "the click shows the confirm popup" ); - // The dialog carries the cost, which is why 1580's `hasMoneyFrame` had to be live before the - // reference's own StaticPopup entry could be re-homed into UiPanels.xml: its OnShow reads - // `BankFrame.nextSlotCost`, set by `UpdateBagSlotStatus` off `GetBankSlotCost`. + // `CONFIRM_BUY_BANK_SLOT`'s OnShow prints `BankFrame.nextSlotCost` (StaticPopup.lua:54-56), + // which `UpdateBagSlotStatus` sets from `GetBankSlotCost`. assert!( s.eval::("return StaticPopup1MoneyFrame:IsShown()") .unwrap(), @@ -436,8 +352,7 @@ fn purchase_flow_shows_popup_queues_the_intent_and_the_row_hides_when_full() { ); assert!(!s.take_bank_purchase(), "drained"); - // Six purchased: full -> the whole row hides (PLAYERBANKBAGSLOTS_CHANGED is the no-packet - // buy's own repaint trigger, decision 0604). + // Six is full. A successful buy has no reply packet: `PLAYERBANKBAGSLOTS_CHANGED` repaints. s.set_bank(Some(BankState { num_purchased: 6, next_cost: 999_999_999, @@ -451,27 +366,14 @@ fn purchase_flow_shows_popup_queues_the_intent_and_the_row_hides_when_full() { ); } -/// The bank's bag buttons take BOTH mouse buttons (decision 0908): the ref's -/// `BankItemButtonBagTemplate` OnLoad runs `BankFrameBagButton_OnLoad` → -/// `BankFrameBaseButton_OnLoad`, which registers `("LeftButtonUp","RightButtonUp")` -/// (BankFrame.lua:12), and `BankFrameItemButtonBag_OnClick` reads no button. Ours registered -/// nothing, so the widget default (`{"LeftButtonUp"}`) swallowed every right-click. -/// -/// It was asserted on the click sound alone, because "the one observable that does not need a bag -/// object fed into the slot" was all there was while the popout was a `BenillaBankBagFrame` our -/// own file drove by hand. The reference's handler has a real tail — `ToggleBag(this:GetID())` — -/// so a bag IS fed here now and the right-click is followed all the way to the window it opens. -/// -/// **The sound ORDER is the reference's, and it is the reverse of ours.** Its handler opens the -/// bag FIRST and plays `BAGMENUBUTTONPRESS` after (BankFrame.lua:190-197), so the window's own -/// `igBackPackOpen` leads. Ours played the press kit first. Nothing depends on it; it is here -/// because a transcription that gets the order wrong is exactly the kind of thing the swap is -/// meant to stop having to notice. +/// The bag buttons register both mouse buttons (`BankFrameBaseButton_OnLoad`, BankFrame.lua:12), +/// and `BankFrameItemButtonBag_OnClick` reads no button. It opens the bag before it plays +/// `BAGMENUBUTTONPRESS` (BankFrame.lua:193-194), so `igBackPackOpen` leads. #[test] fn a_bank_bag_button_answers_the_right_button_too() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = setup(); - // A bag in bank slot 1 ⇒ container id 5, which IS `BankFrameBag1:GetID()`. + // Bank bag slot 1: container 5, `BankFrameBag1:GetID()`. s.set_container(5, empty_bag("Bank Bag", 8)); s.set_bank(Some(BankState::default())); s.fire_event("BANKFRAME_OPENED", vec![]); @@ -505,10 +407,8 @@ fn a_bank_bag_button_answers_the_right_button_too() { assert!(s.errors().is_empty(), "click errors: {:?}", s.errors()); } -/// Test 6 — Clicking item slot 1 with an empty cursor queues a `PickupContainerItem(-1, 1)` — read off -/// the cursor's own resulting payload (`bag_tests`' pattern: drive the real click through -/// `mouse_button`, not a bare `run()`, so the XML's `OnClick`/`RegisterForClicks` wiring is -/// actually under test). +/// Driven through `mouse_button`, not `run()`, so the XML's `OnClick` and `RegisterForClicks` +/// wiring is under test. #[test] fn clicking_item_slot_one_with_an_empty_cursor_queues_the_pickup() { let _data = benilla_formats::wow_data_or_skip!(); @@ -565,14 +465,13 @@ fn clicking_item_slot_one_with_an_empty_cursor_queues_the_pickup() { ); } -/// The icon-texture quad whose path contains `needle`, for the desaturation assertions below. fn icon_quad<'a>(quads: &'a [ExtractedQuad], needle: &str) -> Option<&'a ExtractedQuad> { quads.iter().find( |q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle)), ) } -/// Whether that icon is drawn desaturated (`SetItemButtonDesaturated`'s shader arm). +/// Whether the icon is drawn desaturated (`SetItemButtonDesaturated`). fn is_greyed(quads: &[ExtractedQuad], needle: &str) -> bool { match icon_quad(quads, needle).map(|q| &q.content) { Some(QuadContent::Texture { desaturated, .. }) => *desaturated, @@ -580,9 +479,8 @@ fn is_greyed(quads: &[ExtractedQuad], needle: &str) -> bool { } } -/// A bag view for one bank BAG slot — the drop the director made. `contents_count: Some(_)` is -/// what makes it a CONTAINER to `GetInventoryItemCount`, and the value is deliberately non-zero: -/// the binding's `0x16` short-circuit has to be what zeroes it, not an already-zero sum. +/// A bag in a bank bag slot. `contents_count: Some(_)` makes it a container to +/// `GetInventoryItemCount`, non-zero so that the binding's slot-`0x16` cutoff does the zeroing. fn a_bag(locked: bool) -> benilla_ui::script::InvSlotView { benilla_ui::script::InvSlotView { item_id: 4500, @@ -595,20 +493,9 @@ fn a_bag(locked: bool) -> benilla_ui::script::InvSlotView { } } -/// **The bug of 1771, both halves.** A bag dropped into a bank bag slot came up greyed and stayed -/// greyed until the window was reopened, and a bag moved between two bag slots stayed drawn in the -/// one it left. -/// -/// Both are the same defect: the six bag buttons repaint from `BankFrameItemButton_OnUpdate`, -/// which is reached ONLY from `BankFrameItemButton_OnEvent` on `PLAYERBANKSLOTS_CHANGED` or -/// `BANKFRAME_OPENED` — despite the name, it is not an `OnUpdate` handler -/// (`BankItemButtonTemplate`'s `` is `CursorOnUpdate()`). benilla fired that event only -/// for the vault's 24 slots, so the bag buttons showed whatever had been true at the last -/// *unrelated* vault change, and `ITEM_LOCK_CHANGED` — the one event that did reach them — repaints -/// the desaturation only, never the icon. -/// -/// This test drives the reference's own file through the exact sequence and asserts what the -/// player sees at each step. +/// Despite its name, `BankFrameItemButton_OnUpdate` is no `OnUpdate` handler: the bag buttons +/// repaint only on `PLAYERBANKSLOTS_CHANGED` or `BANKFRAME_OPENED` (BankFrame.lua:206-212), and +/// `ITEM_LOCK_CHANGED` repaints the desaturation, never the icon. #[test] fn a_bank_bag_button_paints_the_drop_and_lets_go_of_the_lock() { let _data = benilla_formats::wow_data_or_skip!(); @@ -636,27 +523,23 @@ fn a_bank_bag_button_paints_the_drop_and_lets_go_of_the_lock() { let mut bags: benilla_ui::script::BankBagSlots = Default::default(); bags[0] = Some(a_bag(true)); s.set_bank_bag_slots(bags.clone()); - // The event carries NO arguments (`0x703e50`, `__fastcall(ecx = id)`, no vararg push) — it is - // a broadcast, and every bank button repaints from its own `GetInventorySlot()`. + // The event carries no arguments (`0x703e50`): every bank button repaints its own slot. s.fire_event("PLAYERBANKSLOTS_CHANGED", vec![]); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); assert!( is_greyed(&s.extract(), "INV_Misc_Bag_08"), "in flight, the button greys — BankFrameItemButton_UpdateLock's desaturate arm" ); - // **And no digit.** `BankFrameBagButton_OnLoad` sets `isBag = 1`, so `SetItemButtonCount`'s - // `isBag and count > 0` arm would print any positive number in the corner — the "1" the - // director saw. `GetInventoryItemCount 0x4c8680` short-circuits every container past 0-based - // slot 0x16 to a literal 0, and the bank's six bag slots are all in that band. + // No count: a bag button's `isBag` makes `SetItemButtonCount` print any count above 0, but + // `GetInventoryItemCount` (`0x4c8680`) returns 0 for a container past 0-based slot 0x16. assert!( !s.eval::("return BankFrameBag1Count:IsShown()") .unwrap(), "a bank bag counts nothing — the count fontstring stays hidden" ); - // The lock lets go. `ITEM_LOCK_CHANGED` is all the button gets, and it is enough — PROVIDED - // the snapshot it reads has already been corrected, which is what moving the resolving clear - // ahead of both feeds buys (`ui_items::feed::resolve_item_locks`). + // The lock lets go: `ITEM_LOCK_CHANGED` alone must ungrey it, which holds only because + // `ui_items::feed::resolve_item_locks` corrects the snapshot ahead of both feeds. bags[0] = Some(a_bag(false)); s.set_bank_bag_slots(bags.clone()); s.fire_event("ITEM_LOCK_CHANGED", vec![]); @@ -665,8 +548,7 @@ fn a_bank_bag_button_paints_the_drop_and_lets_go_of_the_lock() { "the stuck grey: unlocked, the button must come back to full colour" ); - // Moved to the next bag slot: the icon has to LEAVE the first button, which only the repaint - // event can do — the lock event never touches the texture. + // Moved to the next bag slot: only the repaint event takes the icon off the first button. bags[0] = None; bags[1] = Some(a_bag(false)); s.set_bank_bag_slots(bags); diff --git a/crates/benilla-app/src/ui_script/basic_controls_tests.rs b/crates/benilla-app/src/ui_script/basic_controls_tests.rs index dcfccfd8..818adcdd 100644 --- a/crates/benilla-app/src/ui_script/basic_controls_tests.rs +++ b/crates/benilla-app/src/ui_script/basic_controls_tests.rs @@ -1,15 +1,11 @@ -//! The stock `Interface\FrameXML\BasicControls.xml` — the reference's second file, driven the way -//! an addon drives it. -//! -//! Nothing benilla ships calls `message`, `TEXT` or `_ERRORMESSAGE`; their only consumers are -//! third-party addons (26 call `TEXT`, ~10 genuinely call `message`, two replace `_ERRORMESSAGE`). -//! So every test here enters from Lua the way those addons do. +//! The stock `BasicControls.xml`: `message`, `_ERRORMESSAGE`, `TEXT` and the `DialogBoxFrame` +//! kit, driven from Lua the way addons call them. use benilla_ui::script::UiScript; use super::test_ui::load_ui as load_xml; -/// Fonts then BasicControls — the manifest's own order, and the whole dependency this file has. +/// Fonts.xml is BasicControls.xml's one dependency. fn basic_controls() -> UiScript { let mut s = UiScript::new().unwrap(); load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); @@ -19,8 +15,6 @@ fn basic_controls() -> UiScript { s } -/// **`message("...")` puts its text in the dialog and shows it** — the whole contract, and the -/// reason the frame has to exist rather than the function being a stub that prints somewhere. #[test] fn message_shows_the_script_errors_dialog_carrying_its_text() { benilla_formats::wow_data_or_skip!(); @@ -39,7 +33,7 @@ fn message_shows_the_script_errors_dialog_carrying_its_text() { "Validation error: [nil]" ); - // The ref guard: a second message while the dialog is up does NOT overwrite the first. + // A second message while the dialog is up keeps the first (`BasicControls.xml:7`). s.run(r#"message("second")"#).unwrap(); assert_eq!( s.eval::("return ScriptErrors_Message:GetText()") @@ -48,14 +42,11 @@ fn message_shows_the_script_errors_dialog_carrying_its_text() { "`if not ScriptErrors:IsVisible()` — the first error is the one you keep" ); - // And OKAY hides it, which is the only way it closes. s.run("ScriptErrorsButton:Click()").unwrap(); assert!(!s.eval::("return ScriptErrors:IsVisible()").unwrap()); assert!(s.errors().is_empty(), "{:?}", s.errors()); } -/// `_ERRORMESSAGE` is the reference's own name for the body, it **returns its argument**, and it -/// is replaceable — which is the entire point for the two corpus addons that replace it. #[test] fn error_message_returns_its_argument_and_can_be_replaced() { benilla_formats::wow_data_or_skip!(); @@ -66,7 +57,7 @@ fn error_message_returns_its_argument_and_can_be_replaced() { "the ref returns `message` — a caller may chain on it" ); - // ImprovedErrorFrame's shape: swap the global, then `message` must route through the swap. + // ImprovedErrorFrame's pattern: `message` calls whatever `_ERRORMESSAGE` is now. s.run( r#"Captured = nil _ERRORMESSAGE = function(m) Captured = m return m end @@ -77,11 +68,7 @@ fn error_message_returns_its_argument_and_can_be_replaced() { assert!(s.errors().is_empty(), "{:?}", s.errors()); } -/// The reference's closing line is transcribed since decision 1305: BasicControls installs -/// `seterrorhandler(_ERRORMESSAGE)`, so a script error pops the dialog like the real client's — -/// and the host channel stays sighted because the ENGINE records every caught error before the -/// dispatch ever runs (that dual channel is what dissolved the old divergence, whose guard this -/// test used to be). +/// `BasicControls.xml:16` installs `_ERRORMESSAGE` as the error handler. #[test] fn error_message_is_the_installed_handler_and_the_host_channel_stays_sighted() { benilla_formats::wow_data_or_skip!(); @@ -91,8 +78,7 @@ fn error_message_is_the_installed_handler_and_the_host_channel_stays_sighted() { .unwrap(), "the reference's own default handler is installed (1305)" ); - // An addon's pcall wrapper calls geterrorhandler()(msg) — with _ERRORMESSAGE installed that - // pops the dialog, exactly as it does in the real client. + // An addon's pcall wrapper reports through `geterrorhandler()`, which pops the dialog. s.run(r#"geterrorhandler()("reported")"#).unwrap(); assert!( s.eval::("return ScriptErrors:IsVisible()").unwrap(), @@ -103,8 +89,7 @@ fn error_message_is_the_installed_handler_and_the_host_channel_stays_sighted() { .unwrap(), "reported" ); - // And an ENGINE-caught error still reaches `UiScript::errors()` — the instruments' channel — - // with the dispatch adding the dialog on top, not instead. + // An engine-caught error still lands in `errors()`; the dispatch adds the dialog on top. s.run("BrokenProbe = CreateFrame('Frame') BrokenProbe:RegisterEvent('B271_PROBE') BrokenProbe:SetScript('OnEvent', function() error('fault') end)") .unwrap(); s.fire_event("B271_PROBE", vec![]); @@ -120,7 +105,6 @@ fn error_message_is_the_installed_handler_and_the_host_channel_stays_sighted() { ); } -/// `TEXT` is the identity function, and 26 corpus addons call it because shipped FrameXML does. #[test] fn text_is_the_identity_function_the_corpus_expects() { benilla_formats::wow_data_or_skip!(); @@ -132,9 +116,6 @@ fn text_is_the_identity_function_the_corpus_expects() { assert_eq!(s.eval::("return TEXT(7)").unwrap(), 7); } -/// The dialog kit an addon inherits from directly: `DialogBoxFrame` plus the three named textures. -/// The OKAY button must hide *the caller's* frame, and `$parent` must resolve against the caller's -/// name — `MyDialogButton`, never `DialogBoxFrameButton`. #[test] fn a_dialog_box_from_the_template_names_and_closes_the_callers_frame() { benilla_formats::wow_data_or_skip!(); diff --git a/crates/benilla-app/src/ui_script/battlefield_minimap_tests.rs b/crates/benilla-app/src/ui_script/battlefield_minimap_tests.rs index 5ef0c935..02a76c56 100644 --- a/crates/benilla-app/src/ui_script/battlefield_minimap_tests.rs +++ b/crates/benilla-app/src/ui_script/battlefield_minimap_tests.rs @@ -1,24 +1,6 @@ -//! The **battle map** — `Blizzard_BattlefieldMinimap`, the reference's own LoadOnDemand addon, -//! driven through the road stock `UIParent.lua` drives it down: `ToggleBattlefieldMinimap()` -//! (SHIFT-M) → `BattlefieldMinimap_LoadUI()` → `UIParentLoadAddOn`. -//! -//! benilla has registered the addon as a chain row since 1957 and a live run confirmed it loads — -//! and that was the whole of the evidence. Nothing had ever checked that its twelve overlay -//! textures get cut from `GetMapOverlayInfo`, that its POI pool grows, that the battleground blips -//! and the flag carrier reach it, that its arrow is the *mini* singleton rather than the world -//! map's, or that its tab, dropdown and opacity slider work at all. This file is that check: the -//! whole addon, top to bottom, against the engine verbs it actually calls. -//! -//! **The harness is the whole manifest, not a cut dependency prefix** — the deliberate difference -//! from `tradeskill_frame.rs` / `talent_frame.rs` / `auction_frame.rs`, whose windows each sit on -//! a handful of FrameXML files. This one reads `MiniMapBattlefieldFrame.status` (Minimap.xml), -//! `GetNumWorldStateUI` and `SHOW_BATTLEFIELD_MINIMAP` (WorldStateFrame.lua), `UIOptionsFrame` -//! (UIOptionsFrame.xml), `OpacityFrameSlider` (ColorPickerFrame.xml), the `WorldMapUnitTemplate` -//! family (WorldMapFrameTemplates.xml) and `MAX_PARTY_MEMBERS`/`MAX_RAID_MEMBERS` — five corners -//! of the manifest, which is exactly the state a player is in when they press SHIFT-M. It also -//! lives *in* the crate rather than under `tests/`, because the LoadOnDemand road needs -//! [`super::test_ui::seat_chain_addon`] and an integration test cannot reach it (see -//! `tests/common/mod.rs`'s own note on the `#[cfg(test)]` boundary). +//! The battle map, the `Blizzard_BattlefieldMinimap` addon, loaded as SHIFT-M loads it +//! (`ToggleBattlefieldMinimap`, `UIParent.lua:216-220`). The harness is the whole manifest: the +//! addon reads globals from many of its files. use benilla_ui::script::{ BattlefieldFlagView, BattlefieldPositionView, QuadContent, UiScript, UnitState, @@ -26,15 +8,10 @@ use benilla_ui::script::{ WorldStateUiView, ARROW_MODEL, }; -/// **The manifest's own warning floor** — one row, and it is a decided permanent gap rather than -/// anything this addon does: stock `OptionsFrame.lua:300` reads `GetCVar("gxRefresh")` and 2177 -/// keeps that CVar unregistered on purpose, because nothing here can set a refresh rate. -/// `world_entry_tests`' own `KNOWN` list carries the same row for the same reason. Pinning the -/// *exact* set rather than allowing any warning is what makes [`quiet`] a real assertion: a -/// warning the addon causes shows up as a second row. +/// The manifest's one warning. Deviation: `gxRefresh`, read at `OptionsFrame.lua:300`, is not +/// registered, because nothing here can set a refresh rate. const MANIFEST_WARNINGS: [&str; 1] = ["unknown CVar 'gxRefresh' (not host-registered) — ignored"]; -/// Zero Lua errors, and no host warning the manifest did not already carry. fn quiet(s: &UiScript) { assert!(s.errors().is_empty(), "script errors: {:#?}", s.errors()); assert_eq!( @@ -44,12 +21,8 @@ fn quiet(s: &UiScript) { ); } -/// The full interface with the addon **registered but not loaded** — the state a character is in -/// the moment before SHIFT-M. -/// -/// The player exists because the in-game UI materializes on world entry (1051) and several stock -/// OnLoads format `UnitName("player")` into their labels; a manifest load with no player is a -/// state the client never reaches (1848). +/// The full interface with the addon registered but not loaded. A player exists because stock +/// OnLoads format `UnitName("player")` into their labels. fn session() -> UiScript { let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); @@ -70,10 +43,8 @@ fn session() -> UiScript { s } -/// The arrow file's facts, as the host hands them over (2007/2015) — `MinimapArrow.m2`'s one -/// looping 3.333 s Stand and its header box, which the implicit rect and the re-centring read. -/// Seated *after* the manifest on purpose: the world map's own arrow is created during that load, -/// so this is the mini arrow's facts and only the mini arrow's. +/// `MinimapArrow.m2`'s facts (one looping 3.333 s Stand, the header box), seated after the +/// manifest load that creates the world map's arrow, so only the mini arrow gets them. fn arrow_facts(s: &mut UiScript) { use benilla_ui::widget::{ModelFileFacts, SequenceFacts}; s.set_model_facts( @@ -90,7 +61,6 @@ fn arrow_facts(s: &mut UiScript) { ); } -/// One `WorldMapOverlay` row, revealed by a single explore bit. fn overlay(texture: &str, w: u32, h: u32, ox: u32, oy: u32, bit: u32) -> WorldMapOverlayView { WorldMapOverlayView { texture: format!("Interface\\WorldMap\\WarsongGulch\\{texture}"), @@ -104,17 +74,9 @@ fn overlay(texture: &str, w: u32, h: u32, ox: u32, oy: u32, bit: u32) -> WorldMa } } -/// A one-zone catalog whose zone **has overlays that make the slicing loop do real work**. -/// -/// The sizes are chosen so both arms of the reference's tile walk run. `SilverwingHold` is -/// 300×200: `ceil(300/256) = 2` wide, `ceil(200/256) = 1` tall, so its second column takes the -/// remainder arm (`mod(300,256) = 44` px in a 64-px power-of-two file) while the first takes the -/// full-tile arm, and its single row takes the remainder arm vertically (`mod(200,256) = 200` in a -/// 256-px file). `WarsongLumberMill` is exactly 256×256 — the one case where *both* `mod`s come -/// out zero and the reference's `if ( texturePixelWidth == 0 ) then texturePixelWidth = 256` guard -/// is what keeps the tile from collapsing. Between them they need three textures, which is also -/// what makes the second half of [`the_overlay_textures_are_cut_from_the_revealed_overlays`] — the -/// tail that parks — observable. +/// Overlays that run both arms of the stock tile walk (`Blizzard_BattlefieldMinimap.lua:139-168`): +/// `SilverwingHold` (300×200) takes a full and a remainder column and a remainder row, and +/// `WarsongLumberMill` (256×256) zeroes both `mod`s, leaving it to the `== 0 then 256` guard. fn catalog() -> Vec { vec![WorldMapContinentView { name: "Kalimdor".into(), @@ -135,13 +97,9 @@ fn catalog() -> Vec { }] } -/// Put the session in a battleground and press SHIFT-M. -/// -/// The world-state push is the gate, not scenery: `BattlefieldMinimap_Toggle` shows nothing unless -/// `MiniMapBattlefieldFrame.status == "active"` **or** `GetNumWorldStateUI() > 0`, which is the -/// reference refusing to open a battle map outside a battle. The catalog + explored bitset put the -/// displayed map on a zone that has revealed overlays, and `player_zone` is what the window's own -/// `` `SetMapToCurrentZone()` lands on. +/// Put the session in a battleground and press SHIFT-M. The world-state push is the gate: +/// `BattlefieldMinimap_Toggle` shows nothing unless `MiniMapBattlefieldFrame.status == "active"` +/// or `GetNumWorldStateUI() > 0` (`Blizzard_BattlefieldMinimap.lua:21`). fn open(s: &mut UiScript) { s.set_world_map_catalog(catalog()); s.set_world_map_explored(vec![0b110; 64]); @@ -168,8 +126,6 @@ fn open(s: &mut UiScript) { ); } -/// The window's own per-frame update, driven the way its `` script runs it: `this` is -/// the frame and the elapsed time its one argument. fn update(s: &mut UiScript, elapsed: f64) { s.run(&format!( "this = BattlefieldMinimap BattlefieldMinimap_OnUpdate({elapsed}) this = nil" @@ -177,8 +133,6 @@ fn update(s: &mut UiScript, elapsed: f64) { .unwrap(); } -/// A frame's centre in the screen units the pointer speaks (its own layout centre × its effective -/// scale) — what a hit test and a `mouse_move` want. fn screen_centre(s: &mut UiScript, frame: &str) -> (f32, f32) { s.resolve(); let (x, y, eff) = s @@ -189,9 +143,6 @@ fn screen_centre(s: &mut UiScript, frame: &str) -> (f32, f32) { ((x * eff) as f32, (y * eff) as f32) } -/// The mini arrow's screen centre. It is anonymous and it is the window's **last** child (its -/// `Create…` runs in the ``, after the XML's own forty-eight), so it is found by kind -/// rather than by name or position. fn arrow_centre(s: &mut UiScript) -> (f32, f32) { s.resolve(); let (x, y, eff) = s @@ -217,18 +168,6 @@ fn num(s: &UiScript, expr: &str) -> f64 { .unwrap_or_else(|e| panic!("{expr} — {e}")) } -/// **The addon loads on demand and every frame its XML declares materializes.** -/// -/// Driven down the reference's own road — `ToggleBattlefieldMinimap()` is what SHIFT-M is bound -/// to, and its first line is `BattlefieldMinimap_LoadUI()` → `UIParentLoadAddOn(...)` → the -/// registry's `LoadAddOn`. Loading it any other way (running the `.xml` as a chain file, the way -/// the auction and tradeskill suites load theirs) would skip the registry, `ADDON_LOADED` and its -/// `arg1` filter — and the `ADDON_LOADED` arm is where the tab gets its seat, the dropdown gets -/// initialized and the opacity gets applied, so it would skip most of the window's initial state -/// too. -/// -/// The gate is asserted in both directions first: the load happens either way, but a battle map -/// opens only inside a battle. #[test] fn the_addon_loads_on_demand_through_uiparents_own_road() { let _data = benilla_formats::wow_data_or_skip!(); @@ -240,8 +179,6 @@ fn the_addon_loads_on_demand_through_uiparents_own_road() { "nothing is loaded before the first toggle" ); - // Out of a battleground: the addon still loads — `BattlefieldMinimap_LoadUI()` runs - // unconditionally — and then `BattlefieldMinimap_Toggle`'s own gate declines to show it. s.run("ToggleBattlefieldMinimap()").unwrap(); assert_eq!( s.eval::("return IsAddOnLoaded(\"Blizzard_BattlefieldMinimap\")") @@ -259,8 +196,6 @@ fn the_addon_loads_on_demand_through_uiparents_own_road() { "the RegisterForSave global stays at WorldStateFrame.lua's own boot value" ); - // Every frame, region and font string the `.xml` declares, by name. The loop answers the FIRST - // missing one rather than a bare false, so a regression names itself. let missing: Option = s .eval( r#" @@ -284,8 +219,6 @@ fn the_addon_loads_on_demand_through_uiparents_own_road() { .unwrap(); assert_eq!(missing, None, "a declared frame never materialized"); - // `BattlefieldMinimapRaidUnitTemplate`'s own OnLoad ran on each of the forty: the unit token - // from the frame's `id=`, and the party blip art on its `$parentIcon`. assert_eq!( s.eval::("return BattlefieldMinimapRaid17.unit") .unwrap(), @@ -297,7 +230,7 @@ fn the_addon_loads_on_demand_through_uiparents_own_road() { "Interface\\WorldMap\\WorldMapPartyIcon" ); - // The `ADDON_LOADED` arm: the saved-variable table came up on `BattlefieldMinimapDefaults`… + // The `ADDON_LOADED` arm seeds the saved table from `BattlefieldMinimapDefaults`, assert_eq!( s.eval::<(f64, bool, bool)>( "return BattlefieldMinimapOptions.opacity, BattlefieldMinimapOptions.locked, \ @@ -306,8 +239,7 @@ fn the_addon_loads_on_demand_through_uiparents_own_road() { .unwrap(), (0.7, true, true) ); - // …the tab took its default seat (no saved position → the `-225-CONTAINER_OFFSET_X` corner, - // whose two constants are ContainerFrame.lua's and UIParent.lua's)… + // seats the tab at the `-225-CONTAINER_OFFSET_X` corner when no position is saved, let (point, relative, rel_point, x, y) = s .eval::<(String, String, String, f64, f64)>( "local p, r, rp, ox, oy = BattlefieldMinimapTab:GetPoint(1) \ @@ -325,14 +257,13 @@ fn the_addon_loads_on_demand_through_uiparents_own_road() { num(&s, "BATTLEFIELD_TAB_OFFSET_Y") ) ); - // …and `BattlefieldMinimap_SetOpacity()` ran off the seeded slider value, so the window is - // already wearing the default 0.7 opacity (its alpha is `1 - value`) before it is ever shown. + // and runs `BattlefieldMinimap_SetOpacity()` off the seeded slider, so the default 0.7 opacity + // (alpha `1 - value`) is on before the window first shows. assert!( (num(&s, "BattlefieldMinimapBackground:GetAlpha()") - 0.3).abs() < 1e-6, "the load-time opacity pass reached the border texture" ); - // Now inside a battleground, the same toggle opens it. open(&mut s); assert_eq!( s.eval::("return SHOW_BATTLEFIELD_MINIMAP").unwrap(), @@ -345,26 +276,15 @@ fn the_addon_loads_on_demand_through_uiparents_own_road() { quiet(&s); } -/// **The twelve map tiles and the overlay textures are cut from the map the engine is showing.** -/// -/// Two loops, both reading host verbs the addon shares with the world map. The tiles are -/// `GetMapInfo()`'s art folder repeated `NUM_WORLDMAP_DETAIL_TILES` times; the overlays are -/// `GetNumMapOverlays()` / `GetMapOverlayInfo(i)` sliced into 256-px tiles, each scaled by the -/// window's own `BattlefieldMinimap1:GetWidth()/256` — a **56/256 = 0.21875** shrink, which is the -/// whole reason this window's overlay geometry is not the world map's and has to be checked -/// separately. -/// -/// Every number below is that arithmetic done by hand from [`catalog`]'s two overlays, so the test -/// fails on a wrong scale, a wrong remainder, a dropped power-of-two round-up or a sign flip in -/// the y offset — not merely on "something was drawn". +/// Overlays are cut into 256-px tiles scaled by `BattlefieldMinimap1:GetWidth()/256` (56/256); +/// every number below is that arithmetic done by hand from [`catalog`]. #[test] fn the_overlay_textures_are_cut_from_the_revealed_overlays() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = session(); open(&mut s); - // The window's `` already ran `SetMapToCurrentZone()` and `BattlefieldMinimap_Update()`, - // so the displayed map is the player's zone and the tiles are laid. + // The `` already ran `SetMapToCurrentZone()` and `BattlefieldMinimap_Update()`. assert_eq!( s.eval::("return GetMapInfo()").unwrap(), "WarsongGulch" @@ -385,7 +305,7 @@ fn the_overlay_textures_are_cut_from_the_revealed_overlays() { "2 + 1 tiles: the 300×200 overlay needs two, the 256×256 one needs one" ); - // The reference reads back `(ULx, ULy, LLx, LLy, URx, URy, LRx, LRy)` since 1840; the old + // The reference's `GetTexCoord` returns `(ULx, ULy, LLx, LLy, URx, URy, LRx, LRy)`; the // `(left, right, top, bottom)` rect is positions 1, 5, 2, 4. let tex_rect = |s: &UiScript, n: u32| -> (f64, f64, f64, f64) { let (l, t, _, b, r, ..): (f64, f64, f64, f64, f64, f64, f64, f64) = s @@ -403,8 +323,7 @@ fn the_overlay_textures_are_cut_from_the_revealed_overlays() { }; const SCALE: f64 = 56.0 / 256.0; - // Tile 1 of SilverwingHold: a full 256-px column, 200 px tall in a 256-px file, at the - // overlay's own (100, 50) offset — x right, y DOWN, both through the shrink. + // Tile 1: a full 256-px column, 200 px of a 256-px file tall, at the (100, 50) offset, y down. assert_eq!( s.eval::("return BattlefieldMinimapOverlay1:GetTexture()") .unwrap(), @@ -428,8 +347,7 @@ fn the_overlay_textures_are_cut_from_the_revealed_overlays() { ) ); - // Tile 2: the remainder column — 44 px of art in the 64-px power-of-two file the client rounds - // up to, seated exactly one full tile (256 × scale) to the right of tile 1. + // Tile 2, the remainder column: 44 px in a 64-px power-of-two file, one tile right of tile 1. assert_eq!( s.eval::("return BattlefieldMinimapOverlay2:GetTexture()") .unwrap(), @@ -450,9 +368,7 @@ fn the_overlay_textures_are_cut_from_the_revealed_overlays() { ) ); - // Tile 3 is the second overlay's only tile: exactly 256×256, the case where both `mod`s are - // zero and the reference's `== 0 then 256` guards are load-bearing — a missing guard would - // give this a zero-width, zero-height, `0..0` cropped tile. + // Tile 3: 256×256 zeroes both `mod`s, so only the `== 0 then 256` guards keep it whole. assert_eq!( s.eval::("return BattlefieldMinimapOverlay3:GetTexture()") .unwrap(), @@ -473,8 +389,7 @@ fn the_overlay_textures_are_cut_from_the_revealed_overlays() { ); } - // Un-explore the lumber mill: the host stops returning that overlay, the repaint lights two - // tiles and PARKS the third — the pool never shrinks, exactly as the world map's does not. + // Un-explore the lumber mill: two tiles stay lit and the third parks; the pool never shrinks. s.set_world_map_explored(vec![0b010; 64]); s.tick(0.0); // the push queues WORLD_MAP_UPDATE, the addon's own repaint event assert_eq!(s.eval::("return GetNumMapOverlays()").unwrap(), 1); @@ -491,14 +406,8 @@ fn the_overlay_textures_are_cut_from_the_revealed_overlays() { quiet(&s); } -/// **The POI layer** — `GetNumMapLandmarks` / `GetMapLandmarkInfo` → `BattlefieldMinimap_CreatePOI` -/// → `WorldMap_GetPOITextureCoords`, the same three verbs the world map's own pool walks, but -/// seated against the 225×150 battle map and sized by `GetBattlefieldMapIconScale()` rather than -/// left at the flat 12 px the world map uses. -/// -/// That scale is the point of the second half: the reference multiplies `DEFAULT_POI_ICON_SIZE` by -/// it on every repaint, so a battleground whose `MinimapIconScale` is not 1 draws bigger icons on -/// the battle map and identical ones on the world map. +/// Each repaint sizes an icon `DEFAULT_POI_ICON_SIZE × GetBattlefieldMapIconScale()` +/// (`Blizzard_BattlefieldMinimap.lua:111-112`), seated on the 225×150 window. #[test] fn the_poi_pool_grows_from_the_landmarks_and_parks_its_tail() { let _data = benilla_formats::wow_data_or_skip!(); @@ -512,8 +421,7 @@ fn the_poi_pool_grows_from_the_landmarks_and_parks_its_tail() { texture_index: icon, uv, }; - // Icon 6 is `ICON_POI_REDFLAG`, icon 9 the second row's first cell — two different cells of - // the 8×8 `POIIcons` atlas, so a hard-coded crop cannot pass both. + // Icon 6 is `ICON_POI_REDFLAG`, icon 9 the second row's first cell of the 8×8 `POIIcons` atlas. s.set_world_map_landmarks(vec![ landmark("Silverwing Flag", 6, (0.25, 0.5)), landmark("Warsong Flag", 9, (0.75, 0.25)), @@ -522,7 +430,7 @@ fn the_poi_pool_grows_from_the_landmarks_and_parks_its_tail() { assert_eq!(s.eval::("return NUM_BATTLEFIELDMAP_POIS").unwrap(), 2); assert!(shown(&s, "BattlefieldMinimapPOI1") && shown(&s, "BattlefieldMinimapPOI2")); - // Cell 6 = column 6, row 0; cell 9 = column 1, row 1. `coordIncrement` is 16/128 = 0.125. + // Cell 6 is column 6, row 0; cell 9 is column 1, row 1; `coordIncrement` is 16/128 = 0.125. let cell = |s: &UiScript, n: u32| -> (f64, f64) { let (l, t, ..): (f64, f64, f64, f64, f64, f64, f64, f64) = s .eval(&format!( @@ -533,8 +441,7 @@ fn the_poi_pool_grows_from_the_landmarks_and_parks_its_tail() { }; assert_eq!(cell(&s, 1), (0.75, 0.0)); assert_eq!(cell(&s, 2), (0.125, 0.125)); - // Seated CENTER against the window's TOPLEFT, x × its width and y × MINUS its height — v runs - // down the sheet where frame y runs up, and the sign is what mirrors every icon if it is wrong. + // CENTER on the window's TOPLEFT at (x × width, -y × height): v runs down, frame y runs up. assert_eq!( s.eval::<(String, String, String, f64, f64)>( "local p, r, rp, x, y = BattlefieldMinimapPOI1:GetPoint(1) return p, r:GetName(), rp, x, y" @@ -563,7 +470,6 @@ fn the_poi_pool_grows_from_the_landmarks_and_parks_its_tail() { "DEFAULT_POI_ICON_SIZE × GetBattlefieldMapIconScale()" ); - // One landmark left: the pool keeps both frames, re-seats the first and parks the second. s.set_world_map_landmarks(vec![landmark("Warsong Flag", 9, (0.75, 0.25))]); s.tick(0.0); assert_eq!( @@ -581,14 +487,9 @@ fn the_poi_pool_grows_from_the_landmarks_and_parks_its_tail() { quiet(&s); } -/// **The battleground blips** — the four position verbs the window polls every frame, plus the -/// party slots it shares with the world map, driven through the real ``. -/// -/// The team-member loop is the subtle one: with no raid up, `playerCount` stays 0, so -/// `BattlefieldMinimapRaid` takes `GetBattlefieldPosition(i)` — the raid frames double as the -/// battleground roster. The `(0, 0)` pair is the hide sentinel on every one of these verbs, and -/// the name each row carries is what the blip's tooltip shows for a teammate who is in neither -/// your party nor your raid. +/// With no raid up `playerCount` stays 0, so `BattlefieldMinimapRaid` takes +/// `GetBattlefieldPosition(i)`: the raid frames double as the battleground roster +/// (`Blizzard_BattlefieldMinimap.lua:287-301`). `(0, 0)` hides a blip on every position verb. #[test] fn the_battleground_blips_and_the_flag_follow_the_position_family() { let _data = benilla_formats::wow_data_or_skip!(); @@ -667,7 +568,6 @@ fn the_battleground_blips_and_the_flag_follow_the_position_family() { "a slot with no position hides" ); - // The carrier takes flag frame 1, wearing the token's own art; the second frame stays parked. assert!(shown(&s, "BattlefieldMinimapFlag1")); assert_eq!( s.eval::("return BattlefieldMinimapFlag1Texture:GetTexture()") @@ -676,8 +576,7 @@ fn the_battleground_blips_and_the_flag_follow_the_position_family() { ); assert!(!shown(&s, "BattlefieldMinimapFlag2")); - // Hovering a teammate's blip names them — `BattlefieldMinimapUnit_OnEnter` prefers the row's - // own `name` over `UnitName(unit)`, which is the only way a non-group teammate gets a label. + // The tooltip prefers the row's `name` over `UnitName` (`Blizzard_BattlefieldMinimap.lua:485`). let (bx, by) = screen_centre(&mut s, "BattlefieldMinimapRaid1"); assert_eq!( s.hit_test_name(bx, by).as_deref(), @@ -693,12 +592,9 @@ fn the_battleground_blips_and_the_flag_follow_the_position_family() { ); s.mouse_move(5.0, 5.0); - // The dropdown's "Show Teammates" off: the party and raid blips go in one pass, before any of - // the position verbs are consulted — and **the flag carrier stays**. That asymmetry is the - // reference's own: `Blizzard_BattlefieldMinimap.lua:245-252`'s `not showPlayers` arm hides - // `BattlefieldMinimapParty1..4` and `BattlefieldMinimapRaid1..40` and nothing else, so the two - // flag frames simply keep whatever the last showPlayers-on pass left on them. Pinned in both - // directions on purpose: "hide everything" is the obvious tidy-up and it is not what 1.12 does. + // Show Teammates off hides the party and raid blips and leaves the flag carrier up: the + // `not showPlayers` arm (`Blizzard_BattlefieldMinimap.lua:245-252`) hides only + // `BattlefieldMinimapParty1..4` and `BattlefieldMinimapRaid1..40`. s.run("BattlefieldMinimapOptions.showPlayers = false") .unwrap(); update(&mut s, 0.1); @@ -710,7 +606,6 @@ fn the_battleground_blips_and_the_flag_follow_the_position_family() { "…but the carrier's flag is not in that arm's two loops, so it stays up" ); - // Back on, and then leaving the battleground — the empty push — hides them for real. s.run("BattlefieldMinimapOptions.showPlayers = true") .unwrap(); update(&mut s, 0.1); @@ -724,17 +619,9 @@ fn the_battleground_blips_and_the_flag_follow_the_position_family() { quiet(&s); } -/// **The player arrow is the MINI singleton, not the world map's.** -/// -/// The two are separate slots in the engine (`Arrow::World` / `Arrow::Mini`), each created once per -/// session by its own `Create…` verb and never freed — so the failure this guards against is the -/// addon's `CreateMiniWorldMapArrowFrame(BattlefieldMinimap)` being answered with the world map's -/// existing arrow, which would leave the battle map with no arrow at all and yank the world map's -/// across the screen on every `PositionMini…`. -/// -/// Told apart by the one property that distinguishes them at the renderer: the mini's model scale -/// is `G48 · 10/9` where the world map's is `G48 · 5/3` (`G48 = 1/√(aspect²+1)`, so at 4:3 that is -/// 0.6667 against 1.0). The seat is cross-checked in Lua against the window's own TOPLEFT. +/// The world map and the battle map each own one arrow (`Arrow::World`, `Arrow::Mini`), created +/// once by its own `Create…` verb. The mini's model scale is `G48 · 10/9` and the world map's +/// `G48 · 5/3`, with `G48 = 1/√(aspect²+1)`: 0.6667 against 1.0 at 4:3. #[test] fn the_player_arrow_is_the_minis_own_singleton_seated_by_the_update() { let _data = benilla_formats::wow_data_or_skip!(); @@ -742,7 +629,6 @@ fn the_player_arrow_is_the_minis_own_singleton_seated_by_the_update() { arrow_facts(&mut s); open(&mut s); - // `BattlefieldMinimap_OnLoad` made it: an anonymous `Model` child of the window. assert_eq!( s.eval::( r#"local n = 0 @@ -766,8 +652,6 @@ fn the_player_arrow_is_the_minis_own_singleton_seated_by_the_update() { ); update(&mut s, 0.1); - // The seat, read back in Lua — the arrow is anonymous, so it is found among the children by - // kind. Same CENTER-on-TOPLEFT law as every blip. let seat = s .eval::<(bool, String, String, f64, f64)>( r#"for _, c in ipairs({ BattlefieldMinimap:GetChildren() }) do @@ -789,8 +673,6 @@ fn the_player_arrow_is_the_minis_own_singleton_seated_by_the_update() { ) ); - // The renderer's side: the arrow model, turned to the player's facing by - // `UpdateWorldMapArrowFrames`, at the MINI's own model scale, on a rect centred where Lua says. let (cx, cy) = arrow_centre(&mut s); let pane = s .extract() @@ -821,8 +703,7 @@ fn the_player_arrow_is_the_minis_own_singleton_seated_by_the_update() { {model_scale}" ); - // Off the displayed map: the `(0, 0)` sentinel routes through `ShowMiniWorldMapArrowFrame(nil)` - // and the pane leaves the render list entirely. + // Off the displayed map, `(0, 0)` calls `ShowMiniWorldMapArrowFrame(nil)`. s.set_world_map_feed(Some((1, 1)), None, 0.0, None, Vec::new(), Vec::new()); update(&mut s, 0.1); s.resolve(); @@ -836,17 +717,9 @@ fn the_player_arrow_is_the_minis_own_singleton_seated_by_the_update() { quiet(&s); } -/// **The tab** — the strip of chat-frame art the window hangs from: it comes and goes with the -/// window, fades in under the cursor on the reference's own three-frame ramp, and refuses to be -/// dragged while the options say the map is locked. -/// -/// The hover ramp is worth spelling out because it looks like a bug and is not. Frame 1 takes the -/// `else` arm and only *starts* hovering (it stores the cursor into the globals `CURSOR_OLD_X/Y` -/// and never into `BattlefieldMinimap.oldX/oldy` — the reference's own slip). Frame 2 therefore -/// compares a nil `oldX` against the cursor, misses, and resets `hoverTime` to 0 while finally -/// recording the position. Only frame 3 can accumulate, so the `BATTLEFIELD_TAB_SHOW_DELAY` clock -/// does not start until the third update after the cursor arrives. A "fix" that fades on frame 2 -/// goes red here, which is the point. +/// The hover ramp is the reference's own slip (`Blizzard_BattlefieldMinimap.lua:330-356`): frame 1 +/// stores the cursor in `CURSOR_OLD_X/Y`, never `BattlefieldMinimap.oldX`, so frame 2 misses and +/// resets `hoverTime`, and `BATTLEFIELD_TAB_SHOW_DELAY` counts from frame 3. #[test] fn the_tab_follows_the_window_fades_in_on_hover_and_gates_its_drag_on_the_lock() { let _data = benilla_formats::wow_data_or_skip!(); @@ -865,7 +738,6 @@ fn the_tab_follows_the_window_fades_in_on_hover_and_gates_its_drag_on_the_lock() "…and starts invisible: the OnLoad's own SetAlpha(0)" ); - // The hover ramp. let (mx, my) = screen_centre(&mut s, "BattlefieldMinimap"); s.mouse_move(mx, my); update(&mut s, 0.3); @@ -892,8 +764,8 @@ fn the_tab_follows_the_window_fades_in_on_hover_and_gates_its_drag_on_the_lock() (0.3, Some(1)), "frame 3 finally crosses BATTLEFIELD_TAB_SHOW_DELAY and starts the fade" ); - // `UIFrameFadeIn` runs on the fade manager's own update, from 0 to DEFAULT_BATTLEFIELD_TAB_ALPHA - // over BATTLEFIELD_TAB_FADE_TIME. + // `UIFrameFadeIn` runs on the fade manager's update, from 0 to `DEFAULT_BATTLEFIELD_TAB_ALPHA` + // (0.75) over `BATTLEFIELD_TAB_FADE_TIME` (0.15 s). s.tick(0.05); let part_way = num(&s, "BattlefieldMinimapTab:GetAlpha()"); assert!( @@ -903,7 +775,6 @@ fn the_tab_follows_the_window_fades_in_on_hover_and_gates_its_drag_on_the_lock() s.tick(0.2); assert_eq!(num(&s, "BattlefieldMinimapTab:GetAlpha()"), 0.75); - // The cursor leaves: the else arm fades it back to the alpha it was remembered at. s.mouse_move(5.0, 5.0); update(&mut s, 0.1); assert_eq!( @@ -915,8 +786,7 @@ fn the_tab_follows_the_window_fades_in_on_hover_and_gates_its_drag_on_the_lock() s.tick(0.2); assert_eq!(num(&s, "BattlefieldMinimapTab:GetAlpha()"), 0.0); - // The lock. A left click on a locked tab returns before `StartMoving`, and `StartMoving` is - // what stamps the userPlaced bit (2193), so the bit is the observable in both directions. + // A left click on a locked tab returns before `StartMoving`, which sets the user-placed bit. assert!(!s .eval::("return BattlefieldMinimapTab:IsUserPlaced()") .unwrap()); @@ -963,7 +833,6 @@ fn the_tab_follows_the_window_fades_in_on_hover_and_gates_its_drag_on_the_lock() "a tab nobody placed clears the saved row" ); - // The second toggle closes it, and the tab goes with it through the window's own OnHide. s.run("ToggleBattlefieldMinimap()").unwrap(); assert!(!shown(&s, "BattlefieldMinimap") && !shown(&s, "BattlefieldMinimapTab")); assert_eq!( @@ -971,8 +840,6 @@ fn the_tab_follows_the_window_fades_in_on_hover_and_gates_its_drag_on_the_lock() "0" ); - // And leaving the battleground closes it on the client's own event, not on a toggle: the - // `PLAYER_ENTERING_WORLD` arm hides a battle map with no battle behind it. s.run("ToggleBattlefieldMinimap()").unwrap(); assert!(shown(&s, "BattlefieldMinimap")); s.set_world_state_ui(Vec::new()); @@ -985,29 +852,17 @@ fn the_tab_follows_the_window_fades_in_on_hover_and_gates_its_drag_on_the_lock() quiet(&s); } -/// **The tab's right-click menu and the opacity slider it opens** — the window's whole options -/// surface, and the only place `BattlefieldMinimapOptions` is written by a player. -/// -/// Worth stating what this path does **not** touch, because it is easy to assume otherwise: -/// `BattlefieldMinimap_SetOpacity` reads no CVar. Its input is `OpacityFrameSlider:GetValue()` — -/// ColorPickerFrame.xml's shared vertical slider, which the addon borrows by pointing -/// `OpacityFrame.opacityFunc` at itself — and what persists is the addon's own -/// `SavedVariablesPerCharacter` table plus WorldStateFrame.lua's `RegisterForSave` global -/// `SHOW_BATTLEFIELD_MINIMAP`. There is no battle-map opacity CVar in 1.12 to register or to give -/// a reference default to, so 1804's rule does not reach this window. -/// -/// The alpha formula has two tiers and both are pinned: the border and the twelve detail tiles get -/// `1 - value`, while the overlays, the close button and the corner get a further 0.15 off it -/// (and, below 0.15, nothing off at all) — so a regression that applies one tier everywhere shows -/// up as the map art and its explored overlays being the same brightness. +/// `BattlefieldMinimap_SetOpacity` reads no CVar: its input is the shared `OpacityFrameSlider`, and +/// what persists is the `SavedVariablesPerCharacter` table and `SHOW_BATTLEFIELD_MINIMAP`. The +/// border and the twelve tiles take `1 - value`; the overlays, close button and corner take a +/// further 0.15 off unless the alpha is under 0.15 (`Blizzard_BattlefieldMinimap.lua:376-390`). #[test] fn the_dropdown_and_the_opacity_slider_drive_the_saved_options() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = session(); open(&mut s); - // A real right-click on the tab — `RegisterForClicks` admits `RightButtonUp`, and the handler's - // first arm opens the menu on the tab's own anchor. + // A real right-click: the tab registers `RightButtonUp` (`Blizzard_BattlefieldMinimap.xml:96`). s.run("BattlefieldMinimapTab:Click(\"RightButton\")") .unwrap(); assert!(shown(&s, "DropDownList1"), "the menu opened"); @@ -1019,8 +874,7 @@ fn the_dropdown_and_the_opacity_slider_drive_the_saved_options() { .unwrap(), ) }; - // Both toggles come up checked off `BattlefieldMinimapDefaults`; the opacity row is an action, - // so it carries no check. + // Both toggles are checked off `BattlefieldMinimapDefaults`; the opacity row is an action. assert_eq!(row(&s, 1), ("Show Teammates".into(), true)); assert_eq!(row(&s, 2), ("Lock Battle Map".into(), true)); assert_eq!(row(&s, 3), ("Change Opacity".into(), false)); @@ -1045,8 +899,7 @@ fn the_dropdown_and_the_opacity_slider_drive_the_saved_options() { "row 2 flips the lock" ); - // Row 3 borrows the shared opacity frame: re-anchored onto the battle map's left edge, with - // both of its hooks pointed at this window. + // Row 3 re-anchors the shared opacity frame on the battle map and points its hooks here. s.run("BattlefieldMinimapTab:Click(\"RightButton\")") .unwrap(); s.run("DropDownList1Button3:Click()").unwrap(); @@ -1065,8 +918,7 @@ fn the_dropdown_and_the_opacity_slider_drive_the_saved_options() { ) ); - // Dragging the slider repaints through `OpacityFrame.opacityFunc` — the slider's own - // `` is the only caller, so this is the live path, not a direct call. + // The slider's `` repaints through `OpacityFrame.opacityFunc`. s.run("OpacityFrameSlider:SetValue(0.25)").unwrap(); let alpha = |s: &UiScript, f: &str| num(s, &format!("{f}:GetAlpha()")); for f in [ @@ -1098,7 +950,7 @@ fn the_dropdown_and_the_opacity_slider_drive_the_saved_options() { "under 0.15 the overlays keep the plain alpha" ); - // Dismissing the frame runs `saveOpacityFunc`, which is what puts the value in the saved table. + // Dismissing the frame runs `saveOpacityFunc`, which writes the saved table. s.run("OpacityFrameCloseButton:Click()").unwrap(); assert!(!shown(&s, "OpacityFrame")); assert!( diff --git a/crates/benilla-app/src/ui_script/battlefield_tests.rs b/crates/benilla-app/src/ui_script/battlefield_tests.rs index c37ba6af..d65d9b8e 100644 --- a/crates/benilla-app/src/ui_script/battlefield_tests.rs +++ b/crates/benilla-app/src/ui_script/battlefield_tests.rs @@ -1,20 +1,15 @@ -//! The stock `BattlefieldFrame.xml` and `Minimap.xml`'s queue icon (decision 1974), driven -//! engine-only over the queue family's pushes: the icon and its tooltip across -//! `UPDATE_BATTLEFIELD_STATUS`, the confirm dialog, the list window across `BATTLEFIELDS_SHOW` -//! / `_CLOSED`, and the join button's request. +//! The stock battleground queue UI, `BattlefieldFrame.xml` and the minimap queue icon, driven by +//! pushed queue and list state. use benilla_ui::script::{BattlefieldListView, BattlefieldMapInfo, BattlefieldQueueSlot, UiScript}; use super::test_ui::load_ui as load_xml; -/// The stock pair with everything they call at load and on the events below — the durability -/// harness's shape (UIParent.xml for the panel kit and `UIPanelWindows`; StaticPopup for the -/// confirm dialog; UIDropDownMenu for the icon's menu; UIPanelTemplates for the buttons). +/// The stock pair plus what they call: the panel kit, the confirm dialog, the icon's menu. fn session() -> UiScript { let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); - // The list verbs read the local player object at call time (1974) — seat one, as every - // in-world call has. + // The list verbs answer nothing without a local player, so seat one. s.set_unit( "player", Some(benilla_ui::script::UnitState { @@ -25,8 +20,7 @@ fn session() -> UiScript { }), ); s.run("function PlaySound() end").unwrap(); - // `FloatingChatFrame.lua`'s constant (l.9), which the queue icon's fade-in reads; the chat - // files are not part of this harness. + // `FloatingChatFrame.lua:9`'s value, read by the icon's fade-in (`BattlefieldFrame.lua:142`). s.run("CHAT_FRAME_FADE_TIME = 0.15").unwrap(); for f in [ "Interface\\FrameXML\\Fonts.xml", @@ -77,8 +71,6 @@ fn visible(s: &UiScript, frame: &str) -> bool { .unwrap() } -/// The minimap's queue icon: hidden with no queue, shown by a queued slot with the reference's -/// three-line tooltip, the confirm dialog on a ready slot, hidden again when the queue clears. #[test] fn the_queue_icon_follows_the_slots_across_update_battlefield_status() { let _data = benilla_formats::wow_data_or_skip!(); @@ -98,7 +90,7 @@ fn the_queue_icon_follows_the_slots_across_update_battlefield_status() { tooltip.starts_with("You are in the queue for Arathi Basin\n"), "{tooltip}" ); - // `SecondsToTime` leaves a trailing space after every unit — the reference's own text. + // `SecondsToTime` ends every unit with a space (`UIParent.lua:1023`). assert!( tooltip.contains("Average wait time: 1 Min (Last 10 players)"), "{tooltip}" @@ -110,7 +102,7 @@ fn the_queue_icon_follows_the_slots_across_update_battlefield_status() { ); assert!(!visible(&s, "StaticPopup1"), "queued is not yet a question"); - // Ready: the confirm dialog with the map name and the port countdown. + // Status 2, ready to enter: the confirm dialog and the port countdown. s.set_battlefield_queue(vec![slot(529, "Arathi Basin", 2, 3), empty(), empty()], 0); s.fire_event("UPDATE_BATTLEFIELD_STATUS", vec![]); assert!( @@ -131,11 +123,10 @@ fn the_queue_icon_follows_the_slots_across_update_battlefield_status() { ), "{tooltip}" ); - // The dialog's Enter Battle is AcceptBattlefieldPort(slot, 1). + // Enter Battle calls `AcceptBattlefieldPort(slot, 1)` (`StaticPopup.lua:101`). s.run("StaticPopup1Button1:Click()").unwrap(); assert_eq!(s.take_battlefield_port_requests(), vec![(1, true)]); - // The queue clears: the icon goes. s.set_battlefield_queue(vec![empty(), empty(), empty()], 0); s.fire_event("UPDATE_BATTLEFIELD_STATUS", vec![]); assert!( @@ -144,9 +135,6 @@ fn the_queue_icon_follows_the_slots_across_update_battlefield_status() { ); } -/// The list window: `BATTLEFIELDS_SHOW` opens it on the pushed list — the fake "First -/// Available" row, one row per instance, the description — the join button asks for the -/// selection, and `BATTLEFIELDS_CLOSED` hides it. #[test] fn the_list_window_opens_on_battlefields_show_and_joins_the_selection() { let _data = benilla_formats::wow_data_or_skip!(); @@ -209,7 +197,7 @@ fn the_list_window_opens_on_battlefields_show_and_joins_the_selection() { "…disabled while not leading a group" ); - // Join the default selection (row 1 = first available), then a specific instance. + // Row 1, the default selection, is First Available and joins instance 0. s.run("BattlefieldFrameJoinButton:Click()").unwrap(); assert_eq!(s.take_battlefield_join_requests(), vec![(0, false)]); assert!(!visible(&s, "BattlefieldFrame"), "joining hides the panel"); @@ -228,8 +216,6 @@ fn the_list_window_opens_on_battlefields_show_and_joins_the_selection() { ); } -/// The queue icon's status pass marks the listed rows: a queued instance reads "(In Queue)" -/// beside its row, and the first-available row carries it when the queue names no instance. #[test] fn the_list_rows_carry_the_queue_status() { let _data = benilla_formats::wow_data_or_skip!(); diff --git a/crates/benilla-app/src/ui_script/binder_tests.rs b/crates/benilla-app/src/ui_script/binder_tests.rs index 04391ae7..b85690dd 100644 --- a/crates/benilla-app/src/ui_script/binder_tests.rs +++ b/crates/benilla-app/src/ui_script/binder_tests.rs @@ -1,13 +1,11 @@ -//! The innkeeper bind confirm (decision 1331, BinderConfirm.xml): the dialog `ui_binder`'s feed -//! raises, its Accept, and the range poll that takes it away — driven exactly as that feed and the -//! app's NPC-session guard drive it. +//! The innkeeper bind confirm, the stock `CONFIRM_BINDER` popup, driven as `ui_binder`'s feed +//! and the app's NPC range guard drive it. use benilla_ui::script::{ScriptValue, UiScript}; use super::test_ui::load_ui as load_xml; -/// The app's own pre-state: a question is pending and in range, which is what -/// `CheckBinderDist()` reports while the dialog is up. +/// A bind question pending and in range, so `CheckBinderDist()` holds. fn setup() -> UiScript { let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); @@ -25,9 +23,7 @@ fn setup() -> UiScript { s } -/// `CONFIRM_BINDER(area)` raises the dialog with the area name filled in, and Accept queues the -/// one `ConfirmBinder()` that becomes `CMSG_BINDER_ACTIVATE`. The whole point of the arc: before -/// this wiring the click produced no dialog and no packet at all (B249). +/// Accept's one `ConfirmBinder()` becomes `CMSG_BINDER_ACTIVATE`. #[test] fn the_confirm_shows_the_area_and_accept_queues_the_bind() { benilla_formats::wow_data_or_skip!(); @@ -55,8 +51,7 @@ fn the_confirm_shows_the_area_and_accept_queues_the_bind() { ); } -/// Cancel and ESC both send **nothing**: declining an innkeeper is silent on the wire (there is -/// no decline opcode), so the only observable is that no confirm was queued. +/// There is no decline opcode: Cancel and Escape send nothing. #[test] fn declining_sends_nothing() { benilla_formats::wow_data_or_skip!(); @@ -74,9 +69,7 @@ fn declining_sends_nothing() { ); } -/// Walking away takes the question off screen: the entry's OnUpdate polls `CheckBinderDist()`, -/// which the app drives from the shared NPC-session range guard. While it holds, ticking changes -/// nothing; the frame it goes false, the dialog hides itself — with no packet either way. +/// The popup's OnUpdate hides it once `CheckBinderDist()` fails (`StaticPopup.lua:1329`). #[test] fn leaving_the_innkeepers_range_hides_the_confirm() { benilla_formats::wow_data_or_skip!(); diff --git a/crates/benilla-app/src/ui_script/bottom_hud_tests.rs b/crates/benilla-app/src/ui_script/bottom_hud_tests.rs index 31100fc5..c757bd4f 100644 --- a/crates/benilla-app/src/ui_script/bottom_hud_tests.rs +++ b/crates/benilla-app/src/ui_script/bottom_hud_tests.rs @@ -1,34 +1,12 @@ -//! **The bottom-of-screen clearance law, enforced** (decision 1499). -//! -//! The bottom band of the screen is shared: the main bar sits on it, the extra action bars, the -//! stance/pet bar and the reputation watch bar stack above it, and the bag windows, the cast bar, -//! the chat panes and the default tooltip anchor all have to keep clear of whatever is standing. -//! `UIParent_ManageFramePositions()` (UIParent.xml) is the ONE thing that decides that — it seats -//! the managed frames and writes the managed globals — and every one of the defects below was a -//! frame that quietly opted out of it. -//! -//! Three guards, each pinning a different way to opt out: -//! -//! 1. [`no_shipped_file_declares_its_own_copy_of_a_managed_offset`] — the *shadow copy*. -//! `BagFrame.xml` kept `local BENILLA_CONTAINER_OFFSET_X/Y = 0/70`. Locals, so the pass's -//! writes could never reach them; the open-bag stack sat at the no-bars corner forever and a -//! raised bottom multibar drew straight through the lowest bag window (director-caught — the -//! screenshot that opened 1499). `GameTooltip.xml` carried the softer `X = X or 0` form of the -//! same thing. A value with two written statements has no owner. -//! -//! 2. [`every_bottom_anchored_top_level_frame_is_accounted_for`] — the *new frame nobody wired -//! up*. This is the guard that has to hold for features that do not exist yet: a top-level -//! frame anchored to the screen's bottom edge is, by construction, in the contested band, so it -//! must be a managed row, a listener consumer, or an exemption someone wrote a reason for. -//! -//! 3. [`no_bottom_band_frame_overlaps_a_raised_bar`] — the *symptom itself*, over the real shipped -//! XML: for every combination of raised bars, no bottom-band frame's rect may intersect a -//! visible bar's. This is the one that would have failed on the director's screenshot. +//! The bottom-band clearance guards. The main bar, the extra action bars, the stance and pet bars +//! and the reputation bar stack in the bottom band; everything else there (the bags, the cast bar, +//! the chat panes, the default tooltip anchor) clears them through `UIParent_ManageFramePositions` +//! (`UIParent.lua`), which seats the managed frames and writes the managed offsets. use benilla_ui::script::UiScript; -/// The globals `UIPARENT_MANAGED_FRAME_POSITIONS`'s `isVar` rows own. Only `UIParent.xml` may -/// write these; everyone else reads them fresh at use (falling back inline, never by assigning). +/// Offsets the stock FrameXML writes and the manage pass uses: our files may read them, never +/// assign them. const MANAGED_GLOBALS: &[&str] = &[ "CONTAINER_OFFSET_X", "CONTAINER_OFFSET_Y", @@ -41,10 +19,8 @@ fn ui_dir() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui") } -/// Blank out `` regions, keeping every newline so line numbers still point at the file. -/// The scan below reads Lua, and an XML comment is prose: `BagFrame.xml`'s own explanation of the -/// backpack anchor says "CONTAINER_OFFSET_X=0 / CONTAINER_OFFSET_Y=70" in English, which is a -/// description of the law, not a violation of it. +/// Blanks out `` regions, newlines kept so line numbers hold: an XML comment is prose, +/// not Lua, even when it spells a managed name. fn without_xml_comments(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut rest = text; @@ -77,31 +53,21 @@ fn shipped_xml() -> Vec<(String, String)> { }) .collect(); out.sort(); - // A sanity floor for the walk, not a census: `assets/ui` retires file by file (1751), so - // the floor sits well under the count rather than one step above it (1956). + // A floor for the walk, not a census: `assets/ui` shrinks as windows move to the stock files. assert!(out.len() >= 6, "only {} xml files swept", out.len()); out } -/// **Nothing outside `UIParent.xml` may ASSIGN a managed offset** — not as a local, not as a -/// global, not as an `or`-guarded default. -/// -/// Reading one is fine and expected (`-CONTAINER_OFFSET_X` in the right stack, `CONTAINER_OFFSET_Y` -/// in the bag stack and the tooltip anchor). Falling back at the point of use is fine -/// (`local offsetY = CONTAINER_OFFSET_Y or 70`) — that is a read with a default, and it is what the -/// per-window harnesses that ship no `UIParent.xml` need. What is forbidden is *storing* the value -/// anywhere the pass cannot reach, because the pass recomputes these on every bar change and a -/// stored copy is stale from the moment the next bar goes up. -/// -/// The rule is spelled as "an assignment statement whose left-hand side names a managed global, -/// or a name containing one" — the second half is what catches `BENILLA_CONTAINER_OFFSET_Y`, which -/// was not the global's name at all and is exactly why nothing noticed it for so long. +/// No file of ours assigns a managed offset, as a local, a global or an `or`-guarded default: the +/// pass recomputes them on every bar change, so a stored copy goes stale. Reading one with an +/// inline fallback (`CONTAINER_OFFSET_Y or 70`) is fine; a left-hand side that only contains the +/// name counts as an assignment. #[test] fn no_shipped_file_declares_its_own_copy_of_a_managed_offset() { let mut offences = Vec::new(); for (name, text) in shipped_xml() { if name == r"Interface\FrameXML\UIParent.xml" { - continue; // the owner: its var rows are where these numbers are defined + continue; // the owner: its var rows define these } for (n, line) in without_xml_comments(&text).lines().enumerate() { let code = line.split("--").next().unwrap_or(""); @@ -130,10 +96,8 @@ fn no_shipped_file_declares_its_own_copy_of_a_managed_offset() { ); } -/// Frames whose seat the manage pass owns — the `UIPARENT_MANAGED_FRAME_POSITIONS` keys that name -/// a frame, plus the two the pass's custom tail seats by hand. Kept here as literal text so this -/// test and `UIParent.xml` cannot drift apart silently: the next guard asserts every one of these -/// really appears in the shipped pass. +/// Frames the manage pass seats: the `UIPARENT_MANAGED_FRAME_POSITIONS` frame rows and four that +/// its tail seats by hand. The next guard checks each still appears in the stock `UIParent.lua`. const MANAGED_FRAMES: &[&str] = &[ "MultiBarBottomLeft", "GroupLootFrame1", @@ -149,9 +113,8 @@ const MANAGED_FRAMES: &[&str] = &[ "QuestWatchFrame", ]; -/// Top-level frames that ARE anchored to the screen's bottom edge and are deliberately not managed. -/// Every entry carries the reason, because "it was already like that" is how the bag stack got its -/// shadow copy. Adding a frame here is a decision; adding one silently is what this test forbids. +/// Top-level frames anchored to the screen's bottom edge that the pass does not manage, each with +/// its reason. const BOTTOM_EXEMPT: &[(&str, &str)] = &[ ( "MainMenuBar", @@ -196,19 +159,12 @@ const BOTTOM_EXEMPT: &[(&str, &str)] = &[ ), ]; -/// **A top-level frame anchored to the screen's bottom edge is either managed, or exempt with a -/// stated reason.** There is no third option, and this is the guard that covers features nobody -/// has written yet. -/// -/// The band at the bottom of the screen is contested by construction: the main bar owns it, and -/// every optional bar stacks upward into it. So the moment a new window anchors `BOTTOM`, -/// `BOTTOMLEFT` or `BOTTOMRIGHT` to the screen root, it has taken on a clearance problem — and the -/// only correct answers are to join `UIPARENT_MANAGED_FRAME_POSITIONS`, to seat itself from a -/// managed global and register a listener (the bag stack's answer), or to say out loud why it does -/// not need to. Failing here is not a bug report; it is a prompt to pick one. +/// A top-level frame anchored to the screen's bottom edge sits in the contested band, so it has a +/// `UIPARENT_MANAGED_FRAME_POSITIONS` row, seats itself from a managed offset with a listener, or +/// is listed in `BOTTOM_EXEMPT` with its reason. #[test] fn every_bottom_anchored_top_level_frame_is_accounted_for() { - // The pass is the stock `UIParent.lua`'s since 1988 — read off the player's chain. + // The pass is the stock `UIParent.lua`'s, read off the player's chain. let _data = benilla_formats::wow_data_or_skip!(); let pass = String::from_utf8_lossy( &super::reference_ui::read(r"Interface\FrameXML\UIParent.lua") @@ -244,13 +200,9 @@ fn every_bottom_anchored_top_level_frame_is_accounted_for() { ); } -/// Every top-level INSTANCE (a ``/`