From cd11b26005dc35e9aab8859a798cee8043d4a934 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:20:21 +0700 Subject: [PATCH 01/40] Comments in benilla-app, the UI script layer: one line of fact each, no history, no record numbers (#184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #41: the first of five `benilla-app` pieces. This one is the UI script layer: `ui_script` with its test harnesses, the text engine and layout (`ui_text`), the UI pass and the UI model panes. Its comments are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 22,041 comment lines become 7,133. - **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal. - **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them: - On a fresh login the reference fires `PLAYER_LOGIN` and `PLAYER_ENTERING_WORLD` from the player's create (`0x5deb60` β†’ `0x4908c0`); only a `/reload` goes through `0x490168`. The comments now state both paths. - `ComputeStep` (`0x5ca2d0`) applies negative pair kerns and rounds up. It does have a kerning term. - A fallback icon global and the retired `UiPanels.xml` panels no longer exist. The comments that named them now describe the stock FrameXML mechanism that replaced them. - Stock FrameXML line citations are checked against the stock 1.12 files and corrected, and several misattributed files are fixed (`MainMenuBarArtFrame` lives in `MainMenuBar.xml`). - Dozens more turned up on the way: docs sitting on the wrong item, removed functions, stale counts and links. - **Deviation labels.** "Deviation:" now marks only a choice with a reason. An unbuilt behaviour says so plainly. - **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Five second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the code, the stock files, vmangos and the reference. Behaviour differences the rewrite surfaced are filed as their own issues (#183 among them). The comments here state what the code does. String literals are untouched; they are #92. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/ui_models/mod.rs | 872 +++-------- crates/benilla-app/src/ui_pass.rs | 971 +++--------- .../src/ui_script/action_bar_tests.rs | 538 ++----- crates/benilla-app/src/ui_script/addons.rs | 1371 ++++------------- crates/benilla-app/src/ui_script/bag_tests.rs | 509 ++---- .../src/ui_script/bagnon_render_tests.rs | 564 ++----- .../benilla-app/src/ui_script/bank_tests.rs | 210 +-- .../src/ui_script/basic_controls_tests.rs | 35 +- .../ui_script/battlefield_minimap_tests.rs | 264 +--- .../src/ui_script/battlefield_tests.rs | 32 +- .../benilla-app/src/ui_script/binder_tests.rs | 19 +- .../src/ui_script/bottom_hud_tests.rs | 188 +-- .../benilla-app/src/ui_script/buff_tests.rs | 285 +--- .../benilla-app/src/ui_script/cast_tests.rs | 158 +- .../src/ui_script/character_tests.rs | 402 ++--- .../src/ui_script/chat_options_tests.rs | 98 +- .../src/ui_script/chat_resize_tests.rs | 91 +- .../benilla-app/src/ui_script/chat_tests.rs | 294 +--- .../src/ui_script/cinematic_tests.rs | 93 +- .../src/ui_script/color_picker_tests.rs | 181 +-- .../src/ui_script/combat_text_tests.rs | 87 +- .../src/ui_script/combo_frame_tests.rs | 53 +- crates/benilla-app/src/ui_script/content.rs | 97 +- .../benilla-app/src/ui_script/death_tests.rs | 81 +- .../src/ui_script/delete_item_tests.rs | 159 +- .../src/ui_script/dressup_tests.rs | 144 +- .../src/ui_script/dropdown_tests.rs | 59 +- .../benilla-app/src/ui_script/duel_tests.rs | 20 +- .../src/ui_script/durability_tests.rs | 88 +- .../src/ui_script/enchant_confirm_tests.rs | 23 +- .../benilla-app/src/ui_script/errors_tests.rs | 70 +- .../benilla-app/src/ui_script/escape_tests.rs | 170 +- .../src/ui_script/event_shape_gate.rs | 90 +- .../src/ui_script/exp_bar_tests.rs | 87 +- .../src/ui_script/extract/colorselect.rs | 108 +- .../benilla-app/src/ui_script/extract/mod.rs | 952 +++--------- .../benilla-app/src/ui_script/extract/text.rs | 244 +-- .../benilla-app/src/ui_script/fade_tests.rs | 21 +- .../src/ui_script/faux_scroll_tests.rs | 121 +- .../src/ui_script/frame_flag_gate.rs | 271 +--- .../src/ui_script/friends_tests.rs | 142 +- .../src/ui_script/game_menu_tests.rs | 205 +-- crates/benilla-app/src/ui_script/gate.rs | 71 +- .../src/ui_script/group_loot_tests.rs | 279 +--- .../benilla-app/src/ui_script/guild_tests.rs | 245 +-- .../src/ui_script/help_frame_tests.rs | 51 +- .../src/ui_script/honor_frame_tests.rs | 200 +-- crates/benilla-app/src/ui_script/input.rs | 264 +--- .../src/ui_script/inspect_tests.rs | 191 +-- .../src/ui_script/instance_tests.rs | 41 +- .../src/ui_script/keybindings_tests.rs | 202 +-- crates/benilla-app/src/ui_script/lifecycle.rs | 1010 +++--------- .../benilla-app/src/ui_script/loot_tests.rs | 241 +-- .../benilla-app/src/ui_script/macro_tests.rs | 251 +-- crates/benilla-app/src/ui_script/manifest.rs | 356 +---- .../src/ui_script/merchant_tests.rs | 314 +--- .../src/ui_script/micro_menu_tests.rs | 93 +- .../src/ui_script/minimap_tests.rs | 146 +- .../src/ui_script/mirror_timer_tests.rs | 85 +- crates/benilla-app/src/ui_script/mod.rs | 666 ++------ .../src/ui_script/money_frame_tests.rs | 103 +- .../src/ui_script/multibar_stance_tests.rs | 515 ++----- .../src/ui_script/options_tests.rs | 1253 ++++----------- .../src/ui_script/panel_template_tests.rs | 180 +-- .../benilla-app/src/ui_script/panel_tests.rs | 423 ++--- .../src/ui_script/perf_bar_tests.rs | 81 +- .../src/ui_script/pet_bar_tests.rs | 226 +-- .../src/ui_script/pet_frame_tests.rs | 206 +-- .../src/ui_script/pet_paperdoll_tests.rs | 187 +-- .../src/ui_script/pet_stable_tests.rs | 56 +- .../src/ui_script/petition_tests.rs | 135 +- .../src/ui_script/quest_share_tests.rs | 25 +- .../benilla-app/src/ui_script/quest_tests.rs | 150 +- .../src/ui_script/quest_timer_tests.rs | 55 +- .../src/ui_script/questlog_tests.rs | 507 ++---- .../benilla-app/src/ui_script/quiver_tests.rs | 179 +-- .../benilla-app/src/ui_script/raid_tests.rs | 256 +-- .../benilla-app/src/ui_script/reference_ui.rs | 881 +++-------- .../src/ui_script/reputation_frame_tests.rs | 234 +-- .../src/ui_script/resolve_bench.rs | 291 +--- .../src/ui_script/screenshot_tests.rs | 22 +- crates/benilla-app/src/ui_script/session.rs | 114 +- .../benilla-app/src/ui_script/shape_gate.rs | 350 +---- .../src/ui_script/shipped_xml_tests.rs | 807 +++------- .../src/ui_script/skills_frame_tests.rs | 163 +- .../src/ui_script/spellbook_tests.rs | 208 +-- .../src/ui_script/static_popup_tests.rs | 62 +- .../benilla-app/src/ui_script/summon_tests.rs | 57 +- .../benilla-app/src/ui_script/tabard_tests.rs | 26 +- .../src/ui_script/talent_wipe_tests.rs | 31 +- .../src/ui_script/target_aura_tests.rs | 54 +- .../benilla-app/src/ui_script/taxi_tests.rs | 65 +- crates/benilla-app/src/ui_script/test_ui.rs | 486 ++---- .../src/ui_script/tooltip_anchor_tests.rs | 198 +-- .../src/ui_script/tooltip_compare_tests.rs | 109 +- .../src/ui_script/tooltip_template_tests.rs | 182 +-- .../src/ui_script/tot_frame_tests.rs | 183 +-- .../src/ui_script/trainer_tests.rs | 317 +--- .../src/ui_script/tutorial_tests.rs | 14 +- .../src/ui_script/ui_menu_tests.rs | 38 +- .../src/ui_script/uiparent_tests.rs | 128 +- .../src/ui_script/unit_frame_tests.rs | 581 ++----- .../src/ui_script/unit_popup_tests.rs | 153 +- .../src/ui_script/verb_event_gate.rs | 70 +- .../src/ui_script/world_entry_tests.rs | 489 ++---- .../src/ui_script/world_map_tests.rs | 229 +-- .../src/ui_script/world_state_tests.rs | 80 +- .../src/ui_script/zone_text_tests.rs | 87 +- .../benilla-app/src/ui_text/engine/faces.rs | 41 +- crates/benilla-app/src/ui_text/engine/gpu.rs | 64 +- crates/benilla-app/src/ui_text/engine/mod.rs | 576 ++----- .../benilla-app/src/ui_text/layout/measure.rs | 229 +-- crates/benilla-app/src/ui_text/layout/mod.rs | 431 ++---- .../src/ui_text/layout/overflow.rs | 143 +- crates/benilla-app/src/ui_text/layout/wrap.rs | 171 +- crates/benilla-app/src/ui_text/markup.rs | 176 +-- crates/benilla-app/src/ui_text/measurer.rs | 65 +- crates/benilla-app/src/ui_text/mod.rs | 208 +-- crates/benilla-app/src/ui_text/outline.rs | 82 +- crates/benilla-app/src/ui_text/pack.rs | 216 +-- 120 files changed, 6953 insertions(+), 21868 deletions(-) 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 ``/` - + - + - -- Deviation: ERA_WINDOW_SCALE (OptionsFrame.xml, loaded earlier) stands in for the - -- UIParent scale the era draws this menu and its options window at. - -- A second OnShow on this frame would replace this one: the last SetScript wins. + -- ERA_WINDOW_SCALE (OptionsFrame.xml) stands in for the era's UIParent scale. A + -- second OnShow on this frame would replace this one. this:SetScale(ERA_WINDOW_SCALE or 1) if UpdateMicroButtons then UpdateMicroButtons() end if Disable_BagButtons then Disable_BagButtons() end diff --git a/crates/benilla-app/assets/ui/KeyBindingsPage.xml b/crates/benilla-app/assets/ui/KeyBindingsPage.xml index 2c316954..a89fa332 100644 --- a/crates/benilla-app/assets/ui/KeyBindingsPage.xml +++ b/crates/benilla-app/assets/ui/KeyBindingsPage.xml @@ -1,24 +1,16 @@ - + - + @@ -91,10 +81,8 @@ return fallback end - -- ── Key display ── 1.12's GetBindingText in full form, as the reference shows it here and - -- in tooltips (the action bar's abbreviated form is the stock GetBindingText): modifier - -- prefixes stay uppercase tokens, the base key reads its KEY_* global, else itself. - -- "CTRL--" is CTRL plus the minus key. + -- ── Key display ── 1.12's GetBindingText in full form: modifier prefixes stay tokens, the + -- base key reads its KEY_* global. "CTRL--" is CTRL plus the minus key. function KeyBindings_KeyText(key) if not key then return NORMAL_FONT_COLOR_CODE .. KeyBindings_String("NOT_BOUND", "Not Bound") .. FONT_COLOR_CODE_CLOSE @@ -119,9 +107,7 @@ return prefix .. KeyBindings_String("KEY_" .. rest, rest) end - -- ── Page state ── `sections` is the era section model, `selected`/`keyID` the 1.12 - -- selection, and `searchPainted` maps OptionsFrame.xml's live search rows (row name to - -- binding index). + -- ── Page state ── `searchPainted` maps OptionsFrame.xml's search rows to binding indices. KeyBindingsPage = { sections = {}, items = {} } KEYBINDINGS_ROW_BASE = "BenillaOptionsFrameContainerBodyKeybindingsRow" @@ -129,9 +115,8 @@ KEYBINDINGS_ROW_HEIGHT = 25 KEYBINDINGS_SEARCH_ROWS = 8 - -- ── Section model ── the era's CreateKeybindingInitializers: one section per category - -- token in first-appearance order, which is 1.12's Bindings.xml order. Sections start - -- collapsed (the era's Classic overrides pass no `expanded`); a rebuild keeps each state. + -- ── Section model ── one section per category in Bindings.xml order, collapsed at first + -- as in the era; a rebuild keeps each state. function KeyBindings_BuildSections() local page = KeyBindingsPage local was = {} @@ -153,8 +138,7 @@ KeyBindings_Flatten() end - -- The visible list the faux scroll runs over (the era's EvaluateVisibility): each header, - -- then its rows when expanded. + -- The visible list the faux scroll runs over. function KeyBindings_Flatten() local page = KeyBindingsPage page.items = {} @@ -168,7 +152,7 @@ end end - -- A header click (the era's OnExpandedChanged), voiced with the checkbox sound. + -- A header click, voiced with the checkbox sound. function KeyBindings_ToggleSection(n) local sec = KeyBindingsPage.sections[n] if not sec then return end @@ -178,7 +162,7 @@ KeyBindingsPage_Update() end - -- Expands or collapses a section silently; the capture fixture opens sections with it. + -- Silent, for the capture fixture. function KeyBindings_ExpandSection(n, on) local sec = KeyBindingsPage.sections[n] if not sec then return end @@ -186,8 +170,7 @@ KeyBindings_Flatten() end - -- ── Painters ── one row in one mode, shared by the list and the search rows, whose - -- capsules capture in place as the era's live results do. + -- ── Painters ── shared by the list and the search rows, which capture in place. function KeyBindings_PaintBindRow(name, index) local command, _, key1, key2 = GetBinding(index) local b1 = getglobal(name .. "Key1Button") @@ -234,7 +217,7 @@ getglobal(name .. "Key2Button"):Hide() end - -- ── The list repaint (1.12 KeyBindingFrame_Update over the era section list). ── + -- ── The list repaint (1.12 KeyBindingFrame_Update) ── function KeyBindingsPage_Update() local page = KeyBindingsPage local scroll = getglobal("BenillaOptionsFrameContainerBodyKeybindingsScrollFrame") @@ -255,14 +238,12 @@ end FauxScrollFrame_Update(scroll, table.getn(page.items), KEYBINDINGS_DISPLAYED, KEYBINDINGS_ROW_HEIGHT) - -- The trough is ours and the stock kit never shows one, so it follows the bar - -- `FauxScrollFrame_Update` has just shown or hidden. + -- Our trough follows the bar `FauxScrollFrame_Update` has just shown or hidden. BenillaScrollTrough_Follow(scroll:GetName()) KeyBindings_UpdateUnbindKey() end - -- The page entry (BenillaOptionsFrame_SelectCategory): rebuilds the sections, since a - -- rebind or a set switch may have changed the table, then repaints from the top. + -- The page entry: a rebind or a set switch may have changed the table. function KeyBindingsPage_Refresh() KeyBindings_BuildSections() local box = getglobal("BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck") @@ -279,9 +260,7 @@ getglobal("BenillaOptionsFrameContainerBodyKeybindingsOutput"):SetText(text) end - -- ── Selection ── selecting a capsule arms the host's capture (BenillaBindCapture), which - -- swallows raw input and hands the canonical chord to KeyBindings_OnHostKey; deselecting - -- disarms. + -- ── Selection ── arms the host's capture, which hands its chord to KeyBindings_OnHostKey. function KeyBindings_SetSelected(command, keyID) KeyBindingsPage.selected = command KeyBindingsPage.keyID = keyID @@ -297,8 +276,7 @@ end end - -- Repaints whichever view shows binding rows, the list or every search row, since a bind - -- from one search row can unbind a victim shown on another. + -- Every search row: a bind from one can unbind a victim shown on another. function KeyBindings_RepaintVisible() if KeyBindingsPage.searchPainted then for name, index in pairs(KeyBindingsPage.searchPainted) do @@ -311,9 +289,7 @@ end function KeyBindingButton_OnClick(button) - -- As 1.12: while armed, only a left or right click reselects or deselects. A middle, - -- 4 or 5 press is a bind that the armed capture takes after this click, so it must - -- leave the selection alone. + -- As 1.12: while armed, a middle, 4 or 5 press is a bind the capture takes after this. local page = KeyBindingsPage if page.selected then if button ~= "LeftButton" and button ~= "RightButton" then @@ -337,14 +313,11 @@ KeyBindings_RepaintVisible() end - -- The live commit: saves the set a bind, unbind or reset has just changed. function KeyBindings_Commit() SaveBindings(GetCurrentBindingSet()) end - -- The host's capture callback, `chord` canonical (ALT-CTRL-SHIFT-TOKEN): 1.12's OnKeyDown - -- armed branch, then the live commit. A stolen chord names its victim in red only when it - -- was the victim's last key; the command's keys rebind in slot order. + -- `chord` is ALT-CTRL-SHIFT-TOKEN; 1.12's OnKeyDown armed branch, then the live commit. function KeyBindings_OnHostKey(chord) local page = KeyBindingsPage if not page.selected then @@ -379,9 +352,7 @@ KeyBindings_RepaintVisible() end - -- 1.12's KeyBindingFrame_SetBinding: a refused bind restores the slot's old key. SetBinding - -- refuses only a string that is not a key, so this fires for a chord the capture could not - -- name, never for the wheel; the message stays 1.12's wheel text. + -- 1.12's KeyBindingFrame_SetBinding, message and all; SetBinding refuses only a non-key. function KeyBindings_SetOrRestore(key, command, oldKey) if not SetBinding(key, command) then if oldKey then SetBinding(oldKey, command) end @@ -391,8 +362,7 @@ end end - -- The Unbind Key button (OptionsFrame.xml): 1.12's body, which empties the selected slot - -- and slides the other into slot order, then the live commit. + -- 1.12's Unbind Key body, then the live commit. function KeyBindings_Unbind() local page = KeyBindingsPage local key1, key2 = GetBindingKey(page.selected) @@ -409,12 +379,9 @@ KeyBindings_RepaintVisible() end - -- ── Character-specific set ── 1.12's binding sets under the era's checkbox. Checking - -- saves set 2 at once, a copy of the current binds (LoadBindings(2) falls back to the - -- account set), and that saved set is what the checkbox reads. Unchecking deletes it, so - -- it confirms (the era confirms only on uncheck) and the box stays checked meanwhile. - -- The accept loads set 1 before saving it; saving first would write the character binds - -- into the account set. + -- ── Character-specific set ── checking saves set 2 at once (LoadBindings(2) falls back + -- to the account set); unchecking confirms first, as the era does. The accept loads set 1 + -- before saving it, or the character binds would be written into the account set. function KeyBindings_CharacterToggle(box) PlaySound("igMainMenuOptionCheckBoxOn") if box:GetChecked() then @@ -442,8 +409,7 @@ KeyBindingsPage_Refresh() end, } - -- Reset To Default, the page's Defaults button: it confirms, as the era's does, with our - -- own string since 1.12 has none, and resets the current set with a live commit. + -- Reset To Default confirms, as the era's does; our string, since 1.12 has none. StaticPopupDialogs["CONFIRM_RESET_KEYBINDINGS"] = { text = "Are you sure you want to reset all keybindings to their defaults?", button1 = KeyBindings_String("OKAY", "Okay"), @@ -458,11 +424,8 @@ end, } - -- ── Search ── a binding's tag is its display name uppercased (the era's - -- AddSearchTags(GetBindingName(action))); matches list under the Keybindings group head, - -- which opens this page (the era's `redirectCategory`, Blizzard_SettingsPanel.lua l.578). - -- OptionsFrame.xml paints them as live rows. Deviation: a static pool of - -- KEYBINDINGS_SEARCH_ROWS rows, where the era's results scroll. + -- ── Search ── a binding's tag is its display name uppercased, as the era's. + -- A static pool of KEYBINDINGS_SEARCH_ROWS rows, where the era's results scroll. function KeyBindings_SearchMatches(words) local out = {} for i = 1, GetNumBindings() do diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 6a3af262..1bfb34ee 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -1,18 +1,13 @@ - + @@ -31,17 +26,14 @@ - + - + - + @@ -1110,9 +1031,7 @@ - + - + @@ -1163,8 +1080,8 @@ - + @@ -1191,11 +1108,8 @@ - + @@ -1218,8 +1132,7 @@ - + @@ -1273,9 +1186,7 @@ - + @@ -1315,13 +1226,12 @@ - + - + @@ -1329,9 +1239,8 @@ - + @@ -1341,15 +1250,13 @@ - - + @@ -1358,10 +1265,8 @@ - + @@ -1396,10 +1301,8 @@ - + - + - + @@ -1521,7 +1424,7 @@ - + @@ -1529,8 +1432,7 @@ - + @@ -1576,9 +1478,7 @@ - + @@ -1593,28 +1493,23 @@ BenillaScroll_SyncBar(this) - + - + - + - + @@ -1661,9 +1556,9 @@ OptionsRow_OnLoad(self, "BlockTrades", "Block Trades", "OPTION_TOOLTIP_BLOCK_TRADES") - + @@ -1702,9 +1597,8 @@ OptionsSliderRow_Setup(self, 0.5, 1.5, 0.05) - + @@ -1720,7 +1614,6 @@ }) - @@ -1757,9 +1650,9 @@ OptionsSliderRow_Setup(self, 90, 270, 10, "raw") - + @@ -1780,8 +1673,7 @@ "OPTION_TOOLTIP_SMART_PIVOT") - + @@ -1807,9 +1699,7 @@ - + @@ -1843,7 +1733,6 @@ OptionsRow_OnLoad(self, "SoundVolume", "Sound Volume", "OPTION_TOOLTIP_SOUND_VOLUME", 1) - -- Era parentInitializer (SettingsPanel.lua l.665). self.parentRow = "RowMaster" @@ -1891,7 +1780,7 @@ self.soundQuirk = 1 - + @@ -1903,9 +1792,8 @@ self.soundQuirk = 1 - + @@ -1920,9 +1808,8 @@ - + @@ -1940,8 +1827,7 @@ OptionsSliderRow_Setup(self, OptionsFrameSliders[1].minValue, OptionsFrameSliders[1].maxValue, OptionsFrameSliders[1].valueStep) - -- Era CommitFlag.Apply (Graphics.lua l.684): a - -- live write would rescale the slider mid-drag. + -- A live write would rescale the slider mid-drag. self.deferred = 1 @@ -1973,16 +1859,16 @@ { "Low", "Medium", "High" }) - + - -- OptionsFrame_SetDefaults' default (l.441-445); + -- OptionsFrame_SetDefaults' default (l.444-445); -- `or 1` covers a VM with no CVars. local gd = tonumber(GetCVarDefault("gamma")) or 1 OptionsNumFuncRow_OnLoad(self, GetGamma, SetGamma, @@ -1993,9 +1879,8 @@ OptionsFrameSliders[6].valueStep, "travel") - + @@ -2004,9 +1889,8 @@ OptionsRow_OnLoad(self, "weatherDensity", "Weather Intensity", "OPTION_TOOLTIP_WEATHER_DETAIL") - -- Our stop names for the reference's rates: - -- `0x67b870` maps 0 to 3 onto the spawn-rate gains - -- {0.1, 0.33, 0.66, 1.0} in `[0x8680ec]`. + -- Our names for `0x67b870`'s spawn-rate gains + -- `{0.1, 0.33, 0.66, 1.0}` (`[0x8680ec]`). OptionsSliderRow_Setup(self, OptionsFrameSliders[9].minValue, OptionsFrameSliders[9].maxValue, OptionsFrameSliders[9].valueStep, nil, @@ -2020,10 +1904,8 @@ -- Deviation: modern Classic's two-entry Display - -- Mode dropdown (Graphics.lua in - -- Blizzard_SettingsDefinitions_Shared), as there - -- is no exclusive fullscreen; gxWindow "0" is the - -- borderless window. Applies on the click. + -- Mode, as there is no exclusive fullscreen; + -- gxWindow "0" is the borderless window. OptionsRow_OnLoad(self, "gxWindow", "Display Mode", "BENILLA_TOOLTIP_DISPLAY_MODE") OptionsDropdownRow_Setup(self, { @@ -2038,9 +1920,8 @@ - -- 1.12's Video Options checkbox 5. Not deferred - -- despite its gxRestart = 1: the present mode - -- changes live. + -- Not deferred despite gxRestart: vsync changes + -- live. OptionsRow_OnLoad(self, "gxVSync", "Vertical Sync", "OPTION_TOOLTIP_VERTICAL_SYNC") @@ -2051,11 +1932,10 @@ - -- Deviation: benilla's own row (the reference draws - -- to the backbuffer), and no Multisampling row, - -- which misses alpha-tested cutouts. Deferred: a - -- live write rebuilds the world render target on - -- every drag tick. + -- Deviation: our own row (the reference draws to + -- the backbuffer), and no Multisampling, which + -- misses alpha-tested cutouts. Deferred: a write + -- rebuilds the render target. OptionsRow_OnLoad(self, "renderScale", "Render Scale", "BENILLA_TOOLTIP_RENDER_SCALE") OptionsSliderRow_Setup(self, 0.5, 2.0, 0.05) @@ -2063,9 +1943,8 @@ - + for i, name in BENILLA_GRAPHICS_SLIDER_ROWS do @@ -2079,8 +1958,7 @@ - + @@ -2272,8 +2150,8 @@ + from SHOW_COMBAT_TEXT; at "0" neither `0x607140` nor + `0x6128b0` floats anything. --> @@ -2284,8 +2162,8 @@ "OPTION_TOOLTIP_SHOW_DAMAGE") - + @@ -2312,11 +2190,9 @@ - + @@ -2383,8 +2259,7 @@ "0", "Show Right ActionBar", "OPTION_TOOLTIP_SHOW_MULTIBAR3") - + @@ -2413,8 +2288,7 @@ OptionsGlobalRow_OnLoad(self, "ALWAYS_SHOW_MULTIBARS", "Always Show ActionBars", "OPTION_TOOLTIP_ALWAYS_SHOW_MULTIBARS") - -- The click arm (UIOptionsFrame.xml:683); the load - -- arm is the stock VARIABLES_LOADED one. + -- UIOptionsFrame.xml:680-683's click arm. self.applyFunc = "MultiActionBar_UpdateGridVisibility" @@ -2430,17 +2304,15 @@ - + - + @@ -2460,9 +2332,8 @@ self.cvarEvent = "STATUS_BAR_TEXT" - + @@ -2474,8 +2345,8 @@ self.applyFunc = "TargetofTarget_Update" - + @@ -2494,8 +2365,8 @@ self.applyFunc = "TargetofTarget_Update" - + @@ -2548,8 +2419,7 @@ "Automatic Quest Tracking", "OPTION_TOOLTIP_AUTO_QUEST_WATCH") - + @@ -2558,23 +2428,22 @@ OptionsRow_OnLoad(self, "profanityFilter", "Profanity Filter", "OPTION_TOOLTIP_PROFANITY_FILTER") - + - + OptionsFuncRow_OnLoad(self, TutorialsEnabled, BenillaOptionsFrame_SetTutorialsEnabled, "1", "Show Tutorials", "OPTION_TOOLTIP_SHOW_TUTORIALS") - + @@ -2596,9 +2465,8 @@ - + @@ -2611,7 +2479,7 @@ - + OptionsRow_OnLoad(self, "guildMemberNotify", "Guild Member Alert", "OPTION_TOOLTIP_GUILDMEMBER_ALERT") @@ -2663,9 +2531,9 @@ "Show Party Chat Bubbles", "OPTION_TOOLTIP_PARTY_CHAT_BUBBLES") - @@ -2680,10 +2548,9 @@ - + @@ -2714,8 +2581,7 @@ OptionsRow_OnLoad(self, "UnitNamePlayer", "Player Names", "OPTION_TOOLTIP_SHOW_PLAYER_NAMES") - + @@ -2745,9 +2611,8 @@ - + @@ -2761,8 +2626,7 @@ - + @@ -2919,12 +2783,11 @@ - + - + @@ -2932,13 +2795,11 @@ ScrollFrame_OnLoad() - + FauxScrollFrame_OnVerticalScroll(KEYBINDINGS_ROW_HEIGHT, KeyBindingsPage_Update) - + if not KeyBindingsPage.selected then ScrollFrameTemplate_OnMouseWheel(arg1) @@ -2947,8 +2808,7 @@ - + if not KeyBindingsPage.selected then @@ -2957,8 +2817,7 @@ - + - + @@ -3025,9 +2883,8 @@ - +