diff --git a/crates/benilla-app/assets/ui/ActionBar.xml b/crates/benilla-app/assets/ui/ActionBar.xml
index 72f59b5f..86e8da3b 100644
--- a/crates/benilla-app/assets/ui/ActionBar.xml
+++ b/crates/benilla-app/assets/ui/ActionBar.xml
@@ -1715,7 +1715,7 @@
-
+
@@ -1956,51 +1956,51 @@
-
+ BenillaActionButton_OnLoad(self, 1)
-
+ BenillaActionButton_OnLoad(self, 2)
-
+ BenillaActionButton_OnLoad(self, 3)
-
+ BenillaActionButton_OnLoad(self, 4)
-
+ BenillaActionButton_OnLoad(self, 5)
-
+ BenillaActionButton_OnLoad(self, 6)
-
+ BenillaActionButton_OnLoad(self, 7)
-
+ BenillaActionButton_OnLoad(self, 8)
-
+ BenillaActionButton_OnLoad(self, 9)
-
+ BenillaActionButton_OnLoad(self, 10)
-
+ BenillaActionButton_OnLoad(self, 11)
-
+ BenillaActionButton_OnLoad(self, 12)
diff --git a/crates/benilla-app/assets/ui/BagFrame.xml b/crates/benilla-app/assets/ui/BagFrame.xml
index d2977057..4bf6846e 100644
--- a/crates/benilla-app/assets/ui/BagFrame.xml
+++ b/crates/benilla-app/assets/ui/BagFrame.xml
@@ -1856,7 +1856,7 @@
the bar. (MainMenuBarArtFrame is defined in ActionBar.xml, which the loader loads before
this file; the anchor resolves by name.)
-->
-
+
diff --git a/crates/benilla-app/assets/ui/BankFrame.xml b/crates/benilla-app/assets/ui/BankFrame.xml
index e67a7455..835cbb14 100644
--- a/crates/benilla-app/assets/ui/BankFrame.xml
+++ b/crates/benilla-app/assets/ui/BankFrame.xml
@@ -296,7 +296,7 @@
-
+
@@ -1449,23 +1452,23 @@
the loader resolves a key-shaped `text=` through the shipped strings, so the row is
localized like the reference's. (Tabs 1/4 carried the enUS literals from 0437;
same words either way in enUS, and now the same mechanism.) -->
-
-
+
@@ -1791,7 +1794,7 @@
-
+
@@ -1804,7 +1807,7 @@
-
+
@@ -1817,7 +1820,7 @@
-
+
@@ -1830,7 +1833,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/ChatFrame.xml b/crates/benilla-app/assets/ui/ChatFrame.xml
index 3064ddc8..ac926578 100644
--- a/crates/benilla-app/assets/ui/ChatFrame.xml
+++ b/crates/benilla-app/assets/ui/ChatFrame.xml
@@ -1412,7 +1412,7 @@
one script here that is genuinely ChatFrame1's rather than the dock's, so the template
carries the gate rather than the registration.
-->
-
-
+
diff --git a/crates/benilla-app/assets/ui/DressUpFrame.xml b/crates/benilla-app/assets/ui/DressUpFrame.xml
index ab5a0f4f..a6a17b4a 100644
--- a/crates/benilla-app/assets/ui/DressUpFrame.xml
+++ b/crates/benilla-app/assets/ui/DressUpFrame.xml
@@ -147,7 +147,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/ErrorsFrame.xml b/crates/benilla-app/assets/ui/ErrorsFrame.xml
index 7bcfbdb0..b8a26a70 100644
--- a/crates/benilla-app/assets/ui/ErrorsFrame.xml
+++ b/crates/benilla-app/assets/ui/ErrorsFrame.xml
@@ -48,7 +48,7 @@
end
]]>
-
+
diff --git a/crates/benilla-app/assets/ui/FriendsFrame.xml b/crates/benilla-app/assets/ui/FriendsFrame.xml
index 44d109b1..ecaf1e23 100644
--- a/crates/benilla-app/assets/ui/FriendsFrame.xml
+++ b/crates/benilla-app/assets/ui/FriendsFrame.xml
@@ -2433,7 +2433,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/GameMenuFrame.xml b/crates/benilla-app/assets/ui/GameMenuFrame.xml
index 4e5ddb05..d0b5c3b3 100644
--- a/crates/benilla-app/assets/ui/GameMenuFrame.xml
+++ b/crates/benilla-app/assets/ui/GameMenuFrame.xml
@@ -87,9 +87,11 @@
by deleting one `GameMenuButton_Pending` call and writing its OnClick.
- The Options label stays a literal "Options": 1.12 GlobalStrings has no era
GAMEMENU_OPTIONS (checked; its OPTIONS_MENU is "Options Menu", a different string).
- - The ref frame is `toplevel="true" movable="true" parent="UIParent"`. Dropped: this engine has
- no cross-file `parent=` attachment (SkillFrame.xml's header comment), and `movable` is inert
- in the ref too (GameMenuFrame defines no drag scripts, so it never actually moves).
+ - The ref frame is `toplevel="true" movable="true" parent="UIParent"`. `parent="UIParent"` is
+ ours too, and it attaches for real: the loader does read a cross-file `parent=`, resolving
+ the name by global lookup at load (decision 1734), and the frame below declares it. Only
+ `movable` is dropped, and it is inert in the ref too (GameMenuFrame defines no drag
+ scripts, so it never actually moves).
`frameStrata="DIALOG"` is what puts it over the world UI, and that is kept.
**The "no frame-level raise" half of that reason is gone**: the raise law is built
(`script::object::toplevel`, wow-re `ui/scratch/toplevel-raise.md`) and the loader honours
diff --git a/crates/benilla-app/assets/ui/GossipFrame.xml b/crates/benilla-app/assets/ui/GossipFrame.xml
index 305b974b..6eaacce9 100644
--- a/crates/benilla-app/assets/ui/GossipFrame.xml
+++ b/crates/benilla-app/assets/ui/GossipFrame.xml
@@ -292,7 +292,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/GroupLootFrame.xml b/crates/benilla-app/assets/ui/GroupLootFrame.xml
index cf76f1fb..14db6e93 100644
--- a/crates/benilla-app/assets/ui/GroupLootFrame.xml
+++ b/crates/benilla-app/assets/ui/GroupLootFrame.xml
@@ -63,24 +63,22 @@
while the item template is still in flight. `GetLootRollItemLink` (decision 1059) is the
click's, not the hover's: the tooltip wants the roll, not the markup.
- `toplevel="true"` (ref l.243) IS STILL OMITTED, but for a NEW reason — the old one ("`SetToplevel`
- isn't in the v1 object model yet ... trivially re-added the day `SetToplevel` lands") has expired.
- `SetToplevel` landed: the loader calls the real method and the raise law — occlusion-gated,
- confined to this frame's own stratum — is `script::object::toplevel` (wow-re
- `ui/scratch/toplevel-raise.md`).
+ `toplevel="true"` (ref l.243) IS CARRIED, since decision 1739. Two earlier reasons for
+ omitting it have both expired: `SetToplevel` landed (the loader calls the real method, and
+ the raise law — occlusion-gated, confined to this frame's own stratum — is
+ `script::object::toplevel`, wow-re `ui/scratch/toplevel-raise.md`), and the one blocker
+ left after that was a test fixture, not a mechanism.
- What adding it back does, measured rather than guessed: `GroupLootFrame1`'s rect
- (390.5,60)-(633.5,144) overlaps `ExhaustionTick` — `ActionBar.xml` l.1111, `frameStrata="DIALOG"`
- transcribed verbatim from ref-MainMenuBar.xml l.415-442 — in a ~2.5 px band, so showing roll
- frame 1 raises its whole subtree to DIALOG level 3 while frame 2 (which overlaps nothing, the
- four stack with a 15 px gap) stays at level 0. That is exactly what the reference does, and the
- only pixels it moves are the sliver where the roll frame meets the tick. It does, however, break
- `group_loot_tests::start_loot_roll_claims_frames_in_order_and_paints_the_roll`, whose
- `quad_center("UI-GroupLoot-Dice-Up")` picks the FIRST dice quad in painter order as a stand-in
- for "frame 1's Need button" — an unstated assumption that declaration order is draw order among
- the four, which the raise legitimately ends. That is a fixture proxy, not the test's assertion
- (the BoP-confirm gate it proves is untouched), so re-adding the attribute is one word plus a
- two-line fix to that helper — a director's call on the look, not a blocked mechanism.
+ What it does, measured rather than guessed: `GroupLootFrame1`'s rect (390.5,60)-(633.5,144)
+ overlaps `ExhaustionTick` — `ActionBar.xml` l.1111, `frameStrata="DIALOG"` transcribed
+ verbatim from ref-MainMenuBar.xml l.415-442 — in a ~2.5 px band, so showing a roll frame
+ raises its whole subtree within DIALOG. The only pixels that move are the sliver where the
+ roll frame meets the tick, and it is exactly what the reference does. It did cost
+ `group_loot_tests::start_loot_roll_claims_frames_in_order_and_paints_the_roll` its
+ `quad_center("UI-GroupLoot-Dice-Up")` stand-in for "frame 1's Need button" — an unstated
+ assumption that declaration order is draw order among the four, which the raise
+ legitimately ends; that test now takes the button by NAME and asserts the hit before
+ clicking.
NAMED FONT OBJECTS ARE SUPPORTED on this branch (the loader's top-level `` handling,
`loader/mod.rs`'s `do_font`, plus the full `Fonts.xml` transcription, both landed since
@@ -321,7 +319,7 @@
`CreateFrame("Frame", "BRollBarFrame"..i, bar, "GroupLootFrameTemplate")`, which resolves on
the real client and missed here for no reason but our prefix.
-->
-
+
diff --git a/crates/benilla-app/assets/ui/HelpFrame.xml b/crates/benilla-app/assets/ui/HelpFrame.xml
index e0a49792..7bdb7415 100644
--- a/crates/benilla-app/assets/ui/HelpFrame.xml
+++ b/crates/benilla-app/assets/ui/HelpFrame.xml
@@ -659,9 +659,10 @@
13px of the plate out of the mouse rect — the art bleeds past the drawn frame there.
`parent="UIParent"` is the reference's and is kept verbatim (ItemTextFrame.xml's
- precedent); it is inert in this engine, which has no cross-file parent attachment and
- screen-parents a bare toplevel anyway. `movable` is the reference's too and is equally
- inert: this window declares no drag scripts, so it never actually moves. -->
+ precedent). It is NOT inert — the loader resolves a cross-file `parent=` by name (1734), so
+ this window really hangs under `UIParent` and hides with it. `movable` is the reference's
+ too and IS inert, on both sides: this window declares no drag scripts and neither does the
+ reference's, which never calls `StartMoving` on it. -->
diff --git a/crates/benilla-app/assets/ui/HonorFrame.xml b/crates/benilla-app/assets/ui/HonorFrame.xml
index 24eb3c56..12a69a08 100644
--- a/crates/benilla-app/assets/ui/HonorFrame.xml
+++ b/crates/benilla-app/assets/ui/HonorFrame.xml
@@ -15,11 +15,16 @@
Today, Yesterday, This Week, Last Week, Lifetime.
A CHILD PAGE of `CharacterFrame` (ref l.5: `parent="CharacterFrame"`, `id="5"`), shown and
- hidden by `CharacterFrame_ShowSubFrame` — but a SEPARATE top-level file, the
- SkillFrame.xml / PetPaperDollFrame.xml / ReputationFrame.xml shape: this engine reads no XML
- `parent=` attribute (only physical `` nesting creates a real parent), so the page is
- pinned to `CharacterFrame`'s own TOPLEFT by NAME and loaded right after its siblings so it
- paints on top (later file = higher z). Joining `BENILLA_CHARACTERFRAME_SUBFRAMES` as entry 5
+ hidden by `CharacterFrame_ShowSubFrame` — a SEPARATE top-level file, the SkillFrame.xml /
+ PetPaperDollFrame.xml / ReputationFrame.xml shape, and it carries the reference's
+ `parent="CharacterFrame"` for real: the loader does read a cross-file `parent=`, resolving
+ the name by global lookup at load (decision 1734). That name has to exist by the time this
+ file loads — an unresolvable one is not an error, it warns and falls back to the enclosing
+ element — which the manifest order gives it. The page is still sized and pinned to
+ `CharacterFrame`'s own TOPLEFT by NAME and still loaded right after its siblings so it paints
+ on top (later file = higher z): that arrangement produces the identical rect and is what the
+ page was built and approved against, not a workaround for something the engine cannot do.
+ Joining `BENILLA_CHARACTERFRAME_SUBFRAMES` as entry 5
is all it has to do to be closed by `CharacterFrame_OnHide`'s hide-loop.
THE TAB ROW IS NOW WHOLE. Four of the reference's five tabs shipped before this page and
@@ -401,10 +406,12 @@
page's id against its slot in BENILLA_CHARACTERFRAME_SUBFRAMES, and with this page the two
cover the real client's whole row. -->
-
+
diff --git a/crates/benilla-app/assets/ui/ItemRef.xml b/crates/benilla-app/assets/ui/ItemRef.xml
index c433c708..ba279778 100644
--- a/crates/benilla-app/assets/ui/ItemRef.xml
+++ b/crates/benilla-app/assets/ui/ItemRef.xml
@@ -93,7 +93,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/LootFrame.xml b/crates/benilla-app/assets/ui/LootFrame.xml
index 0e24fec5..6a593a1d 100644
--- a/crates/benilla-app/assets/ui/LootFrame.xml
+++ b/crates/benilla-app/assets/ui/LootFrame.xml
@@ -558,7 +558,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/MailFrame.xml b/crates/benilla-app/assets/ui/MailFrame.xml
index e61fb60c..230e3e42 100644
--- a/crates/benilla-app/assets/ui/MailFrame.xml
+++ b/crates/benilla-app/assets/ui/MailFrame.xml
@@ -792,7 +792,7 @@
-
+
@@ -1270,14 +1270,14 @@
-
+ getglobal(this:GetName().."Text"):SetText("Send Money")SendMailRadioButton_OnClick(1)
-
+ getglobal(this:GetName().."Text"):SetText("C.O.D.")
@@ -1414,7 +1414,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/MerchantFrame.xml b/crates/benilla-app/assets/ui/MerchantFrame.xml
index 41a769cc..d83c8228 100644
--- a/crates/benilla-app/assets/ui/MerchantFrame.xml
+++ b/crates/benilla-app/assets/ui/MerchantFrame.xml
@@ -776,7 +776,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/MicroMenu.xml b/crates/benilla-app/assets/ui/MicroMenu.xml
index 115dc280..7a42ab9f 100644
--- a/crates/benilla-app/assets/ui/MicroMenu.xml
+++ b/crates/benilla-app/assets/ui/MicroMenu.xml
@@ -23,10 +23,14 @@
the action bar behind it, and an unguarded call would abort the rest of that OnShow. Same
posture as UiPanels.xml's `CloseMenus and CloseMenus()` and the `if GameTooltip` guards.
- Structure: the buttons are TOP-LEVEL frames anchored by name into MainMenuBarArtFrame
- (this engine has no cross-file `parent=` attachment — SkillFrame.xml's header comment), then
- seated one frame level above the art by BenillaActionBarArt_SeatAbove (ActionBar.xml) so the
- bar plate doesn't paint over them. Same shape the bag bar next door already uses.
+ Structure: the buttons are declared at this file's top level and carry
+ `parent="MainMenuBarArtFrame"`, which really does attach them to the bar art — the loader
+ reads a cross-file `parent=`, resolving the name by global lookup at load (decision 1734),
+ so the art must already exist when this file loads, which the manifest order gives it. They
+ are anchored into it by name as well, and seated one frame level above it by
+ BenillaActionBarArt_SeatAbove (ActionBar.xml) so the bar plate doesn't paint over them —
+ both kept as they are, because that is the row the bar was built and approved against. Same
+ shape the bag bar next door already uses.
SCOPE / real-client divergences (noted, not bugs):
- EVERY button now opens something. Help was the last "art only, wired the day the panel
diff --git a/crates/benilla-app/assets/ui/MultiBars.xml b/crates/benilla-app/assets/ui/MultiBars.xml
index 8ce393a7..1161df20 100644
--- a/crates/benilla-app/assets/ui/MultiBars.xml
+++ b/crates/benilla-app/assets/ui/MultiBars.xml
@@ -278,7 +278,7 @@
them (its HorizontalMultiBar1/2 templates carry it too, l.36/l.151), and the shipped state
a character with a zero toggle byte lands in. MultiActionBar_Update is the only thing that
opens one. -->
-
+
@@ -336,7 +336,7 @@
-
+
@@ -416,7 +416,7 @@
RIGHT_ACTIONBAR_PAGE = 3 (actions 25..36). Geometry from ref-MultiActionBars.xml: a 38x500
frame, button 1 at TOPRIGHT, each next TOP-to-BOTTOM of the previous at -6. -->
-
+
@@ -473,7 +473,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/PartyFrame.xml b/crates/benilla-app/assets/ui/PartyFrame.xml
index 1c935b11..2857e589 100644
--- a/crates/benilla-app/assets/ui/PartyFrame.xml
+++ b/crates/benilla-app/assets/ui/PartyFrame.xml
@@ -420,16 +420,16 @@
-
+
-
+
-
+
-
+
diff --git a/crates/benilla-app/assets/ui/PetActionBar.xml b/crates/benilla-app/assets/ui/PetActionBar.xml
index 5da5e3d3..ef099474 100644
--- a/crates/benilla-app/assets/ui/PetActionBar.xml
+++ b/crates/benilla-app/assets/ui/PetActionBar.xml
@@ -476,7 +476,7 @@
-
+
-
+ BenillaPetActionButton_OnLoad(self, 7)
-
+ BenillaPetActionButton_OnLoad(self, 8)
-
+ BenillaPetActionButton_OnLoad(self, 9)
-
+ BenillaPetActionButton_OnLoad(self, 10)
diff --git a/crates/benilla-app/assets/ui/PetPaperDollFrame.xml b/crates/benilla-app/assets/ui/PetPaperDollFrame.xml
index d793c0a6..196a8e3b 100644
--- a/crates/benilla-app/assets/ui/PetPaperDollFrame.xml
+++ b/crates/benilla-app/assets/ui/PetPaperDollFrame.xml
@@ -6,8 +6,12 @@
`ref-CharacterFrame.lua` for the tab dispatch).
**Its own top-level file, loaded straight after `CharacterFrame.xml`** — the SkillFrame.xml
- precedent: this engine reads no XML `parent=` attribute, so a page ships as a sibling pinned
- to `CharacterFrame`'s TOPLEFT by name, and `BENILLA_CHARACTERFRAME_SUBFRAMES` (which
+ precedent. It carries the reference's `parent="CharacterFrame"`, and that attaches for real:
+ the loader does read a cross-file `parent=`, resolving the name by global lookup at load
+ (decision 1734), which is why the load order matters — an unresolvable name is not an error,
+ it warns and falls back to the enclosing element. The page is still sized and pinned to
+ `CharacterFrame`'s TOPLEFT by name, which gives the identical rect and is what the page was
+ built and approved against, and `BENILLA_CHARACTERFRAME_SUBFRAMES` (which
this page joins as entry 2) is what hides it when the whole window closes.
**Almost none of the page's Lua lives here, and that is the point.** The reference's own
@@ -327,8 +331,10 @@
]]>
-
+
@@ -488,7 +494,7 @@
(its tooltip lists what it eats), not a happiness readout; the happiness face that
does move lives on the pet unit frame (decision 1005/0990). Shown for hunter pets
only. -->
-
+
@@ -568,7 +574,7 @@
-
+
self:EnableMouse(true)
@@ -576,7 +582,7 @@
GameTooltip:Hide()
-
+
self:EnableMouse(true)
@@ -584,7 +590,7 @@
GameTooltip:Hide()
-
+
self:EnableMouse(true)
@@ -592,7 +598,7 @@
GameTooltip:Hide()
-
+
self:EnableMouse(true)
@@ -600,7 +606,7 @@
GameTooltip:Hide()
-
+
self:EnableMouse(true)
@@ -689,7 +695,7 @@
-
+
@@ -702,7 +708,7 @@
-
+
@@ -715,7 +721,7 @@
-
+
@@ -728,7 +734,7 @@
-
+
@@ -741,7 +747,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/PetStable.xml b/crates/benilla-app/assets/ui/PetStable.xml
index 509d49e6..8629999a 100644
--- a/crates/benilla-app/assets/ui/PetStable.xml
+++ b/crates/benilla-app/assets/ui/PetStable.xml
@@ -42,8 +42,9 @@
and l.161-162 and it is transcribed verbatim rather than guarded: the window's current
pet slot cannot paint without it, and a guard here would hide a missing binding instead
of reporting it.
- - `parent="UIParent"` (xml l.72) is dropped: this engine reads no XML `parent=` attribute
- (`PetPaperDollFrame.xml`'s header). The frame's own `` are kept verbatim even
+ - `parent="UIParent"` (xml l.72) is ours too, and it attaches for real: the loader does
+ read a cross-file `parent=`, resolving the name by global lookup at load (decision
+ 1734), and the frame below declares it. The frame's own `` are kept verbatim even
though `ShowUIPanel` re-`SetPoint`s it into the left slot — they name the same
TOPLEFT (0,-104) the slot manager writes, so the two agree (ItemTextFrame.xml keeps its
reference anchor for the same reason).
diff --git a/crates/benilla-app/assets/ui/QuestFrame.xml b/crates/benilla-app/assets/ui/QuestFrame.xml
index f553b537..9052680b 100644
--- a/crates/benilla-app/assets/ui/QuestFrame.xml
+++ b/crates/benilla-app/assets/ui/QuestFrame.xml
@@ -868,7 +868,7 @@
-
+
@@ -1564,14 +1564,14 @@
-
+
-
-
-
-
-
+
+
+
+
+
-
+
diff --git a/crates/benilla-app/assets/ui/QuestTimerFrame.xml b/crates/benilla-app/assets/ui/QuestTimerFrame.xml
index 1d8b9c35..37b7bfe5 100644
--- a/crates/benilla-app/assets/ui/QuestTimerFrame.xml
+++ b/crates/benilla-app/assets/ui/QuestTimerFrame.xml
@@ -151,7 +151,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/ReputationFrame.xml b/crates/benilla-app/assets/ui/ReputationFrame.xml
index 04070c1f..79e27a91 100644
--- a/crates/benilla-app/assets/ui/ReputationFrame.xml
+++ b/crates/benilla-app/assets/ui/ReputationFrame.xml
@@ -13,10 +13,15 @@
addon-visible contract and neither is ours to vary.
A CHILD PAGE of `CharacterFrame` (ref l.211: `parent="CharacterFrame"`, `id="3"`), shown and
- hidden by `CharacterFrame_ShowSubFrame` — but a SEPARATE top-level file, the SkillFrame.xml /
- PetPaperDollFrame.xml shape: this engine reads no XML `parent=` attribute (only physical
- `` nesting creates a real parent), so the page is pinned to `CharacterFrame`'s own
- TOPLEFT by NAME and loaded right after it so it paints on top (later file = higher z).
+ hidden by `CharacterFrame_ShowSubFrame` — a SEPARATE top-level file, the SkillFrame.xml /
+ PetPaperDollFrame.xml shape, and it carries the reference's `parent="CharacterFrame"` for
+ real: the loader does read a cross-file `parent=`, resolving the name by global lookup at
+ load (decision 1734). That name must exist by the time this file loads — an unresolvable one
+ warns and falls back to the enclosing element rather than erroring — which the manifest order
+ gives it. The page is still pinned to `CharacterFrame`'s own TOPLEFT by NAME and still loaded
+ right after it so it paints on top (later file = higher z): that arrangement produces the
+ identical rect and is what the page was built and approved against, not a workaround for
+ something the engine cannot do.
`CharacterFrame.xml`'s `CharacterFrame_OnHide` hide-loop over
`BENILLA_CHARACTERFRAME_SUBFRAMES` is what closes it with the window; joining that table as
entry 3 is all this page has to do to get that.
@@ -584,10 +589,12 @@
`character_tests.rs` pins every page's id against its slot in
BENILLA_CHARACTERFRAME_SUBFRAMES. -->
-
+
diff --git a/crates/benilla-app/assets/ui/SkillFrame.xml b/crates/benilla-app/assets/ui/SkillFrame.xml
index ea8c72fb..151f9392 100644
--- a/crates/benilla-app/assets/ui/SkillFrame.xml
+++ b/crates/benilla-app/assets/ui/SkillFrame.xml
@@ -14,12 +14,14 @@
physically nested `` the way PaperDollFrame is: PaperDollFrame is declared INSIDE
CharacterFrame.xml's own `` tree (a real engine parent/child), while this page is a
SEPARATE file's top-level `` (CharacterFrame.xml is shared-tree with another session
- this pass, so only its tab machinery is touched here — not its frame tree). `parent=` is
- transcribed for fidelity but is a NO-OP in this engine (grepped `benilla-ui/src/loader`: the
- XML `parent` attribute is read nowhere — only physical `` nesting creates a real
- parent). Position instead rides `` (a NAMED
- lookup, works regardless of tree position, the same mechanism every sibling-anchor in this
- codebase already uses) — loaded right after `CharacterFrame.xml` in the load list so it
+ this pass, so only its tab machinery is touched here — not its frame tree). `parent=` really attaches
+ (decision 1734; the loader resolves the name globally at load and it wins over the enclosing
+ element — `shipped_xml_tests::every_declared_parent_really_attaches` asserts it on the loaded
+ tree). The line above used to say the attribute was read nowhere, which was true once and had
+ outlived itself. Position still rides `` — a NAMED
+ lookup that works regardless of tree position, the same mechanism every sibling-anchor in
+ this codebase uses, kept because it is the geometry this page ships with — loaded right after
+ `CharacterFrame.xml` in the load list so it
paints ON TOP of it (later file = higher z, the established house convention every top-level
window already relies on implicitly). Because a true engine Hide-cascade doesn't reach a
non-child, `CharacterFrame.xml`'s own `CharacterFrame_OnHide` was extended (its own
@@ -687,20 +689,20 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/crates/benilla-app/assets/ui/StanceBar.xml b/crates/benilla-app/assets/ui/StanceBar.xml
index 059ffb52..75933e7b 100644
--- a/crates/benilla-app/assets/ui/StanceBar.xml
+++ b/crates/benilla-app/assets/ui/StanceBar.xml
@@ -267,7 +267,7 @@
-
+
@@ -320,43 +320,43 @@
-
+ BenillaShapeshiftButton_OnLoad(self, 1)
-
+ BenillaShapeshiftButton_OnLoad(self, 2)
-
+ BenillaShapeshiftButton_OnLoad(self, 3)
-
+ BenillaShapeshiftButton_OnLoad(self, 4)
-
+ BenillaShapeshiftButton_OnLoad(self, 5)
-
+ BenillaShapeshiftButton_OnLoad(self, 6)
-
+ BenillaShapeshiftButton_OnLoad(self, 7)
-
+ BenillaShapeshiftButton_OnLoad(self, 8)
-
+ BenillaShapeshiftButton_OnLoad(self, 9)
-
+ BenillaShapeshiftButton_OnLoad(self, 10)
diff --git a/crates/benilla-app/assets/ui/TaxiFrame.xml b/crates/benilla-app/assets/ui/TaxiFrame.xml
index f7918fb8..afbebfe3 100644
--- a/crates/benilla-app/assets/ui/TaxiFrame.xml
+++ b/crates/benilla-app/assets/ui/TaxiFrame.xml
@@ -406,7 +406,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/UIDropDownMenu.xml b/crates/benilla-app/assets/ui/UIDropDownMenu.xml
index 17382700..ea43e3bc 100644
--- a/crates/benilla-app/assets/ui/UIDropDownMenu.xml
+++ b/crates/benilla-app/assets/ui/UIDropDownMenu.xml
@@ -951,7 +951,7 @@
world map is FULLSCREEN strata — DIALOG would render the list underneath it (the strata
escalation later reference versions added to ToggleDropDownMenu, applied statically
here since our customer set is known). -->
-
+
diff --git a/crates/benilla-app/assets/ui/UnitFrames.xml b/crates/benilla-app/assets/ui/UnitFrames.xml
index 77a5c6c5..50e03b2d 100644
--- a/crates/benilla-app/assets/ui/UnitFrames.xml
+++ b/crates/benilla-app/assets/ui/UnitFrames.xml
@@ -1179,7 +1179,7 @@
BELOW the MEDIUM panel windows on purpose, and the player frame shares the top-left corner
with the left-slot panel. See the note on PartyFrame.xml's template (decision 0597) for why
the default MEDIUM makes a unit frame's bar children draw through a window's background. -->
-
+
@@ -1461,7 +1461,7 @@
-
+
@@ -1519,7 +1519,7 @@
every login. Steady-state behavior is identical — Update Shows/Hides on target changes. -->
-
+
@@ -1772,7 +1772,7 @@
-
+
@@ -2064,7 +2064,7 @@
-
+
@@ -2504,7 +2504,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/WorldMapFrame.xml b/crates/benilla-app/assets/ui/WorldMapFrame.xml
index 541e9f8f..19469ff0 100644
--- a/crates/benilla-app/assets/ui/WorldMapFrame.xml
+++ b/crates/benilla-app/assets/ui/WorldMapFrame.xml
@@ -440,7 +440,7 @@
restored that line, and this was the frame it broke. `CinematicFrame` is unparented for
exactly the same reason — every `full` panel must be.
-->
-
+
@@ -662,7 +662,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/ZoneText.xml b/crates/benilla-app/assets/ui/ZoneText.xml
index 6865b272..7b13aef4 100644
--- a/crates/benilla-app/assets/ui/ZoneText.xml
+++ b/crates/benilla-app/assets/ui/ZoneText.xml
@@ -293,7 +293,7 @@ the territory line is asked for on NEW_AREA alone.
and its real trigger is Show, not a click, so "click-raise is meaningless on mouse-less
splash text" was the wrong argument. What holds now: these are LOW-strata splash frames
that overlap nothing in LOW, so the occlusion gate declines. One word to re-add.) -->
-
+
@@ -336,7 +336,7 @@ the territory line is asked for on NEW_AREA alone.
-
+
diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc
index 405c4021..e815b066 100644
--- a/crates/benilla-app/assets/ui/benilla.toc
+++ b/crates/benilla-app/assets/ui/benilla.toc
@@ -272,9 +272,9 @@ PetPaperDollFrame.xml
# "Show as Experience Bar" box.
ReputationFrame.xml
# The Skills tab (decision 0437 phase 4): a CharacterFrame child page, but a separate
-# top-level file (SkillFrame.xml's own header comment — this engine has no cross-file
-# `parent=` attachment, so it's positioned by name via `` instead). Loaded after CharacterFrame.xml so it paints ON TOP of
+# top-level file — it declares `parent="CharacterFrame"`, which really attaches (1734), and
+# is positioned by name via ``, the geometry it ships
+# with. Loaded after CharacterFrame.xml so it paints ON TOP of
# it (later file = higher z) and so `CharacterFrame` already exists for that anchor to
# resolve against — seated at the real client's own tab 4, between Reputation above and
# Honor below.
diff --git a/crates/benilla-app/src/net/motion/remote.rs b/crates/benilla-app/src/net/motion/remote.rs
index bfd1b936..58f8c012 100644
--- a/crates/benilla-app/src/net/motion/remote.rs
+++ b/crates/benilla-app/src/net/motion/remote.rs
@@ -766,8 +766,15 @@ impl RemoteMotion {
let mut pos = self.wow_pos;
pos[0] += self.jump_xy_vel[0] * dt;
pos[1] += self.jump_xy_vel[1] * dt;
- pos[2] += self.vertical_velocity * dt;
- let vertical = (self.vertical_velocity - GRAVITY * dt).max(-TERMINAL_VELOCITY);
+ // **The same exact fall step the local mover runs** ([`crate::player::mover::fall_step`],
+ // decision 1740) — one shared machine, which is what the reference has. This used to
+ // move at the START-of-step speed and only then apply gravity (explicit Euler), so a
+ // watched player's jump overshot by `½·g·dt²` every frame and floated; the local mover
+ // had the mirror-image bug in the other direction. The mean velocity is the exact
+ // displacement rate under constant acceleration, so neither drifts now.
+ let (vertical, mean_vy) =
+ crate::player::mover::fall_step(self.vertical_velocity, dt, TERMINAL_VELOCITY);
+ pos[2] += mean_vy * dt;
let speed = self.jump_xy_vel[0].hypot(self.jump_xy_vel[1]);
return (pos, self.orientation, vertical, speed);
}
diff --git a/crates/benilla-app/src/net/motion/tests.rs b/crates/benilla-app/src/net/motion/tests.rs
index 18ed8a33..55290c29 100644
--- a/crates/benilla-app/src/net/motion/tests.rs
+++ b/crates/benilla-app/src/net/motion/tests.rs
@@ -274,9 +274,16 @@ fn remote_motion_jump_is_a_parabola_not_flag_walking() {
"horizontal coasts at the frozen 7 yd/s: {pos:?}"
);
assert!(pos[1].abs() < 1e-3, "no lateral drift: {pos:?}");
+ // **The analytic height, `v₀·t − ½g·t²`** (decision 1740). This asserted `v₀·t` until the
+ // airborne-integrator round — explicit Euler, moving the whole step at the START-of-step speed
+ // with no gravity in the displacement at all, which over this 0.5 s step is 3.98 yd against a
+ // true 1.57. The local mover had the mirror-image error in the other direction. Both now run
+ // the one exact step ([`crate::player::mover::fall_step`]), so this is the closed form and it
+ // holds at any `dt`.
+ let analytic = 7.955_547 * 0.5 - 0.5 * GRAVITY * 0.5 * 0.5;
assert!(
- (pos[2] - 7.955_547 * 0.5).abs() < 1e-3,
- "height integrates v·dt: {pos:?}"
+ (pos[2] - analytic).abs() < 1e-3,
+ "height follows the closed form {analytic}: {pos:?}"
);
assert!(
(vz - (7.955_547 - GRAVITY * 0.5)).abs() < 1e-3,
diff --git a/crates/benilla-app/src/player/arc.rs b/crates/benilla-app/src/player/arc.rs
index d7026fe4..0f60521e 100644
--- a/crates/benilla-app/src/player/arc.rs
+++ b/crates/benilla-app/src/player/arc.rs
@@ -61,11 +61,21 @@ impl Player {
let new_arc = airborne && (!was_airborne || jumped);
if new_arc {
self.airborne_since = Some(now);
- self.jump_zspeed = if jumped { self.vel_y } else { 0.0 };
+ // From `launch_vz`, NOT `vel_y` (decision 1740): the mover now integrates gravity on
+ // the take-off frame too, so `vel_y` has already moved `g·dt` down the parabola by the
+ // time this runs and would seat the arc's launch speed a step short — visible on the
+ // wire, where the jump tail sends this value on every packet of the arc, and in the
+ // FALLINGFAR leg split. The reference writes `+0xa0` once, in `StartFalling`.
+ self.jump_zspeed = if jumped { self.launch_vz } else { 0.0 };
self.fall_start_y = launch_y;
self.fall_far = false;
} else if !airborne {
self.airborne_since = None;
+ // The arc is over: its direction nibble and its knockback provenance go with it, so
+ // the next standstill jump gets its one steer back and the wire stops planting FORWARD
+ // (decision 1740). The reference's `StopFalling` is the same edge.
+ self.arc_dirs_set = false;
+ self.knock_arc = false;
}
if airborne {
let far = if self.jump_zspeed != 0.0 {
@@ -125,6 +135,10 @@ mod tests {
fn grounded_at(ground: f32) -> Player {
Player {
vel_y: JUMP_SPEED,
+ // The mover records this the instant the take-off is decided, and the arc snapshot
+ // reads it rather than `vel_y` (decision 1740) — by the time the bookkeeping runs, the
+ // real mover has already stepped `vel_y` one `g·dt` down the parabola.
+ launch_vz: JUMP_SPEED,
pos: Vec3::new(0.0, ground, 0.0),
..Default::default()
}
@@ -140,6 +154,46 @@ mod tests {
p
}
+ /// **The launch speed on the wire is the LAUNCH's, not the post-step velocity** (decision
+ /// 1740). The mover integrates gravity on the take-off frame too, so `vel_y` is already
+ /// `g·dt` down by the time the arc bookkeeping runs; reading it would send observers a jump
+ /// tail one step short (0.32 yd/s at 60 fps, 0.64 at 30 — and frame-rate dependent, so two
+ /// clients watching the same jump would disagree). The reference writes `+0xa0` once, in
+ /// `StartFalling`, and never touches it again for the arc.
+ #[test]
+ fn the_arc_snapshots_the_launch_speed_not_the_already_integrated_one() {
+ let mut p = grounded_at(100.0);
+ // What the mover leaves behind on the take-off frame: the launch recorded, `vel_y` already
+ // advanced one step by the exact fall step.
+ let dt = 1.0 / 60.0;
+ p.vel_y = super::super::mover::fall_step(JUMP_SPEED, dt, 60.148_003).0;
+ assert!(
+ p.vel_y < JUMP_SPEED,
+ "the take-off frame really did integrate"
+ );
+ let edges = p.advance_airborne_arc(true, true, 0.0, 100.0);
+ assert!(edges.new_arc);
+ assert_eq!(
+ p.jump_zspeed, JUMP_SPEED,
+ "the wire tail carries the launch speed for the whole arc, not this frame's velocity"
+ );
+ }
+
+ /// The arc's direction nibble and its knockback provenance are **arc-scoped**: a landing
+ /// clears both, so the next standstill jump gets its one steer back and the wire stops
+ /// planting FORWARD (decision 1740 — the reference's `StopFalling` edge).
+ #[test]
+ fn landing_clears_the_arc_nibble_and_the_knockback_provenance() {
+ let mut p = grounded_at(100.0);
+ p.advance_airborne_arc(true, true, 0.0, 100.0);
+ p.arc_dirs_set = true;
+ p.knock_arc = true;
+ let edges = p.advance_airborne_arc(false, false, 0.5, 100.0);
+ assert!(edges.landed, "the arc closed");
+ assert!(!p.arc_dirs_set, "the nibble goes with the arc");
+ assert!(!p.knock_arc, "so does the knockback provenance");
+ }
+
#[test]
fn a_fresh_jump_snapshots_the_true_launch_ground_not_the_risen_pos() {
let mut p = grounded_at(100.0);
diff --git a/crates/benilla-app/src/player/controller.rs b/crates/benilla-app/src/player/controller.rs
index 776f7f77..bc3d04d7 100644
--- a/crates/benilla-app/src/player/controller.rs
+++ b/crates/benilla-app/src/player/controller.rs
@@ -745,6 +745,13 @@ pub(super) fn control(
wire_jump,
knock_launch,
water_floor,
+ // **The air-control nudge speed is `min(MOVE_WALK, MOVE_RUN)`, read live**
+ // (decision 1740) — the walk override inside the reference's `0x7c4c90(1)`
+ // (`0x7c4d19`/`0x7c4d1b`), not a constant. `AIR_NUDGE_SPEED`'s 2.5 is the default
+ // `MOVE_WALK` and stays as the fallback for before the server has sent us speeds;
+ // once it has, a walk aura, a Slow or a daze moves this with them, and the min is
+ // why a *slowed* run still cannot out-steer a walk.
+ mover_speeds.map_or(super::AIR_NUDGE_SPEED, |s| s.walk.min(s.run)),
)
};
diff --git a/crates/benilla-app/src/player/flags.rs b/crates/benilla-app/src/player/flags.rs
index d38a9196..f714557b 100644
--- a/crates/benilla-app/src/player/flags.rs
+++ b/crates/benilla-app/src/player/flags.rs
@@ -165,6 +165,16 @@ pub(super) fn this_frame(
move_flags_now = 0;
}
}
+ // **A knockback arc streams FORWARD for its whole length** (decision 1740). The reference's
+ // apply plants it as part of the launch (`0x6179c0`'s `0x617a18 or edx,0x8001` — set bit 0,
+ // clear bit 1) and nothing clears it until the arc ends, and the send mask
+ // (`0x618909 and edx,0x75a07dff`) keeps bit 0, so observers see it. It is also the mechanism
+ // that makes the arc unsteerable, so the bit and the freeze are the same fact, set in one
+ // place. Ours never sent it at all: a knocked-back body streamed a bare FALLING and observers
+ // replayed it as a plain drop.
+ if player.knock_arc {
+ move_flags_now = (move_flags_now & !move_flags::BACKWARD) | move_flags::FORWARD;
+ }
// The two incapacitate suppressions — rooted drops the direction bits, stunned drops the
// turn bits — applied to the whole word in one place, whichever branch built it, and with
// the reference's byte trail in [`state::incapacitated_flags`] (decision 0880).
@@ -194,3 +204,84 @@ pub(super) fn this_frame(
fall_time: wire_fall_time,
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::player::input::MoveAxes;
+
+ fn still() -> MoveAxes {
+ MoveAxes {
+ fwd: 0,
+ side: 0,
+ mouselook: false,
+ turning: false,
+ translating: false,
+ autorun_armed: false,
+ strafe_left: false,
+ strafe_right: false,
+ turn_left: false,
+ turn_right: false,
+ }
+ }
+
+ /// **A knockback arc streams FORWARD for its whole length** (decision 1740). The reference's
+ /// apply plants the bit as part of the launch (`0x6179c0`'s `0x617a18 or edx,0x8001` — set bit
+ /// 0, clear bit 1) and only the arc's end clears it; the send mask
+ /// (`0x618909 and edx,0x75a07dff`) keeps bit 0, so observers get it on every packet of the
+ /// flight. We sent a bare FALLING instead, and observers replayed a knockback as a plain drop.
+ ///
+ /// It is the same bit that makes the arc unsteerable, which is why the two are one fact set in
+ /// one place — see [`super::mover`]'s air-control gate.
+ #[test]
+ fn a_knockback_arc_plants_forward_on_the_wire() {
+ let mut player = Player {
+ knock_arc: true,
+ ..Default::default()
+ };
+ // Airborne, no keys held at all — nothing else could produce a direction bit.
+ let f = this_frame(
+ &mut player,
+ &still(),
+ None,
+ true,
+ false,
+ false,
+ false,
+ false,
+ 1.0,
+ 0.0,
+ );
+ assert!(
+ f.wire & move_flags::FORWARD != 0,
+ "the launch's planted FORWARD rides the wire: {:#x}",
+ f.wire
+ );
+ assert!(
+ f.wire & move_flags::BACKWARD == 0,
+ "and it clears BACKWARD, as the apply does: {:#x}",
+ f.wire
+ );
+ assert!(f.wire & move_flags::FALLING != 0, "still an airborne arc");
+
+ // The same frame without the knockback provenance streams no direction at all.
+ let mut plain = Player::default();
+ let f = this_frame(
+ &mut plain,
+ &still(),
+ None,
+ true,
+ false,
+ false,
+ false,
+ false,
+ 1.0,
+ 0.0,
+ );
+ assert!(
+ f.wire & move_flags::ANY_MOVE == 0,
+ "an ordinary fall with no keys held streams no direction: {:#x}",
+ f.wire
+ );
+ }
+}
diff --git a/crates/benilla-app/src/player/mover.rs b/crates/benilla-app/src/player/mover.rs
index 25d5abf7..37df9382 100644
--- a/crates/benilla-app/src/player/mover.rs
+++ b/crates/benilla-app/src/player/mover.rs
@@ -37,11 +37,10 @@ use avian3d::prelude::*;
use bevy::prelude::*;
use super::{
- move_trace, Player, AIR_NUDGE_SPEED, CAPSULE_HEIGHT, FEATHER_TERMINAL_VELOCITY,
- FOOT_CONE_HEIGHT, GRAVITY, GROUND_COS, GROUND_PROBE, HOVER_CLIMB_RATE, HOVER_HEIGHT,
- JUMP_SPEED, LAND_PROBE, SKIN_WIDTH, STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_ADVANCE,
- STEP_UP_HEIGHT, TERMINAL_VELOCITY, WATER_WALK_PITCH_FLOOR, WEDGE_MIN_FALL, WEDGE_STALL_RATIO,
- WEDGE_STILL_FRAMES,
+ move_trace, Player, CAPSULE_HEIGHT, FEATHER_TERMINAL_VELOCITY, FOOT_CONE_HEIGHT, GRAVITY,
+ GROUND_COS, GROUND_PROBE, HOVER_CLIMB_RATE, HOVER_HEIGHT, JUMP_SPEED, LAND_PROBE, SKIN_WIDTH,
+ STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_ADVANCE, STEP_UP_HEIGHT, TERMINAL_VELOCITY,
+ WATER_WALK_PITCH_FLOOR, WEDGE_MIN_FALL, WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES,
};
/// **The trace-mask arm `0x6315f0`, as a predicate** — the liquid surface [`step`] may stand on
@@ -125,6 +124,7 @@ pub(super) fn step(
wire_jump: bool,
knockback: Option,
water_floor: Option,
+ nudge_speed: f32,
) -> Outcome {
let dt = time.delta_secs();
let input_horiz = if moving {
@@ -272,11 +272,20 @@ pub(super) fn step(
// 1620), and being knocked back mid-air is exactly what a knockback is for; the server's own
// gate (`Unit::KnockBack`) tests stun, root and a live spline, never FALLING.
player.vel_y = launch.y;
+ player.launch_vz = launch.y;
player.horiz_vel = Vec3::new(launch.x, 0.0, launch.z);
player.wedged = false;
player.steep_support = false;
jumped = true;
knocked = true;
+ // **The launch plants the direction nibble, and that is what freezes the arc** — the
+ // reference's `0x617a18 or edx,0x8001` sets FORWARD and clears BACKWARD as part of the
+ // apply (decision 1740). It is set regardless of `xy_speed`, so a purely vertical knockback
+ // is just as unsteerable as a 25 yd/s one; reading the freeze off the *velocity* instead
+ // (what we did until 1740) let that case steer from the second frame. `knock_arc` also
+ // rides the wire for the arc's length, which is the half we were not sending at all.
+ player.arc_dirs_set = true;
+ player.knock_arc = true;
} else if grounded {
player.vel_y = 0.0;
// **HOVER refuses the jump** — the FIRST test in `CMovement::Jump 0x7c6230`
@@ -295,33 +304,49 @@ pub(super) fn step(
// it is the *same* jump, entered through the door the wire is given.
if (want_jump && !player.modes.hover) || wire_jump {
player.vel_y = JUMP_SPEED;
+ player.launch_vz = JUMP_SPEED;
player.wedged = false;
player.steep_support = false;
jumped = true;
+ // The arc's nibble starts as the keys are: a jump taken with a direction held is
+ // locked to its momentum, a jump from a standstill has one steer in hand (1740).
+ player.arc_dirs_set = moving;
}
- } else {
- // **Feather fall is a terminal-velocity substitution, and nothing else** (decision 0866).
- // The reference's fall-velocity query `0x7c5d20` picks its clamp from one flag test
- // (`0x7c5d23 test [ecx+0x40], 0x20000000`) — the ordinary 60.148 or 7.0 under
- // `MOVEFLAG_SAFE_FALL`. Gravity itself is unchanged, so a Slow Fall still *accelerates*
- // normally for the first ~0.36 s and only then rides the cap: the drop starts like any
- // other and settles into a drift, which is what Slow Fall looks like.
- //
- // **This is semi-implicit Euler; the reference is not** (decision 1736). It evaluates a
- // CLOSED FORM every substep — `+0x7c − D(+0x78)` via `0x7c5e70`, anchored at the launch —
- // so its arc is frame-rate independent and cannot drift, while ours accumulates and lands
- // low by `½·g·dt·t`: **0.066 yd at the jump apex at 60 fps (4.0% of the 1.640 yd apex),
- // 0.133 at 30 fps**, saturating near 0.50/1.00 yd at terminal. Our jump is exactly one
- // frame short, and the error is frame-rate dependent, which a jump arc must not be.
- // (`0x7c5d20`, cited above, is the velocity *query* that serves the clamp select — it
- // writes nothing. Both wow-re's ledger and this file had it as the integrator until the
- // 1736 round; the constants it yields are still bit-exact.)
+ } else if !player.airborne_prev {
+ // **A step-off — the arc nobody launched.** The walk election calls `StartFalling(0)`, so
+ // the launch speed is exactly 0 (not the gravity-polluted first tick), which is what the
+ // wire tail sends and what splits the FALLINGFAR legs (0179). The nibble seeds from the
+ // keys like any other take-off: you walked off this ledge, so you keep your momentum.
+ player.launch_vz = 0.0;
+ player.arc_dirs_set = moving;
+ }
+
+ // **The fall step, for every airborne frame INCLUDING the take-off's** (decision 1740). It used
+ // to sit inside the `else` above, which meant a launch frame moved a whole `v₀·dt` with no
+ // gravity at all while every later frame moved at its END-of-step speed — one overshoot
+ // followed by a systematic undershoot. The reference has no such seam: `StartFalling` anchors
+ // the arc and the closed form is evaluated from `t = 0`, the launch frame included.
+ //
+ // **Feather fall is a terminal-velocity substitution, and nothing else** (decision 0866). The
+ // reference's fall-velocity query `0x7c5d20` picks the clamp from one flag test
+ // (`0x7c5d23 test [ecx+0x40], 0x20000000`) — the ordinary 60.148 or 7.0 under
+ // `MOVEFLAG_SAFE_FALL`. Gravity itself is unchanged, so a Slow Fall still *accelerates*
+ // normally for the first ~0.36 s and only then rides the cap: the drop starts like any other
+ // and settles into a drift, which is what Slow Fall looks like.
+ //
+ // `player.vel_y` keeps the END-of-step speed (the state the next frame and the wire read);
+ // `fall_mean_vy` is what this frame actually MOVES at, and for constant acceleration the mean
+ // is exact — see [`fall_step`].
+ let mut fall_mean_vy = player.vel_y;
+ if !held && !anchored && (jumped || !grounded) {
let terminal = if player.modes.feather_fall {
FEATHER_TERMINAL_VELOCITY
} else {
TERMINAL_VELOCITY
};
- player.vel_y = (player.vel_y - GRAVITY * dt).max(-terminal);
+ let (end_vy, mean_vy) = fall_step(player.vel_y, dt, terminal);
+ player.vel_y = end_vy;
+ fall_mean_vy = mean_vy;
}
let mut air_nudged = false;
// The anchor owns the horizontal too — the wipe leaves the basis recompute nothing to build a
@@ -334,10 +359,10 @@ pub(super) fn step(
// this frame, and the walk arm below would hand our momentum straight back to the keys.
} else if grounded && !anchored {
player.horiz_vel = input_horiz;
- } else if !held && !anchored && moving && player.horiz_vel.length_squared() < 0.01 {
+ } else if !held && !anchored && moving && !player.arc_dirs_set {
// Air control: one nudge to steer a jump that took off from a standstill (a moving jump
- // keeps its momentum locked, since horiz_vel is already non-zero). The pressed direction
- // *really* moves us, so it re-seeds the frozen airborne direction flags.
+ // keeps its momentum locked, because its nibble was already set at take-off). The pressed
+ // direction *really* moves us, so it re-seeds the frozen airborne direction flags.
//
// **The guard is a velocity proxy for a flag test, and the reference's is the flag**
// (decision 1736, wow-re `airborne-steerability.md`). `0x7c5a20`/`0x7c5c20` do not bail on
@@ -351,12 +376,16 @@ pub(super) fn step(
// Fixing it means carrying the nibble as arc state and seeding it at the launch — 1736
// §Plan, deliberately not done in the round that found it.
//
- // [`AIR_NUDGE_SPEED`] is likewise the right number for the wrong reason: the reference
- // takes `min(MOVE_WALK, MOVE_RUN)` from the unit's live speeds (`0x7c4c90(1)`'s walk
- // override), and 2.5 is merely the DEFAULT `MOVE_WALK`. Under a walk aura, a Slow or a
- // daze it is wrong.
- player.horiz_vel = dir.normalize_or_zero() * AIR_NUDGE_SPEED;
+ // [`super::AIR_NUDGE_SPEED`] is likewise the right number for the wrong reason: the
+ // reference takes `min(MOVE_WALK, MOVE_RUN)` from the unit's live speeds (`0x7c4c90(1)`'s
+ // walk override), and 2.5 is merely the DEFAULT `MOVE_WALK` — so it is now the fallback
+ // `nudge_speed` the caller passes when the server has told us no speeds yet, not the law.
+ player.horiz_vel = dir.normalize_or_zero() * nudge_speed;
air_nudged = true;
+ // The press sets the nibble, which is what makes this one-shot on the reference: the next
+ // press finds `+0x40 & 0xf` non-zero, `arg = 0` goes to the basis rebuild, and the bail
+ // fires. No second steer, and no drifting back toward the keys.
+ player.arc_dirs_set = true;
}
let pre_move = center;
@@ -402,7 +431,8 @@ pub(super) fn step(
let velocity = if held || anchored {
Vec3::ZERO
} else {
- player.horiz_vel + Vec3::Y * player.vel_y
+ // `fall_mean_vy`, not `player.vel_y` — the exact displacement rate for this step.
+ player.horiz_vel + Vec3::Y * fall_mean_vy
};
// The airborne slide is the OTHER shared resolve ([`airborne_step`]) — the same code a
// remote mover's arc runs, so a jump meets our walls whoever is jumping (decision 0627).
@@ -530,6 +560,14 @@ pub(super) fn step(
anchored,
});
+ // The mover's own airborne record for the next step's arc-start test — see
+ // [`Player::airborne_prev`]. Held and anchored bodies are not airborne: the anchor ends the arc
+ // (0880) and the settle hold has no arc at all.
+ // `jumped ||`, not just `!grounded`: a launch frame IS the arc's first airborne frame even
+ // though the body is still standing on the floor it pushed off. Without that, the frame after a
+ // jump looks like a fresh step-off and re-seeds the arc state the launch just set — which is
+ // exactly how a standstill jump lost the one steer it is owed.
+ player.airborne_prev = !held && !anchored && (jumped || !grounded);
Outcome {
held,
grounded,
@@ -948,6 +986,43 @@ pub(crate) fn grounded_step(
/// ([`crate::net::motion::remote`]). Without that, a watched player who jumps into a building is
/// drawn *inside* it for the length of the jump and pops back out on the landing packet — the
/// airborne half of the very defect 0626 fixed on the ground (decision 0627).
+/// **One frame of the fall, integrated exactly** — the end-of-step vertical speed and the *mean*
+/// speed to move at, given the speed at the step's start.
+///
+/// Under constant acceleration the mean velocity over a step IS the exact displacement rate, so
+/// `mean · dt` is the closed form `v₀·dt − ½g·dt²`, not an approximation of it. That matters
+/// because the reference does not integrate at all: it evaluates a closed form anchored at the
+/// launch every substep (`+0x7c − D(+0x78)` via `0x7c5e70`), so its arc is frame-rate independent
+/// and cannot drift (decision 1740, wow-re `airborne-steerability.md`). Stepping at either
+/// endpoint's velocity instead of the mean loses `½·g·dt²` per frame — and both of ours did, in
+/// opposite directions:
+///
+/// - the local mover applied gravity *before* moving (semi-implicit), so it moved at the END-of-step
+/// speed and fell **low**: `½·g·dt·t` by elapsed time, = 0.066 yd at the 1.640 yd jump apex at
+/// 60 fps (4.0%), 0.133 at 30 fps. The jump was exactly one frame short, and short by *more* the
+/// worse the frame rate — which a jump arc must never be;
+/// - the remote dead-reckon moved *before* applying gravity (explicit Euler), so it moved at the
+/// START-of-step speed and overshot by the same amount, floating every other player's jump.
+///
+/// The terminal clamp is handled piecewise rather than by clamping the mean, because a step that
+/// *reaches* terminal partway is two regimes: accelerating to `−terminal` at `t_c`, then coasting.
+/// Clamping the mean would under-move that one step by up to `½·g·dt²`.
+pub(crate) fn fall_step(v0: f32, dt: f32, terminal: f32) -> (f32, f32) {
+ let unclamped = v0 - GRAVITY * dt;
+ if unclamped >= -terminal {
+ // Wholly inside the clamp: mean of the endpoints, exact.
+ return (unclamped, 0.5 * (v0 + unclamped));
+ }
+ if v0 <= -terminal {
+ // Already at (or past) terminal for the whole step — constant velocity.
+ return (-terminal, -terminal);
+ }
+ // The clamp engages partway: accelerate for `t_c`, then coast the remainder.
+ let t_c = (v0 + terminal) / GRAVITY;
+ let displacement = 0.5 * (v0 - terminal) * t_c - terminal * (dt - t_c);
+ (-terminal, displacement / dt)
+}
+
pub(crate) fn airborne_step(
world: &benilla_world::collision::WorldCollision<'_, '_>,
capsule: &Collider,
@@ -1275,6 +1350,7 @@ pub(crate) fn step_up(
#[cfg(test)]
mod tests {
use super::*;
+ use crate::player::AIR_NUDGE_SPEED;
use crate::player::CAPSULE_RADIUS;
use bevy::ecs::system::RunSystemOnce;
@@ -2149,6 +2225,7 @@ mod tests {
false,
None,
Some(SURFACE),
+ AIR_NUDGE_SPEED,
);
}
player.pos.y
@@ -2221,6 +2298,7 @@ mod tests {
false,
None,
Some(SURFACE),
+ AIR_NUDGE_SPEED,
);
}
player.pos.y
@@ -2304,6 +2382,7 @@ mod tests {
false,
None,
Some(SURFACE),
+ AIR_NUDGE_SPEED,
);
}
player.pos
@@ -2381,8 +2460,9 @@ mod tests {
false,
None,
None,
+ AIR_NUDGE_SPEED,
);
- (out.jumped, player.vel_y)
+ (out.jumped, player.launch_vz)
})
})
.unwrap();
@@ -2431,8 +2511,9 @@ mod tests {
wire_jump,
None,
None,
+ AIR_NUDGE_SPEED,
);
- (out.jumped, player.vel_y)
+ (out.jumped, player.launch_vz)
};
(press(true, false), press(false, true))
})
@@ -2445,7 +2526,245 @@ mod tests {
assert_eq!(
wire,
(true, JUMP_SPEED),
- "`force = 0` skips that test and takes off on the land seed `0xc0fe93d8`"
+ "`force = 0` skips that test and takes off on the land seed `0xc0fe93d8` (the launch \
+ speed, not the post-gravity `vel_y` — 1740)"
+ );
+ }
+
+ /// **The fall step is EXACT, not merely close** (decision 1740). Under constant acceleration
+ /// the mean of the endpoint velocities is the true displacement rate, so one step of `dt` and
+ /// `n` steps of `dt/n` must land on the same height — that is the whole property the reference
+ /// gets for free by evaluating a closed form, and the one both of our integrators lacked.
+ ///
+ /// The old semi-implicit form fails this: it lost `½·g·dt²` per step, so a coarser step landed
+ /// lower, which is a jump arc that changes shape with the frame rate.
+ #[test]
+ fn the_fall_step_is_frame_rate_independent() {
+ let total = 0.5_f32;
+ let drop = |steps: u32| {
+ let dt = total / steps as f32;
+ let (mut v, mut y) = (JUMP_SPEED, 0.0_f32);
+ for _ in 0..steps {
+ let (end, mean) = fall_step(v, dt, TERMINAL_VELOCITY);
+ y += mean * dt;
+ v = end;
+ }
+ y
+ };
+ // The analytic height after `total`: v₀·t − ½g·t².
+ let analytic = JUMP_SPEED * total - 0.5 * GRAVITY * total * total;
+ for steps in [1_u32, 2, 8, 30, 120] {
+ let got = drop(steps);
+ assert!(
+ (got - analytic).abs() < 1.0e-4,
+ "{steps} steps must reproduce the closed form {analytic}, got {got}"
+ );
+ }
+ }
+
+ /// The jump apex is the reference's, to the millimetre: `v₀²/2g` = 1.640 yd. The old
+ /// integrator undershot it by 4.0% at 60 fps and 8% at 30 — visible as a jump that could not
+ /// quite clear what it should (decision 1740).
+ #[test]
+ fn the_jump_apex_is_the_analytic_one_at_any_frame_rate() {
+ let apex = JUMP_SPEED * JUMP_SPEED / (2.0 * GRAVITY);
+ for fps in [30.0_f32, 60.0, 144.0] {
+ let dt = 1.0 / fps;
+ let (mut v, mut y, mut high) = (JUMP_SPEED, 0.0_f32, 0.0_f32);
+ while v > -1.0 {
+ let (end, mean) = fall_step(v, dt, TERMINAL_VELOCITY);
+ y += mean * dt;
+ high = high.max(y);
+ v = end;
+ }
+ // Within one step's worth of sampling — the apex falls between frames, and that is
+ // sampling, not drift.
+ assert!(
+ (high - apex).abs() < 0.5 * GRAVITY * dt * dt + 1.0e-4,
+ "at {fps} fps the apex should be ~{apex}, got {high}"
+ );
+ }
+ }
+
+ /// **Terminal is a piecewise clamp, not a clamped mean** (decision 1740). A step that reaches
+ /// terminal partway is two regimes, and clamping the mean instead would under-move it by up to
+ /// `½·g·dt²`. Below terminal the clamp must not perturb the exact answer at all.
+ #[test]
+ fn the_terminal_clamp_is_exact_on_the_step_that_reaches_it() {
+ let dt = 1.0 / 60.0;
+ // Well below terminal: untouched by the clamp.
+ let (end, mean) = fall_step(0.0, dt, TERMINAL_VELOCITY);
+ assert!((end - -GRAVITY * dt).abs() < 1.0e-6);
+ assert!((mean - -0.5 * GRAVITY * dt).abs() < 1.0e-6);
+ // Straddling terminal: reach it at `t_c`, coast the rest.
+ let v0 = -TERMINAL_VELOCITY + 0.5 * GRAVITY * dt;
+ let (end, mean) = fall_step(v0, dt, TERMINAL_VELOCITY);
+ assert_eq!(end, -TERMINAL_VELOCITY, "the step ends at terminal");
+ let t_c = (v0 + TERMINAL_VELOCITY) / GRAVITY;
+ let want = (0.5 * (v0 - TERMINAL_VELOCITY) * t_c - TERMINAL_VELOCITY * (dt - t_c)) / dt;
+ assert!(
+ (mean - want).abs() < 1.0e-6,
+ "piecewise, not a clamped mean"
+ );
+ // Already at terminal: constant velocity, no acceleration at all.
+ let (end, mean) = fall_step(-TERMINAL_VELOCITY, dt, TERMINAL_VELOCITY);
+ assert_eq!((end, mean), (-TERMINAL_VELOCITY, -TERMINAL_VELOCITY));
+ }
+
+ /// **A knockback is unsteerable because its launch plants a direction bit — not because it is
+ /// fast** (decision 1740, wow-re `airborne-steerability.md`).
+ ///
+ /// This is the case the old gate got wrong, and it is the reason the gate changed. Air control
+ /// opens on the reference exactly while the direction nibble `[CMovement+0x40] & 0xf` is clear
+ /// (`0x7c6afc test al,0xf` guarding the only two `arg = 1` openers), and `0x6179c0`'s
+ /// `0x617a18 or edx,0x8001` plants FORWARD as part of every knockback apply — *regardless of
+ /// `speedXY`*. We tested `horiz_vel.length_squared() < 0.01` instead, a velocity proxy that
+ /// agrees for every ordinary jump and disagrees for exactly this input: a knockback with no
+ /// horizontal component at all, which the proxy reads as "standing still, may steer". Since
+ /// `Outcome::knocked` is true on the launch frame only, nothing else covered it either — the
+ /// player got 2.5 yd/s of free steering from the second frame of a purely vertical toss.
+ #[test]
+ fn a_vertical_knockback_is_no_more_steerable_than_a_fast_one() {
+ let flat: [(f32, f32); 2] = [(-3.0, 0.0), (3.0, 0.0)];
+ // Straight up, no horizontal at all — the input the old velocity proxy could not see.
+ const STRAIGHT_UP: Vec3 = Vec3::new(0.0, 12.0, 0.0);
+ let horiz = world_from_profile(&flat)
+ .world_mut()
+ .run_system_once(|world: benilla_world::collision::WorldCollision| {
+ let capsule = player_capsule();
+ let mut time = Time::default();
+ time.advance_by(std::time::Duration::from_secs_f32(1.0 / 60.0));
+ let mut player = Player::default();
+ // Frame 1: the launch.
+ let out = step(
+ &mut player,
+ &time,
+ &world,
+ &capsule,
+ false,
+ Vec3::ZERO,
+ 0.0,
+ false,
+ false,
+ Some(STRAIGHT_UP),
+ None,
+ AIR_NUDGE_SPEED,
+ );
+ assert!(out.knocked && out.jumped, "the toss opened an arc");
+ assert_eq!(player.horiz_vel, Vec3::ZERO, "and it is purely vertical");
+ // Frames 2..6: airborne, and now the player leans on a movement key.
+ let mut nudged = false;
+ for _ in 0..5 {
+ let out = step(
+ &mut player,
+ &time,
+ &world,
+ &capsule,
+ true,
+ Vec3::NEG_X,
+ 7.0,
+ false,
+ false,
+ None,
+ None,
+ AIR_NUDGE_SPEED,
+ );
+ nudged |= out.air_nudged;
+ }
+ (player.horiz_vel, nudged)
+ })
+ .unwrap();
+ assert_eq!(
+ horiz,
+ (Vec3::ZERO, false),
+ "the planted nibble locks the arc: no nudge fires and the body does not drift, however \
+ hard the key is held"
+ );
+ }
+
+ /// **The air-control nudge is `min(MOVE_WALK, MOVE_RUN)` off the unit's live speeds, not a
+ /// constant** (decision 1740). `0x7c5c20(1)` → `0x7c4c90(1)`, whose non-zero argument is a walk
+ /// override returning `min(+0x88, +0x8c)`. `AIR_NUDGE_SPEED`'s 2.5 is merely the *default*
+ /// `MOVE_WALK`, so the constant is right until a walk aura, a Slow or a daze moves either
+ /// speed — and the `min` is why a slowed run still cannot out-steer a walk.
+ #[test]
+ fn the_standstill_nudge_takes_the_speed_it_is_given() {
+ let flat: [(f32, f32); 2] = [(-3.0, 0.0), (3.0, 0.0)];
+ let steer = |nudge: f32| {
+ world_from_profile(&flat)
+ .world_mut()
+ .run_system_once(move |world: benilla_world::collision::WorldCollision| {
+ let capsule = player_capsule();
+ let mut time = Time::default();
+ time.advance_by(std::time::Duration::from_secs_f32(1.0 / 60.0));
+ let mut player = Player::default();
+ // Jump from a dead standstill: the nibble is clear, so one steer is in hand.
+ step(
+ &mut player,
+ &time,
+ &world,
+ &capsule,
+ false,
+ Vec3::ZERO,
+ 0.0,
+ true,
+ false,
+ None,
+ None,
+ nudge,
+ );
+ // Airborne, and now a key goes down.
+ let out = step(
+ &mut player,
+ &time,
+ &world,
+ &capsule,
+ true,
+ Vec3::NEG_X,
+ 7.0,
+ false,
+ false,
+ None,
+ None,
+ nudge,
+ );
+ // …and a second press must change nothing: the nibble is set now.
+ step(
+ &mut player,
+ &time,
+ &world,
+ &capsule,
+ true,
+ Vec3::NEG_Z,
+ 7.0,
+ false,
+ false,
+ None,
+ None,
+ nudge,
+ );
+ (out.air_nudged, player.horiz_vel)
+ })
+ .unwrap()
+ };
+ let (fired, v) = steer(2.5);
+ assert!(fired, "a standstill jump has one steer in hand");
+ assert!(
+ (v.length() - 2.5).abs() < 1.0e-4,
+ "at the speed given: {v:?}"
+ );
+ assert!(v.x < 0.0, "and in the pressed direction: {v:?}");
+ // A walk aura: the same jump steers slower, because the law reads the live speeds.
+ let (fired, v) = steer(1.0);
+ assert!(fired);
+ assert!(
+ (v.length() - 1.0).abs() < 1.0e-4,
+ "a slowed body steers slower — the constant is not the law: {v:?}"
+ );
+ // One-shot in both cases: the second press found the nibble set and did nothing.
+ assert!(
+ v.z.abs() < 1.0e-4,
+ "the second press must not steer again: {v:?}"
);
}
@@ -2491,8 +2810,9 @@ mod tests {
false,
Some(PUSH),
None,
+ AIR_NUDGE_SPEED,
);
- (out.jumped, out.knocked, player.vel_y, player.horiz_vel)
+ (out.jumped, out.knocked, player.launch_vz, player.horiz_vel)
};
(knock(false), knock(true))
})
@@ -2505,7 +2825,8 @@ mod tests {
);
assert_eq!(
vel_y, PUSH.y,
- "the vertical is the server's take-off speed, not JUMP_SPEED ({JUMP_SPEED})"
+ "the vertical is the server's take-off speed, not JUMP_SPEED ({JUMP_SPEED}) — read off \
+ `launch_vz`, because `vel_y` has already taken this frame's gravity step (1740)"
);
assert_eq!(
horiz,
@@ -2602,6 +2923,7 @@ mod tests {
false,
None,
Some(SURFACE),
+ AIR_NUDGE_SPEED,
);
(player.pos.y, player.vel_y)
};
@@ -2679,6 +3001,7 @@ mod tests {
false,
None,
Some(SURFACE),
+ AIR_NUDGE_SPEED,
);
low = low.min(player.pos.y);
}
diff --git a/crates/benilla-app/src/player/state.rs b/crates/benilla-app/src/player/state.rs
index 7b04f9d6..722bf942 100644
--- a/crates/benilla-app/src/player/state.rs
+++ b/crates/benilla-app/src/player/state.rs
@@ -722,6 +722,38 @@ pub(crate) struct Player {
/// `StartFalling(0)`). Observers replay the parabola from it, and the FALLINGFAR latch splits
/// its distance/timer legs on it (decision 0179); held constant while `fall_time` advances.
pub(super) jump_zspeed: f32,
+ /// **The launch vertical speed, recorded the instant a take-off is decided** — before this
+ /// frame's gravity touches it. [`super::arc`] snapshots [`Self::jump_zspeed`] from *this*, not
+ /// from `vel_y`: the mover now integrates gravity on the take-off frame too (decision 1740), so
+ /// by the time the arc bookkeeping runs `vel_y` is already one step down the parabola and would
+ /// seat a launch speed `g·dt` short. The reference has no such ambiguity — `+0xa0` is written
+ /// once by `StartFalling` and never touched again for the arc.
+ pub(super) launch_vz: f32,
+ /// **The arc's direction nibble — the reference's `[CMovement+0x40] & 0xf`** (decision 1740).
+ /// Air control opens exactly while this is CLEAR: `0x7c5a20`/`0x7c5c20` bail on
+ /// `FALLING && arg == 0`, and the only two openers that pass `arg = 1` sit behind
+ /// `0x7c6afc test al,0xf`. So a jump from a standstill can be steered once, a jump taken with a
+ /// direction already held cannot, and a **knockback never can** — its launch plants FORWARD
+ /// itself (`0x6179c0`'s `0x617a18 or edx,0x8001`), which is the real freeze mechanism and has
+ /// nothing to do with the arc's speed. Seeded at take-off, set by the one nudge that fires,
+ /// cleared when the arc ends.
+ ///
+ /// This replaced a `horiz_vel.length_squared() < 0.01` proxy that agreed with the reference for
+ /// every ordinary jump and disagreed for one input: a knockback with `xy_speed ≈ 0`, which the
+ /// proxy read as "standing still, may steer".
+ pub(super) arc_dirs_set: bool,
+ /// This airborne arc was launched by a **knockback**, so its planted FORWARD bit rides the wire
+ /// for the arc's whole length (decision 1740) — the reference's `0x617a18` sets FORWARD and
+ /// clears BACKWARD, and the send mask `0x618909 and edx,0x75a07dff` keeps bit 0. Distinct from
+ /// [`super::mover::Outcome::knocked`], which is true on the launch frame only.
+ pub(super) knock_arc: bool,
+ /// **Was the body airborne at the end of the previous mover step** — the mover's own record,
+ /// so the arc-start seeding does not have to read [`Self::airborne_since`] (decision 1740).
+ /// That field belongs to the *wire* lifecycle in [`super::arc`] and is written by
+ /// [`super::flags`], which runs AFTER the mover: reading it here made a physics decision
+ /// depend on a system further down the frame, so a step-off's launch state was seeded a frame
+ /// late whenever the mover ran on its own. Separate owners, separate fields.
+ pub(super) airborne_prev: bool,
/// The translation-direction move-flag bits ([`crate::creature_anim::move_flags::ANY_MOVE`]) the
/// current airborne arc launched with. Mid-air these are the *actual* motion — momentum is frozen at
/// takeoff, so held keys move nothing — and the live flags (animation, pose, wire) read them instead
diff --git a/crates/benilla-app/src/player/swim.rs b/crates/benilla-app/src/player/swim.rs
index 841fb5b9..aa2c9fc0 100644
--- a/crates/benilla-app/src/player/swim.rs
+++ b/crates/benilla-app/src/player/swim.rs
@@ -270,6 +270,14 @@ pub(super) fn breach_step(
capsule: &Collider,
) -> Outcome {
player.vel_y = SWIM_JUMP_SPEED;
+ // The breach is a take-off like any other, so it records its launch speed for the arc
+ // bookkeeping to snapshot (decision 1740). Without this the jump tail would send `zspeed = 0`
+ // for every water exit and observers would replay the breach as a step-off rather than a hop.
+ player.launch_vz = SWIM_JUMP_SPEED;
+ // Leaving the water starts a fresh arc, and it starts it with the swim keys still down — the
+ // nibble seeds from them exactly as a ledge step-off does.
+ player.arc_dirs_set = player.horiz_vel.length_squared() > 0.0;
+ player.knock_arc = false;
let half_h = Vec3::Y * (CAPSULE_HEIGHT * 0.5);
let out = world.slide_body(
capsule,
diff --git a/crates/benilla-app/src/portrait/glue_booth.rs b/crates/benilla-app/src/portrait/glue_booth.rs
index 39a2709c..6e9a4e25 100644
--- a/crates/benilla-app/src/portrait/glue_booth.rs
+++ b/crates/benilla-app/src/portrait/glue_booth.rs
@@ -235,6 +235,11 @@ pub(crate) struct CreateScene {
/// Whether the current spawn wrote the fog rows enabled (the create law) — a screen hop with
/// the same scene race still rebuilds when this flips.
fog: bool,
+ /// Whether the light buffer currently holds the **ghost** rig ([`ghost_rig`]) rather than the
+ /// scene's authored one. Unlike [`Self::fog`] a change here does NOT rebuild the scene: the
+ /// reference's fork is a per-frame fill callback on models that are already standing, so the
+ /// buffer is rewritten in place and the stage's braziers keep burning across the click.
+ ghost: bool,
/// The scene's own light buffer — the scene's **authored M2 light rig** folded per the world
/// law (see [`SceneRig`]: directionals → ambient + SH probe slot 0, points → the per-vertex
/// point table) plus the ref's per-race fog (`CharModelFogInfo`) — **create only**: at select
@@ -323,6 +328,88 @@ fn scene_rig(lights: &[benilla_assets::ModelLight]) -> SceneRig {
}
}
+/// **The GHOST select screen's hardcoded sun** — the one directional the reference injects when the
+/// selected roster record carries `CHARSELECT+0xfc & 0x2000`, replacing the authored rig entirely
+/// (wow-re `glue-select-model.md` §C1–C4 + `glue-select-ghost-treatment.md`, VERIFIED).
+///
+/// `0x472150` is a per-model per-frame **fill callback** (`[model+0x3bc]`, registered by `0x7134b0`)
+/// whose *entire body* is gated on that bit (`47218e mov ecx,[record+0xfc]; 472194 test ch,0x20;
+/// 472197 je 0x4722b2`). It is registered on **three** models — the background scene M2
+/// (`0x472144`), the character (`0x472bac`) and the **pet** (`0x472ebd`) — so all three fork
+/// together off one `[0x83856c]`-keyed read. For a living selection the callback is a complete
+/// no-op and the authored rig stands, exactly as at create (decision 0435's law, select-confirmed).
+///
+/// For a ghost it does two things, and this function is both:
+///
+/// - **the collector is WIPED** — `0x71bc30` opens `mov ecx,0x6d; rep stosd`, 436 bytes zeroed, so
+/// the scene-DB gather is discarded: no authored ambient, and **no point lights** (which is why
+/// the ghost stage loses the NightElf/Scourge stage-local lamps that make those two scenes);
+/// - **one hardcoded directional is injected** (built by `0x71b4a0`, forced directional by
+/// `0x71b620(0)`, colours by two `0x6d6ad0()→0x6d62e0()` DN reads, specular left 0).
+///
+/// **The direction, and the trap in it.** `0x472280 mov [ebp-4],0xbf800000` writes the raw literal
+/// `(0,0,-1)` into `CGLight+0x24`, which is a **from-light** vector: `0x71b6a0` stores it verbatim
+/// (normalize only, no `fchs`) and `0x71bce0` negates all three components (`0x71be7c`/`0x71be81`/
+/// `0x71be86`) before accumulating the SH lobe, so the lobe is centred on `-u` = **`(0,0,+1)`, i.e.
+/// lit from directly overhead** (WoW Z up). The fixed-function lane agrees — `0x59c820` negates
+/// too, and GL's directional `GL_POSITION` *is* the to-light vector — so the sign does not depend
+/// on which lane you emulate. The trap: an **authored** M2 light has TWO negations (`0x718960`
+/// already stores `-Bz`, and [`scene_rig`] feeds `+bone_z` as its to-light to match), while this one
+/// has exactly ONE. Carrying the authored convention across double-negates and lights the ghost
+/// from below.
+///
+/// **The colours are DBC data, not a live capture.** `0x4721e2 mov esi,[edx+0xc]` hard-codes
+/// **`LightParams` row 3** — `Light.dbc`'s **death** preset column (id 3 in 59 of 374 rows and in no
+/// other column; the world's ghost arm selects the same slot, `0x5de9df mov ecx,4; call 0x6d4620`)
+/// — and `0x6d6ad0` maps it to `LightIntBand` ids `18·3−17+sub`: **37 diffuse** (colour sub 0),
+/// **38 ambient** (sub 1), sampled at a hardcoded time key of `0` (`xor edx,edx`; half-minutes,
+/// `[0, 2880)`). Both rows are **single-key in the shipped 1.12.1 chain**, hence time-invariant,
+/// hence these literals — 0469 parked this as "pending a live capture" on the earlier reading that
+/// the tables were runtime-only, and that park is withdrawn.
+///
+/// The values are stated rather than read back out of our own `Light.dbc` reader for one reason:
+/// the glue screens run **pre-world**, where no map, no zone and no `LightParams` state exist at
+/// all — the reference reaches them through the DN singleton it keeps alive across the glue, and we
+/// have no such thing to ask. A chain whose bands are keyed (a patched install) would diverge here;
+/// that is the stated bound.
+fn ghost_rig() -> SceneRig {
+ SceneRig {
+ // `LightIntBand` 38 = 0x001A3855 → (26, 56, 85).
+ ambient: [26.0 / 255.0, 56.0 / 255.0, 85.0 / 255.0],
+ // `LightIntBand` 37 = 0x005E99C6 → (94, 153, 198), on the to-light `(0,0,+1)`.
+ lobes: vec![(
+ benilla_assets::coords::wow_to_bevy([0.0, 0.0, 1.0]),
+ [94.0 / 255.0, 153.0 / 255.0, 198.0 / 255.0],
+ )],
+ // Wiped: the gather is discarded whole, so the stage's own lamps do not survive.
+ points: Vec::new(),
+ }
+}
+
+/// Pack a [`SceneRig`] into the glue scene's light buffer contents.
+///
+/// Rig materials read the probe + point table; the core rows still carry the plain ambient (with a
+/// zero sun) so any non-rig lane that ever lands in the booth degrades sanely. The directional fold
+/// lands in probe slot 0 — booth instances carry `MeshTag` 0, the rig lane's read side. The dial is
+/// 1.0: the rig lane ignores it, so byte levels stand.
+///
+/// One function for both rigs, so the authored and the ghost light cannot drift into two packings.
+fn scene_light_blob(
+ rig: &SceneRig,
+ fog_rgb: [f32; 3],
+ fog_far: f32,
+ fog: bool,
+) -> benilla_world::lighting::LightBlob {
+ let mut blob = benilla_world::lighting::LightBlob::model(rig.ambient, [0.0; 3], Vec3::NEG_Y)
+ .probe(rig.ambient, &rig.lobes)
+ .fog(fog_rgb, fog_far, fog)
+ .dial(1.0);
+ for (pos, color) in &rig.points {
+ blob = blob.point(*pos, SCENE_POINT_RANGE, *color);
+ }
+ blob
+}
+
/// The ref's per-scene fog: the race rows are `CharModelFogInfo` (GlueParent.lua) — color + far,
/// near always 0; the main menu's is `AccountLogin.xml`'s own `` + `fogFar` attribute.
fn scene_fog(token: &str) -> ([f32; 3], f32) {
@@ -561,7 +648,12 @@ pub(super) fn spawn_glue_booth(
..default()
},
bevy::camera::RenderTarget::Image(image.clone().into()),
- benilla_world::ffx_glow::FfxGlow::BOOTH,
+ // **The glue screens run the reference's own glue pass pair**, not a bake's nothing
+ // (decision 1731): this camera draws the fullscreen `UI_` diorama the reference
+ // brackets its glue FFX around (`0x46fad3`/`0x46fae0`), and the GlueXML frames composite
+ // over the result afterwards — which is why a ghost selection washes the scene steel-blue
+ // while the character list, the buttons and the logo stay clean.
+ benilla_world::ffx_glow::FfxGlow::GLUE_SCENE,
// Placeholder — `sync_glue_booth` overwrites transform + projection from the model's bounds
// on the first bake (the same `body_frame` law as the paper doll).
Projection::from(bevy::camera::PerspectiveProjection {
@@ -628,12 +720,52 @@ pub(super) fn spawn_glue_booth(
pet_stage_scale: 1.0,
pet_baked: None,
fog: true,
+ ghost: false,
light: None,
variants: default(),
rev: 0,
});
}
+/// Drive the glue screens' **FFX state** ([`benilla_world::ffx_glow::GlueFfx`]) off the live look —
+/// which pass the glue pair has installed, and what `LightParams.glow` is pinned to.
+///
+/// One writer for all three glue screens, because the reference's three writers are all reached
+/// through the same widget and the same build (wow-re `glue-select-ghost-treatment.md`):
+///
+/// | look | reference | pass | glow |
+/// |---|---|---|---|
+/// | none (login, empty account) | `CSimpleModelFFX::OnShow 0x46fa60` | glow | `*0x8380b4` 0.30 |
+/// | a create look | same — no select build runs | glow | 0.30 |
+/// | a living select record | `0x472ff7`/`0x473002` | glow | `*0x838574` 0.40 |
+/// | a ghost select record | `0x472fde`/`0x472fe9` | **death** | `*0x838570` 0.15 |
+///
+/// An empty account really is the OnShow state and not the living arm: `0x472950` is gated
+/// `-1 ≤ [0x83856c] < [0xb42140]`, so with no roster nothing re-pins anything.
+///
+/// It reads [`GluePreview`] rather than the roster because that is what every glue screen already
+/// writes — the login screen's `None`, the create screen's `Create` look, the select screen's
+/// `Select` record — so no screen can forget to reset it on the way out.
+pub(super) fn sync_glue_ffx(
+ preview: Res,
+ mut ffx: ResMut,
+) {
+ use benilla_world::ffx_glow::GlueFfx;
+ let want = match preview.look {
+ Some(GlueLook::Select(l)) => {
+ if l.flags & benilla_protocol::CHARACTER_FLAG_GHOST != 0 {
+ GlueFfx::SelectGhost
+ } else {
+ GlueFfx::SelectLiving
+ }
+ }
+ Some(GlueLook::Create(_)) | None => GlueFfx::Shown,
+ };
+ if *ffx != want {
+ *ffx = want;
+ }
+}
+
/// Drive the background scene (see [`CreateScene`]): load the selected race's `UI_*` model, spawn
/// it looping sequence 0, resize the booth target to the window (the scene is a fullscreen render),
/// seat the character on the stage spot, and hold the booth camera on the scene's authored camera 0
@@ -714,6 +846,14 @@ pub(super) fn sync_glue_scene(
GlueScene::MainMenu => true,
GlueScene::Race(_) => matches!(preview.look, Some(GlueLook::Create(_))),
};
+ // The GHOST light fork (wow-re `glue-select-model.md` §C1, and [`ghost_rig`]): the selected
+ // roster record's `CHARSELECT+0xfc & 0x2000`, which reaches the background scene, the character
+ // and the pet through one `[0x83856c]`-keyed read in the reference and through one light buffer
+ // here — all three of ours bind it.
+ let ghost = matches!(
+ preview.look,
+ Some(GlueLook::Select(l)) if l.flags & benilla_protocol::CHARACTER_FLAG_GHOST != 0
+ );
if scene.token != Some(token) {
scene.token = Some(token);
scene.handle = Some(asset_server.load(m2_url(&format!(
@@ -733,6 +873,33 @@ pub(super) fn sync_glue_scene(
// Same scene, other screen: rebuild in place (the handle is cached — same-frame respawn).
scene.spawned = false;
commands.entity(scene.root).despawn_related::();
+ } else if scene.spawned && scene.ghost != ghost {
+ // Same scene, same screen, the selection crossed the ghost bit: **re-light, don't rebuild**.
+ // The reference's fork is a per-frame fill callback on models that are already standing
+ // (`0x472150` on `[model+0x3bc]`), so nothing is respawned there either — and respawning
+ // here would restart the stage's own emitters, which the click has no business touching.
+ if let (Some(model), Some(light)) = (
+ scene.handle.as_ref().and_then(|h| m2s.get(h)),
+ scene.light.clone(),
+ ) {
+ let rig = if ghost {
+ ghost_rig()
+ } else {
+ scene_rig(&model.lights)
+ };
+ let (fog_rgb, fog_far) = scene_fog(token);
+ scene_light_blob(&rig, fog_rgb, fog_far, fog).write(&queue, &light);
+ scene.ghost = ghost;
+ // The character and the pet re-bake onto the rewritten buffer (`sync_glue_booth` and
+ // `sync_glue_pet` both key on this).
+ scene.rev += 1;
+ info!(
+ "glue scene: {} light — {} directional, {} point light(s)",
+ if ghost { "GHOST" } else { "authored" },
+ rig.lobes.len(),
+ rig.points.len(),
+ );
+ }
}
if !scene.spawned {
let Some(model) = scene.handle.as_ref().and_then(|h| m2s.get(h)) else {
@@ -752,20 +919,15 @@ pub(super) fn sync_glue_scene(
.iter()
.find(|a| a.id == 0)
.map_or((Quat::IDENTITY, 1.0), |a| stage_frame(model, a.bone));
- let rig = scene_rig(&model.lights);
+ // A GHOST selection replaces the gather outright (see [`ghost_rig`]); a living one keeps
+ // the scene's authored rig, which is the create law select-confirmed.
+ let rig = if ghost {
+ ghost_rig()
+ } else {
+ scene_rig(&model.lights)
+ };
let (fog_rgb, fog_far) = scene_fog(token);
- // Rig materials read the probe + point table; the core rows still carry the plain ambient
- // (with a zero sun) so any non-rig lane that ever lands in the booth degrades sanely. The
- // directional fold lands in probe slot 0 — booth instances carry MeshTag 0, the rig lane's
- // read side. The dial is 1.0: the rig lane ignores it, so byte levels stand.
- let mut blob =
- benilla_world::lighting::LightBlob::model(rig.ambient, [0.0; 3], Vec3::NEG_Y)
- .probe(rig.ambient, &rig.lobes)
- .fog(fog_rgb, fog_far, fog)
- .dial(1.0);
- for (pos, color) in &rig.points {
- blob = blob.point(*pos, SCENE_POINT_RANGE, *color);
- }
+ let blob = scene_light_blob(&rig, fog_rgb, fog_far, fog);
let light = scene
.light
.get_or_insert_with(|| blob.create(&device, "wow_create_scene_light"))
@@ -780,6 +942,7 @@ pub(super) fn sync_glue_scene(
tint_mirrors.0.insert("glue_scene", light.clone());
blob.write(&queue, &light);
scene.fog = fog;
+ scene.ghost = ghost;
let dc = blob.probe_dc();
info!(
"create scene: UI_{token} rig — ambient {:?}, {} point light(s), probe dc ({:.2},{:.2},{:.2})",
@@ -1739,6 +1902,41 @@ mod tests {
}
}
+ /// GOLDEN — the ghost select screen's hardcoded sun, every number byte-VERIFIED (wow-re
+ /// `glue-select-ghost-treatment.md`; see [`ghost_rig`] for the citations).
+ ///
+ /// The **sign** is the assertion that matters. wow-re's earlier §C4 was internally
+ /// contradictory about it — it called `CGLight+0x24` a to-light vector with no sign flip in the
+ /// SH chain, then concluded "lit from directly overhead" via the fixed-function commit, which
+ /// negates. Those differ by 180°: a character lit from above versus from below. The round that
+ /// settled it found `0x71bce0` negating all three components (`0x71be7c`/`0x71be81`/`0x71be86`)
+ /// before it accumulates, so both lanes agree on `(0,0,+1)`. The trap it also found is why this
+ /// is pinned rather than trusted to a comment: an AUTHORED M2 light already carries one
+ /// negation (`0x718960` stores `-Bz`, which is why [`scene_rig`] feeds `+bone_z`), so reusing
+ /// the authored convention here double-negates and lights the ghost from underneath.
+ #[test]
+ fn the_ghost_sun_is_one_overhead_directional_over_a_wiped_gather() {
+ let rig = ghost_rig();
+ // The gather is WIPED — `0x71bc30`'s `mov ecx,0x6d; rep stosd`. No stage lamps survive.
+ assert!(
+ rig.points.is_empty(),
+ "a ghost selection discards the scene-DB gather whole"
+ );
+ // Exactly one directional.
+ assert_eq!(rig.lobes.len(), 1);
+ let (to_light, diffuse) = rig.lobes[0];
+ // Bevy +Y is up: `wow_to_bevy([0,0,1])`. Overhead, not underfoot.
+ assert_eq!(to_light, Vec3::Y, "lit from directly overhead (WoW Z up)");
+ // `LightIntBand` 37 = 0x005E99C6 → (94,153,198); 38 = 0x001A3855 → (26,56,85).
+ let byte = |c: f32| (c * 255.0).round() as u32;
+ assert_eq!(
+ diffuse.map(byte),
+ [94, 153, 198],
+ "LightParams row 3, sub 0"
+ );
+ assert_eq!(rig.ambient.map(byte), [26, 56, 85], "…and sub 1");
+ }
+
/// GOLDEN — the rig fold's classification law (wow-re `m2-dynamic-lights.md` +
/// `glue-model-lighting.md`): directionals accumulate (ambient tracks summed; diffuse×intensity
/// as per-light SH lobes whose to-light is the **bone's +Z axis**, never the def position),
diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs
index 01af65d7..4a212a53 100644
--- a/crates/benilla-app/src/portrait/mod.rs
+++ b/crates/benilla-app/src/portrait/mod.rs
@@ -1165,6 +1165,8 @@ impl Plugin for PortraitPlugin {
sync_stable_booth,
glue_booth::sync_glue_booth,
glue_booth::sync_glue_scene,
+ // The glue screens' FFX state, off the same look every screen already writes.
+ glue_booth::sync_glue_ffx,
// After the scene's framing: the viewport/clear its law decided (1619).
glue_booth::pillarbox_glue_scene,
// After the scene: the pet's seat and its light are the scene's to publish.
diff --git a/crates/benilla-app/src/ui_quest.rs b/crates/benilla-app/src/ui_quest.rs
index bea4a97a..02a77d3a 100644
--- a/crates/benilla-app/src/ui_quest.rs
+++ b/crates/benilla-app/src/ui_quest.rs
@@ -251,16 +251,21 @@ impl NpcSession for QuestGiver {
self.npc.filter(|g| !benilla_protocol::guid::is_item(*g))
}
- /// **A party share is not range-guarded** (decision 1733). Its giver is a live player with a
- /// position, a name and a portrait, so it stays a full session for [`crate::ui_session`]'s
- /// *other* consumer — but the guard's threshold is the NPC **service** range
- /// ([`crate::target::SERVICE_RANGE_SQ`], 5.56 yd), a rule about reaching an NPC's counter, and
- /// a share has no counter to reach. The server's own bound is `QUEST_SHARE_DISTANCE`, 14.0 yd
- /// (vmangos `Object.h:72`), tested at ACCEPT and answered with `QUEST_PARTY_MSG_TOO_FAR` —
- /// so a sharer standing six yards off would have had the panel yanked out from under a quest
- /// the server was perfectly willing to grant.
- fn range_guarded(&self) -> bool {
- !self.npc.is_some_and(benilla_protocol::guid::is_player)
+ /// Walking away from a party member's SHARED quest still owes them the decline
+ /// (decision 1741). The distance that triggers it is not this method's business — the guard
+ /// takes the leash from the giver's own type (`crate::ui_session::leash_sq`: 14.0 yd for a
+ /// player, the 5.56 yd service range for an NPC), which is what 1733 got wrong by exempting
+ /// the share panel outright.
+ ///
+ /// **The walk-away sends strictly LESS than the button does**, and that is the reference's,
+ /// not a simplification: its watchdog calls the session end directly (`0x4933da` → `0x501130`)
+ /// rather than going through `DeclineQuest`, so an NPC's panel closes silently here — no
+ /// giver re-open — while the button's path still re-opens it.
+ fn walk_away_send(&self, npc: u64) -> Option {
+ benilla_protocol::guid::is_player(npc).then_some(ClientCommand::QuestPushResult {
+ sharer: npc,
+ msg: QuestShareMsg::DECLINE_QUEST,
+ })
}
fn close(&mut self) {
diff --git a/crates/benilla-app/src/ui_script/frame_flag_gate.rs b/crates/benilla-app/src/ui_script/frame_flag_gate.rs
new file mode 100644
index 00000000..3c26d034
--- /dev/null
+++ b/crates/benilla-app/src/ui_script/frame_flag_gate.rs
@@ -0,0 +1,632 @@
+//! **The reference's frame FLAGS are the test oracle** — the flag half of decision 0675's "the
+//! reference file is the test oracle", and the gate for the class decision 1739 closed.
+//!
+//! `framexml_diff` guards the *numbers* a transcribed window carries. Nothing guarded its *flags*,
+//! and they went quietly wrong at scale: on 2026-08-30 a sweep found **47 frames** the reference
+//! marks `toplevel` and ours did not (so no window ever came to the front), **23** the reference
+//! makes mouse-interactive and ours did not (so a click on a window's own background fell through
+//! to the 3D world), and **81** carrying a reference `id=` ours dropped (so `GetID()` — the
+//! contract every 1.12 addon reads a slot index out of — answered 0). Every gate was green
+//! throughout, because a flag is invisible to a behavioural test that never presses on the frame.
+//!
+//! ## Two properties make this a guard rather than a comfort
+//!
+//! **Our side is read from the ENGINE, never from our XML.** An attribute-vs-attribute diff is
+//! precisely what let the class hide, twice over: benilla renames templates (`ChatFrameTemplate` →
+//! `BenillaChatFrameTemplate`), so a name-keyed text diff reports a gap of zero on a window that is
+//! entirely missing the flag; and the `` **auto-enable** law (wow-re
+//! `ui/scratch/scripts-auto-enable.md` §1, VERIFIED — an ``/``/``/
+//! ``/`` reaches the same enable primitive `0x76af00(2,-1)` the attribute
+//! does) makes `enableMouse=` a poor proxy for whether the frame actually takes the mouse. Asking
+//! the loaded engine — `IsToplevel()`, `IsMouseEnabled()`, `GetID()` — is immune to both.
+//!
+//! **Divergences are an explicit list with a reason each, never a pattern.** [`KNOWN`] carries
+//! them, in both directions; a new one cannot hide inside a tolerance. The list is where the
+//! seven frames the reference makes interactive through *handlers we do not carry* are recorded —
+//! those want the handler (and its tooltip), never a bare `enableMouse="true"` that would swallow
+//! the click and give nothing back.
+//!
+//! ## Three flags, and only three
+//!
+//! `toplevel`, the effective mouse enable, and `id` are mechanical: the reference's value is right
+//! for any frame we transcribe, and a difference is a defect. The neighbours are **not**, and are
+//! deliberately out of scope rather than silently tolerated — `movable` is an inert flag for all
+//! but two reference frames (nothing calls `StartMoving` on the rest, so copying it would be
+//! cargo-cult), `frameStrata` decides what draws over what and is the director's call,
+//! `setAllPoints` has an exact ``+`` equivalent that five of our pages deliberately
+//! use, and `hidden` is equivalent whenever the reference hides in `OnLoad` instead. Decision 1739
+//! carries the reasoning per attribute.
+//!
+//! ## What it can and cannot see
+//!
+//! The population is the shipped tree's **named, published frames**, so a virtual template is not
+//! compared directly — but every instance of one is, carrying the template's resolved flags, which
+//! is the same coverage by a different route (a `KNOWN` entry naming a template therefore reads as
+//! stale and is refused).
+//!
+//! The reference side is **XML only**: a `SetID` or `EnableMouse` the reference makes from Lua at
+//! `OnLoad` is invisible here and reads as absent. That blind spot only ever under-reports — it
+//! can hide a divergence, never invent one — so nothing it misses turns into a false failure; the
+//! handful of frames it does hide are in `KNOWN` with that reason.
+//!
+//! The whole module skips cleanly with no install — `_extracted_framexml/` is a gitignored
+//! Blizzard asset, like every other client-data test here.
+
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+
+use benilla_ui::framexml::{self, Element, TopLevel};
+
+/// Element tags that are *frames* — the ones with flags to compare. Regions (``,
+/// ``) carry names too and must not shadow a frame of the same name.
+const FRAME_TAGS: &[&str] = &[
+ "Frame",
+ "Button",
+ "CheckButton",
+ "EditBox",
+ "ScrollFrame",
+ "Slider",
+ "StatusBar",
+ "MessageFrame",
+ "ScrollingMessageFrame",
+ "Model",
+ "PlayerModel",
+ "DressUpModel",
+ "TabardModel",
+ "ColorSelect",
+ "SimpleHTML",
+ "GameTooltip",
+ "Minimap",
+ "MovieFrame",
+ "WorldFrame",
+ "Cooldown",
+];
+
+/// The five `` handler names that auto-enable the MOUSE kind, and only those five
+/// (wow-re `ui/scratch/scripts-auto-enable.md` §1's kind-2 OR-chain, `0x769fb7`..`0x76a022`).
+/// `OnDragStop`/`OnReceiveDrag` bind a slot and trip no enable — they are deliberately absent.
+const MOUSE_HANDLERS: &[&str] = &[
+ "OnEnter",
+ "OnLeave",
+ "OnMouseDown",
+ "OnMouseUp",
+ "OnDragStart",
+];
+
+/// Widget kinds whose **constructor** enables the mouse, so an element of that tag needs no
+/// `enableMouse` and no handler to be clickable.
+///
+/// This is deliberately the SAME list [`benilla_ui::widget::WidgetArena::create`] uses, and it has
+/// to be: both are one claim about the client's ctors (`CSimpleButton`/`CSimpleEditBox` enable
+/// input in theirs; a `Slider` thumb must be draggable; a scroll frame takes the wheel — each
+/// carries its own citation there). Reading the engine's list here means a wrong entry is wrong in
+/// one place rather than two, and the sweep never reports a divergence that is only the two models
+/// disagreeing with each other.
+///
+/// It is also what answers the chat window: `ChatFrame1` is a `ScrollingMessageFrame`, so the
+/// reference's takes the mouse from its ctor even though `FloatingChatFrameTemplate` writes
+/// `enableMouse="false"` — which is a no-op, the XML path only ever ENABLES
+/// (`scripts-auto-enable.md` §1.3). That is how a 1.12 chat link is clickable at all.
+const MOUSE_BY_CTOR: &[&str] = &[
+ "Button",
+ "CheckButton",
+ "EditBox",
+ "ScrollingMessageFrame",
+ "ScrollFrame",
+ "Slider",
+];
+
+/// One accepted difference between benilla and the reference, with the reason it is accepted.
+///
+/// `frame` is benilla's name for it; `flag` is which of the three; `why` is why the difference is
+/// right (or, for the handler gaps, why the honest fix is not this flag).
+struct Known {
+ frame: &'static str,
+ flag: Flag,
+ why: &'static str,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+enum Flag {
+ Toplevel,
+ Mouse,
+ Id,
+}
+
+impl std::fmt::Display for Flag {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(match self {
+ Flag::Toplevel => "toplevel",
+ Flag::Mouse => "mouse",
+ Flag::Id => "id",
+ })
+ }
+}
+
+/// The accepted differences, in four groups: **seven frames the reference makes mouse-interactive
+/// through HANDLERS we do not carry**, the merchant rows whose mouse we take where it does not,
+/// the faux scroll panes we build from a different widget kind, and the ids the reference sets
+/// from Lua. Every entry is a judgement someone made; none of them is a tolerance.
+const KNOWN: &[Known] = &[
+ // ── The reference has it, we do not: all seven are handler gaps, not flag gaps ─────────────
+ //
+ // In each of these the reference's mouse comes from an ``/`` pair whose body
+ // we have not built, and the interaction the player would notice is the TOOLTIP those handlers
+ // show — not the click-blocking the flag gives. Declaring `enableMouse="true"` here would make
+ // the frame swallow the click and hand back nothing, which is worse than the gap.
+ Known {
+ frame: "PetPaperDollFrameExpBar",
+ flag: Flag::Mouse,
+ why:
+ "the reference bar inherits TextStatusBar, whose OnEnter/OnLeave show the value text; \
+ ours is a plain StatusBar. Wants the status-text arc, not the flag.",
+ },
+ Known {
+ frame: "TargetofTargetHealthBar",
+ flag: Flag::Mouse,
+ why: "same as PetPaperDollFrameExpBar — a plain StatusBar where the reference inherits \
+ TextStatusBar.",
+ },
+ Known {
+ frame: "TargetofTargetManaBar",
+ flag: Flag::Mouse,
+ why: "same as PetPaperDollFrameExpBar — a plain StatusBar where the reference inherits \
+ TextStatusBar.",
+ },
+ Known {
+ frame: "TradePlayerItem7",
+ flag: Flag::Mouse,
+ why:
+ "the enchant slot: ours inherits BenillaTradeEnchantItemTemplate, the reference's the \
+ ordinary PlayerTradeItemTemplate. A structural difference in our own trade window.",
+ },
+ Known {
+ frame: "WhoFrameDropDown",
+ flag: Flag::Mouse,
+ why: "the reference's /who sort dropdown carries its own handlers over the shared \
+ UIDropDownMenuTemplate; ours takes the template alone.",
+ },
+ Known {
+ frame: "WorldMapPlayer",
+ flag: Flag::Mouse,
+ why: "the reference's map dot has OnEnter -> WorldMapUnit_OnEnter, the name-on-hover \
+ tooltip. Wants that handler, not the flag.",
+ },
+ Known {
+ frame: "WorldStateAlwaysUpFrame",
+ flag: Flag::Mouse,
+ why:
+ "the reference's PvP objective banner has OnEnter/OnLeave for its tooltip. Wants that \
+ handler, not the flag.",
+ },
+ // ── We take the mouse where the reference does not ─────────────────────────────────────────
+ //
+ // The merchant rows: ours are one mouse-taking row each; the reference splits every row into
+ // an inert container plus a `$parentItemButton` that takes the mouse on its behalf. Same
+ // clicks, a different frame under them.
+ Known {
+ frame: "MerchantItem1",
+ flag: Flag::Mouse,
+ why: "inherits BenillaMerchantItemTemplate",
+ },
+ Known {
+ frame: "MerchantItem2",
+ flag: Flag::Mouse,
+ why: "inherits BenillaMerchantItemTemplate",
+ },
+ Known {
+ frame: "MerchantItem3",
+ flag: Flag::Mouse,
+ why: "inherits BenillaMerchantItemTemplate",
+ },
+ Known {
+ frame: "MerchantItem4",
+ flag: Flag::Mouse,
+ why: "inherits BenillaMerchantItemTemplate",
+ },
+ Known {
+ frame: "MerchantItem5",
+ flag: Flag::Mouse,
+ why: "inherits BenillaMerchantItemTemplate",
+ },
+ Known {
+ frame: "MerchantItem6",
+ flag: Flag::Mouse,
+ why: "inherits BenillaMerchantItemTemplate",
+ },
+ Known {
+ frame: "MerchantItem7",
+ flag: Flag::Mouse,
+ why: "inherits BenillaMerchantItemTemplate",
+ },
+ Known {
+ frame: "MerchantItem8",
+ flag: Flag::Mouse,
+ why: "inherits BenillaMerchantItemTemplate",
+ },
+ Known {
+ frame: "MerchantItem9",
+ flag: Flag::Mouse,
+ why: "inherits BenillaMerchantItemTemplate",
+ },
+ Known {
+ frame: "MerchantItem10",
+ flag: Flag::Mouse,
+ why: "inherits BenillaMerchantItemTemplate",
+ },
+ Known {
+ frame: "MerchantItem11",
+ flag: Flag::Mouse,
+ why: "inherits BenillaMerchantItemTemplate",
+ },
+ Known {
+ frame: "MerchantItem12",
+ flag: Flag::Mouse,
+ why: "inherits BenillaMerchantItemTemplate",
+ },
+ Known {
+ frame: "MerchantBuyBackItem",
+ flag: Flag::Mouse,
+ why: "inherits BenillaMerchantItemTemplate",
+ },
+ Known {
+ frame: "HonorFrameProgressBar",
+ flag: Flag::Mouse,
+ why: "our honour bar carries its own hover tooltip; the reference's page has none",
+ },
+ Known {
+ frame: "WorldMapFrame",
+ flag: Flag::Mouse,
+ why: "our map body takes the mouse so a click on it cannot reach the world behind a \
+ FULLSCREEN_DIALOG window; the reference relies on WorldMapButton alone",
+ },
+ // ── The faux scroll panes: a different WIDGET KIND, not a missing flag ─────────────────────
+ //
+ // The reference declares each of these `` and
+ // takes the mouse from that kind's constructor, even though a faux pane never really scrolls —
+ // the kind is chosen for the wheel and the scrollbar plumbing. benilla's `FauxScrollFrameTemplate`
+ // is a plain `` and wires the wheel explicitly, with an `` on the pane
+ // routed through `BenillaFauxScrollFrame_OnMouseWheel` (FriendsFrame.xml's own note at the
+ // friends pane). Same behaviour — the list scrolls under the wheel — reached a different way,
+ // and the reference's own faux panes use nothing else the mouse flag gives.
+ //
+ // The two mail panes are a further step out: ours are flat art with a FontString body, a
+ // render approximation MailFrame.xml names at the site, so neither side scrolls them.
+ Known {
+ frame: "FriendsFrameFriendsScrollFrame",
+ flag: Flag::Mouse,
+ why: "our faux scroll pane is a Frame with an explicit , not a ScrollFrame",
+ },
+ Known {
+ frame: "FriendsFrameIgnoreScrollFrame",
+ flag: Flag::Mouse,
+ why: "as FriendsFrameFriendsScrollFrame",
+ },
+ Known {
+ frame: "GuildListScrollFrame",
+ flag: Flag::Mouse,
+ why: "as FriendsFrameFriendsScrollFrame",
+ },
+ Known {
+ frame: "WhoListScrollFrame",
+ flag: Flag::Mouse,
+ why: "as FriendsFrameFriendsScrollFrame",
+ },
+ Known {
+ frame: "SkillListScrollFrame",
+ flag: Flag::Mouse,
+ why: "as FriendsFrameFriendsScrollFrame",
+ },
+ Known {
+ frame: "ReputationListScrollFrame",
+ flag: Flag::Mouse,
+ why: "as FriendsFrameFriendsScrollFrame",
+ },
+ Known {
+ frame: "OpenMailScrollFrame",
+ flag: Flag::Mouse,
+ why: "our open-mail body is flat art plus a FontString — the render approximation \
+ MailFrame.xml names; nothing scrolls it on either side",
+ },
+ Known {
+ frame: "SendMailScrollFrame",
+ flag: Flag::Mouse,
+ why: "as OpenMailScrollFrame",
+ },
+ // ── id ─────────────────────────────────────────────────────────────────────────────────────
+ //
+ // The reference side of this comparison is XML only: the sweep cannot see a `SetID` the
+ // reference makes from Lua at `OnLoad`, so a frame the reference numbers *there* reads as 0
+ // here. Every entry below is that case, and each ends at the same runtime number ours declares
+ // — which is why they are accepted rather than fixed. (The blind spot only ever under-reports:
+ // it can hide a divergence, never invent one, so nothing it misses is a false failure.)
+ Known {
+ frame: "CharacterBag0Slot",
+ flag: Flag::Id,
+ why: "the bag slots are numbered from Lua in the reference (ContainerFrame.lua's bag-slot \
+ OnLoad); ours declares the same inventory slot in XML",
+ },
+ Known {
+ frame: "CharacterBag1Slot",
+ flag: Flag::Id,
+ why: "as CharacterBag0Slot",
+ },
+ Known {
+ frame: "CharacterBag2Slot",
+ flag: Flag::Id,
+ why: "as CharacterBag0Slot",
+ },
+ Known {
+ frame: "CharacterBag3Slot",
+ flag: Flag::Id,
+ why: "as CharacterBag0Slot",
+ },
+ Known {
+ frame: "KeyRingButton",
+ flag: Flag::Id,
+ why: "-2 is KEYRING_CONTAINER, which the reference also arrives at — from Lua, not XML",
+ },
+ Known {
+ frame: "BankFrame",
+ flag: Flag::Id,
+ why: "id=\"-1\" is ours: the bank window's own sentinel for \"not a container slot\", \
+ which the reference has no equivalent of",
+ },
+];
+
+/// The extracted reference FrameXML directory, or `None` when the install isn't there.
+fn reference_dir() -> Option {
+ let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../WoW/_extracted_framexml");
+ dir.is_dir().then_some(dir)
+}
+
+/// Every named *frame* element in the reference corpus, keyed by name — templates and instances
+/// alike, nested `` included, `$parent`-relative names excluded (they repeat across
+/// templates and name nothing on their own).
+fn reference_frames() -> Option> {
+ let dir = reference_dir()?;
+ let mut paths: Vec = std::fs::read_dir(&dir)
+ .ok()?
+ .flatten()
+ .map(|e| e.path())
+ .filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("xml")))
+ .collect();
+ paths.sort();
+
+ let mut out: HashMap = HashMap::new();
+ for path in paths {
+ // Blizzard ships a UTF-8 BOM on some of these and stray high bytes in comments; the parse
+ // is what matters, so read lossily rather than refusing the file.
+ let bytes = std::fs::read(&path).ok()?;
+ let text = String::from_utf8_lossy(&bytes);
+ let Ok(doc) = framexml::parse(text.trim_start_matches('\u{feff}')) else {
+ continue;
+ };
+ for item in &doc.items {
+ if let TopLevel::Template(el) | TopLevel::Instance(el) = item {
+ collect_named(el, &mut out);
+ }
+ }
+ }
+ Some(out)
+}
+
+/// Recurse an element tree, publishing every named frame. First name wins, matching the client's
+/// auto-publish rule.
+fn collect_named(el: &Element, out: &mut HashMap) {
+ if FRAME_TAGS.iter().any(|t| t.eq_ignore_ascii_case(&el.tag)) {
+ if let Some(name) = el.attr("name") {
+ if !name.contains("$parent") {
+ out.entry(name.to_string()).or_insert_with(|| el.clone());
+ }
+ }
+ }
+ for child in &el.children {
+ collect_named(child, out);
+ }
+}
+
+/// The templates `el` inherits, **right to left** — a later name in the list overrides an earlier
+/// one, so the search for an inherited value has to try the last one first.
+fn inherits(el: &Element) -> impl Iterator {
+ el.attr("inherits")
+ .unwrap_or("")
+ .split(',')
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .collect::>()
+ .into_iter()
+ .rev()
+}
+
+/// An attribute's value on `name`, following `inherits=`; the element's own value wins over any
+/// template's. `depth` guards a malformed cycle (the shipped corpus has none).
+fn resolved_attr<'a>(
+ name: &str,
+ frames: &'a HashMap,
+ attr: &str,
+ depth: u32,
+) -> Option<&'a str> {
+ if depth > 16 {
+ return None;
+ }
+ let el = frames.get(name)?;
+ if let Some(v) = el.attr(attr) {
+ return Some(v);
+ }
+ inherits(el).find_map(|t| resolved_attr(t, frames, attr, depth + 1))
+}
+
+/// Whether any element in `name`'s inherits chain declares one of `handlers` inside ``.
+fn declares_handler(
+ name: &str,
+ frames: &HashMap,
+ handlers: &[&str],
+ depth: u32,
+) -> bool {
+ if depth > 16 {
+ return false;
+ }
+ let Some(el) = frames.get(name) else {
+ return false;
+ };
+ let own = el
+ .children
+ .iter()
+ .filter(|c| c.tag.eq_ignore_ascii_case("Scripts"))
+ .flat_map(|s| s.children.iter())
+ .any(|h| handlers.iter().any(|w| w.eq_ignore_ascii_case(&h.tag)));
+ own || inherits(el).any(|t| declares_handler(t, frames, handlers, depth + 1))
+}
+
+/// Whether the reference's frame of this name **takes the mouse** once loaded — the three ways
+/// `0x76af00(2, -1)` is reached, per `scripts-auto-enable.md` §1.3: the widget's own ctor, the
+/// `enableMouse` attribute, or an auto-enabling `` handler.
+fn reference_takes_mouse(name: &str, frames: &HashMap) -> bool {
+ let Some(el) = frames.get(name) else {
+ return false;
+ };
+ if MOUSE_BY_CTOR
+ .iter()
+ .any(|t| t.eq_ignore_ascii_case(&el.tag))
+ {
+ return true;
+ }
+ if resolved_attr(name, frames, "enableMouse", 0).is_some_and(|v| v.eq_ignore_ascii_case("true"))
+ {
+ return true;
+ }
+ declares_handler(name, frames, MOUSE_HANDLERS, 0)
+}
+
+/// The reference name for one of ours: the same name, or the one behind our `Benilla` prefix
+/// (`BenillaChatFrameTemplate` is the reference's `ChatFrameTemplate` — the rename that made a
+/// text diff report a gap of zero on a window missing every flag).
+fn reference_name<'a>(ours: &str, frames: &'a HashMap) -> Option<&'a str> {
+ if let Some((k, _)) = frames.get_key_value(ours) {
+ return Some(k);
+ }
+ let bare = ours.strip_prefix("Benilla")?;
+ frames.get_key_value(bare).map(|(k, _)| k.as_str())
+}
+
+/// One divergence found by the sweep, rendered for the failure message.
+fn describe(frame: &str, flag: Flag, ours: &str, theirs: &str) -> String {
+ format!(" {frame} — {flag}: ours {ours}, reference {theirs}")
+}
+
+/// **Every frame both UIs name carries the reference's `toplevel`, mouse enable and `id`** — read
+/// off the loaded engine, with [`KNOWN`] the only accepted differences.
+///
+/// Verified to fail: delete `toplevel="true"` from `CharacterFrame.xml` and this names
+/// `CharacterFrame`; delete `id="1"` from `ActionButton1` and it names that.
+#[test]
+fn the_shipped_frames_carry_the_references_flags() {
+ let Some(reference) = reference_frames() else {
+ return; // no install — the same skip every client-data test here takes
+ };
+ assert!(
+ reference.len() > 500,
+ "only {} reference frames parsed — the corpus scan broke, and a sweep over nothing \
+ passes whatever we did",
+ reference.len()
+ );
+
+ let mut s = benilla_ui::script::UiScript::new().unwrap();
+ s.set_screen_size(1024.0, 768.0);
+ let failures = super::load_default_ui(&s);
+ assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
+ s.resolve();
+
+ // Read the three flags off the ENGINE, not off our XML — the whole point of the module.
+ let flags = |n: &str| -> Option<(bool, bool, i64)> {
+ s.eval::(&format!(
+ "local f = getglobal(\"{n}\") \
+ if not f or not f.IsToplevel then return -1 end \
+ return (f:IsToplevel() and 1 or 0) + (f:IsMouseEnabled() and 2 or 0)"
+ ))
+ .ok()
+ .filter(|v| *v >= 0)
+ .map(|v| {
+ let id = s
+ .eval::(&format!("return getglobal(\"{n}\"):GetID()"))
+ .unwrap_or(0);
+ (v & 1 == 1, v & 2 == 2, id)
+ })
+ };
+
+ let mut divergences: Vec = Vec::new();
+ let mut compared = 0usize;
+ // Every KNOWN entry starts unclaimed; a real divergence claims it. What is left at the end
+ // is an entry describing a difference that no longer exists.
+ let mut unused: Vec<&Known> = KNOWN.iter().collect();
+
+ let mut ours: Vec = super::shipped_xml_tests::shipped_frame_names();
+ ours.sort();
+ for name in &ours {
+ let Some(theirs) = reference_name(name, &reference) else {
+ continue;
+ };
+ let Some((toplevel, mouse, id)) = flags(name) else {
+ continue; // not a frame in the loaded tree (a template, or a region name)
+ };
+ compared += 1;
+
+ let want_toplevel = resolved_attr(theirs, &reference, "toplevel", 0)
+ .is_some_and(|v| v.eq_ignore_ascii_case("true"));
+ let want_mouse = reference_takes_mouse(theirs, &reference);
+ let want_id: i64 = resolved_attr(theirs, &reference, "id", 0)
+ .and_then(|v| v.trim().parse().ok())
+ .unwrap_or(0);
+
+ for (flag, differs, ours_s, theirs_s) in [
+ (
+ Flag::Toplevel,
+ toplevel != want_toplevel,
+ toplevel.to_string(),
+ want_toplevel.to_string(),
+ ),
+ (
+ Flag::Mouse,
+ mouse != want_mouse,
+ mouse.to_string(),
+ want_mouse.to_string(),
+ ),
+ (Flag::Id, id != want_id, id.to_string(), want_id.to_string()),
+ ] {
+ if !differs {
+ continue;
+ }
+ if KNOWN.iter().any(|k| k.frame == name && k.flag == flag) {
+ unused.retain(|u| !(u.frame == name && u.flag == flag));
+ continue;
+ }
+ divergences.push(describe(name, flag, &ours_s, &theirs_s));
+ }
+ }
+
+ assert!(
+ compared > 400,
+ "only {compared} frames compared — the pairing broke, and the sweep guards nothing"
+ );
+ assert!(
+ divergences.is_empty(),
+ "{} frame flag(s) diverge from the reference. Each is either a defect to fix or an entry \
+ to add to KNOWN with the reason it is right — never a tolerance:\n{}",
+ divergences.len(),
+ divergences.join("\n")
+ );
+ // A KNOWN entry that no longer describes a real difference is stale documentation claiming a
+ // divergence that has been fixed — the exact rot this module exists to prevent elsewhere.
+ let stale: Vec = unused
+ .iter()
+ .map(|k| format!(" {} ({}) — claimed: {}", k.frame, k.flag, k.why))
+ .collect();
+ assert!(
+ stale.is_empty(),
+ "{} KNOWN entr(y/ies) no longer describe a real difference — delete them, an accepted \
+ divergence that has been fixed is documentation claiming a defect we do not have:\n{}",
+ stale.len(),
+ stale.join("\n")
+ );
+}
diff --git a/crates/benilla-app/src/ui_script/group_loot_tests.rs b/crates/benilla-app/src/ui_script/group_loot_tests.rs
index 4c027760..5fae46e0 100644
--- a/crates/benilla-app/src/ui_script/group_loot_tests.rs
+++ b/crates/benilla-app/src/ui_script/group_loot_tests.rs
@@ -239,7 +239,25 @@ fn start_loot_roll_claims_frames_in_order_and_paints_the_roll() {
// proof loot_tests.rs runs for LootSlot. Roll 7 is BIND-ON-PICKUP, so the seam's gate holds
// the vote back and asks instead (decision 0594): nothing on the wire until the popup is
// accepted.
- let (nx, ny) = quad_center(&quads, "UI-GroupLoot-Dice-Up");
+ //
+ // Taken BY NAME, not by painter order. This used to be
+ // `quad_center(&quads, "UI-GroupLoot-Dice-Up")` — the first dice quad — which silently assumed
+ // declaration order is draw order among the four roll frames. `toplevel="true"` (decision
+ // 1739) ends that: the frame a roll claims raises above its siblings on Show, so the first
+ // dice belonged to frame 2 and the click landed on the BoE roll, which has no confirm gate and
+ // went straight to the wire. The proxy was the fixture, never the assertion.
+ let (nx, ny) = s
+ .eval::<(f64, f64)>(
+ "return (GroupLootFrame1RollButton:GetLeft() + GroupLootFrame1RollButton:GetRight()) \
+ / 2, (GroupLootFrame1RollButton:GetBottom() + GroupLootFrame1RollButton:GetTop()) / 2",
+ )
+ .unwrap();
+ let (nx, ny) = (nx as f32, ny as f32);
+ assert_eq!(
+ s.hit_test_name(nx, ny).as_deref(),
+ Some("GroupLootFrame1RollButton"),
+ "the click has to land on frame 1's Need button for the rest of this to mean anything"
+ );
s.mouse_button(nx, ny, "LeftButton", true);
s.mouse_button(nx, ny, "LeftButton", false);
assert!(
diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs
index 77eb375e..81acd1ea 100644
--- a/crates/benilla-app/src/ui_script/mod.rs
+++ b/crates/benilla-app/src/ui_script/mod.rs
@@ -930,6 +930,11 @@ mod enchant_confirm_tests;
#[cfg(test)]
mod framexml_diff;
+/// Its FLAG twin (decision 1739): the whole-tree sweep for `toplevel`/mouse/`id` against the
+/// reference, read off the loaded engine rather than off our XML.
+#[cfg(test)]
+mod frame_flag_gate;
+
#[cfg(test)]
mod friends_tests;
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 73a1816b..e89e4c91 100644
--- a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
+++ b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
@@ -1166,8 +1166,95 @@ fn a_cinematic_leaves_nothing_of_the_interface_on_screen() {
);
}
+/// **Every `parent=` the shipped tree declares REALLY attaches** — the tripwire for 1734's trap.
+///
+/// A parent name is resolved at LOAD, and a name that names nothing is **not an error**: the loader
+/// warns ("names no frame — falling back to the enclosing one") and silently reparents to whatever
+/// element encloses the declaration. So a typo, a renamed window, or a file that loads earlier in
+/// the manifest than its parent's leaves the frame attached to the wrong thing with a green suite
+/// and one log line nobody greps — which is how 1734's 41 harness failures were the *lucky*
+/// outcome and a silent half-fix was the unlucky one.
+///
+/// Asserted on the LOADED tree, not on the text: `GetParent():GetName()` is what the frame ended up
+/// attached to, which is the only form of this claim worth making. It is also the standing answer
+/// to "does this engine read a cross-file `parent=`?" — several file headers still said it does
+/// not, years after it did, and five pages carry a hand-written substitute for `setAllPoints`
+/// because of it.
+#[test]
+fn every_declared_parent_really_attaches() {
+ let mut s = benilla_ui::script::UiScript::new().unwrap();
+ s.set_screen_size(1024.0, 768.0);
+ let failures = super::load_default_ui(&s);
+ assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
+ s.resolve();
+
+ let declared = shipped_frame_parents();
+ assert!(
+ declared.len() > 60,
+ "only {} parent declarations found — the scan broke",
+ declared.len()
+ );
+ let mut wrong: Vec = Vec::new();
+ for (child, parent) in &declared {
+ let got = s
+ .eval::(&format!(
+ "local f = getglobal(\"{child}\") \
+ if not f or not f.GetParent then return \"\" end \
+ local p = f:GetParent() \
+ return p and (p:GetName() or \"\") or \"\""
+ ))
+ .unwrap_or_else(|_| "".to_string());
+ if got != *parent {
+ wrong.push(format!(
+ " {child}: declared parent={parent}, attached to {got}"
+ ));
+ }
+ }
+ assert!(
+ wrong.is_empty(),
+ "{} frame(s) did not attach to the parent they declare:\n{}",
+ wrong.len(),
+ wrong.join("\n")
+ );
+}
+
+/// `(named non-virtual frame, the `parent=` it declares)` for every such declaration in the shipped
+/// tree — the population of [`every_declared_parent_really_attaches`].
+fn shipped_frame_parents() -> Vec<(String, String)> {
+ let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
+ let mut out: Vec<(String, String)> = Vec::new();
+ for entry in std::fs::read_dir(&dir).expect("assets/ui").flatten() {
+ let path = entry.path();
+ if !path.extension().is_some_and(|e| e == "xml") {
+ continue;
+ }
+ let text = std::fs::read_to_string(&path).expect("read");
+ for chunk in text.split('<').skip(1) {
+ let head = &chunk[..chunk.find('>').unwrap_or(chunk.len())];
+ if head.contains("virtual=\"true\"") {
+ continue;
+ }
+ let attr = |key: &str| -> Option {
+ let i = head.find(key)?;
+ let rest = &head[i + key.len()..];
+ let j = rest.find('"')?;
+ Some(rest[..j].to_string())
+ };
+ let (Some(name), Some(parent)) = (attr("name=\""), attr("parent=\"")) else {
+ continue;
+ };
+ if !name.contains('$') && !parent.contains('$') {
+ out.push((name, parent));
+ }
+ }
+ }
+ out.sort();
+ out.dedup();
+ out
+}
+
/// Every named, non-virtual frame the shipped tree declares — the sweep's population.
-fn shipped_frame_names() -> Vec {
+pub(super) fn shipped_frame_names() -> Vec {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
let mut names: Vec = Vec::new();
for entry in std::fs::read_dir(&dir).expect("assets/ui").flatten() {
diff --git a/crates/benilla-app/src/ui_session.rs b/crates/benilla-app/src/ui_session.rs
index 746d58f7..3e601643 100644
--- a/crates/benilla-app/src/ui_session.rs
+++ b/crates/benilla-app/src/ui_session.rs
@@ -7,12 +7,17 @@
//! The threshold is the byte-verified NPC-service gate the cursor grays at
//! ([`SERVICE_RANGE_SQ`], 5.5556 yd center-to-center — `crate::target::cursor_mode`), so a window
//! closes exactly where the cursor says its NPC is out of service — one law for "out of service".
-//! Whether the real client's auto-close shares that exact gate is INFERRED (its close mechanism
-//! isn't RE'd); the constant is the knob if it reads too eager or too lax in play.
+//!
+//! **That last sentence used to end "…is INFERRED (its close mechanism isn't RE'd)". It is now a
+//! fact** (decision 1741): the close mechanism is `0x493230`, called per frame from
+//! `CGWorldFrame`'s layer update, and a census of all fourteen of the client's arming sites found
+//! thirteen pushing this very constant. The one thing the guess missed is that the leash is not
+//! the window's at all — it belongs to the latched OBJECT, and swaps to 14.0 yd for a player
+//! ([`leash_sq`]).
use bevy::prelude::*;
-use crate::net::{GuidIndex, SelfPlayer};
+use crate::net::{ClientCommand, GuidIndex, NetCommands, SelfPlayer};
use crate::target::SERVICE_RANGE_SQ;
use benilla_world::schedule::WorldStage;
@@ -57,19 +62,47 @@ pub(crate) trait NpcSession: Resource {
fn npc(&self) -> Option;
/// The window's client-side close (no packet) — the same clear its close button does.
fn close(&mut self);
- /// Whether the OPEN session is subject to the service-range guard below. Almost always yes,
- /// which is why it is defaulted — but "bound to a live unit" and "must stay inside NPC service
- /// range" are two claims, and a party quest-share separates them: its giver is a live *player*
- /// with a name and a portrait (so [`Self::npc`] must report it) whose distance rule is the
- /// server's 14 yd share bound, not this 5.56 yd counter reach (decision 1733).
+ /// What the WALK-AWAY close owes the wire, if anything — sent by the guard below just before
+ /// it closes, and `None` for every session but one.
///
- /// Contrast [`crate::ui_quest::QuestGiver`]'s item filter, which answers the *other* question
- /// by returning `None`: a quest-starter item is not a unit at all (decision 0664).
- fn range_guarded(&self) -> bool {
- true
+ /// It exists because the reference's watchdog does not route through the window's own close
+ /// verb: `0x4933da` calls the quest session's end (`0x501130`) **directly**, skipping
+ /// `DeclineQuest`'s core — so walking away from an NPC's quest panel is network-silent (no
+ /// giver re-open, and the DETAILS suppression flag is not consulted) while walking away from a
+ /// party member's SHARED quest still owes them `MSG_QUEST_PUSH_RESULT{DECLINE_QUEST}`
+ /// (decision 1741). A hook rather than a call to [`Self::close`] because that difference —
+ /// send *less* on the walk-away than on the button — is exactly what the binary does.
+ fn walk_away_send(&self, _npc: u64) -> Option {
+ None
}
}
+/// The leash the walk-away guard measures against, **chosen by the latched object's type, not by
+/// the window** — the reference's `0x4930d0`, which stores the opener's own range and then
+/// overwrites it when the object carries the PLAYER typemask bit (`0x493156`).
+///
+/// Thirteen of the client's fourteen arming sites push the same `50/9` yd
+/// ([`SERVICE_RANGE_SQ`]) — mailbox, loot, stable master, auctioneer, trainer, flight master,
+/// gossip, item text, petition, bank, vendor, questgiver — so benilla having one constant for all
+/// of them is right, and byte-exact. What benilla did not have is the swap: a session latched onto
+/// a **player** is leashed at `10.0 + 4.0 = 14.0` yd instead (`[0x8044b0] + [0x80306c]`, squared
+/// into the cell at `0x49316d`). The only such session that registers this guard is a shared
+/// quest — and 1733 read that case as "not range-guarded at all", which is wrong in the same
+/// direction a plain revert to 5.56 yd would have been wrong (decision 1741).
+///
+/// The 14.0 coinciding with vmangos's `QUEST_SHARE_DISTANCE` is corroboration, not the derivation:
+/// the client's number is two `.rdata` floats summed.
+fn leash_sq(npc: u64) -> f32 {
+ if benilla_protocol::guid::is_player(npc) {
+ PLAYER_LEASH_SQ
+ } else {
+ SERVICE_RANGE_SQ
+ }
+}
+
+/// `14.0²` — the player-typemask leash (see [`leash_sq`]).
+const PLAYER_LEASH_SQ: f32 = 196.0;
+
/// Did an NPC-bound window switch to a **different** NPC of the same kind while it was already open
/// (a `Some(a) → Some(b)`, `a != b`)? The real client can't just repaint: its `ShowUIPanel` early-
/// returns when the frame is already visible, so the only way MERCHANT_SHOW (etc.) re-plays the open
@@ -85,22 +118,26 @@ pub(crate) fn npc_switched(prev: Option, now: Option) -> bool {
pub(crate) fn close_npc_session_out_of_range(
mut session: ResMut,
index: Res,
+ commands: Res,
self_q: Query<&Transform, With>,
transforms: Query<&Transform>,
) {
let Some(npc) = session.npc() else { return };
- if !session.range_guarded() {
- return;
- }
let Some(self_tf) = self_q.iter().next() else {
return;
};
+ // Boundary-INCLUSIVE, centre-to-centre, no bounding radii — the reference's own compare
+ // (`0x4932f2`/`0x4932fe`: `dist² <= leash²` keeps). The leash is the object's, not the
+ // window's ([`leash_sq`]).
let out = match index.0.get(&npc).and_then(|e| transforms.get(*e).ok()) {
- Some(tf) => tf.translation.distance_squared(self_tf.translation) > SERVICE_RANGE_SQ,
+ Some(tf) => tf.translation.distance_squared(self_tf.translation) > leash_sq(npc),
None => true, // the NPC despawned out from under the window
};
if out {
debug!("ui_session: NPC {npc:#x} out of range/gone — client-side close");
+ if let Some(cmd) = session.walk_away_send(npc) {
+ let _ = commands.0.send(cmd);
+ }
session.close();
}
}
@@ -178,14 +215,28 @@ mod tests {
use super::*;
use crate::ui_merchant::MerchantOpen;
+ /// A realistic NPC guid — `HIGHGUID_UNIT` in the high word. Not a bare low guid: the high word
+ /// is what makes a guid a *player* (`guid::is_player`), and since 1741 the walk-away leash
+ /// depends on that, so a "vendor" numbered `0x42` is silently a player and gets 14 yd.
+ const VENDOR: u64 = 0xF130_0000_0000_0042;
+
/// Drive the guard in a minimal Bevy app over the merchant session (the trait's first
/// consumer): in range keeps the window, stepping past the service gate closes it, and a
/// despawned NPC closes it too.
+ ///
+ /// The vendor guid is a real `HIGHGUID_UNIT` one. It used to be a bare `0x42`, which is a
+ /// **player** guid (the high word is what decides — `guid::is_player`), and that went unnoticed
+ /// until the leash started depending on the type: a "vendor" with a player guid is leashed at
+ /// 14 yd and this test's 6 yd step stopped closing it (decision 1741).
#[test]
fn out_of_range_or_despawned_npc_closes_the_session() {
let mut app = App::new();
app.init_resource::();
app.init_resource::();
+ // The guard sends the walk-away answer for the one session that owes one (1741); the
+ // merchant owes none, but the channel has to exist for the system to run.
+ let (tx, _rx) = crossbeam_channel::unbounded();
+ app.insert_resource(NetCommands(tx));
app.add_systems(Update, close_npc_session_out_of_range::);
app.world_mut()
.spawn((SelfPlayer, Transform::from_xyz(0.0, 0.0, 0.0)));
@@ -196,12 +247,12 @@ mod tests {
app.world_mut()
.resource_mut::()
.0
- .insert(0x42, vendor);
+ .insert(VENDOR, vendor);
// 5.0 yd < the 5.5556 yd service gate: stays open.
app.world_mut()
.resource_mut::()
- .open(0x42, vec![]);
+ .open(VENDOR, vec![]);
app.update();
assert!(app.world().resource::().is_open());
@@ -216,24 +267,28 @@ mod tests {
// Re-open, then the NPC despawns out from under the window: closes too.
app.world_mut()
.resource_mut::()
- .open(0x42, vec![]);
- app.world_mut().resource_mut::().0.remove(&0x42);
+ .open(VENDOR, vec![]);
+ app.world_mut()
+ .resource_mut::()
+ .0
+ .remove(&VENDOR);
app.update();
assert!(!app.world().resource::().is_open());
}
- /// **A party quest-share is exempt from the guard, and still a full session.** The two are one
- /// test because they are the two halves of decision 1733's split: the giver is a live player,
- /// so `npc()` must keep reporting it (the QuestFrame portrait and name banner read it through
- /// [`InteractNpc`]) — while the *distance* rule is the server's 14 yd share bound, not this
- /// 5.56 yd NPC-counter reach. Before the split, a sharer standing six yards off had the panel
- /// yanked away from a quest the server would have granted.
+ /// **The leash belongs to the OBJECT, not the window** (decision 1741, correcting 1733): a
+ /// session latched onto a player is guarded at 14.0 yd, one latched onto an NPC at 5.56 yd.
+ /// 1733 read the share case as "not range-guarded at all" — wrong in the same direction that
+ /// reverting it to the NPC range would have been wrong, which is why both bounds are moved
+ /// here against the same panel.
+ ///
+ /// It also pins what the walk-away SENDS: a shared quest still owes the sharer the decline,
+ /// and an NPC's panel closes silently — the reference's watchdog skips the giver re-open the
+ /// button's path does.
#[test]
- fn a_shared_quest_panel_survives_the_service_range_but_stays_a_session() {
+ fn the_walk_away_leash_is_the_givers_own_and_a_share_still_answers() {
use crate::ui_quest::{QuestGiver, QuestView};
- // The panel's wire view, whichever giver opened it — only the GUID differs between an NPC
- // offer and a share, which is the whole point.
fn detail(npc: u64) -> QuestView {
QuestView::Detail(benilla_protocol::messages::QuestDetails {
npc,
@@ -241,7 +296,7 @@ mod tests {
title: "A Threat Within".into(),
details: String::new(),
objectives: String::new(),
- auto_finish: 1,
+ auto_finish: 0,
choices: Vec::new(),
rewards: Vec::new(),
money: 0,
@@ -250,59 +305,67 @@ mod tests {
}
const SHARER: u64 = 0x0000_0000_0000_002A; // HIGHGUID_PLAYER: a zero high word
- const VENDOR: u64 = 0xF130_0000_0000_0007; // HIGHGUID_UNIT
+ const NPC: u64 = 0xF130_0000_0000_0007; // HIGHGUID_UNIT
- let mut app = App::new();
- app.init_resource::();
- app.init_resource::();
- app.init_resource::();
- app.add_systems(
- Update,
+ /// Open the panel on `giver`, stand `dist` yards away, run the guard once, and report
+ /// whether the window survived and what went out.
+ fn walk(giver: u64, dist: f32) -> (bool, Vec) {
+ let (tx, rx) = crossbeam_channel::unbounded();
+ let mut app = App::new();
+ app.insert_resource(NetCommands(tx));
+ app.init_resource::();
+ app.init_resource::();
+ app.add_systems(Update, close_npc_session_out_of_range::);
+ app.world_mut()
+ .spawn((SelfPlayer, Transform::from_xyz(0.0, 0.0, 0.0)));
+ let e = app
+ .world_mut()
+ .spawn(Transform::from_xyz(dist, 0.0, 0.0))
+ .id();
+ app.world_mut()
+ .resource_mut::()
+ .0
+ .insert(giver, e);
+ app.world_mut()
+ .resource_mut::()
+ .open(giver, detail(giver));
+ app.update();
(
- close_npc_session_out_of_range::,
- feed_interact_npc,
+ app.world().resource::().is_open(),
+ rx.try_iter().collect(),
)
- .chain(),
- );
- app.world_mut()
- .spawn((SelfPlayer, Transform::from_xyz(0.0, 0.0, 0.0)));
- // Ten yards away: well past the NPC service gate, well inside the share bound.
- let sharer = app
- .world_mut()
- .spawn(Transform::from_xyz(10.0, 0.0, 0.0))
- .id();
- app.world_mut()
- .resource_mut::()
- .0
- .insert(SHARER, sharer);
+ }
- app.world_mut()
- .resource_mut::()
- .open(SHARER, detail(SHARER));
- app.update();
+ // A SHARE: 10 yd is past the NPC gate and well inside the player leash — 1733 would have
+ // kept this for the wrong reason, a plain revert would have closed it.
+ let (open, sent) = walk(SHARER, 10.0);
+ assert!(open, "a share is leashed at 14 yd, not 5.56");
+ assert!(sent.is_empty(), "still in range, nothing owed: {sent:?}");
+
+ // Past 14 yd it does close — and answers the sharer.
+ let (open, sent) = walk(SHARER, 15.0);
+ assert!(!open, "past the player leash the panel closes");
assert!(
- app.world().resource::().is_open(),
- "a shared quest's panel is not NPC-range-guarded"
- );
- assert_eq!(
- app.world().resource::().1,
- Some(SHARER),
- "it is still a session: the panel's portrait and name banner point at the sharer"
+ matches!(
+ sent.as_slice(),
+ [ClientCommand::QuestPushResult {
+ sharer: SHARER,
+ msg: benilla_protocol::messages::QuestShareMsg::DECLINE_QUEST,
+ }]
+ ),
+ "walking away from a share still declines it: {sent:?}"
);
- // The control: the identical distance with an NPC giver closes, as it always has.
- app.world_mut()
- .resource_mut::()
- .0
- .insert(VENDOR, sharer);
- app.world_mut()
- .resource_mut::()
- .open(VENDOR, detail(VENDOR));
- app.update();
+ // An NPC at the same 10 yd is long gone — the leash did not follow the window.
+ let (open, sent) = walk(NPC, 10.0);
+ assert!(!open, "an NPC giver is still leashed at 5.56 yd");
assert!(
- !app.world().resource::().is_open(),
- "an NPC giver ten yards off still closes"
+ sent.is_empty(),
+ "the walk-away skips the giver re-open the button does: {sent:?}"
);
+
+ // Boundary-inclusive: exactly on the leash keeps the window (`dist² <= leash²`).
+ assert!(walk(SHARER, 14.0).0, "14.0 yd exactly is still in range");
}
/// The `"npc"` portrait token's resolver ([`feed_interact_npc`]): the open NPC session's guid,
@@ -324,7 +387,7 @@ mod tests {
app.world_mut()
.resource_mut::()
.0
- .insert(0x42, npc);
+ .insert(VENDOR, npc);
// No window open → no npc.
app.update();
@@ -333,13 +396,13 @@ mod tests {
// A merchant opens on the indexed NPC → resolves to its entity.
app.world_mut()
.resource_mut::()
- .open(0x42, vec![]);
+ .open(VENDOR, vec![]);
app.update();
assert_eq!(app.world().resource::().0, Some(npc));
// Gossip carries the same guid (a browse-goods hop) and wins the priority chain — still the
// one NPC's entity.
- app.world_mut().resource_mut::().npc = Some(0x42);
+ app.world_mut().resource_mut::().npc = Some(VENDOR);
app.update();
assert_eq!(app.world().resource::().0, Some(npc));
@@ -358,7 +421,7 @@ mod tests {
app.init_resource::();
app.world_mut()
.resource_mut::()
- .open(0x42, 1);
+ .open(VENDOR, 1);
app.update();
assert_eq!(
app.world().resource::().0,
@@ -380,7 +443,7 @@ mod tests {
app.init_resource::();
app.world_mut()
.resource_mut::()
- .open(0x42, 2, vec![]);
+ .open(VENDOR, 2, vec![]);
app.update();
assert_eq!(
app.world().resource::().0,
diff --git a/crates/benilla-app/tests/world_api_wall.rs b/crates/benilla-app/tests/world_api_wall.rs
index 1b39045c..a0620077 100644
--- a/crates/benilla-app/tests/world_api_wall.rs
+++ b/crates/benilla-app/tests/world_api_wall.rs
@@ -308,7 +308,18 @@ fn is_instrument_consumer(rel: &str) -> bool {
/// the uploader is exactly the game opinion the wall exists to keep out. The alternative was
/// mirroring the region into every registered buffer to keep the count flat, which re-opens B49 in
/// the tint lane (decision 1731).
-const CEILING: usize = 164;
+/// And 164 → 165: `ffx_glow::GlueFfx`, a PUBLISH of the same shape as `rig_anim::AnimParked` and
+/// the two above — a resource the ENGINE reads and the GAME writes, and exactly ONE bit wide.
+/// Running an FFX pass pair is machinery; *which* pair a screen installs is the screen's own, and
+/// the reference says so by building two pairs (WorldFrame `0x481c46`, CGlueMgr `0x46a723`) and
+/// selecting between them at two different sites. The world's selector is already inside the
+/// engine's reach — `PLAYER_FLAGS_GHOST` on the live player — but the glue's is not and cannot be:
+/// it is the ghost bit of the **selected roster row** (`0x472fd9 test dh,0x20`), a character
+/// nobody is standing in, on a screen the engine has no concept of. The alternative was handing the
+/// renderer a roster to read to keep a count flat (decision 1731). Note the direction of travel:
+/// this replaced `FfxGlow::state_scale`, a float that let a bake inherit a player-state lane by
+/// arithmetic — the enum that took its place makes report B49's invariant structural.
+const CEILING: usize = 165;
/// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be
/// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it
diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs
index 51189dbf..86b9e035 100644
--- a/crates/benilla-ui/src/script/mod.rs
+++ b/crates/benilla-ui/src/script/mod.rs
@@ -1164,6 +1164,9 @@ impl UiScript {
}
let mut model = self.model_mut();
model.mouse_down_on.clear();
+ // …and its one-slot twin `root+0x80`, which the mouse-down raise reads: a capture left
+ // behind would aim the next press's raise at whatever the pointer was last holding.
+ model.mouse_capture = None;
// [`Model::last_click`] is deliberately NOT cleared here. It looks like it belongs in this
// list, and the binary says otherwise: `[CButton+0x334]` has exactly three writers
// image-wide (the ctor, the fired-double zero, the fired-single stamp) and none of them is
diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs
index ede1e80f..8e2d10c3 100644
--- a/crates/benilla-ui/src/script/model.rs
+++ b/crates/benilla-ui/src/script/model.rs
@@ -319,6 +319,16 @@ pub(crate) struct Model {
/// same-frame press+release test in [`UiScript::mouse_button`]. Keyed by button so a `LeftButton`
/// press is not cleared by a `RightButton` release.
pub(crate) mouse_down_on: HashMap,
+ /// The client's single mouse-capture slot **`root+0x80`** — the frame a press captured, held
+ /// until every button is up. The same fact as [`Model::mouse_down_on`] in the one-slot form,
+ /// and the two differ only while more than one button is held; this is the form the
+ /// mouse-down **raise** reads, which is what needs the precedence to be unambiguous.
+ ///
+ /// Written at `0x7663e6` with the resolved target — capture-**else**-hover, so an existing
+ /// capture survives a chorded press over a different frame — and cleared at `0x7664bb` only
+ /// when the post-event button mask is empty, so a chorded release keeps it. A title-region
+ /// press never reaches `0x7663e6` and so captures nothing.
+ pub(crate) mouse_capture: Option,
/// Per **frame**, the [`UiScript::now`] second at which its last single `OnClick` fired — the
/// double-click detector's entire state, and the faithful stand-in for the client's per-widget
/// timestamp `[CButton+0x334]` (wow-re `ui/scratch/button-doubleclick-law.md`).
@@ -1472,6 +1482,7 @@ impl Model {
mouseover: None,
hover_repick: false,
mouse_down_on: HashMap::new(),
+ mouse_capture: None,
last_click: HashMap::new(),
pending_size_changed: Vec::new(),
errors: Vec::new(),
diff --git a/crates/benilla-ui/src/script/object/toplevel.rs b/crates/benilla-ui/src/script/object/toplevel.rs
index 4ffd3e1c..d215fcfd 100644
--- a/crates/benilla-ui/src/script/object/toplevel.rs
+++ b/crates/benilla-ui/src/script/object/toplevel.rs
@@ -84,7 +84,7 @@
//! | `0x775b0a` (Lua `Frame:Raise()`) | the explicit script call | [`install`] |
//! | `0x76aeec` in `effective_visible_show 0x76ae10` | **the Show trigger** | [`raise_on_show`], run from [`crate::script::event::fire_visibility_changes`] |
//! | `0x7652d7` in begin-move `0x7652b0` | drag start | [`super::movable`]'s `start_moving` |
-//! | `0x766392` in the mouse-down handler `0x7662c0` | **the click trigger** | **not wired** — the pointer path is another concern's; the hook is one call in `pointer.rs`'s button-down arm |
+//! | `0x766392` in the mouse-down handler `0x7662c0` | **the click trigger** | [`crate::script::UiScript::mouse_button`]'s down arm, first thing — capture-else-hover, unguarded, ahead of the title-region swallow |
//! | `0x764a4c` (`force = 0`) | re-applies an already-computed bit `0x10` | no counterpart (see the OVERLAPPED note above) |
//! | `0x7650d6` | dead — zero references binary-wide | — |
//!
diff --git a/crates/benilla-ui/src/script/pointer.rs b/crates/benilla-ui/src/script/pointer.rs
index f57f6b65..496bad4b 100644
--- a/crates/benilla-ui/src/script/pointer.rs
+++ b/crates/benilla-ui/src/script/pointer.rs
@@ -329,6 +329,37 @@ impl UiScript {
/// statement-position calls (`script.mouse_button(...);`) are unaffected.
pub fn mouse_button(&mut self, x: f32, y: f32, button: &str, down: bool) -> bool {
let hit_id = self.hit_test(x, y);
+ // ── The RAISE, and it happens FIRST ─────────────────────────────────────────────────────
+ //
+ // wow-re ledger `0x7662c0` (VERIFIED, the `toplevel-raise.md` §5 fan-out): the
+ // mouse-button-DOWN handler "resolves target = root+0x80 (existing capture) else
+ // root+0x7c (hover frame), calls `0x76a5b0` Raise() **UNGUARDED** @`0x766392` (the
+ // toplevel gate is inside `0x7650f0`, so the raised frame may be an ancestor of the
+ // clicked one), then title-region drag or capture+OnMouseDown".
+ //
+ // Four clauses, each load-bearing and each visible:
+ //
+ // · **DOWN only.** The sibling category-`0xe` handler `0x766420` never raises — a window
+ // comes forward on the press, not on the release.
+ // · **Before the title-region swallow below**, so grabbing a window by its title bar
+ // raises it too, even though that path dispatches no `OnMouseDown` at all.
+ // · **Unguarded** — no toplevel test out here. [`toplevel::raise`] walks to the nearest
+ // toplevel self-or-ancestor and does nothing when there is none, which is exactly what
+ // makes a click on a *child* (a button, a tab, the window's own background texture)
+ // bring the whole window forward.
+ // · **Capture else hover.** [`Model::mouse_capture`] is `root+0x80`; while a button is
+ // held it wins, so a chorded press over a different window raises the one being
+ // dragged, not the one under the cursor.
+ //
+ // Nothing here is gated on the frame being mouse-enabled: the "hover frame" is the
+ // hit-test result, which already applies that gate.
+ if down {
+ let target = self.model_ref().mouse_capture;
+ if let Some(t) = target.or_else(|| self.hit_test_frame(x, y)) {
+ let mut model = self.model_mut();
+ super::object::toplevel::raise(&mut model, t);
+ }
+ }
// ── The title region, and it SWALLOWS the press ─────────────────────────────────────────
//
// A mouse-down inside `frame:GetTitleRegion()` starts a mode-2 move and never reaches the
@@ -420,6 +451,11 @@ impl UiScript {
model.mouse_down_on.remove(button);
}
}
+ // `0x7663e6` writes the resolved target into `root+0x80` — capture-else-hover, so
+ // this is an `or`, not an assignment: an existing capture is not displaced by a
+ // second button pressed elsewhere. (This is the point the reference reaches only
+ // on a title-region MISS, which is why the swallow above returns before it.)
+ model.mouse_capture = model.mouse_capture.or(hit_handle);
// A press REPLACES any in-flight gesture, so a started one has to be ended first
// — `abandon_drag`'s own doc carries why a silent drop is a stuck UI rather than a
// cancelled drag. Its `OnDragStop` fires below, outside this borrow.
@@ -451,6 +487,12 @@ impl UiScript {
(click, None, false, jump, None, abandoned, color_jump)
} else {
let pressed = model.mouse_down_on.remove(button);
+ // `root+0x80` is cleared at `0x7664bb` **only when the post-event button mask is
+ // zero** — a chorded release keeps the capture for the button still held. With the
+ // per-button map already drained above, "mask is zero" is "the map is empty".
+ if model.mouse_down_on.is_empty() {
+ model.mouse_capture = None;
+ }
// A left release ends any in-flight thumb drag (decision 0250 §5).
if button == "LeftButton" {
super::slider::end_drag(&mut model);
diff --git a/crates/benilla-ui/src/script/tests/toplevel.rs b/crates/benilla-ui/src/script/tests/toplevel.rs
index f9013cc8..2a22ead4 100644
--- a/crates/benilla-ui/src/script/tests/toplevel.rs
+++ b/crates/benilla-ui/src/script/tests/toplevel.rs
@@ -515,3 +515,177 @@ fn starting_a_move_raises_the_dragged_window() {
s.run("Dialog:StopMovingOrSizing()").unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
+
+// ── The mouse-DOWN trigger ──────────────────────────────────────────────────────────────────────
+
+/// The centre of `name`'s resolved rect, in the coordinate space [`UiScript::mouse_button`] takes.
+fn centre(s: &UiScript, name: &str) -> (f32, f32) {
+ let (x, y) = s
+ .eval::<(f64, f64)>(&format!(
+ "return ({name}:GetLeft() + {name}:GetRight()) / 2, \
+ ({name}:GetBottom() + {name}:GetTop()) / 2"
+ ))
+ .unwrap();
+ (x as f32, y as f32)
+}
+
+/// **A press brings the window forward** — the trigger at `0x766392` in the mouse-down handler
+/// `0x7662c0`, the last of the six census sites to be wired (wow-re ledger `0x7662c0`).
+///
+/// Without it `toplevel` gives only half the behaviour a player reads as "windows": a window you
+/// *open* comes to the front, and a window you *click* stays buried under whatever is on top of it.
+#[test]
+fn pressing_a_toplevel_window_brings_it_to_the_front() {
+ let mut s = board_and_dialog();
+ s.run(
+ r#"
+ Dialog:SetToplevel(true)
+ Dialog:EnableMouse(true)
+ Dialog:Show()
+ Board:SetFrameLevel(9) -- put the dialog back underneath
+ Dialog:SetFrameLevel(0)
+ "#,
+ )
+ .unwrap();
+ s.resolve();
+ assert!(level(&mut s, "Dialog") < level(&mut s, "Board"));
+
+ // A point inside Dialog and clear of Board, so the hit is unambiguous.
+ let (x, y) = centre(&s, "Dialog");
+ assert_eq!(s.hit_test_name(x, y).as_deref(), Some("Dialog"));
+ s.mouse_button(x, y, "LeftButton", true);
+ assert!(
+ level(&mut s, "Dialog") > level(&mut s, "Board"),
+ "the press brought it to the front"
+ );
+ s.mouse_button(x, y, "LeftButton", false);
+ assert!(s.errors().is_empty(), "{:?}", s.errors());
+}
+
+/// The trigger is **UNGUARDED** — there is no toplevel test at the call site. The worker resolves
+/// the nearest toplevel *self-or-ancestor*, so pressing a plain child raises the window that owns
+/// it. This is the clause that makes clicking a window's button, tab or background art work at all;
+/// with a guard out here, only a press landing on the window frame itself would raise.
+#[test]
+fn pressing_a_child_of_a_toplevel_window_raises_the_window() {
+ let mut s = board_and_dialog();
+ s.run(
+ r#"
+ Dialog:SetToplevel(true)
+ Dialog:Show()
+ Knob = CreateFrame("Frame", "Knob", Dialog)
+ Knob:SetPoint("CENTER", Dialog, "CENTER")
+ Knob:SetSize(40, 40)
+ Knob:EnableMouse(true)
+ Board:SetFrameLevel(9)
+ Dialog:SetFrameLevel(0)
+ "#,
+ )
+ .unwrap();
+ s.resolve();
+ assert!(level(&mut s, "Dialog") < level(&mut s, "Board"));
+
+ let (x, y) = centre(&s, "Knob");
+ assert_eq!(s.hit_test_name(x, y).as_deref(), Some("Knob"));
+ s.mouse_button(x, y, "LeftButton", true);
+ assert!(
+ level(&mut s, "Dialog") > level(&mut s, "Board"),
+ "pressing the child raised its toplevel ancestor"
+ );
+ s.mouse_button(x, y, "LeftButton", false);
+ assert!(s.errors().is_empty(), "{:?}", s.errors());
+}
+
+/// **The RELEASE never raises.** The sibling category-`0xe` handler `0x766420` has no `Raise` call
+/// anywhere in it; only the down edge does. A raise on the release would be invisible in the
+/// ordinary case and wrong in the one that matters — a press that started a drag elsewhere.
+#[test]
+fn releasing_over_a_toplevel_window_does_not_raise_it() {
+ let mut s = board_and_dialog();
+ s.run(
+ r#"
+ Dialog:SetToplevel(true)
+ Dialog:EnableMouse(true)
+ Dialog:Show()
+ Board:SetFrameLevel(9)
+ Dialog:SetFrameLevel(0)
+ "#,
+ )
+ .unwrap();
+ s.resolve();
+ let (x, y) = centre(&s, "Dialog");
+ let before = level(&mut s, "Dialog");
+ // A release with no press before it: the down edge never ran, so nothing may move.
+ s.mouse_button(x, y, "LeftButton", false);
+ assert_eq!(
+ level(&mut s, "Dialog"),
+ before,
+ "the release raised nothing"
+ );
+ assert!(s.errors().is_empty(), "{:?}", s.errors());
+}
+
+/// **Capture else hover.** The handler resolves `root+0x80` (the existing capture) *before* falling
+/// back to `root+0x7c` (the frame under the cursor), so a second button pressed while the first is
+/// still held raises the frame being HELD, not whatever the pointer has wandered onto. The capture
+/// clears only when the last button comes up (`0x7664bb`, gated on the post-event mask), so the
+/// next press after that resolves to the hover again.
+#[test]
+fn a_chorded_press_raises_the_held_frame_not_the_one_under_the_cursor() {
+ let mut s = board_and_dialog();
+ s.run(
+ r#"
+ Dialog:SetToplevel(true)
+ Dialog:EnableMouse(true)
+ Dialog:Show()
+
+ -- A second window well clear of the first, with something of its own to raise over: the
+ -- gate is occlusion, so a frame overlapping nothing would decline whatever we press.
+ Other = CreateFrame("Frame", "Other")
+ Other:SetPoint("BOTTOMLEFT", 500, 100)
+ Other:SetSize(200, 200)
+ Other:EnableMouse(true)
+ Other:SetToplevel(true)
+ Tile = CreateFrame("Frame", "Tile")
+ Tile:SetPoint("BOTTOMLEFT", 660, 260) -- clips Other's far corner only
+ Tile:SetSize(60, 60)
+ Tile:SetFrameLevel(9)
+
+ Board:SetFrameLevel(9)
+ Dialog:SetFrameLevel(0)
+ Other:SetFrameLevel(0)
+ "#,
+ )
+ .unwrap();
+ s.resolve();
+
+ // Hold the left button on Dialog…
+ let (dx, dy) = centre(&s, "Dialog");
+ s.mouse_button(dx, dy, "LeftButton", true);
+ let dialog_held = level(&mut s, "Dialog");
+ let other_before = level(&mut s, "Other");
+
+ // …then press the right button away over Other. The capture is still Dialog's, so Other is not
+ // the raise target even though it is what the cursor is on.
+ let (ox, oy) = centre(&s, "Other");
+ assert_eq!(s.hit_test_name(ox, oy).as_deref(), Some("Other"));
+ s.mouse_button(ox, oy, "RightButton", true);
+ assert_eq!(
+ level(&mut s, "Other"),
+ other_before,
+ "the frame under the cursor is not the raise target while a capture is held"
+ );
+ assert_eq!(level(&mut s, "Dialog"), dialog_held);
+
+ // Both buttons up — only now does the capture clear.
+ s.mouse_button(ox, oy, "LeftButton", false);
+ s.mouse_button(ox, oy, "RightButton", false);
+ let other_now = level(&mut s, "Other");
+ s.mouse_button(ox, oy, "LeftButton", true);
+ assert!(
+ level(&mut s, "Other") > other_now,
+ "with no capture held the press raises what the cursor is over"
+ );
+ s.mouse_button(ox, oy, "LeftButton", false);
+ assert!(s.errors().is_empty(), "{:?}", s.errors());
+}
diff --git a/crates/benilla-world/src/ffx_glow.rs b/crates/benilla-world/src/ffx_glow.rs
index 906dd999..ce88d9a9 100644
--- a/crates/benilla-world/src/ffx_glow.rs
+++ b/crates/benilla-world/src/ffx_glow.rs
@@ -48,10 +48,8 @@ use crate::view::WorldCamera;
pub struct FfxGlow {
/// Per-view multiplier on the zone glow weight. See [`Self::WORLD`] / [`Self::UI_PANE`].
pub(crate) gain_scale: f32,
- /// Per-view multiplier on the whole **player-state** class of screen effects: the haze mix
- /// `z` ([`FfxHazeMix`] — the drunk/underwater screen-toward-blur cross-fade) *and* the
- /// FFXDeath gate `y` ([`FfxDeathFade`] — the ghost combine). `1.0` only on the true world
- /// view.
+ /// **Which FFX pass pair this view runs**, and therefore what drives the combine's `y` (the
+ /// FFXDeath gate) and `z` (the haze mix) — see [`FfxState`].
///
/// **The law, and why it is ONE field.** The reference runs its FFX pass inside the
/// WorldFrame's own paint — a BEGIN/END pair, `0x6cd890`/`0x6cda70`, bracketed at
@@ -70,9 +68,40 @@ pub struct FfxGlow {
///
/// The haze had its own `haze_scale` and the death gate had none, so a released ghost's
/// portraits baked through the FFXDeath combine and came back steel-blue luma (report B49,
- /// decision 1481). Naming the *class* rather than the member is the fix: the next
- /// player-state pass is gated by construction.
- pub(crate) state_scale: f32,
+ /// decision 1481). Naming the *class* rather than the member was the fix; naming the **pass
+ /// pair** (decision 1731) is the same fix one step further, and it is the reference's own
+ /// shape — see [`FfxState`].
+ pub(crate) state: FfxState,
+}
+
+/// **Which of the reference's FFX pass pairs a view runs.** The binary builds *two*, and the
+/// active-pass slot `[0xce8bb4]` holds whichever the screen that is painting installed:
+///
+/// - the **WorldFrame** pair — `0x6cc130` CFFXGlow → `[0xb4b350]`, `0x6cc690` CFFXDeath →
+/// `[0xb4b39c]`, built at `0x481c46`; selected by `0x5de9c0` off `PLAYER_FLAGS_GHOST`;
+/// - the **glue** pair — `[0xb414c4]` glow / `[0xb41468]` death, built at CGlueMgr init
+/// `0x46a723`/`0x46a752`; selected by the select build's tail `0x472fd9 test dh,0x20` off the
+/// selected roster record's `CHARSELECT+0xfc & 0x2000` (wow-re `death-pass.md` §4(c) +
+/// `glue-select-model.md` §A2, both VERIFIED).
+///
+/// benilla's views coexist where the reference's screens take turns, so what the reference
+/// expresses as one global slot written by whoever paints, we express as a property of the view.
+/// That is *why* this is an enum and not a scale: a bake's [`Self::None`] cannot inherit a
+/// player-state lane by arithmetic accident, which is the invariant decision 1481 legislated after
+/// report B49, now structural.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum FfxState {
+ /// The **WorldFrame** pair — the live player's own state: the ghost death combine
+ /// ([`FfxDeathFade`], off `PLAYER_FLAGS_GHOST`) and the drunk/underwater haze
+ /// ([`FfxHazeMix`]). The true world view only.
+ Player,
+ /// The **glue** pair — the death combine iff the SELECTED ROSTER ROW is a ghost
+ /// ([`GlueFfx::death`]), and no haze: a glue screen has no drunk player and no camera in a
+ /// liquid, and the reference's glue pass pair carries no haze lane to read.
+ Glue,
+ /// Neither — a bake standing in for a 1.12 UI model widget, which the reference composites
+ /// *after* the WorldFrame's own FFX bracket and which therefore sees no pass state at all.
+ None,
}
impl FfxGlow {
@@ -81,7 +110,17 @@ impl FfxGlow {
/// combine). The only view that carries the latter.
pub const WORLD: Self = Self {
gain_scale: 1.0,
- state_scale: 1.0,
+ state: FfxState::Player,
+ };
+ /// The **glue screens' own** fullscreen render (login / create / character select) — the
+ /// reference's glue pass pair, installed by CGlueMgr and applied around the glue scene's paint
+ /// (`0x46fad3 call 0x6cd890` … `0x46fae0 jmp 0x6cda70`). It is not a bake standing in for a UI
+ /// widget: it is the screen, so it runs the zone glow AND, for a ghost selection, the death
+ /// combine — while the GlueXML frames over it composite afterwards, untinted, exactly as the
+ /// reference's character list and buttons do.
+ pub const GLUE_SCENE: Self = Self {
+ gain_scale: 1.0,
+ state: FfxState::Glue,
};
/// A portrait/booth bake at world parity for *lighting and glow* (decision 0638) — but never
/// a player-state pass: a bake stands in for a UI model widget, which the reference
@@ -90,7 +129,7 @@ impl FfxGlow {
/// goes steel-blue (decision 1481, report B49).
pub const BOOTH: Self = Self {
gain_scale: 1.0,
- state_scale: 0.0,
+ state: FfxState::None,
};
/// **Decode only, no glow** — for a bake that stands in for a 1.12 *UI model widget*. The
/// reference applies its FFX pass inside the WorldFrame's own paint (the `0x6cd890`/`0x6cda70`
@@ -100,7 +139,7 @@ impl FfxGlow {
/// text and buttons don't bloom.)
pub const UI_PANE: Self = Self {
gain_scale: 0.0,
- state_scale: 0.0,
+ state: FfxState::None,
};
}
@@ -124,6 +163,85 @@ pub struct FfxGlowGain(pub f32);
#[derive(Resource, Clone, Default, ExtractResource)]
pub struct FfxDeathFade(pub f32);
+/// **The glue screens' FFX state** — the two things the select build's tail writes, and nothing
+/// else (`0x472fba`–`0x473007`, byte-read from wow-re's own disassembly; the fork is
+/// `472fd9 test dh,0x20` on the selected record's `CHARSELECT+0xfc`):
+///
+/// ```text
+/// 472fde mov ecx,ds:0xb41468 ; call 0x6cde60 ; ghost → install the glue DEATH pass
+/// 472fe9 mov edx,ds:0x838570 ; mov [esi+0x110],edx ; …and pin LightParams.glow
+/// 472ff7 mov ecx,ds:0xb414c4 ; call 0x6cde60 ; living → install the glue GLOW pass
+/// 473002 mov eax,ds:0x838574 ; mov [esi+0x110],eax ; …and pin LightParams.glow
+/// ```
+///
+/// `[esi+0x110]` is the DN/lighting singleton's `LightParams.glow` (`esi` = `0x6d48b0()` =
+/// `&0xce9b60`) — the same scalar `0x6cb930` reads for the death combine's **alpha byte**
+/// (`glow × 255`, `death-pass.md` §3) and the glow combine reads for its blur² weight. So on a glue
+/// screen the glow weight is not a zone value at all: it is one of two constants, chosen by the
+/// same bit that chooses the pass.
+///
+/// Written by the game's char-select feed; read by [`sync_gain`]'s off-world arm and by
+/// [`FfxState::Glue`]'s combine.
+#[derive(Resource, Clone, Copy, Default, PartialEq, Eq, Debug, ExtractResource)]
+pub enum GlueFfx {
+ /// **A glue ModelFFX widget is up and nothing has re-pinned it** — the login screen, the create
+ /// screen, and a character-select screen with an empty account (where no select build runs).
+ /// `CSimpleModelFFX`'s own **OnShow** override `0x46fa60` pins `[+0x110] = *0x8380b4 = 0.30f`
+ /// and installs the glue GLOW pass (`0x46fa74`). OnShow, not per-frame: it tail-jumps to
+ /// `0x76b260`, which runs the widget's `[frame+0x130]` handler (`0x76a0d0` maps `"OnShow"` to
+ /// that slot), which is why the select build's own pin below is not overwritten every frame.
+ #[default]
+ Shown,
+ /// **The character-select screen, LIVING selection.** The select build's tail installs the glue
+ /// GLOW pass and pins `*0x838574 = 0.40f` (`0x472ff7`/`0x473002`). Note what this is *not*: a
+ /// living selection is not "no post-process" — it is the glow pass at a pinned weight.
+ SelectLiving,
+ /// **The character-select screen, GHOST selection.** The glue DEATH pass, and `*0x838570 = 0.15f`
+ /// (`0x472fde`/`0x472fe9`).
+ SelectGhost,
+}
+
+impl GlueFfx {
+ /// The glue pair's death gate — `1.0` only for [`Self::SelectGhost`]. Instant on both edges,
+ /// like the world's: the reference's swap is a slot write with no time anchor in it, so
+ /// clicking from a ghost row to a living one un-washes the scene on the same frame. And it runs
+ /// on **every** selection, not only the first: `0x472a6d jne 0x472fba` sends an already-built
+ /// record straight into the swap block.
+ fn death(self) -> f32 {
+ match self {
+ Self::SelectGhost => 1.0,
+ Self::Shown | Self::SelectLiving => 0.0,
+ }
+ }
+
+ /// `LightParams.glow` while a glue screen is up — the DN/lighting singleton's `0xce9c70`
+ /// (`0x6d48b0` is `mov eax,0xce9b60; ret`, so `[eax+0x110]` is that field).
+ ///
+ /// **All three values are byte-VERIFIED**, and an accessor-call-site census over all 41
+ /// `call 0x6d48b0` sites found exactly these three writers and three readers (the ffx packs
+ /// `0x6cb0e2`/`0x6cb557`/`0x6cb9e1`). Each constant has exactly one reference image-wide — the
+ /// read itself — so they are literals, not `.data` defaults that something else moves.
+ ///
+ /// This is the scalar the death combine turns into its **primary alpha byte** (`0x6cb9ec`:
+ /// `×255.0`, `+512.0`, `fstp`, `shr eax,0xe`) — 0.15 → 38, 0.40 → 102 — and which both combines
+ /// use as the blur² weight. So a ghost's select screen glows at **less than half** a living
+ /// one's, which is the opposite of what "the ghost look is the loud one" would suggest.
+ ///
+ /// (benilla keeps the float rather than the quantized byte: 38/255 = 0.14902 against 0.15 is
+ /// under a thousandth, and the same scalar feeds the in-world glow lane where the reference
+ /// carries a float too.)
+ fn glow(self) -> f32 {
+ match self {
+ // `*0x8380b4`, the widget's OnShow pin.
+ Self::Shown => 0.30,
+ // `*0x838574`, the select build's living arm.
+ Self::SelectLiving => 0.40,
+ // `*0x838570`, the select build's ghost arm.
+ Self::SelectGhost => 0.15,
+ }
+ }
+}
+
/// The haze mix `z` — the combine's screen-toward-blur cross-fade
/// (`out = lerp(screen, blur, z) + w·blur²`, the shipped FFXGlow.bls). The reference's glow
/// render packs it per frame from the **active player's** state (`0x6cb134`/`0x6cb599`, wow-re
@@ -166,12 +284,17 @@ fn sync_haze(
fn sync_gain(
lighting: Option>,
live: Res,
+ glue: Res,
mut gain: ResMut,
) {
let target = if live.0 {
lighting.map_or(0.5, |l| l.glow)
} else {
- 0.5
+ // Off-world the glue screen pins it outright — the widget's OnShow, or the select build's
+ // own fork ([`GlueFfx::glow`], all three byte-verified). It is never `LightParams`' default
+ // here: this arm used to read a flat 0.5 on the reasoning that an off-world client falls
+ // back to the table default, and the bytes say the glue screens pin it instead.
+ glue.glow()
};
if gain.0 != target {
gain.0 = target;
@@ -417,19 +540,61 @@ fn dither_armed() -> f32 {
/// - **x** — the zone glow weight, scaled by [`FfxGlow::gain_scale`]: authored *scene* data, so a
/// portrait bake carries it at world parity (decision 0638) and only a UI model pane drops it,
/// keeping the combine for its gamma decode alone.
-/// - **y/z** — the FFXDeath gate and the haze mix, both scaled by [`FfxGlow::state_scale`]: these
-/// are keyed on the **viewer's own state**, which the reference paints inside the WorldFrame and
-/// every UI frame composites over, so neither may reach a bake (decision 1481).
+/// - **y/z** — the FFXDeath gate and the haze mix, both selected by [`FfxGlow::state`]: which pass
+/// pair this view runs decides what drives them, and a bake runs neither pair (decision 1481,
+/// report B49 — now structural rather than arithmetic, decision 1731).
/// - **w** — the deband-dither arm ([`dither_armed`]), 0 or 1.
-fn combine_uniform(zone_gain: f32, death: f32, haze: f32, glow: &FfxGlow) -> [f32; 4] {
+fn combine_uniform(
+ zone_gain: f32,
+ world: FfxPassState,
+ glue: FfxPassState,
+ glow: &FfxGlow,
+) -> [f32; 4] {
+ let feed = match glow.state {
+ FfxState::Player => world,
+ FfxState::Glue => glue,
+ FfxState::None => FfxPassState::INERT,
+ };
[
zone_gain * glow.gain_scale,
- death * glow.state_scale,
- haze * glow.state_scale,
+ feed.death,
+ feed.haze,
dither_armed(),
]
}
+/// One pass pair's live state, as [`combine_uniform`] consumes it: the death gate and the haze mix
+/// that pair carries. The glue pair has no haze lane at all, which is a fact about the reference
+/// and not a value we happen to leave at zero.
+#[derive(Clone, Copy)]
+struct FfxPassState {
+ death: f32,
+ haze: f32,
+}
+
+impl FfxPassState {
+ /// The **WorldFrame** pair's live state: the ghost gate off `PLAYER_FLAGS_GHOST` and the
+ /// drunk/underwater haze, both of the live player.
+ fn world(death: f32, haze: f32) -> Self {
+ Self { death, haze }
+ }
+
+ /// The **glue** pair's live state. Death only, and the constructor is where that is enforced:
+ /// the reference's haze is `primary.z` of the *WorldFrame* glow combine `0x6cb020`, packed from
+ /// the active player's inebriation and the camera-eye liquid probe (`drunk-blur-z.md`) — a glue
+ /// screen has neither, and CGlueMgr's pair has no lane to read them into. Passing a haze here
+ /// should be impossible rather than merely wrong.
+ fn glue(death: f32) -> Self {
+ Self { death, haze: 0.0 }
+ }
+
+ /// What a view running neither pair reads — a bake.
+ const INERT: Self = Self {
+ death: 0.0,
+ haze: 0.0,
+ };
+}
+
#[derive(Default)]
struct FfxGlowNode;
@@ -451,8 +616,11 @@ impl ViewNode for FfxGlowNode {
let pipeline_cache = world.resource::();
let uniform = combine_uniform(
world.resource::().0,
- world.resource::().0,
- world.resource::().0,
+ FfxPassState::world(
+ world.resource::().0,
+ world.resource::().0,
+ ),
+ FfxPassState::glue(world.resource::().death()),
glow,
);
let (Some(downsample), Some(gauss_h), Some(gauss_v), Some(combine)) = (
@@ -563,11 +731,13 @@ impl Plugin for FfxGlowPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(FfxGlowGain(0.647)) // overwritten by sync_gain from zone data
.init_resource::()
+ .init_resource::()
.init_resource::()
.add_plugins((
ExtractComponentPlugin::::default(),
ExtractResourcePlugin::::default(),
ExtractResourcePlugin::::default(),
+ ExtractResourcePlugin::::default(),
ExtractResourcePlugin::::default(),
))
.add_systems(Update, (sync_gain, sync_haze, ensure_ffx_glow));
@@ -596,18 +766,33 @@ impl Plugin for FfxGlowPlugin {
mod tests {
use super::*;
+ /// The world pair, live: a released ghost, sober.
+ fn ghost_world() -> FfxPassState {
+ FfxPassState::world(1.0, 0.0)
+ }
+ /// The glue pair with a living selection — the common case.
+ fn living_glue() -> FfxPassState {
+ FfxPassState::glue(0.0)
+ }
+
/// The zone glow is scene data — a portrait bake wants it at world parity (decision 0638) —
- /// while the death and haze lanes are player state and must stop at the world view
- /// (decision 1481). One preset table, checked as a whole so a new preset can't quietly join
- /// the wrong side.
+ /// while the death and haze lanes belong to a *pass pair*, and a bake runs neither
+ /// (decision 1481, now structural: 1731). One preset table, checked as a whole so a new preset
+ /// can't quietly join the wrong side.
#[test]
- fn only_the_world_view_carries_the_player_state_lanes() {
+ fn only_the_two_screen_views_run_a_pass_pair() {
for (name, view) in [("BOOTH", FfxGlow::BOOTH), ("UI_PANE", FfxGlow::UI_PANE)] {
- assert_eq!(view.state_scale, 0.0, "{name} must not carry player state");
+ assert_eq!(
+ view.state,
+ FfxState::None,
+ "{name} is a bake standing in for a UI widget — it runs no pass pair"
+ );
}
- assert_eq!(FfxGlow::WORLD.state_scale, 1.0);
+ assert_eq!(FfxGlow::WORLD.state, FfxState::Player);
+ assert_eq!(FfxGlow::GLUE_SCENE.state, FfxState::Glue);
// The glow half is unchanged by that law: a bake still glows like the world.
assert_eq!(FfxGlow::BOOTH.gain_scale, 1.0);
+ assert_eq!(FfxGlow::GLUE_SCENE.gain_scale, 1.0);
assert_eq!(FfxGlow::UI_PANE.gain_scale, 0.0);
}
@@ -615,18 +800,18 @@ mod tests {
/// luma. The gate reaches the world view and nothing else — while the zone glow still does.
#[test]
fn a_ghosts_bake_is_not_death_combined() {
- let (zone, ghost, sober) = (0.5, 1.0, 0.0);
+ let zone = 0.5;
assert_eq!(
- combine_uniform(zone, ghost, sober, &FfxGlow::WORLD),
+ combine_uniform(zone, ghost_world(), living_glue(), &FfxGlow::WORLD),
[0.5, 1.0, 0.0, 0.0]
);
assert_eq!(
- combine_uniform(zone, ghost, sober, &FfxGlow::BOOTH),
+ combine_uniform(zone, ghost_world(), living_glue(), &FfxGlow::BOOTH),
[0.5, 0.0, 0.0, 0.0],
"the booth keeps the zone glow and drops the death gate"
);
assert_eq!(
- combine_uniform(zone, ghost, sober, &FfxGlow::UI_PANE),
+ combine_uniform(zone, ghost_world(), living_glue(), &FfxGlow::UI_PANE),
[0.0, 0.0, 0.0, 0.0]
);
}
@@ -635,14 +820,87 @@ mod tests {
/// world-only, and still is.
#[test]
fn a_drunk_players_bake_stays_sharp() {
- let (zone, alive, drunk) = (0.5, 0.0, 1.0);
+ let (zone, drunk) = (0.5, FfxPassState::world(0.0, 1.0));
assert_eq!(
- combine_uniform(zone, alive, drunk, &FfxGlow::WORLD),
+ combine_uniform(zone, drunk, living_glue(), &FfxGlow::WORLD),
[0.5, 0.0, 1.0, 0.0]
);
assert_eq!(
- combine_uniform(zone, alive, drunk, &FfxGlow::BOOTH),
+ combine_uniform(zone, drunk, living_glue(), &FfxGlow::BOOTH),
[0.5, 0.0, 0.0, 0.0]
);
}
+
+ /// **The two pairs are independent, which is the whole point of naming them** (decision 1731).
+ /// The reference has one active-pass slot because its screens take turns; ours coexist, so the
+ /// invariant has to be stated: a ghost on the CHARACTER-SELECT list death-combines the glue
+ /// scene and nothing else, and a released ghost in the WORLD never reaches the glue view.
+ #[test]
+ fn each_pass_pair_reaches_only_its_own_view() {
+ let zone = 0.5;
+ let ghost_glue = FfxPassState::glue(1.0);
+ let alive_world = FfxPassState::world(0.0, 0.0);
+ assert_eq!(
+ combine_uniform(zone, alive_world, ghost_glue, &FfxGlow::GLUE_SCENE),
+ [0.5, 1.0, 0.0, 0.0],
+ "a ghost roster row death-combines the glue scene"
+ );
+ assert_eq!(
+ combine_uniform(zone, alive_world, ghost_glue, &FfxGlow::WORLD),
+ [0.5, 0.0, 0.0, 0.0],
+ "…and never the world view, whose own player is alive"
+ );
+ assert_eq!(
+ combine_uniform(zone, ghost_world(), living_glue(), &FfxGlow::GLUE_SCENE),
+ [0.5, 0.0, 0.0, 0.0],
+ "and a world ghost never reaches the glue view"
+ );
+ }
+
+ /// The glue pair carries **no haze lane** — a fact about the reference (its pair is built by
+ /// CGlueMgr; the haze is `primary.z` of the *WorldFrame* glow combine), enforced by
+ /// [`FfxPassState::glue`] having no way to say otherwise. Pinned so the day someone widens that
+ /// constructor, a test argues back — and pinned against a fully hazed WORLD, which is the state
+ /// that would leak if the two pairs were ever refolded into one.
+ #[test]
+ fn the_glue_pair_never_hazes() {
+ let drunk_world = FfxPassState::world(0.0, 1.0);
+ assert_eq!(
+ combine_uniform(
+ 0.5,
+ drunk_world,
+ FfxPassState::glue(1.0),
+ &FfxGlow::GLUE_SCENE
+ ),
+ [0.5, 1.0, 0.0, 0.0],
+ "the glue view death-combines on its own gate and never hazes"
+ );
+ }
+
+ /// GOLDEN — the three writers of the glue screens' `LightParams.glow`, and the alpha byte the
+ /// death combine quantizes the ghost one into. Every number byte-VERIFIED (wow-re
+ /// `glue-select-ghost-treatment.md`); each constant has exactly one reference image-wide.
+ ///
+ /// The default is a shown-but-unselected glue screen — login, create, and an empty account —
+ /// which is the state the login screen renders in and which is NOT either select arm.
+ #[test]
+ fn the_glue_screens_pin_three_verified_glow_weights() {
+ assert_eq!(GlueFfx::default(), GlueFfx::Shown);
+ assert_eq!(
+ GlueFfx::Shown.glow(),
+ 0.30,
+ "*0x8380b4, the widget's OnShow"
+ );
+ assert_eq!(GlueFfx::SelectLiving.glow(), 0.40, "*0x838574");
+ assert_eq!(GlueFfx::SelectGhost.glow(), 0.15, "*0x838570");
+ // Only the ghost arm installs the death pass, and it is the only arm that does.
+ assert_eq!(GlueFfx::SelectGhost.death(), 1.0);
+ assert_eq!(GlueFfx::Shown.death(), 0.0);
+ assert_eq!(GlueFfx::SelectLiving.death(), 0.0);
+ // The combine's primary alpha byte (`0x6cb9ec`: ×255, +512, fstp, shr 14) — 38 ghosted,
+ // 102 living. A ghost's screen glows at less than half a living one's.
+ let alpha_byte = |glow: f32| ((glow * 255.0 + 512.0).to_bits() >> 14) & 0xff;
+ assert_eq!(alpha_byte(GlueFfx::SelectGhost.glow()), 38);
+ assert_eq!(alpha_byte(GlueFfx::SelectLiving.glow()), 102);
+ }
}