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