- the minimap cluster and the battleground list are the reference's own `Minimap.xml` and `BattlefieldFrame.xml`, ours deleted, and the sixteen engine names the static probe found unbound are built, not stubbed: the battleground list and queue family (the as-group join refused client-side with no packet when the map's `MaxPlayers` is below the group), the bodyless `CMSG_BATTLEFIELD_STATUS` on world enter — benilla never sent it, so a queue joined before a reconnect was invisible — the 5.5556 yd `BATTLEFIELDS_CLOSED` leash, and the meeting-stone pair over the `0x295` five-way table with both asymmetries; the ping's lifetime is the stock Lua's now (held 5 s, faded 0.5 s), `GetPingPosition` answers two numbers always — our nil was a Lua error on every frame of the stock `Minimap_OnUpdate` — and the map-change drop the reference never had is gone (1974)
- the world map is the reference's own `WorldMapFrame.xml`, our transcription deleted; built for it: the arrow-frame family (an anonymous `Model` child at its 33.6 px footprint, drawn app-side as the minimap's own arrow sprite through one shared constant), the battleground position family over `MSG_BATTLEGROUND_PLAYER_POSITIONS` both ways (the reference's 5000 ms request throttle), and `GetPlayerMapPosition` resolving `raid1..raid40` where it knew only the party (B320); `SetupFullscreenScale` — the engine binding `0x48c270` the map's and the cinematic frame's OnShow call, which our map had stood in for with Lua — is bound to the carved law `min(0.75 · aspect, 1)` off the configured aspect, written through the frame's own `SetScale` (1.0 at 4:3 and wider, 0.9375 at 5:4); a hovered POI writes the map's own area label, not the tooltip; named gap: `WorldMapPing` draws nothing yet (1980, 1981)
- the tutorial frame is the reference's own `TutorialFrame.xml`, over a tutorial system benilla never had: two bit banks filled from `SMSG_TUTORIAL_FLAGS` — the fire-once bank `TriggerTutorial` reads and sets (never a send) and the acknowledged bank `FlagTutorial` writes and sends; there is no tutorial CVar in the binary (210 `CVar::Register` sites censused), so "disable tutorials" is every bit acknowledged; the sites this app can produce are wired (the world-enter Welcome cascade, the level-up arm, item pushes, the loot sparkle, the meeting-stone and battleground arms), the rest named; the loader learned `<TitleRegion>` — the stock frame's drag handle, a region that emits no quad; and the `TabardModel` widget kind arrives ahead of its window, five ints bounded by the `.rdata` table `{170, 17, 6, 17, 51}` and the constant 100 000 tabard cost (1976, 1977)
- the chat window drew as an opaque white plate: the chat-cache loader, gated on `InWorld` alone, restored into the boot VM 130 ms before the deferred entry load filled it, and its per-VM memo then blocked the restore that would have reached the real frames — the plate the stock Lua tints black stayed the XML's white; between the entry edge and the load there is now no VM in the world (the edge parks the boot VM, the load takes it back), so no in-world feed pushes into a VM with no interface; the perf pill reads `59 fps 7.0 ms 17.3 cpu` — the MAIN thread's milliseconds in full text and the process-wide sum dim, because the sum of every thread's CPU time had been read as a frame time (a raid read 17 on it at a solid 60 with the main thread at 7); and `WOW_PROBE_UI_ONE_TEX=1` is a pricing lever, never a look, for the draw-count ceiling a UI atlas would reach before one is built (1978, 1954)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
489 lines
24 KiB
Rust
489 lines
24 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> ← `dressup` (1969)
|
|
//! └─ TabardModel 0x503bd0 table 0x84ee40 (10) <TabardModel> ← `tabard` (1977)
|
|
//! ```
|
|
//!
|
|
//! 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`) is `super::dressup` (1969) and `TabardModel` (the ten
|
|
//! tabard verbs) `super::tabard` (1977) — each built the day its stock window came onto the chain,
|
|
//! as 1134 §4 said they would be.
|
|
//!
|
|
//! ## 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).
|
|
pub(super) 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
|
|
}
|
|
|
|
/// **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()?;
|
|
|
|
// ── 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.
|
|
// **`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 = string_arg(&path)
|
|
.ok_or_else(|| usage(lua, &this, "SetModel(\"file\")"))?
|
|
.to_string();
|
|
with_model(lua, &this, |m| {
|
|
m.path = Some(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, a): (Table, Value, Value, Value, Option<Value>)| {
|
|
// FIVE arguments, and the fifth is the alpha — the same numeric path and the same
|
|
// `[0,1]` clamp as the components, not a flag. It is **guarded with a default of
|
|
// `1.0`** where r/g/b are read unconditionally and yield `0.0` when absent, so
|
|
// `SetFogColor(r, g, b)` sets alpha 1.0. Getting that backwards renders the fog
|
|
// invisible. Decision 1845.
|
|
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u32;
|
|
let a = a.as_ref().map_or(1.0, num);
|
|
let packed = (q(a) << 24) | (q(num(&r)) << 16) | (q(num(&g)) << 8) | q(num(&b));
|
|
with_model(lua, &this, |m| m.fog_color = packed)
|
|
},
|
|
)?,
|
|
)?;
|
|
m.set(
|
|
"GetFogColor",
|
|
lua.create_function(|lua, this: Table| {
|
|
// FOUR values, always — the packed dword unpacked. Never set reads `1, 1, 1, 1`,
|
|
// because the `CSimpleModel` ctor's terminal write is `0xffffffff` (decision 1845).
|
|
let packed = with_model(lua, &this, |m| m.fog_color)?;
|
|
let ch = |shift: u32| f64::from((packed >> shift) & 0xff) / 255.0;
|
|
Ok((ch(16), ch(8), ch(0), ch(24)))
|
|
})?,
|
|
)?;
|
|
|
|
// ── 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)
|
|
}
|
|
|
|
/// `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;
|
|
})?;
|
|
// On the dressing room's pane the same call rebuilds the model from the unit — the
|
|
// reference's `0x5059a0` duplicates the live model, substitutions and all gone — which
|
|
// is app state here (`super::dressup`, 1969).
|
|
super::dressup::redress_if_dressup(lua, &this)
|
|
})?,
|
|
)?;
|
|
|
|
// `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, |_| ())?;
|
|
super::dressup::redress_if_dressup(lua, &this) // the same worker as SetUnit's (§1)
|
|
})?,
|
|
)?;
|
|
|
|
// `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)
|
|
}
|
|
|
|
impl crate::script::UiScript {
|
|
/// A named model pane's scene state, or `None` if no live frame carries that name as a
|
|
/// `Model`/`PlayerModel`.
|
|
///
|
|
/// **The read side of the pane, for a host that draws it.** The app keeps one off-screen body
|
|
/// bake per window (this module's header: the scene is ours, the pixels are the host's), and
|
|
/// every frame it has to ask the pane the reference drives which way it is turned. Before
|
|
/// decision 1751 that came from benilla-named globals a file of ours called
|
|
/// (`BenillaPaperDollModel_SetFacing` and its four siblings, one scalar per window on
|
|
/// [`Model`]); the stock FrameXML instead calls
|
|
/// `Model_OnLoad`/`Model_RotateLeft`/`Model_RotateRight`/`Model_OnUpdate`, which write
|
|
/// `PlayerModel:SetRotation` — i.e. this state, on the pane itself. So the host reads the pane.
|
|
///
|
|
/// By NAME rather than by handle because that is what the host knows: the character sheet's
|
|
/// bake belongs to `CharacterModelFrame`, and since 1751 that name is the reference's own. A
|
|
/// linear scan, because a name index would be a second store to keep true and a loaded
|
|
/// interface holds single-digit model panes.
|
|
pub fn model_pane(&self, name: &str) -> Option<ModelState> {
|
|
self.model_ref()
|
|
.arena
|
|
.iter_frames()
|
|
.find_map(|(_, f)| match (&f.name, &f.kind_state) {
|
|
(Some(n), KindState::Model(m)) if n == name => Some(m.clone()),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
/// A named model pane's yaw in radians — [`Self::model_pane`]'s one hot field, and `0.0` for a
|
|
/// pane that does not exist yet (its window's file not loaded, or its `OnLoad` not run).
|
|
///
|
|
/// `0.0` rather than an `Option` because every caller is a per-frame booth mirror whose only
|
|
/// other answer would be "keep the last one", and a pane that has not loaded has no last one.
|
|
/// The reference's own default, 0.61, is authored in `Model_OnLoad`, so a real pane only reads
|
|
/// 0.0 before it loads.
|
|
pub fn model_pane_facing(&self, name: &str) -> f32 {
|
|
self.model_pane(name).map_or(0.0, |m| m.facing)
|
|
}
|
|
}
|