Files
benilla-octo/crates/benilla-ui/src/script/modelframe.rs
T
SamandClaude Fable 5.1 44803419a7 the model pane takes the file's camera, and the who list sorts
- the `<Model>` widget's perspective leg: a pane whose file supplies the camera it selected renders through a camera of its own whose viewport is its own cell of the same tile atlas — the reference's structure, one target, a viewport per widget, depth cleared per widget — with the composite, packer and reaper untouched; the selection is RAW (`0x76cec0` reads the count off `MD20+0x124` and the record at `[model+0x3c4] + idx·0x84 + 0x80`, `cameraLookup` never consulted), so `M2Model::cameras` is the whole table in file order and `benilla-extract m2cam` prints it, reproducing wow-re's numbers to the digit; the camera is two fields because the reference has two — `+0x320` a PENDING index and `+0x31c` the installed one — and while a request is pending the pane paints nothing (`76d5f0`'s draw gate, now the paint list's own filter); an index past the count installs the NULL camera, which IS the orthographic leg every shipped in-game pane takes; the projection is the booths' byte-derived diagonal-FOV matrix at the pane's aspect, and the eye and target ride the model's root transform as `0x718960`'s publish does — so `SetFacing` cancels along with `SetPosition` and `SetModelScale`, a contradiction with a wow-re note that went out as a §5 round and came back agreeing: the up vector `0x7ac640` builds is model-space +Z at roll 0, the very axis the facing turns about, and the note's sentence is scoped to `<PlayerModel>`'s frozen camera; each perspective camera takes a render layer of its own from a pool of eight, the orthographic camera clears the atlas and every perspective one clears only depth; `SetLight` is a typed `CGLight` that folds each intensity into its colour, normalises a direction on write, quantizes to 8 bits, returns before the copy on `SetLight(0, …)` and reads the second intensity at index 8 on a short ambient block; the five fog verbs are built (`ClearFog` is `76f5c5 and [edi+0x3a4],-2`, bit 0 alone), so the `Model` UNBUILT list is empty for the first time since 1134; a pane that arms a light or fog takes a rig from a lazily-grown pool, and the tiles' material twins — never reaped since the cache was written — are; `WOW_PROBE_MODEL_CAMERA=1` reads the renderer's own camera back for the three cancellations, `pass=5 fail=0` (2027)
- the layout cache writes in the shutdown tail: an unlocked chat window's size and position did not survive `/logout` or `/reload` and `benilla-config/layout/` was never created (B353) — the lane was right (1.12.1 persists a chat window through `StartMoving`/`StartSizing` and the engine's userPlaced bit; no `SetChatWindowSaved*` verb exists in the 5875 FrameXML), but `UiLayoutPlugin` hung its flush on `OnExit(InWorld)`, which a `/reload` never leaves, its debounced writer was never registered in `Update` at all, and on the roots where the edge did fire the VM-reading saver raced `end_ui_session`'s VM replacement on the same unordered edge (measured: the placement moves with nothing but registration positions); the reference's UI shutdown `0x490bd0` has an ordered tail — `PLAYER_LOGOUT` (`0x490c2a`) → `layout-cache.txt` (`0x490c79`) → the flat saved file (`0x490c7e`) → the per-addon files → `AddOns.txt` — on the same five roots as SavedVariables, `/reload` included, so `save_now` joins `shutdown_ui_state` in that slot, one call site reached from every root, and its own edges are deleted; the debounce stays as the crash writer and only that, a deliberate divergence, and the chat-look flush next door is pinned `.before(end_ui_session)` (2029); the VM gets its font engine at the LOAD edge: the measurer was seated only from an `Update` system on the first frame the glyph atlas exists, but `ReloadUI()` mints a fresh VM and runs the in-game load in the same exclusive slot, so every `<OnLoad>` and every addon's file scope measured 0 through the whole reload (`LOAD MEASURE ZERO` live, 33.185 after) — `seat_text_measurer` is one construction site called from both edges; the options tabs fit once from `<OnShow>`, the `OptionsTab_FitWidth` OnUpdate poll and its `fitted` latch gone, because `GetStringWidth` answers in the region's own units and this window's 0.78 scale is applied in the window's own `<OnShow>` after every `<OnLoad>` — an OnLoad fit read 73.19 where the settled value is 76.47; the settled widths are unchanged to the last digit (76.467232 / 91.661896) and the one-frame 72/87 flash at every open goes; four comments that still called the text measure a frame late, and seven gossip-suite comments that put `UIPanelScrollFrameTemplate` in our `ScrollTemplates.xml` when it lives in the reference's `UIPanelTemplates.xml`, are corrected (2028)
- the `/who` sort is a seven-key chain, not "order by the clicked column" (B365): the reference keeps seven `{key, dir}` slots at `0xc2817c`, seeded zone → level → class → group → name → race → guild; `SortWho` (`0x5ad890`) promotes the clicked key to the front and flips its direction ONLY when it was already there — Name, Name reverses, Name, Level, Name restores what the first click left — and the comparator `0x5ada00` walks the chain as tie-breakers, most-recently-used first, through an ASCII fold (`A`–`Z` by `+0x20`, not a Unicode one), the three DBC arms comparing the localized name out of `ChrClasses`/`ChrRaces`/`AreaTable` where ours had sorted by raw id, and an unresolved row TIES (its miss jumps to `0x5adbb2`, the chain loop's own `inc esi`); the click fires `WHO_LIST_UPDATE` synchronously through `SignalEvent` at `0x5ad9ed`, so the binding promotes and sorts the snapshot and the handler repaints before the OnClick returns, where ours redrew a tick later — a redraw one click stale reads exactly like a header that does nothing; the chain is process state (initialised once from `0x401666`), so it survives a logout; the rider from the same carve: `SMSG_WHO` has two exits, and with `SetWhoToUI` clear more than three rows go to the frame with nothing printed and three or fewer to chat (`0x5adfcc jl`, `eax = 3`) where we printed every unclaimed answer; and wow-re's §11 round corrected the first build before it landed — the chat lines come out in WIRE order with the total line LAST, because `0x5ae0a1` composes each line inside the parse loop and the `qsort` at `0x5ae0e2` sits past its back-edge (2030)
- two lighting reports, one frame and one bit each: the surfacing hitch (B354) is a one-frame stale atmosphere — `update_time_lighting` is late by construction, waiting on the wire drain, the weather tick and the submersion verdict, and five `Update` systems that read the `WowLighting` it resolves declared no ordering, so the executor handed each of them last frame's value, deterministically and invisibly while the atmosphere drifts a minute at a time; at a submersion crossing it jumps, on the very frame the sky and cloud domes un-hide, so the first dry frame painted `LightParams` 27's underwater sky over a dry world (the clip's blue frame, (58, 137, 173) against the settled (53, 58, 34)) and `tick_clouds`' surfacing full-rebuild ran at the underwater cloud density, 0.0, rebuilding an empty field that crept back over 0.4 s — the fix for the pop-in handing the symptom straight back; `LightingConsumeSet` names the read side of `LightingResolveSet`, tied in one place, and four consumers join it, the FFX haze and warp are ordered after the verdict they had the same hole against, and two tests enforce it — zero ambiguities between the two sets, and a source scan over `benilla-world` that fails any `WowLighting` reader not in the set; `emit_minimap` is left stale knowingly because joining cost a 178th item through the world API wall; `WOW_FORCE_SUB=<frames>` holds the eye verdict submerged and releases it, so the crossing reproduces server-less (2032); the black furniture below a boat's deck (B373): `MeshTag` bits 6..=13 are the exterior payload's ground-shade byte and bits 6..=18 the interior payload's SH-probe slot, and `entity_shade` guarded the overlap by asking whether a part was on the interior classifier's Bake law — one of the TWO populations holding a probe slot; the other is a lit interior MODD prop, which a WMO-display GameObject parents under the net entity so it sails with the deck, putting it inside the light node's descendant walk — so every frame the boat's shade byte was written over each prop's slot, `(slot & 0x1f00) | byte`: a neighbouring prop's light where that row was live, a zeroed row — solid black — where it was not; the guard now asks the payload question (`probe_payload`) at both write sites, the tag's bits and the marker derived from one binding; measured live at Ratchet, 749 of 749 resident interior prop parts renamed before, 0 of 640 after, and both ledger leads refuted at the bytes — the MODD colours are healthy (`floor112` raises even `#010101` to diffuse 112) and the fold is rigid-motion invariant since every 1.12 transport authors zero MOLR lights; a prop's mouseover identity now names its lane, words and probe slot, and `WOW_PICK` prints `model_flags.z` (2031); the sea-crossing probe can now measure the seam a report names — an explicit `WOW_PROBE_DOCK` goes first, boards only once the body stands at the dock, carries a map id, takes only a candidate within boarding distance, and drops onto the deck rather than into the hull, because a taxi node's `z` is the WATERLINE for the sea ferries while their decks stand ~16 yd above it — and the seam re-anchor returns the cycle it moved to or `None`, so a healthy crossing no longer logs the server's 9.7 h uptime as a skew inside a 350 s cycle

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:37:40 +07:00

871 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.
//!
//! ## The clock is the engine's; the pixels are the host's (decision 2007)
//!
//! A pane's animation state is not "a sequence index the host interprets": the reference widget
//! owns a private `CM2Scene` whose clock its own `OnUpdate` advances, arms sequences through
//! `0x7121a0` with an anchor the sampler re-reads, fires `OnUpdateModel` at the top of every
//! paint and `OnAnimFinished` from the completion callback (wow-re
//! `ui/scratch/modelframe-render-law.md` §4). All of that is Lua-observable — the shipped
//! cooldown is nothing but those two handlers scrubbing `SetSequenceTime` — so it lives here:
//! [`ModelState::clock_ms`] / [`ModelState::armed`], the tick's model pass, and the bindings
//! below. What the engine does NOT have is the file: which ids it owns, how long each sequence
//! runs, whether it loops. Those are the file's **facts** ([`crate::widget::ModelFileFacts`]),
//! handed over by the host once the asset is resident ([`crate::script::UiScript::set_model_facts`])
//! — the reference's own "asset ready" edge, on which the loader's Stand seed runs.
//!
//! ## What is deliberately NOT here
//!
//! Five of `Model`'s own 23 — `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 std::sync::Arc;
use mlua::{Lua, MultiValue, Table, Value};
use super::object::frame_handle_of;
use super::Model;
use crate::widget::{model_key, FrameHandle, KindState, ModelFileFacts, ModelLight, ModelState};
impl Model {
/// FrameXML units per **layout unit** — `768 · √(a²+1)` for the screen's aspect `a`
/// (render law §3; the `G48 = 1/√(a²+1)` root scale against the `768`-tall FrameXML
/// space). `SetPosition`'s space, and the unit a size-less pane's implicit rect is measured
/// in. `4/3` before a screen exists.
pub(crate) fn layout_unit(&self) -> f32 {
let (w, h) = (self.screen.width(), self.screen.height());
let a = if h > 0.0 && w > 0.0 { w / h } else { 4.0 / 3.0 };
768.0 * (a * a + 1.0).sqrt()
}
/// The **implicit rect** (decision 2015): a model pane that authored no size takes its
/// file's bounding-box extent, in layout units, as its size — the reference's geometry
/// overrides (`0x76d080`/`0x76d0d0`) answer the bbox whenever no size is authored, and its
/// layout consumes them like any size. Written into the layout input when the facts are
/// known; a pane with an authored size, or no file, or no facts yet, is left alone.
pub(crate) fn apply_implicit_rect(&mut self, h: FrameHandle) {
let unit = self.layout_unit();
let extent = {
let Some(f) = self.arena.frame(h) else { return };
let KindState::Model(m) = &f.kind_state else {
return;
};
let Some(path) = m.path.as_deref() else {
return;
};
let Some(facts) = self.model_facts.get(&model_key(path)) else {
return;
};
let authored = !m.implicit_size
&& self
.layout_inputs
.get(&h)
.is_some_and(|i| i.width != 0.0 || i.height != 0.0);
if authored {
return;
}
facts.extent()
};
let (w, ht) = (extent.0 * unit, extent.1 * unit);
let input = self.layout_inputs.entry(h).or_default();
let changed =
input.width.to_bits() != w.to_bits() || input.height.to_bits() != ht.to_bits();
input.width = w;
input.height = ht;
if changed {
self.touch_layout_frame(h);
}
if let Some(KindState::Model(m)) = self.arena.frame_mut(h).map(|f| &mut f.kind_state) {
m.implicit_size = true;
}
}
/// A size was AUTHORED on `h` (`SetWidth`/`SetHeight`/`SetSize`, the XML `<Size>` through
/// them): an implicit rect no longer applies to it, for good — the reference's override
/// yields to any authored size.
pub(crate) fn note_authored_size(&mut self, h: FrameHandle) {
if let Some(KindState::Model(m)) = self.arena.frame_mut(h).map(|f| &mut f.kind_state) {
m.implicit_size = false;
}
}
/// The facts the host has handed over for `path`, or `None` — in which case the path is
/// queued for the host to load ([`crate::script::UiScript::model_facts_wanted`]), once.
pub(crate) fn model_facts_for(&mut self, path: &str) -> Option<Arc<ModelFileFacts>> {
let key = model_key(path);
match self.model_facts.get(&key) {
Some(facts) => Some(facts.clone()),
None => {
if !self.model_facts_wanted.contains(&key) {
self.model_facts_wanted.push(key);
}
None
}
}
}
}
/// The facts of the file `this` pane currently holds (`None` for an empty pane or a file the
/// host has not loaded yet).
fn facts_of_pane(lua: &Lua, this: &Table) -> mlua::Result<Option<Arc<ModelFileFacts>>> {
let path = with_model(lua, this, |m| m.path.clone())?;
Ok(path.and_then(|p| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.model_facts_for(&p)
}))
}
/// 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
}
/// `lua_isnumber` — a number, or a string that converts, and **nothing else**. Distinct from
/// [`num`] on purpose: `SetLight` *raises* on a non-number where the coordinate setters coerce,
/// so the two questions cannot share one helper.
fn number(v: &Value) -> Option<f32> {
match v {
Value::Number(n) => Some(*n as f32),
Value::Integer(i) => Some(*i as f32),
Value::String(s) => s.to_str().ok().and_then(|t| t.trim().parse().ok()),
_ => None,
}
}
/// `[0x8029d4]` — the client's "is this float zero" epsilon, shared by `SetLight`'s intensity
/// gate, `0x71b6a0`'s direction normalise and the paint's degenerate-rect test.
const FLOAT_EPS: f32 = 2.384_185_8e-7;
/// `Model:SetLight`'s usage string (`0x878c00`), which the binding raises verbatim.
const SET_LIGHT_USAGE: &str = "Usage: Model:SetLight(enabled[, omni, dirX, dirY, dirZ, \
ambIntensity[, ambR, ambG, ambB], dirIntensity[, dirR, dirG, dirB]]";
/// What [`parse_set_light`] decided.
enum SetLight {
/// A non-number where the binding requires one — `luaL_error(SET_LIGHT_USAGE)`.
Raise,
/// **Trap 1.** `enabled == 0` returns at `76e2cb` *before* the local light is copied into the
/// widget, so the call writes nothing at all: it neither disables a light nor edits one.
NoOp,
/// The light to copy wholesale over the widget's (`76e777 0x76cf30`, `rep movsd 0x1b`).
Set(ModelLight),
}
/// `Model:SetLight` `0x76e1e0`'s argument walk (render law §5.3), over the arguments **after
/// `self`** — so `a[0]` is the reference's Lua index 2.
///
/// The binding builds a **local** `CGLight` from `0x71b4a0` — type 1, every colour ZERO, which is
/// not the widget ctor's white — fills it, and copies the whole thing over the widget's at the
/// end. So an omitted colour block lands the *binding's* black, never the ctor's white.
///
/// **Trap 2** is the walking cursor: the ambient colour triple is read only when its intensity is
/// nonzero **and** all three components are numbers; when either fails, the colour stays white
/// and the cursor does *not* advance past it — so the second intensity is read at index 8 rather
/// than 11, and `SetLight(1, 0, x, y, z, ambI, dirI)` is a legal seven-argument form.
fn parse_set_light(a: &[Value]) -> SetLight {
let Some(enabled) = a.first().and_then(number) else {
return SetLight::Raise;
};
if enabled as i32 == 0 {
return SetLight::NoOp;
}
// Indices 3..=7 — omni, x, y, z, ambIntensity — are all mandatory once the light is enabled.
let mut head = [0.0f32; 5];
for (i, slot) in head.iter_mut().enumerate() {
match a.get(i + 1).and_then(number) {
Some(v) => *slot = v,
None => return SetLight::Raise,
}
}
let omni = head[0] as i32 != 0;
let mut vector = [head[1], head[2], head[3]];
if !omni {
// `0x71b6a0` normalises a DIRECTION on write (`71b6c5`-`71b705`); the position setter
// `0x71b650` stores raw.
let len = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt();
if len > FLOAT_EPS {
vector = vector.map(|v| v / len);
}
}
// The colour blocks: each packed to 8 bits per channel and unpacked again (`0x76f900`), so a
// Set→Get round trip is lossy exactly the way `SetFogColor`'s is.
let quantize = |v: f32| ((v.clamp(0.0, 1.0) * 255.0).round()) / 255.0;
let block = |at: usize| -> Option<[f32; 3]> {
let c: Vec<f32> = (at..at + 3)
.filter_map(|k| a.get(k).and_then(number))
.collect();
(c.len() == 3).then(|| [quantize(c[0]), quantize(c[1]), quantize(c[2])])
};
let ambient_i = head[4];
let (ambient_rgb, next) = match block(6).filter(|_| ambient_i.abs() > FLOAT_EPS) {
Some(rgb) => (rgb, 9), // the cursor advanced: Lua index 11
None => ([1.0; 3], 6), // white, and the cursor stayed: Lua index 8
};
let mut light = ModelLight {
enabled: true,
omni,
vector,
ambient: ambient_rgb.map(|c| c * ambient_i),
diffuse: [0.0; 3],
};
if let Some(diffuse_i) = a.get(next).and_then(number) {
let rgb = block(next + 1)
.filter(|_| diffuse_i.abs() > FLOAT_EPS)
.unwrap_or([1.0; 3]);
light.diffuse = rgb.map(|c| c * diffuse_i);
}
SetLight::Set(light)
}
/// **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();
// The file's facts, if the host has handed them over — the reference's "asset
// resident" test (`71d5a3`), which decides whether the loader's Stand seed runs
// now or when the file lands ([`ModelState::set_file`]).
let facts = lua
.app_data_mut::<Model>()
.expect("model app_data")
.model_facts_for(&path);
with_model(lua, &this, |m| m.set_file(path, facts.as_deref()))?;
// A size-less pane takes the file's rect the moment the file is known (2015).
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model app_data")
.apply_implicit_rect(h);
Ok(())
})?,
)?;
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, ModelState::clear_file))?,
)?;
// ── Animation (decision 2007) ───────────────────────────────────────────────────────────
//
// Both verbs are the same arm, `0x7121a0(model, -1, id, 0, ms, 1.0f, 0, 1)` — `SetSequence`
// with `ms = 0` (`0x76dec0` → `0x76cf50`), `SetSequenceTime` with the caller's `ms`
// (`0x76dfc0` → `0x76cf80`). The arm interrupts whatever plays, resolves the id through the
// file's `animationLookup`, and bakes the anchor `cursor_lo = sceneClock − trunc(ms)` that
// the sampler re-reads every frame (render law §4.2). The id is an `AnimationData` id, not a
// file slot; one the file does not own stops the playing track and arms nothing.
m.set(
"SetSequence",
lua.create_function(|lua, (this, seq): (Table, Value)| {
let seq = int(&seq);
let facts = facts_of_pane(lua, &this)?;
with_model(lua, &this, |m| m.arm(seq, 0, facts.as_deref()))
})?,
)?;
m.set(
"SetSequenceTime",
lua.create_function(|lua, (this, seq, ms): (Table, Value, Value)| {
let (seq, ms) = (int(&seq), int(&ms));
let facts = facts_of_pane(lua, &this)?;
with_model(lua, &this, |m| m.arm(seq, i64::from(ms), facts.as_deref()))
})?,
)?;
// ── 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))?,
)?;
// `SetCamera(n)` = `0x76e0e0` -> `0x76cec0`: select the file's camera by **RAW table index**
// (`cameraLookup` is not on this path). With the file's facts in hand the index is resolved
// now — an index past the table's count installs the NULL camera, which is the orthographic
// leg; without them it is deferred, and the pane draws nothing until it resolves.
m.set(
"SetCamera",
lua.create_function(|lua, (this, c): (Table, Value)| {
let c = int(&c);
let facts = facts_of_pane(lua, &this)?;
with_model(lua, &this, |m| m.install_camera(c, facts.as_deref()))
})?,
)?;
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 ────────────────────────────────────────────────────────────
//
// Typed since decision 2027, off the render law's §5.1-§5.4: the widget's embedded `CGLight`
// and the four fog fields, with `SetLight`'s argument walk and both of its traps.
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 a: Vec<Value> = it.collect();
match parse_set_light(&a) {
SetLight::Raise => Err(mlua::Error::runtime(SET_LIGHT_USAGE)),
SetLight::NoOp => with_model(lua, &this, |_| ()),
SetLight::Set(l) => with_model(lua, &this, |m| m.light = l),
}
})?,
)?;
m.set(
"GetLight",
lua.create_function(|lua, this: Table| {
let l = with_model(lua, &this, |m| m.light)?;
let mut out: Vec<Value> = vec![
Value::Number(f64::from(u8::from(l.enabled))),
Value::Number(f64::from(u8::from(l.omni))),
Value::Number(f64::from(l.vector[0])),
Value::Number(f64::from(l.vector[1])),
Value::Number(f64::from(l.vector[2])),
];
// Each colour block comes back as `1.0, r, g, b` when ANY component is `> 0`, and as
// a lone `0` otherwise (`76e91f`/`76e92f`/`76e93f je 0x76e953` -> `push 1.0`) — which
// is what makes the arity 7 | 10 | 13 rather than a fixed 13.
for c in [l.ambient, l.diffuse] {
if c.iter().any(|&v| v > 0.0) {
out.push(Value::Number(1.0));
out.extend(c.iter().map(|&v| Value::Number(f64::from(v))));
} else {
out.push(Value::Number(0.0));
}
}
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));
// `76f059 or [edi+0x3a4],1` — setting the colour ARMS the fog. That is the only
// arming verb (with the XML `<FogColor>` child); there is no `SetFogEnabled`.
with_model(lua, &this, |m| {
m.fog_color = packed;
m.fog = true;
})
},
)?,
)?;
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)))
})?,
)?;
// `SetFogNear 0x76f1e0` / `SetFogFar 0x76f390` store the number RAW — no clamp, no ordering
// check (`76f282`/`76f432 fstp`); the `≥ 0` clamp exists only on the XML attribute path. The
// getters read the same fields back. Nothing here arms the fog.
m.set(
"SetFogNear",
lua.create_function(|lua, (this, v): (Table, Value)| {
let v = num(&v);
with_model(lua, &this, |m| m.fog_near = v)
})?,
)?;
m.set(
"GetFogNear",
lua.create_function(|lua, this: Table| with_model(lua, &this, |m| m.fog_near))?,
)?;
m.set(
"SetFogFar",
lua.create_function(|lua, (this, v): (Table, Value)| {
let v = num(&v);
with_model(lua, &this, |m| m.fog_far = v)
})?,
)?;
m.set(
"GetFogFar",
lua.create_function(|lua, this: Table| with_model(lua, &this, |m| m.fog_far))?,
)?;
// `ClearFog 0x76f540`: `76f5c5 and [edi+0x3a4],-2` — **bit 0 and nothing else**. The colour,
// the near and the far all survive, so a later `SetFogColor` re-arms the same ramp. A clear
// that also reset them would read as knowledge and be wrong.
m.set(
"ClearFog",
lua.create_function(|lua, this: Table| with_model(lua, &this, |m| m.fog = false))?,
)?;
// ── 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.
//
// 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**. Since decision 2007 a
// pane with a file has an instance the host renders, so the override is STORED on the pane
// (`ModelState::icon`, the queued-and-replayed case — the host applies it whenever the file
// is resident) and a pane with no file drops it, as the client does. The stock
// `MainMenuBarBagButtons.lua` is the caller: `ItemAnim_OnEvent` puts the pushed item's icon
// on `ForcedBackpackItem.mdx`, whose one batch carries no texture of its own.
//
// The argument gate is shape A: `lua_isstring` (tags 3|4) then `lua_tostring`, raising
// `Usage: %s:ReplaceIconTexture("texture")` on anything else.
m.set(
"ReplaceIconTexture",
lua.create_function(|lua, (this, path): (Table, Value)| {
let Some(path) = string_arg(&path) else {
return Err(usage(lua, &this, "ReplaceIconTexture(\"texture\")"));
};
let path = path.to_string();
with_model(lua, &this, |m| {
if m.path.is_some() {
m.icon = Some(path);
}
})
})?,
)?;
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)
}
/// The host hands over what it knows about a model **file** (decision 2007): its sequences
/// and bounds, read off the loaded asset. Every pane holding that file then runs the
/// reference's residency completion — the loader's Stand seed for a pane that was waiting,
/// the ownership check for an arm that was queued ([`ModelState::seed_from_facts`]) — and
/// its clock starts answering ([`ModelState::play_head`]).
pub fn set_model_facts(&mut self, path: &str, facts: ModelFileFacts) {
let key = model_key(path);
let facts = Arc::new(facts);
let mut model = self.model_mut();
model.model_facts.insert(key.clone(), facts.clone());
model.model_facts_wanted.retain(|k| *k != key);
// A one-off walk per file, not per frame: the panes that named this file are the
// waiters the reference links on the streaming drain (`0x71d640`).
let handles: Vec<_> = model
.arena
.iter_frames()
.filter_map(|(h, f)| match &f.kind_state {
KindState::Model(m) if m.path.as_deref().is_some_and(|p| model_key(p) == key) => {
Some(h)
}
_ => None,
})
.collect();
for h in handles {
if let Some(KindState::Model(m)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
m.seed_from_facts(&facts);
}
model.apply_implicit_rect(h);
}
}
/// The screen's aspect moved: every implicit rect is measured in layout units, which scale
/// with `√(a²+1)`, so each one is re-derived (decision 2015). An authored size is untouched.
pub(crate) fn reapply_implicit_rects(&mut self) {
let mut model = self.model_mut();
let panes: Vec<FrameHandle> = model
.arena
.ticked_kinds()
.iter()
.copied()
.filter(|&h| {
model.arena.frame(h).is_some_and(
|f| matches!(&f.kind_state, KindState::Model(m) if m.implicit_size),
)
})
.collect();
for h in panes {
model.apply_implicit_rect(h);
}
}
/// The model files panes have named that no facts have arrived for — drained: the host
/// loads each once and answers through [`Self::set_model_facts`]. Keys are
/// [`crate::widget::model_key`]s (case-folded, `/`-separated, no extension).
pub fn model_facts_wanted(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().model_facts_wanted)
}
/// Does the engine hold facts for `path`'s file — the host's "already answered" test.
pub fn has_model_facts(&self, path: &str) -> bool {
self.model_ref().model_facts.contains_key(&model_key(path))
}
/// **The paint list**: every effectively-visible model pane holding a file whose facts the
/// engine has — with its scene clock and, when something is armed, its play head. The host's
/// renderer reads this once per frame (decision 2008) instead of the extract carrying a
/// cursor that moves every tick (`QuadContent::ModelPane`'s doc says why). Handle order —
/// the arena's registry order, stable across frames.
pub fn visible_model_panes(&self) -> Vec<ModelPaneFrame> {
let model = self.model_ref();
model
.arena
.ticked_kinds()
.iter()
.filter_map(|&h| {
let f = model.arena.frame(h)?;
if !f.effective_visible {
return None;
}
let KindState::Model(m) = &f.kind_state else {
return None;
};
let path = m.path.as_deref()?;
let facts = model.model_facts.get(&model_key(path))?;
// The reference's draw gate (`76d5f0 cmp [this+0x320],-1 ; jne`): a pane whose
// camera question is still open paints NOTHING — not the model, not its
// `OnUpdateModel`. Reachable from Lua by `SetCamera(n)` on a pane whose file has
// not landed yet (decision 2027).
if m.camera_pending.is_some() {
return None;
}
Some(ModelPaneFrame {
handle: h,
clock_ms: m.clock_ms,
play: m.play_head(facts),
})
})
.collect()
}
}
/// One visible model pane as the host's renderer sees it this frame —
/// [`UiScript::visible_model_panes`]'s row.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ModelPaneFrame {
pub handle: crate::widget::FrameHandle,
/// The pane's private scene clock, ms ([`ModelState::clock_ms`]) — the global-sequence
/// clock of everything the pane draws.
pub clock_ms: u64,
/// Where the armed sequence stands, or `None` while nothing is armed (the file draws at its
/// rest pose).
pub play: Option<crate::widget::ModelPlayHead>,
}