diff --git a/crates/benilla-app/assets/ui/GameMenuFrame.xml b/crates/benilla-app/assets/ui/GameMenuFrame.xml index 11e68d61..cd4aebe1 100644 --- a/crates/benilla-app/assets/ui/GameMenuFrame.xml +++ b/crates/benilla-app/assets/ui/GameMenuFrame.xml @@ -2,7 +2,7 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 6df5aa3e..1f45cd98 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -4055,11 +4055,17 @@ - + diff --git a/crates/benilla-app/assets/ui/UIParent.xml b/crates/benilla-app/assets/ui/UIParent.xml index 3b1f8389..5657677c 100644 --- a/crates/benilla-app/assets/ui/UIParent.xml +++ b/crates/benilla-app/assets/ui/UIParent.xml @@ -1480,8 +1480,8 @@ -- -- **benilla's own drag code does NOT use them**, and that is deliberate rather than an -- oversight: our UI space is 1:1 logical px and `GetCursorPosition` already reports in - -- the same space `SetPoint` offsets are read in (the same identity `MouseIsOver`'s - -- comment below records for `GetEffectiveScale`). Multiplying here would place a dragged + -- the same space `SetPoint` offsets are read in at the UI root's scale of 1 (a frame + -- under a `SetScale` reads its own units — `MouseIsOver` below divides for that). Multiplying here would place a dragged -- frame at 1.5x the cursor on a 1600-wide window. Kept for the addons, unused by us. function GetScreenHeightScale() return GetScreenHeight() / 768 @@ -1512,13 +1512,18 @@ -- hand back for it, and the reference's own comment on that guard ("a symptom not the real -- issue") is about where the unresolved frame came from, not about the guard. -- - -- ONE DIVERGENCE, and it is a no-op: the reference divides the cursor by - -- `frame:GetEffectiveScale()` because 1.12 hands Lua screen pixels. benilla's UI space is - -- 1:1 logical px and `GetEffectiveScale` is the constant 1 (`script/pointer.rs`, - -- `object.rs` l.271), so the division is written out rather than left in to divide by one - -- forever. If a real UI scale ever lands, this is one of the sites that needs it back. + -- The cursor is divided by `frame:GetEffectiveScale()` exactly as the reference does + -- (UIParent.lua l.1389-1390): `GetLeft`/`GetRight`/`GetTop`/`GetBottom` answer in the + -- frame's OWN units (screen ÷ effective scale, `object/layout_methods.rs`), and a frame + -- CAN be scaled — the stock world map is (`SetupFullscreenScale`, 1981), and with the + -- division written out its player blip answered "not over" under the very cursor that + -- had just entered it, so the hover tooltip came up empty (decision 1985). An earlier + -- note here called the scale "the constant 1"; that was true of the UI root and of + -- nothing a `SetScale` has touched. function MouseIsOver(frame, topOffset, bottomOffset, leftOffset, rightOffset) local x, y = GetCursorPosition() + x = x / frame:GetEffectiveScale() + y = y / frame:GetEffectiveScale() local left, right = frame:GetLeft(), frame:GetRight() local top, bottom = frame:GetTop(), frame:GetBottom() if not left then diff --git a/crates/benilla-app/assets/ui/UiPanels.xml b/crates/benilla-app/assets/ui/UiPanels.xml index 5b75fc2f..0279eece 100644 --- a/crates/benilla-app/assets/ui/UiPanels.xml +++ b/crates/benilla-app/assets/ui/UiPanels.xml @@ -404,7 +404,7 @@ -- The reference keeps `CloseMenus` in `UIParent.lua:984`, not in its dropdown file, and -- this file is already where our slice of UIParent.lua lives (`IsOptionFrameOpen` below, -- `CloseAllWindows`, `ToggleGameMenu`). It sits at manifest line 144, ahead of all five - -- consumers — the stock dropdown at 155, MicroMenu, GameMenuFrame and two test kits. + -- consumers — the stock dropdown at 155, the stock micro row, GameMenuFrame and two test kits. -- -- The BOUND is inlined rather than read from `UIDROPDOWNMENU_MAXLEVELS`, which lives in the -- dropdown file: a kit that loads this file and not that one would otherwise fail on diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc index 709c8c1a..76a012bd 100644 --- a/crates/benilla-app/assets/ui/benilla.toc +++ b/crates/benilla-app/assets/ui/benilla.toc @@ -443,13 +443,17 @@ PetActionBarAdapters.xml # reference's toc seats it right after PetActionBarFrame (l.73-74): the bonus buttons' # Up/Down call PetActionButtonUp/Down, which PetActionBar.xml above defines. Interface\FrameXML\BonusActionBarFrame.xml -# The micro-button row in the bar's right-hand recess (the ref's own file split, its TOC -# loads MainMenuBarMicroButtons right after MainMenuBar). After MainMenuBar.xml: it anchors -# into MainMenuBarArtFrame by name and seats itself above the art plate with its own -# BenillaActionBarArt_SeatAbove (moved here from ActionBar.xml with 1938). It must come BEFORE the panels it toggles, because each of -# their OnShow/OnHide calls the `UpdateMicroButtons` this file defines (the panel frames -# themselves are read back through getglobal, so the reverse dependency is call-time). -MicroMenu.xml +# The micro-button row in the bar's right-hand recess — the reference's own file (1751; +# decision 1987), which its toc seats right after MainMenuBar (l.34-35). After MainMenuBar.xml: +# the eight buttons carry parent="MainMenuBarArtFrame", resolved by name at load (1734), and a +# child is born one level above its parent (order.rs; SetParent 0x76ab65) — the seat our retired +# MicroMenu.xml set by hand. BEFORE the panels it toggles: each of their OnShow/OnHide calls the +# UpdateMicroButtons this file defines, and that function reads CharacterFrame, SpellBookFrame, +# QuestLogFrame, GameMenuFrame, OptionsFrame, SoundOptionsFrame, UIOptionsFrame, FriendsFrame, +# WorldMapFrame, HelpFrame and KeyRingButton UNGUARDED at call time, so every one of them is up +# before anything can be shown (SoundOptionsFrame and UIOptionsFrame are OptionsFrame.xml's two +# aliases — our one options window answers for the reference's three). +Interface\FrameXML\MainMenuBarMicroButtons.xml # ══ THE BATTLEGROUND LIST + THE MINIMAP CLUSTER — the reference's own, off the player's chain # (1751; decision 1974) ══ # BattlefieldFrame.xml + BattlefieldFrame.lua: the battlemaster's instance list (the "First @@ -688,7 +692,7 @@ Interface\FrameXML\ContainerFrame.xml # LOAD), TextStatusBar.xml (`SetTextStatusBarTextPrefix`, called at load too, plus the # `TextStatusBar` the pet page's XP bar inherits), UnitFrames.xml (`PlayerFrameHealthBar` / # `PlayerFrameManaBar`, named in that same OnLoad), MainMenuBar.xml (`MainMenuExpBar`, likewise), -# MicroMenu.xml (`MicroButtonTooltipText`, the five tab hovers) and UIParent.xml +# MainMenuBarMicroButtons.xml (`MicroButtonTooltipText`, the five tab hovers) and UIParent.xml # (`Model_OnLoad`/`_RotateLeft`/`_RotateRight`/`_OnUpdate` — the model pane's turntable, the # reference's own code living in our UIParent.lua counterpart). # @@ -817,7 +821,7 @@ Interface\FrameXML\DressUpFrame.xml # the Spell/Pet toggle tabs. Its whole engine surface was already ours (1924's UpdateSpells, 1931's # GetSpellTabInfo tuple); what stopped it in 1926 — MultiActionBar_ShowAllGrids/HideAllGrids on # show/hide — has been the reference's MultiActionBars.lua since 1938. Loaded here, after the -# action-bar files it calls into and after MicroMenu (UpdateMicroButtons on show). The 'P' binding +# action-bar files it calls into and after MainMenuBarMicroButtons (UpdateMicroButtons on show). The 'P' binding # calls its own ToggleSpellBook(BOOKTYPE_SPELL). 0216's transcription is gone. Interface\FrameXML\SpellBookFrame.xml # The one adapter the reference's file needs from this engine: its pet-page autocast shine is a @@ -1029,7 +1033,7 @@ Interface\FrameXML\PetitionFrame.xml # Stock QuestLogFrame.xml sources QuestLogFrame.lua; the 'L' binding calls its ToggleQuestLog(), # and its reward hovers are GameTooltip:SetQuestLogItem / SetQuestLogRewardSpell (engine, 1944). # After QuestFrame.xml (QuestItemTemplate, QuestMoneyFrame_OnLoad, QuestFrameItems_Update), -# MicroMenu.xml (QuestLog_OnShow calls UpdateMicroButtons unguarded) and the same kit as the +# MainMenuBarMicroButtons.xml (QuestLog_OnShow calls UpdateMicroButtons unguarded) and the same kit as the # giver; the reference's toc has the pair at l.53-54. Interface\FrameXML\QuestLogFrame.xml # The timed-quest countdown window under the minimap (decision 1150): the reference's own @@ -1093,7 +1097,7 @@ ScriptLogFrame.xml # UIPanelTemplates (UIPanelCloseButton, UIPanelButtonTemplate2, GameMenuButtonTemplate and the # real UIPanelScrollFrameTemplate the ticket editor is built on), ScrollTemplates (the faux kit # behind the GM category list, its shared trough, and the ScrollingEdit pair), GameTooltip (the -# toast wears the tooltip backdrop colours at OnLoad) and MicroMenu (whose Help button calls this +# toast wears the tooltip backdrop colours at OnLoad) and MainMenuBarMicroButtons (whose Help button calls this # file's ToggleHelpFrame, and whose UpdateMicroButtons this file calls back on show/hide — both # resolved at call time, so that pair could sit either way round). The one seat that is a REAL # requirement rather than legibility is **after BuffFrame.xml**: the toast re-anchors @@ -1130,7 +1134,7 @@ Interface\FrameXML\TabardFrame.xml Interface\FrameXML\TutorialFrame.xml # The game menu (decision 0674): the frame ESC opens. LAST on purpose — it is the outermost # layer of the shell, it depends on everything it touches being loaded (UiPanels' popup engine -# + panel manager, MicroMenu's UpdateMicroButtons, BagFrame's Disable_BagButtons), and nothing +# + panel manager, MainMenuBarMicroButtons' UpdateMicroButtons, BagFrame's Disable_BagButtons), and nothing # depends on it: the ESC chain and the micro button both reach it by name at call time. # # NOT yet on the chain, and the reason is not in the gap report: the stock file's buttons reach diff --git a/crates/benilla-app/src/capture/probe_taxi.rs b/crates/benilla-app/src/capture/probe_taxi.rs index f73a577e..2a59965e 100644 --- a/crates/benilla-app/src/capture/probe_taxi.rs +++ b/crates/benilla-app/src/capture/probe_taxi.rs @@ -48,7 +48,14 @@ impl Plugin for ProbeTaxiPlugin { load_expectations.after(benilla_assets::AssetSet::Open), ) .add_systems(Update, taxi_probe) - .add_systems(PreUpdate, hold_w_post_land.after(bevy::input::InputSystems)); + .add_systems( + PreUpdate, + // After the loading cover's input swallow as well as winit's input pass — see + // `probes::act::ProbeKeyPlugin` for why a synthetic press orders past it. + hold_w_post_land + .after(bevy::input::InputSystems) + .after(crate::loading_screen::CoverInput), + ); } } diff --git a/crates/benilla-app/src/capture/probes/act.rs b/crates/benilla-app/src/capture/probes/act.rs index 9641dbca..ac3a5b5f 100644 --- a/crates/benilla-app/src/capture/probes/act.rs +++ b/crates/benilla-app/src/capture/probes/act.rs @@ -145,7 +145,12 @@ impl Plugin for ProbeKeyPlugin { app.insert_resource(ProbeKeys { taps, armed: false }) .add_systems( bevy::app::PreUpdate, - fire_probe_key.after(bevy::input::InputSystems), + fire_probe_key + .after(bevy::input::InputSystems) + // …and after the loading cover's input swallow, which runs in the same window + // and empties every button plane: an instrument driving the client is the + // operator, not the player, so its press has to land on the far side of it. + .after(crate::loading_screen::CoverInput), ); } } diff --git a/crates/benilla-app/src/cursor.rs b/crates/benilla-app/src/cursor.rs index 71b581c4..805ca1ae 100644 --- a/crates/benilla-app/src/cursor.rs +++ b/crates/benilla-app/src/cursor.rs @@ -34,8 +34,18 @@ use bevy::prelude::*; /// The held cursor payload's icon path (`Interface\Icons\…`, extensionless — the DBC/FrameXML /// convention), any arm — `None` if nothing is held or that arm's icon hasn't resolved yet /// (either way, the caller falls back to the mode cursor). -fn payload_icon(script: &benilla_ui::script::UiScript) -> Option { +/// +/// **`covered` — the loading cover drops the overlay** (decision 1990, VERIFIED). The reference's +/// world transition ends `0x401900 → 0x495920 → 0x6e4940` with `0x523d20(1)` + `0x523c20(1)`: +/// cursor index **1**, the plain arrow, with any item/spell overlay dropped, set *before* the +/// screen goes up. Without this a portal taken with an item on the cursor showed the item's icon +/// over the loading art for the whole load — the mode half of the same rule is +/// [`drive_displayed_cursor`]'s. +fn payload_icon(script: &benilla_ui::script::UiScript, covered: bool) -> Option { use benilla_ui::script::CursorPayload; + if covered { + return None; + } match script.cursor_payload()? { CursorPayload::Item(i) => i.texture, CursorPayload::Spell(s) => s.texture, @@ -193,6 +203,8 @@ fn drive_displayed_cursor( // world, so re-entering the UI always restores once. mut last: Local>>, mut displayed: ResMut, + // The loading cover parks the mode at Point — see the arm at the top of the body. + screen: Res, ) { use crate::target::{CursorKind, WorldCursor}; use benilla_ui::script::UiCursorMode; @@ -204,6 +216,19 @@ fn drive_displayed_cursor( // glue phase instead of every frame inside it. let last = last.get_for(script.as_deref()); + // **Under the loading cover the cursor is the plain arrow** (decision 1990, VERIFIED). The + // reference's transition parks cursor index **1** at `0x6e49f5`/`0x6e49ff` before raising the + // screen; ours is parked here every covered frame instead of once, which reads the same because + // the cover has already taken the input plane and nothing can write the mode under it. Ahead of + // every other arm — a FrameXML write, the repair/targeting base, the sticky UI mode — because + // the reference's park is likewise unconditional. `last` is cleared like the over-world arm, so + // the first crossing back into the UI after the reveal restores the base once. + if screen.covering() { + displayed.0 = WorldCursor::default(); + *last = None; + return; + } + // **The BASE mode is not a constant** (`0xbe2c4c`, wow-re cursor-system.md §7: *"it is // independently mutable — e.g. a spell-cancel flow parks it at Cast(2)"*), and that is the // piece B208 kept missing. `ResetCursor` restores *the value of this cell*, not a hardcoded @@ -372,6 +397,8 @@ mod other { world_assets: Option>, mut images: ResMut>, mut payload_cursors: ResMut, + // The loading cover drops the payload overlay — see [`payload_icon`]. + screen: Res, window: Option>>, // The key of the cursor we last handed the window — the macOS arm's `last_set` under the // same name, because it is the same fact: OS state, not memory about the VM, so it is not a @@ -382,7 +409,9 @@ mod other { let Some(window) = window else { return; }; - let held_icon = script.as_ref().and_then(|s| payload_icon(s)); + let held_icon = script + .as_ref() + .and_then(|s| payload_icon(s, screen.covering())); if let Some(icon) = held_icon { if last_set.as_deref() == Some(icon.as_str()) { return; // already showing this icon @@ -521,6 +550,8 @@ mod macos { script: Option>, world_assets: Option>, mode: Res, + // The loading cover drops the payload overlay — see [`super::payload_icon`]. + screen: Res, rig: Res, cinematic: Option>, mut focus: MessageReader, @@ -552,7 +583,9 @@ mod macos { *last_set = None; } } - let held_icon = script.as_ref().and_then(|s| payload_icon(s)); + let held_icon = script + .as_ref() + .and_then(|s| payload_icon(s, screen.covering())); if let Some(icon) = &held_icon { if !payload_cursors.0.contains_key(icon) && !decode_failed.contains(icon) { let built = world_assets.and_then(|mut a| { @@ -704,11 +737,29 @@ mod tests { over_ui: bool, lua: &str, armed: bool, + ) -> WorldCursor { + frame_covered(standing, world, over_ui, lua, armed, false) + } + + /// One frame, with `covered` deciding whether the loading cover is up — the arm that parks the + /// plain arrow ahead of every other rule. + fn frame_covered( + standing: WorldCursor, + world: WorldCursor, + over_ui: bool, + lua: &str, + armed: bool, + covered: bool, ) -> WorldCursor { let mut app = App::new(); let script = benilla_ui::script::UiScript::new().unwrap(); script.run(lua).unwrap(); app.insert_non_send_resource(script); + app.insert_resource(if covered { + crate::loading_screen::LoadingScreen::test_covering() + } else { + crate::loading_screen::LoadingScreen::default() + }); app.insert_resource(world); app.insert_resource(crate::ui_script::PointerOverUi(over_ui)); app.insert_resource(DisplayedCursor(standing)); @@ -724,6 +775,35 @@ mod tests { app.world().resource::().0 } + /// **Under the loading cover the cursor is the plain arrow, whatever else is true** (decision + /// 1990). The reference's world transition parks cursor index 1 (`0x6e49f5`/`0x6e49ff`) and + /// drops any item/spell overlay *before* the screen goes up, so a portal taken with a spell + /// armed — or with an item on the cursor — arrives showing the arrow, not the payload. + /// + /// Both of the arms that would otherwise win are exercised here: an armed spell (which parks + /// the BASE at Cast, so even the UI restore reads blue) and a FrameXML write in the same frame. + #[test] + fn the_loading_cover_parks_the_plain_arrow_over_every_other_rule() { + for (over_ui, lua, armed) in [ + (false, "", false), + (true, "", true), + (true, "SetCursor(\"CAST_CURSOR\")", true), + (false, "", true), + ] { + assert_eq!( + frame_covered(SWORD, SWORD, over_ui, lua, armed, true), + WorldCursor::default(), + "covered: the arrow wins (over_ui={over_ui}, armed={armed}, lua={lua:?})" + ); + } + // …and the cover is the only reason: the same frame uncovered keeps the world's verdict. + assert_eq!( + frame_covered(SWORD, SWORD, false, "", false, false), + SWORD, + "no cover, no park" + ); + } + /// **The base mode is Cast while a spell awaits its click** (wow-re cursor-system.md §7: the /// base cell `0xbe2c4c` "is independently mutable — e.g. a spell-cancel flow parks it at /// Cast(2)"), and `ResetCursor` restores *that value*, never a hardcoded Point. @@ -819,6 +899,7 @@ mod tests { ); script.run("ShowContainerSellCursor(0, 1)").unwrap(); app.insert_non_send_resource(script); + app.insert_resource(crate::loading_screen::LoadingScreen::default()); app.insert_resource(CAST_GREY); app.insert_resource(crate::ui_script::PointerOverUi(true)); app.insert_resource(DisplayedCursor(CAST_GREY)); diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index 6a1a752c..ebd9343c 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -670,6 +670,19 @@ type WireBody = ( Option<&'static benilla_world::world_unit::WorldUnit>, ); +/// One viewer-reconcile row: the entity, whether it is the embodied self, whether it is marked. +type ViewerRow = ( + Entity, + Has, + Has, +); + +/// Only entities that can be a viewer at all — one of the two markers present. +type ViewerCandidate = Or<( + With, + With, +)>; + /// The edges on which a body's [`WorldUnit`](benilla_world::world_unit::WorldUnit) restatement can /// move — [`publish_world_units`]' query filter (the anchor's removal is read separately). type WireBodyMoved = Or<( @@ -696,11 +709,9 @@ fn publish_world_units( bodies: Query, all_bodies: Query, mut unanchored: RemovedComponents, - viewers: Query<( - Entity, - Has, - Has, - )>, + // Only entities that can be a viewer at all: the reconcile below needs one of the two + // present, and an unfiltered query walked every entity in the world each frame. + viewers: Query, ) { let freed: Vec = unanchored.read().collect(); let due = bodies diff --git a/crates/benilla-app/src/loading_screen.rs b/crates/benilla-app/src/loading_screen.rs index 01dfd3f5..8de71b3b 100644 --- a/crates/benilla-app/src/loading_screen.rs +++ b/crates/benilla-app/src/loading_screen.rs @@ -11,9 +11,16 @@ //! The two cases out of current scope (taxi/boat/zeppelin's moving flight-path icon; a richer //! progress model) slot in as an extra overlay layer / a different progress source without restructure. //! -//! **The lifecycle is event-raised, readiness-cleared (decision 0737).** The reference *blocks* on -//! its world load, so its screen covers from the entry edge until the world is loaded by -//! construction; an async-streaming client has to build that observable explicitly. The screen +//! **The lifecycle is event-raised, readiness-cleared (decision 0737)** — and the reference's is +//! too, which 0737 assumed it was not. This header claimed for months that the reference *blocks* +//! on its world load and so covers "by construction"; the wow-re round behind decision 1990 says +//! half of that is wrong. The reference runs ordinary frames with **one** blocking stretch inside +//! them (`SMSG_NEW_WORLD`'s `0x401b00` defers `0x401bc0` onto the deadline heap, which drains at +//! `0x420d0c` and runs to completion in that one iteration, `Sleep(1)` residency spins and all) — +//! but the screen is raised *frames earlier*, at `TRANSFER_PENDING`, and dismissed *frames later* +//! by a per-frame readiness poll. Which is this file's shape. What an async-streaming client has to +//! build explicitly is not the lifecycle after all: it is the **input suppression** the blocking +//! stretch does not provide either (see [`input`], decision 1990). The screen //! rises at the explicit edges — the character pick's `Connected` edge (before the glue tears //! down), `SMSG_TRANSFER_PENDING` (the portal walk-in), the worldport snap — all observed *here*, //! from the messages/resources the net bridge already publishes; a residency backstop catches any @@ -37,6 +44,12 @@ use benilla_world::schedule::WorldStage; use benilla_world::terrain_stream::WorldLoadProgress; use benilla_world::world_map::CurrentMap; +/// **The cover takes the input plane** — the loading screen's input half, in its own file because +/// it is a different mechanism (a source cut in `PreUpdate`) from the state machine below, and one +/// file should not have to explain both. +mod input; +pub(crate) use input::CoverInput; + // Bar layout — VERIFIED THREE ways (build 5875): the `WoW.exe` `LoadingScreen.cpp` bar descriptor // table (@0x7ffd34, `FUN_00407150`) gives entry = {cx, cy, halfW, halfH} with rect = [cx ± halfW·0.5] // × [cy ± halfH·0.5] and fill right edge = left + progress·halfW; Border {0.5,0.075,0.600,0.050}, Fill @@ -211,7 +224,8 @@ impl LoadingScreen { self.active } - /// An active cover, for tests that drive covered-frame accounting (the entry-load deferral). + /// An active cover, for tests that drive covered-frame accounting (the entry-load deferral) + /// and the cursor's covered arm. #[cfg(test)] pub(crate) fn test_covering() -> Self { Self { @@ -259,6 +273,10 @@ impl Plugin for LoadingScreenPlugin { // we cover it here. Visibility set now propagates in PostUpdate and renders this frame, so // the swap never flashes. .add_systems(Update, drive_loading_screen.in_set(WorldStage::Present)); + // …and the other half of what a cover *is*: while it is up, the client takes no input + // (`input`). Wired here rather than as a plugin of its own so the cover and its input rule + // can never be registered apart. + input::build(app); } } diff --git a/crates/benilla-app/src/loading_screen/input.rs b/crates/benilla-app/src/loading_screen/input.rs new file mode 100644 index 00000000..9ff11bf5 --- /dev/null +++ b/crates/benilla-app/src/loading_screen/input.rs @@ -0,0 +1,440 @@ +//! **The cover takes the input plane.** While the loading screen covers the frame, benilla accepts +//! no mouse and no keyboard: no hover, no cursor classification, no click, no keybinding, no +//! camera, no movement. One system, at the source, so *nothing under the cover has to know the +//! cover exists*. +//! +//! ## The reference does this too, and it does it at the source +//! +//! This file was written on the assumption that the reference simply blocks inside its world load, +//! so the question never arises there. The wow-re round behind decision 1990 says otherwise, and +//! the answer is better than the assumption: **the reference has an explicit input veto, registered +//! by the screen's own raise.** +//! +//! `0x406800` (the raise) ends `0x4068e0 call 0x4069e0`, which registers the three-byte stub +//! `0x406a50` — `xor eax,eax; ret` — on event-bus categories **1, 8 (key down), 0xa (auto-repeat), +//! 0xb (button down) and 0xc (mouse move)** at priority **8.0f**; `0x407e80` (the dismiss) +//! unregisters all five. The dispatcher `0x4245b0` stops its walk on a zero return (`0x4246ad`), +//! and every `CSimpleTop` input handler sits at 1.0f — so those five categories never reach the +//! interface at all while the screen is up. Category 0xc's registrant is `UpdateMouseFocus` +//! (`0x7660d0`) itself, so the veto stops the pointer resolver outright: **the cursor freezes.** +//! Key-up, button-up, the wheel, category 0x10 and `WM_ACTIVATE` are deliberately let through. +//! +//! So the shape below is the reference's shape — one veto at the source, not a condition on each +//! consumer — arrived at independently and then confirmed. Two deliberate differences, named +//! rather than drifted into: +//! +//! - **We swallow the wheel and the up edges too.** The up edges are equivalent by outcome: the +//! reference's held key comes back **released** anyway (`0x4908c0 → 0x49093c → 0x5144c0` zeroes +//! the movement mask on world-enter, and a still-held key cannot re-arm it — it reclassifies to +//! the vetoed auto-repeat category), which is exactly what the release edge below produces. The +//! wheel is a real deviation: in the reference a notch under the loading screen still zooms the +//! camera you cannot see. Swallowing it is the director's "nothing through the screen", and it +//! costs a zoom nobody asked for. +//! - **We clear the world pick; the reference leaves it running at the frozen point.** `0x481790` +//! hangs off the layer walk rather than the bus, so it keeps picking — and in the two windows +//! where a world is populated (before `SMSG_NEW_WORLD`, and again once the new player object +//! exists) it *can* set a context cursor under the screen. It is invisible there only because the +//! pointer cannot move. Ours is strictly quieter and observably identical. +//! +//! ## Why this is a source cut and not a run condition +//! +//! The obvious way to build it is a run condition per input system. That is the way it regresses: +//! the census behind this module counted **fifty-one** places raw input enters the app, more than +//! half of them with no state gate of any kind — the whole `target::TargetUpdate` pick chain +//! included, which is the one the director caught. Every one of them would have had to remember, +//! and every future one would have to remember too. The director's report was exactly a thing +//! nobody remembered: the world pick ran under the cover, found a unit, and the *hardware cursor +//! changed to the sword* over art the player was only waiting on. +//! +//! So the cut is at the source, in `PreUpdate`, after `bevy::input::InputSystems` has built this +//! frame's input and before anything reads it. A consumer written a year from now is covered +//! without knowing this file exists — which is the only property that survives a multi-year +//! codebase. +//! +//! ## The four things it takes, and what makes the pointer half work +//! +//! 1. **The button planes** — `ButtonInput`/``/``. See [`swallow`] for +//! the exact rule: a press that arrives under the cover never happened, and anything held from +//! *before* the cover is released once, with a real release edge, so a gesture already in +//! flight (a mouselook session, a held `MOVEFORWARD` binding, an armed UI drag) unwinds through +//! the path it already has for a released button instead of being stranded down forever. +//! 2. **The raw message queues** — `KeyboardInput`, `MouseButtonInput`, `MouseMotion`, +//! `MouseWheel`, `CursorMoved` — drained, because a `MessageReader` reads the queue, not the +//! button state (`feed_ui_input`'s character/EditBox feed and the glue screens all read raw). +//! 3. **The accumulators** — `AccumulatedMouseMotion`/`AccumulatedMouseScroll`, which +//! `InputSystems` builds from those same messages and which the camera and the wheel bindings +//! poll rather than read. +//! 4. **The pointer position itself**, which is the half a message drain cannot reach: a hit-test +//! does not read an event, it reads `Window::cursor_position()`, a *field*. So the cover blanks +//! it and restores it on the way out ([`CoveredPointer`]). +//! +//! That fourth one is the load-bearing trick, and it is not a hack: **"the pointer is not in the +//! window" is a state every pointer consumer in the client already implements correctly**, because +//! it is the ordinary alt-tabbed-away case. `update_hover`/`update_hovered_object` clear +//! [`crate::target::Hovered`] and return; `update_pick_occlusion` leaves the ray at infinity; +//! `classify_cursor` then reads an empty pick and settles on Point; `feed_ui_input` takes its +//! `pointer_left_window()` arm, which leaves the hovered frame (one `OnLeave`) and disarms any +//! press/drag; bevy's own `ui_focus_system` clears every `Interaction`, so `glue_clicks` goes +//! quiet with it. Not one of those had to be told about loading screens. +//! +//! ## What it deliberately does not take +//! +//! **Synthetic input.** The capture probes press into the same `ButtonInput` resources — but they +//! do it in `Update`, after this system, and the pointer probes drive +//! `ui_script::SyntheticPointer` rather than the window. An instrument driving the client is the +//! operator, not the player. The two probe writers that *do* run in `PreUpdate` order themselves +//! [`after`](CoverInput) this set so the relationship is written down rather than left ambiguous. +//! +//! **The cursor's own art.** Parking it at the plain arrow and dropping any item/spell overlay is +//! the reference's `0x6e4940` (cursor index 1, set *before* the screen goes up), and it lives with +//! the cursor in [`crate::cursor`] rather than here — it is an output, not a channel. +//! +//! **Nothing else.** There is no dev exemption: the debug panel, the perf pill, the inspector and +//! their chords go quiet under the cover along with everything else. That is a real cost — a stuck +//! load is exactly when you want the instruments — and it is deliberate, because the alternative +//! is a keycode allowlist, and a keycode handed to the dev plane is also a keycode handed to the +//! keybinding table. The diagnosis path for a stuck load is the wait instrument +//! ([`super::WAIT_LOG_AFTER`]), which names the blocking term without anyone touching a key. + +use bevy::input::keyboard::{Key, KeyboardInput}; +use bevy::input::mouse::{ + AccumulatedMouseMotion, AccumulatedMouseScroll, MouseButtonInput, MouseMotion, MouseWheel, +}; +use bevy::math::DVec2; +use bevy::prelude::*; +use bevy::window::{CursorMoved, PrimaryWindow}; + +use super::LoadingScreen; + +/// The set [`swallow_input_under_the_cover`] runs in — `PreUpdate`, after +/// `bevy::input::InputSystems` and before `bevy::ui::UiSystems::Focus`. +/// +/// Exported so the handful of `PreUpdate` systems that legitimately write input — the capture +/// probes — can order themselves after it instead of racing it. +#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct CoverInput; + +/// Where the OS pointer really is while the cover has the window's cursor position blanked. +/// +/// Re-read every covered frame, not stashed once: `bevy_winit` keeps writing the window's position +/// on every physical move regardless of whether anyone reads the `CursorMoved` message we drain, +/// so this tracks the pointer through the whole load and hands back the *current* place on the way +/// out. Without the hand-back the position would stay `None` until the player's next mouse move — +/// hover would come back dead after a teleport, which is the same defect in the other direction. +#[derive(Resource, Default)] +pub(crate) struct CoveredPointer { + /// The last position seen while covered, in physical pixels. + stashed: Option, + /// Is the window's cursor position currently ours (blanked) rather than winit's? + blanked: bool, +} + +/// One frame's swallow of one button plane. +/// +/// The whole rule, and it needs no memory of its own: +/// +/// - **A press that arrives under the cover never happened** — `just_pressed` is exactly this +/// frame's arrivals, and [`ButtonInput::reset`] takes each out of all three sets. +/// - **Whatever is still held came from before the cover, and is released once, with an edge** — +/// after the step above, `pressed` can only contain buttons held on an earlier frame, so +/// [`ButtonInput::release_all`] moves precisely those into `just_released`. On every *later* +/// covered frame `pressed` is already empty and this is a no-op, so the release edge is +/// delivered exactly once per cover, with no flag to keep. +/// +/// A real OS release arriving mid-cover is likewise silent: bevy's own release only records an +/// edge for a button it still has pressed, and we took it out on the first covered frame. +fn swallow(input: &mut ButtonInput) +where + T: Copy + Eq + std::hash::Hash + Send + Sync + 'static, +{ + let arrived: Vec = input.get_just_pressed().copied().collect(); + for button in arrived { + input.reset(button); + } + input.release_all(); +} + +/// `swallow` for a plane whose button type is not `Copy` (`ButtonInput`'s logical keys carry +/// a `SmolStr`). +fn swallow_cloned(input: &mut ButtonInput) +where + T: Clone + Eq + std::hash::Hash + Send + Sync + 'static, +{ + let arrived: Vec = input.get_just_pressed().cloned().collect(); + for button in arrived { + input.reset(button); + } + input.release_all(); +} + +/// The raw input-message queues, as one [`bevy::ecs::system::SystemParam`] — bevy's 16-element +/// system-param ceiling, the same squeeze `drive_loading_screen` next door already pays. +#[derive(bevy::ecs::system::SystemParam)] +struct RawInput<'w> { + keyboard: ResMut<'w, Messages>, + buttons: ResMut<'w, Messages>, + motion: ResMut<'w, Messages>, + wheel: ResMut<'w, Messages>, + moved: ResMut<'w, Messages>, +} + +impl RawInput<'_> { + fn drain(&mut self) { + self.keyboard.clear(); + self.buttons.clear(); + self.motion.clear(); + self.wheel.clear(); + self.moved.clear(); + } +} + +/// The button planes + the accumulators, as one param (see [`RawInput`] on the ceiling). +#[derive(bevy::ecs::system::SystemParam)] +struct Buttons<'w> { + keys: ResMut<'w, ButtonInput>, + logical: ResMut<'w, ButtonInput>, + mouse: ResMut<'w, ButtonInput>, + motion: ResMut<'w, AccumulatedMouseMotion>, + scroll: ResMut<'w, AccumulatedMouseScroll>, +} + +impl Buttons<'_> { + fn swallow_all(&mut self) { + swallow(&mut *self.keys); + swallow_cloned(&mut *self.logical); + swallow(&mut *self.mouse); + *self.motion = AccumulatedMouseMotion::default(); + *self.scroll = AccumulatedMouseScroll::default(); + } +} + +/// `PreUpdate`, in [`CoverInput`]: take the whole input plane for as long as the cover is up. +/// +/// **The one-frame edge, stated so nobody has to rediscover it.** The cover is raised in `Update` +/// (`drive_loading_screen`, in `WorldStage::Present`), and this runs in `PreUpdate`, so the raise +/// frame itself still takes input — its `Input` stage ran before the raise did. That is the +/// correct boundary rather than a miss: the raise frame's *render* is the first one that draws the +/// cover, so input dies on exactly the frames whose previous present showed it, and the player +/// never sees the cover on a frame that also acted on their mouse. +fn swallow_input_under_the_cover( + screen: Res, + mut pointer: ResMut, + mut window: Query<&mut Window, With>, + mut buttons: Buttons, + mut raw: RawInput, +) { + if !screen.covering() { + // The way out: hand the window its pointer back, once, on the frame the cover drops. + if pointer.blanked { + pointer.blanked = false; + let stashed = pointer.stashed.take(); + if let Ok(mut window) = window.single_mut() { + window.set_physical_cursor_position(stashed); + } + } + return; + } + + buttons.swallow_all(); + raw.drain(); + + // The pointer half. Re-stash before blanking so the position tracks the real cursor through + // the load (see [`CoveredPointer`]); `physical_cursor_position()` already answers `None` for a + // pointer outside the window, which is the same nothing we are about to write. + if let Ok(mut window) = window.single_mut() { + if let Some(seen) = window.physical_cursor_position() { + pointer.stashed = Some(seen.as_dvec2()); + } + if window.physical_cursor_position().is_some() { + window.set_physical_cursor_position(None); + } + pointer.blanked = true; + } +} + +/// Wire the gate. Called by [`super::LoadingScreenPlugin`] — this is not a plugin of its own, +/// because the cover and its input rule are one system and must never be registerable apart. +pub(super) fn build(app: &mut App) { + app.init_resource::().add_systems( + PreUpdate, + swallow_input_under_the_cover + .in_set(CoverInput) + // After the input is built, before anything reads it. `UiSystems::Focus` is bevy's own + // `PreUpdate` reader (it hit-tests the cursor into `Interaction`, which is what + // `glue::glue_clicks` rides), so it has to land on the other side of us. + .after(bevy::input::InputSystems) + .before(bevy::ui::UiSystems::Focus), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy::input::ButtonState; + + /// The [`swallow`] rule, both halves, in the order they matter: a press that arrives under the + /// cover is erased, and a button held from before it is released **once**, with a real edge. + #[test] + fn a_press_under_the_cover_never_happened_and_a_held_one_is_released_once() { + let mut keys = ButtonInput::::default(); + + // Held from before the cover: pressed on an earlier frame, so its `just_pressed` edge is + // gone by now (bevy clears the edge sets at the head of every frame). + keys.press(KeyCode::KeyW); + keys.clear(); + // …and this frame, under the cover, the player also presses SPACE. + keys.press(KeyCode::Space); + + swallow(&mut keys); + + assert!(!keys.pressed(KeyCode::Space), "the arrival is erased"); + assert!(!keys.just_pressed(KeyCode::Space), "…edge and all"); + assert!( + !keys.just_released(KeyCode::Space), + "a press that never happened cannot release either" + ); + assert!(!keys.pressed(KeyCode::KeyW), "the held key is let go"); + assert!( + keys.just_released(KeyCode::KeyW), + "…with a real release edge, so a held MOVEFORWARD binding unwinds" + ); + + // The next covered frame: bevy clears the edge sets, and there is nothing left to release. + keys.clear(); + swallow(&mut keys); + assert_eq!( + keys.get_just_released().count(), + 0, + "the release is delivered exactly once per cover, not every frame" + ); + assert_eq!(keys.get_pressed().count(), 0); + } + + /// A real OS release arriving mid-cover produces no second edge — bevy's own `release` only + /// records one for a button it still holds pressed, and the first covered frame took it out. + #[test] + fn an_os_release_under_the_cover_is_silent() { + let mut keys = ButtonInput::::default(); + keys.press(KeyCode::KeyW); + keys.clear(); + swallow(&mut keys); // frame 1: W released, with its edge + + keys.clear(); // frame 2's head, as bevy's own input systems do it + keys.release(KeyCode::KeyW); // the player physically lets go + swallow(&mut keys); + assert!( + !keys.just_released(KeyCode::KeyW), + "no second release edge for the same key" + ); + } + + /// The gate against a real `App` and bevy's own `InputSystems`: with the cover up, a frame of + /// input reaches nobody — button planes, raw queues and the window's cursor position all read + /// empty — and the pointer comes back on the frame the cover drops, with no mouse move needed. + #[test] + fn a_covered_frame_hands_no_input_to_anyone_and_gives_the_pointer_back() { + let mut app = App::new(); + app.add_plugins(( + bevy::input::InputPlugin, + // For `Messages` — a window message, not an input one — and for the + // window types themselves. `primary_window: None`: the test spawns its own. + bevy::window::WindowPlugin { + primary_window: None, + exit_condition: bevy::window::ExitCondition::DontExit, + ..default() + }, + )) + .init_resource::() + .init_resource::() + // `UiSystems::Focus` is bevy's, and pulling `UiPlugin` into a unit test to state an + // ordering the real wiring already states would test bevy, not us. + .add_systems( + PreUpdate, + swallow_input_under_the_cover + .in_set(CoverInput) + .after(bevy::input::InputSystems), + ); + let window = app + .world_mut() + .spawn(( + Window { + resolution: bevy::window::WindowResolution::new(800, 600), + ..default() + }, + PrimaryWindow, + )) + .id(); + + let seen = |app: &App| { + app.world() + .entity(window) + .get::() + .unwrap() + .physical_cursor_position() + }; + let put_cursor = |app: &mut App, at: Option| { + app.world_mut() + .entity_mut(window) + .get_mut::() + .unwrap() + .set_physical_cursor_position(at); + }; + + // A pointer in the middle of the window, and a key held from before the cover. + put_cursor(&mut app, Some(DVec2::new(400.0, 300.0))); + app.world_mut() + .resource_mut::>() + .press(KeyCode::KeyW); + + // --- Uncovered: an ordinary frame. Nothing is touched. + app.update(); + assert!(seen(&app).is_some(), "no cover, no blanking"); + assert!( + app.world() + .resource::>() + .pressed(KeyCode::KeyW), + "no cover, no swallow" + ); + + // --- The cover goes up, and the player presses SPACE under it. + app.world_mut().resource_mut::().active = true; + app.world_mut().write_message(KeyboardInput { + key_code: KeyCode::Space, + logical_key: Key::Space, + state: ButtonState::Pressed, + text: None, + repeat: false, + window, + }); + app.update(); + + assert!( + seen(&app).is_none(), + "the pointer is not in the window while the cover is up — the one fact every \ + hit-test in the client already handles" + ); + let keys = app.world().resource::>(); + assert_eq!(keys.get_pressed().count(), 0, "no key survives the cover"); + assert!( + keys.just_released(KeyCode::KeyW), + "…and the one held across the raise is released with an edge" + ); + assert!( + !keys.pressed(KeyCode::Space) && !keys.just_pressed(KeyCode::Space), + "the press that arrived under the cover never happened" + ); + assert!( + app.world().resource::>().is_empty(), + "the raw queue is drained too, so a MessageReader sees nothing either" + ); + + // --- The cover clears: the pointer comes straight back. + app.world_mut().resource_mut::().active = false; + app.update(); + assert_eq!( + seen(&app), + Some(Vec2::new(400.0, 300.0)), + "the position is handed back on the frame the cover drops" + ); + } +} diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index 2f1c2c68..91b3830d 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -1234,12 +1234,13 @@ pub(super) fn apply_net_updates( } SessionEvent::RaidTargetSet { icon, guid } => group.apply_raid_target(icon, guid), SessionEvent::RaidTargetList { entries } => group.apply_raid_target_list(&entries), - // The ready check came back with the Raid tab (decision 1549): the open form bumps - // the ticket the feed turns into a `READY_CHECK` edge. The ANSWER form is still - // ignored — the reference has no per-member answer surface in 1.12 (the raid pane - // shows no ready column; only later clients do), so there is nothing to show and - // storing it would be state with no reader. - SessionEvent::ReadyCheckRequest => group.apply_ready_check(), + // The ready check (decision 1549, completed by 1989): the open form takes the + // leader arm or the popup arm by our guid; the ANSWER form — forwarded to the leader + // alone — is logged for the engine's flags, which the timeout tick sums up after 30 s + // (1.12 has no per-member answer surface, only the AFK summary line). + SessionEvent::ReadyCheckRequest => { + group::ready_check_request(&mut group, &mut chat_log, &self_guid) + } SessionEvent::RaidInstanceInfo { entries } => group.apply_raid_instance_info(entries), // A group member pinged (decision 1596). The wire carries raw world floats and the // relay is stateless in the reference too — we seat them as the pin and the minimap @@ -1249,7 +1250,9 @@ pub(super) fn apply_net_updates( SessionEvent::MinimapPing { guid, x, y } => { ping.seat((x, y), guid); } - SessionEvent::ReadyCheckAnswer { .. } => {} + SessionEvent::ReadyCheckAnswer { guid, ready } => { + group.apply_ready_check_answer(guid, ready != 0) + } // ── The duel family (decision 0633): the session mirror + the two DisplayError // lines the handlers emit inline; the Era events fire off the mirror's edges in // `ui_duel::feed_duel`, and the countdown ticks in its own system ── diff --git a/crates/benilla-app/src/net/apply/group.rs b/crates/benilla-app/src/net/apply/group.rs index 190d82a8..34f734ff 100644 --- a/crates/benilla-app/src/net/apply/group.rs +++ b/crates/benilla-app/src/net/apply/group.rs @@ -26,6 +26,18 @@ fn push_group_lines(chat_log: &mut ChatLog, lines: Vec) { } } +/// `MSG_RAID_READY_CHECK`, the open form (decision 1989): our own echo as leader takes the +/// response-collection arm and prints nothing; as anyone else we print the leader's line and take +/// the popup ticket. The leader test is the reference's guid compare (`0x4ba3a0`). +pub(super) fn ready_check_request( + group: &mut GroupState, + chat_log: &mut ChatLog, + self_guid: &SelfGuid, +) { + let we_lead = self_guid.0 == Some(group.leader); + push_group_lines(chat_log, group.apply_ready_check_request(we_lead)); +} + /// `SMSG_GROUP_INVITE` — someone asked us into their group. pub(super) fn invited(group: &mut GroupState, chat_log: &mut ChatLog, inviter: &str) { push_group_lines(chat_log, group.apply_invited(inviter)); diff --git a/crates/benilla-app/src/player/controller.rs b/crates/benilla-app/src/player/controller.rs index e8c8a9fc..d56f4cf6 100644 --- a/crates/benilla-app/src/player/controller.rs +++ b/crates/benilla-app/src/player/controller.rs @@ -98,6 +98,10 @@ pub(super) fn control( // The spyglass scope ([`scoped_view`]): while held, the rig is pinned to first person and // the wheel cannot leave it. Res, + // Is the loading cover up? The cover takes the whole input plane at the source + // (`loading_screen::input`), so nothing new arrives here — but a mouse gesture already in + // flight is retained state, and only its owner can unwind it. See the cancel below. + Res, ), mut commands: Commands, mut player: ResMut, @@ -156,6 +160,7 @@ pub(super) fn control( let view_subject = &speed_capsule.8; let self_guid = speed_capsule.9 .0; let scoped = &speed_capsule.10; + let covered = speed_capsule.11.covering(); // The auto-follow knobs (decisions 1493/1502), with far sight's one exception folded in here so // both camera seats below agree: while the rig orbits somebody ELSE's body (Mind Vision, Sentry // Totem), our own facing is not what "behind" means, so the return is forced off rather than @@ -264,6 +269,20 @@ pub(super) fn control( drunk::wobble(time.elapsed().as_millis() as u32, f) } }; + // **A cover cancels a gesture; it never completes one.** The cover emptied the button planes + // in `PreUpdate`, so the look session below ends on its own this frame — and ending it also + // *settles* the click test the press armed, against `PressGesture::is_click`'s free window. A + // right-press 0.1 s before a portal would therefore have dispatched a `WorldRightClick` under + // the loading screen, acting on the pick latched in the world we just left. Dropping both + // tests first leaves the session to end with nothing to fire. This is the one place the cover + // has to be named by hand: taking the input away cannot rewind a state machine that is already + // mid-gesture, and this system owns the only such state the world side has (the UI's own armed + // press/drag is unwound by `UiScript::pointer_left_window`, which the blanked cursor already + // triggers). + if covered { + *left_click = None; + *right_click = None; + } run_look_session( &buttons, mouse_motion, diff --git a/crates/benilla-app/src/ui_chat/feed.rs b/crates/benilla-app/src/ui_chat/feed.rs index 22a19b37..d5087709 100644 --- a/crates/benilla-app/src/ui_chat/feed.rs +++ b/crates/benilla-app/src/ui_chat/feed.rs @@ -633,7 +633,8 @@ fn notice_event( /// **YOU_JOINED lands before, YOU_LEFT lands after.** The notice's arg4/arg7/arg8/arg9 are read off /// the client's channel record ([`ChannelState::stamp_channel`] is our leg of that), so a record /// torn down before the line is composed costs it the slot number — and, since the color resolves -/// through `ChatTypeInfo["CHANNEL"..arg8]` ([`super::event::resolved_color`]), its color with it. +/// through `ChatTypeInfo["CHANNEL"..arg8]` — the stock window's own Lua since 1948 — its color +/// with it. /// The reference's YOU_LEFT arm only *flags* the teardown (`0x49c115 mov dword [ebp-0xc],1`); the /// event fires first (`0x49c5b0 call 0x49a870`) and only then does `0x49c5b5 test` / /// `0x49c5c2 call 0x49bbd0` destroy the record — VERIFIED by disassembly at those addresses (1275). diff --git a/crates/benilla-app/src/ui_chat/frames.rs b/crates/benilla-app/src/ui_chat/frames.rs index 3d7d0657..ba24e1a7 100644 --- a/crates/benilla-app/src/ui_chat/frames.rs +++ b/crates/benilla-app/src/ui_chat/frames.rs @@ -6,7 +6,8 @@ //! or monster lines), the `[Language]` header, the `[N. Name]` channel prefix with its " - Zone" //! tail stripped (the SPEECH branch only — a notice prints arg4 whole, 1275), and the //! `CHAT__NOTICE` channel-notice strings. Formats are QUOTED from the extracted GlobalStrings -//! (0288's pin, §2/§4); colors come from [`super::event::resolved_color`]. +//! (0288's pin, §2/§4); colors come from the stock window's `ChatTypeInfo` table (1948), whose +//! shipped defaults are transcribed in [`super::event::default_color`]. use bevy::prelude::*; diff --git a/crates/benilla-app/src/ui_chat/input/parse.rs b/crates/benilla-app/src/ui_chat/input/parse.rs index 3848fd8b..7fc5f599 100644 --- a/crates/benilla-app/src/ui_chat/input/parse.rs +++ b/crates/benilla-app/src/ui_chat/input/parse.rs @@ -39,7 +39,8 @@ fn social_body(key: &str, args: &str) -> ParsedChat { /// resource or a network channel. #[derive(Debug, Clone, PartialEq)] pub(in crate::ui_chat) enum ParsedChat { - /// `/r [text]` — reply to the last received tell ([`crate::ui_chat::edit::ChatEditState::last_tell`]). + /// `/r [text]` — reply to the last received tell (the tell ring is the stock `ChatEdit_*` + /// Lua's since 1948 — see [`crate::ui_chat::edit`]). Reply { text: String }, /// `/join [password]` (aliases /channel /chan — SLASH_JOIN). Join { name: String, password: String }, diff --git a/crates/benilla-app/src/ui_party/feed.rs b/crates/benilla-app/src/ui_party/feed.rs index 9e4e5212..fe0aaaa6 100644 --- a/crates/benilla-app/src/ui_party/feed.rs +++ b/crates/benilla-app/src/ui_party/feed.rs @@ -56,6 +56,10 @@ pub(super) struct FedParty { saved_answers: u32, /// The ready-check ticket last seen ([`GroupState::ready_check`]). ready_check: u32, + /// The request generation last seen ([`GroupState::ready_check_requests`]) and how far into + /// that request's answer log the engine has been fed (decision 1989). + ready_check_requests: u32, + answers_forwarded: usize, /// The raid-target icon board last pushed — what `RAID_TARGET_UPDATE` fires on. Eight guids, /// so a plain copy rather than the `Vec` diffs above. raid_targets: [u64; 8], @@ -124,11 +128,21 @@ pub(super) fn feed_party( // harness has none, and a lockout then shows its map id, never a blank row. map_catalog: Option>, mut fed: Local>, + mut chat: ResMut, ) { let Some(mut script) = script else { return; }; let (fed, vm_reset) = fed.get_reset(&script); + // The ready-check summary the timeout tick composed (decision 1989) — a client-composed + // `CHAT_MSG_SYSTEM` line, pushed the way every other one is. Ahead of the gate: the tick + // runs on the frame clock, not on anything the gate watches. + for line in script.take_ready_check_lines() { + chat.push_event(crate::ui_chat::ChatEvent::text_only( + crate::ui_chat::ChatEventKind::System, + line, + )); + } let chr = classes.as_deref().map(|t| &t.0); // The gate (1439): the group state, any member/self descriptor change or DESPAWN (a removed // store is invisible to `Changed`), the streamed-guid index the merged view resolves @@ -277,6 +291,7 @@ pub(super) fn feed_party( // descriptor, and the compare must still answer). Zero when ungrouped, and deliberately // not guarded against zero at the comparison — see `PartyState::leader_guid`. leader_guid: group.leader, + own_guid: self_guid.unwrap_or(0), raid, loot_method, master_looter, @@ -468,6 +483,20 @@ pub(super) fn feed_party( script.fire_event("READY_CHECK", vec![]); } } + // The request generation — both arms — is the engine's state half (decision 1989): the leader + // arm's close-if-nobody-pending, the member arm's 30 s deadline. Then the answer log, replayed + // from where this memo left off; a new request restarts the cursor with the log. + if group.ready_check_requests != fed.ready_check_requests { + fed.ready_check_requests = group.ready_check_requests; + fed.answers_forwarded = 0; + if !vm_reset { + script.ready_check_request(Some(group.leader) == self_guid); + } + } + for &(guid, ready) in group.ready_check_answers.iter().skip(fed.answers_forwarded) { + script.ready_check_answered(guid, ready); + } + fed.answers_forwarded = group.ready_check_answers.len(); // ── UPDATE_INSTANCE_INFO (decision 1549) ──────────────────────────────────────────────── // @@ -1217,9 +1246,11 @@ fn test_apply_local( true } PartyRequest::ReadyCheckStart => { - // The echo a real server sends back to the whole raid, us included — which is what - // makes the popup appear for the person who pressed the button. - group.apply_ready_check(); + // The echo a real server sends back to the whole raid, us included. The presser is the + // leader (the stock Raid tab offers the button to no one else), and the leader's own + // echo takes the response-collection arm: no popup for the person who pressed it, + // which is the reference's behaviour (decision 1989) — the summary line is theirs. + group.apply_ready_check_request(true); true } PartyRequest::RequestRaidInfo => { @@ -1909,15 +1940,19 @@ mod tests { "9 is not a subgroup" ); - // Ready Check echoes back to us, which is what puts the popup on the asker's screen. - let before = group.ready_check; + // Ready Check echoes back to us as the leader: the response-collection arm, which bumps + // the request generation and never the popup ticket (decision 1989). + let (ticket, requests) = (group.ready_check, group.ready_check_requests); assert!(test_apply_local( &mut group, &PartyRequest::ReadyCheckStart, me, None )); - assert_eq!(group.ready_check, before + 1); + assert_eq!( + (group.ready_check, group.ready_check_requests), + (ticket, requests + 1) + ); // And the kick empties a seat — but never our own row. let n = group.members.len(); diff --git a/crates/benilla-app/src/ui_party/mod.rs b/crates/benilla-app/src/ui_party/mod.rs index 687b736a..b1b15ffc 100644 --- a/crates/benilla-app/src/ui_party/mod.rs +++ b/crates/benilla-app/src/ui_party/mod.rs @@ -112,6 +112,15 @@ pub struct GroupState { /// fires `READY_CHECK` on a counter edge and a second check while the first popup is still up /// re-arms it. A boolean could not tell the two apart. pub ready_check: u32, + /// The request GENERATION — bumped by BOTH arms of the open form (decision 1989), unlike the + /// ticket above, which only the non-leader arm bumps: the leader never gets the popup, because + /// `0x4ba360`'s leader arm never reaches the `READY_CHECK` fire. The feed turns an edge here + /// into the engine's `ready_check_request` and resets its answer cursor. + pub ready_check_requests: u32, + /// The answers forwarded to us as leader since the last request — `{guid, ready}` in wire + /// order. A log the feed replays into the engine from its own cursor, append-only within one + /// check so the feed never has to mutate this state; the next request clears it. + pub ready_check_answers: Vec<(u64, bool)>, } // The GlobalStrings templates, quoted verbatim from the reference client's own patch chain @@ -129,6 +138,7 @@ const NEW_LEADER_YOU: &str = "You are now the group leader."; // ERR_NEW_LEADER_ const RAID_MEMBER_ADDED: &str = "%s has joined the raid group"; // ERR_RAID_MEMBER_ADDED_S (GlobalStrings:1824) const RAID_MEMBER_REMOVED: &str = "%s has left the raid group"; // ERR_RAID_MEMBER_REMOVED_S (GlobalStrings:1825) const RAID_YOU_JOINED: &str = "You have joined a raid group"; // ERR_RAID_YOU_JOINED (GlobalStrings:1826) +const READY_CHECK_START_S: &str = "%s has initiated a ready check"; // ERR_RAID_LEADER_READY_CHECK_START_S (GlobalStrings:1823) /// `format!`-free "%s" substitution — the templates are quoted verbatim from GlobalStrings, so /// they carry printf placeholders, not Rust ones. @@ -389,10 +399,34 @@ impl GroupState { self.saved_instances_answers = self.saved_instances_answers.wrapping_add(1); } - /// `MSG_RAID_READY_CHECK` (open form) — the leader started one. Bumps the ticket the feed - /// turns into a `READY_CHECK` event edge. - pub fn apply_ready_check(&mut self) { + /// `MSG_RAID_READY_CHECK` (open form) — the leader started one, and the server echoes the + /// request to every member including the leader. The handler `0x4ba360` splits on the leader + /// guid (decision 1989): the **leader** takes the response-collection arm and neither prints + /// nor pops; everyone **else** prints `ERR_RAID_LEADER_READY_CHECK_START_S` with the leader's + /// name and gets the popup — the ticket the feed turns into a `READY_CHECK` event edge. Both + /// arms bump the request generation and start a fresh answer log. + pub fn apply_ready_check_request(&mut self, we_lead: bool) -> Vec { + self.ready_check_requests = self.ready_check_requests.wrapping_add(1); + self.ready_check_answers.clear(); + if we_lead { + return Vec::new(); + } self.ready_check = self.ready_check.wrapping_add(1); + // The reference resolves the leader's name from its cache and prints whatever it holds; + // ours is the roster's name for the leader's guid, which is the same cache's content. + let leader = self + .members + .iter() + .find(|m| m.guid == self.leader) + .map(|m| m.name.as_str()) + .unwrap_or(""); + vec![fmt_s(READY_CHECK_START_S, leader)] + } + + /// `MSG_RAID_READY_CHECK` (answer form) — one member's answer, which the server forwards to + /// the leader alone. Logged for the feed to replay into the engine's flags (decision 1989). + pub fn apply_ready_check_answer(&mut self, guid: u64, ready: bool) { + self.ready_check_answers.push((guid, ready)); } } @@ -446,6 +480,42 @@ mod tests { /// The ungated diff (0440 byte law): a FIRST roster prints joins for everyone already /// there; later rosters diff both ways. + /// The open form's two arms (decision 1989): the leader's own echo neither prints nor pops; + /// a member prints the leader's line and takes the popup ticket. Both bump the request + /// generation and restart the answer log. + #[test] + fn the_open_form_pops_for_a_member_and_stays_quiet_for_the_leader() { + let mut g = GroupState { + leader: 0xA11CE, + members: vec![member("Alice", 0xA11CE), member("Bob", 0xB0B)], + ..Default::default() + }; + g.apply_ready_check_answer(0xB0B, true); + + assert_eq!( + g.apply_ready_check_request(false), + vec!["Alice has initiated a ready check".to_string()] + ); + assert_eq!((g.ready_check, g.ready_check_requests), (1, 1)); + assert!( + g.ready_check_answers.is_empty(), + "a request starts a fresh log" + ); + + assert!( + g.apply_ready_check_request(true).is_empty(), + "the leader's echo" + ); + assert_eq!( + (g.ready_check, g.ready_check_requests), + (1, 2), + "no popup for the leader" + ); + + g.apply_ready_check_answer(0xB0B, false); + assert_eq!(g.ready_check_answers, vec![(0xB0B, false)]); + } + #[test] fn join_lines_come_from_roster_diffs() { let mut g = GroupState::default(); diff --git a/crates/benilla-app/src/ui_pass.rs b/crates/benilla-app/src/ui_pass.rs index f60e67a5..3431f857 100644 --- a/crates/benilla-app/src/ui_pass.rs +++ b/crates/benilla-app/src/ui_pass.rs @@ -854,6 +854,10 @@ struct BatchPools { materials: std::collections::HashMap>, /// Per slot: the solo-run material (key + tint it was built with), `None` while pooled. solo: Vec>, + /// Solo materials a slot let go of, by key — taken before a fresh one is built, so a run + /// shifting slots (a tooltip opening above it) re-points at a material that exists + /// instead of adding one and dropping one per shifted slot per frame (review 2026-09-04). + solo_free: std::collections::HashMap>, /// Per slot: the material the batch entity currently carries. bound: Vec>>, } @@ -1059,6 +1063,7 @@ fn rebuild_ui_mesh( *slot = None; } } + pools.solo_free.retain(|k, _| !retired.contains(&k.0)); } let (hidden, mesh_cost, cost_wanted) = (&hide_and_meter.0, &mut hide_and_meter.1, &hide_and_meter.2); @@ -1282,6 +1287,7 @@ fn rebuild_ui_mesh( // on their next miss. if pools.materials.len() > 256 { pools.materials.clear(); + pools.solo_free.clear(); } let mut used = 0usize; let mut n_rewrites = 0usize; @@ -1370,7 +1376,10 @@ fn rebuild_ui_mesh( tint: Vec4::from_array(tint), }) }; - match &mut pools.solo[used] { + let BatchPools { + solo, solo_free, .. + } = &mut *pools; + match &mut solo[used] { Some((handle, k, t)) if *k == key => { if *t != tint { if let Some(m) = materials.get_mut(&*handle) { @@ -1381,14 +1390,32 @@ fn rebuild_ui_mesh( handle.clone() } slot => { - let handle = fresh(materials); - *slot = Some((handle.clone(), key, tint)); + if let Some(old) = slot.take() { + solo_free.entry(old.1).or_default().push(old); + } + let reused = + solo_free + .get_mut(&key) + .and_then(Vec::pop) + .map(|(handle, k, t)| { + if t != tint { + if let Some(m) = materials.get_mut(&handle) { + m.tint = Vec4::from_array(tint); + } + } + (handle, k, tint) + }); + let (handle, k, t) = + reused.unwrap_or_else(|| (fresh(materials), key, tint)); + *slot = Some((handle.clone(), k, t)); handle } } } None => { - pools.solo[used] = None; + if let Some(old) = pools.solo[used].take() { + pools.solo_free.entry(old.1).or_default().push(old); + } pools .materials .entry(key) diff --git a/crates/benilla-app/src/ui_script/durability_tests.rs b/crates/benilla-app/src/ui_script/durability_tests.rs index 3e7afa4f..ea928141 100644 --- a/crates/benilla-app/src/ui_script/durability_tests.rs +++ b/crates/benilla-app/src/ui_script/durability_tests.rs @@ -414,7 +414,7 @@ fn the_quest_tracker_stacks_below_the_durability_guy() { "Interface\\FrameXML\\BasicControls.xml", "Interface\\FrameXML\\ItemButtonTemplate.xml", "Interface\\FrameXML\\QuestFrame.xml", - "MicroMenu.xml", + r"Interface\FrameXML\MainMenuBarMicroButtons.xml", "Interface\\FrameXML\\QuestLogFrame.xml", ] { load_xml(&s, f); diff --git a/crates/benilla-app/src/ui_script/errors_tests.rs b/crates/benilla-app/src/ui_script/errors_tests.rs index ba2fdf71..72d05553 100644 --- a/crates/benilla-app/src/ui_script/errors_tests.rs +++ b/crates/benilla-app/src/ui_script/errors_tests.rs @@ -131,7 +131,7 @@ fn an_error_toast_draws_over_an_open_panel_window() { load_xml(&s, "UIParent.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); - load_xml(&s, "MicroMenu.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); // A left-slot panel open, and the toast raised after it — the order that must not decide. diff --git a/crates/benilla-app/src/ui_script/frame_flag_gate.rs b/crates/benilla-app/src/ui_script/frame_flag_gate.rs index 4f6d5158..52031e22 100644 --- a/crates/benilla-app/src/ui_script/frame_flag_gate.rs +++ b/crates/benilla-app/src/ui_script/frame_flag_gate.rs @@ -574,10 +574,10 @@ fn the_shipped_frames_carry_the_references_flags() { // frames are the reference's own now), which took the paired count from the low 400s to // the high 150s; 1966 (TradeFrame), 1968 (GameTooltip), 1969 (DressUpFrame) and 1970 // (MailFrame) to the high 80s, and 1980 (WorldMapFrame, whose 50-odd blip frames were - // most of what was left) to the high teens. The floor guards the pairing, not the - // census — it comes down with every window that migrates, and reaches zero with the - // last file of ours that declares a reference-named frame. - compared > 10, + // most of what was left) to the high teens, and 1987 (the micro row) to nine. The floor + // guards the pairing, not the census — it comes down with every window that migrates, + // and reaches zero with the last file of ours that declares a reference-named frame. + compared > 5, "only {compared} frames compared — the pairing broke, and the sweep guards nothing" ); assert!( diff --git a/crates/benilla-app/src/ui_script/game_menu_tests.rs b/crates/benilla-app/src/ui_script/game_menu_tests.rs index 73761fc1..e6a62c4a 100644 --- a/crates/benilla-app/src/ui_script/game_menu_tests.rs +++ b/crates/benilla-app/src/ui_script/game_menu_tests.rs @@ -577,7 +577,7 @@ fn the_world_map_cannot_open_behind_the_menu_and_gives_its_slot_back() { "Interface\\FrameXML\\MainMenuBar.xml", "Interface\\FrameXML\\ActionBarFrame.xml", "Interface\\FrameXML\\BonusActionBarFrame.xml", - "MicroMenu.xml", + r"Interface\FrameXML\MainMenuBarMicroButtons.xml", r"Interface\FrameXML\WorldMapFrame.xml", ]); @@ -638,7 +638,7 @@ fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() { "Interface\\FrameXML\\MainMenuBar.xml", "Interface\\FrameXML\\ActionBarFrame.xml", "Interface\\FrameXML\\BonusActionBarFrame.xml", - "MicroMenu.xml", + r"Interface\FrameXML\MainMenuBarMicroButtons.xml", r"Interface\FrameXML\WorldMapFrame.xml", "Interface\\FrameXML\\MerchantFrame.xml", // The loot window is the reference's own since 1751 — see `test_ui::LOOT_UI` for what diff --git a/crates/benilla-app/src/ui_script/help_frame_tests.rs b/crates/benilla-app/src/ui_script/help_frame_tests.rs index a725b8c1..7834a200 100644 --- a/crates/benilla-app/src/ui_script/help_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/help_frame_tests.rs @@ -38,7 +38,7 @@ fn setup() -> UiScript { "Interface\\FrameXML\\StaticPopup.xml", // The stock file's `HelpFrame_OnShow` calls `UpdateMicroButtons()` before // `GetGMStatus()`, so without the micro row the OnShow raises and the status ask never - // happens. Ours never called it. MicroMenu needs the bar's button kit under it. + // happens. Ours never called it. The row needs the bar's button kit under it. "Cooldown.xml", "Interface\\FrameXML\\ActionButtonTemplate.xml", "Interface\\FrameXML\\TextStatusBar.lua", @@ -47,7 +47,7 @@ fn setup() -> UiScript { "Interface\\FrameXML\\MainMenuBar.xml", "Interface\\FrameXML\\ActionBarFrame.xml", "Interface\\FrameXML\\BonusActionBarFrame.xml", - "MicroMenu.xml", + r"Interface\FrameXML\MainMenuBarMicroButtons.xml", // `TicketStatusFrame_OnEvent` re-anchors TemporaryEnchantFrame before it arms the repoll // timer, so without BuffFrame the handler raises and `refreshTime` is never set. This is // the one ordering requirement benilla.toc already calls out for this window. diff --git a/crates/benilla-app/src/ui_script/macro_tests.rs b/crates/benilla-app/src/ui_script/macro_tests.rs index f1645170..08d80663 100644 --- a/crates/benilla-app/src/ui_script/macro_tests.rs +++ b/crates/benilla-app/src/ui_script/macro_tests.rs @@ -108,7 +108,7 @@ fn harness_with(player: &str) -> UiScript { r"Interface\FrameXML\UIPanelTemplates.xml", // `ClassTrainerListScrollFrameTemplate` — the icon chooser's scroll frame inherits it. r"Interface\FrameXML\ClassTrainerFrameTemplates.xml", - "MicroMenu.xml", // stock `MacroFrame_OnShow`/`_OnHide` drive the micro button + r"Interface\FrameXML\MainMenuBarMicroButtons.xml", // stock `MacroFrame_OnShow`/`_OnHide` drive the micro button ] { super::test_ui::load_ui(&s, file); } diff --git a/crates/benilla-app/src/ui_script/micro_menu_tests.rs b/crates/benilla-app/src/ui_script/micro_menu_tests.rs index d537a013..e781a4e2 100644 --- a/crates/benilla-app/src/ui_script/micro_menu_tests.rs +++ b/crates/benilla-app/src/ui_script/micro_menu_tests.rs @@ -1,85 +1,119 @@ -//! The shipped `assets/ui/MicroMenu.xml` — the eight micro buttons in the main bar's right-hand -//! recess — loaded behind `ActionBar.xml` (their anchor target `MainMenuBarArtFrame` and the -//! `BenillaActionBarArt_SeatAbove` seating helper) into a bare engine. +//! The stock `Interface\FrameXML\MainMenuBarMicroButtons.xml` — the eight micro buttons in the +//! main bar's right-hand recess — off the player's chain behind the stock bar (their anchor +//! target `MainMenuBarArtFrame`) into a bare engine (decision 1987). //! -//! What these guard, in order: the row's ref geometry (29×58 at art-frame BOTTOMLEFT +(552,2), a -//! 26 px stride) — which is the whole point of the slice, since the gap the row fills is exactly -//! that panel of empty bar art; the `HitRectInsets` header that keeps a button's transparent top -//! from eating the mouse; `UpdateMicroButtons` tracking a panel's visibility; and -//! `UpdateTalentButton`'s level-10 gate closing the row up behind the hidden talent button. +//! What these guard, in order: the row's geometry (29×58 at art-frame BOTTOMLEFT +(552,2), a +//! 26 px stride, and the reference's own 1 px nudge once the talent gate has run); the +//! `HitRectInsets` header that keeps a button's transparent top from eating the mouse; the +//! character button's portrait crop; `UpdateTalentButton`'s level-10 gate closing the row up +//! behind the hidden talent button; the two-line hover with the bound key in its label; and, +//! under the whole shipped manifest, `UpdateMicroButtons` following a real panel and the +//! level-up pulse the reference hangs on the talent button. -use benilla_ui::script::{QuadContent, ScriptValue, TexCoords, UiScript}; +use benilla_ui::script::{QuadContent, ScriptValue, TexCoords, UiScript, UnitState}; -/// Load `ActionBar.xml` then `MicroMenu.xml` into a 1024×768 engine, asserting both load clean. -/// 1024 wide is deliberate: the 1024-wide bar then centers at x=0, so every ref offset below is +/// The eight, in row order. +const ROW: [&str; 8] = [ + "CharacterMicroButton", + "SpellbookMicroButton", + "TalentMicroButton", + "QuestLogMicroButton", + "SocialsMicroButton", + "WorldMapMicroButton", + "MainMenuMicroButton", + "HelpMicroButton", +]; + +/// The stock bar, then the stock row, into a 1024×768 engine, asserting both load clean. 1024 +/// wide is deliberate: the 1024-wide bar then centers at x=0, so every reference offset below is /// also an absolute screen coordinate. fn harness() -> UiScript { - harness_with(&[]) -} - -/// …with more shipped files behind the row — the tooltip test needs the real plate. -fn harness_with(extra: &[&str]) -> UiScript { let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); - let files: Vec<&str> = [ + for file in [ "Interface\\FrameXML\\Fonts.xml", "Cooldown.xml", "Interface\\FrameXML\\ActionButtonTemplate.xml", "Interface\\FrameXML\\TextStatusBar.lua", "Interface\\FrameXML\\TextStatusBar.xml", "UIParent.xml", + // The eight labels and their NEWBIE_TOOLTIP_* lines, read at each button's OnLoad… "Interface\\FrameXML\\GlobalStrings.lua", + // …through `TEXT()`. + "Interface\\FrameXML\\BasicControls.xml", "Interface\\FrameXML\\MainMenuBar.xml", r"Interface\FrameXML\MoneyFrame.lua", r"Interface\FrameXML\MoneyFrame.xml", "Interface\\FrameXML\\GameTooltip.xml", "Interface\\FrameXML\\ActionBarFrame.xml", "Interface\\FrameXML\\BonusActionBarFrame.xml", - "MicroMenu.xml", - ] - .into_iter() - .chain(extra.iter().copied()) - .collect(); - // Through `test_ui::load_ui`, not off disk: the list above now names a chain file, and a - // reader that joins `assets/ui` cannot resolve one (the wall 1838 hit, and 1887 again). - for file in files { + r"Interface\FrameXML\MainMenuBarMicroButtons.xml", + ] { super::test_ui::load_ui(&s, file); } assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); s } -/// The row's geometry, quoted from ref-MainMenuBarMicroButtons.xml: CharacterMicroButton at the -/// art frame's BOTTOMLEFT +(552,2), each button 29×58, the rest chained BOTTOMLEFT-to-previous- -/// BOTTOMRIGHT +(-3,0) ⇒ a 26 px stride, so the eight run x 552..757 — inside the 1024-wide bar, -/// between the page arrows (x≈522) and the bag cluster at the far right. +/// Set the player's level and fire the `UNIT_LEVEL` the talent gate listens for. +fn set_player_level(s: &mut UiScript, level: u32) { + s.set_unit( + "player", + Some(UnitState { + exists: true, + level, + ..Default::default() + }), + ); + s.fire_event("UNIT_LEVEL", vec![ScriptValue::Str("player".into())]); +} + +fn left_of(s: &UiScript, name: &str) -> f64 { + s.eval::(&format!("return {name}:GetLeft()")) + .unwrap_or_else(|e| panic!("{name}: {e}")) +} + +/// The row's geometry, the reference's own numbers: CharacterMicroButton at the art frame's +/// BOTTOMLEFT +(552,2), each button 29×58, the rest chained BOTTOMLEFT-to-previous-BOTTOMRIGHT +/// +(-3,0) ⇒ a 26 px stride, so the eight run x 552..757 as declared — between the page arrows +/// (x≈522) and the bag cluster at the far right. **As declared** is the operative phrase: the XML +/// seats the quest-log button at −3 and `UpdateTalentButton` re-seats it at −2 +/// (`MainMenuBarMicroButtons.lua:139`), so the first level pass at 10+ nudges the tail right by +/// one pixel. A real row is 1 px wider past its first `PLAYER_ENTERING_WORLD` than its XML +/// says; that is the client's, and it is asserted here as the reference's law rather than +/// sanded off (our retired file wrote −3 both ways). #[test] fn the_micro_row_sits_where_the_reference_puts_it() { + let _data = benilla_formats::wow_data_or_skip!(); let mut s = harness(); - // Past level 10 so all eight are shown and the row is unbroken. - set_player_level(&mut s, 10); s.resolve(); - let names = [ - "CharacterMicroButton", - "SpellbookMicroButton", - "TalentMicroButton", - "QuestLogMicroButton", - "SocialsMicroButton", - "WorldMapMicroButton", - "MainMenuMicroButton", - "HelpMicroButton", - ]; - for (i, name) in names.iter().enumerate() { + // As declared, before any level pass: a clean 26 px stride. + for (i, name) in ROW.iter().enumerate() { let (left, bottom, w, h) = s .eval::<(f64, f64, f64, f64)>(&format!( "return {name}:GetLeft(), {name}:GetBottom(), {name}:GetWidth(), {name}:GetHeight()" )) .unwrap_or_else(|e| panic!("{name}: {e}")); assert_eq!((w, h), (29.0, 58.0), "{name} size"); - assert_eq!(left, 552.0 + 26.0 * i as f64, "{name} left edge"); + assert_eq!( + left, + 552.0 + 26.0 * i as f64, + "{name} left edge, as declared" + ); assert_eq!(bottom, 2.0, "{name} sits 2 above the bar's bottom"); } + + // Past level 10 the gate re-seats the quest-log button at −2: the tail moves 1 px right. + set_player_level(&mut s, 60); + s.resolve(); + for (i, name) in ROW.iter().enumerate().skip(3) { + assert_eq!( + left_of(&s, name), + 553.0 + 26.0 * i as f64, + "{name} after the talent gate's own −2" + ); + } assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } @@ -88,8 +122,8 @@ fn the_micro_row_sits_where_the_reference_puts_it() { /// bar's XP strip and the sliver of world above it. #[test] fn the_transparent_top_of_a_micro_button_does_not_capture_the_mouse() { + let _data = benilla_formats::wow_data_or_skip!(); let mut s = harness(); - set_player_level(&mut s, 10); s.resolve(); // Mid-button horizontally (552 + 14), inside the art band (y 2..42) ⇒ captures. @@ -98,7 +132,7 @@ fn the_transparent_top_of_a_micro_button_does_not_capture_the_mouse() { Some("CharacterMicroButton"), "the art band takes the mouse" ); - // Same column, in the dead 18-unit header (y 42..60) ⇒ nothing of ours captures it. + // Same column, in the dead 18-unit header (y 42..60) ⇒ nothing of the row captures it. assert_ne!( s.hit_test_name(566.0, 50.0).as_deref(), Some("CharacterMicroButton"), @@ -107,13 +141,14 @@ fn the_transparent_top_of_a_micro_button_does_not_capture_the_mouse() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// The character button's face: the region binds the SAME `"player"` portrait slot the unit frame -/// samples, and carries the ref's crop window out to the renderer. Both halves matter — the crop is -/// what makes an 18×25 rectangle show a face instead of the whole square bake squashed into it, and -/// the extract path used to pin portrait quads to full UVs and drop it (fixed with this slice). -/// Pushing the button swaps the window and dims it (ref CharacterMicroButton_SetPushed). +/// The character button's face: `MicroButtonPortrait` binds the SAME `"player"` portrait slot +/// the unit frame samples, and carries the reference's crop window out to the renderer. Both +/// halves matter — the crop is what makes an 18×25 rectangle show a face instead of the whole +/// square bake squashed into it. Pushing the button swaps the window and dims it +/// (`CharacterMicroButton_SetPushed`, `MainMenuBarMicroButtons.lua:109-117`). #[test] fn the_character_button_carries_the_player_portrait_through_the_reference_crop() { + let _data = benilla_formats::wow_data_or_skip!(); let mut s = harness(); s.resolve(); @@ -136,8 +171,8 @@ fn the_character_button_carries_the_player_portrait_through_the_reference_crop() assert_eq!(unit, "player"); assert!( circular, - "the ref's round stencil lives in the bake's own UV space, so the crop below yields a \ - rectangular slice OF a masked face — not an ellipse fitted to this 18x25 region" + "the reference's round stencil lives in the bake's own UV space, so the crop below yields \ + a rectangular slice OF a masked face — not an ellipse fitted to this 18x25 region" ); let round4 = |c: TexCoords| match c { TexCoords::Rect(e) => e.map(|v| (v * 10_000.0).round() / 10_000.0), @@ -146,7 +181,7 @@ fn the_character_button_carries_the_player_portrait_through_the_reference_crop() assert_eq!( round4(coords.expect("a crop window")), [0.2, 0.8, 0.0666, 0.9], - "the normal window (ref CharacterMicroButton_SetNormal)" + "the normal window (CharacterMicroButton_SetNormal)" ); s.run("CharacterMicroButton_SetPushed()").unwrap(); @@ -155,7 +190,7 @@ fn the_character_button_carries_the_player_portrait_through_the_reference_crop() assert_eq!( round4(coords.expect("a crop window")), [0.2666, 0.8666, 0.0, 0.8333], - "the held-down window (ref CharacterMicroButton_SetPushed)" + "the held-down window (CharacterMicroButton_SetPushed)" ); assert_eq!( s.eval::("return MicroButtonPortrait:GetAlpha()") @@ -166,50 +201,12 @@ fn the_character_button_carries_the_player_portrait_through_the_reference_crop() assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// `UpdateMicroButtons` (ref MainMenuBarMicroButtons.lua l.20-84): a button is PUSHED exactly -/// while its panel is up. Driven here with a stand-in frame under the real panel's name rather -/// than the whole character window — this is the visibility contract, not that window's. -#[test] -fn a_micro_button_pushes_while_its_panel_is_open() { - let s = harness(); - s.run( - r#" - local f = CreateFrame("Frame", "CharacterFrame") - f:SetPoint("CENTER", 0, 0); f:SetSize(100, 100); f:Hide() - "#, - ) - .unwrap(); - - let state = |s: &UiScript| { - s.eval::("return CharacterMicroButton:GetButtonState()") - .unwrap() - }; - s.run("UpdateMicroButtons()").unwrap(); - assert_eq!(state(&s), "NORMAL", "closed panel ⇒ button up"); - - s.run("CharacterFrame:Show(); UpdateMicroButtons()") - .unwrap(); - assert_eq!(state(&s), "PUSHED", "open panel ⇒ button held down"); - - s.run("CharacterFrame:Hide(); UpdateMicroButtons()") - .unwrap(); - assert_eq!(state(&s), "NORMAL", "closed again ⇒ button pops back"); - - // A button whose panel doesn't exist at all (the three unwired ones, and any panel a test - // harness didn't load) never pushes — and never errors. - assert_eq!( - s.eval::("return SocialsMicroButton:GetButtonState()") - .unwrap(), - "NORMAL" - ); - assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); -} - -/// `UpdateTalentButton` (ref l.133-141): under level 10 the talent button is hidden and the quest -/// log button slides onto its seat, so the row has no hole in it; at 10 the button returns and the -/// tail shifts back out by one stride. +/// `UpdateTalentButton` (l.133-141): under level 10 the talent button is hidden and the quest +/// log button slides onto its seat, so the row has no hole in it; at 10 the button returns and +/// the tail shifts back out — by one stride less two, the gate's own −2. #[test] fn the_talent_button_appears_at_level_ten_and_the_row_closes_up_below_it() { + let _data = benilla_formats::wow_data_or_skip!(); let mut s = harness(); set_player_level(&mut s, 9); @@ -220,13 +217,9 @@ fn the_talent_button_appears_at_level_ten_and_the_row_closes_up_below_it() { "no talents before 10" ); // The quest log button takes the talent button's own seat — slot 3, not slot 4. + assert_eq!(left_of(&s, "QuestLogMicroButton"), 552.0 + 26.0 * 2.0); assert_eq!( - s.eval::("return QuestLogMicroButton:GetLeft()") - .unwrap(), - 552.0 + 26.0 * 2.0 - ); - assert_eq!( - s.eval::("return HelpMicroButton:GetLeft()").unwrap(), + left_of(&s, "HelpMicroButton"), 552.0 + 26.0 * 6.0, "the whole tail moves up one slot with it" ); @@ -239,51 +232,84 @@ fn the_talent_button_appears_at_level_ten_and_the_row_closes_up_below_it() { "the button returns at 10" ); assert_eq!( - s.eval::("return QuestLogMicroButton:GetLeft()") - .unwrap(), - 552.0 + 26.0 * 3.0 + left_of(&s, "QuestLogMicroButton"), + 553.0 + 26.0 * 3.0, + "…and the tail goes back out, at the gate's own −2" ); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// The hover (ref MainMenuBarMicroButton's OnEnter, l.13): every button's plate is the ref's -/// TWO-line `GameTooltip_AddNewbieTip` — the label, then that button's own `NEWBIE_TOOLTIP_*` -/// explanation in gold. 1.12 ships detailed tips ON (`SHOW_NEWBIE_TIPS = "1"`, ref -/// UIOptionsFrame.lua l.100), so the paragraph is the DEFAULT hover; this file used to open-code -/// the tips-off branch and show a bare label. Decision 0661. -/// -/// Checked across all eight, because the explanation is threaded per button through OnLoad and a -/// single missing argument is invisible until someone hovers that one button. +/// The hover (the template's OnEnter, `MainMenuBarMicroButtons.xml:12-14`): every button's plate +/// is the reference's TWO-line `GameTooltip_AddNewbieTip` — the label, then that button's own +/// `NEWBIE_TOOLTIP_*` explanation in gold. 1.12 ships detailed tips ON (`SHOW_NEWBIE_TIPS = "1"`, +/// `UIOptionsFrame.lua:100`; ours sits in OptionsFrame.xml's uvar block), so the paragraph is the +/// DEFAULT hover (decision 0661). Through the engine's own hover, because the stock OnEnter reads +/// the firing frame off `this`. For the bound ones the label carries the key the way the +/// reference prints it — `GetBindingKey`'s raw token in `NORMAL_FONT_COLOR_CODE` parentheses +/// (`MicroButtonTooltipText`, l.11-18) — re-read on `UPDATE_BINDINGS`, which is the event +/// `bindings.rs` fires after every (re)load of the table. #[test] fn every_micro_button_hovers_with_its_reference_explanation() { - let mut s = harness_with(&["UIParent.xml"]); - set_player_level(&mut s, 10); - // 1.12 ships detailed tips ON — `SHOW_NEWBIE_TIPS = "1"` is UIOptionsFrame_Init's (ref - // UIOptionsFrame.lua l.100; ours sits in OptionsFrame.xml's uvar block, 1968), and a harness - // without the options file says so itself, the way the reference's tooltip would read it. + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = harness(); s.run("SHOW_NEWBIE_TIPS = \"1\"").unwrap(); + // A key the character button's label can carry: the registry, then the reference's own + // `SetBinding`, then the event the row re-reads its labels on. + s.register_bindings(&crate::bindings::registry_commands()); + assert_eq!( + s.eval::>("return SetBinding(\"C\", \"TOGGLECHARACTER0\")") + .unwrap(), + Some(1) + ); + s.fire_event("UPDATE_BINDINGS", vec![]); s.resolve(); - for (button, newbie) in [ - ("CharacterMicroButton", "NEWBIE_TOOLTIP_CHARACTER"), - ("SpellbookMicroButton", "NEWBIE_TOOLTIP_SPELLBOOK"), - ("TalentMicroButton", "NEWBIE_TOOLTIP_TALENTS"), - ("QuestLogMicroButton", "NEWBIE_TOOLTIP_QUESTLOG"), - ("SocialsMicroButton", "NEWBIE_TOOLTIP_SOCIAL"), - ("WorldMapMicroButton", "NEWBIE_TOOLTIP_WORLDMAP"), - ("MainMenuMicroButton", "NEWBIE_TOOLTIP_MAINMENU"), - ("HelpMicroButton", "NEWBIE_TOOLTIP_HELP"), + for (button, label, newbie) in [ + ( + "CharacterMicroButton", + "CHARACTER_BUTTON", + "NEWBIE_TOOLTIP_CHARACTER", + ), + // The spellbook's own OnEnter (l.79-87) picks its label by `PlayerHasSpells()`. + ( + "SpellbookMicroButton", + "PlayerHasSpells() and SPELLBOOK_ABILITIES_BUTTON or ABILITYBOOK_BUTTON", + "NEWBIE_TOOLTIP_SPELLBOOK", + ), + ( + "TalentMicroButton", + "TALENTS_BUTTON", + "NEWBIE_TOOLTIP_TALENTS", + ), + ( + "QuestLogMicroButton", + "QUESTLOG_BUTTON", + "NEWBIE_TOOLTIP_QUESTLOG", + ), + ( + "SocialsMicroButton", + "SOCIAL_BUTTON", + "NEWBIE_TOOLTIP_SOCIAL", + ), + ( + "WorldMapMicroButton", + "WORLDMAP_BUTTON", + "NEWBIE_TOOLTIP_WORLDMAP", + ), + ( + "MainMenuMicroButton", + "MAINMENU_BUTTON", + "NEWBIE_TOOLTIP_MAINMENU", + ), + ("HelpMicroButton", "HELP_BUTTON", "NEWBIE_TOOLTIP_HELP"), ] { - // A hover is a handler: the reference's helper reads the firing frame off `this`. - s.run(&format!("this = {button} BenillaMicroButton_OnEnter(this)")) - .unwrap(); + super::test_ui::hover(&mut s, button); assert_eq!( s.eval::("return GameTooltip:NumLines()").unwrap(), 2, "{button}: the label, then the explanation" ); - // Line 1 is the button's own label (plus a `|cff…(key)|r` suffix on the five bound ones). - let label = s.eval::(&format!("return {button}.label")).unwrap(); + let label = s.eval::(&format!("return {label}")).unwrap(); let line1 = s .eval::("return GameTooltipTextLeft1:GetText()") .unwrap(); @@ -295,26 +321,130 @@ fn every_micro_button_hovers_with_its_reference_explanation() { s.eval::("return GameTooltipTextLeft2:GetText()") .unwrap(), s.eval::(&format!("return {newbie}")).unwrap(), - "{button}: line 2 is the ref's {newbie}, verbatim" + "{button}: line 2 is the reference's {newbie}, verbatim" ); assert_eq!( s.eval::("return GameTooltip.default").unwrap(), 1, "{button}: the default-corner anchor, not ANCHOR_RIGHT off the button" ); + super::test_ui::unhover(&mut s); } + + // The bound one carries its key the reference's way: the raw token, gold, in parentheses, + // after a space — `MicroButtonTooltipText`'s own concatenation. + super::test_ui::hover(&mut s, "CharacterMicroButton"); + let line1 = s + .eval::("return GameTooltipTextLeft1:GetText()") + .unwrap(); + assert_eq!( + line1, + s.eval::( + "return CHARACTER_BUTTON .. \" \" .. NORMAL_FONT_COLOR_CODE .. \"(C)\" .. FONT_COLOR_CODE_CLOSE" + ) + .unwrap(), + "the key read on UPDATE_BINDINGS" + ); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// Set the player's level and fire the `UNIT_LEVEL` the talent gate listens for. -fn set_player_level(s: &mut UiScript, level: u32) { +/// Under the whole shipped manifest — the only VM in which every panel `UpdateMicroButtons` +/// (l.20-84) reads exists — a button is PUSHED exactly while its panel is up, the character +/// button's face dims with it, and the level-up pulse the reference hangs on the talent button +/// (`TalentMicroButton_OnEvent`, l.119-124: sixty seconds, unless the character sheet is open) +/// is live — 0304's "no microbutton pulse" scope-out, retired by the file that owns the pulse. +#[test] +fn a_micro_button_pushes_while_its_panel_is_open_and_the_talent_button_pulses_on_a_ding() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + // A player always exists by the time the manifest loads (1051/1848) — with a race and a + // class, which the sheet's own `PaperDollFrame_SetLevel` formats into its title on show. s.set_unit( "player", - Some(benilla_ui::script::UnitState { + Some(UnitState { exists: true, - level, + name: Some("Probefour".into()), + level: 60, + race: Some("Night Elf".into()), + race_file: Some("NightElf".into()), + class: Some("Warrior".into()), + class_file: Some("WARRIOR".into()), + sex: 2, + is_player: true, + player_controlled: true, ..Default::default() }), ); - s.fire_event("UNIT_LEVEL", vec![ScriptValue::Str("player".into())]); + // The reference's strings, which the app runs ahead of the manifest (lifecycle.rs) — the + // ding's chat lines below format `SPELL_STAT0_NAME`..`4` through them. + super::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua"); + assert!(super::load_default_ui(&s).is_empty()); + s.resolve(); + + let state = |s: &UiScript, button: &str| { + s.eval::(&format!("return {button}:GetButtonState()")) + .unwrap() + }; + let face = |s: &UiScript| { + s.eval::("return MicroButtonPortrait:GetAlpha()") + .unwrap() + }; + + assert_eq!(state(&s, "CharacterMicroButton"), "NORMAL"); + assert_eq!(face(&s), 1.0); + // The button's own click (its OnClick, l.49-51) — the sheet's OnShow calls back. + s.run("ToggleCharacter(\"PaperDollFrame\")").unwrap(); + assert_eq!( + state(&s, "CharacterMicroButton"), + "PUSHED", + "open sheet ⇒ button held down" + ); + assert_eq!( + face(&s), + 0.5, + "…and the face dims (CharacterMicroButton_SetPushed)" + ); + s.run("ToggleCharacter(\"PaperDollFrame\")").unwrap(); + assert_eq!(state(&s, "CharacterMicroButton"), "NORMAL"); + assert_eq!(face(&s), 1.0); + + // The reference's own full-screen panel; its OnShow/OnHide call back the same way. + s.run("ToggleWorldMap()").unwrap(); + assert_eq!(state(&s, "WorldMapMicroButton"), "PUSHED"); + s.run("ToggleWorldMap()").unwrap(); + assert_eq!(state(&s, "WorldMapMicroButton"), "NORMAL"); + + // A ding with the sheet closed: sixty seconds on the pulse list (UiPanels.xml's + // SetButtonPulse, the reference's UIParent.lua machinery). The nine args are the ones + // `ui_unit` fires (level, health, power, talent points, five stats). + s.fire_event( + "PLAYER_LEVEL_UP", + vec![ + ScriptValue::Int(61), + ScriptValue::Int(12), + ScriptValue::Int(0), + ScriptValue::Int(1), + ScriptValue::Int(1), + ScriptValue::Int(1), + ScriptValue::Int(1), + ScriptValue::Int(0), + ScriptValue::Int(0), + ], + ); + assert_eq!( + s.eval::("return TalentMicroButton.pulseTimeLeft") + .unwrap(), + 60.0, + "the sixty-second pulse" + ); + assert!( + s.eval::( + "for _, b in ipairs(PULSEBUTTONS) do if b == TalentMicroButton then return true end end \ + return false" + ) + .unwrap(), + "on the pulse list" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index acf02fb7..15896ba6 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -796,7 +796,7 @@ fn interface_harness() -> UiScript { "Interface\\FrameXML\\BasicControls.xml", "Interface\\FrameXML\\ItemButtonTemplate.xml", "Interface\\FrameXML\\QuestFrame.xml", - "MicroMenu.xml", + r"Interface\FrameXML\MainMenuBarMicroButtons.xml", "Interface\\FrameXML\\QuestLogFrame.xml", ], ); diff --git a/crates/benilla-app/src/ui_script/pet_bar_tests.rs b/crates/benilla-app/src/ui_script/pet_bar_tests.rs index c5690411..8bc7087b 100644 --- a/crates/benilla-app/src/ui_script/pet_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/pet_bar_tests.rs @@ -692,7 +692,7 @@ fn tooltip_line1(s: &UiScript) -> String { /// /// The exact string is pinned, colour codes and all, because the ref's concatenation puts the /// colour BEFORE the space (`Attack|cffffd200 (CTRL-1)|r`, not `Attack |cffffd200(CTRL-1)|r` — -/// which is what MicroMenu.xml's different-shaped version of the same idea produces). +/// which is what the stock `MicroButtonTooltipText` — the same idea, the other shape — produces). #[test] fn token_tooltips_name_their_binding_and_pet_spells_do_not() { let mut s = hovered_pet_bar(); diff --git a/crates/benilla-app/src/ui_script/quest_timer_tests.rs b/crates/benilla-app/src/ui_script/quest_timer_tests.rs index 492a0d2c..7fe1d165 100644 --- a/crates/benilla-app/src/ui_script/quest_timer_tests.rs +++ b/crates/benilla-app/src/ui_script/quest_timer_tests.rs @@ -44,7 +44,7 @@ fn harness() -> UiScript { load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); - load_xml(&s, "MicroMenu.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); load_xml(&s, "Interface\\FrameXML\\QuestTimerFrame.xml"); s diff --git a/crates/benilla-app/src/ui_script/questlog_tests.rs b/crates/benilla-app/src/ui_script/questlog_tests.rs index 86b0a551..f2b29d53 100644 --- a/crates/benilla-app/src/ui_script/questlog_tests.rs +++ b/crates/benilla-app/src/ui_script/questlog_tests.rs @@ -105,7 +105,7 @@ fn shipped_questlog_frame_loads_clean() { load_xml(&s, "UIParent.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); - load_xml(&s, "MicroMenu.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); } @@ -139,7 +139,7 @@ fn shipped_questlog_frame_drives_end_to_end() { load_xml(&s, "UIParent.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); - load_xml(&s, "MicroMenu.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(eight_entries()); @@ -349,7 +349,7 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() { load_xml(&s, "UIParent.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); - load_xml(&s, "MicroMenu.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); // A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()` // (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944). load_xml(&s, "UIParent.xml"); @@ -498,7 +498,7 @@ fn watch_guards_no_op_without_erroring() { load_xml(&s, "UIParent.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); - load_xml(&s, "MicroMenu.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); // A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()` // (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944). load_xml(&s, "UIParent.xml"); @@ -619,7 +619,7 @@ fn progress_auto_watches_for_five_minutes() { load_xml(&s, "OptionsFrame.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); - load_xml(&s, "MicroMenu.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(eight_entries()); @@ -705,7 +705,7 @@ fn the_auto_watch_flag_is_the_references_uvar_and_gates_the_watch() { load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); - load_xml(&s, "MicroMenu.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(eight_entries()); assert_eq!(s.eval::("return AUTO_QUEST_WATCH").unwrap(), "1"); @@ -761,7 +761,7 @@ fn the_row_tag_is_its_own_right_flush_string_and_the_state_word_wins() { load_xml(&s, "UIParent.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); - load_xml(&s, "MicroMenu.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); let mut state = eight_entries(); @@ -844,7 +844,7 @@ fn empty_quest_log_hides_rows_and_disables_abandon() { load_xml(&s, "UIParent.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); - load_xml(&s, "MicroMenu.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(QuestLogState::default()); @@ -909,7 +909,7 @@ fn reward_rows_follow_the_refs_two_per_row_layout() { load_xml(&s, "UIParent.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); - load_xml(&s, "MicroMenu.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); let mut state = eight_entries(); @@ -1098,7 +1098,7 @@ fn overflowing_detail_content_clips_to_the_scrollframe_rect() { load_xml(&s, "UIParent.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); - load_xml(&s, "MicroMenu.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(overflowing_entry()); @@ -1173,7 +1173,7 @@ fn wheel_over_the_detail_pane_changes_vertical_scroll() { load_xml(&s, "UIParent.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); - load_xml(&s, "MicroMenu.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(overflowing_entry()); @@ -1238,7 +1238,7 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not load_xml(&s, "UIParent.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); - load_xml(&s, "MicroMenu.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(overflowing_entry()); @@ -1320,7 +1320,7 @@ fn reward_row_hover_serves_the_shared_item_tooltip() { load_xml(&s, "UIParent.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); - load_xml(&s, "MicroMenu.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(eight_entries()); @@ -1475,7 +1475,7 @@ fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() { load_xml(&s, "UIParent.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); - load_xml(&s, "MicroMenu.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); // A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()` // (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944). load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); @@ -1574,7 +1574,7 @@ fn shift_click_on_a_title_posts_the_quest_name_with_chat_open_and_watches_with_i load_xml(&s, "UIParent.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); - load_xml(&s, "MicroMenu.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); // A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()` // (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944). load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); @@ -1684,7 +1684,7 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() { load_xml(&s, "UIParent.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); - load_xml(&s, "MicroMenu.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); // Solo, quest 1 sharable and selected by the auto-selection. @@ -1771,7 +1771,7 @@ fn share_quest_is_dark_on_an_empty_log_even_in_a_party() { load_xml(&s, "UIParent.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); - load_xml(&s, "MicroMenu.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(QuestLogState::default()); @@ -1812,7 +1812,7 @@ fn share_quest_click_queues_the_selected_quests_id() { load_xml(&s, "UIParent.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml"); - load_xml(&s, "MicroMenu.xml"); + load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml"); load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml"); s.set_quest_log(entries_sharable(&[1, 5])); diff --git a/crates/benilla-app/src/ui_script/raid_tests.rs b/crates/benilla-app/src/ui_script/raid_tests.rs index 8286d09c..b0b4466b 100644 --- a/crates/benilla-app/src/ui_script/raid_tests.rs +++ b/crates/benilla-app/src/ui_script/raid_tests.rs @@ -80,6 +80,7 @@ fn push_raid(s: &mut UiScript, raid: Vec) { members, leader_index: 0, leader_guid: 0, // the player leads; their guid is unset in this fixture + own_guid: 0, raid, loot_method: "group".into(), master_looter: None, diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs index 745f52ca..1035666b 100644 --- a/crates/benilla-app/src/ui_script/reference_ui.rs +++ b/crates/benilla-app/src/ui_script/reference_ui.rs @@ -1116,12 +1116,13 @@ mod tests { /// do not ship under its own name usually has a counterpart of ours under a different one, and /// both would declare the same frames. /// - /// That half doubles as the map nothing else holds: `FloatingChatFrame.xml` is our `ChatFrame.xml`, - /// `MainMenuBarMicroButtons.xml` is our `MicroMenu.xml`, `StaticPopup.xml` is our - /// `UiPanels.xml`. (The row this map used to lead with — `PlayerFrame.xml`/`TargetFrame.xml`/ - /// `PetFrame.xml` all being our one `UnitFrames.xml` — is retired: those four are the - /// reference's own files now, 1751.) `chain_gap_report` calls several of those unblocked, and they are — - /// individually. They just cannot load beside the file of ours already holding their names. + /// That half used to double as the map nothing else held — `FloatingChatFrame.xml` was our + /// `ChatFrame.xml`, `MainMenuBarMicroButtons.xml` our `MicroMenu.xml`, `StaticPopup.xml` our + /// `UiPanels.xml`'s dialog half, `PlayerFrame.xml`/`TargetFrame.xml`/`PetFrame.xml` our one + /// `UnitFrames.xml`. Every one of those is the reference's own file now (1751; the micro row + /// last, 1987), so the frame half names nothing today. It stays because a stock window + /// declaring a frame one of ours still holds is the first thing a swap has to rule out — + /// such a pair can load individually and cannot load side by side. /// /// Run it before attempting a swap. It predicts which ones will fail without attempting them. #[test] @@ -1419,8 +1420,8 @@ mod tests { /// FrameXML function is invisible to both, because loading a file never runs the body that /// calls it: `LootFrame.xml` shipped load-clean and raised at `LootFrame.lua:85` the first /// time it met real data, and stock `CharacterFrame.xml` would have raised on the first tab - /// HOVER, because `MicroButtonTooltipText` did not exist here (it does now — `MicroMenu.xml`, - /// our `MainMenuBarMicroButtons.xml` counterpart, is where the reference declares it). + /// HOVER, because `MicroButtonTooltipText` did not exist here (it does now — the stock + /// `MainMenuBarMicroButtons.xml` declares it, off the chain since 1987). /// /// So: for every chain `.xml` in the manifest, census the bare `Name(` call sites across it /// and its `.lua`, subtract what those two define themselves, keep the names the reference's diff --git a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs index b4ed57f4..e5160b93 100644 --- a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs +++ b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs @@ -625,10 +625,10 @@ fn every_archive_path_a_shipped_lua_chunk_names_survives_its_own_escaping() { } } } - // Never let the sweep pass by matching nothing (19 after 1971, 8 after 1980; the floor - // follows the census down as windows migrate). + // Never let the sweep pass by matching nothing (19 after 1971, 8 after 1980, 4 after 1987; + // the floor follows the census down as windows migrate). assert!( - paths.len() >= 5, + paths.len() >= 4, "only {} archive paths swept out of the shipped Lua", paths.len() ); @@ -1375,9 +1375,10 @@ fn a_cinematic_leaves_nothing_of_the_interface_on_screen() { let before = names.iter().filter(|n| visible(&s, n)).count(); assert!( // 50 until 1938 took the three bar files stock, 20 until 1974 took the minimap cluster - // (its 22 named frames were most of what this sweep counted); the floor follows the - // census of OUR files down, and 12 is what is left — the glue and dev frames. - before > 10, + // (its 22 named frames were most of what this sweep counted), 12 until 1987 took the + // micro row; the floor follows the census of OUR files down, and 2 is what is left — + // the glue and dev frames. + before >= 2, "only {before} frames visible before the cinematic — the sweep found no interface to \ hide, so it would pass no matter what the cascade did" ); @@ -1448,8 +1449,9 @@ fn every_declared_parent_really_attaches() { let declared = shipped_frame_parents(); assert!( // A sanity floor for the scan, not a census — the declarations retire with the files - // that carry them (1751); 67 before 1938, 30 after 1956, 14 after 1970. - declared.len() >= 10, + // that carry them (1751); 67 before 1938, 30 after 1956, 14 after 1970, 2 after 1987 + // (the micro row's eight were most of what was left). + declared.len() >= 2, "only {} parent declarations found — the scan broke", declared.len() ); diff --git a/crates/benilla-app/src/ui_script/spellbook_tests.rs b/crates/benilla-app/src/ui_script/spellbook_tests.rs index c22520a9..10d7006b 100644 --- a/crates/benilla-app/src/ui_script/spellbook_tests.rs +++ b/crates/benilla-app/src/ui_script/spellbook_tests.rs @@ -114,7 +114,7 @@ pub(super) fn spellbook_ui(w: f32, h: f32) -> UiScript { "KeyBindingsPage.xml", "OptionsFrame.xml", "Interface\\FrameXML\\MultiActionBars.xml", - "MicroMenu.xml", + r"Interface\FrameXML\MainMenuBarMicroButtons.xml", "Interface\\FrameXML\\SpellBookFrame.xml", "SpellBookAdapters.xml", ] { @@ -703,7 +703,7 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() { r"Interface\FrameXML\UIPanelTemplates.xml", // The icon chooser's scroll frame inherits `ClassTrainerListScrollFrameTemplate`. r"Interface\FrameXML\ClassTrainerFrameTemplates.xml", - "MicroMenu.xml", + r"Interface\FrameXML\MainMenuBarMicroButtons.xml", "Interface\\FrameXML\\ActionButtonTemplate.xml", "Interface\\FrameXML\\TextStatusBar.lua", "Interface\\FrameXML\\TextStatusBar.xml", diff --git a/crates/benilla-app/src/ui_script/test_ui.rs b/crates/benilla-app/src/ui_script/test_ui.rs index 091e0ff6..a55aa87b 100644 --- a/crates/benilla-app/src/ui_script/test_ui.rs +++ b/crates/benilla-app/src/ui_script/test_ui.rs @@ -70,6 +70,14 @@ fn load_entry(s: &UiScript, entry: &str, strict_templates: bool, no_warnings: bo "{entry}: loader errors: {:?}", report.errors ); + if path + .rsplit('/') + .next() + .is_some_and(|leaf| leaf.eq_ignore_ascii_case("MainMenuBarMicroButtons.xml")) + { + s.run(MICRO_BUTTON_STAND_INS) + .expect("the micro-button stand-ins"); + } if no_warnings { assert!( report.warnings.is_empty(), @@ -92,6 +100,39 @@ fn load_entry(s: &UiScript, entry: &str, strict_templates: bool, no_warnings: bo report.frames } +/// **What a kit owes the stock micro-button row.** `UpdateMicroButtons` +/// (`MainMenuBarMicroButtons.lua:20-84`) reads ten panels, `KeyRingButton` and `IsBagOpen` +/// UNGUARDED — the reference's own contract, because in the client every one of them is up +/// before anything can be shown. A kit is a prefix of the manifest and stops wherever its window +/// does, so [`load_entry`] wraps the function the moment the row loads: on its FIRST call — a +/// panel's OnShow, by which time the kit is complete — the wrapper seats a hidden, unnamed +/// stand-in under each name the kit never declared (a hidden frame is what a closed panel +/// answers), then runs the reference's own body. First use rather than load time, and unnamed, +/// because both registries keep the first writer: the arena's name map +/// (`named_registry_is_non_overwriting`) and `_G` itself — a named frame is published +/// non-overwriting (RF-0023: a `lua_gettable` nil check on GLOBALSINDEX, which is why a +/// metatable fallback would block the real publish just the same). A stand-in seated before the +/// real declaration shadows the real frame for good; the first cut did exactly that, 126 +/// failures, every tabbed panel's `numTabs` read off the stand-in. `fire_chat_login`'s +/// `UIOptionsFrame` stand-in is the same move for the chat files. Seated here rather than at the +/// kits' seventy-odd consumers because the dependency is the row's, not any one window's — and +/// the shipped manifest never needs it. `tests/common/mod.rs` carries the same chunk for the +/// integration tests, which cannot reach this module (decision 1987). +pub(super) const MICRO_BUTTON_STAND_INS: &str = r#" + local real = UpdateMicroButtons + function UpdateMicroButtons() + for _, name in ipairs({ "CharacterFrame", "SpellBookFrame", "QuestLogFrame", "GameMenuFrame", + "OptionsFrame", "SoundOptionsFrame", "UIOptionsFrame", "FriendsFrame", "WorldMapFrame", + "HelpFrame" }) do + if not getglobal(name) then local f = CreateFrame("Frame") f:Hide() setglobal(name, f) end + end + if not KeyRingButton then KeyRingButton = CreateFrame("Button") KeyRingButton:Hide() end + if not KEYRING_CONTAINER then KEYRING_CONTAINER = -2 end + if not IsBagOpen then function IsBagOpen() return nil end end + return real() + end +"#; + /// One file's bytes, from whichever store its path names — the chain for a path, this crate's /// `assets/ui` for a bare name. Also the `` / `