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:
@@ -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