- the combat log's last eight wire bodies decode — PARTYKILLLOG, SPELLINSTAKILLLOG, PROCRESIST, SPELLORDAMAGE_IMMUNE, SPELLDISPELLOG, DISPEL_FAILED, ENCHANTMENTLOG, SPELLLOGEXECUTE — each layout read off vmangos's own writer and pinned by a byte-exact golden (DISPEL_FAILED carries no count, the list runs to the end of the body; SPELLLOGEXECUTE's POWER_DRAIN row is (amount, power, multiplier), verified at the client's bytes as well as the server's); with the eleven chat types that were waiting on them, 44 chat types become 55 — 29 sentence families, the death pair and the aura rows riding descriptor diffs because 1.12 has no "unit died" opcode, plus the six trailers ((glancing), (30 blocked), (12 absorbed) and kin) that had been missing from every family; also corrected: the durability-death line was going to the red error frame with hard-coded English, and SMSG_SPELLNONMELEEDAMAGELOG lacked its split-damage branch and its blocked wording
- PlayerModel is its own frame kind: SetUnit, RefreshUnit and SetRotation were published on Model, but they belong to the <PlayerModel> method table (0x84f1fc), and the client's lookup chains derived → base only, so a plain <Model> can never reach them — they got there from a strings scan read as ownership, and a strings hit answers whether a name EXISTS, not who owns it (SetUnit's one pooled string is referenced by the PlayerModel and GameTooltip tables and by no Model entry); the new PlayerModel → Model → Frame → Region chain unblocks CreateFrame("PlayerModel"), which is how pfUI builds every unit-frame portrait — and through pfUI, pfQuest and ShaguDPS
- a nil where a number goes is four shapes, not one law: a census of all 408 widget-registrar entries found 62 numeric positions that validate-or-raise, 43 that gate then stage an alternate, 64 that silently coerce to 0.0 and 5 that gate then store nothing — so SetMaxLetters(nil) completes and stores 0, "no limit" (the argument COUNT is checked exactly, the type is not), and the backdrop colour setters are asymmetric on purpose: a nil r/g/b channel is 0.0 but a nil alpha is OPAQUE, and reading every nil as 0.0 would have turned those borders transparent; both setters now also quantize ×255 into the packed 0xAARRGGBB byte quad the client actually stores, so 0.09 reads back as 23/255
- two more addon-killers: LIGHTYELLOW_FONT_COLOR_CODE was the one missing line of Fonts.xml's seven-line colour block (aux-addon concatenates it at file scope and died on its own first line), and the guard now enumerates the whole block l.4-20 instead of the names an earlier round had noticed absent; and the unit frames publish the reference's names — 1.12 has no *PowerBar anywhere, every bar is ManaBar and the colour table is ManaBarColor (8 of the top-20 addons read it by name), so ManaBarColor is the published table our own bars index into, with 22 more regions renamed to the reference's own
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
352 lines
16 KiB
Rust
352 lines
16 KiB
Rust
//! The model-pane family's method surfaces — `Model` and `PlayerModel`, the 3D panes an addon or a
|
|
//! FrameXML frame parks a model in.
|
|
//!
|
|
//! **The same split as [`super::minimap`] and [`super::cooldown`], and for the same reason.** The
|
|
//! engine core holds exactly the scene the Lua API reads and writes ([`ModelState`]); the pixels
|
|
//! are the app renderer's job. That is the posture the `<Minimap>` and cooldown widgets already
|
|
//! run under — a sized hole the game layer draws into.
|
|
//!
|
|
//! ## Two tables, chained — not one table with everything in it
|
|
//!
|
|
//! 1.12 registers **four** model-pane types, and each has its own Lua method table that **never
|
|
//! repeats its base's entries**; a derived pane reaches its base's verbs through the miss leg of
|
|
//! `vtable+0x8` (wow-re `ui/scratch/model-pane-method-tables.md`, byte-enumerated 2026-08-30):
|
|
//!
|
|
//! ```text
|
|
//! CSimpleFrame 0x778590
|
|
//! └─ CSimpleModel 0x76f870 table 0x878948 (23) <Model> ← built here
|
|
//! └─ CGCharacterModelBase 0x506260 table 0x84f1fc (3) <PlayerModel> ← built here
|
|
//! ├─ DressUpModelFrame 0x5050d0 table 0x84f190 (3) <DressUpModel> ← not built
|
|
//! └─ TabardModel 0x503bd0 table 0x84ee40 (10) <TabardModel> ← not built
|
|
//! ```
|
|
//!
|
|
//! Our `__index` dispatcher already walks a *slice* of registry keys per kind, so the chain is
|
|
//! `&[REG_PLAYERMODEL_METHODS, REG_MODEL_METHODS]` and neither table duplicates the other. The
|
|
//! direction is derived → base **only**: a plain `<Model>` does not acquire `SetUnit`.
|
|
//!
|
|
//! `DressUpModel` (`Undress`/`Dress`/`TryOn`) and `TabardModel` (10 tabard verbs) are deliberately
|
|
//! **not built**: zero callers in the corpus, and our dress-up window already models the intents
|
|
//! host-side (`super::dressup`), so wiring them is a design change to that subsystem rather than a
|
|
//! missing verb. Named, not stubbed (decision 1134 §4).
|
|
//!
|
|
//! ## Why this surface, in this order
|
|
//!
|
|
//! It is the wall four of the twenty most-installed 1.12 addons hit at once. pfUI's action-bar
|
|
//! module builds the pet bar's autocast shine as
|
|
//!
|
|
//! ```lua
|
|
//! f.autocast = CreateFrame("Model", nil, f)
|
|
//! f.autocast:SetModel("Interface\\Buttons\\UI-AutoCastButton.mdx")
|
|
//! f.autocast:SetSequence(0)
|
|
//! ```
|
|
//!
|
|
//! and its unit frames build portraits as `CreateFrame("PlayerModel", …)` driven by `SetUnit` +
|
|
//! `SetCamera`. pfUI is embedded in pfQuest, pfQuest-turtle and ShaguDPS as well, so one missing
|
|
//! type stopped all four dead, each of them *after* the whole rest of the UI had built.
|
|
//!
|
|
//! ## Ownership is read off the registrar, never off a string scan
|
|
//!
|
|
//! This module published `SetUnit`, `RefreshUnit` and `SetRotation` on `Model` until 2026-08-30,
|
|
//! on the strength of an isolated-string scan of `WoW.exe` finding one occurrence of each. **A
|
|
//! `strings` hit cannot settle ownership** — it answers *whether a name exists*, and `SetUnit`'s
|
|
//! single pooled string `0x84f22c` is referenced by **two** method-table entries in two different
|
|
//! tables (`PlayerModel 0x84f1fc[0]` and `GameTooltip 0x854290`). All three are `PlayerModel`'s.
|
|
//!
|
|
//! The question a `strings` hit *does* settle, and the reason it was reached for: wow-re's
|
|
//! registrar-dump tool silently missed six of the 23 widget tables — including this whole family —
|
|
//! so the "not in wow-re's scan" half of the old header was a tooling defect, not a fidelity fact.
|
|
//! The enumeration recipe that replaces both is §5.1 of the note above: census `(call|jmp)
|
|
//! 0x701d80`, read the count from the registering `mov edx`, read the pairs at
|
|
//! `base + 8*i`, and settle "which table owns method M" by counting image-wide dword references to
|
|
//! M's name VA.
|
|
//!
|
|
//! ## What is deliberately NOT here
|
|
//!
|
|
//! Seven of `Model`'s own 23 — `AdvanceTime 0x76eca0`, `ReplaceIconTexture 0x76ed70`,
|
|
//! `SetFogNear 0x76f1e0`, `GetFogNear 0x76f2d0`, `SetFogFar 0x76f390`, `GetFogFar 0x76f480`,
|
|
//! `ClearFog 0x76f540`. No corpus caller, and their bodies are uncarved: `ClearFog`'s exact effect
|
|
//! on the colour/near/far triple is a guess until someone reads it, and a guessed clear reads as
|
|
//! knowledge. Named, not stubbed.
|
|
//!
|
|
//! Also absent, and correctly so: `SetCreature` and `SetCustomRace`. Neither string exists in
|
|
//! 5875 in any form (substring scan of the whole mapped image returns 0, against a positive
|
|
//! control of 27 hits for `Creature`) — they are later-expansion names, and publishing one would
|
|
//! be decision 1189's error.
|
|
//!
|
|
//! And any interpretation of `SetLight`'s numbers — the engine core has no lighting model, so the
|
|
//! tuple is stored verbatim rather than typed into a scene semantics nobody has verified.
|
|
|
|
use mlua::{Lua, MultiValue, Table, Value};
|
|
|
|
use super::object::frame_handle_of;
|
|
use super::Model;
|
|
use crate::widget::{KindState, ModelState};
|
|
|
|
/// Registry key of the `Model` method table (the MAXCSTACK discipline: Lua-side root, named key).
|
|
pub(super) const REG_MODEL_METHODS: &str = "__benilla_model_methods";
|
|
|
|
/// Registry key of the `PlayerModel` method table — its **own three** entries only. The other 23
|
|
/// names a `<PlayerModel>` answers come from [`REG_MODEL_METHODS`] through the dispatcher's chain,
|
|
/// exactly as the client's `0x506260` reaches `0x76f870` on a miss.
|
|
pub(super) const REG_PLAYERMODEL_METHODS: &str = "__benilla_playermodel_methods";
|
|
|
|
/// Run `f` over a frame's Model state under one short write borrow. Errors if `this` is not a live
|
|
/// Model (unreachable through the kind dispatcher, but the method table is a plain Lua value — a
|
|
/// caller can fish it out and misapply it).
|
|
fn with_model<T>(lua: &Lua, this: &Table, f: impl FnOnce(&mut ModelState) -> T) -> mlua::Result<T> {
|
|
let h = frame_handle_of(lua, this)?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
let frame = model
|
|
.arena
|
|
.frame_mut(h)
|
|
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
|
|
match &mut frame.kind_state {
|
|
KindState::Model(m) => Ok(f(m)),
|
|
_ => Err(mlua::Error::runtime("not a Model")),
|
|
}
|
|
}
|
|
|
|
/// A Lua number-ish → f32, `nil` and non-numbers → 0.0.
|
|
///
|
|
/// The widget's setters are all coordinates and angles, and the corpus passes them through
|
|
/// arithmetic that can produce a nil (`C.bars[...].icon_size / 25` when the config key is absent).
|
|
/// The reference marshals through `lua_tonumber`, which is this.
|
|
fn num(v: &Value) -> f32 {
|
|
match v {
|
|
Value::Number(n) => *n as f32,
|
|
Value::Integer(i) => *i as f32,
|
|
Value::String(s) => s.to_str().ok().and_then(|t| t.parse().ok()).unwrap_or(0.0),
|
|
_ => 0.0,
|
|
}
|
|
}
|
|
|
|
/// Same, as an integer (sequence and camera indices).
|
|
fn int(v: &Value) -> i32 {
|
|
num(v) as i32
|
|
}
|
|
|
|
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
|
let m = lua.create_table()?;
|
|
|
|
// ── Content: the model, and clearing ────────────────────────────────────────────────────
|
|
//
|
|
// `SetModel` and `PlayerModel:SetUnit` are the two ways a pane gets content and they are
|
|
// alternatives, not layers: setting one clears the other, so `GetModel` after a `SetUnit`
|
|
// 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.
|
|
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,
|
|
};
|
|
with_model(lua, &this, |m| {
|
|
m.path = path;
|
|
m.unit = None;
|
|
})
|
|
})?,
|
|
)?;
|
|
m.set(
|
|
"GetModel",
|
|
lua.create_function(|lua, this: Table| with_model(lua, &this, |m| m.path.clone()))?,
|
|
)?;
|
|
m.set(
|
|
"ClearModel",
|
|
lua.create_function(|lua, this: Table| {
|
|
with_model(lua, &this, |m| {
|
|
m.path = None;
|
|
m.unit = None;
|
|
m.sequence_time = None;
|
|
})
|
|
})?,
|
|
)?;
|
|
// ── Animation ───────────────────────────────────────────────────────────────────────────
|
|
m.set(
|
|
"SetSequence",
|
|
lua.create_function(|lua, (this, seq): (Table, Value)| {
|
|
let seq = int(&seq);
|
|
with_model(lua, &this, |m| {
|
|
m.sequence = seq;
|
|
// A fresh sequence starts unscrubbed: `SetSequenceTime` is a scrub INTO the
|
|
// current sequence, so carrying the old pair across a change would park the new
|
|
// animation at a time that belongs to the previous one. The cooldown indicator
|
|
// drives exactly this pair every frame and is the reason to get it right.
|
|
m.sequence_time = None;
|
|
})
|
|
})?,
|
|
)?;
|
|
m.set(
|
|
"SetSequenceTime",
|
|
lua.create_function(|lua, (this, seq, ms): (Table, Value, Value)| {
|
|
let pair = (int(&seq), int(&ms));
|
|
with_model(lua, &this, |m| {
|
|
m.sequence = pair.0;
|
|
m.sequence_time = Some(pair);
|
|
})
|
|
})?,
|
|
)?;
|
|
|
|
// ── The pane's view: yaw, scale, camera, position ───────────────────────────────────────
|
|
//
|
|
// `SetFacing` is `Model`'s yaw setter (`0x878948[4]` -> `0x76dce0`, writing `+0x39c`).
|
|
// `SetRotation` writes THE SAME FIELD but is `PlayerModel`'s, not a second name here — see
|
|
// `playermodel_install`. A `<Model>` that wants its pane turned calls this one.
|
|
m.set(
|
|
"SetFacing",
|
|
lua.create_function(|lua, (this, rad): (Table, Value)| {
|
|
let rad = num(&rad);
|
|
with_model(lua, &this, |m| m.facing = rad)
|
|
})?,
|
|
)?;
|
|
m.set(
|
|
"GetFacing",
|
|
lua.create_function(|lua, this: Table| with_model(lua, &this, |m| m.facing))?,
|
|
)?;
|
|
m.set(
|
|
"SetModelScale",
|
|
lua.create_function(|lua, (this, s): (Table, Value)| {
|
|
let s = num(&s);
|
|
with_model(lua, &this, |m| m.scale = s)
|
|
})?,
|
|
)?;
|
|
m.set(
|
|
"GetModelScale",
|
|
lua.create_function(|lua, this: Table| with_model(lua, &this, |m| m.scale))?,
|
|
)?;
|
|
m.set(
|
|
"SetCamera",
|
|
lua.create_function(|lua, (this, c): (Table, Value)| {
|
|
let c = int(&c);
|
|
with_model(lua, &this, |m| m.camera = c)
|
|
})?,
|
|
)?;
|
|
m.set(
|
|
"SetPosition",
|
|
lua.create_function(|lua, (this, x, y, z): (Table, Value, Value, Value)| {
|
|
let p = (num(&x), num(&y), num(&z));
|
|
with_model(lua, &this, |m| m.position = p)
|
|
})?,
|
|
)?;
|
|
m.set(
|
|
"GetPosition",
|
|
lua.create_function(|lua, this: Table| {
|
|
let (x, y, z) = with_model(lua, &this, |m| m.position)?;
|
|
Ok((x, y, z))
|
|
})?,
|
|
)?;
|
|
|
|
// ── The scene: light and fog ────────────────────────────────────────────────────────────
|
|
//
|
|
// `SetLight`'s numbers are stored VERBATIM and handed back verbatim. The engine core has no
|
|
// lighting model, so typing this tuple would be asserting a scene semantics nobody has
|
|
// verified — and a wrong typing is worse than an opaque one, because it reads as knowledge.
|
|
m.set(
|
|
"SetLight",
|
|
lua.create_function(|lua, args: MultiValue| {
|
|
let mut it = args.into_iter();
|
|
let this = match it.next() {
|
|
Some(Value::Table(t)) => t,
|
|
_ => return Err(mlua::Error::runtime("expected a Model")),
|
|
};
|
|
let nums: Vec<f32> = it.map(|v| num(&v)).collect();
|
|
with_model(lua, &this, |m| m.light = Some(nums))
|
|
})?,
|
|
)?;
|
|
m.set(
|
|
"GetLight",
|
|
lua.create_function(|lua, this: Table| {
|
|
let light = with_model(lua, &this, |m| m.light.clone())?;
|
|
let out = light
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|n| Value::Number(f64::from(n)))
|
|
.collect::<Vec<_>>();
|
|
Ok(MultiValue::from_vec(out))
|
|
})?,
|
|
)?;
|
|
m.set(
|
|
"SetFogColor",
|
|
lua.create_function(|lua, (this, r, g, b): (Table, Value, Value, Value)| {
|
|
let c = (num(&r), num(&g), num(&b));
|
|
with_model(lua, &this, |m| m.fog_color = Some(c))
|
|
})?,
|
|
)?;
|
|
m.set(
|
|
"GetFogColor",
|
|
lua.create_function(|lua, this: Table| {
|
|
let c = with_model(lua, &this, |m| m.fog_color)?;
|
|
Ok(match c {
|
|
Some((r, g, b)) => MultiValue::from_vec(vec![
|
|
Value::Number(f64::from(r)),
|
|
Value::Number(f64::from(g)),
|
|
Value::Number(f64::from(b)),
|
|
]),
|
|
None => MultiValue::new(),
|
|
})
|
|
})?,
|
|
)?;
|
|
|
|
lua.set_named_registry_value(REG_MODEL_METHODS, m)?;
|
|
playermodel_install(lua)
|
|
}
|
|
|
|
/// `PlayerModel`'s **own three** verbs (table `0x84f1fc`) — nothing else. Everything a
|
|
/// `<PlayerModel>` else answers is `Model`'s, reached through the dispatcher's chain.
|
|
fn playermodel_install(lua: &Lua) -> mlua::Result<()> {
|
|
let m = lua.create_table()?;
|
|
|
|
// `SetUnit(unit)` — `0x84f1fc[0]` -> `0x505d70`. The pane's other content arm, and `SetModel`'s
|
|
// alternative: each clears the other, so `GetModel` after a `SetUnit` cannot answer a stale
|
|
// path. Ours stores the unit TOKEN and resolves it at render.
|
|
m.set(
|
|
"SetUnit",
|
|
lua.create_function(|lua, (this, unit): (Table, Value)| {
|
|
let unit = match &unit {
|
|
Value::String(s) => Some(s.to_str()?.to_string()),
|
|
_ => None,
|
|
};
|
|
with_model(lua, &this, |m| {
|
|
m.unit = unit;
|
|
m.path = None;
|
|
})
|
|
})?,
|
|
)?;
|
|
|
|
// `RefreshUnit()` — `0x84f1fc[1]` -> `0x505e40`. Re-reads the unit the pane already shows; for
|
|
// us a no-op with a live receiver check, because the pane holds the TOKEN and resolves it at
|
|
// render, so there is no cached appearance here to invalidate. Present because the reference's
|
|
// own `DressUpFrame`/`PaperDollFrame` call it (3 sites) and addons that hook them will too.
|
|
m.set(
|
|
"RefreshUnit",
|
|
lua.create_function(|lua, this: Table| with_model(lua, &this, |_| ()))?,
|
|
)?;
|
|
|
|
// `SetRotation(rad)` — `0x84f1fc[2]` -> the Lua glue `0x505f00` -> the worker `0x505bb0`, whose
|
|
// final instruction is `0x505c44 mov [esi+0x39c], eax`: **the same yaw field `SetFacing`
|
|
// writes**, so the Lua-observable effect of the two verbs is identical and `GetFacing` reads
|
|
// either back. That equality is the whole reason this can be one line.
|
|
//
|
|
// What `0x505bb0` does BESIDES the yaw write is deliberately not modeled, and is worth naming
|
|
// because it is real: it picks a turn animation from the sign of the change (`0xc`
|
|
// ShuffleRight when the current facing is **<** the argument, `0xb` ShuffleLeft when **>**,
|
|
// `0` Stand on equality or NaN — the mapping wow-re CORRECTED on 2026-08-23 after publishing
|
|
// it inverted), plays it unless that id is already armed on bone slot 0, and then
|
|
// UNCONDITIONALLY sets `[+0x3e8] = 1` and `[+0x3ec] = now_ms + 100` — a 100 ms turn hold that
|
|
// the per-paint `0x505c50` expires. Every one of those is invisible to Lua (no getter reads
|
|
// them) and lands on a model renderer we have not built, so storing them here would be state
|
|
// nobody writes and nobody reads. The addresses are the pin for the day the renderer exists.
|
|
m.set(
|
|
"SetRotation",
|
|
lua.create_function(|lua, (this, rad): (Table, Value)| {
|
|
let rad = num(&rad);
|
|
with_model(lua, &this, |m| m.facing = rad)
|
|
})?,
|
|
)?;
|
|
|
|
lua.set_named_registry_value(REG_PLAYERMODEL_METHODS, m)
|
|
}
|