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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-31 19:55:36 +07:00
co-authored by Claude Fable 5
parent f6f2af3da9
commit 6e3a67e039
28 changed files with 1237 additions and 130 deletions
+24 -2
View File
@@ -70,10 +70,16 @@ pub struct AddonBindingBody {
/// A host request queued by Lua: persist the live table as set `1`/`2` (`Save`), on which the
/// 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| {
+90 -7
View File
@@ -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",