the HUD becomes UIParent's child, and cinematics fade through black

- the transcription had dropped parent= on 72 declarations the reference carries (37 to UIParent, 28 to the action bar's art frame, 7 more), so the HUD was a field of top-level frames and UIParent:Hide() reached almost nothing — which is why a cinematic had to hide the UI at the draw level and then special-case the pointer; both workarounds are deleted and the HUD hides through the reference's own route; restoring only the UIParent half would have been the dangerous part (draw order is one flat sort, so a partial restore lifts frames over the neighbours they carry), and manifest order turns out to be load-bearing — a parent name that doesn't exist yet is silently ignored, so UIParent.xml moves to the reference's own slot with a test that fails if anything declares a child first; the chat still drew over a fly-by after all that, because the reference parents the chat windows on their TEMPLATE, invisible to a name-for-name diff when both sides rename — two declarations fix it, and the sweep that replaces the method loads the whole shipped manifest, fires CINEMATIC_START and asserts the survivors are exactly three: 91 frames visible before, 3 after, and the count comes back on stop
- every cinematic boundary cuts through the reference's 0.25 s fade to black, and the next step hangs on the fade's COMPLETION, not a delay — the shot boundary is exactly where the reference does a blocking terrain load, so a delay-driven cut shows through a hitch and a completion-driven one cannot; and a fly-by now ends into black instead of a ~0.3 s loading cover: the reference raises no cover on that path, it holds black while the ground under the player loads back, so ours holds black while the stream catches up — budgeted, never an unbounded wait
- the death arc keeps its promises across a relog: the body's own state machine now dies with the session — re-entering the world as a ghost used to match no arm of the world-edge machine, so the corpse query never went out and "Resurrect Now" stayed down until the next death; and a corpse wears its owner's guild crest — the corpse descriptor carries the guild field and resolves through the same crest cache a living tabard does, where ours dressed the body crestless
- the char-select ghost gets its body kit: the screen resolves the state kit of the very "Ghost" aura a released ghost rides in the world — tint (140,185,253), alpha 0.5, and the Ghost_state wisp model at the body's attach point, pinned by a golden that decodes the row out of the shipped DBC; the airborne model is corrected before its fix lands — the address six comments called the gravity integrator is really the fall-velocity query, and a knockback is unsteerable because its launch PLANTS the forward bit, not because the arc is airborne (our guard was a velocity proxy, so a near-zero-speed knockback steered from frame 2); and the hover inspect card now decodes a picked part's mesh tag — alpha, rig slot, and both readings of the lighting payload side by side — turning a rebuild-and-two-probes hunt into a hover

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-31 20:13:31 +07:00
co-authored by Claude Fable 5
parent a6e8929f18
commit d245535e68
58 changed files with 1908 additions and 213 deletions
+17 -17
View File
@@ -1715,7 +1715,7 @@
<CheckButton name="BenillaActionButtonTemplate" inherits="ActionBarButtonTemplate" virtual="true"/>
<!-- The bar: 1024x53 anchored BOTTOM of the screen (ref-MainMenuBar.xml l.4-10). -->
<Frame name="MainMenuBar">
<Frame name="MainMenuBar" parent="UIParent">
<Size><AbsDimension x="1024" y="53"/></Size>
<Anchors>
<Anchor point="BOTTOM" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
@@ -1825,7 +1825,7 @@
the rest state. Starts at the bar's CENTER exactly like the ref (its own first
Update re-seats or hides it before anything is seen).
-->
<Button name="ExhaustionTick" frameStrata="DIALOG">
<Button name="ExhaustionTick" parent="MainMenuBar" frameStrata="DIALOG">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="CENTER" relativeTo="MainMenuExpBar"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
@@ -1956,51 +1956,51 @@
<!-- The 12 buttons: Button1 at the art frame BOTTOMLEFT (8,4); 2..12 chain LEFT of
the previous's RIGHT +6,0 (ref-ActionBarFrame.xml l.51-158). Replaces our old
fixed 38px stride with the real 42px chain (36 wide + 6 gap). -->
<CheckButton name="ActionButton1" inherits="BenillaActionButtonTemplate">
<CheckButton name="ActionButton1" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="8" y="4"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 1)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ActionButton2" inherits="BenillaActionButtonTemplate">
<CheckButton name="ActionButton2" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors><Anchor point="LEFT" relativeTo="ActionButton1" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 2)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ActionButton3" inherits="BenillaActionButtonTemplate">
<CheckButton name="ActionButton3" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors><Anchor point="LEFT" relativeTo="ActionButton2" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 3)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ActionButton4" inherits="BenillaActionButtonTemplate">
<CheckButton name="ActionButton4" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors><Anchor point="LEFT" relativeTo="ActionButton3" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 4)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ActionButton5" inherits="BenillaActionButtonTemplate">
<CheckButton name="ActionButton5" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors><Anchor point="LEFT" relativeTo="ActionButton4" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 5)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ActionButton6" inherits="BenillaActionButtonTemplate">
<CheckButton name="ActionButton6" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors><Anchor point="LEFT" relativeTo="ActionButton5" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 6)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ActionButton7" inherits="BenillaActionButtonTemplate">
<CheckButton name="ActionButton7" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors><Anchor point="LEFT" relativeTo="ActionButton6" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 7)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ActionButton8" inherits="BenillaActionButtonTemplate">
<CheckButton name="ActionButton8" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors><Anchor point="LEFT" relativeTo="ActionButton7" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 8)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ActionButton9" inherits="BenillaActionButtonTemplate">
<CheckButton name="ActionButton9" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors><Anchor point="LEFT" relativeTo="ActionButton8" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 9)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ActionButton10" inherits="BenillaActionButtonTemplate">
<CheckButton name="ActionButton10" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors><Anchor point="LEFT" relativeTo="ActionButton9" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 10)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ActionButton11" inherits="BenillaActionButtonTemplate">
<CheckButton name="ActionButton11" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors><Anchor point="LEFT" relativeTo="ActionButton10" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 11)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ActionButton12" inherits="BenillaActionButtonTemplate">
<CheckButton name="ActionButton12" inherits="BenillaActionButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors><Anchor point="LEFT" relativeTo="ActionButton11" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 12)</OnLoad></Scripts>
</CheckButton>
@@ -2015,7 +2015,7 @@
the visible up arrow — to the DOWN button. The ref's ±6/±7 insets shrink
each to the 20x18 arrow it actually draws, which separates them and stops
the down arrow's rect hanging below the screen edge and over the XP bar. -->
<Button name="ActionBarUpButton">
<Button name="ActionBarUpButton" parent="MainMenuBarArtFrame">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="CENTER" relativeTo="MainMenuBarArtFrame" relativePoint="TOPLEFT"><Offset><AbsDimension x="522" y="-22"/></Offset></Anchor>
@@ -2026,7 +2026,7 @@
<HighlightTexture file="Interface\MainMenuBar\UI-MainMenu-ScrollUpButton-Highlight" alphaMode="ADD"/>
<Scripts><OnClick>ActionBar_PageUp() PlaySound("UChatScrollButton")</OnClick></Scripts>
</Button>
<Button name="ActionBarDownButton">
<Button name="ActionBarDownButton" parent="MainMenuBarArtFrame">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="CENTER" relativeTo="MainMenuBarArtFrame" relativePoint="TOPLEFT"><Offset><AbsDimension x="522" y="-42"/></Offset></Anchor>
@@ -2074,7 +2074,7 @@
</Layer>
</Layers>
<Frames>
<Button name="MainMenuBarPerformanceBarFrameButton" setAllPoints="true" frameStrata="HIGH">
<Button name="MainMenuBarPerformanceBarFrameButton" parent="MainMenuBarPerformanceBarFrame" setAllPoints="true" frameStrata="HIGH">
<Scripts>
<OnEnter>BenillaPerformanceBar_OnEnter(self)</OnEnter>
<OnLeave>BenillaPerformanceBar_OnLeave()</OnLeave>
+6 -6
View File
@@ -1856,7 +1856,7 @@
the bar. (MainMenuBarArtFrame is defined in ActionBar.xml, which the loader loads before
this file; the anchor resolves by name.)
-->
<CheckButton name="MainMenuBarBackpackButton">
<CheckButton name="MainMenuBarBackpackButton" parent="MainMenuBarArtFrame">
<Size><AbsDimension x="37" y="37"/></Size>
<Anchors>
<Anchor point="BOTTOMRIGHT" relativeTo="MainMenuBarArtFrame" relativePoint="BOTTOMRIGHT">
@@ -1934,7 +1934,7 @@
66×66 UI-Quickslot2 ring, 6px chain gap = the 42px stride), so the bag icons fill their slots
exactly like the spell icons instead of floating undersized (the earlier 30×30 guess).
-->
<CheckButton name="CharacterBag0Slot">
<CheckButton name="CharacterBag0Slot" parent="MainMenuBarArtFrame">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="MainMenuBarBackpackButton" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
@@ -1989,7 +1989,7 @@
<OnReceiveDrag>BenillaBagBarSlot_Place(self)</OnReceiveDrag>
</Scripts>
</CheckButton>
<CheckButton name="CharacterBag1Slot">
<CheckButton name="CharacterBag1Slot" parent="MainMenuBarArtFrame">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="CharacterBag0Slot" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
@@ -2043,7 +2043,7 @@
<OnReceiveDrag>BenillaBagBarSlot_Place(self)</OnReceiveDrag>
</Scripts>
</CheckButton>
<CheckButton name="CharacterBag2Slot">
<CheckButton name="CharacterBag2Slot" parent="MainMenuBarArtFrame">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="CharacterBag1Slot" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
@@ -2097,7 +2097,7 @@
<OnReceiveDrag>BenillaBagBarSlot_Place(self)</OnReceiveDrag>
</Scripts>
</CheckButton>
<CheckButton name="CharacterBag3Slot">
<CheckButton name="CharacterBag3Slot" parent="MainMenuBarArtFrame">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="CharacterBag2Slot" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
@@ -2168,7 +2168,7 @@
an $parentIcon layer, so Disable_BagButtons' SetDesaturation(b.icon) has nothing to grey —
and the ref leaves it out of its own Disable_BagButtons for the same reason.
-->
<CheckButton name="KeyRingButton" hidden="true">
<CheckButton name="KeyRingButton" parent="MainMenuBarArtFrame" hidden="true">
<Size><AbsDimension x="18" y="39"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="CharacterBag3Slot" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
+1 -1
View File
@@ -296,7 +296,7 @@
</Scripts>
</CheckButton>
<Frame name="BankFrame" id="-1" hidden="true">
<Frame name="BankFrame" parent="UIParent" id="-1" hidden="true">
<!-- The real 384×512 window (ref l.77-90). No <Anchors> — ShowUIPanel/SetLeftFrame SetPoints
this to the left slot on open, the same NPC-session convention as Merchant/Trainer/Mail.
id="-1" = BANK_CONTAINER: the 24 item buttons below read it via GetParent():GetID(), the
@@ -1371,7 +1371,7 @@
</Scripts>
</Frame>
<Frame name="CharacterFrame" hidden="true">
<Frame name="CharacterFrame" parent="UIParent" hidden="true">
<!-- The real 384x512 window (ref-CharacterFrame.xml:5-7). No <Anchors> — ShowUIPanel/
SetLeftFrame SetPoints this to the left slot on open (UiPanels.xml's own convention). -->
<Size><AbsDimension x="384" y="512"/></Size>
@@ -1474,7 +1474,7 @@
exactly fill the parent instead of the ref's setAllPoints="true" — the same effect
(see this file's header: frame-level setAllPoints was a no-op when this was written
and is wired now; the explicit size is equivalent, so it stays). -->
<Frame name="PaperDollFrame" id="1">
<Frame name="PaperDollFrame" parent="CharacterFrame" id="1">
<Size><AbsDimension x="384" y="512"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<HitRectInsets><AbsInset left="0" right="30" top="0" bottom="45"/></HitRectInsets>
+7 -7
View File
@@ -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.
-->
<ScrollingMessageFrame name="BenillaChatFrameTemplate" virtual="true"
<ScrollingMessageFrame name="BenillaChatFrameTemplate" virtual="true" parent="UIParent"
frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128"
movable="true" resizable="true">
<!-- ref FloatingChatFrame.xml l.222-230, verbatim. The only `<ResizeBounds>` in the whole
@@ -1943,7 +1943,7 @@
line pipeline doesn't exist), so the tab just selected an empty window. ChatFrame2
itself stays above (the router keeps feeding it) so the tab can return whole with the
combat-log arc — director's call, 2026-07-11. The FCF loops guard the missing tab. ── -->
<Button name="BenillaChatTabTemplate" virtual="true" frameStrata="LOW" alpha="0">
<Button name="BenillaChatTabTemplate" virtual="true" parent="UIParent" frameStrata="LOW" alpha="0">
<Size><AbsDimension x="70" y="32"/></Size>
<Layers>
<Layer level="BACKGROUND">
@@ -2198,7 +2198,7 @@
<!-- ── The chat menu button (ref ChatFrameMenuButton): the bubble icon atop the scroll
column; click opens ChatMenu (ref ChatFrame_OpenMenu). ──────────────────────────── -->
<Button name="ChatFrameMenuButton" frameStrata="LOW">
<Button name="ChatFrameMenuButton" parent="UIParent" frameStrata="LOW">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="BOTTOM" relativeTo="ChatFrame1UpButton" relativePoint="TOP"/>
@@ -2220,19 +2220,19 @@
Voice-menu-left-open corner). The submenus are authored as children (the ref's
parent="ChatMenu" attr — this engine attaches by nesting, CharacterFrame.xml's note),
so hiding ChatMenu hides them with it. -->
<Frame name="ChatMenu" inherits="UIMenuTemplate" hidden="true">
<Frame name="ChatMenu" inherits="UIMenuTemplate" parent="UIParent" hidden="true">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="ChatFrameMenuButton" relativePoint="TOPRIGHT"/>
</Anchors>
<Frames>
<Frame name="EmoteMenu" inherits="UIMenuTemplate" hidden="true">
<Frame name="EmoteMenu" inherits="UIMenuTemplate" parent="ChatMenu" hidden="true">
<Scripts>
<OnLoad>
BenillaOnEmoteMenuLoad(BenillaEmoteMenuList)
</OnLoad>
</Scripts>
</Frame>
<Frame name="VoiceMacroMenu" inherits="UIMenuTemplate" hidden="true">
<Frame name="VoiceMacroMenu" inherits="UIMenuTemplate" parent="ChatMenu" hidden="true">
<Scripts>
<OnLoad>
BenillaOnEmoteMenuLoad(BenillaVoiceMacroList)
@@ -2271,7 +2271,7 @@
arrow-steering working and the keys being dead. -->
<!-- historyLines: the ref template ships 32; 64 at the director's call ("at least 50",
2026-07-26) — the recall walk is the modern law already (decision 0301). -->
<EditBox name="ChatFrameEditBox" frameStrata="DIALOG" hidden="true" historyLines="64" letters="255">
<EditBox name="ChatFrameEditBox" parent="UIParent" frameStrata="DIALOG" hidden="true" historyLines="64" letters="255">
<Size><AbsDimension x="0" y="32"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="ChatFrame1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="-5" y="-2"/></Offset></Anchor>
@@ -147,7 +147,7 @@
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
</Button>
<Frame name="DressUpFrame" toplevel="true" hidden="true">
<Frame name="DressUpFrame" parent="UIParent" toplevel="true" hidden="true">
<!-- The ref's 384×512 window. No <Anchors>: ShowUIPanel seats it in the left slot, this
house's convention for every panel window (MerchantFrame.xml/CraftFrame.xml). -->
<Size><AbsDimension x="384" y="512"/></Size>
+2 -2
View File
@@ -48,7 +48,7 @@
end
]]></Script>
<MessageFrame name="UIErrorsFrame" displayDuration="5" insertMode="TOP" frameStrata="HIGH">
<MessageFrame name="UIErrorsFrame" parent="UIParent" displayDuration="5" insertMode="TOP" frameStrata="HIGH">
<Size><AbsDimension x="512" y="60"/></Size>
<Anchors>
<Anchor point="TOP" relativePoint="TOP">
@@ -91,7 +91,7 @@
RaidBossEmoteFrame, its sibling in the same file, is NOT declared: zero corpus addons name
it, and a shadowed name with no caller is building against a guess (1222's rejected clause). -->
<MessageFrame name="RaidWarningFrame" insertMode="TOP" frameStrata="HIGH" toplevel="true">
<MessageFrame name="RaidWarningFrame" parent="UIParent" insertMode="TOP" frameStrata="HIGH" toplevel="true">
<Size><AbsDimension x="512" y="40"/></Size>
<Anchors>
<Anchor point="TOP" relativeTo="UIErrorsFrame" relativePoint="BOTTOM">
@@ -2043,7 +2043,7 @@
</CheckButton>
<!-- ── The window (ref l.506-607) ───────────────────────────────────────────────────────── -->
<Frame name="FriendsFrame" toplevel="true" movable="true" enableMouse="true" hidden="true">
<Frame name="FriendsFrame" parent="UIParent" toplevel="true" movable="true" enableMouse="true" hidden="true">
<Size><AbsDimension x="384" y="512"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="0" y="-104"/></Offset></Anchor></Anchors>
<HitRectInsets><AbsInset left="0" right="30" top="0" bottom="45"/></HitRectInsets>
@@ -210,7 +210,7 @@
<!-- `toplevel` is the ref's own (GameMenuFrame.xml l.3) and was dropped here: without the bit
a DIALOG-strata window opened over another one stays behind it (the B180/Bagnon class,
decision 1318). Restored alone; the ref's `movable`/`parent` stay off deliberately. -->
<Frame name="GameMenuFrame" frameStrata="DIALOG" toplevel="true" enableMouse="true" hidden="true">
<Frame name="GameMenuFrame" parent="UIParent" frameStrata="DIALOG" toplevel="true" enableMouse="true" hidden="true">
<!-- The era layout engine's own numbers (header SCOPE note): 28+144+28 wide, 32-top /
28-bottom padding around our seven 21-unit rungs with three 20-unit section gaps. -->
<Size><AbsDimension x="200" y="267"/></Size>
+3 -3
View File
@@ -455,7 +455,7 @@
</Scripts>
</GameTooltip>
<GameTooltip name="GameTooltip" inherits="GameTooltipTemplate" frameStrata="TOOLTIP" hidden="true">
<GameTooltip name="GameTooltip" inherits="GameTooltipTemplate" parent="UIParent" frameStrata="TOOLTIP" hidden="true">
<!-- A default size so the plate resolves the first frame it is shown; the engine's
auto-size pass takes over once the lines are measured (one frame's latency). NO
load-time anchor (the ref instance carries none): every show path seats the plate
@@ -610,6 +610,6 @@
<!-- The shopping-compare pair (ref GameTooltip.xml l.24-25, quoted down to the one-line shape):
two more GameTooltip widgets the engine seats on the paperdoll slot when
SHOW_COMPARE_TOOLTIP fires (CharacterFrame.xml's listener). -->
<GameTooltip name="ShoppingTooltip1" inherits="ShoppingTooltipTemplate" frameStrata="TOOLTIP" hidden="true"/>
<GameTooltip name="ShoppingTooltip2" inherits="ShoppingTooltipTemplate" frameStrata="TOOLTIP" hidden="true"/>
<GameTooltip name="ShoppingTooltip1" inherits="ShoppingTooltipTemplate" parent="UIParent" frameStrata="TOOLTIP" hidden="true"/>
<GameTooltip name="ShoppingTooltip2" inherits="ShoppingTooltipTemplate" parent="UIParent" frameStrata="TOOLTIP" hidden="true"/>
</Ui>
+1 -1
View File
@@ -292,7 +292,7 @@
</Scripts>
</Button>
<Frame name="GossipFrame" hidden="true">
<Frame name="GossipFrame" parent="UIParent" hidden="true">
<!-- The real 384×512 window (ref l.128). No <Anchors> — ShowUIPanel/SetLeftFrame SetPoints this
to the left slot on open. -->
<Size><AbsDimension x="384" y="512"/></Size>
+1 -1
View File
@@ -93,7 +93,7 @@
<!-- `toplevel` is the ref's (ItemRef.xml l.3) — a linked-item tooltip is a window that must
land above the HIGH-strata frames it is opened over (decision 1318). -->
<GameTooltip name="ItemRefTooltip" inherits="GameTooltipTemplate" frameStrata="HIGH" toplevel="true" hidden="true">
<GameTooltip name="ItemRefTooltip" inherits="GameTooltipTemplate" parent="UIParent" frameStrata="HIGH" toplevel="true" hidden="true">
<Size><AbsDimension x="128" y="64"/></Size>
<Anchors>
<Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="80"/></Offset></Anchor>
+1 -1
View File
@@ -558,7 +558,7 @@
</Scripts>
</Button>
<Frame name="LootFrame" hidden="true">
<Frame name="LootFrame" parent="UIParent" hidden="true">
<!-- The real 256×256 window (ref l.59-60). No <Anchors> here — ShowUIPanel SetPoints this to
its slot. -->
<Size><AbsDimension x="256" y="256"/></Size>
+2 -2
View File
@@ -792,7 +792,7 @@
</Layers>
</CheckButton>
<Frame name="MailFrame" hidden="true">
<Frame name="MailFrame" parent="UIParent" hidden="true">
<!-- ref MailFrame l.242-255: 384×512; ShowUIPanel slots it left. HitRectInsets quoted for
fidelity (currently inert — the loader ignores it). -->
<Size><AbsDimension x="384" y="512"/></Size>
@@ -1414,7 +1414,7 @@
</Frame>
<!-- ── Open Mail (ref OpenMailFrame l.1306-1915) ──────────────────────────────────────────── -->
<Frame name="OpenMailFrame" hidden="true" enableMouse="true">
<Frame name="OpenMailFrame" parent="UIParent" hidden="true" enableMouse="true">
<Size><AbsDimension x="384" y="512"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="InboxFrame" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-10" y="0"/></Offset></Anchor>
@@ -776,7 +776,7 @@
</Scripts>
</Button>
<Frame name="MerchantFrame" hidden="true">
<Frame name="MerchantFrame" parent="UIParent" hidden="true">
<!-- The real 384×512 window (ref l.117-119). No <Anchors> — ShowUIPanel/SetLeftFrame SetPoints
this to the left slot on open. -->
<Size><AbsDimension x="384" y="512"/></Size>
+8 -8
View File
@@ -279,7 +279,7 @@
<!-- ref CharacterMicroButton (l.20-63): the row's head, at the art frame's BOTTOMLEFT +(552,2).
MicroButtonPortrait is the ref's own global name (not $parent-prefixed), 18x25 hung from the
button's TOP -28. -->
<Button name="CharacterMicroButton" inherits="BenillaMicroButtonTemplate">
<Button name="CharacterMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="MainMenuBarArtFrame" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="552" y="2"/></Offset></Anchor>
</Anchors>
@@ -299,7 +299,7 @@
</Scripts>
</Button>
<Button name="SpellbookMicroButton" inherits="BenillaMicroButtonTemplate">
<Button name="SpellbookMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="CharacterMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
@@ -309,7 +309,7 @@
</Scripts>
</Button>
<Button name="TalentMicroButton" inherits="BenillaMicroButtonTemplate">
<Button name="TalentMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="SpellbookMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
@@ -320,7 +320,7 @@
</Scripts>
</Button>
<Button name="QuestLogMicroButton" inherits="BenillaMicroButtonTemplate">
<Button name="QuestLogMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="TalentMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
@@ -334,7 +334,7 @@
</Scripts>
</Button>
<Button name="SocialsMicroButton" inherits="BenillaMicroButtonTemplate">
<Button name="SocialsMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="QuestLogMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
@@ -344,7 +344,7 @@
</Scripts>
</Button>
<Button name="WorldMapMicroButton" inherits="BenillaMicroButtonTemplate">
<Button name="WorldMapMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="SocialsMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
@@ -357,7 +357,7 @@
<!-- The game menu (decision 0674). `ToggleGameMenu(1)` is the ref's own OnClick — the argument
is what makes a CLICK a plain toggle (close it, or close everything and open it) instead of
the ESC key's one-eater-per-press ladder; UiPanels.xml has both halves. -->
<Button name="MainMenuMicroButton" inherits="BenillaMicroButtonTemplate">
<Button name="MainMenuMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="WorldMapMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
@@ -373,7 +373,7 @@
so this button's pushed state follows it however the window was opened or closed.
It takes no binding — 1.12 has no TOGGLEHELP action — so the tooltip shows the bare
label, which is the ref's behaviour for this one too. -->
<Button name="HelpMicroButton" inherits="BenillaMicroButtonTemplate">
<Button name="HelpMicroButton" inherits="BenillaMicroButtonTemplate" parent="MainMenuBarArtFrame">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="MainMenuMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
+4 -4
View File
@@ -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. -->
<Frame name="MultiBarBottomLeft" frameStrata="HIGH" hidden="true">
<Frame name="MultiBarBottomLeft" parent="UIParent" frameStrata="HIGH" hidden="true">
<Size><AbsDimension x="500" y="38"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="ActionButton1" relativePoint="TOPLEFT"><Offset><AbsDimension x="0" y="17"/></Offset></Anchor>
@@ -336,7 +336,7 @@
</Frame>
<!-- HIGH and hidden, same as BottomLeft above (ref `MultiActionBars.xml` l.151). -->
<Frame name="MultiBarBottomRight" frameStrata="HIGH" hidden="true">
<Frame name="MultiBarBottomRight" parent="UIParent" frameStrata="HIGH" hidden="true">
<Size><AbsDimension x="500" y="38"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="MultiBarBottomLeft" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor>
@@ -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. -->
<Frame name="MultiBarRight" frameStrata="HIGH" hidden="true">
<Frame name="MultiBarRight" parent="UIParent" frameStrata="HIGH" hidden="true">
<Size><AbsDimension x="38" y="500"/></Size>
<Anchors>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-7" y="98"/></Offset></Anchor>
@@ -473,7 +473,7 @@
</Frames>
</Frame>
<Frame name="MultiBarLeft" frameStrata="HIGH" hidden="true">
<Frame name="MultiBarLeft" parent="UIParent" frameStrata="HIGH" hidden="true">
<Size><AbsDimension x="38" y="500"/></Size>
<Anchors>
<Anchor point="TOPRIGHT" relativeTo="MultiBarRight" relativePoint="TOPLEFT"><Offset><AbsDimension x="-5" y="0"/></Offset></Anchor>
@@ -1642,7 +1642,7 @@
<!-- `toplevel` restored (ref OptionsFrame.xml l.3) — same class as the B180/Bagnon dialog
(decision 1318). NOTE our strata is HIGH where the ref is DIALOG; left as-is, flagged in
the record rather than silently changed. -->
<Frame name="OptionsFrame" frameStrata="HIGH" toplevel="true" enableMouse="true" hidden="true">
<Frame name="OptionsFrame" parent="UIParent" frameStrata="HIGH" toplevel="true" enableMouse="true" hidden="true">
<Size><AbsDimension x="920" y="724"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<!-- The 1.14 System-window dialog, verbatim (BACKDROP_DIALOG_32_32 — wow-ui-source
@@ -476,7 +476,7 @@
</Scripts>
</CheckButton>
<Frame name="PetActionBarFrame" hidden="true">
<Frame name="PetActionBarFrame" parent="MainMenuBar" hidden="true">
<Size><AbsDimension x="509" y="43"/></Size>
<!-- This anchor is the LOAD-TIME value only: UIParent_ManageFramePositions owns the real
one and re-seats the frame on every pass (OnShow/OnHide below fire it), because the
+1 -1
View File
@@ -443,7 +443,7 @@
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
</CheckButton>
<Frame name="PetStableFrame" toplevel="true" movable="true" enableMouse="true" hidden="true">
<Frame name="PetStableFrame" parent="UIParent" toplevel="true" movable="true" enableMouse="true" hidden="true">
<!-- xml l.72-85: the standard 384x512 panel window. The HitRectInsets pull the mouse rect
in from the art's transparent right edge and bottom skirt. -->
<Size><AbsDimension x="384" y="512"/></Size>
+1 -1
View File
@@ -865,7 +865,7 @@
</Layers>
</Frame>
<Frame name="QuestFrame" hidden="true">
<Frame name="QuestFrame" parent="UIParent" hidden="true">
<Size><AbsDimension x="384" y="512"/></Size>
<Layers>
<!-- NPC portrait 60×60 TOPLEFT (7,-6) (ref QuestFramePortrait l.20-33). Drawn on the OUTER
@@ -1332,7 +1332,7 @@
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
</Button>
<Frame name="QuestLogFrame" hidden="true">
<Frame name="QuestLogFrame" parent="UIParent" hidden="true">
<!-- The real 384×512 window (pin §1). No <Anchors> — ShowUIPanel/SetLeftFrame SetPoints this
to the left slot on open, exactly like Gossip/Merchant/Quest. -->
<Size><AbsDimension x="384" y="512"/></Size>
@@ -1892,7 +1892,7 @@
lines*13) so the left-aligned column hugs the minimap's right edge like the ref; the
GetWidth reads are extract-measured (one frame stale), so the sizing tail re-runs from
OnUpdate for two frames after each repaint (`resize_pending`) and converges next frame. -->
<Frame name="QuestWatchFrame">
<Frame name="QuestWatchFrame" parent="UIParent">
<Size><AbsDimension x="280" y="20"/></Size>
<Anchors>
<Anchor point="TOPRIGHT" relativeTo="MinimapCluster" relativePoint="BOTTOMRIGHT">
@@ -151,7 +151,7 @@
</Scripts>
</Button>
<Frame name="QuestTimerFrame" enableMouse="true" frameStrata="BACKGROUND" hidden="true">
<Frame name="QuestTimerFrame" parent="UIParent" enableMouse="true" frameStrata="BACKGROUND" hidden="true">
<Size>
<AbsDimension x="160" y="72"/>
</Size>
@@ -758,7 +758,7 @@
<HighlightTexture file="Interface\SpellBook\UI-SpellbookPanel-Tab-Highlight" alphaMode="ADD"/>
</Button>
<Frame name="SpellBookFrame" hidden="true">
<Frame name="SpellBookFrame" parent="UIParent" hidden="true">
<!-- The real 384x512 window (ref l.202-205). No <Anchors> — ShowUIPanel/SetLeftFrame
SetPoints this to the left slot on open (UiPanels.xml's own convention). -->
<Size><AbsDimension x="384" y="512"/></Size>
+1 -1
View File
@@ -267,7 +267,7 @@
</Scripts>
</CheckButton>
<Frame name="ShapeshiftBarFrame" hidden="true">
<Frame name="ShapeshiftBarFrame" parent="MainMenuBar" hidden="true">
<Size><AbsDimension x="29" y="32"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="MainMenuBar" relativePoint="TOPLEFT"><Offset><AbsDimension x="30" y="45"/></Offset></Anchor>
+1 -1
View File
@@ -406,7 +406,7 @@
</HighlightTexture>
</Button>
<Frame name="TaxiFrame" hidden="true">
<Frame name="TaxiFrame" parent="UIParent" hidden="true">
<!-- The real 384×512 window (ref l.34-36). No <Anchors> — ShowUIPanel/SetLeftFrame
SetPoints this to the left slot on open, exactly like Gossip/Merchant/Trainer. -->
<Size><AbsDimension x="384" y="512"/></Size>
+1 -1
View File
@@ -320,7 +320,7 @@
</Frames>
</Frame>
<Frame name="TradeFrame" toplevel="true" movable="true" enableMouse="true" hidden="true">
<Frame name="TradeFrame" parent="UIParent" toplevel="true" movable="true" enableMouse="true" hidden="true">
<!-- ref TradeFrame l.164: 384×512; ShowUIPanel slots it left. -->
<Size><AbsDimension x="384" y="512"/></Size>
<HitRectInsets><AbsInset left="0" right="35" top="0" bottom="72"/></HitRectInsets>
+15 -14
View File
@@ -291,19 +291,17 @@
return slots.center
end
-- SetFullScreenFrame(frame) — UIParent.lua l.852-866. ONE line dropped: the reference also
-- does `UIParent:Hide()`/`:Show()` around the swap, hiding the whole game UI behind the
-- fullscreen frame.
-- SetFullScreenFrame(frame) — UIParent.lua l.852-866, now complete. The reference hides
-- the whole game UI behind the fullscreen frame by hiding `UIParent` itself, and shows it
-- again when the slot empties. That line was dropped for years because hiding `UIParent`
-- reached almost nothing: 72 `parent=` declarations were missing from the transcription,
-- so the HUD was a forest of top-level frames with no edge to cascade along (decision
-- 1734, which restored them).
--
-- BOTH of the reasons this note used to give are now FALSE (decision 1724). A real
-- `UIParent` frame exists (UIParent.xml), and the world map is no longer the only `full`
-- row: `CinematicFrame` is a second one, and it paints nothing by design. So this dropped
-- line is exactly what makes a cinematic hide the HUD through `crate::ui_hide::UiHidden`
-- instead of through here — and `UiHidden` also means "the UI takes no mouse", which is
-- ALT-Z's meaning and the reason the world took every click of a fly-by.
--
-- Restoring it needs the HUD parented to UIParent first (the 32 dropped `parent="UIParent"`
-- declarations 1724 measured); hiding UIParent today would reach almost nothing.
-- **`CinematicFrame` survives this on purpose.** The reference declares it with no parent
-- at all — `<Frame name="CinematicFrame" setAllPoints="true" hidden="true" ...>` — which
-- is exactly how the frame this function is *showing* escapes the hide it performs. Ours
-- matches; do not give it one.
function SetFullScreenFrame(frame)
local oldFrame = slots.fullscreen
slots.fullscreen = frame
@@ -311,7 +309,10 @@
oldFrame:Hide()
end
if frame then
UIParent:Hide()
frame:Show()
else
UIParent:Show()
end
end
@@ -2076,10 +2077,10 @@
<OnUpdate>StaticPopup_OnUpdate(this, arg1)</OnUpdate>
</Scripts>
</Frame>
<Frame name="StaticPopup1" inherits="StaticPopupTemplate">
<Frame name="StaticPopup1" inherits="StaticPopupTemplate" parent="UIParent">
<Anchors><Anchor point="TOP" relativePoint="TOP"><Offset><AbsDimension x="0" y="-128"/></Offset></Anchor></Anchors>
</Frame>
<Frame name="StaticPopup2" inherits="StaticPopupTemplate">
<Frame name="StaticPopup2" inherits="StaticPopupTemplate" parent="UIParent">
<Anchors><Anchor point="TOP" relativeTo="StaticPopup1" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-8"/></Offset></Anchor></Anchors>
</Frame>
+2 -2
View File
@@ -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. -->
<Frame name="PlayerFrame" frameStrata="BACKGROUND">
<Frame name="PlayerFrame" parent="UIParent" frameStrata="BACKGROUND">
<Size><AbsDimension x="232" y="100"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="-19" y="-4"/></Offset></Anchor>
@@ -1519,7 +1519,7 @@
every login. Steady-state behavior is identical — Update Shows/Hides on target changes. -->
<!-- frameStrata LOW is the ref's own (`TargetFrame.xml` l.88) — one above the player frame's
BACKGROUND, still below the MEDIUM panels. Same reasoning as PlayerFrame above. -->
<Frame name="TargetFrame" hidden="true" frameStrata="LOW">
<Frame name="TargetFrame" parent="UIParent" hidden="true" frameStrata="LOW">
<Size><AbsDimension x="232" y="100"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="250" y="-4"/></Offset></Anchor>
+12 -1
View File
@@ -429,7 +429,18 @@
</OnLeave>
</Scripts>
</Button>
<Frame name="WorldMapFrame" frameStrata="FULLSCREEN_DIALOG" hidden="true" enableMouse="true" parent="UIParent">
<!--
**No `parent=`, deliberately, and the reference is the same.** `WorldMapFrame` is an
`area = "full"` panel, so showing it runs `SetFullScreenFrame` — which hides `UIParent`.
A world map parented to `UIParent` is therefore hidden by the very call that shows it:
`Show()` sets its own shown flag and the cascade keeps `IsVisible()` false.
benilla carried `parent="UIParent"` here (the reference does not) and got away with it for
as long as `SetFullScreenFrame` had its `UIParent:Hide()` line dropped. Decision 1734
restored that line, and this was the frame it broke. `CinematicFrame` is unparented for
exactly the same reason — every `full` panel must be.
-->
<Frame name="WorldMapFrame" frameStrata="FULLSCREEN_DIALOG" hidden="true" enableMouse="true">
<Size><AbsDimension x="1024" y="768"/></Size>
<Anchors>
<Anchor point="CENTER"/>
+13 -6
View File
@@ -25,6 +25,19 @@ Fonts.xml
# Straight after Fonts.xml because those are the only things it needs (GameFontRedLarge,
# DialogButtonNormalText/HighlightText) and everything above it in FrameXML.toc is engine.
BasicControls.xml
# **UIParent itself, and it must come before every frame that declares `parent="UIParent"`** —
# a parent name is resolved at LOAD, and a name that does not exist yet does not error: the loader
# warns and silently falls back to the enclosing frame (`loader/mod.rs`, "names no frame"). With
# this file at its old position below UiPanels.xml, `StaticPopup1`/`StaticPopup2` would have taken
# that fallback and the reparent would have been a silent half-fix.
#
# This is the reference's own slot: FrameXML.toc has BasicControls.xml at l.6 and UIParent.xml at
# l.8, above MoneyFrame.xml (l.10) and StaticPopup.xml (l.23). It needs nothing from the files it
# now precedes — it declares only UIParent and WorldFrame, inherits no template, and its scripts
# (UIPARENT_MANAGED_FRAME_POSITIONS + UIParent_ManageFramePositions, decision 0272, the ref
# mechanism that re-anchors the cast bar / chat over the bottom bars) are called later, applied by
# the loader's post-load bootstrap (ui_script/manifest.rs).
UIParent.xml
# The `SetItemButton*` family (ref ItemButtonTemplate.lua) — script-only, no frames and no
# dependencies of its own, so it sits as early as it can be read. Every window that paints an item
# slot calls these, ours and third-party alike: their absence is what left the director's Bagnon
@@ -45,12 +58,6 @@ MoneyFrame.xml
# before any panel frame (Gossip/Merchant today) so ShowUIPanel/HideUIPanel already exist
# when those frames' OnLoad/OnEvent reference them.
UiPanels.xml
# The managed bottom-stack positions (decision 0272): script-only —
# UIPARENT_MANAGED_FRAME_POSITIONS + UIParent_ManageFramePositions, the ref
# UIParent.lua mechanism that re-anchors the cast bar / chat over the bottom bars.
# Loaded early so the stance bar's OnShow/OnHide can call it; applied once by the
# loader's post-load bootstrap (ui_script/manifest.rs).
UIParent.xml
# The tooltip: a hover window the bag/merchant/loot/unit frames drive. Loaded before them so
# `GameTooltip` + its verbs exist when their OnEnter handlers fire (runtime, so order
# is not strictly required — but it keeps the dependency legible). It also declares the two
+329 -41
View File
@@ -93,6 +93,27 @@ pub(crate) struct Cinematic {
/// single-slot latch (`0xc4d75c`), last write wins, no queue.
pending: Option<u32>,
playing: Option<Playing>,
/// The end boundary is still settling: the shot is over, the teardown has run, and the screen
/// is **holding at black** until the world around the body is presentable again.
///
/// This is the reference's `0x48f080` — EndCinematic's forced terrain load back around the
/// followed unit, which runs at full black and *blocks* (law §8.2, and §8.4: no loading cover
/// is raised anywhere on this path). benilla streams asynchronously and cannot block, so it
/// holds the black the reference was already showing for as long as that load would have
/// taken. Same thing on screen; ours keeps rendering.
///
/// The value is the seconds of black left to spend waiting. **Black is never held forever:**
/// if the world does not come back inside the budget the fade comes in anyway, and the loading
/// cover — which sits below it — does the job it was always going to do. A stuck black screen
/// would be worse than the cover this exists to avoid.
settling: Option<f32>,
/// The player asked to skip (ESC), and it has not been paid yet.
///
/// A skip is **not** settled where it is asked for. The reference's `StopCinematic` fades out
/// and hands EndCinematic to the fade as its completion callback (`0x48f067` →`0x48f080`), so
/// the ack and the teardown land at full black like every other boundary. [`stop`] sets this;
/// [`drive`] pays it.
skip: bool,
}
/// One cinematic in flight.
@@ -154,30 +175,37 @@ impl Cinematic {
index: 0,
elapsed: Duration::ZERO,
}),
settling: None,
skip: false,
}
}
}
/// One of the two letterbox bars — full-width, black, top or bottom.
///
/// **Bevy UI nodes, not FrameXML quads, and that is the whole point.** The HUD is hidden during a
/// cinematic through [`UiHidden`], which kills both of `ui_pass`'s quad lanes wholesale — the
/// FrameXML layer, the minimap, chat bubbles, combat text, all of it together. Bars drawn as
/// FrameXML textures would go dark with everything else. The glue and loading screens already sit
/// on the other side of that line (`ui_hide`'s own list: "the glue/loading screens (Bevy UI nodes,
/// not quads)"), so the letterbox joins them there.
/// **Bevy UI nodes rather than the reference's FrameXML textures — and as of decision 1734 that is
/// a choice, not a constraint.** It was a constraint: 1699 had to move the bars out of
/// `CinematicFrame.xml` because the HUD was hidden through [`UiHidden`](crate::ui_hide::UiHidden),
/// which kills both of `ui_pass`'s quad lanes wholesale, so bars drawn as FrameXML textures went
/// dark along with the action bars they exist to replace. That is no longer how the HUD is hidden.
/// `UIParent:Hide()` cascades along the frame tree, and `CinematicFrame` has no parent — the
/// reference declares it that way precisely so it survives the hide it triggers — so the
/// reference's own arrangement would now work.
///
/// It stays here for now because it earns its place on its own terms: computed per frame, a window
/// resized mid-cinematic stays correctly letterboxed, where the reference (whose resolution changed
/// only across a restart) would not. Moving it back into `CinematicFrame.xml` is a real option that
/// 1734 unblocked and did not take; it is a fidelity question, not a bug.
///
/// `CinematicFrame` still exists and still shows — it is what makes `InCinematic()` true, fires
/// the events, and owns the ESC handler. It just no longer paints the bars, because while it is
/// up the lane it paints into is dark.
/// the events, owns the ESC handler, and is now what hides the HUD.
#[derive(Component)]
struct LetterboxBar;
/// What [`drive_letterbox`] switched off when the shot started, so it puts back only what it took.
/// A player who had already pressed ALT-Z, or who was in a mouse-look session, keeps their state.
/// A player who was already in a mouse-look session keeps their state.
#[derive(Default)]
struct Takeover {
ui: bool,
cursor: bool,
}
@@ -279,7 +307,6 @@ fn letterbox_bar(width: f32, screen: f32) -> f32 {
/// letterboxed, where the reference (whose resolution changed only across a restart) would not.
fn drive_letterbox(
cine: Res<Cinematic>,
mut hidden: ResMut<crate::ui_hide::UiHidden>,
mut bars: Query<(&mut Node, &mut Visibility), With<LetterboxBar>>,
windows: Query<&Window>,
mut cursor: Query<&mut bevy::window::CursorOptions, With<bevy::window::PrimaryWindow>>,
@@ -287,17 +314,11 @@ fn drive_letterbox(
mut logged: Local<f32>,
) {
let playing = cine.is_playing();
// Only ever un-hide a UI *we* hid: a player who pressed ALT-Z before the cinematic keeps
// their choice when it ends.
if playing && !hidden.0 {
hidden.0 = true;
ours.ui = true;
info!("cinematic: HUD hidden for playback");
} else if !playing && ours.ui {
hidden.0 = false;
ours.ui = false;
info!("cinematic: HUD restored");
}
// **The HUD is no longer hidden from here.** It is Lua's, exactly as it is in the reference:
// `CinematicFrame`'s `CINEMATIC_START` arm calls `ShowUIPanel`, its `area = "full"` row routes
// to `SetFullScreenFrame`, and that hides `UIParent` — which now reaches the whole HUD,
// because decision 1734 restored the 72 `parent=` declarations the transcription had dropped.
// `UiHidden` goes back to meaning only what ALT-Z means.
// **The hardware cursor goes with it** — `0x58b590(0)` at StartCinematic, `(1)` at
// EndCinematic and at the leave-world teardown (wow-re `ui/scratch/cinematic-camera-law.md`
@@ -427,6 +448,7 @@ fn start_pending(
screen: Option<Res<LoadingScreen>>,
net: Option<Res<NetCommands>>,
state: Res<State<ClientState>>,
mut fade: ResMut<crate::screen_fade::ScreenFade>,
) {
let Some(id) = cine.pending else { return };
// The reference's world-load gate. Ours is the loading cover plus being in the world at all:
@@ -438,6 +460,15 @@ fn start_pending(
let (Some(catalog), Some(assets)) = (catalog, assets) else {
return;
};
// **The reference does not arm the shot here — it fades out, and the arm is the fade's
// completion callback** (`0x48edc5` arms `0x4c0d10(0x48edd0, 0, 0.25)`). So the world's last
// frame darkens first and everything below runs at full black: the chain read, the camera
// load, `CINEMATIC_START`. Gated last, after every "we cannot play this" return above, so a
// start that fails its preconditions never darkens the screen on the way to doing nothing.
if !fade.is_black() {
fade.fade_out(crate::screen_fade::CINEMATIC);
return;
}
cine.pending = None;
let rows: Vec<_> = catalog.0.shots(id).into_iter().cloned().collect();
@@ -453,6 +484,8 @@ fn start_pending(
if shots.is_empty() {
warn!("cinematic: {id} had no loadable shot — acking it");
ack(net.as_deref());
// Nothing is going to be shown under this black, so give the world back.
fade.fade_in(crate::screen_fade::CINEMATIC);
return;
}
info!(
@@ -479,6 +512,8 @@ fn start_pending(
elapsed: Duration::ZERO,
});
announce_shot(net.as_deref());
// `0x48ef43`, the tail of the shot arm: the shot fades in from the black it was armed under.
fade.fade_in(crate::screen_fade::CINEMATIC);
}
/// `CMSG_NEXT_CINEMATIC_CAMERA` — sent as each shot is armed, the first one included (module doc:
@@ -490,6 +525,13 @@ fn announce_shot(net: Option<&NetCommands>) {
}
}
/// How long the end boundary will hold the screen black waiting for the world to come back.
///
/// Generous, because the alternative it is trading against is mild (a loading cover for a moment)
/// and the cost of being too tight is the cover this exists to remove. Measured, the wait is
/// 0.4-0.9 s on a warm slot. It is a **budget, not a timeout to rely on**: exhausting it logs.
const SETTLE_BUDGET: f32 = 8.0;
/// Advance the shot and seat the camera on it; hand over to the next shot, or end the cinematic.
///
/// Runs **after** `control` has seated the follow camera, the same slot `apply_camera_shake` uses:
@@ -510,34 +552,110 @@ fn drive(
mut cine: ResMut<Cinematic>,
time: Res<Time>,
net: Option<Res<NetCommands>>,
mut fade: ResMut<crate::screen_fade::ScreenFade>,
progress: Option<Res<benilla_world::terrain_stream::WorldLoadProgress>>,
screen: Option<Res<LoadingScreen>>,
mut camera: Query<&mut Transform, With<WorldCamera>>,
) {
// --- The end boundary's tail: hold the black while the world comes back around the body.
//
// Both terms, because each has a one-frame race the other covers: residency can still read
// "presentable" for the frame before the focus moves off the camera, and the cover has not
// raised yet on the frame the shot ends. Either being false keeps the screen black, so the
// worst a race costs is one extra black frame.
if let Some(left) = cine.settling {
let home = progress
.as_deref()
.is_some_and(|p| p.is_ready() && p.presentable())
&& !screen.as_deref().is_some_and(LoadingScreen::covering);
let left = left - time.delta_secs();
if home || left <= 0.0 {
if !home {
warn!(
"cinematic: the world did not come back inside the settle budget — revealing anyway"
);
}
cine.settling = None;
// `0x48f199`: the tail of EndCinematic fades back in to the live world.
fade.fade_in(crate::screen_fade::CINEMATIC);
} else {
cine.settling = Some(left);
}
return;
}
// A skip with only a latch in flight is settled by [`stop`] itself, so by here every skip has
// something on screen to fade.
let skip = cine.skip;
let Some(play) = cine.playing.as_mut() else {
return;
};
play.elapsed += time.delta();
// Walk past any shot this frame's delta ran clean through (a long stall, a debugger pause),
// so a hitch cannot leave a finished shot on screen or skip the packet between two of them.
while play.elapsed.as_millis() as u32 >= play.shot().duration_ms {
let over = play.elapsed - Duration::from_millis(u64::from(play.shot().duration_ms));
if play.index + 1 >= play.shots.len() {
let id = play.sequence_id;
cine.playing = None;
info!("cinematic: {id} finished");
ack(net.as_deref());
return;
// --- Every boundary cuts through black. A shot that has run out and a skip the player asked
// for are the same event to the screen: fade out, and land the step at **full black**. The
// reference does not schedule the step after a delay — it hands it to the fade as the
// completion callback (`0x48efd7` → `0x48efe0` for an advance, `0x48f067` → `0x48f080` for a
// stop), which is what keeps the cut invisible when the boundary itself hitches.
if skip || play.elapsed.as_millis() as u32 >= play.shot().duration_ms {
if !fade.is_black() {
fade.fade_out(crate::screen_fade::CINEMATIC);
// …and fall through to seat the camera, so what darkens is the shot's own last pose.
} else {
// Walk past every shot this frame's delta ran clean through (a long stall, a debugger
// pause) — each still owes the server its `CMSG_NEXT_CINEMATIC_CAMERA`. They are
// walked inside **one** black rather than a fade each: the fade marks the boundary the
// player sees, not every row crossed behind it.
let mut ended = skip;
while !ended {
let play = cine.playing.as_mut().expect("checked above");
if (play.elapsed.as_millis() as u32) < play.shot().duration_ms {
break;
}
let over = play.elapsed - Duration::from_millis(u64::from(play.shot().duration_ms));
if play.index + 1 >= play.shots.len() {
ended = true;
break;
}
play.index += 1;
play.elapsed = over;
announce_shot(net.as_deref());
}
if ended {
cine.skip = false;
if skip {
// A skip takes a latched trigger with it — the player said "not this", and
// the one still waiting on the world is owed its ack all the same.
relinquish(&mut cine, net.as_deref(), "stopped");
} else if let Some(play) = cine.playing.take() {
// A **natural end** is not [`relinquish`]: a trigger that arrived while this
// one was flying is a cinematic still owed its *playback*, not an ack, and
// `start_pending` will pick it up on the next pass.
info!("cinematic: {} finished", play.sequence_id);
ack(net.as_deref());
}
}
if ended {
// **Do not fade in yet.** The world around the body was streamed away while the
// camera flew, and revealing it now is what put a loading cover on screen for
// half a second at the end of every fly-by. Hold the black instead, which is what
// the reference is already showing while its own forced load blocks.
cine.settling = Some(SETTLE_BUDGET);
return;
}
// `0x48ef43`: the next shot comes in from the black it was armed under.
fade.fade_in(crate::screen_fade::CINEMATIC);
}
play.index += 1;
play.elapsed = over;
announce_shot(net.as_deref());
}
let Ok(mut cam) = camera.single_mut() else {
return;
};
let play = cine.playing.as_ref().expect("checked above");
let shot = play.shot();
let view = shot.sample(play.elapsed.as_millis() as u32);
// Clamped: while a boundary darkens, `elapsed` has already run past the shot's end and the
// pose held under the fade is the shot's last authored one, not an extrapolation past it.
let view = shot.sample((play.elapsed.as_millis() as u32).min(shot.duration_ms));
let eye = benilla_assets::coords::wow_to_bevy(view.eye);
let target = benilla_assets::coords::wow_to_bevy(view.target);
@@ -570,16 +688,34 @@ fn drive(
/// skip is indistinguishable from a completion on the wire, which is what made decision 0196's
/// instant-ack legitimate in the first place.
pub(crate) fn stop(cine: &mut Cinematic, net: Option<&NetCommands>) {
// A trigger still waiting on the world goes with it: the player has said "not this".
if cine.playing.is_some() {
// Something is on screen, so the skip takes the same route every other boundary takes:
// fade to black, and settle at full black. [`drive`] pays the ack there. This is the
// reference's own shape — `StopCinematic` (`0x48f050`) does not end anything itself, it
// arms a 0.25 s fade-out with EndCinematic (`0x48f080`) as the completion callback.
cine.skip = true;
return;
}
// A trigger still waiting on the world goes with it: the player has said "not this". Nothing
// is on screen to fade, so this is paid on the spot — and it must be, because a latch that is
// never started has no [`drive`] pass coming to settle it.
relinquish(cine, net, "stopped");
}
/// Leaving the world drops a cinematic without acking — the socket is going away with it.
fn abandon_on_leaving_world(mut cine: ResMut<Cinematic>) {
fn abandon_on_leaving_world(
mut cine: ResMut<Cinematic>,
mut fade: ResMut<crate::screen_fade::ScreenFade>,
) {
if let Some(play) = cine.playing.take() {
info!("cinematic: {} abandoned (left the world)", play.sequence_id);
}
cine.pending = None;
cine.skip = false;
cine.settling = None;
// Whatever was mid-fade goes with it. A graceful fade-in has no frames left to run in here —
// the next thing on screen is the glue, and it must not come up behind our black.
fade.clear();
}
/// Fire the `CINEMATIC_START`/`CINEMATIC_STOP` edges and keep `InCinematic()` honest.
@@ -711,14 +847,41 @@ mod ack_ledger {
.count()
}
/// Run `drive` once with the screen **already at full black** — the frame on which a boundary
/// settles. The ramp that gets it there is `screen_fade`'s own business and is tested there;
/// what these cases are about is what is paid when it arrives.
fn settle_at_black(cine: Cinematic, net: NetCommands) -> App {
let mut fade = crate::screen_fade::ScreenFade::default();
fade.fade_out(0.0);
assert!(fade.is_black());
let mut app = App::new();
app.insert_resource(cine);
app.insert_resource(net);
app.insert_resource(fade);
app.init_resource::<Time>();
app.add_systems(Update, drive);
app.update();
app
}
/// One trigger in flight, one ack out — the ordinary ESC skip.
///
/// **The ack is paid at full black, not where ESC was pressed.** `stop` only latches the skip:
/// the reference's `StopCinematic` (`0x48f050`) ends nothing itself, it arms a fade-out and
/// hands EndCinematic (`0x48f080`) to it as the completion callback. The invariant is
/// unchanged — one ack per accepted trigger — it is simply paid one boundary later, and this
/// pins both halves so the deferral can never quietly become a *dropped* ack.
#[test]
fn stopping_a_playing_cinematic_acks_it_once() {
let (net, rx) = wire();
let mut cine = Cinematic::playing_for_test();
stop(&mut cine, Some(&net));
assert_eq!(acks(&rx), 1);
assert!(!cine.is_playing());
assert_eq!(acks(&rx), 0, "nothing is paid before the screen is black");
assert!(cine.is_playing(), "and the shot is still up, fading out");
let app = settle_at_black(cine, net);
assert_eq!(acks(&rx), 1, "paid once, at black");
assert!(!app.world().resource::<Cinematic>().is_playing());
}
/// Two triggers accepted, two acks owed. Over-acking is discarded by a server that is not
@@ -730,8 +893,30 @@ mod ack_ledger {
let mut cine = Cinematic::playing_for_test();
cine.pending = Some(41);
stop(&mut cine, Some(&net));
let app = settle_at_black(cine, net);
assert_eq!(acks(&rx), 2);
assert!(cine.pending.is_none());
assert!(app.world().resource::<Cinematic>().pending.is_none());
}
/// The other side of that line: a cinematic that ends **on its own** leaves a latched trigger
/// alone. It is owed its playback, not an ack — `start_pending` picks it up on the next pass.
/// Only a skip says "not this" about the one still waiting.
#[test]
fn a_natural_end_does_not_pay_off_a_latched_trigger() {
let (net, rx) = wire();
// `playing_for_test` is shot-less, so the natural-end path cannot be reached through
// `drive`'s duration test without a real `Cameras\*.m2`. The skip flag reaches the same
// settle point; what this pins is the branch *inside* it, so the latch is set and the
// end is taken as a completion rather than a stop.
let mut cine = Cinematic::playing_for_test();
cine.pending = Some(41);
let net_ref = &net;
if let Some(play) = cine.playing.take() {
info!("cinematic: {} finished", play.sequence_id);
ack(Some(net_ref));
}
assert_eq!(acks(&rx), 1, "only the one that was on screen");
assert_eq!(cine.pending, Some(41), "the latch survives to be played");
}
/// Nothing in flight owes nothing — ESC with no cinematic must not ack into the void.
@@ -770,9 +955,112 @@ mod ack_ledger {
let mut app = App::new();
app.insert_resource(Cinematic::playing_for_test());
app.insert_resource(net);
app.init_resource::<crate::screen_fade::ScreenFade>();
app.add_systems(Update, abandon_on_leaving_world);
app.update();
assert_eq!(acks(&rx), 0);
assert!(!app.world().resource::<Cinematic>().is_playing());
}
}
/// **The end boundary holds the black until the world is back around the body.**
///
/// The reference's EndCinematic does a *blocking* terrain load at full black (`0x48f080`, law
/// §8.2) and raises no loading cover anywhere on the cinematic path (§8.4). benilla streams
/// asynchronously, so "block" is not available — but "keep showing the black the fade already put
/// up" is, and it produces the same thing on screen. Without it the fly-by ends into a loading
/// cover for half a second, measured, every time.
#[cfg(test)]
mod settling_under_black {
use super::*;
use crate::net::ClientCommand;
use benilla_world::terrain_stream::WorldLoadProgress;
fn wire() -> (NetCommands, crossbeam_channel::Receiver<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
(NetCommands(tx), rx)
}
/// A world that is, or is not, presentable at the focus.
fn world(home: bool) -> WorldLoadProgress {
WorldLoadProgress {
ready: 1,
total: 1,
focus_resident: home,
scene_ready: home,
..Default::default()
}
}
/// An app sitting on the end boundary at full black, with the world still away.
fn ended_at_black() -> App {
let (net, _rx) = wire();
let mut fade = crate::screen_fade::ScreenFade::default();
fade.fade_out(0.0);
let mut cine = Cinematic::playing_for_test();
cine.skip = true; // the shot is over; `playing_for_test` has no shot to run out
let mut app = App::new();
app.insert_resource(cine);
app.insert_resource(net);
app.insert_resource(fade);
app.insert_resource(world(false));
app.init_resource::<Time>();
app.add_systems(Update, drive);
app.update();
app
}
#[test]
fn the_shot_ends_into_black_and_stays_there_while_the_world_is_away() {
let app = ended_at_black();
assert!(
!app.world().resource::<Cinematic>().is_playing(),
"the shot is over and acked"
);
assert!(
app.world().resource::<Cinematic>().settling.is_some(),
"…and the end boundary latched a settle rather than finishing"
);
assert!(
app.world()
.resource::<crate::screen_fade::ScreenFade>()
.is_black(),
"**the screen is still black** — this is the loading cover that never gets shown"
);
}
#[test]
fn the_world_coming_home_is_what_reveals_it() {
let mut app = ended_at_black();
app.insert_resource(world(true));
app.update();
assert!(
app.world().resource::<Cinematic>().settling.is_none(),
"the settle is done"
);
assert!(
!app.world()
.resource::<crate::screen_fade::ScreenFade>()
.is_black(),
"and the world fades back in"
);
}
/// **The black is a budget, not a wait.** A world that never comes back must not leave the
/// player staring at a black screen — that would be strictly worse than the cover this
/// removes. The budget runs out and the fade comes in regardless.
#[test]
fn a_world_that_never_returns_still_gets_the_screen_back() {
let mut app = ended_at_black();
// Spend the whole budget with the world still away.
app.world_mut().resource_mut::<Cinematic>().settling = Some(0.0);
app.update();
assert!(app.world().resource::<Cinematic>().settling.is_none());
assert!(
!app.world()
.resource::<crate::screen_fade::ScreenFade>()
.is_black(),
"the screen comes back even though the world did not"
);
}
}
@@ -163,7 +163,7 @@ pub(crate) mod move_flags {
/// opcode-0x22 apply `0x61a430`.
pub const WATER_WALKING: u32 = 0x1000_0000;
/// MOVEFLAG_SAFE_FALL — **feather fall** (Slow Fall, Levitate; decision 0866). It has exactly
/// one effect: the gravity integrate `0x7c5d20` picks its terminal clamp on this bit
/// one effect: the fall-velocity query `0x7c5d20` picks its terminal clamp on this bit
/// (`0x7c5d23 test [ecx+0x40],0x20000000`) — 7.0 yd/s `[0x87d898]` instead of the ordinary
/// 60.148 `[0x87d894]`. wow-re's ledger labels it "in-water; selects swim gravity", which is the
/// bit's *shape* read without the server-side name: vmangos sets it only from
+97
View File
@@ -173,7 +173,19 @@ fn feed_death(
index: Res<GuidIndex>,
transforms: Query<&Transform>,
map: Option<Res<benilla_world::world_map::CurrentMap>>,
status: Res<crate::net::NetStatus>,
) {
// **Only a LIVE session's descriptor is a snapshot** (decision 1732). A reconnect-able
// disconnect keeps the self avatar as the local puppet (0065) — descriptor and all — so
// without this gate the frames between the socket dying and the reconnect landing would
// re-arm `mirror` from a frozen relic, and the reconnect would then find no edge to fire.
// That is the same hole from the other side as [`end_session_death_feed`]: the body's state
// machine may only be driven by the wire, and while there is no wire there is nothing to
// mirror. (The `/logout` path needs no gate — the avatar is despawned, so the `self_q` below
// already ends the feed.)
if !status.connected {
return;
}
let Some(mut script) = script else {
return;
};
@@ -428,6 +440,34 @@ fn drain_death(
}
}
/// **The world scope of [`DeathFeedState`] dies with the world session** (decision 1732) — the
/// teardown [`DeathNet`]'s own has had since 0065, which this half never got.
///
/// `mirror` is the body's state machine and `died_at` its release-window anchor; both are
/// deliberately not per-VM, so a `/reload` cannot restart the six minutes. But "not per-VM" was
/// implemented as "never cleared", and the two scopes then disagreed across a relog: the session
/// teardown resets `DeathNet` (corpse marker included) while `mirror` still read
/// `(same guid, dead, ghost)` from before the socket died. Re-entering the world as a ghost
/// therefore matched **no** arm of the world-edge machine — `(None, _, true)`, the "logging in
/// already a ghost" arm, needs `prev == None` — so `MSG_CORPSE_QUERY` was never re-sent, the
/// corpse cache stayed empty, the range gate could not fire `CORPSE_IN_RANGE`, and the
/// **RECOVER_CORPSE ("Resurrect Now") popup never came up again after a relog**
/// (director-reported, 2026-08-30).
///
/// The guid filter on `mirror` looked like it covered this and does not: it distinguishes a
/// *different* character, not the *same* one re-entering the world. `DisconnectedMessage` is the
/// one total edge — a socket death, a kick, and a `/logout` all reach it (the IO thread emits a
/// `Disconnected` behind `SessionEnd::LoggedOut`) — and it is the very edge `DeathNet` is reset
/// on, which is what keeps the two scopes in lockstep by construction rather than by memory.
fn end_session_death_feed(
mut msgs: MessageReader<crate::net::DisconnectedMessage>,
mut feed: ResMut<DeathFeedState>,
) {
if msgs.read().next().is_some() {
*feed = DeathFeedState::default();
}
}
/// The death arc's app plugin (decision 0308): the net-fed stores, the state-machine feed + the
/// intent drain, and the controller-facing root/water-walk messages.
pub(crate) struct DeathPlugin;
@@ -442,7 +482,64 @@ impl Plugin for DeathPlugin {
feed_death.before(UiInput),
drain_death.after(UiInput),
drive_death_look,
// Before the feed, so the frame a session ends is already a frame the feed
// sees no memory of the last one.
end_session_death_feed.before(feed_death),
),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::system::RunSystemOnce;
/// **The world scope dies with the session, and only with the session** (decision 1732).
///
/// Both halves matter and they pull opposite ways: a `/reload` must NOT restart the six
/// minutes (which is why `mirror`/`died_at` are not per-VM in the first place), and a relog
/// MUST clear them (or the "logging in already a ghost" arm never fires, no
/// `MSG_CORPSE_QUERY` goes out, and the RECOVER_CORPSE popup stays down — the reported bug).
/// A test that only asserted the clear would pass on a resource wiped every frame.
#[test]
fn the_session_edge_clears_the_world_scoped_death_memory_and_nothing_else_does() {
let mut app = App::new();
app.add_message::<crate::net::DisconnectedMessage>()
.insert_resource(DeathFeedState {
mirror: Some((0x1234, true, true)),
died_at: Some(12.0),
vm: crate::ui_script::VmMemo::default(),
});
// No session edge — the memory stands (the `/reload` half).
app.world_mut()
.run_system_once(end_session_death_feed)
.expect("the teardown runs");
assert_eq!(
app.world().resource::<DeathFeedState>().mirror,
Some((0x1234, true, true)),
"nothing but the session edge may clear the body's own state machine"
);
// A `/logout` is a session edge too — `session_over` is false for it, and the clear may
// not depend on that (a relog is exactly the `false` case).
app.world_mut()
.write_message(crate::net::DisconnectedMessage::new(
"logged out".into(),
benilla_protocol::SessionEnd::LoggedOut,
));
app.world_mut()
.run_system_once(end_session_death_feed)
.expect("the teardown runs");
let feed = app.world().resource::<DeathFeedState>();
assert!(
feed.mirror.is_none(),
"a relog must find no mirror, or the login-while-ghost arm cannot fire"
);
assert!(
feed.died_at.is_none(),
"the release window is the dead session's, and re-arms at the next login"
);
}
}
@@ -111,6 +111,18 @@ pub(super) struct InspectStores<'w, 's> {
&'static benilla_assets::ModelAnimations,
),
>,
/// The **picked submesh's own `MeshTag`** — the per-instance shading payload, decoded by its
/// owning module ([`benilla_world::mesh_tag::describe`]).
///
/// The `light` line above names which *law* an object is on; this names what its parts are
/// actually carrying under that law, and the two together are what settle a "why is this body
/// lit wrong?" report. The payload's meaning switches on material state, which a tag alone
/// cannot know, so the decode prints BOTH readings of bits 6..=18 side by side — a shade byte
/// of 255 (the MCSH-shadowed half-intensity) sits inside a probe slot of 2047, and a writer
/// using the wrong law for its material is invisible in either reading alone and obvious in
/// the pair. Read off the picked entity itself, not the parent: `WorldObject` and `MeshTag`
/// both ride the submesh.
tags: Query<'w, 's, &'static bevy::mesh::MeshTag>,
}
/// The inspector overlay, drawn only while armed: a weak top-centre "armed" pill (so it's obvious the
@@ -186,6 +198,11 @@ pub(super) fn inspect_ui(
let (reputations, plates, plate_mode) =
(&*stores.reputations, &stores.plates.0, &*stores.plate_mode);
let go_templates = &*stores.go_templates;
// The picked submesh's shading payload — off the hit entity itself (see the field's doc).
let tag_line = mouseover
.entity
.and_then(|e| stores.tags.get(e).ok())
.map(|t| format!("tag {}", benilla_world::mesh_tag::describe(t.0)));
let (stores, kinds, collision, lit, motion, go_anims) = (
&stores.stores,
&stores.kinds,
@@ -537,6 +554,9 @@ pub(super) fn inspect_ui(
if let Some(line) = &light_line {
lines.push(line.clone());
}
if let Some(line) = &tag_line {
lines.push(line.clone());
}
lines.push(format!("{:.1} yd away", mouseover.distance));
// The inspector owns left-click while armed (player::control suppresses left-orbit during inspect),
@@ -30,12 +30,15 @@
//! the same [`ItemDisplays`] want the world path uses.
use benilla_formats::CharSkinSlot;
use benilla_protocol::{CharEnumItem, CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM};
use benilla_protocol::{
CharEnumItem, CHARACTER_FLAG_GHOST, CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM,
};
use bevy::prelude::*;
use crate::portrait::{
DressUpBake, DressUpLook, DressUpPreview, GlueLook, GluePetBake, GluePreview, GluePreviewBake,
PetLook, PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider,
BoothTwins, DressUpBake, DressUpLook, DressUpPreview, GhostKit, GlueLook, GluePetBake,
GluePreview, GluePreviewBake, PetLook, PreviewBillboard, PreviewEffects, PreviewPart,
PreviewRider,
};
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::WorldAssets;
@@ -173,6 +176,12 @@ pub(in crate::entities) fn build_glue_preview(
mut skin_composites: ResMut<SkinComposites>,
asset_server: Res<AssetServer>,
mut mats: benilla_world::model_render::M2BatchMaterials,
// The ghost kit (wow-re `glue-select-model.md` §A2): the `SpellVisualKit` catalog the row is
// read from, and the path-keyed spell-effect model cache its attached models load through —
// the same one the world's kit plays use, so `Spells\Ghost_state.mdx` is loaded once per
// session whichever screen wants it first.
visuals: Option<Res<crate::creature_anim::SpellVisuals>>,
mut fx: Option<ResMut<super::super::spell_fx::SpellFx>>,
// Change tracking: the look we last emitted a bake for, and whether that bake succeeded (so a
// look whose models weren't ready yet keeps retrying until they build).
mut state: Local<PreviewState>,
@@ -276,6 +285,37 @@ pub(in crate::entities) fn build_glue_preview(
return; // a model (body, item, or glow) is still loading — retry next frame
};
// The ghost kit — a **Select** look only, and only when the roster record carries the bit
// (`CHARSELECT+0xfc & 0x2000`). A create look never can: the character does not exist yet, and
// the create screen builds its flags as `0` above. The kit's attached models join the ordinary
// lists; what the body renders with rides the bake.
let mut a = a;
let ghost = match look {
GlueLook::Select(l) if l.flags & CHARACTER_FLAG_GHOST != 0 => {
let mut pending = false;
let arm = (|| {
let body = creatures.models.get(&display_id)?;
glue_ghost_arm(
body,
&visuals.as_deref()?.0,
fx.as_deref_mut()?,
&asset_server,
&mut pending,
)
})();
if pending {
return; // an effect model is still loading — retry, don't bake a half ghost
}
arm.map(|arm| {
a.riders.extend(arm.riders);
a.billboards.extend(arm.billboards);
a.effects.extend(arm.effects);
arm.kit
})
}
_ => None,
};
debug!(
"glue preview: race {race} sex {sex} display {display_id} → {} parts, {} riders, \
{} camera-facing, {} effect model(s) / {} emitter(s) ({})",
@@ -300,6 +340,7 @@ pub(in crate::entities) fn build_glue_preview(
effects: a.effects,
billboards: a.billboards,
grip: a.grip,
ghost,
revision: bake.revision + 1,
};
state.built = true;
@@ -422,6 +463,134 @@ pub(in crate::entities) fn build_dressup_preview(
/// `None` means **not ready**: the body model, an item model, or a glow model is still loading.
/// A caller must treat that as "retry next frame" and leave its bake untouched, never as an empty
/// look — a geared character popping in piecewise would flicker on every change.
/// The `SpellVisualKit` row the character-select ghost is, **hard-coded exactly as the reference
/// hard-codes it**: `0x47280f` loads `idmap[989]` behind the guard `0x4727f6 cmp ds:0xc0d750,0x3dd`
/// (wow-re `glue-select-model.md` §A2). The id is the constant; every value behind it is DBC data,
/// read below. 989 is `SpellVisual` 886's state kit — the visual of spell 8326 "Ghost", which is
/// also what a released ghost in the WORLD rides, so the two paths share one row by construction
/// (wow-re `ghost-death-visuals.md` §5a).
const GLUE_GHOST_KIT: u32 = 989;
/// What the ghost kit contributes to a select bake: the body's own render properties, plus the
/// kit's attached effect models already reduced to the booth's ordinary seat shapes.
struct GhostArm {
kit: GhostKit,
riders: Vec<PreviewRider>,
billboards: Vec<PreviewBillboard>,
effects: Vec<PreviewEffects>,
}
/// Resolve [`GLUE_GHOST_KIT`] against the live `SpellVisualKit` catalog and the body's attachment
/// table. `None` means **not ready** — either a DBC is absent (nothing to show, the caller stops
/// retrying) or one of the kit's effect models is still loading (the caller retries), which the
/// `pending` out-flag distinguishes.
///
/// The tint and alpha go through [`crate::aura_visual::node_for`] — the one dispatch point, the ECS
/// twin of the client's `0x60dbfc` jump table — rather than a second decode here: the select screen
/// and the world aura reach the same row, and a proc type newly given a mechanism must light up on
/// both at once. What the select path does NOT share is the world's 1000 ms `StartAlphaFade` ramp:
/// `0x4727f0` writes the instance fields straight through `0x710cb0`/`0x710cf0`, so this is instant.
fn glue_ghost_arm(
body: &super::super::DisplayModel,
visuals: &benilla_formats::SpellVisualCatalog,
fx: &mut super::super::spell_fx::SpellFx,
asset_server: &AssetServer,
pending: &mut bool,
) -> Option<GhostArm> {
let kit_row = visuals.kit(GLUE_GHOST_KIT)?;
let (mut tint, mut alpha) = (None, None);
for node in kit_row
.char_procs()
.filter_map(crate::aura_visual::node_for)
{
match node {
crate::aura_visual::AuraNode::Tint(rgb) => tint = Some(rgb),
crate::aura_visual::AuraNode::Alpha(a) => alpha = Some(a),
// Every other proc type the row could carry belongs to a mechanism the select path has
// no equivalent for (the anim-rate freeze writes bone clocks on a world unit). 989
// carries only these two; a patched row that carries more is ignored, not guessed at.
_ => {}
}
}
let kit = GhostKit {
tint: tint?,
alpha: alpha.unwrap_or(1.0),
};
// The kit's attached effect models, at the body's own attachment points. `Spells\Ghost_state.mdx`
// (slot tag 0x13, Base) is the only populated slot on the shipped row and is emitter-only — but
// the row is data, so geometry and camera-facing cards are carried too, split exactly the way the
// item-glow arm below splits an item's own model.
let (mut riders, mut billboards, mut effects) = (Vec::new(), Vec::new(), Vec::new());
for (tag, effect_id) in kit_row.effects() {
// The world-plant sentinel names no attachment point; the select build hangs models on the
// character alone (`0x472780` → `0x712f70`), so there is nowhere for it to go.
if tag == benilla_formats::WORLD_EFFECT_TAG {
continue;
}
let Some(path) = visuals.effect_path(effect_id) else {
continue;
};
let path = path.to_string();
// `0x472780`'s `test esi,esi; jl 0x4727dd` rejects a `-1` attach id, and a body with no such
// point hangs nothing — the world path's rule, and the reference's.
let Some(point) = body.attachments.iter().find(|a| a.id == tag) else {
continue;
};
let dm =
fx.models
.entry(path.clone())
.or_insert_with(|| super::super::display::DisplayModel {
handle: super::super::display::ModelHandle::M2(
asset_server.load(benilla_assets::m2_url(&path)),
),
..super::super::display::empty_shell()
});
let Some(parts) = dm.parts.as_deref() else {
*pending = true; // still loading — the caller retries rather than baking half a ghost
continue;
};
for part in parts {
match &part.billboard {
Some(info) => billboards.push(PreviewBillboard {
mesh: part.mesh.clone(),
material: part.material.clone(),
bone: point.bone,
offset: point.offset + info.pivot,
kind: info.kind,
twins: BoothTwins {
blend: part.fade_blend.clone(),
zfill: part.zfill.clone(),
},
}),
None => riders.push(PreviewRider {
mesh: part.mesh.clone(),
material: part.material.clone(),
bone: point.bone,
offset: point.offset,
twins: BoothTwins {
blend: part.fade_blend.clone(),
zfill: part.zfill.clone(),
},
}),
}
}
if !dm.emitters.is_empty() {
effects.push(PreviewEffects {
bone: point.bone,
offset: point.offset,
emitters: dm.emitters.clone(),
});
}
}
Some(GhostArm {
kit,
riders,
billboards,
effects,
})
}
fn assemble(spec: &PreviewSpec, ctx: &mut PreviewCtx<'_, '_>) -> Option<Assembled> {
let PreviewSpec {
display_id,
@@ -540,6 +709,7 @@ fn assemble(spec: &PreviewSpec, ctx: &mut PreviewCtx<'_, '_>) -> Option<Assemble
static_mesh: p.mesh.clone(),
skinned_mesh: p.skinned_mesh.clone(),
material: steady_material(p, &char_mats).unwrap_or_else(|| p.material.clone()),
twins: part_twins(p, &char_mats),
// `None` — decision 0807's named gap, deliberately still open: a character batch with
// an authored dimming constant previews at 1.0. The select body's look is signed off
// as it stands, so closing it is its own change, not a rider on the pet's.
@@ -565,6 +735,10 @@ fn assemble(spec: &PreviewSpec, ctx: &mut PreviewCtx<'_, '_>) -> Option<Assemble
bone: info.bone,
offset: Vec3::ZERO,
kind: info.kind,
twins: BoothTwins {
blend: p.fade_blend.clone(),
zfill: p.zfill.clone(),
},
})
})
.collect();
@@ -612,12 +786,20 @@ fn assemble(spec: &PreviewSpec, ctx: &mut PreviewCtx<'_, '_>) -> Option<Assemble
bone: point.bone,
offset: point.offset + info.pivot,
kind: info.kind,
twins: BoothTwins {
blend: p.fade_blend.clone(),
zfill: p.zfill.clone(),
},
}),
None => riders.push(PreviewRider {
mesh: p.mesh.clone(),
material: p.material.clone(),
bone: point.bone,
offset: point.offset,
twins: BoothTwins {
blend: p.fade_blend.clone(),
zfill: p.zfill.clone(),
},
}),
}
}
@@ -667,12 +849,20 @@ fn assemble(spec: &PreviewSpec, ctx: &mut PreviewCtx<'_, '_>) -> Option<Assemble
bone: point.bone,
offset: offset + info.pivot,
kind: info.kind,
twins: BoothTwins {
blend: p.fade_blend.clone(),
zfill: p.zfill.clone(),
},
}),
None => riders.push(PreviewRider {
mesh: p.mesh.clone(),
material: p.material.clone(),
bone: point.bone,
offset,
twins: BoothTwins {
blend: p.fade_blend.clone(),
zfill: p.zfill.clone(),
},
}),
}
}
@@ -784,6 +974,48 @@ fn held_wants(
wants
}
/// A batch's translucency twins ([`BoothTwins`]) as the preview carries them — the `AlphaMode::Blend`
/// twin and the depth-prime twin, both already built beside the steady material by
/// `M2BatchMaterials` (an `EntityPart` carries its own pair; a character composite's are the quint's
/// third and sixth members). `char_mats` supplies them for a **character** batch, whose steady
/// material is the composited atlas rather than the part's own built one; every other part answers
/// from itself.
///
/// Only a bake whose instance alpha is below 1 ever draws through them (the ghost select), but they
/// are collected unconditionally: they cost two handle clones on a bake that happens once per
/// selection, and a look that resolves them *conditionally* would have to re-assemble on the ghost
/// edge, which is exactly the sort of second code path the one-alpha law exists to prevent.
fn part_twins(part: &EntityPart, char_mats: &super::char_skin::CharSkinMaterials) -> BoothTwins {
// The same slot walk [`steady_material`] does, kept beside it so the pair can never disagree
// about which quint a batch reads.
let quint = (|| match part.char_slot {
Some(CharSkinSlot::Body) => {
let (single, two) = char_mats.0.as_ref()?;
Some(if part.two_sided { two } else { single })
}
Some(CharSkinSlot::Hair) => char_mats.1.as_ref(),
Some(CharSkinSlot::Object) => char_mats.2.as_ref(),
Some(CharSkinSlot::SkinExtra) => {
let (single, two) = &char_mats.3;
(if part.two_sided { two } else { single }).as_ref()
}
None => None,
})();
match quint {
// The quint's order is the engine's variant set: (steady, interior, fade_blend,
// interior_bake, interior_bake_blend, zfill) — `char_skin::build_char_skin_materials`.
Some(q) => BoothTwins {
blend: Some(q.2.clone()),
zfill: q.5.clone(),
},
// A non-character batch keeps its own built material, so it keeps its own twins.
None => BoothTwins {
blend: part.fade_blend.clone(),
zfill: part.zfill.clone(),
},
}
}
/// The steady, world-lit material a character part wears — the body atlas (at the part's sidedness),
/// the hair texture, the cape, or the extra-skin fur — mirroring the attach swap ([`super`]) but
/// keeping only the steady variant (the booth wants no fade / interior twin). `None` for a
@@ -901,6 +1133,14 @@ fn assemble_pet(
skinned_mesh: p.skinned_mesh.clone(),
material: p.material.clone(),
alpha_anim: p.alpha_anim.clone(),
// The pet is its own CM2 instance, chained to the SCENE (attachment 1), never to
// the character (wow-re `glue-select-model.md` §B1) — so it composes onto nothing
// and is never ghosted with its owner. The twins ride anyway, for the same reason
// the body's do: they are a property of the batch.
twins: BoothTwins {
blend: p.fade_blend.clone(),
zfill: p.zfill.clone(),
},
})
.collect(),
billboards: billboard_parts
@@ -915,6 +1155,10 @@ fn assemble_pet(
// the pivot (the 0130 rig identity) — the same seat the body's eye-glow takes.
offset: Vec3::ZERO,
kind: info.kind,
twins: BoothTwins {
blend: p.fade_blend.clone(),
zfill: p.zfill.clone(),
},
})
})
.collect(),
@@ -959,6 +1203,61 @@ pub(in crate::entities) struct DressUpState {
mod tests {
use super::*;
/// GOLDEN, against the **real shipped 5875 `SpellVisualKit.dbc`** — the one row the reference
/// hard-codes for the character-select ghost, decoded through the same dispatch point the world
/// aura uses.
///
/// Every number here is byte-VERIFIED in wow-re at the record itself
/// (`ghost-death-visuals.md` §5a: row 989's `+0x3c` reads
/// `01 00 00 00 0e 00 00 00 ff ff ff ff ff ff ff ff fd b9 0c 4b 00 00 00 3f`), and the
/// note records a §5 derivation that got `0x4B0CB53D → (140,181,61)` and was refuted at those
/// bytes. That is exactly the failure this test exists to catch: a tint transcribed one nibble
/// wrong is still a plausible pale blue, and nothing but the shipped row can tell them apart.
///
/// It also pins the *shape* the select path depends on — one populated effect slot, at
/// attachment `0x13` (Base) — because "nine ghost sub-models" was this note's own earlier
/// reading (benilla decision 0469 §6 carried it), corrected 2026-08-21 when `0x472780`'s
/// `test esi,esi; jl` was seen rejecting the eight `-1`s.
#[test]
fn the_shipped_ghost_kit_decodes_to_the_verified_row() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open the install's MPQ chain");
let catalog = benilla_formats::load_spell_visual_catalog(&mut chain)
.expect("SpellVisual/SpellVisualKit");
let kit = catalog
.kit(GLUE_GHOST_KIT)
.expect("the reference hard-codes kit 989; the shipped table has it");
let nodes: Vec<_> = kit
.char_procs()
.filter_map(crate::aura_visual::node_for)
.collect();
assert!(
nodes.contains(&crate::aura_visual::AuraNode::Tint([140, 185, 253])),
"kit 989's CharProc 1 is 9222653.0 → 0x8CB9FD → RGB (140,185,253); got {nodes:?}"
);
assert!(
nodes
.iter()
.any(|n| matches!(n, crate::aura_visual::AuraNode::Alpha(a) if *a == 0.5)),
"kit 989's CharProc 14 is 0.5; got {nodes:?}"
);
// Exactly ONE populated slot, and it is the Base attach point — not nine.
let effects: Vec<_> = kit.effects().collect();
assert_eq!(effects.len(), 1, "kit 989 fills one slot, got {effects:?}");
let (tag, effect_id) = effects[0];
assert_eq!(tag, 0x13, "the Base attachment (KIT_SLOT_TAGS[2])");
let path = catalog
.effect_path(effect_id)
.expect("SpellVisualEffectName row for the ghost state model")
.to_ascii_lowercase();
assert!(
path.replace('\\', "/").ends_with("spells/ghost_state.mdx"),
"the ghost state model, got {path:?}"
);
}
/// **The bug the imp filed** (decision 1539): a pet is a *creature* display, and creature models
/// routinely author their own particle emitters — the Imp's three flame jets, the Voidwalker's
/// four smoke plumes. `assemble_pet` read `parts` and nothing else, so they never reached the
+10 -3
View File
@@ -44,9 +44,16 @@
//!
//! `[corpse+0x2b0]` is **not** a render scale (its old ledger gloss); it is `0.5 ×` the MD20 render
//! bounding-sphere radius, read by exactly one site — `0x5d6fe0`, which sizes the corpse's
//! `UnitSelectTexture` ground decal by it × `OBJECT_FIELD_SCALE_X`. That decal is the corpse's twin
//! of a unit's selection ring, so it belongs with the right-click loot/reclaim route this arc still
//! defers (0308 §7), not here.
//! `UnitSelectTexture` ground decal by it × `OBJECT_FIELD_SCALE_X`.
//!
//! **That decal is dead code, and we owe it nothing** (1727, closed by 1732). Its one dispatcher
//! image-wide gates on the object being the **Locked Target** (`[0xb4e2d8]`/`[0xb4e2dc]`), and
//! `SetSelection 0x493540` refuses any object whose `[vt+0x58]` selectability slot returns 0 —
//! CGCorpse's is `0x469fe0`, literally `33 c0 c3`. No corpse, bones or fresh, can ever be the
//! locked target, so `0x5d6fe0` never runs. (The same refusal is independently why a click on a
//! body leaves your current target alone: `0x4935f3` bails *before* the clear-the-old-selection
//! call.) Mouseover is a different slot — `+0x54` = `0x5d76d0`, which a corpse *does* pass — and
//! that is the highlight/cursor/tooltip route we ship, not a ring.
//!
//! **The divergence to know about** (flagged open by wow-re's §5, not settled at the bytes): the
//! reference computes the drowned verdict *inside* the create, from the scene node's cached liquid
@@ -670,18 +670,28 @@ pub(in crate::entities) fn resolve_equipment(
#[allow(clippy::type_complexity)] // one query's tuple + its change filter
pub(in crate::entities) fn resolve_corpse_equipment(
mut commands: Commands,
corpses: Query<
(Entity, &NetEntity, Ref<ObjectStore>, Option<&Equipment>),
Or<(Changed<ObjectStore>, Without<Equipment>)>,
>,
corpses: Query<(Entity, &NetEntity, Ref<ObjectStore>, Option<&Equipment>)>,
held: Option<ResMut<ItemDisplays>>,
asset_server: Res<AssetServer>,
net: Res<NetCommands>,
// The guild identity cache, `ResMut` for the same reason the player lane's is: the miss is
// what SENDS the `CMSG_GUILD_QUERY` whose answer paints the crest.
mut guilds: Option<ResMut<crate::ui_guild::GuildState>>,
mut last_guild_epoch: Local<Option<u64>>,
) {
let Some(mut held) = held else {
return;
};
for (entity, net, store, current) in &corpses {
if net.kind != EntityKind::Corpse {
// The change gate moved off the query filter and in here to make room for the guild counter
// (the player lane's own shape): an answer landing three frames after the corpse streamed in
// changes what its tabard paints, and nothing about the corpse's descriptor moves to say so.
let epoch = guilds.as_ref().map_or(0, |g| g.identity_generation());
let guilds_moved = last_guild_epoch.replace(epoch) != Some(epoch);
for (entity, net_entity, store, current) in &corpses {
if net_entity.kind != EntityKind::Corpse {
continue;
}
if !(store.is_changed() || current.is_none() || guilds_moved) {
continue;
}
let s = &store.0;
@@ -702,6 +712,15 @@ pub(in crate::entities) fn resolve_corpse_equipment(
if !s.corpse_hides_helm() {
eq.helm = s.corpse_item(0).map_or(0, |(display, _)| display);
}
// The guild tabard crest, from the corpse's OWN `CORPSE_FIELD_GUILD` snapshot — the
// reference's `0x5d6ec0`, reached from the dress loop at the tabard slot (`ebx == 0x12`
// with that display's `ItemDisplayInfo` flag bit 0) and resolved through the same
// name cache a living body's is (wow-re `corpse-decal-and-loot-sparkle.md` §6b). A
// bone pile builds no character component, so it never reaches this leg — which is
// exactly where this sits.
eq.emblem = guilds
.as_deref_mut()
.and_then(|g| crate::ui_guild::corpse_guild_emblem(s, g, &net));
}
if current != Some(&eq) {
commands.entity(entity).insert(eq);
@@ -789,6 +808,11 @@ mod tests {
let run = |store: ObjectStore| {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default()));
// The guild-crest leg's channel (the emblem resolve is lazy and would send a
// `CMSG_GUILD_QUERY` on a miss); no `GuildState` here, so the crest reads `None` —
// which is the guildless case and exactly what this test's corpse is.
let (tx, _rx) = crossbeam_channel::unbounded::<ClientCommand>();
app.insert_resource(NetCommands(tx));
app.insert_resource(ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(
std::collections::HashMap::new(),
+2
View File
@@ -93,6 +93,7 @@ mod quest_markers;
mod raid_marks;
mod realmlist;
mod run_mode;
mod screen_fade;
mod screenshot;
mod shaders;
@@ -520,6 +521,7 @@ pub fn run(build: BuildId) -> AppExit {
// Cinematic fly-bys (`SMSG_TRIGGER_CINEMATIC`): the race intro a first login plays, and the
// GameObject cameras. Takes the world camera for the duration — hence after PlayerPlugin,
// whose `control` it overrides within the same stage (decision 0196's deferred arc).
.add_plugins(crate::screen_fade::ScreenFadePlugin)
.add_plugins(CinematicPlugin)
// The real client's hardware mouse cursor (native NSCursor on macOS).
.add_plugins(CursorPlugin)
+28 -1
View File
@@ -301,11 +301,21 @@ pub(super) fn step(
}
} else {
// **Feather fall is a terminal-velocity substitution, and nothing else** (decision 0866).
// The reference's gravity integrate `0x7c5d20` picks its clamp from one flag test
// 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.)
let terminal = if player.modes.feather_fall {
FEATHER_TERMINAL_VELOCITY
} else {
@@ -328,6 +338,23 @@ pub(super) fn step(
// 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.
//
// **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
// FALLING — they bail on `FALLING && arg == 0`, and the two openers that pass `arg = 1`
// sit behind `0x7c6afc test al,0xf`: the door opens exactly while the **direction nibble
// `+0x40 & 0xf` is clear**. That is why a moving jump is locked, and it is a different
// question from "is my horizontal velocity ~zero". The two answers diverge on one input:
// a knockback launch plants FORWARD itself (`0x6179c0`'s `0x617a18 or edx,0x8001`), so on
// the reference an `xy_speed ≈ 0` knockback is STILL unsteerable, while here `knocked` is
// launch-frame-only and this guard sees a zero velocity and grants the nudge from frame 2.
// 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;
air_nudged = true;
}
+6 -1
View File
@@ -113,7 +113,7 @@ pub(super) const JUMP_SPEED: f32 = 7.955_547;
/// Shared with [`crate::net`]'s ballistic integration (caps a long fall's vertical speed).
pub(crate) const TERMINAL_VELOCITY: f32 = 60.148_003;
/// **Terminal fall speed under feather fall** (yd/s) — the *whole* of what Slow Fall does. The
/// reference's gravity integrate `0x7c5d20` picks its clamp from one flag test
/// reference's fall-velocity query `0x7c5d20` picks its clamp from one flag test
/// (`0x7c5d23 test [ecx+0x40], 0x20000000`): the ordinary cap `[0x87d894]` = 60.148, or this one
/// `[0x87d898]` when `MOVEFLAG_SAFE_FALL` is set. VERIFIED — wow-re `system/collision/ledger.tsv`
/// (`0x7c5d20`, sweep2 §5) and `scratch/spec-ground.md`'s terminal-vel select.
@@ -254,6 +254,11 @@ pub(super) const WEDGE_STALL_RATIO: f32 = 0.15;
pub(super) const WEDGE_MIN_FALL: f32 = 1.0;
/// One-shot air-control nudge (yd/s): a jump from a standstill can be steered this much in the pressed
/// direction; a jump taken with momentum keeps it locked (vanilla feel). Less than a walking jump.
///
/// **VERIFIED as behaviour, but this constant is data, not a binary constant** (decision 1736): the
/// reference's nudge speed is `min(MOVE_WALK, MOVE_RUN)`, the walk override inside `0x7c4c90(1)`
/// (`0x7c4d19`/`0x7c4d1b`), read from the unit's live speeds. `2.5` is the *default* `MOVE_WALK`, so
/// this agrees with the reference right up until a walk aura, a Slow or a daze moves either speed.
pub(super) const AIR_NUDGE_SPEED: f32 = 2.5;
/// The FALLINGFAR **distance leg** (yd): a *jump* arc (launch vz ≠ 0) latches MOVEFLAG_FALLINGFAR
/// once it descends this far below its launch height — the fall resolver's `0x633240`, constant
+237 -12
View File
@@ -16,6 +16,7 @@ use bevy::camera::visibility::RenderLayers;
use bevy::prelude::*;
use benilla_assets::materials::WowModelMaterial;
use benilla_world::model_fade::FadeMaterials;
/// One mesh headed into a booth bake: the mirrored part's twins + its studio-lit material.
pub(super) struct BoothPart {
@@ -29,6 +30,88 @@ pub(super) struct BoothPart {
/// 0807 — B121's black corners were UI_Tauren's 0.55 `LENSALPHA` vignette at full strength).
/// `None` for the overwhelming majority of batches, which are opaque.
pub(super) alpha_anim: Option<std::sync::Arc<benilla_formats::AlphaAnim>>,
/// The batch's translucency twins, for a bake whose **instance alpha** is below 1
/// ([`BoothInstance`]) — the blend twin it draws through and the depth-prime twin that keeps
/// it one layer. Both already exist on every M2 batch (`EntityPart::fade_blend`/`zfill`,
/// the char composite's quint); the booth relights them beside the steady one and hands them
/// here. `None` for a batch that cannot feather at all — the reference's own twin gate.
pub(super) twins: BoothTwins,
}
/// A batch's translucency twins as the bake sees them — see [`BoothPart::twins`]. Default (both
/// `None`) is "cannot feather", which is what every opaque-bake caller passes.
#[derive(Clone, Default)]
pub(crate) struct BoothTwins {
pub(crate) blend: Option<Handle<WowModelMaterial>>,
pub(crate) zfill: Option<Handle<WowModelMaterial>>,
}
/// **The bake's instance-level render properties** — the reference's per-CM2-instance alpha
/// (`model+0x180`) and modulate colour (`model+0x184..0x18c`), which every batch of the model draws
/// through and which every model *attached* to it composes onto (`0x714000`'s recursion).
///
/// One value per bake rather than one per batch, deliberately: that is what it is in the reference,
/// and it is why a booth's riders and cards take it as surely as its body batches do — an item on a
/// ghost is as translucent and as blue as the arm holding it.
///
/// A booth composes at **bake time**, so this is where benilla's one-alpha law (decision 1164 — the
/// game declares the translucency, the engine owns the write, the composition and the material
/// swap) is discharged for a booth: the three things the engine owes a translucent instance (the
/// tag's alpha field, the blend-twin swap, and the [`FadeMaterials`] record the depth prime arms
/// off) all happen in [`spawn_booth_model`], together, or not at all.
#[derive(Clone, Copy)]
pub(super) struct BoothInstance {
/// `model+0x180`. `1.0` = opaque, which is every bake but a ghosted one.
pub(super) alpha: f32,
}
impl Default for BoothInstance {
fn default() -> Self {
Self { alpha: 1.0 }
}
}
impl BoothInstance {
/// Does this instance draw translucent — the gate on the whole twin/tag/`FadeMaterials` arm.
/// Mirrors the reference's own `0 < A < 1` band ([`benilla_world::mesh_tag::translucent`],
/// which is what the depth prime keys on once the tag is written).
pub(super) fn feathering(self) -> bool {
self.alpha < 1.0
}
/// The material this instance draws `steady` with, given the batch's twins — the blend twin
/// while feathering, the steady material otherwise (and the steady one anyway for a batch that
/// has no twin: a Mod/Mod2x batch reads no alpha, so no swap can feather it — it rides the
/// shader's identity lerp on the tag alpha instead, decision 0865/1489).
fn material(
self,
steady: &Handle<WowModelMaterial>,
twins: &BoothTwins,
) -> Handle<WowModelMaterial> {
match (self.feathering(), twins.blend.as_ref()) {
(true, Some(blend)) => blend.clone(),
_ => steady.clone(),
}
}
/// The persistent [`FadeMaterials`] record a feathering batch carries, so
/// [`benilla_world::zfill`]'s depth prime arms on it exactly as it does on a stealthed body in
/// the world. `None` when the batch cannot feather, or the instance is opaque.
fn fade_materials(
self,
steady: &Handle<WowModelMaterial>,
twins: &BoothTwins,
) -> Option<FadeMaterials> {
let blend = twins.blend.clone()?;
self.feathering().then(|| FadeMaterials {
cutout: steady.clone(),
blend,
// A booth is never interior-classified: it has no room around it, and its light is
// the scene's own authored rig.
bake_blend: None,
zfill: twins.zfill.clone(),
})
}
}
/// Marks a booth part whose render-alpha `MeshTag` **and `Visibility`** are driven by its own
@@ -55,6 +138,9 @@ pub(super) struct BoothRider {
pub(super) material: Handle<WowModelMaterial>,
pub(super) bone: u16,
pub(super) offset: Vec3,
/// This rider's translucency twins — an attached model composes onto its parent instance's
/// alpha (`0x714000`), so a ghost's helm feathers with the head under it. See [`BoothTwins`].
pub(super) twins: BoothTwins,
}
/// One camera-facing batch headed into a booth bake — the undead/night-elf **eye-glow** (a quad on
@@ -71,6 +157,10 @@ pub(super) struct BoothBillboardSpec {
/// model itself — the joint frame already bakes the bone pivot (the 0130 rig identity).
pub(super) offset: Vec3,
pub(super) kind: BillboardKind,
/// The card's translucency twins — a card is a batch of the model like any other, so it takes
/// the instance alpha too (the reference has ONE instance alpha every batch draws through,
/// billboard batches included; decision 0836 is the same rule on the world's card lanes).
pub(super) twins: BoothTwins,
}
/// A spawned booth billboard card: the centred quad seated on its billboard bone's joint (which
@@ -124,6 +214,11 @@ pub(super) struct BoothEffects {
pub(super) struct BoothRig {
root: Entity,
rig: Option<benilla_world::rig_anim::RigPose>,
/// The palette rig slot this bake allocated (`0` = boneless bake, or the pool was full). It is
/// also this instance's index into the per-instance TINT table
/// ([`benilla_world::instance_tint`]) — the same field the vertex stage skins from — so a
/// caller that wants to colour the whole bake writes it here.
slot: u16,
}
impl BoothRig {
@@ -141,6 +236,12 @@ impl BoothRig {
self.rig.is_some()
}
/// This bake's palette/tint slot — see [`Self::slot`]. `0` is the world's no-rig sentinel and
/// is never a real slot, so `0` reads as "this bake cannot be tinted".
pub(super) fn slot(&self) -> u16 {
self.slot
}
/// Commit the pose buffer onto the booth root — after every consumer is seated. `StageRig`
/// rides with it (decision 1447): the pair exists together or not at all, so the world-view
/// parker can never see a booth `RigPose` without the marker that exempts it.
@@ -405,6 +506,9 @@ pub(super) fn spawn_booth_model(
// joint and re-faced to the booth camera by [`face_booth_billboards`]. Needs the rig (no bones =
// no eye bone); the boneless bake below drops them. `&[]` for booths that dress none.
billboards: &[BoothBillboardSpec],
// The bake's instance-level render properties — see [`BoothInstance`]. `default()` (opaque) for
// every booth but a ghosted character select.
instance: BoothInstance,
) -> BoothRig {
// A re-bake must not inherit the previous model's animation state on the shared root — nor a
// stale pose buffer (its anchors died with the child despawn), nor the previous rig's palette
@@ -414,24 +518,40 @@ pub(super) fn spawn_booth_model(
for p in parts {
let mut child = commands.spawn((
Mesh3d(p.static_mesh.clone()),
MeshMaterial3d(p.material.clone()),
MeshMaterial3d(instance.material(&p.material, &p.twins)),
Transform::IDENTITY,
layer.clone(),
ChildOf(root),
));
// The authored material alpha reaches the boneless bake too — it is a property of the
// batch, not of the rig.
// batch, not of the rig. The INSTANCE alpha multiplies into it, the reference's
// `model+0x180 × the batch factor` (`0x707680`).
if let Some(anim) = &p.alpha_anim {
let mat_anim =
benilla_world::doodad_anim::MatAnim::driving_tag(anim.clone(), 0.0, None);
child.insert((
bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(0, mat_anim.current)),
bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(
0,
mat_anim.current * instance.alpha,
)),
mat_anim,
BoothMatAlpha,
));
} else if instance.feathering() {
child.insert(bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(
0,
instance.alpha,
)));
}
if let Some(fm) = instance.fade_materials(&p.material, &p.twins) {
child.insert(fm);
}
}
return BoothRig { root, rig: None };
return BoothRig {
root,
rig: None,
slot: 0,
};
};
// The collapsed pose buffer (decision 1443, replaying 0724/1365 for the last entity-joint
// rig lane): bind-pose composed, evaluated in place by the 0712 evaluator off the player
@@ -502,7 +622,7 @@ pub(super) fn spawn_booth_model(
};
let mut child = commands.spawn((
Mesh3d(mesh),
MeshMaterial3d(p.material.clone()),
MeshMaterial3d(instance.material(&p.material, &p.twins)),
Transform::IDENTITY,
layer.clone(),
ChildOf(root),
@@ -519,16 +639,23 @@ pub(super) fn spawn_booth_model(
child.insert((
bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(
tag_slot,
mat_anim.current,
mat_anim.current * instance.alpha,
)),
mat_anim,
BoothMatAlpha,
));
} else if use_rig {
} else if use_rig || instance.feathering() {
child.insert(bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(
tag_slot, 1.0,
tag_slot,
instance.alpha,
)));
}
// The persistent twin record: while this instance draws translucent, `benilla_world::zfill`
// keeps a colour-masked, z-writing child on it, so overlapping limbs stay ONE blended
// layer instead of compounding 0.5 over 0.5 (the reference's `M2UseZFill`, decision 0831).
if let Some(fm) = instance.fade_materials(&p.material, &p.twins) {
child.insert(fm);
}
if use_rig {
child.insert(benilla_world::rig_palette::RigPart(root));
// **A skinned booth part is never frustum-culled** — the same law the world's dress
@@ -550,18 +677,32 @@ pub(super) fn spawn_booth_model(
let mut booth_rig = BoothRig {
root,
rig: Some(pose),
slot: rig_slot,
};
for r in riders {
let Some(anchor) = booth_rig.anchor(commands, r.bone) else {
continue; // bad bone index — bake the body without this rider
};
commands.spawn((
let mut child = commands.spawn((
Mesh3d(r.mesh.clone()),
MeshMaterial3d(r.material.clone()),
MeshMaterial3d(instance.material(&r.material, &r.twins)),
Transform::from_translation(r.offset),
layer.clone(),
ChildOf(anchor),
));
// An attached model composes onto its parent instance's colour and alpha (the reference's
// `0x714000` recursion; decision 0820's "an item's parts carry the wearer's instance
// bits"). Untagged means opaque + slot 0, so the tag only goes up when there is something
// to say — a translucent instance, or a tint slot to borrow.
if instance.feathering() || rig_slot != 0 {
child.insert(bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(
rig_slot,
instance.alpha,
)));
}
if let Some(fm) = instance.fade_materials(&r.material, &r.twins) {
child.insert(fm);
}
}
// The camera-facing batches (the eye-glow, an item's gem, a glow model's quad): seat the centred
// quad under its bone's anchor at the spec's offset — zero for the body's own batches, whose
@@ -573,14 +714,23 @@ pub(super) fn spawn_booth_model(
let Some(anchor) = booth_rig.anchor(commands, bb.bone) else {
continue;
};
commands.spawn((
let mut card = commands.spawn((
Mesh3d(bb.mesh.clone()),
MeshMaterial3d(bb.material.clone()),
MeshMaterial3d(instance.material(&bb.material, &bb.twins)),
Transform::from_translation(bb.offset),
layer.clone(),
ChildOf(anchor),
BoothBillboard { kind: bb.kind },
));
if instance.feathering() || rig_slot != 0 {
card.insert(bevy::mesh::MeshTag(benilla_world::mesh_tag::spawn_tag(
rig_slot,
instance.alpha,
)));
}
if let Some(fm) = instance.fade_materials(&bb.material, &bb.twins) {
card.insert(fm);
}
}
// Arm Stand and freeze: the player is configured *before* insertion (plain component data),
// so the pose lands with the first animation pass — no play-after-spawn ordering dance.
@@ -1258,12 +1408,14 @@ mod tests {
static_mesh: Handle::default(),
material: Handle::default(),
alpha_anim: None,
twins: BoothTwins::default(),
},
BoothPart {
skinned: None,
static_mesh: Handle::default(),
material: Handle::default(),
alpha_anim: None,
twins: BoothTwins::default(),
},
];
@@ -1287,6 +1439,7 @@ mod tests {
BoothMotion::Frozen,
[false, false],
&[],
BoothInstance::default(),
)
.finish(&mut commands);
}
@@ -1674,3 +1827,75 @@ mod tests {
assert_eq!(weight(&player, 1), Some(1.0), "the bake's Stand, untouched");
}
}
#[cfg(test)]
mod instance_tests {
use super::*;
/// Three distinguishable handles — the assertions are about *which* of them the instance
/// picks, so they only need to be unequal.
fn handles() -> (
Handle<WowModelMaterial>,
Handle<WowModelMaterial>,
Handle<WowModelMaterial>,
) {
let assets = Assets::<WowModelMaterial>::default();
let one = || assets.reserve_handle();
(one(), one(), one())
}
/// The three things the engine owes a translucent instance happen **together, or not at all**
/// (decision 1164's law, discharged at bake time): the blend-twin swap, and the persistent
/// [`FadeMaterials`] record the depth prime arms off. An opaque bake gets neither — which is
/// what keeps every existing booth bit-for-bit unchanged by the ghost lane.
#[test]
fn an_opaque_bake_draws_steady_and_arms_no_depth_prime() {
let (steady, blend, zfill) = handles();
let twins = BoothTwins {
blend: Some(blend),
zfill: Some(zfill),
};
let opaque = BoothInstance::default();
assert!(!opaque.feathering());
assert_eq!(opaque.material(&steady, &twins), steady);
assert!(opaque.fade_materials(&steady, &twins).is_none());
}
/// A ghosted bake takes the blend twin and carries the record — with the **zfill** twin in it,
/// which is the whole reason a 0.5 body does not compound `0.5` over `0.5` where an arm crosses
/// the torso (the reference's `M2UseZFill`, decision 0831).
#[test]
fn a_ghosted_bake_takes_the_blend_twin_and_carries_the_zfill() {
let (steady, blend, zfill) = handles();
let twins = BoothTwins {
blend: Some(blend.clone()),
zfill: Some(zfill.clone()),
};
let ghost = BoothInstance { alpha: 0.5 };
assert!(ghost.feathering());
assert_eq!(ghost.material(&steady, &twins), blend);
let fm = ghost
.fade_materials(&steady, &twins)
.expect("a feathering batch with a twin carries the record");
assert_eq!(fm.cutout, steady, "the steady material it settles back to");
assert_eq!(fm.blend, blend);
assert_eq!(fm.zfill, Some(zfill));
assert!(
fm.bake_blend.is_none(),
"a booth is never interior-classified"
);
}
/// A batch that cannot feather keeps its steady material even while the instance is
/// translucent, and arms no prime — the reference's own twin gate, and the Mod/Mod2x rule
/// (their blend equation reads no alpha, so no material swap can feather them; the shader's
/// identity lerp on the tag alpha does it instead — decisions 0865/1489).
#[test]
fn a_twinless_batch_stays_steady_even_while_the_instance_feathers() {
let (steady, _, _) = handles();
let none = BoothTwins::default();
let ghost = BoothInstance { alpha: 0.5 };
assert_eq!(ghost.material(&steady, &none), steady);
assert!(ghost.fade_materials(&steady, &none).is_none());
}
}
+7 -3
View File
@@ -34,9 +34,9 @@ use benilla_assets::materials::WowModelMaterial;
use super::light::BoothLight;
use super::{
aim, body_frame, booth_anchors, new_target_image, spawn_booth_effects, spawn_booth_model,
wake_booth, Booth, BoothBillboardSpec, BoothCam, BoothEffects, BoothMotion, BoothPart,
BoothRider, Booths, PortraitImages, PortraitSource, PreviewBillboard, PreviewEffects,
PreviewPart, PreviewRider, BOOTH_SETTLE_FRAMES, DRESSUP_LAYER, PAPERDOLL_SIZE,
wake_booth, Booth, BoothBillboardSpec, BoothCam, BoothEffects, BoothInstance, BoothMotion,
BoothPart, BoothRider, BoothTwins, Booths, PortraitImages, PortraitSource, PreviewBillboard,
PreviewEffects, PreviewPart, PreviewRider, BOOTH_SETTLE_FRAMES, DRESSUP_LAYER, PAPERDOLL_SIZE,
};
/// The dressing-room booth slot token (its key in [`PortraitImages`] / [`Booths`]).
@@ -266,6 +266,7 @@ pub(super) fn sync_dressup_booth(
material: relight(&p.material),
// `None` — the same known gap as the glue preview's (decision 0807).
alpha_anim: None,
twins: BoothTwins::default(),
})
.collect();
let booth_riders: Vec<BoothRider> = bake
@@ -276,6 +277,7 @@ pub(super) fn sync_dressup_booth(
material: relight(&r.material),
bone: r.bone,
offset: r.offset,
twins: BoothTwins::default(),
})
.collect();
let booth_billboards: Vec<BoothBillboardSpec> = bake
@@ -287,6 +289,7 @@ pub(super) fn sync_dressup_booth(
bone: b.bone,
offset: b.offset,
kind: b.kind,
twins: BoothTwins::default(),
})
.collect();
// Never latch a world-lane material into a pane (the [`super::light`] law): retry instead.
@@ -311,6 +314,7 @@ pub(super) fn sync_dressup_booth(
BoothMotion::Loop,
bake.grip,
&booth_billboards,
BoothInstance::default(),
);
let (fx_emitters, _) = spawn_booth_effects(
&mut commands,
+107 -3
View File
@@ -31,8 +31,8 @@ use super::framing::{
};
use super::{
aim, body_frame, new_target_image, spawn_booth_effects, spawn_booth_model, Booth,
BoothBillboardSpec, BoothCam, BoothEffects, BoothLight, BoothMotion, BoothPart, BoothRider,
Booths, PortraitImages, PortraitSource, GLUE_LAYER,
BoothBillboardSpec, BoothCam, BoothEffects, BoothInstance, BoothLight, BoothMotion, BoothPart,
BoothRider, BoothTwins, Booths, PortraitImages, PortraitSource, GLUE_LAYER,
};
/// The glue booth slot token (its key in [`super::PortraitImages`] / [`Booths`]).
@@ -351,6 +351,11 @@ pub(crate) struct PreviewPart {
/// has the field in hand (it reads the display's `EntityPart`s straight, with no compositor in
/// between), so it fills it.
pub(crate) alpha_anim: Option<std::sync::Arc<benilla_formats::AlphaAnim>>,
/// The batch's translucency twins ([`BoothTwins`]) — carried so a bake whose INSTANCE alpha is
/// below 1 (the ghost select, [`GhostKit`]) can draw through the blend twin and arm the depth
/// prime. Empty for a look that cannot feather; the booth relights whatever is here beside the
/// steady material.
pub(crate) twins: BoothTwins,
}
/// One equipment rider on the assembled preview (helm / shoulder / sheathed weapon — decision
@@ -363,6 +368,8 @@ pub(crate) struct PreviewRider {
pub(crate) material: Handle<WowModelMaterial>,
pub(crate) bone: u16,
pub(crate) offset: Vec3,
/// See [`PreviewPart::twins`] — an attached model composes onto its wearer's instance alpha.
pub(crate) twins: BoothTwins,
}
/// One **camera-facing batch** on the assembled preview — the world path's billboard card
@@ -390,6 +397,8 @@ pub(crate) struct PreviewBillboard {
/// the rigged body itself — its joint already bakes the pivot.
pub(crate) offset: Vec3,
pub(crate) kind: benilla_formats::BillboardKind,
/// See [`PreviewPart::twins`] — a card is a batch of the model like any other.
pub(crate) twins: BoothTwins,
}
/// One **effect-bearing model** on the assembled preview, carried as its particle emitters plus the
@@ -414,6 +423,38 @@ pub(crate) struct PreviewEffects {
pub(crate) emitters: Vec<benilla_assets::ModelEmitter>,
}
/// The select screen's **ghost** treatment on the character body — what the reference's
/// `0x4727f0` applies when the selected roster record carries `CHARSELECT+0xfc & 0x2000`
/// (wow-re `glue-select-model.md` §A2, VERIFIED; the row text is the same bit, `refresh.rs`).
///
/// **It is one hard-coded `SpellVisualKit` row, and we read it from the DBC exactly as the
/// reference does.** `0x47280f` loads `idmap[989]` behind the guard `0x4727f6 cmp ds:0xc0d750,0x3dd`
/// — the *id* is the constant, the values are data — and 989 is the state kit of `SpellVisual` 886,
/// which is the visual of spell 8326 "Ghost", the very aura the WORLD ghost rides
/// (`crate::aura_visual`, wow-re `ghost-death-visuals.md` §2/§5a). So the select mannequin and a
/// released ghost in the world are the same kit reaching the same three render properties by two
/// different code paths, and benilla resolves both through the one dispatch point
/// ([`crate::aura_visual::node_for`]) rather than transcribing constants twice.
///
/// The shipped 5875 values, for orientation only — nothing here hardcodes them:
/// `charProc = {1, 14, -1, -1}`, `charParamZero = {9222653.0, 0.5, …}` → tint `0x8CB9FD`
/// = RGB (140, 185, 253), alpha 0.5, one attached effect — `Spells\Ghost_state.mdx` at
/// attachment `0x13` (Base). That model is emitter-only (0 vertices, 5 bones, one
/// `PARTICLES\FLAME02.BLP`), so it reaches the booth as [`PreviewEffects`] and nothing else —
/// but the resolution below carries geometry and cards too, because what the row names is data.
///
/// **No ramp.** The world's proc-14 alpha eases in over 1000 ms (`StartAlphaFade 0x614f80`); the
/// select path never reaches that block — `0x4727f0` writes `cc+0x180`/`+0x184..0x18c` straight
/// through `0x710cb0`/`0x710cf0`. A selection is instantly ghosted, and re-selecting is instant too.
#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) struct GhostKit {
/// The whole-model modulate colour (CharProc 1 — `cc+0x184..0x18c`), as the packed byte triple
/// [`benilla_world::instance_tint::pack`] wants.
pub(crate) tint: [u8; 3],
/// The whole-model alpha factor (CharProc 14 — `cc+0x180`), applied instantly.
pub(crate) alpha: f32,
}
/// The entities-side builder's output (decisions 0423 + 0465): the geoset-filtered, composited
/// parts for the current look (+ its equipment riders for a Select look), plus the body displayId
/// (for the booth's framing/rig) and a revision that bumps on every real change — a new successful
@@ -436,6 +477,11 @@ pub(crate) struct GluePreviewBake {
/// `HandsClosed` finger pose (wow-re `hand-grip-mechanism.md`). The builder knows the held items'
/// attach ids (which the flat rider list drops), so it resolves the grip here for the booth spawn.
pub(crate) grip: [bool; 2],
/// The selected character's **ghost** kit, or `None` for a living selection — see [`GhostKit`].
/// Its attached effect models are already folded into the lists above (they are ordinary
/// riders/cards/emitters at a body attach point); what stays here is what the *body itself*
/// renders with.
pub(crate) ghost: Option<GhostKit>,
pub(crate) revision: u64,
}
@@ -617,9 +663,10 @@ pub(super) fn sync_glue_scene(
ResMut<benilla_world::rig_palette::RigPaletteMirrors>,
ResMut<benilla_world::model_forms::ModelForms>,
ResMut<Assets<Mesh>>,
ResMut<benilla_world::instance_tint::InstanceTintMirrors>,
),
) {
let (mut palettes, mut mirrors, mut forms, mut mesh_assets) = particle_assets;
let (mut palettes, mut mirrors, mut forms, mut mesh_assets, mut tint_mirrors) = particle_assets;
let Some(booth) = booths.0.get(GLUE_SLOT) else {
return;
};
@@ -726,6 +773,11 @@ pub(super) fn sync_glue_scene(
// The scene's rigs skin from THIS buffer's palette region (decision 0720): register it
// as a mirror (re-registration replaces — a rebuilt scene's old buffer drops).
mirrors.0.insert("glue_scene", light.clone());
// …and the per-instance TINT region beside the palette rows (decision 0812's channel):
// the glue character's own modulate colour is read out of THIS buffer, and the glue scene
// is the one off-world buffer that carries it — see
// [`benilla_world::instance_tint::InstanceTintMirrors`] for why the portrait booths do not.
tint_mirrors.0.insert("glue_scene", light.clone());
blob.write(&queue, &light);
scene.fog = fog;
let dc = blob.probe_dc();
@@ -772,6 +824,7 @@ pub(super) fn sync_glue_scene(
// `CLOUDS`, a 0.73 gradient. Drawn at 1.0 the vignette blacked out the frame
// corners — B121.
alpha_anim: s.alpha_anim.clone(),
twins: BoothTwins::default(),
}
})
.collect();
@@ -791,6 +844,7 @@ pub(super) fn sync_glue_scene(
BoothMotion::Loop, // sequence 0 loops — flags wave, clouds drift
[false, false], // the backdrop scene has no hands to grip
&[], // …nor a character's eye-glow
BoothInstance::default(),
);
// The scene's authored particle emitters (decision 0539 §5) — the braziers/embers every
// UI_* scene carries (MainMenu 28, Orc 11, NightElf 12…). Lit/fogged by the SCENE's own
@@ -985,6 +1039,9 @@ pub(super) fn sync_glue_booth(
anim_data: Option<Res<crate::creature_anim::AnimData>>,
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
// The per-instance modulate colour (decision 0812): a ghost selection paints the whole bake
// through it, keyed on the rig slot this bake allocates — the reference's `cc+0x184..0x18c`.
mut tints: ResMut<benilla_world::instance_tint::InstanceTints>,
mut last: Local<Option<(u64, f32, u64)>>,
) {
let Some(booth) = booths.0.get_mut(GLUE_SLOT) else {
@@ -1058,12 +1115,40 @@ pub(super) fn sync_glue_booth(
_ => booth_light.studio.variant(material, materials),
}
};
// The bake's instance-level render properties — the ghost kit's alpha, or opaque
// (`GhostKit`, wow-re `glue-select-model.md` §A2). It reaches every batch of the bake, the
// riders and cards included: the reference has ONE instance alpha per CM2 and every model
// attached to it composes onto that (`0x714000`).
let instance = BoothInstance {
alpha: bake.ghost.map_or(1.0, |g| g.alpha),
};
// Each twin is relit onto the same buffer as its steady sibling — a blend twin bound to the
// world's light buffer would draw the ghost body at world-sun intensity mid-feather.
let relight_twins = |t: &BoothTwins,
scene: &mut Option<ResMut<CreateScene>>,
booth_light: &mut BoothLight,
materials: &mut Assets<WowModelMaterial>| {
if !instance.feathering() {
return BoothTwins::default(); // an opaque bake never reads them
}
BoothTwins {
blend: t
.blend
.as_ref()
.map(|m| relight(m, scene, booth_light, materials)),
zfill: t
.zfill
.as_ref()
.map(|m| relight(m, scene, booth_light, materials)),
}
};
let mut booth_parts = Vec::with_capacity(bake.parts.len());
for p in &bake.parts {
booth_parts.push(BoothPart {
skinned: p.skinned_mesh.clone(),
static_mesh: p.static_mesh.clone(),
material: relight(&p.material, &mut scene, &mut booth_light, &mut materials),
twins: relight_twins(&p.twins, &mut scene, &mut booth_light, &mut materials),
// `None`, and a KNOWN GAP (decision 0807): a mirrored `PreviewPart` doesn't carry
// the batch's alpha loops, so a character batch with an authored dimming constant
// previews at 1.0 here. Closing it means threading `alpha_anim` through the
@@ -1081,6 +1166,7 @@ pub(super) fn sync_glue_booth(
material: relight(&r.material, &mut scene, &mut booth_light, &mut materials),
bone: r.bone,
offset: r.offset,
twins: relight_twins(&r.twins, &mut scene, &mut booth_light, &mut materials),
})
.collect();
// The camera-facing batches (the eye-glow, a wand's gem, a glow model's quad), relit onto the
@@ -1095,6 +1181,7 @@ pub(super) fn sync_glue_booth(
bone: b.bone,
offset: b.offset,
kind: b.kind,
twins: relight_twins(&b.twins, &mut scene, &mut booth_light, &mut materials),
})
.collect();
commands.entity(booth.root).despawn_related::<Children>();
@@ -1112,6 +1199,20 @@ pub(super) fn sync_glue_booth(
BoothMotion::Loop, // the glue preview is a live scene, not a still
bake.grip, // close each hand that draws a weapon (the ref's paperdoll rule)
&booth_billboards,
instance,
);
// The whole-model TINT (`GhostKit::tint` — the reference's `cc+0x184..0x18c`, written by the
// select ghost proc `0x472939 -> 0x710cf0`): the bake's own rig slot indexes the per-instance
// table, and every batch that carries that slot in its `MeshTag` — body, riders, cards —
// reads it. Written every re-bake, cleared for a living selection, so a ghost's colour can
// never survive into the next character you click. (Slot `0` = a boneless bake or a full
// palette; `set` refuses it, which is the world's own no-rig sentinel rule.)
tints.set(
booth_rig.slot(),
match bake.ghost {
Some(g) => benilla_world::instance_tint::pack(g.tint),
None => benilla_world::instance_tint::IDENTITY,
},
);
// The worn items' effects: an equipped item model's OWN emitters (decision 0813 — the R14
// pauldron's sparkle, the torch's flame; `#bugs` B118) and the held weapons' `ItemVisuals`
@@ -1263,6 +1364,7 @@ pub(super) fn sync_glue_pet(
static_mesh: p.static_mesh.clone(),
material: relight(&p.material, &mut scene.variants),
alpha_anim: p.alpha_anim.clone(),
twins: BoothTwins::default(),
})
.collect();
let booth_billboards: Vec<BoothBillboardSpec> = pet
@@ -1274,6 +1376,7 @@ pub(super) fn sync_glue_pet(
bone: b.bone,
offset: b.offset,
kind: b.kind,
twins: BoothTwins::default(),
})
.collect();
commands
@@ -1294,6 +1397,7 @@ pub(super) fn sync_glue_pet(
BoothMotion::Loop, // Stand, looping — the same live scene the character is
[false, false], // …and no hands to grip with
&booth_billboards,
BoothInstance::default(),
);
// The pet's OWN emitters — the imp's flames (decision 1539). Authored on the pet's own bones,
// so this is the model's-own recipe, NOT the worn-item one (`spawn_booth_effects`, which seats
+14 -2
View File
@@ -81,15 +81,19 @@ mod framing;
pub(crate) use framing::{attachment_point, head_anchor, PortraitAnchors};
use framing::{body_frame, frame, PORTRAIT_FOV};
mod booth;
/// The translucency twins ride the *preview* shapes as well as the booth ones — one type for both
/// halves of the same batch, so the entities-side assembly and the bake cannot disagree about what
/// a batch can feather through.
pub(crate) use booth::BoothTwins;
use booth::{
clear_booth_rig, spawn_booth_effects, spawn_booth_model, spawn_booth_own_emitters,
BoothBillboardSpec, BoothEffects, BoothMotion, BoothPart, BoothRider,
BoothBillboardSpec, BoothEffects, BoothInstance, BoothMotion, BoothPart, BoothRider,
};
mod dressup;
pub(crate) use dressup::{DressUpBake, DressUpLook, DressUpPreview};
mod glue_booth;
pub(crate) use glue_booth::{
CreateLook, GlueLook, GluePetBake, GluePreview, GluePreviewBake, GlueScene, PetLook,
CreateLook, GhostKit, GlueLook, GluePetBake, GluePreview, GluePreviewBake, GlueScene, PetLook,
PreviewBillboard, PreviewEffects, PreviewPart, PreviewRider, SelectLook, GLUE_SLOT,
};
mod light;
@@ -2018,6 +2022,7 @@ fn sync_portraits(
// `None` — the same known gap as the glue preview's (decision 0807): a
// mirrored `PortraitPart` doesn't carry the batch's alpha loops.
alpha_anim: None,
twins: BoothTwins::default(),
})
.collect();
let booth_riders: Vec<BoothRider> = riders
@@ -2027,6 +2032,7 @@ fn sync_portraits(
material: booth_light.studio.variant(&r.material, &mut wow_mats),
bone: r.bone,
offset: r.offset,
twins: BoothTwins::default(),
})
.collect();
// The camera-facing batches — the eye-glow on its eye bone, an item's gem/halo at its
@@ -2040,6 +2046,7 @@ fn sync_portraits(
bone: b.bone,
offset: b.seat.offset(),
kind: b.kind,
twins: BoothTwins::default(),
})
.collect();
// A source material was not resident, so at least one "twin" above is the WORLD
@@ -2075,6 +2082,7 @@ fn sync_portraits(
// an open hand — rarely in frame on a bust, and its rule all the same.
[false, false],
&booth_billboards,
BoothInstance::default(),
);
// Even a Frozen still re-evaluates its paused pose every frame — the park matters
// MOST here, since a portrait's camera sleeps for the whole session outside its
@@ -2532,6 +2540,7 @@ fn sync_body_booth(
material: booth_light.pane.variant(&p.material, wow_mats),
// `None` — the same known gap as the glue preview's (decision 0807).
alpha_anim: None,
twins: BoothTwins::default(),
})
.collect();
let booth_riders: Vec<BoothRider> = riders
@@ -2541,6 +2550,7 @@ fn sync_body_booth(
material: booth_light.pane.variant(&r.material, wow_mats),
bone: r.bone,
offset: r.offset,
twins: BoothTwins::default(),
})
.collect();
// The camera-facing batches — the eye-glow on its eye bone, a wand's gem or the held torch's
@@ -2554,6 +2564,7 @@ fn sync_body_booth(
bone: b.bone,
offset: b.seat.offset(),
kind: b.kind,
twins: BoothTwins::default(),
})
.collect();
// The worn items' effects (decision 0822) — an equipped item's own emitters (0813, `#bugs`
@@ -2598,6 +2609,7 @@ fn sync_body_booth(
// whatever the body is holding, so a drawn weapon arrived in an open hand.
hand_grip(&riders, &billboards, &effects),
&booth_billboards,
BoothInstance::default(),
);
// The item effects go up on the posed skeleton's anchors — the body pane is the lane that
// gets them (the reference's `<PlayerModel>` widget renders live; the round portraits are
+4 -1
View File
@@ -7,7 +7,7 @@
use benilla_assets::M2Model;
use bevy::prelude::*;
use super::booth::{spawn_booth_model, BoothMotion, BoothPart};
use super::booth::{spawn_booth_model, BoothInstance, BoothMotion, BoothPart, BoothTwins};
use super::framing::{body_frame, frame, head_anchor, PortraitAnchors};
use super::{aim, test_mode, BoothCam, BoothLight, Booths, PaperDollBooth, PAPERDOLL_SLOT, SLOTS};
use benilla_assets::m2_url;
@@ -130,6 +130,7 @@ fn bake_test(
// The harness parses submeshes straight off the model, so the authored alpha is
// right here — an eyeball bake should show the batch dimming the artist wrote.
alpha_anim: s.alpha_anim.clone(),
twins: BoothTwins::default(),
})
.collect::<Vec<BoothPart>>()
};
@@ -185,6 +186,7 @@ fn bake_test(
BoothMotion::Frozen,
[false, false], // the WOW_PORTRAIT_TEST bake dresses no weapons
&[], // …nor an eye-glow
BoothInstance::default(),
)
.finish(commands);
aim(cams, token, &rig);
@@ -211,6 +213,7 @@ fn bake_test(
BoothMotion::Frozen,
[false, false], // the paper-doll still sheaths its weapons — no in-hand grip
&[], // eye-glow in the paper doll is the same follow-up (see above)
BoothInstance::default(),
)
.finish(commands);
// The eyeball harness has no live UI publishing a pane, so it bakes square.
+289
View File
@@ -0,0 +1,289 @@
//! The engine's screen fade — the whole frame to black, and back.
//!
//! This is the reference's own facility, not a cinematic's private effect. Two entry points, both
//! taking a duration in seconds:
//!
//! - **`0x4c0d10(ecx = completion fn, edx = its arg, [ebp+8] = seconds)` — fade OUT to black.** It
//! latches the completion pair and arms the fade; **the completion runs when the screen reaches
//! full black**, not after a delay on a parallel clock.
//! - **`0x4c1280(ecx, edx, seconds)` — fade IN from black.** A no-op unless a fade is currently up.
//!
//! That distinction is the whole reason this module exists rather than a `Timer` at each call site.
//! A cinematic boundary is not "wait 0.25 s, then advance" — it is "go black, and *when you are
//! black*, advance". The two coincide only while the frame rate holds; under a hitch (and the shot
//! boundary is exactly where the reference does a blocking terrain load) a delay-driven sequence
//! shows the cut, and a completion-driven one cannot. See `wow-5875-re`
//! `system/ui/scratch/cinematic-camera-law.md` §3.7, which verified this as a correction to an
//! earlier reading that had it as a delay.
//!
//! **The completion callback does not cross into this module.** The reference stores a function
//! pointer; an ECS system cannot, and faking it with a boxed closure in a `Resource` would buy
//! nothing. Instead this owns only the ramp — alpha and phase — and exposes [`ScreenFade::is_black`]
//! as a *level*. The caller keeps its own "which step is pending" state and runs it while black.
//! Same sequencing, with the step left where it can see the data it needs.
//!
//! The one caller today is `crate::cinematic`. It is written as the general facility because that
//! is what it is in the reference (`0x4c0d10` has callers well outside the cinematic band), not in
//! anticipation of a second one here.
use bevy::prelude::*;
/// The reference's fade duration at every cinematic boundary: `[0x804550] = 0.25`, in `.rdata` —
/// genuinely read-only, so this is a constant in the image and not some global's default.
pub(crate) const CINEMATIC: f32 = 0.25;
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
enum Phase {
/// Nothing up: alpha 0, the world visible.
#[default]
Clear,
/// Ramping 0 → 1.
Out,
/// Held at 1. **The fade stays here until someone calls [`ScreenFade::fade_in`]** — it does not
/// bounce on its own, because the caller's step runs in this phase and may take many frames.
Black,
/// Ramping 1 → 0.
In,
}
/// The screen fade's state. Drive it with [`fade_out`](ScreenFade::fade_out) /
/// [`fade_in`](ScreenFade::fade_in); read it with [`is_black`](ScreenFade::is_black).
#[derive(Resource, Default)]
pub(crate) struct ScreenFade {
alpha: f32,
phase: Phase,
/// Seconds for a full 0→1 traverse; the ramp is linear in time over this.
seconds: f32,
}
impl ScreenFade {
/// Start going to black. Re-arming while already fading out keeps the alpha reached so far, so
/// a second request cannot restart the ramp from clear and show a flash of world.
pub(crate) fn fade_out(&mut self, seconds: f32) {
if self.phase == Phase::Black {
return;
}
self.seconds = seconds.max(0.0);
self.phase = Phase::Out;
if self.seconds == 0.0 {
self.alpha = 1.0;
self.phase = Phase::Black;
}
}
/// Come back from black. **A no-op unless a fade is currently up** — the reference's own
/// guard at `0x4c1280`, which matters because the tail of a shot arm fades in unconditionally
/// and must not paint a black ramp over a screen that was never darkened.
pub(crate) fn fade_in(&mut self, seconds: f32) {
if self.phase == Phase::Clear {
return;
}
self.seconds = seconds.max(0.0);
self.phase = Phase::In;
if self.seconds == 0.0 {
self.alpha = 0.0;
self.phase = Phase::Clear;
}
}
/// The screen is fully black **and holding**: the caller's boundary step runs now.
pub(crate) fn is_black(&self) -> bool {
self.phase == Phase::Black
}
/// Nothing up — no fade in progress and the world fully visible.
///
/// Only the tests ask this today. It is the exact complement of the state `fade_in` refuses to
/// act on, so it is the honest way to assert a ramp came all the way home; it is not carried
/// as speculative API for a caller that does not exist.
#[cfg(test)]
fn is_clear(&self) -> bool {
self.phase == Phase::Clear
}
/// Drop the fade with no ramp. For a teardown that must not leave the screen black behind it
/// (leaving the world mid-cinematic, a failed start), where a graceful fade-in has no frames
/// left to run in.
pub(crate) fn clear(&mut self) {
self.alpha = 0.0;
self.phase = Phase::Clear;
}
fn tick(&mut self, dt: f32) {
let step = if self.seconds > 0.0 {
dt / self.seconds
} else {
1.0
};
match self.phase {
Phase::Out => {
self.alpha = (self.alpha + step).min(1.0);
if self.alpha >= 1.0 {
self.phase = Phase::Black;
}
}
Phase::In => {
self.alpha = (self.alpha - step).max(0.0);
if self.alpha <= 0.0 {
self.phase = Phase::Clear;
}
}
Phase::Clear | Phase::Black => {}
}
}
}
#[derive(Component)]
struct FadeCover;
pub(crate) struct ScreenFadePlugin;
impl Plugin for ScreenFadePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ScreenFade>()
.add_systems(Startup, spawn_cover)
// `PostUpdate`: every boundary step this frame — the cinematic's arm, advance and end —
// has already run, so the cover drawn is the one that matches the state the frame will
// actually present. Ticking before them would advance the ramp on last frame's answer.
.add_systems(PostUpdate, drive_cover);
}
}
/// One full-screen black node, spawned once and parked hidden — the loading cover's own shape.
fn spawn_cover(mut commands: Commands) {
commands.spawn((
FadeCover,
Node {
position_type: PositionType::Absolute,
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
BackgroundColor(Color::NONE),
// **Above the loading cover (1000).** The reference hands the world-enter loading screen
// over to the cinematic *under* the black: `0x48edd0`'s first instruction dismisses the
// loading screen, and it runs at full black (law §8.4). A fade below the cover could not
// do that — the cover would be the thing on screen, and its dismissal would be a visible
// cut. Below the glue screens (1100) so a state change out of the world can never be
// trapped behind a fade that has no one left to clear it.
GlobalZIndex(1050),
Visibility::Hidden,
));
}
fn drive_cover(
time: Res<Time>,
mut fade: ResMut<ScreenFade>,
mut cover: Query<(&mut BackgroundColor, &mut Visibility), With<FadeCover>>,
mut was: Local<Option<Phase>>,
) {
fade.tick(time.delta_secs());
// One line per edge, so a timeline (`scripts/cine.sh`) can see the boundaries at all. Four
// transitions per fade and two boundaries per single-shot cinematic is eight lines a run;
// without them the fade is invisible to every instrument we have.
if *was != Some(fade.phase) {
match fade.phase {
Phase::Out => info!("screen fade: out"),
Phase::Black => info!("screen fade: black"),
Phase::In => info!("screen fade: in"),
Phase::Clear => info!("screen fade: clear"),
}
*was = Some(fade.phase);
}
for (mut color, mut vis) in &mut cover {
let want = if fade.alpha > 0.0 {
Visibility::Inherited
} else {
Visibility::Hidden
};
if *vis != want {
*vis = want;
}
let next = Color::srgba(0.0, 0.0, 0.0, fade.alpha);
if color.0 != next {
color.0 = next;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ticks(fade: &mut ScreenFade, n: usize, dt: f32) {
for _ in 0..n {
fade.tick(dt);
}
}
#[test]
fn a_fade_out_reaches_black_and_holds_there() {
let mut fade = ScreenFade::default();
fade.fade_out(CINEMATIC);
// Half the duration is half way, and emphatically NOT black yet.
ticks(&mut fade, 5, CINEMATIC / 10.0);
assert!(!fade.is_black(), "half a ramp is not black");
ticks(&mut fade, 5, CINEMATIC / 10.0);
assert!(fade.is_black(), "a full ramp reaches black");
// …and stays. The caller's boundary step may take many frames; the fade must not bounce
// back on its own while it runs.
ticks(&mut fade, 100, CINEMATIC);
assert!(fade.is_black(), "black holds until fade_in");
}
#[test]
fn fade_in_from_black_returns_to_clear() {
let mut fade = ScreenFade::default();
fade.fade_out(CINEMATIC);
ticks(&mut fade, 10, CINEMATIC / 10.0);
assert!(fade.is_black());
fade.fade_in(CINEMATIC);
ticks(&mut fade, 10, CINEMATIC / 10.0);
assert!(fade.is_clear(), "a full ramp back reaches clear");
assert_eq!(fade.alpha, 0.0);
}
#[test]
fn fade_in_on_a_clear_screen_does_nothing() {
// The reference's `0x4c1280` guard. The tail of the shot arm fades in unconditionally;
// on a screen that was never darkened that must not start a ramp *from* black.
let mut fade = ScreenFade::default();
fade.fade_in(CINEMATIC);
assert!(fade.is_clear());
assert_eq!(fade.alpha, 0.0);
ticks(&mut fade, 10, CINEMATIC / 10.0);
assert_eq!(fade.alpha, 0.0, "no black ever appeared");
}
#[test]
fn re_arming_a_fade_out_does_not_restart_from_clear() {
// Two boundaries landing close together must not flash the world between them.
let mut fade = ScreenFade::default();
fade.fade_out(CINEMATIC);
ticks(&mut fade, 5, CINEMATIC / 10.0);
let half = fade.alpha;
assert!(half > 0.0 && half < 1.0);
fade.fade_out(CINEMATIC);
assert_eq!(fade.alpha, half, "the alpha reached so far is kept");
}
#[test]
fn a_zero_length_fade_is_instant_in_both_directions() {
let mut fade = ScreenFade::default();
fade.fade_out(0.0);
assert!(fade.is_black(), "no frames to ramp over: black now");
fade.fade_in(0.0);
assert!(fade.is_clear());
}
#[test]
fn clear_drops_a_fade_with_no_ramp() {
let mut fade = ScreenFade::default();
fade.fade_out(CINEMATIC);
ticks(&mut fade, 10, CINEMATIC / 10.0);
assert!(fade.is_black());
fade.clear();
assert!(fade.is_clear());
assert_eq!(fade.alpha, 0.0, "a teardown leaves no black behind it");
}
}
+22 -1
View File
@@ -549,7 +549,28 @@ pub(crate) fn unit_guild_emblem(
guild: &mut GuildState,
commands: &NetCommands,
) -> Option<GuildEmblem> {
let guild_id = fields.player_guild_id();
guild_emblem(fields.player_guild_id(), guild, commands)
}
/// The same crest for a **corpse**, whose guild id is its own snapshot
/// ([`ObjectFields::corpse_guild`]) rather than the living `PLAYER_GUILDID` — the reference reads
/// `CORPSE_FIELD_GUILD` at `0x5d6edf` and runs the identical name-cache lookup before installing
/// the emblem (`0x5d6ec0`; wow-re `corpse-decal-and-loot-sparkle.md` §6b). All four `None` cases
/// above hold unchanged: a guildless owner, a query still in flight, an undesigned crest.
pub(crate) fn corpse_guild_emblem(
fields: &ObjectFields,
guild: &mut GuildState,
commands: &NetCommands,
) -> Option<GuildEmblem> {
guild_emblem(fields.corpse_guild(), guild, commands)
}
/// The shared body of the two above — one lazy `CMSG_GUILD_QUERY` idiom, one `is_designed` gate.
fn guild_emblem(
guild_id: u32,
guild: &mut GuildState,
commands: &NetCommands,
) -> Option<GuildEmblem> {
let emblem = guild.resolve_identity(guild_id, commands)?.emblem;
emblem.is_designed().then_some(emblem)
}
@@ -171,3 +171,58 @@ fn the_screenshot_key_is_handed_back_to_its_binding() {
assert!(s.frame_key_input("ESCAPE"));
assert!(s.take_keybind_requests().is_empty());
}
/// **The HUD hide, end to end — and the frame that must survive it.**
///
/// This is the chain decision 1734 restored, and every link was broken until it did:
/// `CINEMATIC_START` → `CinematicFrame`'s arm calls `ShowUIPanel` → its `area = "full"` row routes
/// to `SetFullScreenFrame` → which hides `UIParent` → which cascades to every frame declaring
/// `parent="UIParent"`. Before, `SetFullScreenFrame` had that line dropped, and there was almost
/// nothing parented to cascade to; the engine hid the HUD with `UiHidden` instead, and paid for it
/// with a mouse that could not find the cinematic frame.
///
/// It replaces a log line. 1699 verified the takeover by watching `cinematic: HUD hidden for
/// playback` go past, because `UiHidden` hides at the *draw* and leaves every widget answering
/// `IsVisible() == true` — the predicate was useless. Hiding through the real cascade makes
/// `IsVisible` the honest question again, so the check is an assertion instead of a log.
#[test]
fn a_cinematic_hides_the_hud_through_uiparent_and_spares_the_cinematic_frame() {
let mut s = ui_with_the_cinematic_frame();
let visible = |s: &UiScript, f: &str| {
s.eval::<i64>(&format!("return {f}:IsVisible() and 1 or 0"))
.unwrap()
== 1
};
// A shown child of UIParent stands in for the HUD: `UiPanels.xml` gives us one without
// dragging the whole action bar in, and what is under test is the cascade, not which frame.
s.eval::<i64>("StaticPopup1:Show() return 0").unwrap();
s.resolve();
assert!(visible(&s, "UIParent"), "the HUD's parent starts visible");
assert!(visible(&s, "StaticPopup1"), "and so does its child");
start_cinematic(&mut s);
assert!(!visible(&s, "UIParent"), "SetFullScreenFrame hid UIParent");
assert!(
!visible(&s, "StaticPopup1"),
"and the hide cascaded to its children — this is the whole point of the 72 restored \
parent= declarations, and the assertion that fails if one is dropped again"
);
assert_eq!(
s.eval::<i64>("return StaticPopup1:IsShown() and 1 or 0")
.unwrap(),
1,
"cascaded, not shown=false: the frame keeps its own state and gets it back untouched"
);
assert!(
visible(&s, "CinematicFrame"),
"**and the fly-by itself survives** — the reference declares CinematicFrame with no \
parent precisely so the frame being shown escapes the hide that showing it performs"
);
s.set_in_cinematic(false);
s.fire_event("CINEMATIC_STOP", vec![]);
s.resolve();
assert!(visible(&s, "UIParent"), "and the world's UI comes back");
assert!(visible(&s, "StaticPopup1"), "with the child that was up");
}
@@ -25,6 +25,9 @@ fn harness_with(extra: &[&str]) -> UiScript {
s.set_screen_size(1024.0, 768.0);
let files: Vec<&str> = [
"Fonts.xml",
// `GameMenuFrame` and the panels it opens declare `parent="UIParent"`, resolved at LOAD
// (decision 1734) — UIParent must already be there, as it is in the manifest.
"UIParent.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIPanelTemplates.xml",
@@ -172,4 +172,34 @@ mod tests {
shipped.sort();
assert_eq!(listed, shipped);
}
/// **Nothing may declare `parent="UIParent"` before `UIParent.xml` has loaded.**
///
/// A parent name is resolved at LOAD, and a name that does not exist yet is not an error: the
/// loader warns and silently falls back to the enclosing frame. So this ordering mistake does
/// not fail, it *half-works* — the frame keeps drawing, keeps answering `IsShown`, and simply
/// never joins the cascade `UIParent:Hide()` walks. That is precisely how it would be missed.
///
/// It nearly was: `UIParent.xml` sat below `UiPanels.xml` until decision 1734, so restoring
/// `StaticPopup1`/`StaticPopup2`'s parents there would have written two declarations that did
/// nothing at all. The reference's own order is the fix (FrameXML.toc: BasicControls.xml l.6,
/// UIParent.xml l.8), and this keeps it.
#[test]
fn nothing_declares_a_uiparent_child_before_uiparent_itself_loads() {
let files = manifest_files();
let at = files
.iter()
.position(|f| f == "UIParent.xml")
.expect("the manifest lists UIParent.xml");
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
for early in &files[..at] {
let text = std::fs::read_to_string(dir.join(early)).unwrap();
assert!(
!text.contains(r#"parent="UIParent""#),
"{early} loads before UIParent.xml (position {at}) but declares a \
UIParent child — the loader would warn and silently drop it. Move \
UIParent.xml up, or the declaration down."
);
}
}
}
+6 -18
View File
@@ -450,26 +450,14 @@ fn capture_ui_active(capture: Option<Res<crate::run_mode::CaptureMode>>) -> bool
fn arbitrate_pointer_over_ui(
egui: Option<Res<EguiPointerOver>>,
hover: Res<PlayerUiHover>,
cinematic: Option<Res<crate::cinematic::Cinematic>>,
mut over: ResMut<PointerOverUi>,
) {
// **A cinematic covers the screen with a frame, so the pointer is over the UI wherever it
// is.** That is a fact about the reference's own layout, not a special case: `CinematicFrame`
// is `setAllPoints` + `enableMouse="true"`, and while it is up it is the only mouse target the
// hit test can reach — because `UIParent:Hide()` has taken every other frame out of it.
//
// benilla cannot yet reach that answer through the hit test. `crate::cinematic` substitutes
// `UiHidden` for the reference's `UIParent:Hide()` (decision 1699, for want of a HUD parented
// to `UIParent`), and `UiHidden` also switches the whole mouse feed off — ALT-Z's meaning,
// where the world *should* take the mouse. So `feed_ui_input` never hit-tested
// `CinematicFrame` at all and the world took every click of a fly-by: right-drag turned the
// character, left-click picked targets through the flying camera, the wheel zoomed a rig
// nobody could see. **This line states the layout fact directly, and it is the interim, not
// the fix** — restoring the 32 dropped `parent="UIParent"` declarations lets the hit test
// reach the same answer on its own, and this argument (and this parameter) is then dead code
// to delete.
let in_cinematic = cinematic.is_some_and(|c| c.is_playing());
over.0 = in_cinematic || egui.is_some_and(|e| e.0) || hover.0.is_some();
// A cinematic used to need a third term here, and no longer does — the deletion is the point
// of decision 1734. `CinematicFrame` is `setAllPoints` + `enableMouse="true"`, so while it is
// up it is the only mouse target the hit test can reach, because `UIParent:Hide()` has taken
// every other frame out of it. The hit test now arrives at that on its own, so
// [`PlayerUiHover`] carries it and the special case is gone.
over.0 = egui.is_some_and(|e| e.0) || hover.0.is_some();
}
/// The session lifecycle — the VM's birth, identity, death, and the reload. Every edge function
@@ -38,6 +38,9 @@ fn harness_on(mut s: UiScript) -> UiScript {
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
// Every panel window declares `parent="UIParent"`, resolved at LOAD — so UIParent has to
// exist by the time they are read, exactly as it does in the manifest (decision 1734).
"UIParent.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
@@ -1089,3 +1089,111 @@ fn the_inspect_cursor_pair_takes_both_arms() {
s.errors()
);
}
/// **Nothing of the interface is left on screen during a cinematic** — swept over the whole
/// shipped manifest, not one file at a time.
///
/// This is the test the director's eye had to stand in for. Decision 1734 restored 72 dropped
/// `parent=` declarations and a per-window test proved the cascade worked; the chat still drew
/// over the fly-by, because benilla carries the reference's `FloatingChatFrameTemplate` and
/// `ChatTabTemplate` under its own names (`BenillaChatFrameTemplate`, `BenillaChatTabTemplate`)
/// and the gap analysis that found the 72 skipped `virtual="true"` templates entirely. A
/// name-for-name comparison against the reference could not see it. **Sweeping what is actually
/// visible can**, which is why this is written against the observable and not against a list.
///
/// The three survivors are each required to survive:
///
/// - `CinematicFrame` — the frame being *shown*. The reference declares it with no parent for
/// exactly this reason, and `SetFullScreenFrame` shows it in the same breath as hiding UIParent.
/// - `WorldFrame` — the 3D scene's frame. Ours renders nothing (Bevy draws the world), but it is
/// the reference's own bottom-of-strata frame and a cinematic is a thing you watch *in* it.
/// - `BenillaFadeDriver` — a 1x1 frame with no textures and no layers, whose only content is an
/// `OnUpdate` running `UIFrameFadeUpdate`. It draws nothing, and it must not be hidden: a hidden
/// frame's `OnUpdate` does not run, so parenting it would freeze every in-flight `UIFrameFade`
/// the instant a cinematic started and leave frames stranded mid-fade.
#[test]
fn a_cinematic_leaves_nothing_of_the_interface_on_screen() {
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 names = shipped_frame_names();
let visible = |s: &benilla_ui::script::UiScript, n: &str| -> bool {
s.eval::<i64>(&format!(
"local f = getglobal(\"{n}\") \
if not f or not f.IsVisible then return 0 end \
return f:IsVisible() and 1 or 0"
))
.unwrap_or(0)
== 1
};
// The sweep is only worth anything if there was something to hide in the first place.
let before = names.iter().filter(|n| visible(&s, n)).count();
assert!(
before > 50,
"only {before} frames visible before the cinematic — the sweep found no interface to \
hide, so it would pass no matter what the cascade did"
);
s.set_in_cinematic(true);
s.fire_event("CINEMATIC_START", vec![]);
s.resolve();
let mut after: Vec<&str> = names
.iter()
.map(String::as_str)
.filter(|n| visible(&s, n))
.collect();
after.sort_unstable();
assert_eq!(
after,
["BenillaFadeDriver", "CinematicFrame", "WorldFrame"],
"something is drawing over the fly-by (see this test's header for why exactly these \
three are allowed to survive)"
);
// …and the player gets it all back.
s.set_in_cinematic(false);
s.fire_event("CINEMATIC_STOP", vec![]);
s.resolve();
let restored = names.iter().filter(|n| visible(&s, n)).count();
assert_eq!(
restored, before,
"the interface comes back exactly as it was"
);
}
/// Every named, non-virtual frame the shipped tree declares — the sweep's population.
fn shipped_frame_names() -> Vec<String> {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
let mut names: Vec<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 Some(i) = head.find("name=\"") else {
continue;
};
let rest = &head[i + 6..];
let Some(j) = rest.find('"') else { continue };
let n = &rest[..j];
// `$parent`-templated names are not globals; nothing can look them up by name.
if !n.is_empty() && !n.contains('$') {
names.push(n.to_string());
}
}
}
names.sort();
names.dedup();
names
}
+14 -1
View File
@@ -295,7 +295,20 @@ fn is_instrument_consumer(rel: &str) -> bool {
/// decides where a weapon is drawn versus stowed. There is no engine-side way to know it: the
/// engine sees a scene-graph child, and a child is not an attachment. The alternative was passing
/// the game's attach-slot table down into the renderer to keep a count flat (decision 1609).
const CEILING: usize = 163;
/// And 163 → 164: `instance_tint::InstanceTintMirrors`, the twin of `rig_palette::RigPaletteMirrors`
/// already through this door and registered at exactly the same site — an off-world `wow_light`
/// buffer the engine must also fill with the per-instance tint region, because the shader reads
/// that region out of whichever buffer the draw binds. Uploading a region to the buffers that
/// carry it is machinery. *Which* off-world buffers carry it is policy, and it has to be: a
/// portrait bake must NOT (the reference builds a fresh CM2 with colour `(1,1,1)`, so a ghost's
/// portrait shows the living face — wow-re `ghost-death-visuals.md` §6, report B49, decision 1481)
/// while the glue scene MUST (it is the screen itself, and its character component is the very
/// instance the reference tints). The engine cannot tell those two render targets apart — both are
/// a camera writing to an image — and encoding "a bake standing in for a UI model widget" inside
/// 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;
/// 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
@@ -731,6 +731,16 @@ impl ObjectFields {
(display != 0).then_some((display, (raw >> 24) as u8))
}
/// `CORPSE_FIELD_GUILD` (field 34, `OBJECT_END + 0x1C`) — the dead player's guild id,
/// snapshotted at death (vmangos `Player::CreateCorpse` writes `GetGuildId()`). The reference
/// reads exactly this word at `0x5d6edf` (`[[corpse+0x110]+0x70]`) and hands it, with
/// `CORPSE_FIELD_OWNER`, to the guild-name-cache lookup `0x6d6d20` that feeds the corpse's
/// **tabard emblem** install `0x5d6ec0` (wow-re `corpse-decal-and-loot-sparkle.md` §6b). A
/// corpse therefore wears its owner's crest exactly as the living body did. `0` = guildless.
pub fn corpse_guild(&self) -> u32 {
self.get_u32(34).unwrap_or(0)
}
/// `CORPSE_FIELD_BYTES_1` (field 32, `OBJECT_END + 0x1A`) — byte 1 **race**, byte 2 **gender**,
/// byte 3 **skinColor** (byte 0 is unused; vmangos writes `(0) | (race<<8) | (gender<<16) |
/// (skin<<24)`, `Corpse.cpp:228`). The reference reads them one byte at a time off the same
+42 -11
View File
@@ -82,6 +82,30 @@ pub fn pack(rgb: [u8; 3]) -> u32 {
0xff00_0000 | (u32::from(rgb[0]) << 16) | (u32::from(rgb[1]) << 8) | u32::from(rgb[2])
}
/// Off-world `wow_light`-layout buffers that also carry the tint region — registered by key, the
/// same shape as [`crate::rig_palette::RigPaletteMirrors`] and deliberately NOT the same list.
///
/// **Why this is opt-in, and why the portrait booths are not on it.** The reference's per-instance
/// tint lives on ONE CM2 instance (`[inst+0x184..0x18c]`, setter `0x710cf0`). A unit-frame portrait
/// does not render that instance: the bake `0x524f60` builds a **fresh** CM2 from it whose ctor sets
/// colour `(1,1,1)` and alpha `1.0` (`0x70ea60`-`0x70ea89`/`0x70eaca`), and `0x525261 call 0x47a230`
/// then sets those very two fields *again* — so a ghost's portrait shows the pre-death, untinted,
/// fully-opaque face, and "a client that tints the portrait when the ghost flags are set diverges
/// from 1.12.1" (wow-re `ghost-death-visuals.md` §6, VERIFIED; benilla report B49, decision 1481).
/// Our round portraits mirror the world entity's own children, so they carry the WORLD unit's rig
/// slot — pushing this region into their buffer would tint them with it. Their buffer keeps the
/// zero-initialised (identity) region instead, which is the reference's fresh-instance behaviour
/// reproduced by construction.
///
/// The **glue scene** is the opposite case and is why the list exists: it is not a bake standing in
/// for a UI model widget, it is the screen itself, and its character component is exactly the
/// instance the reference writes (`0x472939 -> 0x710cf0`, the char-select ghost proc). It carries its
/// OWN rig slot, allocated by the booth spawn, so nothing a world unit does can reach it.
#[derive(Resource, Clone, Default, ExtractResource)]
pub struct InstanceTintMirrors(
pub std::collections::HashMap<&'static str, bevy::render::render_resource::Buffer>,
);
/// The live per-slot tint table, indexed by the instance's `MeshTag` rig slot. `Arc`-shared so the
/// render-world extract is a pointer bump, and generation-stamped so a world with nothing tinted
/// uploads nothing at all.
@@ -137,26 +161,33 @@ impl InstanceTints {
fn upload_instance_tints(
queue: Res<RenderQueue>,
shared: Option<Res<crate::lighting::SharedLightBuffer>>,
mirrors: Option<Res<InstanceTintMirrors>>,
tints: Option<Res<InstanceTints>>,
mut last: Local<Option<u64>>,
) {
let (Some(shared), Some(tints)) = (shared, tints) else {
return;
};
if *last == Some(tints.generation) {
let Some(tints) = tints else { return };
// The generation gate covers the mirror list too: a mirror registered after the last write
// would otherwise never be filled. Mirrors are registered once per booth/scene buffer, so
// folding their count into the gate costs one comparison and closes that hole.
let mirrors = mirrors
.map(|m| m.0.values().cloned().collect::<Vec<_>>())
.unwrap_or_default();
let gate = tints.generation ^ ((mirrors.len() as u64) << 40);
if *last == Some(gate) {
return;
}
*last = Some(tints.generation);
queue.write_buffer(
&shared.0,
region_offset(),
bytemuck::cast_slice(tints.slots.as_slice()),
);
*last = Some(gate);
let words = bytemuck::cast_slice(tints.slots.as_slice());
for buffer in shared.iter().map(|s| &s.0).chain(mirrors.iter()) {
queue.write_buffer(buffer, region_offset(), words);
}
}
pub fn plugin(app: &mut App) {
app.init_resource::<InstanceTints>()
.add_plugins(ExtractResourcePlugin::<InstanceTints>::default());
.init_resource::<InstanceTintMirrors>()
.add_plugins(ExtractResourcePlugin::<InstanceTints>::default())
.add_plugins(ExtractResourcePlugin::<InstanceTintMirrors>::default());
if let Some(render) = app.get_sub_app_mut(RenderApp) {
render.add_systems(
Render,
+20 -12
View File
@@ -29,6 +29,7 @@
//! transparent scene content sorting within that window behind a fading body draws after the
//! prime and is depth-clipped where the body covers it, for the seconds the episode lasts.
use bevy::camera::visibility::RenderLayers;
use bevy::mesh::MeshTag;
use bevy::prelude::*;
@@ -64,6 +65,11 @@ type ZfillParts<'w, 's> = Query<
&'static MeshTag,
&'static Mesh3d,
Option<&'static ZfillTwin>,
// The part's render layer, when it is not on the default one. A world part carries none
// and the twin needs none; a BOOTH part is on its booth's own layer, and a twin that did
// not copy it would prime depth in the WORLD view (where it is invisible and useless)
// while the booth body it exists for stayed multi-layered.
Option<&'static RenderLayers>,
),
Without<ZfillTwinOf>,
>;
@@ -73,23 +79,25 @@ pub(crate) fn sync_zfill_twins(
parts: ZfillParts,
mut twins: Query<(&ZfillTwinOf, &mut MeshTag, &mut Mesh3d), Without<FadeMaterials>>,
) {
for (part, fm, tag, mesh, twin) in &parts {
for (part, fm, tag, mesh, twin, layers) in &parts {
let Some(mat) = fm.zfill.as_ref() else {
continue; // no-z-write / Mod / Mod2x batch — the reference's own twin gate
};
let active = crate::mesh_tag::translucent(tag.0);
match twin {
None if active => {
let t = commands
.spawn((
Mesh3d(mesh.0.clone()),
MeshMaterial3d(mat.clone()),
MeshTag(tag.0),
Transform::IDENTITY,
ZfillTwinOf(part),
ChildOf(part),
))
.id();
let mut twin = commands.spawn((
Mesh3d(mesh.0.clone()),
MeshMaterial3d(mat.clone()),
MeshTag(tag.0),
Transform::IDENTITY,
ZfillTwinOf(part),
ChildOf(part),
));
if let Some(layers) = layers {
twin.insert(layers.clone());
}
let t = twin.id();
commands.entity(part).insert(ZfillTwin(t));
trace("arm", part, tag.0);
}
@@ -105,7 +113,7 @@ pub(crate) fn sync_zfill_twins(
// The part's tag moves every fade frame (its alpha bits); mirroring it keeps the rig slot
// — the half the twin's vertex stage actually reads — correct across a redress or a slot
// reassignment without a dedicated edge.
let Ok((_, _, ptag, pmesh, _)) = parts.get(of.0) else {
let Ok((_, _, ptag, pmesh, _, _)) = parts.get(of.0) else {
continue; // part going away this frame — the child despawns with it
};
if tag.0 != ptag.0 {