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. 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/ 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 visibility cascade). benilla is PART WAY there - 41 of our top-level frames declare
rect, so anchors agree - re-parenting is a separate arc if a cascade consumer appears. 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"> <Frame name="UIParent" setAllPoints="true" frameStrata="MEDIUM">
<Scripts> <Scripts>
+11 -3
View File
@@ -293,9 +293,17 @@
-- SetFullScreenFrame(frame) — UIParent.lua l.852-866. ONE line dropped: the reference also -- 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 -- 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 -- fullscreen frame.
-- full frame we ship — the world map — already paints its own full-screen blackout --
-- (WorldMapFrame.xml), which is what that Hide buys the reference. -- 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) function SetFullScreenFrame(frame)
local oldFrame = slots.fullscreen local oldFrame = slots.fullscreen
slots.fullscreen = frame 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 # requirement rather than legibility is **after BuffFrame.xml**: the toast re-anchors
# TemporaryEnchantFrame out of its own way, and that frame is declared there. # TemporaryEnchantFrame out of its own way, and that frame is declared there.
HelpFrame.xml 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 # 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. # 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. # 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))); 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 // 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). // 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):"); 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, pub loaded: bool,
/// Load errors, verbatim, tagged by file. /// Load errors, verbatim, tagged by file.
pub errors: Vec<String>, 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. /// Names it calls that the VM does not have — see the module doc on what this is worth.
pub missing_globals: Vec<String>, pub missing_globals: Vec<String>,
/// Dependencies named in its `.toc` that are not in the folder. /// 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. // consumer — the same rule `load_dependencies` applies to errors.
let baseline = RenderBaseline::of(&script); 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); 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 // 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. // 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(), interface: toc.interface_versions(),
loaded: errors.is_empty(), loaded: errors.is_empty(),
errors, errors,
absent_own_files: absent.own,
absent_foreign_files: absent.foreign,
missing_globals: wants.missing_globals, missing_globals: wants.missing_globals,
missing_deps, missing_deps,
missing_templates, missing_templates,
@@ -1635,15 +1661,47 @@ fn globals_of(script: &UiScript) -> BTreeSet<String> {
.collect() .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, /// 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). /// 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 provider = |req: &str| -> Option<Vec<u8>> { read_under(root, req) };
let mut errors = Vec::new(); let mut errors = Vec::new();
let mut absent = AbsentFiles::default();
for file in &toc.files { for file in &toc.files {
let path = benilla_ui::loader::join_ref(name, file); let path = benilla_ui::loader::join_ref(name, file);
let Some(bytes) = read_under(root, &path) else { let Some(bytes) = read_under(root, &path) else {
errors.push(format!("{file}: not found")); 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; continue;
}; };
if is_lua(file) { 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}")), Err(e) => errors.push(format!("{file}: {e}")),
} }
} }
errors (errors, absent)
} }
fn is_lua(entry: &str) -> bool { fn is_lua(entry: &str) -> bool {
@@ -3423,6 +3481,101 @@ mod dependency_tests {
let _ = std::fs::remove_dir_all(&tmp); 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] #[test]
fn a_sibling_addons_embedded_library_is_invisible() { fn a_sibling_addons_embedded_library_is_invisible() {
let tmp = let tmp =
@@ -105,7 +105,7 @@ pub fn probe(root: &Path, name: &str, evals: &[String]) -> Option<ProbeOutcome>
&mut dep_order, &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 session_errors = super::drive_session_start(&mut script, name, &dep_order);
let answers = evals let answers = evals
+29 -5
View File
@@ -207,7 +207,7 @@ impl Plugin for BindingsPlugin {
.before(benilla_world::schedule::WorldStage::Input) .before(benilla_world::schedule::WorldStage::Input)
.run_if(in_state(ClientState::InWorld)), .run_if(in_state(ClientState::InWorld)),
load_character_bindings.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 /// Drain the VM's queued binding requests.
/// character file exists deletes it — the confirmed permanent delete. ///
fn save_bindings(script: Option<NonSendMut<UiScript>>, files: Res<BindingFiles>) { /// 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 }; let Some(mut script) = script else { return };
for req in script.take_keybind_requests() { 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 snapshot = script.keybind_snapshot();
let text = store::to_diff(&snapshot); let text = store::to_diff(&snapshot);
let path = match which { 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 //! 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 //! intro, is the one file authored to loop — a difference we inherit for free by ending on the
//! band rather than on the flag.) //! band rather than on the flag.)
//! - **Between the shots of a multi-camera row the client sends `CMSG_NEXT_CINEMATIC_CAMERA`** //! - **The client announces every shot with `CMSG_NEXT_CINEMATIC_CAMERA` — including the first.**
//! (`0x48efe0`), and a camera id of `0` **ends** the cinematic rather than being skipped. No //! The send sits inside the *shot arm* `0x48edf0`, not the shot advance: `0x48ef11 push 0xfb`
//! shipped row has a second camera, so this path is exercised only by a server with its own //! → `0x418190` (the packet builder) → `0x48ef24 call 0x5ab630` (flush), immediately before
//! DBCs — which is exactly why it is written rather than assumed away. //! `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: //! - **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 //! 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 //! that frame (`assets/ui/CinematicFrame.xml`) carries the same handler and why the Lua binding
@@ -40,11 +45,17 @@
//! //!
//! # What we deliberately do differently //! # What we deliberately do differently
//! //!
//! The reference defers both the start and the stop by **0.25 s** through a scheduled fade //! **Every cinematic boundary in the reference cuts through black, and benilla cuts hard.** The
//! (`0x4c0d10`, the constant at `[0x804550]`). benilla plays the shot immediately and acks //! `0.25 s` at `[0x804550]` is not a scheduled delay — it is a screen fade, and the next step is
//! immediately: the fade is a transition effect we have not built, and faking its *delay* without //! its *completion callback*: `0x4c0d10` builds a fullscreen opaque-black quad (`0xff000000`) and
//! its *picture* would only add latency. Noted rather than silently dropped — it is the one timing //! latches what to run when it is fully up, so `CINEMATIC_START`, each shot advance and
//! difference from the reference on this path. //! `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 //! # What playback takes over
//! //!
@@ -86,6 +97,12 @@ pub(crate) struct Cinematic {
/// One cinematic in flight. /// One cinematic in flight.
struct Playing { 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. /// The `CinematicSequences.dbc` id, for logging.
sequence_id: u32, sequence_id: u32,
/// The row's shots, in order. Non-empty (a sequence that resolves to nothing is acked at the /// 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() self.playing.is_some()
} }
/// The shot on screen: `(sequence id, shot index, narration sound id)`. The identity pair is /// The shot on screen: `(run, shot index, narration sound id)`. The identity pair is what lets
/// what lets a follower tell "still the same shot" from "the next one", which is the question /// a follower tell "still the same shot" from "the next one", which is the question the
/// the narration channel actually asks. /// narration channel actually asks — and it is keyed on [`Playing::run`] rather than the
pub(crate) fn playing_shot(&self) -> Option<(u32, usize, u32)> { /// 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()?; 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 { Self {
pending: None, pending: None,
playing: Some(Playing { playing: Some(Playing {
run: 0,
sequence_id: 0, sequence_id: 0,
shots: Vec::new(), shots: Vec::new(),
index: 0, index: 0,
@@ -154,6 +173,14 @@ impl Cinematic {
#[derive(Component)] #[derive(Component)]
struct LetterboxBar; 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; pub(crate) struct CinematicPlugin;
impl Plugin for CinematicPlugin { impl Plugin for CinematicPlugin {
@@ -255,7 +282,8 @@ fn drive_letterbox(
mut hidden: ResMut<crate::ui_hide::UiHidden>, mut hidden: ResMut<crate::ui_hide::UiHidden>,
mut bars: Query<(&mut Node, &mut Visibility), With<LetterboxBar>>, mut bars: Query<(&mut Node, &mut Visibility), With<LetterboxBar>>,
windows: Query<&Window>, 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>, mut logged: Local<f32>,
) { ) {
let playing = cine.is_playing(); let playing = cine.is_playing();
@@ -263,14 +291,37 @@ fn drive_letterbox(
// their choice when it ends. // their choice when it ends.
if playing && !hidden.0 { if playing && !hidden.0 {
hidden.0 = true; hidden.0 = true;
*ours = true; ours.ui = true;
info!("cinematic: HUD hidden for playback"); info!("cinematic: HUD hidden for playback");
} else if !playing && *ours { } else if !playing && ours.ui {
hidden.0 = false; hidden.0 = false;
*ours = false; ours.ui = false;
info!("cinematic: HUD restored"); 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 let height = playing
.then(|| windows.iter().next()) .then(|| windows.iter().next())
.flatten() .flatten()
@@ -340,7 +391,31 @@ fn take_trigger(
ack(net.as_deref()); ack(net.as_deref());
continue; 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.len(),
shots.iter().map(|s| s.duration_ms).sum::<u32>() 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 { cine.playing = Some(Playing {
run: RUN.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
sequence_id: id, sequence_id: id,
shots, shots,
index: 0, index: 0,
elapsed: Duration::ZERO, 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. /// 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.index += 1;
play.elapsed = over; play.elapsed = over;
if let Some(net) = net.as_deref() { announce_shot(net.as_deref());
let _ = net.0.send(ClientCommand::NextCinematicCamera);
}
} }
let Ok(mut cam) = camera.single_mut() else { let Ok(mut cam) = camera.single_mut() else {
@@ -453,6 +548,13 @@ fn drive(
let forward = (target - eye).normalize_or_zero(); let forward = (target - eye).normalize_or_zero();
let up = if forward == Vec3::ZERO { let up = if forward == Vec3::ZERO {
Vec3::Y 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 { } else {
Quat::from_axis_angle(forward, view.roll) * Vec3::Y 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 /// skip is indistinguishable from a completion on the wire, which is what made decision 0196's
/// instant-ack legitimate in the first place. /// instant-ack legitimate in the first place.
pub(crate) fn stop(cine: &mut Cinematic, net: Option<&NetCommands>) { pub(crate) fn stop(cine: &mut Cinematic, net: Option<&NetCommands>) {
if let Some(play) = cine.playing.take() { // A trigger still waiting on the world goes with it: the player has said "not this".
info!("cinematic: {} stopped", play.sequence_id); relinquish(cine, net, "stopped");
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);
}
} }
/// Leaving the world drops a cinematic without acking — the socket is going away with it. /// 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) { if *published == Some(playing) {
return; 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); *published = Some(playing);
script.set_in_cinematic(playing); script.set_in_cinematic(playing);
script.fire_event( 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>>, world_assets: Option<ResMut<WorldAssets>>,
mode: Res<super::DisplayedCursor>, mode: Res<super::DisplayedCursor>,
rig: Res<CameraControl>, rig: Res<CameraControl>,
cinematic: Option<Res<crate::cinematic::Cinematic>>,
mut focus: MessageReader<bevy::window::WindowFocused>, mut focus: MessageReader<bevy::window::WindowFocused>,
// None of the `Local`s below is a [`crate::ui_script::VmMemo`], deliberately (decision // 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 // 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(); let stem = mode.0.stem();
// The held payload's cursor wins over the classified mode (falls back to the mode cursor // 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 // 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 /// the flying cinematic camera (a first login's race intro) and every NPC around the body
/// despawns until relog (decision 0196). /// despawns until relog (decision 0196).
CompleteCinematic, CompleteCinematic,
/// Advance a multi-camera cinematic to its next shot (`CMSG_NEXT_CINEMATIC_CAMERA`) — see /// Announce the shot now being armed (`CMSG_NEXT_CINEMATIC_CAMERA`) — see
/// [`benilla_protocol::WorldWriter::next_cinematic_camera`]. No shipped 1.12 sequence has a /// [`benilla_protocol::WorldWriter::next_cinematic_camera`]. Sent for **every** shot, the
/// second camera, so this rides only a server with its own DBCs. /// 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, NextCinematicCamera,
/// **Acknowledge a granted mover mode** — root, water-walk, feather-fall or hover (decisions /// **Acknowledge a granted mover mode** — root, water-walk, feather-fall or hover (decisions
/// 0308, 0866): the echoed `counter` + our live pose, on the opcode /// 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 benilla_world::schedule::WorldStage;
use super::mixer::{self, StreamingSoundHandle}; use super::mixer::{self, StreamingSoundHandle};
use super::{kit::SoundKits, SoundConfig, SoundOutput}; use super::{kit::SoundKits, SoundOutput};
use crate::cinematic::Cinematic; use crate::cinematic::Cinematic;
/// A cut-off narration is **cut**, with no fade at all — wow-re /// 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. /// The narration channel: which shot it belongs to, and its live handle.
#[derive(Default)] #[derive(Default)]
pub(super) struct CinematicVoice { pub(super) struct CinematicVoice {
/// `(sequence id, shot index)` of the shot whose narration is playing — the identity that /// `(run, shot index)` of the shot whose narration is playing — the identity that decides
/// decides whether the current shot's audio is already running. /// whether the current shot's audio is already running. Keyed on the **run** rather than the
shot: Option<(u32, usize)>, /// 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>>, handle: Option<StreamingSoundHandle<kira::sound::FromFileError>>,
} }
@@ -70,20 +73,19 @@ fn drive_narration(
mut out: NonSendMut<SoundOutput>, mut out: NonSendMut<SoundOutput>,
mut kits: ResMut<SoundKits>, mut kits: ResMut<SoundKits>,
assets: Option<Res<WorldAssets>>, assets: Option<Res<WorldAssets>>,
config: Res<SoundConfig>,
) { ) {
let playing = cine.as_deref().and_then(Cinematic::playing_shot); 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. // No cinematic (or it just ended/was skipped) — cut whatever was narrating.
voice.stop(); voice.stop();
return; return;
}; };
if voice.shot == Some((sequence, index)) { if voice.shot == Some((run, index)) {
return; return;
} }
// A new shot: the previous one's narration, if any, does not carry over. // A new shot: the previous one's narration, if any, does not carry over.
voice.stop(); voice.stop();
voice.shot = Some((sequence, index)); voice.shot = Some((run, index));
if sound_id == 0 { if sound_id == 0 {
return; return;
} }
@@ -122,7 +124,14 @@ fn drive_narration(
// The old reading — the Sfx category, "the same one every NPC voice line takes" — was a // 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 // reasonable guess at where a player's expectation points, and it was wrong: this channel has
// no category. // 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))) { match mixer_ref.play_stream(data.volume(mixer::amp_to_db(amp))) {
Ok(h) => { Ok(h) => {
info!("cinematic voice: {path}"); 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 /// Copy the cinematic's music stop into [`SoundConfig::music_suppressed`], once per frame.
/// `PreUpdate` beside [`feed_world_hold`] — same shape, same reason: one answer per frame, read by ///
/// every trigger system after it. /// **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( fn feed_music_suppression(
mut config: ResMut<SoundConfig>, mut config: ResMut<SoundConfig>,
cinematic: Option<Res<crate::cinematic::Cinematic>>, 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 // 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. // 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( .add_systems(
Update, Update,
( (
+5
View File
@@ -800,6 +800,11 @@ fn stop_world_soundscape(zone: &mut ZoneAudio, reason: &str) {
zone.area = None; zone.area = None;
zone.interior = None; zone.interior = None;
zone.was_underwater = false; 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 { if had {
info!("sound: world soundscape stopped ({reason})"); 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 // `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). // 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()) { if click_cfg.deselect_on_click && (!payload_held.0 || occlusion.distance.is_finite()) {
clear(&mut selection, &mut seam, engaged); clear(&mut selection, &mut seam, engaged);
} }
@@ -353,6 +361,69 @@ pub(super) fn act_on_right_click(
} }
return; 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 { let (Some(entity), Some(guid)) = (hovered.target, hovered.guid) else {
return; return;
}; };
@@ -186,6 +186,17 @@ const ATTACK_RANGE_SQ: f32 = 109.2025;
const MELEE_OFFSET: f32 = 1.333_33; const MELEE_OFFSET: f32 = 1.333_33;
const MELEE_FLOOR: f32 = 5.0; 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 /// `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). /// predicate is constant-false, so it never shows an interact cursor (wow-re cursor-system §4a).
pub(crate) const GO_TYPE_GENERIC: i32 = 5; 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: /// 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 /// interactable-NPC service ladder, else loot/skin/attack by state, each grayed by its own range
/// gate. No hover (or anything unresolvable) → Point. /// 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. // The loot leg's Pickup/LootAll split (0965): the auto-loot knob (0961) + the live shift.
loot_cfg: Res<crate::ui_loot::LootConfig>, loot_cfg: Res<crate::ui_loot::LootConfig>,
keys: Res<ButtonInput<KeyCode>>, 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 // 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 // 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 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) { let resolved = if go_is_nearest(&hovered, &hovered_object) {
resolve_go() resolve_go()
} else if hovered.corpse.is_some() {
resolve_corpse()
} else { } else {
resolve_unit() resolve_unit()
}; };
@@ -876,6 +962,84 @@ mod tests {
use super::*; use super::*;
use benilla_protocol::messages::dialog_status; 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 /// 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. /// and the click can never disagree about what a pick will do.
#[test] #[test]
+4 -1
View File
@@ -38,9 +38,12 @@ pub(super) fn apply_highlight(
mut tags: Query<&mut MeshTag>, mut tags: Query<&mut MeshTag>,
mut was_lit: Local<Vec<Entity>>, 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 let go = hovered_go
.target .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 unit_hover = hovered.target.filter(|_| go.is_none());
let mut want: Vec<Entity> = Vec::new(); let mut want: Vec<Entity> = Vec::new();
for root in [unit_hover, go, selection.target].into_iter().flatten() { 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.target = None;
hovered.guid = None; hovered.guid = None;
hovered.corpse = None;
hovered.corpse_guid = None;
hovered.distance = f32::MAX; hovered.distance = f32::MAX;
if rig.is_looking() || pointer_over_ui.0 { if rig.is_looking() || pointer_over_ui.0 {
*last_pick = None; *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. // even when the broad phase rejects them — the reference wouldn't click them there either.
let mut faithful: HashSet<Entity> = HashSet::new(); let mut faithful: HashSet<Entity> = HashSet::new();
for (entity, gt, net, anims, drv, store, children, mount_child, drawn, held) in &roots { 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; continue;
} }
// Not drawn ⇒ not clickable (see the query's note). Deliberately BEFORE `faithful.insert` // 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) 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 2
} else { } else {
3 3
@@ -335,16 +352,19 @@ pub(super) fn update_hover(
if faithful.contains(&parent) { if faithful.contains(&parent) {
continue; // posed-mesh-tested above continue; // posed-mesh-tested above
} }
// Not a targetable unit (the self player; or — since 1706 — a **corpse**, which is a // The same eligibility the faithful path states at its head — and **a bone pile lives
// streamed body with a guid, a character model and pickable parts, and yet is not a unit: // here** (decision 1723): the skeletal corpse model ships without a skeleton, so it never
// the reference's own interact slot for `CGCorpse_C` is `0x5d6bf0` (release/loot), never // reaches the posed-mesh pass and this box test is the only thing that can ever pick it.
// `SetTarget`. The faithful path states this rule inline at its head; without it here the // The self player is still excluded (its query filter), and so is everything that is not a
// corpse would be pass-2-eligible through the back door and a left click on one would send // body: a hit resolves to a `Guid` the click will act on, and the kind decides which slot
// a selection for a guid no unit owns. // of [`Hovered`] receives it.
let Ok((_, parent_net)) = units.get(parent) else { let Ok((_, parent_net)) = units.get(parent) else {
continue; continue;
}; };
if !matches!(parent_net.kind, EntityKind::Unit | EntityKind::Player) { if !matches!(
parent_net.kind,
EntityKind::Unit | EntityKind::Player | EntityKind::Corpse
) {
continue; continue;
} }
if let Some(t) = ray_mesh_bounds(origin, dir, aabb, gt) { 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) { if best.is_some_and(|(t, _)| limit < t) {
best = None; 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 { if let Some((t, entity)) = best {
hovered.target = Some(entity); if let Ok((guid, net)) = units.get(entity) {
hovered.guid = units.get(entity).ok().map(|(g, _)| g.0); 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; hovered.distance = t;
} }
*last_pick = best.map(|(_, e)| e); *last_pick = best.map(|(_, e)| e);
+61 -14
View File
@@ -96,17 +96,45 @@ pub(crate) struct Selection {
pub(crate) guid: Option<u64>, pub(crate) guid: Option<u64>,
} }
/// The unit under the cursor this frame (the net entity + its guid), or `None`. Recomputed every frame /// The **character-model pick** this frame — the one ray pass over every skinned body in the scene,
/// by [`hover::update_hover`] (plate rects first, then the posed-mesh pick); a click selects it, and /// published by kind. Recomputed every frame by [`hover::update_hover`] (plate rects first, then the
/// the mouseover highlight — the model emissive + the plate glow — reads it. /// 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)] #[derive(Resource, Default, Clone, Copy)]
pub(crate) struct Hovered { 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) target: Option<Entity>,
pub(crate) guid: Option<u64>, pub(crate) guid: Option<u64>,
/// World-space ray distance to the picked mesh (a plate-rect hover is `0.0` — UI wins any tie). /// 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 /// Meaningless when neither slot is set. Covers **whichever** slot won, so the cursor/interact
/// unit vs. a hovered GameObject ([`go_is_nearest`]), the reference's single nearest-CGObject pick. /// 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, 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 /// 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 /// Which of the two hovered CGObjects — the character-model [`Hovered`] or the [`HoveredObject`] —
/// nearer thing under the cursor, and so the one a click acts on. The reference makes one pick over /// is the nearer thing under the cursor, and so the one a click acts on. The reference makes one
/// all CGObjects and switches on type; benilla runs the two picks separately, then chooses the /// pick over all CGObjects and switches on type; benilla runs the character and GameObject picks
/// nearer here. A GameObject wins only when it is strictly closer than a hovered unit (so a mailbox /// separately, then chooses the nearer here. A GameObject wins only when it is strictly closer
/// in front of a distant NPC takes the click; an NPC in front of a chest keeps it). /// than the character pick (so a mailbox in front of a distant NPC takes the click; an NPC in
pub(crate) fn go_is_nearest(unit: &Hovered, go: &HoveredObject) -> bool { /// front of a chest keeps it).
match (unit.target, go.target) { ///
(Some(_), Some(_)) => go.distance < unit.distance, /// **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, (None, Some(_)) => true,
_ => false, _ => false,
} }
@@ -377,8 +411,14 @@ mod tests {
fn gameobject_wins_the_click_only_when_the_nearer_object() { fn gameobject_wins_the_click_only_when_the_nearer_object() {
let hov = |t: bool, d: f32| Hovered { let hov = |t: bool, d: f32| Hovered {
target: t.then_some(Entity::PLACEHOLDER), target: t.then_some(Entity::PLACEHOLDER),
guid: None,
distance: d, 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 { let obj = |t: bool, d: f32| HoveredObject {
target: t.then_some(Entity::PLACEHOLDER), 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, 10.0), &obj(true, 4.0)));
assert!(!go_is_nearest(&hov(true, 4.0), &obj(true, 10.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))); 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. /// 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()), target: Some(Entity::from_raw_u32(2).unwrap()),
guid: Some(0x1234), guid: Some(0x1234),
distance: 4.0, distance: 4.0,
..Hovered::default()
}; };
click(&mut world, id); click(&mut world, id);
assert!(rx.try_recv().is_err(), "a unit in front of the chest wins"); 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( fn arbitrate_pointer_over_ui(
egui: Option<Res<EguiPointerOver>>, egui: Option<Res<EguiPointerOver>>,
hover: Res<PlayerUiHover>, hover: Res<PlayerUiHover>,
cinematic: Option<Res<crate::cinematic::Cinematic>>,
mut over: ResMut<PointerOverUi>, 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 /// 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 // 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 // "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). // are excluded from the pick set itself (0466's correct half).
let go = hovered_go // "Nothing else was picked, or the GameObject is the nearer pick." **A hovered corpse counts
.target // as something else picked** (decision 1723) — it has no tooltip of its own yet, and the
.zip(hovered_go.guid) // `unit.is_none()` short-circuit would otherwise hand a farther GameObject the gold plate the
.filter(|_| unit.is_none() || go_is_nearest(&hovered, &hovered_go)); // 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()) { if let Some((guid, state)) = unit.filter(|_| go.is_none()) {
// Push first (the engine's builder + the recolor's UnitReaction read the token), then // Push first (the engine's builder + the recolor's UnitReaction read the token), then
+53 -16
View File
@@ -43,7 +43,7 @@
//! |---|---|---|---|---|---|---| //! |---|---|---|---|---|---|---|
//! | 1 | PalantirOfAzora | 0.7854 | 14.9 s | 10 | const 0 | 205 yd | //! | 1 | PalantirOfAzora | 0.7854 | 14.9 s | 10 | const 0 | 205 yd |
//! | 2 | FlybyUndead | **1.5708** | 102.0 s | 22 | 18 keys | 523 yd | //! | 2 | FlybyUndead | **1.5708** | 102.0 s | 22 | 18 keys | 523 yd |
//! | 122 | FlybyNightElf | 0.7854 | 102.3 s | 32 | 19 keys | 813 yd | //! | 122 | FlybyNightElf | 0.7854 | 102.0 s | 32 | 19 keys | 813 yd |
//! | 142 | FlyByHuman | 0.7854 | 87.5 s | 20 | const 2π | 663 yd | //! | 142 | FlyByHuman | 0.7854 | 87.5 s | 20 | const 2π | 663 yd |
//! | 162 | FlyByGnome | 0.7854 | 76.7 s | 27 | const 0 | 705 yd | //! | 162 | FlyByGnome | 0.7854 | 76.7 s | 27 | const 0 | 705 yd |
//! | 182 | FlyByTroll | 0.7854 | 57.8 s | 21 | 12 keys | 39 yd | //! | 182 | FlyByTroll | 0.7854 | 57.8 s | 21 | 12 keys | 39 yd |
@@ -281,8 +281,13 @@ pub struct CinematicPath {
pub near_clip: f32, pub near_clip: f32,
/// The authored far clip — see [`Self::near_clip`]. /// The authored far clip — see [`Self::near_clip`].
pub far_clip: f32, pub far_clip: f32,
/// How long the shot runs, milliseconds: the sequence band the tracks are keyed inside. /// How long the shot runs, milliseconds — the *width* of the sequence band the tracks are
/// keyed inside (`end − start`), not its end. See [`Self::sample`] for why the two differ.
pub duration_ms: u32, pub duration_ms: u32,
/// Where that band begins on the model's global timeline; added to the sample time so a shot
/// whose first key is not at zero (`FlybyNightElf`, `Scry_cam`) starts on its first key
/// instead of holding it.
band_start: u32,
origin: [f32; 3], origin: [f32; 3],
facing_sin_cos: (f32, f32), facing_sin_cos: (f32, f32),
camera: M2Camera, camera: M2Camera,
@@ -309,11 +314,15 @@ impl CinematicPath {
.ok_or_else(|| anyhow::anyhow!("model carries no camera record"))?; .ok_or_else(|| anyhow::anyhow!("model carries no camera record"))?;
// The shot's length is the model's own sequence band (stride 0x44 records at header // The shot's length is the model's own sequence band (stride 0x44 records at header
// 0x1c/0x20, band `[start, end]` at +0x04/+0x08 — the same walk the emitter-timing bake // 0x1c/0x20, band `[start, end]` at +0x04/+0x08 — the same walk the emitter-timing bake
// uses). Every shipped fly-by carries exactly one sequence, `[0, end]`, whose end equals // uses). The reference arms this as an ordinary M2 animation on sequence 0, so the shot
// the last key timestamp on its position and target tracks alike; the band is what the // runs the band: it *plays* for `end − start` and its tracks are keyed at absolute
// file *says*, so that is what we read, with the last key as the fallback for a file that // global-timeline stamps inside `[start, end]`. Eight of the ten shipped cameras band at
// authors no sequence at all. // `[0, end]` and the distinction is invisible on them — but `FlybyNightElf` bands at
let duration_ms = sequence_band_end(bytes) // `[333, 102333]` and `Scry_cam` at `[33, 3333]`, and reading `end` as the duration
// played those two 333 ms / 33 ms too long with the opening frames frozen on the first
// key (which is what sampling `t = 0` against a track that starts at 333 returns).
// The fallback, for a file that authors no sequence at all, is the last key.
let (band_start, band_end) = sequence_band(bytes)
.or_else(|| { .or_else(|| {
[ [
camera.positions.keys.last().map(|k| k.0), camera.positions.keys.last().map(|k| k.0),
@@ -322,8 +331,10 @@ impl CinematicPath {
.into_iter() .into_iter()
.flatten() .flatten()
.max() .max()
.map(|end| (0, end))
}) })
.unwrap_or(0); .unwrap_or((0, 0));
let duration_ms = band_end.saturating_sub(band_start);
Ok(Self { Ok(Self {
camera_id: row.id, camera_id: row.id,
sound_id: row.sound_id, sound_id: row.sound_id,
@@ -331,6 +342,7 @@ impl CinematicPath {
near_clip: camera.near_clip, near_clip: camera.near_clip,
far_clip: camera.far_clip, far_clip: camera.far_clip,
duration_ms, duration_ms,
band_start,
origin: row.origin, origin: row.origin,
facing_sin_cos: row.origin_facing.sin_cos(), facing_sin_cos: row.origin_facing.sin_cos(),
camera, camera,
@@ -339,6 +351,12 @@ impl CinematicPath {
/// Sample the shot at `ms` from its start, **end-clamped** at both ends. /// Sample the shot at `ms` from its start, **end-clamped** at both ends.
/// ///
/// `ms` is measured from the *shot's* start; the tracks are keyed on the model's global
/// timeline, so it is offset by [`Self::band_start`] before it reaches them. On eight of the
/// ten shipped cameras that offset is zero and the two are the same number; on
/// `FlybyNightElf` (band `[333, 102333]`) and `Scry_cam` (`[33, 3333]`) it is the difference
/// between opening on the authored first key and holding it for a third of a second first.
///
/// Two steps, in this order. The reference's publish pass composes each track against its base /// Two steps, in this order. The reference's publish pass composes each track against its base
/// (`eye = position_base + positions(t)`, `target = target_position_base + target(t)`); then /// (`eye = position_base + positions(t)`, `target = target_position_base + target(t)`); then
/// the local frame is planted in the world by the camera row's origin and facing: /// the local frame is planted in the world by the camera row's origin and facing:
@@ -354,6 +372,7 @@ impl CinematicPath {
/// the dwarf (41) and human (81) intros; the sign-flipped and axis-swapped alternatives are /// the dwarf (41) and human (81) intros; the sign-flipped and axis-swapped alternatives are
/// off by an order of magnitude (module doc). /// off by an order of magnitude (module doc).
pub fn sample(&self, ms: u32) -> CinematicView { pub fn sample(&self, ms: u32) -> CinematicView {
let ms = self.band_start.saturating_add(ms);
let eye = self.to_world(sample_against( let eye = self.to_world(sample_against(
&self.camera.positions, &self.camera.positions,
self.camera.position_base, self.camera.position_base,
@@ -387,9 +406,17 @@ fn sample_against(track: &M2Track<M2SplineKey<[f32; 3]>>, base: [f32; 3], ms: u3
std::array::from_fn(|i| base[i] + d[i]) std::array::from_fn(|i| base[i] + d[i])
} }
/// The end of the model's **first** sequence band (header `0x1c` count / `0x20` offset, entry /// The model's **first** sequence band, `(start, end)` in global-timeline milliseconds (header
/// stride `0x44`, band `[start, end]` at `+0x04`/`+0x08`). `None` for a model with no sequences. /// `0x1c` count / `0x20` offset, entry stride `0x44`, band at `+0x04`/`+0x08`). `None` for a model
fn sequence_band_end(b: &[u8]) -> Option<u32> { /// with no sequences.
///
/// **`start` is not always zero, and the shipped corpus is its own proof of these offsets.** Eight
/// of the ten cameras band at `[0, end]`, but `FlybyNightElf` bands at `[333, 102333]` and
/// `Scry_cam` at `[33, 3333]` — and in both files the band's `start` is *exactly* the first
/// timestamp on the position and target tracks, while `end` is exactly the last. Two fields that
/// land on the first and last key of every file in the corpus are the first and last key, which
/// is why this is read rather than assumed to be a duration.
fn sequence_band(b: &[u8]) -> Option<(u32, u32)> {
let le_u32 = |o: usize| -> Option<u32> { let le_u32 = |o: usize| -> Option<u32> {
b.get(o..o + 4) b.get(o..o + 4)
.map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) .map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
@@ -398,7 +425,7 @@ fn sequence_band_end(b: &[u8]) -> Option<u32> {
if n == 0 { if n == 0 {
return None; return None;
} }
le_u32(o + 0x08) Some((le_u32(o + 0x04)?, le_u32(o + 0x08)?))
} }
#[cfg(test)] #[cfg(test)]
@@ -471,16 +498,26 @@ mod tests {
// for, and the one a step/linear-only sampler would silently mangle. // for, and the one a step/linear-only sampler would silently mangle.
assert_eq!(path.camera.positions.interp, 2, "sequence {id} position"); assert_eq!(path.camera.positions.interp, 2, "sequence {id} position");
assert_eq!(path.camera.target.interp, 2, "sequence {id} target"); assert_eq!(path.camera.target.interp, 2, "sequence {id} target");
// **The end condition**: the authored sequence band ends exactly on the last key of // **The end condition, and the proof of the band offsets.** The authored sequence
// both vector tracks, on every shipped fly-by — so "the band" and "the last key" are // band brackets both vector tracks exactly: its `start` is the first key's timestamp
// the same number, and reading either gives the same playback length. // and its `end` is the last one's, on every shipped fly-by. Two header fields that
// land on the first and last key of all eight files are the first and last key — and
// that is what licenses reading the playback length as `end − start` rather than as
// `end`, which is the bug this assertion now pins (`FlybyNightElf` bands at
// `[333, 102333]`, so the two readings differ by a third of a second and by whether
// the shot opens on its first key or holds it).
for (what, track) in [ for (what, track) in [
("position", &path.camera.positions), ("position", &path.camera.positions),
("target", &path.camera.target), ("target", &path.camera.target),
] { ] {
assert_eq!(
track.keys.first().map(|k| k.0),
Some(path.band_start),
"sequence {id} {what} track starts on the band"
);
assert_eq!( assert_eq!(
track.keys.last().map(|k| k.0), track.keys.last().map(|k| k.0),
Some(path.duration_ms), Some(path.band_start + path.duration_ms),
"sequence {id} {what} track ends on the band" "sequence {id} {what} track ends on the band"
); );
} }
@@ -796,6 +796,20 @@ impl ObjectFields {
self.get_u32(36).unwrap_or(0) & 0x01 != 0 self.get_u32(36).unwrap_or(0) & 0x01 != 0
} }
/// `CORPSE_FLAG_LOOTABLE` (`0x20`, [`Self::corpse_flags`] bit 5) — the **PvP insignia** flag,
/// the one the reference's corpse cursor classifier `0x482740` reads at
/// `[[corpse+0x110]+0x74] >> 5 & 1` for its second leg, and the corpse right-click `0x5d6bf0`
/// reads again at `0x5d6c9c` for the matching send.
///
/// **It is not "reclaimable"** — `object-layer/scratch/w2a.md` calls it both things in one
/// file (line 264 "reclaimable", line 618 "PvP flag") and only the second survives the byte
/// read: both readers pair it with the `SPELL_EFFECT_SKIN_PLAYER_CORPSE` learn-time latch
/// `[0xb700e8]`, which is a skinning precondition and has nothing to do with recovering your
/// own body. Nothing in the reclaim path consults this bit.
pub fn corpse_pvp_insignia(&self) -> bool {
self.corpse_flags() & 0x20 != 0
}
/// `DYNAMICOBJECT_CASTER` (fields 6–7, `OBJECT_END + 0x0`, a 2-field guid — vmangos /// `DYNAMICOBJECT_CASTER` (fields 6–7, `OBJECT_END + 0x0`, a 2-field guid — vmangos
/// `UpdateFields_1_12_1.h:325`): the unit whose ground cast anchored this object. /// `UpdateFields_1_12_1.h:325`): the unit whose ground cast anchored this object.
/// Index-twin of [`Self::corpse_owner`] — each TYPEID reuses `OBJECT_END`; the TypeId (or /// Index-twin of [`Self::corpse_owner`] — each TYPEID reuses `OBJECT_END`; the TypeId (or
@@ -60,15 +60,17 @@ impl WorldWriter {
self.send(opcode::CMSG_COMPLETE_CINEMATIC, &[]) self.send(opcode::CMSG_COMPLETE_CINEMATIC, &[])
} }
/// Advance a multi-camera cinematic to its next shot (`CMSG_NEXT_CINEMATIC_CAMERA`, empty /// Announce the cinematic shot being armed (`CMSG_NEXT_CINEMATIC_CAMERA`, empty body) — see
/// body) — see the opcode's own note. Sent *between* cameras; the run still ends with exactly /// the opcode's own note. Sent once per shot, **including the first**, so a stock single-camera
/// one [`complete_cinematic`](Self::complete_cinematic). /// race intro sends exactly one; the run still ends with exactly one
/// [`complete_cinematic`](Self::complete_cinematic).
/// ///
/// No shipped 1.12 `CinematicSequences` row carries a second camera, so this never fires /// The reference's send is inside the shot arm `0x48edf0` (`0x48ef11 push 0xfb` → the
/// against a stock server today. It exists because the sequencing is the *client's*, not the /// `0x418190` builder → `0x5ab630` flush), immediately before that shot's narration starts —
/// data's: a row with two cameras is one the client must play as a single continuous /// *not* inside the shot advance `0x48efe0`, where benilla first placed it. That misreading
/// cinematic, and playing it silently — without the packet the reference sends at `0x48efe0` /// mattered on the shipped path rather than only on a private server's: every 1.12
/// — is the kind of drift that only surfaces on a server shipping its own DBCs. /// `CinematicSequences` row carries exactly one camera, so "between cameras" meant benilla
/// sent this packet **never**.
pub fn next_cinematic_camera(&mut self) -> Result<()> { pub fn next_cinematic_camera(&mut self) -> Result<()> {
self.send(opcode::CMSG_NEXT_CINEMATIC_CAMERA, &[]) self.send(opcode::CMSG_NEXT_CINEMATIC_CAMERA, &[])
} }
+24 -2
View File
@@ -70,10 +70,16 @@ pub struct AddonBindingBody {
/// A host request queued by Lua: persist the live table as set `1`/`2` (`Save`), on which the /// A host request queued by Lua: persist the live table as set `1`/`2` (`Save`), on which the
/// app writes `benilla-config/bindings/…` — and, for `Save(1)` issued while the character set was /// app writes `benilla-config/bindings/…` — and, for `Save(1)` issued while the character set was
/// active, deletes the character file (the reference's confirmed delete-on-switch). /// active, deletes the character file (the reference's confirmed delete-on-switch); or fire a
#[derive(Clone, Copy, Debug, PartialEq, Eq)] /// named binding's action outright (`Run`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum KeybindRequest { pub enum KeybindRequest {
Save(u32), Save(u32),
/// `RunBinding("SCREENSHOT")` — run the command's action as if its chord had been pressed.
/// It is a *request* rather than something the VM can do itself because a host command's
/// action is engine-side by construction (see this module's header): the VM holds the command
/// name and its chords, never its body.
Run(String),
} }
/// The reference's key-string gate — the whole of `SetBinding`'s refusal (decision 1295), in its /// The reference's key-string gate — the whole of `SetBinding`'s refusal (decision 1295), in its
@@ -605,6 +611,22 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(if ok { Some(1) } else { None }) Ok(if ok { Some(1) } else { None })
})?, })?,
)?; )?;
// `RunBinding(command)` — the reference's own passthrough verb. Its live caller is
// `CinematicFrame`'s `OnKeyDown`, which hands the SCREENSHOT chord back to its binding while
// swallowing every other key, so a player can still photograph a fly-by. Anything the VM
// holds a body for it could run itself; a host command's action is engine-side, so this
// queues and the app fires it in the same frame.
lua.globals().set(
"RunBinding",
lua.create_function(|lua, command: String| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let name = command.to_ascii_uppercase();
if model.keybinds.by_name.contains_key(&name) {
model.keybinds.requests.push(KeybindRequest::Run(name));
}
Ok(())
})?,
)?;
lua.globals().set( lua.globals().set(
"GetBindingKey", "GetBindingKey",
lua.create_function(|lua, command: String| { lua.create_function(|lua, command: String| {
+90 -7
View File
@@ -125,6 +125,35 @@ fn int(v: &Value) -> i32 {
num(v) as i32 num(v) as i32
} }
/// **Shape A's gate** (decision 1717's taxonomy, for a *string* position): the client's
/// `lua_isstring` accepts tags 3|4 — a string **or a number** — and nothing else. A number is
/// rendered to decimal text and used as the path, which is why this coerces rather than matching
/// `Value::String` alone.
fn string_arg(v: &Value) -> Option<std::borrow::Cow<'_, str>> {
match v {
Value::String(s) => s
.to_str()
.ok()
.map(|t| std::borrow::Cow::Owned(t.to_string())),
Value::Number(n) => Some(std::borrow::Cow::Owned(n.to_string())),
Value::Integer(i) => Some(std::borrow::Cow::Owned(i.to_string())),
_ => None,
}
}
/// The client's own `Usage:` error, with the receiver's name interpolated the way it interpolates
/// it — `GetName()`, or `<unnamed>` when the widget has none (`0x84c7f0`).
fn usage(lua: &Lua, this: &Table, call: &str) -> mlua::Error {
let name = this
.get::<mlua::Function>("GetName")
.and_then(|f| f.call::<Option<String>>(this.clone()))
.ok()
.flatten()
.unwrap_or_else(|| "<unnamed>".to_string());
let _ = lua;
mlua::Error::runtime(format!("Usage: {name}:{call}"))
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> { pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?; let m = lua.create_table()?;
@@ -135,17 +164,27 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// cannot answer a stale path from three frames ago. Only the `SetModel` arm is a `Model` verb — // cannot answer a stale path from three frames ago. Only the `SetModel` arm is a `Model` verb —
// the unit arm is `PlayerModel`'s (`playermodel_install` below), which is why the paper-doll // the unit arm is `PlayerModel`'s (`playermodel_install` below), which is why the paper-doll
// and dress-up panes are `<PlayerModel>`s and every corpus `<Model>` drives the path arm. // and dress-up panes are `<PlayerModel>`s and every corpus `<Model>` drives the path arm.
// **`SetModel(nil)` RAISES. It is not a clear** — `ClearModel` is the clear, and it is a
// separate entry in the same table. This binding said the opposite until 2026-08-30 ("the
// documented clear, and how the corpus writes 'no model'"), which was invented: the byte read
// (`0x76d950`, shape A) gates the argument with `lua_isstring` and raises
// `Usage: %s:SetModel("file")` on nil, absent, boolean, table, function, userdata and thread.
// Measured before changing it — all 5 corpus call sites pass a string literal and our own
// FrameXML never calls it — so nothing was relying on the leniency.
//
// **What is NOT reproduced, deliberately:** the reference's *second* raise,
// `Invalid model file: %s` (`0x878b44`), for a path that does not resolve — its model load is
// synchronous, so it knows. We render no FrameXML models at all, so we cannot evaluate that
// condition, and a raise whose predicate we have to guess is worse than a named omission
// (1134 §4). A path that would be invalid is simply stored.
m.set( m.set(
"SetModel", "SetModel",
lua.create_function(|lua, (this, path): (Table, Value)| { lua.create_function(|lua, (this, path): (Table, Value)| {
let path = match &path { let path = string_arg(&path)
Value::String(s) => Some(s.to_str()?.to_string()), .ok_or_else(|| usage(lua, &this, "SetModel(\"file\")"))?
// `SetModel(nil)` is the documented clear, and reaching it through the same setter .to_string();
// is how the corpus writes "no model" — not everything calls ClearModel.
_ => None,
};
with_model(lua, &this, |m| { with_model(lua, &this, |m| {
m.path = path; m.path = Some(path);
m.unit = None; m.unit = None;
}) })
})?, })?,
@@ -290,6 +329,50 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?, })?,
)?; )?;
// ── The two verbs that touch no pane state ──────────────────────────────────────────────
// `AdvanceTime()` — `0x878948[14]` -> `0x76eca0`. **It does nothing, and that is the verified
// behaviour, not a stub.** The function takes no Lua argument (no `lua_gettop`, no `tonumber`,
// no `tostring` anywhere in `[0x76eca0, 0x76ed65)`), returns no value, and its one reachable
// call is `0x76cfb0`, whose entire body is `mov eax,1; ret`. It writes no `CSimpleModel` field
// and no global. So there is no clock to advance here and no omission to name: a receiver
// check and zero returns IS the reference.
//
// Present because the shipped chain calls it three times — `Cooldown.lua`, and the glue
// character screens' `<OnUpdateModel>` on `<ModelFFX>`, which inherits this very table — and
// an addon that hooks one of those will call it too.
m.set(
"AdvanceTime",
lua.create_function(|lua, this: Table| with_model(lua, &this, |_| ()))?,
)?;
// `ReplaceIconTexture(path)` — `0x878948[15]` -> `0x76ed70`. **A material swap, not a content
// setter**: `0x76cfe0(0xe, path)` -> `0x710ec0` replaces the refcounted handle in
// `[CM2Model+0xa4][i]` for every M2 texture whose `type == 14`, plus the same-typed slots on
// the ribbon (`0x7b7950`) and particle (`0x7b4d20`) emitters. It sets no pane content, clears
// no unit, and stores nothing on the `0x3dc`-byte widget — the override lives on the CM2Model
// and dies with it when `SetModel`/`ClearModel` releases the instance.
//
// **Which is why storing nothing here is faithful rather than lazy.** The reference's two
// "not ready" cases behave OPPOSITELY: with a `CM2Model` present but its data not resident the
// call is queued on `[cm2+0x3c]` and replayed; with **no CM2Model at all**
// (`[widget+0x318] == 0`) it is **dropped and never replayed**. This engine renders no
// FrameXML models, so our panes are permanently the second case — dropping it is what the
// client does in exactly our state.
//
// The argument gate is real and is shape A: `lua_isstring` (tags 3|4) then `lua_tostring`,
// raising `Usage: %s:ReplaceIconTexture("texture")` on anything else. It is kept because it is
// the whole observable behaviour left.
m.set(
"ReplaceIconTexture",
lua.create_function(|lua, (this, path): (Table, Value)| {
if string_arg(&path).is_none() {
return Err(usage(lua, &this, "ReplaceIconTexture(\"texture\")"));
}
with_model(lua, &this, |_| ())
})?,
)?;
lua.set_named_registry_value(REG_MODEL_METHODS, m)?; lua.set_named_registry_value(REG_MODEL_METHODS, m)?;
playermodel_install(lua) playermodel_install(lua)
} }
@@ -155,6 +155,79 @@ fn a_player_model_is_a_model_plus_three_and_the_chain_runs_one_way() {
); );
} }
/// **`SetModel(nil)` raises; `ClearModel()` clears. They are not the same verb.**
///
/// This binding asserted the opposite until 2026-08-30 — "the documented clear, and how the corpus
/// writes 'no model'" — which was invented, not read. `0x76d950` is shape A (decision 1717's
/// taxonomy): `lua_isstring` gates the argument and `Usage: %s:SetModel("file")` is raised on
/// anything that is not a string or a number. `ClearModel 0x76db20` is a separate table entry.
///
/// The same gate on `ReplaceIconTexture 0x76ed70`, whose *whole* observable behaviour it is: the
/// swap itself lands on a `CM2Model` we do not have, and with no CM2Model the reference drops the
/// call and never replays it.
///
/// A NUMBER is accepted by both — `lua_isstring` takes tags 3|4 and the client renders it to
/// decimal text — which is the half a `Value::String`-only match would get wrong.
#[test]
fn the_string_setters_gate_their_argument_and_a_number_is_a_string() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(r#"g = CreateFrame("Model", "GateM", UIParent)"#)
.unwrap();
s.run(r#"GateM:SetModel("Interface\\Buttons\\A.mdx")"#)
.unwrap();
for bad in ["nil", "{}", "true", "print", ""] {
assert!(
s.run(&format!("GateM:SetModel({bad})")).is_err(),
"SetModel({bad}) must raise — it is not the clear"
);
}
// ...and none of those raises disturbed the path that was already set.
assert_eq!(
s.eval::<String>("return GateM:GetModel()").unwrap(),
r"Interface\Buttons\A.mdx",
"a raised setter leaves the pane alone"
);
// A number IS a string to `lua_isstring`, and the client renders it to decimal text.
s.run("GateM:SetModel(42)").unwrap();
assert_eq!(s.eval::<String>("return GateM:GetModel()").unwrap(), "42");
// ClearModel is the clear, takes no argument, and empties the pane.
s.run("GateM:ClearModel()").unwrap();
assert_eq!(
s.eval::<Option<String>>("return GateM:GetModel()").unwrap(),
None
);
// `ReplaceIconTexture` — same gate, and that gate is all of it. A good argument is accepted
// and changes NOTHING observable: the swap targets the CM2Model's type-14 textures, and a
// pane with no CM2Model drops the call (the reference's own `[widget+0x318] == 0` leg).
for bad in ["nil", "{}", "true", ""] {
assert!(
s.run(&format!("GateM:ReplaceIconTexture({bad})")).is_err(),
"ReplaceIconTexture({bad}) must raise"
);
}
s.run(r#"GateM:SetModel("Interface\\Buttons\\A.mdx")"#)
.unwrap();
s.run(r#"GateM:ReplaceIconTexture("Interface\\Icons\\INV_Misc_QuestionMark")"#)
.unwrap();
assert_eq!(
s.eval::<String>("return GateM:GetModel()").unwrap(),
r"Interface\Buttons\A.mdx",
"it is a MATERIAL swap, not a content setter — the pane's model is untouched"
);
// `AdvanceTime` takes nothing, returns nothing, and does nothing — verified, not stubbed.
assert_eq!(
s.eval::<usize>("return table.getn({ GateM:AdvanceTime() })")
.unwrap(),
0,
"AdvanceTime pushes no return value"
);
}
/// **The whole block, both directions** — decision 1718's rule applied to the two model tables. /// **The whole block, both directions** — decision 1718's rule applied to the two model tables.
/// ///
/// The lists below are the reference's OWN enumerations, transcribed entry-for-entry from /// The lists below are the reference's OWN enumerations, transcribed entry-for-entry from
@@ -203,10 +276,11 @@ fn the_two_model_tables_are_the_references_own() {
const PLAYERMODEL_3: [&str; 3] = ["SetUnit", "RefreshUnit", "SetRotation"]; const PLAYERMODEL_3: [&str; 3] = ["SetUnit", "RefreshUnit", "SetRotation"];
/// The subset of [`MODEL_23`] this client does not build. Named, not stubbed (1134 §4) — their /// The subset of [`MODEL_23`] this client does not build. Named, not stubbed (1134 §4) — their
/// bodies are uncarved and they have no corpus caller. Moving a name OUT of here means the /// bodies are uncarved and they have no corpus caller. Moving a name OUT of here means the
/// verb was actually implemented. /// verb was actually implemented — as `AdvanceTime` and `ReplaceIconTexture` were, once
const UNBUILT: [&str; 7] = [ /// wow-re carved them (`ui/scratch/model-advancetime-replaceicontexture.md`). The five left
"AdvanceTime", /// are the fog near/far set, whose `ClearFog` semantics over the colour/near/far triple nobody
"ReplaceIconTexture", /// has read; guessing that clear would read as knowledge.
const UNBUILT: [&str; 5] = [
"SetFogNear", "SetFogNear",
"GetFogNear", "GetFogNear",
"SetFogFar", "SetFogFar",