- 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>
424 lines
17 KiB
Rust
424 lines
17 KiB
Rust
//! **The model-pane family's Lua surface** — `Model` and `PlayerModel`, read back through the API
|
|
//! that wrote them.
|
|
//!
|
|
//! The property under test throughout is that these widgets are *state the app renders*, not state
|
|
//! the engine interprets: every setter's value must come back out unchanged, and the places where
|
|
//! the widget does have an opinion (one yaw slot written by two verbs on two classes; content being
|
|
//! an either/or) must hold.
|
|
//!
|
|
//! Plus the one structural property, guarded as a whole block:
|
|
//! [`the_two_model_tables_are_the_references_own`] asserts our surfaces against the reference's
|
|
//! enumerated tables in **both** directions, so neither a missing verb nor an invented one can slip
|
|
//! past — and so `Model` can never re-acquire the three that are `PlayerModel`'s.
|
|
|
|
use super::common::script;
|
|
use crate::script::UiScript;
|
|
|
|
/// The whole scene, set and read back — plus the widget's three actual behaviours: `SetRotation`
|
|
/// and `SetFacing` are one slot, `SetModel` and `SetUnit` displace each other, and `ClearModel`
|
|
/// empties both.
|
|
#[test]
|
|
fn the_model_pane_holds_the_scene_it_was_given() {
|
|
let mut s = script();
|
|
s.set_screen_size(800.0, 600.0);
|
|
s.run(r#"m = CreateFrame("Model", "MPane", UIParent)"#)
|
|
.unwrap();
|
|
|
|
// A fresh pane: no content, unit SCALE (not zero — a model at scale 0 is invisible, which is
|
|
// why ModelState hand-writes its Default), no yaw, at the origin.
|
|
assert_eq!(
|
|
s.eval::<(Option<String>, f64, f64)>(
|
|
"return MPane:GetModel(), MPane:GetModelScale(), MPane:GetFacing()"
|
|
)
|
|
.unwrap(),
|
|
(None, 1.0, 0.0),
|
|
"a fresh pane has no model and unit scale"
|
|
);
|
|
|
|
// The path round-trips verbatim — the client's own path space, backslashes and `.mdx` intact.
|
|
// pfUI's autocast shine is exactly this call.
|
|
s.run(r#"MPane:SetModel("Interface\\Buttons\\UI-AutoCastButton.mdx")"#)
|
|
.unwrap();
|
|
assert_eq!(
|
|
s.eval::<String>("return MPane:GetModel()").unwrap(),
|
|
r"Interface\Buttons\UI-AutoCastButton.mdx"
|
|
);
|
|
|
|
// The yaw. `SetFacing` is the `Model` verb for it (`0x878948[4]`); `SetRotation` writes the
|
|
// same field but belongs to `PlayerModel` and is tested there.
|
|
s.run("MPane:SetFacing(-0.25)").unwrap();
|
|
assert_eq!(s.eval::<f64>("return MPane:GetFacing()").unwrap(), -0.25);
|
|
|
|
// Scale, camera, position — plain storage, read back through their own getters.
|
|
s.run("MPane:SetModelScale(0.4) MPane:SetCamera(2) MPane:SetPosition(0.1, -0.2, 3)")
|
|
.unwrap();
|
|
assert_eq!(
|
|
s.eval::<f64>("return MPane:GetModelScale()").unwrap(),
|
|
0.4_f32 as f64
|
|
);
|
|
let (x, y, z): (f64, f64, f64) = s.eval("return MPane:GetPosition()").unwrap();
|
|
assert_eq!(
|
|
(x as f32, y as f32, z as f32),
|
|
(0.1, -0.2, 3.0),
|
|
"GetPosition returns the three numbers SetPosition took"
|
|
);
|
|
|
|
// `SetModel(nil)` is the documented clear and reaches the same place `ClearModel` does — not
|
|
// everything in the corpus calls the dedicated verb.
|
|
s.run("MPane:ClearModel()").unwrap();
|
|
assert_eq!(
|
|
s.eval::<Option<String>>("return MPane:GetModel()").unwrap(),
|
|
None
|
|
);
|
|
}
|
|
|
|
/// **A `<PlayerModel>` is a `<Model>` plus exactly three verbs — and the inheritance runs one way.**
|
|
///
|
|
/// The client registers four model-pane types and each has its own Lua method table that never
|
|
/// repeats its base's; a derived pane reaches its base through the miss leg of `vtable+0x8`
|
|
/// (wow-re `ui/scratch/model-pane-method-tables.md` §3). So `PlayerModel`'s three-entry table
|
|
/// `0x84f1fc` must sit *over* `Model`'s 23-entry `0x878948`, and nothing may chain the other way:
|
|
/// `CSimpleModel`'s lookup `0x76f870` has no leg into `CGCharacterModelBase`'s `0x506260`.
|
|
///
|
|
/// This is the shape pfUI's unit frames need — `CreateFrame("PlayerModel", ...)` driven by
|
|
/// `SetUnit` + `SetCamera`, one verb from each table on the same frame.
|
|
#[test]
|
|
fn a_player_model_is_a_model_plus_three_and_the_chain_runs_one_way() {
|
|
let mut s = script();
|
|
s.set_screen_size(800.0, 600.0);
|
|
s.run(
|
|
r#"
|
|
pm = CreateFrame("PlayerModel", "PMPane", UIParent)
|
|
m = CreateFrame("Model", "MOnly", UIParent)
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
// Its own three resolve on the PlayerModel...
|
|
for verb in ["SetUnit", "RefreshUnit", "SetRotation"] {
|
|
assert_eq!(
|
|
s.eval::<String>(&format!("return type(PMPane.{verb})"))
|
|
.unwrap(),
|
|
"function",
|
|
"PlayerModel must answer its own {verb}"
|
|
);
|
|
// ...and on the plain Model they are ABSENT. This is the assertion that would have caught
|
|
// three years of `SetUnit` published on the wrong class.
|
|
assert_eq!(
|
|
s.eval::<String>(&format!("return type(MOnly.{verb})"))
|
|
.unwrap(),
|
|
"nil",
|
|
"a plain Model must NOT answer {verb} — the chain runs derived -> base only"
|
|
);
|
|
}
|
|
|
|
// ...and the base's verbs resolve through the chain, unrepeated. pfUI's portrait line.
|
|
s.run(r#"PMPane:SetUnit("player") PMPane:SetCamera(0)"#)
|
|
.unwrap();
|
|
assert_eq!(
|
|
s.eval::<Option<String>>("return PMPane:GetModel()")
|
|
.unwrap(),
|
|
None,
|
|
"SetUnit displaces the model path — content is an either/or, not layers"
|
|
);
|
|
s.run(r#"PMPane:SetModel("Interface\\Buttons\\Other.mdx")"#)
|
|
.unwrap();
|
|
assert_eq!(
|
|
s.eval::<String>("return PMPane:GetModel()").unwrap(),
|
|
r"Interface\Buttons\Other.mdx",
|
|
"...and back the other way, the direction an addon reskinning a paper doll takes"
|
|
);
|
|
|
|
// `SetRotation` writes THE SAME yaw field `SetFacing` does — `0x505bb0`'s last instruction is
|
|
// `mov [esi+0x39c], eax`, and `+0x39c` is what `0x76dce0` writes. `GetFacing` reads either.
|
|
s.run("PMPane:SetRotation(1.5)").unwrap();
|
|
assert_eq!(s.eval::<f64>("return PMPane:GetFacing()").unwrap(), 1.5);
|
|
s.run("PMPane:SetFacing(-0.25)").unwrap();
|
|
assert_eq!(
|
|
s.eval::<f64>("return PMPane:GetFacing()").unwrap(),
|
|
-0.25,
|
|
"one slot: SetFacing overwrites what SetRotation wrote"
|
|
);
|
|
|
|
// RefreshUnit is a live no-op: the pane stores the unit TOKEN and resolves it at render, so
|
|
// there is no cached appearance to invalidate. It must still exist — the reference's own
|
|
// DressUp/PaperDoll frames call it, so an addon hooking them will too.
|
|
assert!(s.run("PMPane:RefreshUnit()").is_ok());
|
|
|
|
// ClearModel — a `Model` verb reached through the chain — empties BOTH content slots.
|
|
s.run(r#"PMPane:SetUnit("player") PMPane:ClearModel()"#)
|
|
.unwrap();
|
|
assert_eq!(
|
|
s.eval::<Option<String>>("return PMPane:GetModel()")
|
|
.unwrap(),
|
|
None
|
|
);
|
|
}
|
|
|
|
/// **`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
|
|
/// wow-re's `ui/scratch/model-pane-method-tables.md` §2.1 and §2.2 (each table's count fixed twice:
|
|
/// the registering `mov edx, imm32`, and the dword at `base + 8*count` being the start of the
|
|
/// string pool). They are **not** the names anyone noticed were missing — that is exactly the
|
|
/// mistake 1718 records, and it was re-made inside the test written to enforce it.
|
|
///
|
|
/// Guarding both directions is the point:
|
|
///
|
|
/// - **no gap** — every reference name we claim to publish must resolve;
|
|
/// - **no superset** — a name the reference does not have on a table must not resolve there
|
|
/// (1189: a name we have and the reference lacks routes an addon down a path the real client
|
|
/// never takes);
|
|
/// - **the seven we deliberately do not build must stay absent**, so "unbuilt" cannot quietly
|
|
/// become "stubbed" without this test being edited to say so.
|
|
#[test]
|
|
fn the_two_model_tables_are_the_references_own() {
|
|
/// `Model` — `CSimpleModel`, table `0x878948`, 23 entries, in table order.
|
|
const MODEL_23: [&str; 23] = [
|
|
"SetModel",
|
|
"GetModel",
|
|
"ClearModel",
|
|
"SetPosition",
|
|
"SetFacing",
|
|
"SetModelScale",
|
|
"SetSequence",
|
|
"SetSequenceTime",
|
|
"SetCamera",
|
|
"SetLight",
|
|
"GetLight",
|
|
"GetPosition",
|
|
"GetFacing",
|
|
"GetModelScale",
|
|
"AdvanceTime",
|
|
"ReplaceIconTexture",
|
|
"SetFogColor",
|
|
"GetFogColor",
|
|
"SetFogNear",
|
|
"GetFogNear",
|
|
"SetFogFar",
|
|
"GetFogFar",
|
|
"ClearFog",
|
|
];
|
|
/// `PlayerModel` — `CGCharacterModelBase`, table `0x84f1fc`, 3 entries, in table order.
|
|
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 — 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",
|
|
"GetFogFar",
|
|
"ClearFog",
|
|
];
|
|
|
|
let mut s = script();
|
|
s.set_screen_size(800.0, 600.0);
|
|
s.run(
|
|
r#"
|
|
CreateFrame("Model", "GuardM", UIParent)
|
|
CreateFrame("PlayerModel", "GuardPM", UIParent)
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let is_fn = |s: &UiScript, frame: &str, name: &str| {
|
|
s.eval::<String>(&format!("return type({frame}.{name})"))
|
|
.unwrap()
|
|
== "function"
|
|
};
|
|
|
|
for name in MODEL_23 {
|
|
let want = !UNBUILT.contains(&name);
|
|
// A `Model` verb resolves on BOTH panes — on the PlayerModel through the chain.
|
|
for frame in ["GuardM", "GuardPM"] {
|
|
assert_eq!(
|
|
is_fn(&s, frame, name),
|
|
want,
|
|
"{frame}.{name}: table 0x878948 has it; built = {want}"
|
|
);
|
|
}
|
|
}
|
|
for name in PLAYERMODEL_3 {
|
|
assert!(
|
|
is_fn(&s, "GuardPM", name),
|
|
"GuardPM.{name}: table 0x84f1fc entry, all three are built"
|
|
);
|
|
assert!(
|
|
!is_fn(&s, "GuardM", name),
|
|
"GuardM.{name}: 0x84f1fc is NOT reachable from CSimpleModel's lookup"
|
|
);
|
|
}
|
|
// The two names a `strings` scan of the model band would tempt anyone into, which do not exist
|
|
// in 5875 in ANY form (substring scan of the mapped image returns 0, positive control 27 hits
|
|
// for `Creature`). They are later-expansion verbs; publishing one is decision 1189's error.
|
|
for name in ["SetCreature", "SetCustomRace"] {
|
|
for frame in ["GuardM", "GuardPM"] {
|
|
assert!(
|
|
!is_fn(&s, frame, name),
|
|
"{frame}.{name} does not exist in 1.12.1.5875"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// **`SetSequenceTime` is a scrub INTO the current sequence**, so changing the sequence drops it.
|
|
///
|
|
/// The cooldown indicator drives this pair every frame — `SetSequence(n)` then
|
|
/// `SetSequenceTime(n, ms)` — and carrying a stale scrub across a sequence change would park the
|
|
/// new animation at a time belonging to the previous one.
|
|
#[test]
|
|
fn a_sequence_change_drops_the_scrub() {
|
|
let mut s = script();
|
|
s.set_screen_size(800.0, 600.0);
|
|
s.run(
|
|
r#"
|
|
m = CreateFrame("Model", "MSeq", UIParent)
|
|
m:SetModel("Interface\\Buttons\\UI-AutoCastButton.mdx")
|
|
m:SetSequence(0)
|
|
m:SetSequenceTime(0, 250)
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
// 1.12 has no `GetSequence`, so the scrub is read off the model the way `simplehtml`'s tests
|
|
// read their blocks — through the arena, because there is no Lua getter to read it through.
|
|
let scrub = |s: &UiScript| {
|
|
let lua = s.lua();
|
|
let model = lua.app_data_ref::<crate::script::Model>().expect("model");
|
|
let fh = model.arena.lookup("MSeq").expect("MSeq frame");
|
|
match &model.arena.frame(fh).expect("live frame").kind_state {
|
|
crate::widget::KindState::Model(m) => (m.sequence, m.sequence_time),
|
|
_ => panic!("MSeq is not a Model"),
|
|
}
|
|
};
|
|
assert_eq!(scrub(&s), (0, Some((0, 250))));
|
|
|
|
s.run("MSeq:SetSequence(3)").unwrap();
|
|
assert_eq!(
|
|
scrub(&s),
|
|
(3, None),
|
|
"a new sequence starts unscrubbed — the old (sequence, ms) pair is not carried across"
|
|
);
|
|
|
|
// ...and ClearModel drops the scrub with the content.
|
|
s.run("MSeq:SetSequenceTime(3, 40) MSeq:ClearModel()")
|
|
.unwrap();
|
|
assert_eq!(scrub(&s).1, None);
|
|
}
|
|
|
|
/// `SetLight`'s numbers are stored and returned **verbatim**, however many there are.
|
|
///
|
|
/// The engine core has no lighting model; typing this tuple would assert a scene semantics nobody
|
|
/// has verified, and a wrong typing is worse than an opaque one because it reads as knowledge. So
|
|
/// the contract is exactly "what went in comes out".
|
|
#[test]
|
|
fn the_light_tuple_is_opaque_and_survives_the_round_trip() {
|
|
let mut s = script();
|
|
s.set_screen_size(800.0, 600.0);
|
|
s.run(
|
|
r#"
|
|
m = CreateFrame("Model", "MLight", UIParent)
|
|
m:SetLight(1, 0, 0, -1, -1, 0.7, 1, 1, 1, 0.8, 1, 1, 1)
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
s.eval::<usize>("return table.getn({ MLight:GetLight() })")
|
|
.unwrap(),
|
|
13,
|
|
"every number handed to SetLight comes back"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<(f64, f64)>("local t = { MLight:GetLight() } return t[1], t[6]")
|
|
.unwrap(),
|
|
(1.0, 0.7_f32 as f64)
|
|
);
|
|
|
|
// Fog is the same shape, and unset fog returns NOTHING rather than three zeros — a pane with no
|
|
// fog and a pane fogged to black are different states.
|
|
assert_eq!(
|
|
s.eval::<usize>("return table.getn({ MLight:GetFogColor() })")
|
|
.unwrap(),
|
|
0
|
|
);
|
|
s.run("MLight:SetFogColor(0.1, 0.2, 0.3)").unwrap();
|
|
let (r, g, b): (f64, f64, f64) = s.eval("return MLight:GetFogColor()").unwrap();
|
|
assert_eq!((r as f32, g as f32, b as f32), (0.1, 0.2, 0.3));
|
|
}
|