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