diff --git a/crates/benilla-app/assets/ui/ActionBar.xml b/crates/benilla-app/assets/ui/ActionBar.xml
index e906ee57..72f59b5f 100644
--- a/crates/benilla-app/assets/ui/ActionBar.xml
+++ b/crates/benilla-app/assets/ui/ActionBar.xml
@@ -1715,7 +1715,7 @@
-
+
@@ -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).
-->
-
+
@@ -1956,51 +1956,51 @@
-
+
BenillaActionButton_OnLoad(self, 1)
-
+
BenillaActionButton_OnLoad(self, 2)
-
+
BenillaActionButton_OnLoad(self, 3)
-
+
BenillaActionButton_OnLoad(self, 4)
-
+
BenillaActionButton_OnLoad(self, 5)
-
+
BenillaActionButton_OnLoad(self, 6)
-
+
BenillaActionButton_OnLoad(self, 7)
-
+
BenillaActionButton_OnLoad(self, 8)
-
+
BenillaActionButton_OnLoad(self, 9)
-
+
BenillaActionButton_OnLoad(self, 10)
-
+
BenillaActionButton_OnLoad(self, 11)
-
+
BenillaActionButton_OnLoad(self, 12)
@@ -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. -->
-
+
@@ -2026,7 +2026,7 @@
ActionBar_PageUp() PlaySound("UChatScrollButton")
-
+
@@ -2074,7 +2074,7 @@
-
+
BenillaPerformanceBar_OnEnter(self)
BenillaPerformanceBar_OnLeave()
diff --git a/crates/benilla-app/assets/ui/BagFrame.xml b/crates/benilla-app/assets/ui/BagFrame.xml
index 081d1e84..d2977057 100644
--- a/crates/benilla-app/assets/ui/BagFrame.xml
+++ b/crates/benilla-app/assets/ui/BagFrame.xml
@@ -1856,7 +1856,7 @@
the bar. (MainMenuBarArtFrame is defined in ActionBar.xml, which the loader loads before
this file; the anchor resolves by name.)
-->
-
+
@@ -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).
-->
-
+
@@ -1989,7 +1989,7 @@
BenillaBagBarSlot_Place(self)
-
+
@@ -2043,7 +2043,7 @@
BenillaBagBarSlot_Place(self)
-
+
@@ -2097,7 +2097,7 @@
BenillaBagBarSlot_Place(self)
-
+
@@ -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.
-->
-
+
diff --git a/crates/benilla-app/assets/ui/BankFrame.xml b/crates/benilla-app/assets/ui/BankFrame.xml
index 32de67f7..e67a7455 100644
--- a/crates/benilla-app/assets/ui/BankFrame.xml
+++ b/crates/benilla-app/assets/ui/BankFrame.xml
@@ -296,7 +296,7 @@
-
+
@@ -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). -->
-
+
diff --git a/crates/benilla-app/assets/ui/ChatFrame.xml b/crates/benilla-app/assets/ui/ChatFrame.xml
index 473e313e..3064ddc8 100644
--- a/crates/benilla-app/assets/ui/ChatFrame.xml
+++ b/crates/benilla-app/assets/ui/ChatFrame.xml
@@ -1412,7 +1412,7 @@
one script here that is genuinely ChatFrame1's rather than the dock's, so the template
carries the gate rather than the registration.
-->
-
-
+
@@ -2198,7 +2198,7 @@
-
+
@@ -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. -->
-
+
-
+
BenillaOnEmoteMenuLoad(BenillaEmoteMenuList)
-
+
BenillaOnEmoteMenuLoad(BenillaVoiceMacroList)
@@ -2271,7 +2271,7 @@
arrow-steering working and the keys being dead. -->
-
+
diff --git a/crates/benilla-app/assets/ui/DressUpFrame.xml b/crates/benilla-app/assets/ui/DressUpFrame.xml
index 04ff0a8d..ab5a0f4f 100644
--- a/crates/benilla-app/assets/ui/DressUpFrame.xml
+++ b/crates/benilla-app/assets/ui/DressUpFrame.xml
@@ -147,7 +147,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/ErrorsFrame.xml b/crates/benilla-app/assets/ui/ErrorsFrame.xml
index a25bc854..7bcfbdb0 100644
--- a/crates/benilla-app/assets/ui/ErrorsFrame.xml
+++ b/crates/benilla-app/assets/ui/ErrorsFrame.xml
@@ -48,7 +48,7 @@
end
]]>
-
+
@@ -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). -->
-
+
diff --git a/crates/benilla-app/assets/ui/FriendsFrame.xml b/crates/benilla-app/assets/ui/FriendsFrame.xml
index b85d8595..44d109b1 100644
--- a/crates/benilla-app/assets/ui/FriendsFrame.xml
+++ b/crates/benilla-app/assets/ui/FriendsFrame.xml
@@ -2043,7 +2043,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/GameMenuFrame.xml b/crates/benilla-app/assets/ui/GameMenuFrame.xml
index f4e50673..4e5ddb05 100644
--- a/crates/benilla-app/assets/ui/GameMenuFrame.xml
+++ b/crates/benilla-app/assets/ui/GameMenuFrame.xml
@@ -210,7 +210,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/GameTooltip.xml b/crates/benilla-app/assets/ui/GameTooltip.xml
index e1833092..be11b962 100644
--- a/crates/benilla-app/assets/ui/GameTooltip.xml
+++ b/crates/benilla-app/assets/ui/GameTooltip.xml
@@ -455,7 +455,7 @@
-
+
-
-
+
+
diff --git a/crates/benilla-app/assets/ui/GossipFrame.xml b/crates/benilla-app/assets/ui/GossipFrame.xml
index e350270e..305b974b 100644
--- a/crates/benilla-app/assets/ui/GossipFrame.xml
+++ b/crates/benilla-app/assets/ui/GossipFrame.xml
@@ -292,7 +292,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/ItemRef.xml b/crates/benilla-app/assets/ui/ItemRef.xml
index a4e629aa..c433c708 100644
--- a/crates/benilla-app/assets/ui/ItemRef.xml
+++ b/crates/benilla-app/assets/ui/ItemRef.xml
@@ -93,7 +93,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/LootFrame.xml b/crates/benilla-app/assets/ui/LootFrame.xml
index 1f40c982..0e24fec5 100644
--- a/crates/benilla-app/assets/ui/LootFrame.xml
+++ b/crates/benilla-app/assets/ui/LootFrame.xml
@@ -558,7 +558,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/MailFrame.xml b/crates/benilla-app/assets/ui/MailFrame.xml
index 110b3c3b..e61fb60c 100644
--- a/crates/benilla-app/assets/ui/MailFrame.xml
+++ b/crates/benilla-app/assets/ui/MailFrame.xml
@@ -792,7 +792,7 @@
-
+
@@ -1414,7 +1414,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/MerchantFrame.xml b/crates/benilla-app/assets/ui/MerchantFrame.xml
index c89c9fa8..41a769cc 100644
--- a/crates/benilla-app/assets/ui/MerchantFrame.xml
+++ b/crates/benilla-app/assets/ui/MerchantFrame.xml
@@ -776,7 +776,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/MicroMenu.xml b/crates/benilla-app/assets/ui/MicroMenu.xml
index de6fc068..115dc280 100644
--- a/crates/benilla-app/assets/ui/MicroMenu.xml
+++ b/crates/benilla-app/assets/ui/MicroMenu.xml
@@ -279,7 +279,7 @@
-
+
@@ -299,7 +299,7 @@
-
+
@@ -309,7 +309,7 @@
-
+
@@ -320,7 +320,7 @@
-
+
@@ -334,7 +334,7 @@
-
+
@@ -344,7 +344,7 @@
-
+
@@ -357,7 +357,7 @@
-
+
@@ -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. -->
-
+
diff --git a/crates/benilla-app/assets/ui/MultiBars.xml b/crates/benilla-app/assets/ui/MultiBars.xml
index c56eb69c..8ce393a7 100644
--- a/crates/benilla-app/assets/ui/MultiBars.xml
+++ b/crates/benilla-app/assets/ui/MultiBars.xml
@@ -278,7 +278,7 @@
them (its HorizontalMultiBar1/2 templates carry it too, l.36/l.151), and the shipped state
a character with a zero toggle byte lands in. MultiActionBar_Update is the only thing that
opens one. -->
-
+
@@ -336,7 +336,7 @@
-
+
@@ -416,7 +416,7 @@
RIGHT_ACTIONBAR_PAGE = 3 (actions 25..36). Geometry from ref-MultiActionBars.xml: a 38x500
frame, button 1 at TOPRIGHT, each next TOP-to-BOTTOM of the previous at -6. -->
-
+
@@ -473,7 +473,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index 70627e74..b3200428 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -1642,7 +1642,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/QuestFrame.xml b/crates/benilla-app/assets/ui/QuestFrame.xml
index 77a95b96..ea108cd4 100644
--- a/crates/benilla-app/assets/ui/QuestFrame.xml
+++ b/crates/benilla-app/assets/ui/QuestFrame.xml
@@ -865,7 +865,7 @@
-
+
@@ -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. -->
-
+
diff --git a/crates/benilla-app/assets/ui/QuestTimerFrame.xml b/crates/benilla-app/assets/ui/QuestTimerFrame.xml
index f1ef47ee..1d8b9c35 100644
--- a/crates/benilla-app/assets/ui/QuestTimerFrame.xml
+++ b/crates/benilla-app/assets/ui/QuestTimerFrame.xml
@@ -151,7 +151,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/SpellBookFrame.xml b/crates/benilla-app/assets/ui/SpellBookFrame.xml
index d7642b29..7ffdcb47 100644
--- a/crates/benilla-app/assets/ui/SpellBookFrame.xml
+++ b/crates/benilla-app/assets/ui/SpellBookFrame.xml
@@ -758,7 +758,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/StanceBar.xml b/crates/benilla-app/assets/ui/StanceBar.xml
index 7228c0fe..059ffb52 100644
--- a/crates/benilla-app/assets/ui/StanceBar.xml
+++ b/crates/benilla-app/assets/ui/StanceBar.xml
@@ -267,7 +267,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/TaxiFrame.xml b/crates/benilla-app/assets/ui/TaxiFrame.xml
index e125e637..f7918fb8 100644
--- a/crates/benilla-app/assets/ui/TaxiFrame.xml
+++ b/crates/benilla-app/assets/ui/TaxiFrame.xml
@@ -406,7 +406,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/TradeFrame.xml b/crates/benilla-app/assets/ui/TradeFrame.xml
index 8102bc11..25a68e8e 100644
--- a/crates/benilla-app/assets/ui/TradeFrame.xml
+++ b/crates/benilla-app/assets/ui/TradeFrame.xml
@@ -320,7 +320,7 @@
-
+
diff --git a/crates/benilla-app/assets/ui/UiPanels.xml b/crates/benilla-app/assets/ui/UiPanels.xml
index 7bb79358..9441585c 100644
--- a/crates/benilla-app/assets/ui/UiPanels.xml
+++ b/crates/benilla-app/assets/ui/UiPanels.xml
@@ -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 — ` ` — 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 @@
StaticPopup_OnUpdate(this, arg1)
-
+
-
+
diff --git a/crates/benilla-app/assets/ui/UnitFrames.xml b/crates/benilla-app/assets/ui/UnitFrames.xml
index 5c709785..77a5c6c5 100644
--- a/crates/benilla-app/assets/ui/UnitFrames.xml
+++ b/crates/benilla-app/assets/ui/UnitFrames.xml
@@ -1179,7 +1179,7 @@
BELOW the MEDIUM panel windows on purpose, and the player frame shares the top-left corner
with the left-slot panel. See the note on PartyFrame.xml's template (decision 0597) for why
the default MEDIUM makes a unit frame's bar children draw through a window's background. -->
-
+
@@ -1519,7 +1519,7 @@
every login. Steady-state behavior is identical — Update Shows/Hides on target changes. -->
-
+
diff --git a/crates/benilla-app/assets/ui/WorldMapFrame.xml b/crates/benilla-app/assets/ui/WorldMapFrame.xml
index 9667ae2b..541e9f8f 100644
--- a/crates/benilla-app/assets/ui/WorldMapFrame.xml
+++ b/crates/benilla-app/assets/ui/WorldMapFrame.xml
@@ -429,7 +429,18 @@
-
+
+
diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc
index 093279c0..08be2ac3 100644
--- a/crates/benilla-app/assets/ui/benilla.toc
+++ b/crates/benilla-app/assets/ui/benilla.toc
@@ -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
diff --git a/crates/benilla-app/src/cinematic.rs b/crates/benilla-app/src/cinematic.rs
index 8488d9a6..6eece84e 100644
--- a/crates/benilla-app/src/cinematic.rs
+++ b/crates/benilla-app/src/cinematic.rs
@@ -93,6 +93,27 @@ pub(crate) struct Cinematic {
/// single-slot latch (`0xc4d75c`), last write wins, no queue.
pending: Option,
playing: Option,
+ /// 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,
+ /// 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,
- mut hidden: ResMut,
mut bars: Query<(&mut Node, &mut Visibility), With>,
windows: Query<&Window>,
mut cursor: Query<&mut bevy::window::CursorOptions, With>,
@@ -287,17 +314,11 @@ fn drive_letterbox(
mut logged: Local,
) {
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>,
net: Option>,
state: Res>,
+ mut fade: ResMut,
) {
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,
time: Res,
net: Option>,
+ mut fade: ResMut,
+ progress: Option>,
+ screen: Option>,
mut camera: Query<&mut Transform, With>,
) {
+ // --- 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) {
+fn abandon_on_leaving_world(
+ mut cine: ResMut,
+ mut fade: ResMut,
+) {
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::();
+ 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::().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::().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::();
app.add_systems(Update, abandon_on_leaving_world);
app.update();
assert_eq!(acks(&rx), 0);
assert!(!app.world().resource::().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) {
+ 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::();
+ 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::().is_playing(),
+ "the shot is over and acked"
+ );
+ assert!(
+ app.world().resource::().settling.is_some(),
+ "…and the end boundary latched a settle rather than finishing"
+ );
+ assert!(
+ app.world()
+ .resource::()
+ .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::().settling.is_none(),
+ "the settle is done"
+ );
+ assert!(
+ !app.world()
+ .resource::()
+ .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::().settling = Some(0.0);
+ app.update();
+ assert!(app.world().resource::().settling.is_none());
+ assert!(
+ !app.world()
+ .resource::()
+ .is_black(),
+ "the screen comes back even though the world did not"
+ );
+ }
+}
diff --git a/crates/benilla-app/src/creature_anim/select.rs b/crates/benilla-app/src/creature_anim/select.rs
index 55024999..8096b2b8 100644
--- a/crates/benilla-app/src/creature_anim/select.rs
+++ b/crates/benilla-app/src/creature_anim/select.rs
@@ -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
diff --git a/crates/benilla-app/src/death.rs b/crates/benilla-app/src/death.rs
index d2a825fc..2286a707 100644
--- a/crates/benilla-app/src/death.rs
+++ b/crates/benilla-app/src/death.rs
@@ -173,7 +173,19 @@ fn feed_death(
index: Res,
transforms: Query<&Transform>,
map: Option>,
+ status: Res,
) {
+ // **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,
+ mut feed: ResMut,
+) {
+ 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::()
+ .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::().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::();
+ 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"
+ );
+ }
+}
diff --git a/crates/benilla-app/src/debug_panel/inspect.rs b/crates/benilla-app/src/debug_panel/inspect.rs
index 8415cfa7..9b21997b 100644
--- a/crates/benilla-app/src/debug_panel/inspect.rs
+++ b/crates/benilla-app/src/debug_panel/inspect.rs
@@ -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),
diff --git a/crates/benilla-app/src/entities/attach/preview.rs b/crates/benilla-app/src/entities/attach/preview.rs
index 2d817180..8e980cbd 100644
--- a/crates/benilla-app/src/entities/attach/preview.rs
+++ b/crates/benilla-app/src/entities/attach/preview.rs
@@ -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,
asset_server: Res,
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>,
+ mut fx: Option>,
// 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,
@@ -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,
+ billboards: Vec,
+ effects: Vec,
+}
+
+/// 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 {
+ 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 {
let PreviewSpec {
display_id,
@@ -540,6 +709,7 @@ fn assemble(spec: &PreviewSpec, ctx: &mut PreviewCtx<'_, '_>) -> Option) -> Option) -> Option 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 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
diff --git a/crates/benilla-app/src/entities/corpse.rs b/crates/benilla-app/src/entities/corpse.rs
index f3c52ff1..88852e69 100644
--- a/crates/benilla-app/src/entities/corpse.rs
+++ b/crates/benilla-app/src/entities/corpse.rs
@@ -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
diff --git a/crates/benilla-app/src/entities/equipment/resolve.rs b/crates/benilla-app/src/entities/equipment/resolve.rs
index fdeef8a6..50297a59 100644
--- a/crates/benilla-app/src/entities/equipment/resolve.rs
+++ b/crates/benilla-app/src/entities/equipment/resolve.rs
@@ -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, Option<&Equipment>),
- Or<(Changed, Without)>,
- >,
+ corpses: Query<(Entity, &NetEntity, Ref, Option<&Equipment>)>,
held: Option>,
asset_server: Res,
+ net: Res,
+ // 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>,
+ mut last_guild_epoch: Local>,
) {
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::();
+ app.insert_resource(NetCommands(tx));
app.insert_resource(ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(
std::collections::HashMap::new(),
diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs
index d1c6e1b3..2ab45bff 100644
--- a/crates/benilla-app/src/lib.rs
+++ b/crates/benilla-app/src/lib.rs
@@ -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)
diff --git a/crates/benilla-app/src/player/mover.rs b/crates/benilla-app/src/player/mover.rs
index 48856739..25d5abf7 100644
--- a/crates/benilla-app/src/player/mover.rs
+++ b/crates/benilla-app/src/player/mover.rs
@@ -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;
}
diff --git a/crates/benilla-app/src/player/state.rs b/crates/benilla-app/src/player/state.rs
index a1d436e5..7b04f9d6 100644
--- a/crates/benilla-app/src/player/state.rs
+++ b/crates/benilla-app/src/player/state.rs
@@ -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
diff --git a/crates/benilla-app/src/portrait/booth.rs b/crates/benilla-app/src/portrait/booth.rs
index 2bb13c5e..89c295d1 100644
--- a/crates/benilla-app/src/portrait/booth.rs
+++ b/crates/benilla-app/src/portrait/booth.rs
@@ -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>,
+ /// 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>,
+ pub(crate) zfill: Option>,
+}
+
+/// **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,
+ twins: &BoothTwins,
+ ) -> Handle {
+ 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,
+ twins: &BoothTwins,
+ ) -> Option {
+ 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,
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,
+ /// 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,
+ Handle,
+ Handle,
+ ) {
+ let assets = Assets::::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());
+ }
+}
diff --git a/crates/benilla-app/src/portrait/dressup.rs b/crates/benilla-app/src/portrait/dressup.rs
index 45e116ff..f8731aea 100644
--- a/crates/benilla-app/src/portrait/dressup.rs
+++ b/crates/benilla-app/src/portrait/dressup.rs
@@ -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 = 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 = 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,
diff --git a/crates/benilla-app/src/portrait/glue_booth.rs b/crates/benilla-app/src/portrait/glue_booth.rs
index 127d8ec5..39a2709c 100644
--- a/crates/benilla-app/src/portrait/glue_booth.rs
+++ b/crates/benilla-app/src/portrait/glue_booth.rs
@@ -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>,
+ /// 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,
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,
}
+/// 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,
pub(crate) revision: u64,
}
@@ -617,9 +663,10 @@ pub(super) fn sync_glue_scene(
ResMut,
ResMut,
ResMut>,
+ ResMut,
),
) {
- 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>,
mut cams: Query<(&BoothCam, &mut Transform, &mut Projection)>,
mut palettes: ResMut,
+ // 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,
mut last: Local>,
) {
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>,
+ booth_light: &mut BoothLight,
+ materials: &mut Assets| {
+ 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::();
@@ -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 = 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
diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs
index ebe1ed83..01af65d7 100644
--- a/crates/benilla-app/src/portrait/mod.rs
+++ b/crates/benilla-app/src/portrait/mod.rs
@@ -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 = 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 = 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 `` widget renders live; the round portraits are
diff --git a/crates/benilla-app/src/portrait/test_bake.rs b/crates/benilla-app/src/portrait/test_bake.rs
index 62b81e21..9fad9054 100644
--- a/crates/benilla-app/src/portrait/test_bake.rs
+++ b/crates/benilla-app/src/portrait/test_bake.rs
@@ -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::>()
};
@@ -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.
diff --git a/crates/benilla-app/src/screen_fade.rs b/crates/benilla-app/src/screen_fade.rs
new file mode 100644
index 00000000..e0b6fab5
--- /dev/null
+++ b/crates/benilla-app/src/screen_fade.rs
@@ -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::()
+ .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,
+ mut fade: ResMut,
+ mut cover: Query<(&mut BackgroundColor, &mut Visibility), With>,
+ mut was: Local>,
+) {
+ 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");
+ }
+}
diff --git a/crates/benilla-app/src/ui_guild/mod.rs b/crates/benilla-app/src/ui_guild/mod.rs
index 1b0139b0..0f360d26 100644
--- a/crates/benilla-app/src/ui_guild/mod.rs
+++ b/crates/benilla-app/src/ui_guild/mod.rs
@@ -549,7 +549,28 @@ pub(crate) fn unit_guild_emblem(
guild: &mut GuildState,
commands: &NetCommands,
) -> Option {
- 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 {
+ 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 {
let emblem = guild.resolve_identity(guild_id, commands)?.emblem;
emblem.is_designed().then_some(emblem)
}
diff --git a/crates/benilla-app/src/ui_script/cinematic_tests.rs b/crates/benilla-app/src/ui_script/cinematic_tests.rs
index fb79c0f7..50ead786 100644
--- a/crates/benilla-app/src/ui_script/cinematic_tests.rs
+++ b/crates/benilla-app/src/ui_script/cinematic_tests.rs
@@ -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::(&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::("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::("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");
+}
diff --git a/crates/benilla-app/src/ui_script/game_menu_tests.rs b/crates/benilla-app/src/ui_script/game_menu_tests.rs
index df8c436e..550ad458 100644
--- a/crates/benilla-app/src/ui_script/game_menu_tests.rs
+++ b/crates/benilla-app/src/ui_script/game_menu_tests.rs
@@ -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",
diff --git a/crates/benilla-app/src/ui_script/manifest.rs b/crates/benilla-app/src/ui_script/manifest.rs
index 555f3276..1c45e580 100644
--- a/crates/benilla-app/src/ui_script/manifest.rs
+++ b/crates/benilla-app/src/ui_script/manifest.rs
@@ -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."
+ );
+ }
+ }
}
diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs
index f71a9179..82a59c3d 100644
--- a/crates/benilla-app/src/ui_script/mod.rs
+++ b/crates/benilla-app/src/ui_script/mod.rs
@@ -450,26 +450,14 @@ fn capture_ui_active(capture: Option>) -> bool
fn arbitrate_pointer_over_ui(
egui: Option>,
hover: Res,
- cinematic: Option>,
mut over: ResMut,
) {
- // **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
diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs
index e6fa7854..346e96a6 100644
--- a/crates/benilla-app/src/ui_script/options_tests.rs
+++ b/crates/benilla-app/src/ui_script/options_tests.rs
@@ -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",
diff --git a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
index 9cc44d59..73a1816b 100644
--- a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
+++ b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
@@ -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::(&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 {
+ let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
+ let mut names: Vec = Vec::new();
+ for entry in std::fs::read_dir(&dir).expect("assets/ui").flatten() {
+ 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
+}
diff --git a/crates/benilla-app/tests/world_api_wall.rs b/crates/benilla-app/tests/world_api_wall.rs
index 236fe3dd..1b39045c 100644
--- a/crates/benilla-app/tests/world_api_wall.rs
+++ b/crates/benilla-app/tests/world_api_wall.rs
@@ -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
diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs
index 6c977564..dc56d628 100644
--- a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs
+++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs
@@ -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
diff --git a/crates/benilla-world/src/instance_tint.rs b/crates/benilla-world/src/instance_tint.rs
index 56ee431d..f753919d 100644
--- a/crates/benilla-world/src/instance_tint.rs
+++ b/crates/benilla-world/src/instance_tint.rs
@@ -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,
shared: Option>,
+ mirrors: Option>,
tints: Option>,
mut last: Local>,
) {
- 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::>())
+ .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::()
- .add_plugins(ExtractResourcePlugin::::default());
+ .init_resource::()
+ .add_plugins(ExtractResourcePlugin::::default())
+ .add_plugins(ExtractResourcePlugin::::default());
if let Some(render) = app.get_sub_app_mut(RenderApp) {
render.add_systems(
Render,
diff --git a/crates/benilla-world/src/zfill.rs b/crates/benilla-world/src/zfill.rs
index c33577a8..8997698e 100644
--- a/crates/benilla-world/src/zfill.rs
+++ b/crates/benilla-world/src/zfill.rs
@@ -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,
>;
@@ -73,23 +79,25 @@ pub(crate) fn sync_zfill_twins(
parts: ZfillParts,
mut twins: Query<(&ZfillTwinOf, &mut MeshTag, &mut Mesh3d), Without>,
) {
- 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 {