your corpse answers the mouse, and a cinematic's last holes close

- a corpse is a body you can hover, cursor and click: it enters the same ray pass as units with the winner switched on type at publish (the reference's own shape — one trace over every object), the cursor classifier is transcribed whole (lootable-by-me → the pickup cursor, grayed by range; else the insignia leg behind the learned-skill latch; your own body falls through to the clear), and right-click is "not mounted AND lootable" → CMSG_LOOT; CORPSE_FIELD_FLAGS bit 5 is the PvP insignia flag, not "reclaimable" as an old note had it; the widening also caught that a GameObject anywhere on screen could steal the click from a nearer corpse, and that a left-click on a body cleared your target as if you had clicked ground
- the cinematic sweep closes eleven holes: a shot plays its sequence band's WIDTH, not its end — two shipped fly-bys band away from zero and ran long with their opening frames frozen (the Night Elf intro measured 102333 → 102000 ms); CMSG_NEXT_CINEMATIC_CAMERA announces every shot, the first included — the old "between shots" reading meant it was sent NEVER, since every 1.12 sequence row carries exactly one camera; the ack invariant is stated once (one CMSG_COMPLETE_CINEMATIC per accepted trigger, over-acking tolerated, under-acking never — a dropped ack despawns the world around you until relog); the hardware cursor hides for the shot; and the music cut lands in the frame that starts it — it was one frame late, exactly long enough for a first login's zone fanfare to start under the narration and be stamped as played
- RunBinding exists, and a fly-by stops handing the world its mouse: CinematicFrame's OnKeyDown hands the SCREENSHOT chord back to its binding through RunBinding, a global we never had, so every press during a fly-by raised and took no picture; and the mouse was fully live under a shot — right-drag turned the character, clicks picked targets through the flying camera, both buttons walked the body — so the pointer now reads as over the UI while a shot plays
- AdvanceTime and ReplaceIconTexture are carved off the bytes: AdvanceTime really is a no-op in the reference (its one reachable call is mov eax,1; ret), ReplaceIconTexture is a material swap over a model's type-14 texture slots that stores nothing on the widget, and SetModel(nil) RAISES — the "documented clear" our binding claimed was invented, not read; and the addon roster now says WHOSE package is short when a .toc names files the folder doesn't hold — nine of the corpus's twenty-nine load failures are the addon's own manifest, which the real client also shrugs off ("Couldn't open %s", carry on), so they stop reading as our defects

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-31 19:55:36 +07:00
co-authored by Claude Fable 5
parent f6f2af3da9
commit 6e3a67e039
28 changed files with 1237 additions and 130 deletions
+12 -2
View File
@@ -16,8 +16,18 @@
transcription of the whole routing table would be a list of no-ops that reads as coverage.
Known seam, stated: the real client PARENTS every FrameXML frame under UIParent (scale/
visibility cascade); benilla's top-level frames still attach to the screen root. Same
rect, so anchors agree - re-parenting is a separate arc if a cascade consumer appears.
visibility cascade). benilla is PART WAY there - 41 of our top-level frames declare
parent="UIParent" where the reference declares 104, and 32 frames the reference parents
(MainMenuBar, PlayerFrame, TargetFrame, the four MultiBars, UIErrorsFrame, the panels)
still attach to the screen root because the transcription dropped the attribute. Same
rect, so anchors agree.
The cascade consumer this note was waiting for HAS appeared (decision 1724): a cinematic
hides the HUD by doing `UIParent:Hide()` in the reference, and benilla substitutes an
engine-side draw-kill that also switches the mouse feed off - which is how the world came
to take every click of a race intro. Restoring the dropped parents is the fix; it is a
whole-UI arc (draw order, strata, ALT-Z, the world map's own full-screen row) and so is
proposed rather than taken.
-->
<Frame name="UIParent" setAllPoints="true" frameStrata="MEDIUM">
<Scripts>
+11 -3
View File
@@ -293,9 +293,17 @@
-- 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. benilla has no UIParent frame (the note on `slots` above), and the only
-- full frame we ship — the world map — already paints its own full-screen blackout
-- (WorldMapFrame.xml), which is what that Hide buys the reference.
-- fullscreen frame.
--
-- 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.
function SetFullScreenFrame(frame)
local oldFrame = slots.fullscreen
slots.fullscreen = frame
+3 -1
View File
@@ -455,7 +455,9 @@ ScriptLogFrame.xml
# requirement rather than legibility is **after BuffFrame.xml**: the toast re-anchors
# TemporaryEnchantFrame out of its own way, and that frame is declared there.
HelpFrame.xml
# The cinematic letterbox (the race-intro fly-by's two black bars, and the ESC that skips it).
# The cinematic frame: the ESC that skips a fly-by, and the SCREENSHOT chord it hands back. It
# draws NOTHING — the letterbox moved to Bevy UI nodes in `crate::cinematic` (decision 1699),
# because the lane this frame paints into is dark for the whole shot.
# Needs UiPanels.xml far above for ShowUIPanel/HideUIPanel and its own UIPanelWindows row; needs
# nothing else, and nothing needs it — the engine reaches it purely by firing CINEMATIC_START.
# Seated late because it is an outermost, screen-owning layer, like the two windows below it.
@@ -582,6 +582,71 @@ fn main() {
println!(" {label:>5} {n:>4} {}", "#".repeat(n.min(60)));
}
// **WHOSE package is incomplete** — printed immediately before the ranked blockers, because it
// is the line that stops a session hunting for a client bug that is not one. A `.toc` entry
// whose file the addon does not ship is the ADDON's defect, and the reference client's
// behaviour there is what ours already does: log `Couldn't open %s` and carry on
// (wow-re `ui/scratch/xml-toc-path-resolution.md` §4). Nothing is subtracted from the headline
// — 1213 — so both readings stay available.
let own: Vec<&addon_harness::AddonReport> = reports
.iter()
.filter(|r| !r.absent_own_files.is_empty())
.collect();
let foreign: Vec<&addon_harness::AddonReport> = reports
.iter()
.filter(|r| !r.absent_foreign_files.is_empty())
.collect();
// **"the WHOLE reason" is a claim, and it is checked before it is printed.** An addon can be
// short a file AND raise somewhere else; saying "this is why it fails" on the strength of
// `!loaded` alone would be the same overclaim this column exists to stop. It holds only when
// the absent entries account for every load error the addon has.
let sole_cause = |r: &addon_harness::AddonReport| {
!r.loaded && r.errors.len() == r.absent_own_files.len() + r.absent_foreign_files.len()
};
if !own.is_empty() || !foreign.is_empty() {
println!(
"\n MANIFEST ENTRIES WITH NO FILE — counted in the numbers above, listed here so"
);
println!(" the reader can tell a broken package from a broken client:");
for r in &own {
println!(
" {} — its own .toc lists {} file(s) the package does not contain{}",
r.name,
r.absent_own_files.len(),
if sole_cause(r) {
" [and that is its ONLY load error]"
} else {
""
}
);
for f in r.absent_own_files.iter().take(4) {
println!(" missing: {f}");
}
}
for r in &foreign {
println!(
" {} — wants a folder that is not installed{}",
r.name,
if sole_cause(r) {
" [and that is its ONLY load error]"
} else {
""
}
);
for f in r.absent_foreign_files.iter().take(4) {
println!(" missing: {f}");
}
}
let theirs = own.iter().filter(|r| sole_cause(r)).count();
let ours = foreign.iter().filter(|r| sole_cause(r)).count();
println!(
" ({} with an incomplete package of their own, {theirs} of which fail for that \
alone; {} wanting a neighbour, {ours} for that alone)",
own.len(),
foreign.len()
);
}
// What actually STOPPED them — the ranked first error. Read this before the demand list: a
// wall 60 addons hit is worth more than a verb 60 addons would like (decision 1193).
println!("\n what stopped them (addons whose FIRST load error was each):");
+156 -3
View File
@@ -109,6 +109,30 @@ pub struct AddonReport {
pub loaded: bool,
/// Load errors, verbatim, tagged by file.
pub errors: Vec<String>,
/// **Manifest entries the addon's own package does not contain.** Already counted in
/// [`Self::errors`], and deliberately not removed from it — 1213's rule for the fifth time:
/// a new question gets a new column, because silently shrinking `errors` would move `loaded`
/// and make every number in every past decision record incomparable.
///
/// The question it asks that no other column can: **whose fault is this row?** Five of the
/// corpus's failures are one addon family shipping a `.toc` that lists files it does not
/// contain — `DPSMate_CureDisease.toc` names eight files and the folder holds six, because the
/// three `*Received*` ones were split into a sibling addon and the manifest was never updated.
/// The client is behaving correctly there (the reference logs `Couldn't open %s` and carries
/// on, which is what our loader does), and a headline that cannot say so invites a session to
/// go hunting for a bug that is not ours. The director's own AtlasLoot copy is the same shape.
pub absent_own_files: Vec<String>,
/// **Manifest entries resolving OUTSIDE the addon's folder, into one that is not installed** —
/// resolved paths, not the raw entry, because the `..` collapse is the interesting half.
///
/// A different question from [`Self::absent_own_files`] and kept apart from it: this addon's
/// package is fine, it wants a neighbour. The corpus's two are Auctioneer and BeanCounter
/// reaching `..\Blizzard_AuctionUI\Blizzard_AuctionUITemplates.xml` — which wow-re records as
/// **RESOLVING** in the real client (`ui/scratch/xml-toc-path-resolution.md` §5 case 2, by
/// name), because there the file layer can see `Blizzard_AuctionUI` inside `patch.MPQ`. Ours
/// reads the AddOns directory only, so it misses. That one IS ours, and the split is what
/// makes it visible instead of averaging with the row above.
pub absent_foreign_files: Vec<String>,
/// Names it calls that the VM does not have — see the module doc on what this is worth.
pub missing_globals: Vec<String>,
/// Dependencies named in its `.toc` that are not in the folder.
@@ -520,7 +544,7 @@ fn survey_one(
// consumer — the same rule `load_dependencies` applies to errors.
let baseline = RenderBaseline::of(&script);
let errors = load_addon_files(&script, root, name, &toc);
let (errors, absent) = load_addon_files(&script, root, name, &toc);
let wants = missing_calls(root, name, &toc, &known, &dep_methods);
// AFTER the addon's files: a template it declares in its OWN XML is registered by then, so it
// is not missing. The check asks the VM's live registry, not a name list.
@@ -568,6 +592,8 @@ fn survey_one(
interface: toc.interface_versions(),
loaded: errors.is_empty(),
errors,
absent_own_files: absent.own,
absent_foreign_files: absent.foreign,
missing_globals: wants.missing_globals,
missing_deps,
missing_templates,
@@ -1635,15 +1661,47 @@ fn globals_of(script: &UiScript) -> BTreeSet<String> {
.collect()
}
/// What a manifest entry that does not resolve actually is — see [`AddonReport::absent_own_files`].
#[derive(Debug, Default)]
pub struct AbsentFiles {
/// Entries under the addon's **own** folder that the package does not contain.
pub own: Vec<String>,
/// Entries resolving **outside** it, into a folder that is not installed.
pub foreign: Vec<String>,
}
/// Run the addon's manifest through the same two arms the real loader uses — `.lua` as a chunk,
/// anything else as FrameXML — with the AddOns root as the provider's path space (decision 1186).
fn load_addon_files(script: &UiScript, root: &Path, name: &str, toc: &Toc) -> Vec<String> {
///
/// Returns the errors **and**, beside them, the split of which entries did not resolve. The
/// absent ones are still in `errors` — 1213's rule, for the fifth time: this asks a new question
/// and gets a new column, it does not quietly shrink an old one.
fn load_addon_files(
script: &UiScript,
root: &Path,
name: &str,
toc: &Toc,
) -> (Vec<String>, AbsentFiles) {
let provider = |req: &str| -> Option<Vec<u8>> { read_under(root, req) };
let mut errors = Vec::new();
let mut absent = AbsentFiles::default();
for file in &toc.files {
let path = benilla_ui::loader::join_ref(name, file);
let Some(bytes) = read_under(root, &path) else {
errors.push(format!("{file}: not found"));
// WHOSE package is incomplete. `join_ref` has already collapsed the `..`s the way the
// client does (wow-re `ui/scratch/xml-toc-path-resolution.md` §2), so the resolved
// path is what the real file layer would be handed — and an entry that still points
// inside the addon's own folder is the addon shipping a manifest it does not satisfy,
// while one pointing out of it wants a neighbour that is not installed.
let own = path
.strip_prefix(name)
.is_some_and(|rest| rest.starts_with('/'));
if own {
absent.own.push(file.clone());
} else {
absent.foreign.push(path.clone());
}
continue;
};
if is_lua(file) {
@@ -1665,7 +1723,7 @@ fn load_addon_files(script: &UiScript, root: &Path, name: &str, toc: &Toc) -> Ve
Err(e) => errors.push(format!("{file}: {e}")),
}
}
errors
(errors, absent)
}
fn is_lua(entry: &str) -> bool {
@@ -3423,6 +3481,101 @@ mod dependency_tests {
let _ = std::fs::remove_dir_all(&tmp);
}
/// **A manifest entry with no file is split by WHOSE package is short** — the column that
/// stops a session hunting for a client bug that is not one.
///
/// Both rows below are load failures and stay load failures: nothing is subtracted from
/// `loaded` or from `errors` (1213's rule, for the fifth time). What the split adds is the
/// attribution, and the two cases genuinely differ:
///
/// - **`own`** — the addon ships a `.toc` listing a file it does not contain. Five corpus rows
/// are this, all one family: `DPSMate_CureDisease.toc` names eight files and the folder
/// holds six, because the three `*Received*` ones were split into a sibling addon and the
/// manifest was never updated. Our loader is behaving correctly (the reference logs
/// `Couldn't open %s` and carries on — wow-re `ui/scratch/xml-toc-path-resolution.md` §4).
/// - **`foreign`** — the entry escapes the addon's folder with `..`, which the client supports
/// and `join_ref` reproduces, into a folder that is not installed. The corpus's two are
/// Auctioneer and BeanCounter reaching `..\Blizzard_AuctionUI\...`, which wow-re records as
/// **RESOLVING** in the real client (§5 case 2, by name) because its file layer can see that
/// folder inside `patch.MPQ`. That one IS ours.
///
/// The assertion that matters is that the two never merge: a single "files not found" count
/// would average a broken package with a real client gap and read as one number.
#[test]
fn an_absent_manifest_entry_is_attributed_to_whose_package_is_short() {
let tmp =
std::env::temp_dir().join(format!("benilla-harness-absent-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let write = |name: &str, toc: &str, files: &[(&str, &str)]| {
let dir = tmp.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap();
for (f, body) in files {
std::fs::write(dir.join(f), body).unwrap();
}
};
// Ships one of the two files its own manifest lists — DPSMate_CureDisease's exact shape.
write(
"ShortPackage",
"## Interface: 11200\nhere.lua\ngone.lua\n",
&[("here.lua", "ShortPackageRan = 1\n")],
);
// Reaches a neighbour that is not installed — Auctioneer's exact shape, `..` and all.
write(
"WantsNeighbour",
"## Interface: 11200\nown.lua\n..\\NotInstalled\\templates.xml\n",
&[("own.lua", "WantsNeighbourRan = 1\n")],
);
let reports = survey(&tmp);
let of = |n: &str| reports.iter().find(|r| r.name == n).unwrap();
let short = of("ShortPackage");
assert_eq!(
short.absent_own_files,
vec!["gone.lua".to_string()],
"the entry inside its own folder is the addon's own package being short"
);
assert!(
short.absent_foreign_files.is_empty(),
"and it is NOT a missing neighbour: {:?}",
short.absent_foreign_files
);
let wants = of("WantsNeighbour");
assert_eq!(
wants.absent_foreign_files,
vec!["NotInstalled/templates.xml".to_string()],
"`..` is collapsed the way the client collapses it, and the RESOLVED path is what is \
reported — the collapse is the interesting half"
);
assert!(
wants.absent_own_files.is_empty(),
"its own package is complete: {:?}",
wants.absent_own_files
);
// NOTHING is subtracted. Both are still load failures, still in `errors`, still counted
// in every headline — the split is a new column beside them, never a quieter old one.
for r in [short, wants] {
assert!(!r.loaded, "{}: still a load failure", r.name);
assert!(
r.errors.iter().any(|e| e.contains("not found")),
"{}: the error is still there verbatim: {:?}",
r.name,
r.errors
);
}
// ...and the file that DID exist ran, because a missing entry does not abort the manifest.
assert!(
!short.errors.iter().any(|e| e.contains("here.lua")),
"the surviving file loads: {:?}",
short.errors
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn a_sibling_addons_embedded_library_is_invisible() {
let tmp =
@@ -105,7 +105,7 @@ pub fn probe(root: &Path, name: &str, evals: &[String]) -> Option<ProbeOutcome>
&mut dep_order,
);
let load_errors = super::load_addon_files(&script, root, name, &toc);
let (load_errors, _absent) = super::load_addon_files(&script, root, name, &toc);
let session_errors = super::drive_session_start(&mut script, name, &dep_order);
let answers = evals
+29 -5
View File
@@ -207,7 +207,7 @@ impl Plugin for BindingsPlugin {
.before(benilla_world::schedule::WorldStage::Input)
.run_if(in_state(ClientState::InWorld)),
load_character_bindings.run_if(in_state(ClientState::InWorld)),
save_bindings.after(load_character_bindings),
drain_binding_requests.after(load_character_bindings),
),
);
}
@@ -294,12 +294,36 @@ fn load_character_bindings(
}
}
/// Persist on the window's SaveBindings (Okay): write the set's diff; saving account while a
/// character file exists deletes it — the confirmed permanent delete.
fn save_bindings(script: Option<NonSendMut<UiScript>>, files: Res<BindingFiles>) {
/// Drain the VM's queued binding requests.
///
/// Two of them. `Save` persists on the window's SaveBindings (Okay): write the set's diff; saving
/// account while a character file exists deletes it — the confirmed permanent delete. `Run` fires
/// a named command's action outright, which is `RunBinding(name)` — the reference's own
/// passthrough verb, whose one shipped caller is `CinematicFrame`'s `OnKeyDown` handing the
/// SCREENSHOT chord back to its binding while it swallows every other key (decision 1724).
fn drain_binding_requests(script: Option<NonSendMut<UiScript>>, files: Res<BindingFiles>) {
let Some(mut script) = script else { return };
for req in script.take_keybind_requests() {
let KeybindRequest::Save(which) = req;
// `RunBinding(name)` — fire the named command's action as if its chord had been pressed.
// Only the two Lua-bodied kinds can be run this way: a `Held`/`Host` command's action is a
// bit in the movement word, which a Lua call has no frame to assert for. The one shipped
// caller is `CinematicFrame`'s SCREENSHOT passthrough, and `SCREENSHOT` is `Kind::Edge`.
let which = match req {
KeybindRequest::Save(which) => which,
KeybindRequest::Run(name) => {
match SPECS.iter().find(|s| s.name == name).map(|s| &s.kind) {
Some(Kind::Edge(lua)) | Some(Kind::EdgeUpDown(lua, _)) => {
let lua = *lua;
if let Err(e) = script.run(lua) {
warn!("bindings(RunBinding {name}): {e}");
}
}
Some(_) => warn!("RunBinding({name}): a held/engine action has no body to run"),
None => warn!("RunBinding({name}): no such command"),
}
continue;
}
};
let snapshot = script.keybind_snapshot();
let text = store::to_diff(&snapshot);
let path = match which {
+222 -30
View File
@@ -24,10 +24,15 @@
//! clamp, i.e. plays **once and freezes**; it does not loop. (`Scry_cam`, which is not a race
//! intro, is the one file authored to loop — a difference we inherit for free by ending on the
//! band rather than on the flag.)
//! - **Between the shots of a multi-camera row the client sends `CMSG_NEXT_CINEMATIC_CAMERA`**
//! (`0x48efe0`), and a camera id of `0` **ends** the cinematic rather than being skipped. No
//! shipped row has a second camera, so this path is exercised only by a server with its own
//! DBCs — which is exactly why it is written rather than assumed away.
//! - **The client announces every shot with `CMSG_NEXT_CINEMATIC_CAMERA` — including the first.**
//! The send sits inside the *shot arm* `0x48edf0`, not the shot advance: `0x48ef11 push 0xfb`
//! → `0x418190` (the packet builder) → `0x48ef24 call 0x5ab630` (flush), immediately before
//! `0x48ef29` starts that shot's narration and `0x48ef43` fades it in — the same builder/flush
//! pair `0x48f154 push 0xfc` uses for the completion ack. Since every shot goes through the arm,
//! a shipped single-camera race intro sends exactly one of these, at the start. benilla used to
//! read this as "between the shots of a multi-camera row" and cite `0x48efe0`, which sent none
//! at all on every shot the game actually ships. A camera id of `0` still **ends** the cinematic
//! rather than being skipped (`0x48efe0`).
//! - **ESC is not an engine binding.** `StopCinematic` has zero native callers in the reference:
//! the only skip path is `CinematicFrame.xml`'s own `OnKeyDown`, which is why benilla's copy of
//! that frame (`assets/ui/CinematicFrame.xml`) carries the same handler and why the Lua binding
@@ -40,11 +45,17 @@
//!
//! # What we deliberately do differently
//!
//! The reference defers both the start and the stop by **0.25 s** through a scheduled fade
//! (`0x4c0d10`, the constant at `[0x804550]`). benilla plays the shot immediately and acks
//! immediately: the fade is a transition effect we have not built, and faking its *delay* without
//! its *picture* would only add latency. Noted rather than silently dropped — it is the one timing
//! difference from the reference on this path.
//! **Every cinematic boundary in the reference cuts through black, and benilla cuts hard.** The
//! `0.25 s` at `[0x804550]` is not a scheduled delay — it is a screen fade, and the next step is
//! its *completion callback*: `0x4c0d10` builds a fullscreen opaque-black quad (`0xff000000`) and
//! latches what to run when it is fully up, so `CINEMATIC_START`, each shot advance and
//! `EndCinematic` all execute at full black and `0x4c1280` fades back in. Six sites, both edges,
//! always black, always 0.25 s (wow-re `ui/scratch/cinematic-camera-law.md` §3.7, a CORRECTION to
//! the earlier "deferred by a delay" reading). There is **no audio fade anywhere on this path**.
//!
//! benilla plays and acks immediately. Building the picture is what would let the timing be
//! faithful too, so the two go together and neither is faked without the other — decision 1724
//! leaves both standing rather than adding latency with nothing on screen to justify it.
//!
//! # What playback takes over
//!
@@ -86,6 +97,12 @@ pub(crate) struct Cinematic {
/// One cinematic in flight.
struct Playing {
/// Which *run* this is, counted from process start. The `CinematicSequences.dbc` id alone is
/// not an identity: re-triggering the same sequence while it plays produces the same
/// `(sequence, shot)` pair, and the narration follower reads that pair to tell "still the same
/// shot" from "a new one" — so without this the picture restarted at t=0 while the voice kept
/// running from wherever it had got to.
run: u64,
/// The `CinematicSequences.dbc` id, for logging.
sequence_id: u32,
/// The row's shots, in order. Non-empty (a sequence that resolves to nothing is acked at the
@@ -109,12 +126,13 @@ impl Cinematic {
self.playing.is_some()
}
/// The shot on screen: `(sequence id, shot index, narration sound id)`. The identity pair is
/// what lets a follower tell "still the same shot" from "the next one", which is the question
/// the narration channel actually asks.
pub(crate) fn playing_shot(&self) -> Option<(u32, usize, u32)> {
/// The shot on screen: `(run, shot index, narration sound id)`. The identity pair is what lets
/// a follower tell "still the same shot" from "the next one", which is the question the
/// narration channel actually asks — and it is keyed on [`Playing::run`] rather than the
/// sequence id so that re-triggering the sequence already playing counts as a new shot.
pub(crate) fn playing_shot(&self) -> Option<(u64, usize, u32)> {
let play = self.playing.as_ref()?;
Some((play.sequence_id, play.index, play.shot().sound_id))
Some((play.run, play.index, play.shot().sound_id))
}
}
@@ -130,6 +148,7 @@ impl Cinematic {
Self {
pending: None,
playing: Some(Playing {
run: 0,
sequence_id: 0,
shots: Vec::new(),
index: 0,
@@ -154,6 +173,14 @@ impl Cinematic {
#[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.
#[derive(Default)]
struct Takeover {
ui: bool,
cursor: bool,
}
pub(crate) struct CinematicPlugin;
impl Plugin for CinematicPlugin {
@@ -255,7 +282,8 @@ fn drive_letterbox(
mut hidden: ResMut<crate::ui_hide::UiHidden>,
mut bars: Query<(&mut Node, &mut Visibility), With<LetterboxBar>>,
windows: Query<&Window>,
mut ours: Local<bool>,
mut cursor: Query<&mut bevy::window::CursorOptions, With<bevy::window::PrimaryWindow>>,
mut ours: Local<Takeover>,
mut logged: Local<f32>,
) {
let playing = cine.is_playing();
@@ -263,14 +291,37 @@ fn drive_letterbox(
// their choice when it ends.
if playing && !hidden.0 {
hidden.0 = true;
*ours = true;
ours.ui = true;
info!("cinematic: HUD hidden for playback");
} else if !playing && *ours {
} else if !playing && ours.ui {
hidden.0 = false;
*ours = false;
ours.ui = false;
info!("cinematic: HUD restored");
}
// **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`
// §3.4, the complete 10-site census of the cinematic state cell). Nothing else in benilla
// would: this flag is otherwise written only by the mouse-look session in `player::camera`,
// and `control` skips that whole branch while the view is detached, so the pointer the player
// arrived at character-select with simply sat on top of the fly-by.
//
// Same restore-only-what-we-hid discipline as the HUD above, and the same **write only on a
// real change**: `CursorOptions` is what `bevy_winit`'s `changed_cursor_options` watches, and
// re-applying cursor state to AppKit every frame intermittently stalls the main thread
// (`player::control`'s shadow-copy note, the 0366 frame-tail hunt).
if let Ok(mut opts) = cursor.single_mut() {
if playing && opts.visible {
opts.visible = false;
ours.cursor = true;
} else if !playing && ours.cursor {
ours.cursor = false;
if !opts.visible {
opts.visible = true;
}
}
}
let height = playing
.then(|| windows.iter().next())
.flatten()
@@ -340,7 +391,31 @@ fn take_trigger(
ack(net.as_deref());
continue;
}
cine.pending = Some(id);
// Last write wins, the reference's single-slot latch — but the id it displaces still owes
// the server its ack (see [`relinquish`]).
if let Some(dropped) = cine.pending.replace(id) {
warn!("cinematic: {dropped} displaced by {id} before it started — acking it");
ack(net.as_deref());
}
}
}
/// Give up whatever is in flight, paying its ack.
///
/// **The invariant this exists to keep: one `CMSG_COMPLETE_CINEMATIC` for every trigger we
/// accepted.** Decision 0196 is why the direction matters — an unacked cinematic leaves vmangos
/// anchoring object visibility to its own copy of the flying camera, and everything around the
/// body stays despawned until relog. An *extra* ack is discarded by a server that is not watching
/// one; a missing one is not recoverable in-session. So when two triggers are live at once, both
/// are acked: they are two cinematics the server started, not one.
fn relinquish(cine: &mut Cinematic, net: Option<&NetCommands>, why: &str) {
if let Some(play) = cine.playing.take() {
info!("cinematic: {} {why}", play.sequence_id);
ack(net);
}
if let Some(id) = cine.pending.take() {
info!("cinematic: {id} {why} before it started");
ack(net);
}
}
@@ -385,12 +460,34 @@ fn start_pending(
shots.len(),
shots.iter().map(|s| s.duration_ms).sum::<u32>()
);
// A trigger that lands on top of a running cinematic replaces it — and the one it replaces
// is acked here rather than dropped ([`relinquish`]'s invariant). No stock server does this;
// a GM `.debug play cinematic` during an intro does.
if let Some(old) = cine.playing.take() {
warn!(
"cinematic: {} replaced by {id} while playing — acking it",
old.sequence_id
);
ack(net.as_deref());
}
static RUN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
cine.playing = Some(Playing {
run: RUN.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
sequence_id: id,
shots,
index: 0,
elapsed: Duration::ZERO,
});
announce_shot(net.as_deref());
}
/// `CMSG_NEXT_CINEMATIC_CAMERA` — sent as each shot is armed, the first one included (module doc:
/// `0x48ef11`, inside the shot arm). It is how the server learns which camera is flying, which is
/// the other half of decision 0196's visibility re-anchor.
fn announce_shot(net: Option<&NetCommands>) {
if let Some(net) = net {
let _ = net.0.send(ClientCommand::NextCinematicCamera);
}
}
/// Advance the shot and seat the camera on it; hand over to the next shot, or end the cinematic.
@@ -433,9 +530,7 @@ fn drive(
}
play.index += 1;
play.elapsed = over;
if let Some(net) = net.as_deref() {
let _ = net.0.send(ClientCommand::NextCinematicCamera);
}
announce_shot(net.as_deref());
}
let Ok(mut cam) = camera.single_mut() else {
@@ -453,6 +548,13 @@ fn drive(
let forward = (target - eye).normalize_or_zero();
let up = if forward == Vec3::ZERO {
Vec3::Y
} else if forward.cross(Vec3::Y).length_squared() < 1e-6 {
// Looking straight up or down: rotating `Y` about a `forward` that *is* `Y` is the
// identity, so the authored roll would vanish and `look_at` would fall back to an
// arbitrary (if deterministic) horizon. Roll the one basis vector that is guaranteed not
// to be parallel to the view instead — same angle, about the same axis, just a seed that
// survives. No shipped shot holds a vertical view, which is why this never showed.
Quat::from_axis_angle(forward, view.roll) * Vec3::Z
} else {
Quat::from_axis_angle(forward, view.roll) * Vec3::Y
};
@@ -468,14 +570,8 @@ 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>) {
if let Some(play) = cine.playing.take() {
info!("cinematic: {} stopped", play.sequence_id);
ack(net);
}
// A trigger still waiting on the world is dropped too: the player has said "not this".
if cine.pending.take().is_some() {
ack(net);
}
// A trigger still waiting on the world goes with it: the player has said "not this".
relinquish(cine, net, "stopped");
}
/// Leaving the world drops a cinematic without acking — the socket is going away with it.
@@ -509,6 +605,15 @@ fn feed_ui(
if *published == Some(playing) {
return;
}
// **A fresh VM is already not in a cinematic, so there is no edge to announce.** The memo
// starts `None`, and `None != Some(false)` — which fired a `CINEMATIC_STOP` into every login
// and every `/reload`, an event the reference never sends unedged. Seed the state instead of
// announcing it; the `/reload`-mid-cinematic case (`playing == true`) still falls through and
// re-fires `CINEMATIC_START` into the rebuilt tree, which is what the memo is here for.
if published.is_none() && !playing {
*published = Some(false);
return;
}
*published = Some(playing);
script.set_in_cinematic(playing);
script.fire_event(
@@ -584,3 +689,90 @@ mod tests {
}
}
}
/// **The ack ledger.** Decision 0196's invariant is the one thing on this path that cannot be
/// checked by looking at the screen: an unacked cinematic leaves vmangos anchoring object
/// visibility to its own copy of the flying camera, and everything around the body stays
/// despawned until relog. It had no test at all, and that is precisely why a second trigger could
/// silently displace a playing cinematic and pay nothing — the bug these cases now pin.
#[cfg(test)]
mod ack_ledger {
use super::*;
use crate::net::ClientCommand;
fn wire() -> (NetCommands, crossbeam_channel::Receiver<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
(NetCommands(tx), rx)
}
fn acks(rx: &crossbeam_channel::Receiver<ClientCommand>) -> usize {
rx.try_iter()
.filter(|c| matches!(c, ClientCommand::CompleteCinematic))
.count()
}
/// One trigger in flight, one ack out — the ordinary ESC skip.
#[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());
}
/// Two triggers accepted, two acks owed. Over-acking is discarded by a server that is not
/// watching a cinematic; under-acking is the failure that costs a relog, so this direction is
/// deliberate and not a duplicate to squash.
#[test]
fn a_playing_cinematic_and_a_latched_one_each_pay() {
let (net, rx) = wire();
let mut cine = Cinematic::playing_for_test();
cine.pending = Some(41);
stop(&mut cine, Some(&net));
assert_eq!(acks(&rx), 2);
assert!(cine.pending.is_none());
}
/// Nothing in flight owes nothing — ESC with no cinematic must not ack into the void.
#[test]
fn stopping_nothing_acks_nothing() {
let (net, rx) = wire();
let mut cine = Cinematic::default();
stop(&mut cine, Some(&net));
assert_eq!(acks(&rx), 0);
}
/// **The regression.** A trigger that displaces a latched one pays for the one it displaced —
/// the latch is last-write-wins (the reference's single slot), but the server still started
/// the cinematic it dropped.
#[test]
fn a_displaced_latch_is_acked() {
let (net, rx) = wire();
let mut cine = Cinematic {
pending: Some(41),
..Default::default()
};
// What `take_trigger` does on the second message.
if let Some(_dropped) = cine.pending.replace(81) {
ack(Some(&net));
}
assert_eq!(acks(&rx), 1);
assert_eq!(cine.pending, Some(81));
}
/// Leaving the world is the one path that deliberately pays **nothing**: the socket is going
/// away with the cinematic, exactly as the reference's own teardown does (`0x490a80` clears
/// the flag and sends no `CMSG_COMPLETE_CINEMATIC`).
#[test]
fn abandoning_the_world_acks_nothing() {
let (net, rx) = wire();
let mut app = App::new();
app.insert_resource(Cinematic::playing_for_test());
app.insert_resource(net);
app.add_systems(Update, abandon_on_leaving_world);
app.update();
assert_eq!(acks(&rx), 0);
assert!(!app.world().resource::<Cinematic>().is_playing());
}
}
+10 -1
View File
@@ -504,6 +504,7 @@ mod macos {
world_assets: Option<ResMut<WorldAssets>>,
mode: Res<super::DisplayedCursor>,
rig: Res<CameraControl>,
cinematic: Option<Res<crate::cinematic::Cinematic>>,
mut focus: MessageReader<bevy::window::WindowFocused>,
// None of the `Local`s below is a [`crate::ui_script::VmMemo`], deliberately (decision
// 1290's sweep): every one of them remembers something about the **OS** — what we last told
@@ -551,7 +552,15 @@ mod macos {
}
}
}
let looking = rig.is_looking();
// **A cinematic hides the pointer too**, and on this platform it can only be done here:
// the reference's `0x58b590(0)` at StartCinematic / `(1)` at End (wow-re
// `ui/scratch/cinematic-camera-law.md` §3.4). `crate::cinematic` writes
// `CursorOptions.visible`, which is the right lever everywhere else and is *inert on
// macOS* for the reason this module exists — winit's cursor-rect route does not survive a
// continuously-redrawing Metal view. So the fly-by's pointer is hidden by joining the
// look session's own `NSCursor::hide()`/`unhide()` pair, which keeps AppKit's app-global
// hide counter balanced across both reasons instead of racing a second one against it.
let looking = rig.is_looking() || cinematic.is_some_and(|c| c.is_playing());
let stem = mode.0.stem();
// The held payload's cursor wins over the classified mode (falls back to the mode cursor
// when nothing is held, or its icon hasn't resolved/decoded this frame). The KEY names what
+5 -3
View File
@@ -1694,9 +1694,11 @@ pub(crate) enum ClientCommand {
/// the flying cinematic camera (a first login's race intro) and every NPC around the body
/// despawns until relog (decision 0196).
CompleteCinematic,
/// Advance a multi-camera cinematic to its next shot (`CMSG_NEXT_CINEMATIC_CAMERA`) — see
/// [`benilla_protocol::WorldWriter::next_cinematic_camera`]. No shipped 1.12 sequence has a
/// second camera, so this rides only a server with its own DBCs.
/// Announce the shot now being armed (`CMSG_NEXT_CINEMATIC_CAMERA`) — see
/// [`benilla_protocol::WorldWriter::next_cinematic_camera`]. Sent for **every** shot, the
/// first included: the reference's send is inside the shot arm (`0x48ef11`), not the shot
/// advance, so a shipped single-camera race intro sends exactly one. See
/// [`crate::cinematic`]'s module doc.
NextCinematicCamera,
/// **Acknowledge a granted mover mode** — root, water-walk, feather-fall or hover (decisions
/// 0308, 0866): the echoed `counter` + our live pose, on the opcode
+18 -9
View File
@@ -28,7 +28,7 @@ use benilla_assets::{LockRecover, WorldAssets};
use benilla_world::schedule::WorldStage;
use super::mixer::{self, StreamingSoundHandle};
use super::{kit::SoundKits, SoundConfig, SoundOutput};
use super::{kit::SoundKits, SoundOutput};
use crate::cinematic::Cinematic;
/// A cut-off narration is **cut**, with no fade at all — wow-re
@@ -43,9 +43,12 @@ const CUT_FADE_MS: u64 = 0;
/// The narration channel: which shot it belongs to, and its live handle.
#[derive(Default)]
pub(super) struct CinematicVoice {
/// `(sequence id, shot index)` of the shot whose narration is playing — the identity that
/// decides whether the current shot's audio is already running.
shot: Option<(u32, usize)>,
/// `(run, shot index)` of the shot whose narration is playing — the identity that decides
/// whether the current shot's audio is already running. Keyed on the **run** rather than the
/// sequence id: re-triggering the sequence already on screen restarts the picture at `t = 0`,
/// and a sequence-keyed identity would have matched and left the voice running from wherever
/// it had reached.
shot: Option<(u64, usize)>,
handle: Option<StreamingSoundHandle<kira::sound::FromFileError>>,
}
@@ -70,20 +73,19 @@ fn drive_narration(
mut out: NonSendMut<SoundOutput>,
mut kits: ResMut<SoundKits>,
assets: Option<Res<WorldAssets>>,
config: Res<SoundConfig>,
) {
let playing = cine.as_deref().and_then(Cinematic::playing_shot);
let Some((sequence, index, sound_id)) = playing else {
let Some((run, index, sound_id)) = playing else {
// No cinematic (or it just ended/was skipped) — cut whatever was narrating.
voice.stop();
return;
};
if voice.shot == Some((sequence, index)) {
if voice.shot == Some((run, index)) {
return;
}
// A new shot: the previous one's narration, if any, does not carry over.
voice.stop();
voice.shot = Some((sequence, index));
voice.shot = Some((run, index));
if sound_id == 0 {
return;
}
@@ -122,7 +124,14 @@ fn drive_narration(
// The old reading — the Sfx category, "the same one every NPC voice line takes" — was a
// reasonable guess at where a player's expectation points, and it was wrong: this channel has
// no category.
let amp = if config.enabled { kit_vol } else { 0.0 };
// **The kit volume only** — the `MasterSoundEffects` gate is carried by the master track, not
// baked in here. `apply_master_volume` drives master to 0 whenever `enabled` is off (or the
// dev mute is on) and back up when it returns, so folding `config.enabled` into a one-shot
// starting amp was redundant in the silencing direction and *wrong* in the other: a player who
// started a cinematic with sound off and turned it back on mid-narration got everything else
// back and a narration that stayed silent for the rest of the shot. The reference re-reads its
// gate (`0x7a529c`) every tick; the master track is where benilla re-reads it.
let amp = kit_vol;
match mixer_ref.play_stream(data.volume(mixer::amp_to_db(amp))) {
Ok(h) => {
info!("cinematic voice: {path}");
+18 -4
View File
@@ -211,9 +211,19 @@ fn feed_world_hold(
}
}
/// Copy the cinematic's music stop into [`SoundConfig::music_suppressed`], once per frame in
/// `PreUpdate` beside [`feed_world_hold`] — same shape, same reason: one answer per frame, read by
/// every trigger system after it.
/// Copy the cinematic's music stop into [`SoundConfig::music_suppressed`], once per frame.
///
/// **Ordered `WorldStage::Stream`, not `PreUpdate` beside [`feed_world_hold`]** — the two look
/// alike and are not. The cover `feed_world_hold` reads is a state that lasts seconds, so reading
/// last frame's answer costs nothing; the cinematic's music cut is a one-frame *edge*, and
/// `crate::cinematic`'s driver asserts it in `WorldStage::Input` — after `PreUpdate` has already
/// run. Read there, [`zone::zone_audio`] (`WorldStage::Present`) saw a stale `false` on exactly
/// the frame a cinematic started: on a first-login race intro that is the first uncovered frame,
/// where the zone's own area-change block starts a track — or, worse, the zone's *intro fanfare*,
/// which is then stamped as played and does not come back for `MinDelayMinutes`. One frame later
/// the suppression cut it again, so the symptom was a click and a consumed fanfare rather than
/// anything you could hear as music. `Stream` sits between the two (`Net → Input → Stream →
/// Present`), so the flag the pump reads is always this frame's.
fn feed_music_suppression(
mut config: ResMut<SoundConfig>,
cinematic: Option<Res<crate::cinematic::Cinematic>>,
@@ -418,7 +428,11 @@ impl Plugin for SoundPlugin {
)
// The cover's audio hold (see [`SoundConfig::world_hold`]): fed in PreUpdate so every
// trigger system this frame — whatever stage it runs in — reads one answer.
.add_systems(PreUpdate, (feed_world_hold, feed_music_suppression))
.add_systems(PreUpdate, feed_world_hold)
.add_systems(
Update,
feed_music_suppression.in_set(benilla_world::schedule::WorldStage::Stream),
)
.add_systems(
Update,
(
+5
View File
@@ -800,6 +800,11 @@ fn stop_world_soundscape(zone: &mut ZoneAudio, reason: &str) {
zone.area = None;
zone.interior = None;
zone.was_underwater = false;
// The cinematic latch is scheduler state like the rest of this list, and leaving it set was
// the one field that survived a world transition. It only ever self-corrected by luck of
// ordering: leave the world mid-cinematic and the next entry saw a falling edge that belonged
// to the *previous* session, logging a resume for a world that had had no cinematic in it.
zone.music_suppressed = false;
if had {
info!("sound: world soundscape stopped ({reason})");
}
+71
View File
@@ -117,6 +117,14 @@ pub(super) fn select_on_click(
// `deselectOnClick` (0961) gates the whole arm: "Sticky Targeting" checked = the CVar
// at 0 = an empty-world click keeps the target (1.12's own inverted checkbox).
_ => {
// A **corpse** is an object hit, not empty world (decision 1723): the reference's
// deselect lives in the terrain and nothing legs, and an object leg clears no
// selection — so a left-click on a body must leave the target exactly where it was.
// Without this the corpse would arrive here (its guid is in the other slot) and read
// as "clicked nothing", dropping the player's target every time they clicked a body.
if hovered.corpse.is_some() {
return;
}
if click_cfg.deselect_on_click && (!payload_held.0 || occlusion.distance.is_finite()) {
clear(&mut selection, &mut seam, engaged);
}
@@ -353,6 +361,69 @@ pub(super) fn act_on_right_click(
}
return;
}
// ── The corpse leg: `CGCorpse_C`'s own interact slot, `0x5d6bf0` (vtable `+0x60`) ───────────
// A corpse is not a unit and never routes through the unit block below — the reference gives
// its class a *different* function in the same slot the unit class fills with `0x60bea0`.
// Transcribed from the disassembly at `0x5d6bf0..0x5d6cdd`, which has exactly two legs:
//
// 1. `[player descriptor + UNIT_FIELD_MOUNTDISPLAYID] <= 0` (**not mounted**, `0x5d6c2a jg`)
// AND `0x5d6e20(corpse)` (**lootable** — `CORPSE_FIELD_DYNAMIC_FLAGS` bit 0) → a player-
// state check on the vtable `+0xa4` slot, which on failure raises message `0x85` and
// sends nothing; else `SetAutoLoot` (`0x5df460`) and then `0x5df130` — **`CMSG_LOOT`**.
// 2. else `CORPSE_FIELD_FLAGS` bit 5 (the PvP insignia) AND the `[0xb700e8]`
// SKIN_PLAYER_CORPSE learn latch AND `!0x6067d0(player, corpse)` → the skin cast
// (`0x5f05e0`). Unreachable without that latch, and nothing in 1.12.1 sets it.
//
// **Neither leg touches your own body**, which carries neither flag — the function runs and
// does nothing at all. Recovering your corpse is a separate route (decision 0308's
// `RECOVER_CORPSE` → `CMSG_RECLAIM_CORPSE`) whose click path is out for RE and is NOT
// guessed at here; the director reports a right-click on their own body raising the resurrect
// prompt, so a route exists and this function is not it.
if let (Some(entity), Some(guid)) = (hovered.corpse, hovered.corpse_guid) {
let store = stores.get(entity).ok().map(|(s, _)| s);
// The same one-line answer the GameObject leg gives (tag `use`): a corpse's click is three
// silent refusals deep — no flag, the range gray, the missing latch — and every one of them
// otherwise looks identical from the outside. One run says which.
if benilla_assets::trace::enabled_for("use") {
benilla_assets::trace::line(
"use",
&format!(
"right-click corpse guid={guid:#x} bones={} lootable={} insignia={} cursor={:?} unable={}",
store.is_some_and(|s| s.0.corpse_is_bones()),
store.is_some_and(|s| s.0.corpse_lootable()),
store.is_some_and(|s| s.0.corpse_pvp_insignia()),
cursor.kind,
cursor.unable
),
);
}
if store.is_some_and(|s| s.0.corpse_lootable()) {
// Leg 1. Range rides the cursor's own gray, exactly as the unit loot branch does —
// the classifier and the click ask literally the same question, so the pouch can never
// be lit on something a click refuses.
if !cursor.unable {
debug!("right-click corpse loot: {guid:#x}");
let _ = seam.net.0.send(ClientCommand::Loot { guid });
// Same client-side prediction as the unit corpse (decision 0515): the reference's
// `CMSG_LOOT` sender arms `[player+0x1d28]` and kneels before any reply.
loot_latch.0 = Some(guid);
}
} else if store.is_some_and(|s| s.0.corpse_pvp_insignia()) {
// Leg 2, with its real precondition. `skin_player_corpse` is the mirror of `[0xb700e8]`
// and is `None` for every 1.12.1 player, so this is inert for the reference's own
// reason rather than by omission.
if let Some(spell_id) = learned.skin_player_corpse {
if !cursor.unable {
debug!("right-click corpse insignia: {guid:#x} (spell {spell_id})");
let _ = seam.net.0.send(ClientCommand::CastSpell {
spell_id,
target: Some(guid),
});
}
}
}
return;
}
let (Some(entity), Some(guid)) = (hovered.target, hovered.guid) else {
return;
};
@@ -186,6 +186,17 @@ const ATTACK_RANGE_SQ: f32 = 109.2025;
const MELEE_OFFSET: f32 = 1.333_33;
const MELEE_FLOOR: f32 = 5.0;
/// A **corpse object**'s interact reach, squared — `25.0`, i.e. a flat **5.0 yd**.
///
/// The same `max(rA + rB + 1.333, 5.0)` shape as [`MELEE_FLOOR`] above, evaluated for a corpse:
/// a corpse descriptor carries no `UNIT_FIELD_COMBATREACH` at all, so the sum can never clear the
/// floor and the gate is the floor. Two independent byte facts agree on the number — the cursor
/// leg's `CanLootNow 0x5ec110`, and the `CMSG_LOOT` sender `0x5df130`, whose owned bit-exact
/// difftest (`loot_range_5df130_difftest`) compares `dist²` against a literal `25.0`. Written as
/// the constant rather than the formula because the formula's other term does not exist here.
/// Boundary-inclusive, center-to-center, like every other reach gate in this file.
const CORPSE_INTERACT_RANGE_SQ: f32 = 25.0;
/// `GAMEOBJECT_TYPE_GENERIC` (vmangos `SharedDefines.h`) — world decoration whose highlightable
/// predicate is constant-false, so it never shows an interact cursor (wow-re cursor-system §4a).
pub(crate) const GO_TYPE_GENERIC: i32 = 5;
@@ -698,6 +709,58 @@ fn loot_cursor(auto_loot: bool, shift_held: bool) -> CursorKind {
}
}
/// What a hovered **corpse object** classifies to — the reference's corpse classifier `0x482740`
/// (wow-re `cursor-system.md` §3, VERIFIED), as a pure decision so it can be tested the way the
/// service ladder is. `esi` = player, `edi` = corpse, `d2` = centre-to-centre distance².
///
/// Two legs, and there is **no third** — in particular no own-corpse leg. Your own body carries
/// neither flag, fails both tests, and falls out to the reference's "else clear": the plain
/// pointer. (Recovering it is decision 0308's `RECOVER_CORPSE` route, not a world cursor.)
/// What the corpse classifier reads — one struct rather than seven positional bools, so the call
/// sites (and the test) name each byte fact instead of counting commas.
#[derive(Debug, Clone, Copy, Default)]
struct CorpseFacts {
/// `0x5d6e20` — `CORPSE_FIELD_DYNAMIC_FLAGS` bit 0.
lootable: bool,
/// `CORPSE_FIELD_FLAGS` bit 5 — `CORPSE_FLAG_LOOTABLE`, the PvP insignia.
insignia: bool,
/// `[0xb700e8]` — the `SPELL_EFFECT_SKIN_PLAYER_CORPSE` learn-time latch.
skin_latch: bool,
/// `d2`, centre to centre.
dist_sq: f32,
/// The already-looting override: `[player+0x1d28/2c] == corpse GUID`.
looting_this: bool,
/// The effective auto-loot's two terms (0961): the knob…
auto_loot: bool,
/// …and the live key-0.
shift_held: bool,
}
fn corpse_cursor(f: CorpseFacts) -> Option<(CursorKind, bool)> {
// Leg 1 — lootable by me. Able-vs-Unable is `CanLootNow 0x5ec110(player, corpse)` — the same
// range+state gate as the unit loot leg — OR the already-looting override, which keeps the lit
// pouch on the object whose loot window is open even as you drift out of reach. The mode is
// Pickup(8)/LootAll(16) by the held key-0 (`0x41f8f0`, `0x4827f8`); both fail →
// UnablePickup(28)/UnableLootAll(36) at `0x482818`. Distance grays exactly as on units.
if f.lootable {
let able = f.dist_sq <= CORPSE_INTERACT_RANGE_SQ || f.looting_this;
return Some((loot_cursor(f.auto_loot, f.shift_held), !able));
}
// Leg 2 — the PvP-insignia / skin-player-corpse leg (`0x482875..0x4828a2`): the flag **and**
// the learn latch **and** `!0x6067d0(player, corpse)`, then the reach gate picks
// SkinAlliance(20)/SkinHorde(19) or their Unable twins.
//
// Modelled with its real precondition rather than omitted, so it stays inert for the
// reference's own reason: `[0xb700e8]` is latched only by learning a spell whose `Effect[0]`
// is `SPELL_EFFECT_SKIN_PLAYER_CORPSE`, and no 1.12.1 player holds one. Benilla ships one Skin
// cursor rather than the faction pair (`[0x80439c + 4×0x5efe00(player)]`) — a distinction with
// no consequence while the leg is unreachable.
if f.insignia && f.skin_latch {
return Some((CursorKind::Skin, f.dist_sq > CORPSE_INTERACT_RANGE_SQ));
}
None // "Else clear" — a plain body under the cursor is Point.
}
/// Resolve this frame's [`WorldCursor`] from the hovered unit — the reference's classifier order:
/// interactable-NPC service ladder, else loot/skin/attack by state, each grayed by its own range
/// gate. No hover (or anything unresolvable) → Point.
@@ -729,6 +792,10 @@ pub(super) fn classify_cursor(
// The loot leg's Pickup/LootAll split (0965): the auto-loot knob (0961) + the live shift.
loot_cfg: Res<crate::ui_loot::LootConfig>,
keys: Res<ButtonInput<KeyCode>>,
// `[player+0x1d28/2c]` — the open loot session's object. The corpse leg's already-looting
// override reads it (the reference keeps the lit pouch on the corpse you are looting even
// once you have drifted past the range gate).
loot_latch: Res<crate::ui_loot::LootLatch>,
) {
// A **highlightable** GameObject shows its **data-driven** cursor (wow-re cursor-system §4): a
// mailbox's Mail, a plaque's Inspect, a vein's Mine / herb's GatherHerbs / picked lock's PickLock
@@ -859,8 +926,27 @@ pub(super) fn classify_cursor(
}
None // nothing matched → the reference's `0x4826cb` cursor-clear: Point
};
// The **corpse classifier** `0x482740`, whose whole decision is [`corpse_cursor`] — the store
// reads and the distance are all that happens here.
let resolve_corpse = || {
let (corpse_tf, store, _) = units.get(hovered.corpse?).ok()?;
let store = store?;
let (self_tf, _) = self_q.single().ok()?;
let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
corpse_cursor(CorpseFacts {
lootable: store.0.corpse_lootable(),
insignia: store.0.corpse_pvp_insignia(),
skin_latch: learned.skin_player_corpse.is_some(),
dist_sq: corpse_tf.translation.distance_squared(self_tf.translation),
looting_this: loot_latch.0.is_some() && loot_latch.0 == hovered.corpse_guid,
auto_loot: loot_cfg.auto_loot,
shift_held: shift,
})
};
let resolved = if go_is_nearest(&hovered, &hovered_object) {
resolve_go()
} else if hovered.corpse.is_some() {
resolve_corpse()
} else {
resolve_unit()
};
@@ -876,6 +962,84 @@ mod tests {
use super::*;
use benilla_protocol::messages::dialog_status;
/// The corpse classifier `0x482740` has two legs and no more — and neither of them is your
/// own body, which is what makes a corpse read as the plain pointer until something *else*
/// offers to recover it.
#[test]
fn the_corpse_classifier_has_two_legs_and_no_own_corpse_leg() {
let near = 25.0; // exactly the gate — boundary-inclusive, like every reach in this file
let far = 25.01;
let bones = |dist_sq| CorpseFacts {
lootable: true,
dist_sq,
..CorpseFacts::default()
};
// Leg 1: lootable bones. Pickup vs LootAll by the effective auto-loot, grayed by range.
assert_eq!(
corpse_cursor(bones(near)),
Some((CursorKind::Pickup, false))
);
assert_eq!(
corpse_cursor(CorpseFacts {
auto_loot: true,
..bones(near)
}),
Some((CursorKind::LootAll, false))
);
assert_eq!(corpse_cursor(bones(far)), Some((CursorKind::Pickup, true)));
// …and the already-looting override keeps it lit past the gate.
assert_eq!(
corpse_cursor(CorpseFacts {
looting_this: true,
..bones(far)
}),
Some((CursorKind::Pickup, false))
);
// Leg 2 needs BOTH the insignia flag and the learn-time latch. The flag alone is nothing —
// which is why a battleground body shows no knife to a player who cannot skin one, and why
// this leg is inert in 1.12.1: nothing latches `[0xb700e8]`.
let insignia = |skin_latch, dist_sq| CorpseFacts {
insignia: true,
skin_latch,
dist_sq,
..CorpseFacts::default()
};
assert_eq!(corpse_cursor(insignia(false, near)), None);
assert_eq!(
corpse_cursor(insignia(true, near)),
Some((CursorKind::Skin, false))
);
assert_eq!(
corpse_cursor(insignia(true, far)),
Some((CursorKind::Skin, true))
);
// Leg 1 wins outright when both flags are up — the binary tests lootable FIRST and returns.
assert_eq!(
corpse_cursor(CorpseFacts {
insignia: true,
skin_latch: true,
..bones(near)
}),
Some((CursorKind::Pickup, false))
);
// **Your own corpse**: neither bit is set. Both legs miss, the classifier clears, and the
// cursor is the plain pointer at any range and with anything held.
let mine = |dist_sq| CorpseFacts {
skin_latch: true,
dist_sq,
..CorpseFacts::default()
};
assert_eq!(corpse_cursor(mine(near)), None);
assert_eq!(
corpse_cursor(CorpseFacts {
auto_loot: true,
shift_held: true,
..mine(far)
}),
None
);
}
/// The loot pouch follows the EFFECTIVE auto-loot (0961: setting XOR shift) — the cursor
/// and the click can never disagree about what a pick will do.
#[test]
+4 -1
View File
@@ -38,9 +38,12 @@ pub(super) fn apply_highlight(
mut tags: Query<&mut MeshTag>,
mut was_lit: Local<Vec<Entity>>,
) {
// [`Hovered::any`], not `target`: a hovered **corpse** is a pick too (decision 1723), so it
// holds the brighten off a farther GameObject exactly as a unit does. Whether the corpse body
// itself brightens is a separate byte question and is not assumed here.
let go = hovered_go
.target
.filter(|_| hovered.target.is_none() || go_is_nearest(&hovered, &hovered_go));
.filter(|_| hovered.any().is_none() || go_is_nearest(&hovered, &hovered_go));
let unit_hover = hovered.target.filter(|_| go.is_none());
let mut want: Vec<Entity> = Vec::new();
for root in [unit_hover, go, selection.target].into_iter().flatten() {
+43 -11
View File
@@ -180,6 +180,8 @@ pub(super) fn update_hover(
) {
hovered.target = None;
hovered.guid = None;
hovered.corpse = None;
hovered.corpse_guid = None;
hovered.distance = f32::MAX;
if rig.is_looking() || pointer_over_ui.0 {
*last_pick = None;
@@ -217,7 +219,14 @@ pub(super) fn update_hover(
// even when the broad phase rejects them — the reference wouldn't click them there either.
let mut faithful: HashSet<Entity> = HashSet::new();
for (entity, gt, net, anims, drv, store, children, mount_child, drawn, held) in &roots {
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
// Units, players — and **corpses** (decision 1723). A corpse is a skinned character body
// like any other, so it belongs in this pass, not in a fourth picker duplicating it; the
// reference picks every CGObject in one trace and switches on type at the end. Which slot
// of [`Hovered`] the winner lands in is decided at the publish below, by kind.
if !matches!(
net.kind,
EntityKind::Unit | EntityKind::Player | EntityKind::Corpse
) {
continue;
}
// Not drawn ⇒ not clickable (see the query's note). Deliberately BEFORE `faithful.insert`
@@ -280,7 +289,15 @@ pub(super) fn update_hover(
palettes.world_palette(rig.slot, rig.bones() as usize)
})
};
let priority = if store.is_some_and(|s| s.0.unit_is_dead()) {
// The halo ladder (alive 3 / dead 2), with a **corpse below every unit** (1). Deliberately
// conservative and deliberately not read off `unit_is_dead()`: a corpse descriptor has no
// UNIT fields at all, so that accessor answers "alive" for one and would rank a body above
// a dead mob. Ranking it last means a corpse can never steal the generous-retry pick from
// a unit — the direction that cannot break an existing click. (Where `CGCorpse_C` actually
// sits in the reference's ladder is an open RE question, dispatched with 1723.)
let priority = if net.kind == EntityKind::Corpse {
1
} else if store.is_some_and(|s| s.0.unit_is_dead()) {
2
} else {
3
@@ -335,16 +352,19 @@ pub(super) fn update_hover(
if faithful.contains(&parent) {
continue; // posed-mesh-tested above
}
// Not a targetable unit (the self player; or — since 1706 — a **corpse**, which is a
// streamed body with a guid, a character model and pickable parts, and yet is not a unit:
// the reference's own interact slot for `CGCorpse_C` is `0x5d6bf0` (release/loot), never
// `SetTarget`. The faithful path states this rule inline at its head; without it here the
// corpse would be pass-2-eligible through the back door and a left click on one would send
// a selection for a guid no unit owns.
// The same eligibility the faithful path states at its head — and **a bone pile lives
// here** (decision 1723): the skeletal corpse model ships without a skeleton, so it never
// reaches the posed-mesh pass and this box test is the only thing that can ever pick it.
// The self player is still excluded (its query filter), and so is everything that is not a
// body: a hit resolves to a `Guid` the click will act on, and the kind decides which slot
// of [`Hovered`] receives it.
let Ok((_, parent_net)) = units.get(parent) else {
continue;
};
if !matches!(parent_net.kind, EntityKind::Unit | EntityKind::Player) {
if !matches!(
parent_net.kind,
EntityKind::Unit | EntityKind::Player | EntityKind::Corpse
) {
continue;
}
if let Some(t) = ray_mesh_bounds(origin, dir, aabb, gt) {
@@ -384,9 +404,21 @@ pub(super) fn update_hover(
if best.is_some_and(|(t, _)| limit < t) {
best = None;
}
// **Switch on type at the end**, the reference's own shape: the one pick lands in the unit slot
// or the corpse slot, never both. Everything downstream that means "unit" reads `target` and so
// stays right by construction; the corpse legs read `corpse`.
if let Some((t, entity)) = best {
hovered.target = Some(entity);
hovered.guid = units.get(entity).ok().map(|(g, _)| g.0);
if let Ok((guid, net)) = units.get(entity) {
if net.kind == EntityKind::Corpse {
hovered.corpse = Some(entity);
hovered.corpse_guid = Some(guid.0);
} else {
hovered.target = Some(entity);
hovered.guid = Some(guid.0);
}
} else {
hovered.target = Some(entity);
}
hovered.distance = t;
}
*last_pick = best.map(|(_, e)| e);
+61 -14
View File
@@ -96,17 +96,45 @@ pub(crate) struct Selection {
pub(crate) guid: Option<u64>,
}
/// The unit under the cursor this frame (the net entity + its guid), or `None`. Recomputed every frame
/// by [`hover::update_hover`] (plate rects first, then the posed-mesh pick); a click selects it, and
/// the mouseover highlight — the model emissive + the plate glow — reads it.
/// The **character-model pick** this frame — the one ray pass over every skinned body in the scene,
/// published by kind. Recomputed every frame by [`hover::update_hover`] (plate rects first, then the
/// posed-mesh pick).
///
/// **Two slots, and at most one of them is ever set**, because there is one pick: [`Self::target`]
/// when the winner is a unit or another player, [`Self::corpse`] when it is a corpse object. That
/// split is the reference's own shape — it makes a single pick over all CGObjects and switches on
/// **type** at the end (`0x480df0` → `0x7089c0`), and `CGCorpse_C` is a type that pick can land on
/// while being neither selectable nor a GameObject. Keeping the corpse out of `target` is what
/// keeps every existing consumer — selection, the plates, the unit tooltip, the mouseover
/// highlight, the `mouseover` unit token — correct without knowing corpses exist: a corpse is not
/// a unit and must never answer as one.
#[derive(Resource, Default, Clone, Copy)]
pub(crate) struct Hovered {
/// The hovered **unit or player** — the selectable one. `None` when the pick found a corpse,
/// or nothing.
pub(crate) target: Option<Entity>,
pub(crate) guid: Option<u64>,
/// World-space ray distance to the picked mesh (a plate-rect hover is `0.0` — UI wins any tie).
/// Meaningless when `target` is `None`. Lets the cursor/interact pick the **nearer** of a hovered
/// unit vs. a hovered GameObject ([`go_is_nearest`]), the reference's single nearest-CGObject pick.
/// Meaningless when neither slot is set. Covers **whichever** slot won, so the cursor/interact
/// arbiter picks the nearer of the character pick vs. a hovered GameObject ([`go_is_nearest`]),
/// the reference's single nearest-CGObject pick.
pub(crate) distance: f32,
/// The hovered **corpse object** (decision 1723) — a streamed body with a guid and a character
/// model that is nonetheless not a unit: the reference's `CGCorpse_C` fills the interact slot
/// `+0x60` with its own `0x5d6bf0`, never `SetTarget`. Read by the cursor classifier's corpse
/// leg and the right-click corpse route; deliberately invisible to everything that means
/// "unit".
pub(crate) corpse: Option<Entity>,
/// The hovered corpse's guid — `CMSG_LOOT` / `CMSG_RECLAIM_CORPSE` carry it.
pub(crate) corpse_guid: Option<u64>,
}
impl Hovered {
/// The picked entity whichever slot holds it — the "did the ray hit a character model at all?"
/// question the GameObject arbiter asks.
pub(crate) fn any(&self) -> Option<Entity> {
self.target.or(self.corpse)
}
}
/// The **GameObject** under the cursor this frame — the interaction arc's separate hover authority
@@ -211,14 +239,20 @@ pub(crate) fn latch_press_pick(
};
}
/// Which of the two hovered CGObjects — the unit [`Hovered`] or the [`HoveredObject`] — is the
/// nearer thing under the cursor, and so the one a click acts on. The reference makes one pick over
/// all CGObjects and switches on type; benilla runs the two picks separately, then chooses the
/// nearer here. A GameObject wins only when it is strictly closer than a hovered unit (so a mailbox
/// in front of a distant NPC takes the click; an NPC in front of a chest keeps it).
pub(crate) fn go_is_nearest(unit: &Hovered, go: &HoveredObject) -> bool {
match (unit.target, go.target) {
(Some(_), Some(_)) => go.distance < unit.distance,
/// Which of the two hovered CGObjects — the character-model [`Hovered`] or the [`HoveredObject`] —
/// is the nearer thing under the cursor, and so the one a click acts on. The reference makes one
/// pick over all CGObjects and switches on type; benilla runs the character and GameObject picks
/// separately, then chooses the nearer here. A GameObject wins only when it is strictly closer
/// than the character pick (so a mailbox in front of a distant NPC takes the click; an NPC in
/// front of a chest keeps it).
///
/// **Either slot of [`Hovered`] counts** ([`Hovered::any`]): a hovered *corpse* holds the ground
/// against a farther GameObject exactly as a unit does. Reading only `target` here — as this did
/// before corpses could be picked — would have handed the click, the cursor, the tooltip and the
/// highlight to any GameObject on screen the instant the ray landed on a body instead of a unit.
pub(crate) fn go_is_nearest(character: &Hovered, go: &HoveredObject) -> bool {
match (character.any(), go.target) {
(Some(_), Some(_)) => go.distance < character.distance,
(None, Some(_)) => true,
_ => false,
}
@@ -377,8 +411,14 @@ mod tests {
fn gameobject_wins_the_click_only_when_the_nearer_object() {
let hov = |t: bool, d: f32| Hovered {
target: t.then_some(Entity::PLACEHOLDER),
guid: None,
distance: d,
..Hovered::default()
};
// The same hover, landed on a CORPSE instead of a unit — the other slot of the one pick.
let corpse = |t: bool, d: f32| Hovered {
corpse: t.then_some(Entity::PLACEHOLDER),
distance: d,
..Hovered::default()
};
let obj = |t: bool, d: f32| HoveredObject {
target: t.then_some(Entity::PLACEHOLDER),
@@ -395,6 +435,13 @@ mod tests {
assert!(go_is_nearest(&hov(true, 10.0), &obj(true, 4.0)));
assert!(!go_is_nearest(&hov(true, 4.0), &obj(true, 10.0)));
assert!(!go_is_nearest(&hov(true, 5.0), &obj(true, 5.0)));
// A hovered CORPSE competes on exactly the same terms as a unit (decision 1723): it is the
// other slot of the same pick, so it keeps the click from a farther or tied GameObject and
// yields only to a strictly nearer one.
assert!(!go_is_nearest(&corpse(true, 5.0), &obj(false, 0.0)));
assert!(!go_is_nearest(&corpse(true, 4.0), &obj(true, 10.0)));
assert!(!go_is_nearest(&corpse(true, 5.0), &obj(true, 5.0)));
assert!(go_is_nearest(&corpse(true, 10.0), &obj(true, 4.0)));
}
/// The latch survives the hover being cleared — which is the whole reason it exists.
@@ -284,6 +284,7 @@ mod tests {
target: Some(Entity::from_raw_u32(2).unwrap()),
guid: Some(0x1234),
distance: 4.0,
..Hovered::default()
};
click(&mut world, id);
assert!(rx.try_recv().is_err(), "a unit in front of the chest wins");
@@ -127,3 +127,47 @@ fn the_host_has_a_reference_name_for_the_keys_it_now_delivers() {
);
}
}
/// **The one key a cinematic gives back.** The module doc above says the reference's `OnKeyDown`
/// calls `RunBinding("SCREENSHOT")` by hand *because* the walk's gate is existence — and benilla
/// shipped that transcribed line against a `RunBinding` that did not exist in the whole codebase.
/// Every press of the screenshot key during a fly-by raised `attempt to call global 'RunBinding'`
/// and took no picture, and the three tests above stayed green through all of it because they only
/// ever asked about keys the frame *swallows*.
#[test]
fn the_screenshot_key_is_handed_back_to_its_binding() {
let mut s = ui_with_the_cinematic_frame();
// The binding table the passthrough reads: `GetBindingKey("SCREENSHOT")` has to answer, or the
// arm is skipped and the test proves nothing.
s.register_bindings(&crate::bindings::registry_commands());
s.seed_binding_set(1, None);
s.load_binding_set(1);
let key = s
.keybind_snapshot()
.into_iter()
.find(|(name, _)| name == "SCREENSHOT")
.and_then(|(_, keys)| keys.into_iter().next())
.expect("SCREENSHOT ships a default chord");
start_cinematic(&mut s);
let _ = s.take_keybind_requests();
// Consumed like every other key — the frame is up, so the binding layer must not also see it.
assert!(
s.frame_key_input(&key),
"the frame consumes {key} like any other key"
);
// …and handed straight back to its command by hand. This is the whole point of the arm: the
// request is what the host runs, and an error here means no screenshot.
assert_eq!(
s.take_keybind_requests(),
vec![benilla_ui::script::keybind::KeybindRequest::Run(
"SCREENSHOT".into()
)],
"the screenshot key must reach its binding through RunBinding"
);
// ESCAPE is still the skip, and still queues nothing on the binding channel.
assert!(s.frame_key_input("ESCAPE"));
assert!(s.take_keybind_requests().is_empty());
}
+18 -1
View File
@@ -450,9 +450,26 @@ fn capture_ui_active(capture: Option<Res<crate::run_mode::CaptureMode>>) -> bool
fn arbitrate_pointer_over_ui(
egui: Option<Res<EguiPointerOver>>,
hover: Res<PlayerUiHover>,
cinematic: Option<Res<crate::cinematic::Cinematic>>,
mut over: ResMut<PointerOverUi>,
) {
over.0 = egui.is_some_and(|e| e.0) || hover.0.is_some();
// **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();
}
/// The session lifecycle — the VM's birth, identity, death, and the reload. Every edge function
+7 -4
View File
@@ -639,10 +639,13 @@ fn drive_mouseover_tooltip(
// and an IN_USE object all show the gold name plate while showing NO interact cursor — 0466's
// "no cursor AND no tooltip" coupling was the regression. Transports never reach here — they
// are excluded from the pick set itself (0466's correct half).
let go = hovered_go
.target
.zip(hovered_go.guid)
.filter(|_| unit.is_none() || go_is_nearest(&hovered, &hovered_go));
// "Nothing else was picked, or the GameObject is the nearer pick." **A hovered corpse counts
// as something else picked** (decision 1723) — it has no tooltip of its own yet, and the
// `unit.is_none()` short-circuit would otherwise hand a farther GameObject the gold plate the
// moment the ray landed on a body instead of a unit.
let go = hovered_go.target.zip(hovered_go.guid).filter(|_| {
(unit.is_none() && hovered.corpse.is_none()) || go_is_nearest(&hovered, &hovered_go)
});
if let Some((guid, state)) = unit.filter(|_| go.is_none()) {
// Push first (the engine's builder + the recolor's UnitReaction read the token), then