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
+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)
}