415f054d51
Applies upstream c5fc904f..b4d0987a (34 commits): live loot, auction, mail and trade getters; packet-ordered signals; world click records; transport props. docs/MAP.md and DEVIATIONS.md regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
942 lines
40 KiB
Rust
942 lines
40 KiB
Rust
//! The model-pane widgets' Lua methods, `Model` and `PlayerModel`. The engine holds the scene the
|
||
//! Lua API reads and writes ([`ModelState`]); the app draws it.
|
||
//!
|
||
//! Each 1.12 model-pane class has its own method table that never repeats its base's entries; a
|
||
//! derived pane reaches its base's methods on a miss (`__index` meta `0x7020b0`), never the
|
||
//! reverse, so a plain `<Model>` has no `SetUnit`:
|
||
//!
|
||
//! ```text
|
||
//! CSimpleFrame 0x778590
|
||
//! └─ CSimpleModel 0x76f870 table 0x878948 (23) <Model> ← here
|
||
//! └─ CGCharacterModelBase 0x506260 table 0x84f1fc (3) <PlayerModel> ← here
|
||
//! ├─ DressUpModelFrame 0x5050d0 table 0x84f190 (3) <DressUpModel> ← `dressup`
|
||
//! └─ TabardModel 0x503bd0 table 0x84ee40 (10) <TabardModel> ← `tabard`
|
||
//! ```
|
||
//!
|
||
//! The pane's clock is the engine's, since Lua sees it through `OnUpdateModel`, fired at the top of
|
||
//! every paint (`0x76d1bc`), and `OnAnimFinished` (`0x76cdc0`). What only the file knows arrives
|
||
//! from the host once the asset is resident ([`crate::script::UiScript::set_model_facts`]).
|
||
|
||
use std::sync::Arc;
|
||
|
||
use mlua::{Lua, MultiValue, Table, Value};
|
||
|
||
use super::binding_abi::{self, optional_string, usage};
|
||
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 aspect `a` (`[0x832a48]`
|
||
/// holds `1/√(a²+1)`): the unit of `SetPosition` and of the implicit rect. `a` is 4/3 before a
|
||
/// screen exists.
|
||
pub(crate) fn layout_unit(&self) -> f32 {
|
||
let a = self.screen_aspect();
|
||
768.0 * (a * a + 1.0).sqrt()
|
||
}
|
||
|
||
/// The aspect the screen-scale cells are set from: the window's, as
|
||
/// [`crate::script::UiScript::set_window_size`] gave it, else the root's own width over height,
|
||
/// else 4/3.
|
||
pub(crate) fn screen_aspect(&self) -> f32 {
|
||
Self::aspect_of(self.screen, self.aspect)
|
||
}
|
||
|
||
/// [`Self::screen_aspect`] for a root `screen` and a window aspect `aspect`.
|
||
pub(crate) fn aspect_of(screen: crate::layout::Rect, aspect: Option<f32>) -> f32 {
|
||
if let Some(a) = aspect {
|
||
return a;
|
||
}
|
||
let (w, h) = (screen.width(), screen.height());
|
||
if h > 0.0 && w > 0.0 {
|
||
w / h
|
||
} else {
|
||
4.0 / 3.0
|
||
}
|
||
}
|
||
|
||
/// `ApplyRect`'s size threshold, 2⁻²² layout units (`0x76b5e7`, `0x76b602`, `[0x8029d4]`), in
|
||
/// the root's units, for a reader that compares root-unit rects: [`crate::layout::SIZE_EPS`]
|
||
/// times the layout unit. The resolver compares its layout rects against the constant itself.
|
||
pub(crate) fn size_eps(&self) -> f64 {
|
||
crate::layout::SIZE_EPS * f64::from(self.layout_unit())
|
||
}
|
||
|
||
/// The screen-scale cells (`0x41ad10`) for [`Self::screen_aspect`].
|
||
#[inline]
|
||
pub(crate) fn layout_units(&self) -> crate::layout::LayoutUnits {
|
||
self.space.units()
|
||
}
|
||
|
||
/// The layout space of the screen root under [`Self::layout_units`]: the root's extent in
|
||
/// layout units and the map back to its own units.
|
||
#[inline]
|
||
pub(crate) fn layout_space(&self) -> crate::layout::LayoutSpace {
|
||
self.space
|
||
}
|
||
|
||
/// `SetModel` on the pane `h` (`0x76cce0`): the file, seeded now if its facts are held (else
|
||
/// asked for), and the implicit rect it may give a size-less pane. A frame that is no model
|
||
/// pane is left as it is.
|
||
pub(crate) fn set_pane_model(&mut self, h: FrameHandle, path: String) {
|
||
// The reference's asset-resident test (`0x71d5a3`): the loader's Stand seed runs now, or
|
||
// when the file lands.
|
||
let facts = self.model_facts_for(&path);
|
||
let instance = self.model_instances + 1;
|
||
let Some(KindState::Model(m)) = self.arena.frame_mut(h).map(|f| &mut f.kind_state) else {
|
||
return;
|
||
};
|
||
m.set_file(path, facts.as_deref());
|
||
m.instance = instance;
|
||
self.model_instances = instance;
|
||
// A size-less pane takes the file's rect as soon as the file is known.
|
||
self.apply_implicit_rect(h);
|
||
}
|
||
|
||
/// `SetCamera(idx)` on the pane `h` (`0x76cec0`): on a character pane's copy, else against
|
||
/// the facts of the file it holds.
|
||
pub(crate) fn set_pane_camera(&mut self, h: FrameHandle, idx: i32) {
|
||
let path = match self.arena.frame_mut(h).map(|f| &mut f.kind_state) {
|
||
Some(KindState::Model(m)) => {
|
||
if m.character.as_mut().is_some_and(|c| c.set_camera(idx)) {
|
||
return;
|
||
}
|
||
m.path.clone()
|
||
}
|
||
_ => return,
|
||
};
|
||
let facts = path.and_then(|p| self.model_facts_for(&p));
|
||
if let Some(KindState::Model(m)) = self.arena.frame_mut(h).map(|f| &mut f.kind_state) {
|
||
m.install_camera(idx, facts.as_deref());
|
||
}
|
||
}
|
||
|
||
/// The implicit rect: a pane with no authored size takes its file's bounding-box extent, in
|
||
/// layout units, as the reference's geometry overrides answer it (`0x76d080`, `0x76d0d0`).
|
||
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.is_zero() || !i.height.is_zero());
|
||
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.bits() != crate::layout::Len::Script(w).bits()
|
||
|| input.height.bits() != crate::layout::Len::Script(ht).bits();
|
||
input.width = crate::layout::Len::Script(w);
|
||
input.height = crate::layout::Len::Script(ht);
|
||
// The model-ready size write invalidates the frame (`0x76ce70`).
|
||
self.invalidate_frame(h, changed);
|
||
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`, or XML `<Size>` through them): the
|
||
/// implicit rect yields to it for good, as the reference's override does.
|
||
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 host's facts for `path`; on a miss the path is queued, once per key, for the host to
|
||
/// load.
|
||
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.iter().any(|w| w.key == key) {
|
||
self.model_facts_wanted.push(ModelWanted {
|
||
key,
|
||
path: path.to_string(),
|
||
});
|
||
}
|
||
None
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The facts of the file `this` pane holds, if it holds one the host has loaded.
|
||
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: a Lua-side root).
|
||
pub(super) const REG_MODEL_METHODS: &str = "__benilla_model_methods";
|
||
|
||
/// Registry key of the `PlayerModel` table, its own three methods only: `Model`'s 23 are reached
|
||
/// on a miss, as `0x506260` falls through to `0x76f870`.
|
||
pub(super) const REG_PLAYERMODEL_METHODS: &str = "__benilla_playermodel_methods";
|
||
|
||
/// Run `f` on a frame's Model state under one short write borrow. Errors on a receiver that is not
|
||
/// a live Model: a method taken off the table can be called on anything.
|
||
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(crate::script::object::ERR_WRONG_TYPE)),
|
||
}
|
||
}
|
||
|
||
/// A bare `lua_tonumber` (`0x6f3620`), which `SetPosition` (`0x76dc92`) and `SetFogColor`'s
|
||
/// colour read through: a number or a string the VM converts, anything else 0.0.
|
||
fn num(lua: &Lua, v: &Value) -> f32 {
|
||
binding_abi::coerced_number(lua, Some(v.clone())) as f32
|
||
}
|
||
|
||
/// A scalar setter's argument: `lua_isnumber` (`0x6f34d0`) after the receiver check, else the
|
||
/// verb's `Usage: %s:<line>` naming the pane.
|
||
fn model_number(lua: &Lua, this: &Table, v: Value, line: &str) -> mlua::Result<f64> {
|
||
with_model(lua, this, |_| ())?;
|
||
binding_abi::method_number(lua, this, v, line)
|
||
}
|
||
|
||
/// `SetSequence`/`SetSequenceTime` on any model pane: a character pane's copy takes the arm (the
|
||
/// wrappers push blend 0, `0x76cf60`), a file pane its own sequence. A character pane holding a
|
||
/// file arms the file.
|
||
fn arm_pane(m: &mut ModelState, id: i32, ms: i64, facts: Option<&ModelFileFacts>) {
|
||
m.arm_key_bone(id, ms, false, facts);
|
||
}
|
||
|
||
/// `lua_isnumber` then `lua_tonumber`: a number or a string the VM converts. Separate from
|
||
/// [`num`] because `SetLight` raises on a non-number where the coordinate setters read 0.
|
||
fn number(lua: &Lua, v: &Value) -> Option<f32> {
|
||
binding_abi::is_number(lua, v.clone()).map(|n| n as f32)
|
||
}
|
||
|
||
/// `[0x8029d4]`, the client's float-zero epsilon: `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 line, `Usage: %s:SetLight(…)` (`0x878c00`) naming the receiver.
|
||
const SET_LIGHT_USAGE: &str = "SetLight(enabled[, omni, dirX, dirY, dirZ, \
|
||
ambIntensity[, ambR, ambG, ambB], dirIntensity[, dirR, dirG, dirB]])";
|
||
|
||
/// `PlayerModel:SetUnit`'s usage error (`0x84f284`), which names no receiver.
|
||
const SET_UNIT_USAGE: &str = "Usage: SetUnit(\"unit\")";
|
||
|
||
/// `PlayerModel:SetRotation`'s usage error (`0x84f29c`), which names no receiver.
|
||
const SET_ROTATION_USAGE: &str = "Usage: SetRotation(rotation (in radians))";
|
||
|
||
/// What [`parse_set_light`] decided.
|
||
enum SetLight {
|
||
/// A non-number where one is required: `luaL_error` of [`SET_LIGHT_USAGE`].
|
||
Raise,
|
||
/// `enabled == 0` returns (`0x76e2cb`) before the copy, so the call neither disables nor
|
||
/// edits the light.
|
||
NoOp,
|
||
/// The light, copied whole over the widget's (`0x76cf30` at `0x76e777`).
|
||
Set(ModelLight),
|
||
}
|
||
|
||
/// `Model:SetLight`'s argument walk (`0x76e1e0`) over the arguments after `self` (`a[0]` is Lua
|
||
/// index 2), into a local light whose colours start black (`0x71b4a0`), not the widget's white.
|
||
/// A colour triple is read only when its intensity is nonzero and all three are numbers; otherwise
|
||
/// it is white and the cursor does not advance, so `SetLight(1, 0, x, y, z, ambI, dirI)` reads the
|
||
/// second intensity at index 8, not 11.
|
||
fn parse_set_light(lua: &Lua, a: &[Value]) -> SetLight {
|
||
let Some(enabled) = a.first().and_then(|v| number(lua, v)) else {
|
||
return SetLight::Raise;
|
||
};
|
||
if enabled as i32 == 0 {
|
||
return SetLight::NoOp;
|
||
}
|
||
// Lua indices 3..=7 (omni, x, y, z, ambient intensity) are required once enabled.
|
||
let mut head = [0.0f32; 5];
|
||
for (i, slot) in head.iter_mut().enumerate() {
|
||
match a.get(i + 1).and_then(|v| number(lua, v)) {
|
||
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 {
|
||
// A direction is normalised on write (`0x71b6a0`); a position is stored raw (`0x71b650`).
|
||
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);
|
||
}
|
||
}
|
||
// Each colour is packed to 8 bits a channel and back (`0x76f900`), so a round trip is lossy.
|
||
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(|v| number(lua, v)))
|
||
.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(|v| number(lua, v)) {
|
||
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)
|
||
}
|
||
|
||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||
let m = lua.create_table()?;
|
||
|
||
// ── Content: the model, and clearing ────────────────────────────────────────────────────
|
||
//
|
||
// `SetModel` and `PlayerModel:SetUnit` are alternative contents: setting one clears the other.
|
||
// `SetModel` raises `Usage: %s:SetModel("file")` on anything `lua_isstring` refuses, nil
|
||
// included (`0x76d950`); `ClearModel` is the clear.
|
||
//
|
||
// Deviation: a path that does not resolve is stored, where the reference raises
|
||
// `Invalid model file: %s` (`0x878b44`), because the file loads asynchronously here and the
|
||
// call cannot know yet.
|
||
m.set(
|
||
"SetModel",
|
||
lua.create_function(|lua, (this, path): (Table, Value)| {
|
||
// `lua_isstring` then `lua_tostring` (`0x76d9dc`, `0x76d9ec`).
|
||
let path = optional_string(lua, &path)
|
||
.ok_or_else(|| usage(lua, &this, "SetModel(\"file\")"))?;
|
||
// A pane of this kind, or the error.
|
||
with_model(lua, &this, |_| ())?;
|
||
let h = frame_handle_of(lua, &this)?;
|
||
lua.app_data_mut::<Model>()
|
||
.expect("model app_data")
|
||
.set_pane_model(h, path);
|
||
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 ───────────────────────────────────────────────────────────
|
||
//
|
||
// Both verbs are one arm, `0x7121a0(model, -1, id, 0, ms, 1.0f, 0, 1)`: `SetSequence` with
|
||
// `ms = 0` (`0x76dec0` → `0x76cf50`), `SetSequenceTime` with the caller's (`0x76dfc0` →
|
||
// `0x76cf80`). It anchors the play head at `clock − trunc(ms)`, re-read every frame
|
||
// (`0x71273d`). The id is an `AnimationData` id, not a file slot.
|
||
//
|
||
// On a character pane holding a copy the arm lands on the copy, blend 0; the next paint's
|
||
// turn timer replaces anything but Stand with Stand (`0x505c81`), so it shows for one paint.
|
||
// Each argument passes `lua_isnumber` (`0x76df4c`; `0x76e04c`, `0x76e05d`) and is `_ftol`ed,
|
||
// else `Usage: %s:SetSequence(sequence)` (`0x878b94`) or `…SetSequenceTime(sequence, time)`
|
||
// (`0x878bb4`).
|
||
m.set(
|
||
"SetSequence",
|
||
lua.create_function(|lua, (this, seq): (Table, Value)| {
|
||
let seq = binding_abi::ftol(model_number(lua, &this, seq, "SetSequence(sequence)")?);
|
||
let facts = facts_of_pane(lua, &this)?;
|
||
with_model(lua, &this, |m| arm_pane(m, seq, 0, facts.as_deref()))
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"SetSequenceTime",
|
||
lua.create_function(|lua, (this, seq, ms): (Table, Value, Value)| {
|
||
let line = "SetSequenceTime(sequence, time)";
|
||
let seq = model_number(lua, &this, seq, line)?;
|
||
let ms = model_number(lua, &this, ms, line)?;
|
||
let (seq, ms) = (binding_abi::ftol(seq), binding_abi::ftol(ms));
|
||
let facts = facts_of_pane(lua, &this)?;
|
||
with_model(lua, &this, |m| {
|
||
arm_pane(m, seq, i64::from(ms), facts.as_deref())
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
// ── The pane's view: yaw, scale, camera, position ───────────────────────────────────────
|
||
//
|
||
// `SetFacing` (`0x878948[4]` → `0x76dce0`) writes the yaw at `+0x39c`, the field
|
||
// `PlayerModel:SetRotation` also writes. It and `SetModelScale`, `SetCamera`, `SetFogNear`
|
||
// and `SetFogFar` gate on `lua_isnumber` (`0x76dd6c`, `0x76de5c`, `0x76e16c`, `0x76f26c`,
|
||
// `0x76f41c`), else their usage line (`0x878b5c`, `0x878b78` "SetScale" sic, `0x878be0`,
|
||
// `0x878ca4`, `0x878cc0`).
|
||
m.set(
|
||
"SetFacing",
|
||
lua.create_function(|lua, (this, rad): (Table, Value)| {
|
||
let rad = model_number(lua, &this, rad, "SetFacing(facing)")? as f32;
|
||
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 = model_number(lua, &this, s, "SetScale(scale)")? as f32;
|
||
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`) picks a camera by raw table index, not through
|
||
// `cameraLookup`; past the table it installs the NULL (orthographic) camera, and until the
|
||
// facts arrive the pane draws nothing. `PlayerModel` adds no `SetCamera` (`0x84f1fc`), so a
|
||
// character pane's copy takes this one, until its next build installs the pane's own.
|
||
m.set(
|
||
"SetCamera",
|
||
lua.create_function(|lua, (this, c): (Table, Value)| {
|
||
let c = binding_abi::ftol(model_number(lua, &this, c, "SetCamera(index)")?);
|
||
let h = frame_handle_of(lua, &this)?;
|
||
lua.app_data_mut::<Model>()
|
||
.expect("model app_data")
|
||
.set_pane_camera(h, c);
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
m.set(
|
||
"SetPosition",
|
||
lua.create_function(|lua, (this, x, y, z): (Table, Value, Value, Value)| {
|
||
let p = (num(lua, &x), num(lua, &y), num(lua, &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 ────────────────────────────────────────────────────────────
|
||
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();
|
||
with_model(lua, &this, |_| ())?;
|
||
match parse_set_light(lua, &a) {
|
||
SetLight::Raise => Err(usage(lua, &this, 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])),
|
||
];
|
||
// A colour comes back as `1.0, r, g, b` if any component is above 0, else as a lone 0
|
||
// (`0x76e953`), so the arity is 7, 10 or 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>)| {
|
||
// The fifth argument is alpha, clamped like the rest but 1.0 unless it passes
|
||
// `lua_isnumber` (`0x76efa9`-`0x76efb7`), where r, g and b are bare `lua_tonumber`
|
||
// and read 0.0: `SetFogColor(r, g, b)` and `SetFogColor(r, g, b, "x")` are opaque.
|
||
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u32;
|
||
let a = a.as_ref().and_then(|a| number(lua, a)).unwrap_or(1.0);
|
||
let packed = (q(a) << 24)
|
||
| (q(num(lua, &r)) << 16)
|
||
| (q(num(lua, &g)) << 8)
|
||
| q(num(lua, &b));
|
||
// Setting the colour arms the fog (`0x76f059`), the only verb that does.
|
||
with_model(lua, &this, |m| {
|
||
m.fog_color = packed;
|
||
m.fog = true;
|
||
})
|
||
},
|
||
)?,
|
||
)?;
|
||
m.set(
|
||
"GetFogColor",
|
||
lua.create_function(|lua, this: Table| {
|
||
// Always four values; never set reads `1, 1, 1, 1`, the ctor's `0xffffffff`.
|
||
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`) and `SetFogFar` (`0x76f390`) store raw, with no clamp or order
|
||
// check (the `≥ 0` clamp is XML-only), and do not arm the fog.
|
||
m.set(
|
||
"SetFogNear",
|
||
lua.create_function(|lua, (this, v): (Table, Value)| {
|
||
let v = model_number(lua, &this, v, "SetFogNear(value)")? as f32;
|
||
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 = model_number(lua, &this, v, "SetFogFar(value)")? as f32;
|
||
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`) clears the armed bit alone (`0x76f5c5`): colour, near and far stay.
|
||
m.set(
|
||
"ClearFog",
|
||
lua.create_function(|lua, this: Table| with_model(lua, &this, |m| m.fog = false))?,
|
||
)?;
|
||
|
||
// ── AdvanceTime and ReplaceIconTexture ─────────────────────────────────────────────────
|
||
|
||
// `AdvanceTime()` (`0x878948[14]` → `0x76eca0`) does nothing: it reads no argument, returns
|
||
// nothing, writes no field, and its one call, `0x76cfb0`, is `mov eax,1; ret`.
|
||
m.set(
|
||
"AdvanceTime",
|
||
lua.create_function(|lua, this: Table| with_model(lua, &this, |_| ()))?,
|
||
)?;
|
||
|
||
// `ReplaceIconTexture(path)` (`0x878948[15]` → `0x76ed70`): `0x76cfe0(0xe, path)` → `0x710ec0`
|
||
// retextures every type-14 texture of the model instance and of its ribbon (`0x7b7950`) and
|
||
// particle (`0x7b4d20`) emitters, so `SetModel` and `ClearModel` drop it with the instance.
|
||
// With no instance (`[widget+0x318] == 0`) the call is dropped; with one not yet resident it is
|
||
// replayed on load, so the pane stores the override for the host to apply.
|
||
m.set(
|
||
"ReplaceIconTexture",
|
||
lua.create_function(|lua, (this, path): (Table, Value)| {
|
||
// `lua_isstring` then `lua_tostring` (`0x76edfc`, `0x76ee0d`).
|
||
let Some(path) = optional_string(lua, &path) else {
|
||
return Err(usage(lua, &this, "ReplaceIconTexture(\"texture\")"));
|
||
};
|
||
with_model(lua, &this, |m| {
|
||
if m.path.is_some() {
|
||
m.icon = Some(path);
|
||
}
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
lua.set_named_registry_value(REG_MODEL_METHODS, m)
|
||
}
|
||
|
||
/// `PlayerModel`'s own three methods (table `0x84f1fc`); the rest are `Model`'s, reached on a miss.
|
||
/// Only the in-game state registers it (`0x506230`, from `0x490260`), where `Model`'s is in the
|
||
/// widget set both states install (`0x76f840`, from `0x706350`).
|
||
pub(super) fn install_player_model(lua: &Lua) -> mlua::Result<()> {
|
||
let m = lua.create_table()?;
|
||
|
||
// `SetUnit(unit)` (`0x84f1fc[0]` → `0x505d70`): the token, which `lua_isstring` must pass
|
||
// (`0x505dfc`, else the usage at `0x84f284`), resolves to a GUID now (`0x515970`, raising on
|
||
// an unknown name) and the worker `0x505b50` stores it, so the pane keeps showing that unit
|
||
// whatever the token names later.
|
||
m.set(
|
||
"SetUnit",
|
||
lua.create_function(|lua, (this, unit): (Table, Value)| {
|
||
with_model(lua, &this, |_| ())?;
|
||
// `lua_isstring` then `lua_tostring` (`0x505dfc`, `0x505e20`).
|
||
let token =
|
||
optional_string(lua, &unit).ok_or_else(|| mlua::Error::runtime(SET_UNIT_USAGE))?;
|
||
let h = frame_handle_of(lua, &this)?;
|
||
let guid = {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
crate::script::unit::guid_of(lua, &model, &token)?.unwrap_or(0)
|
||
};
|
||
lua.app_data_mut::<Model>()
|
||
.expect("model app_data")
|
||
.set_pane_unit(h, guid);
|
||
// A dress-up pane rebuilds from the unit's live model, dropping try-ons (`0x5059a0`).
|
||
super::dressup::redress_if_dressup(lua, &this)
|
||
})?,
|
||
)?;
|
||
|
||
// `RefreshUnit()` (`0x84f1fc[1]` → `0x505e40`) runs the same worker on the GUID the pane
|
||
// stored (`0x505ee3` loads `+0x3e0`/`+0x3e4`), not on a token.
|
||
m.set(
|
||
"RefreshUnit",
|
||
lua.create_function(|lua, this: Table| {
|
||
let guid = with_model(lua, &this, |m| m.character.as_ref().map_or(0, |c| c.guid))?;
|
||
let h = frame_handle_of(lua, &this)?;
|
||
lua.app_data_mut::<Model>()
|
||
.expect("model app_data")
|
||
.set_pane_unit(h, guid);
|
||
super::dressup::redress_if_dressup(lua, &this)
|
||
})?,
|
||
)?;
|
||
|
||
// `SetRotation(rad)` (`0x84f1fc[2]` → `0x505f00` → `0x505bb0`) turns whatever `+0x318` holds
|
||
// toward the angle ([`crate::widget::ModelState::rotate`]) and writes the yaw `SetFacing` writes
|
||
// (`0x505c44`). The angle passes `lua_isnumber` (`0x505f8c`), else
|
||
// `Usage: SetRotation(rotation (in radians))` (`0x84f29c`, `0x505fb6`), naming no receiver.
|
||
m.set(
|
||
"SetRotation",
|
||
lua.create_function(|lua, (this, rad): (Table, Value)| {
|
||
with_model(lua, &this, |_| ())?;
|
||
let rad = binding_abi::float_arg(lua, rad, SET_ROTATION_USAGE)? as f32;
|
||
let now_ms = lua
|
||
.app_data_ref::<Model>()
|
||
.expect("model app_data")
|
||
.now_ms();
|
||
let facts = facts_of_pane(lua, &this)?;
|
||
with_model(lua, &this, |m| {
|
||
m.rotate(rad, now_ms, facts.as_deref());
|
||
m.facing = rad;
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
lua.set_named_registry_value(REG_PLAYERMODEL_METHODS, m)
|
||
}
|
||
|
||
impl Model {
|
||
/// The millisecond clock the character panes' turn deadline reads (`0x42c010`).
|
||
pub(crate) fn now_ms(&self) -> u64 {
|
||
(self.now * 1000.0).max(0.0) as u64
|
||
}
|
||
|
||
/// `0x505b50` on the pane `h`: store `guid` and clear the display (`0x505b64`-`0x505b70`), and
|
||
/// if the pane is visible ask for its copy (`0x505b8d`, `0x505b99`).
|
||
pub(crate) fn set_pane_unit(&mut self, h: FrameHandle, guid: u64) {
|
||
self.set_pane_subject(h, guid, 0);
|
||
}
|
||
|
||
/// `0x505cb0` on the pane `h`: clear the GUID, store the creature display, and if the pane is
|
||
/// visible ask for its copy (`0x505cc1`-`0x505cd7`).
|
||
pub(crate) fn set_pane_display(&mut self, h: FrameHandle, display: u32) {
|
||
self.set_pane_subject(h, 0, display);
|
||
}
|
||
|
||
fn set_pane_subject(&mut self, h: FrameHandle, guid: u64, display: u32) {
|
||
let Some(f) = self.arena.frame_mut(h) else {
|
||
return;
|
||
};
|
||
let visible = f.effective_visible;
|
||
let KindState::Model(m) = &mut f.kind_state else {
|
||
return;
|
||
};
|
||
let Some(c) = &mut m.character else {
|
||
return;
|
||
};
|
||
c.guid = guid;
|
||
c.display = display;
|
||
if visible {
|
||
c.request_build();
|
||
}
|
||
}
|
||
}
|
||
|
||
impl crate::script::UiScript {
|
||
/// The visible character panes in the arena's registry order: what each asks the host to draw,
|
||
/// the paint list for their copies.
|
||
pub fn character_panes(&self) -> Vec<CharacterPaneFrame> {
|
||
let model = self.model_ref();
|
||
model
|
||
.arena
|
||
.model_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 c = m.character.as_ref()?;
|
||
Some(CharacterPaneFrame {
|
||
handle: h,
|
||
kind: f.kind,
|
||
name: f.name.clone(),
|
||
guid: c.guid,
|
||
display: c.display,
|
||
builds: c.builds,
|
||
pending: c.pending(),
|
||
copy: c.copy,
|
||
file: m.path.is_some(),
|
||
camera: c.view_camera(),
|
||
facing: m.facing,
|
||
light: m.light,
|
||
fog: m.scene_fog(),
|
||
})
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The host built the copy for request `build` of the pane `h`, or rebuilt it for that same
|
||
/// request: it goes to `+0x318` with Stand armed (`0x5059a0`), replacing a file `SetModel`
|
||
/// put there. A stale answer, for a request a later one replaced, is dropped, as is one for a
|
||
/// pane no longer visible: its hide dropped the copy.
|
||
pub fn character_copy_built(&mut self, h: FrameHandle, build: u64) {
|
||
let mut model = self.model_mut();
|
||
let Some(f) = model.arena.frame_mut(h) else {
|
||
return;
|
||
};
|
||
if !f.effective_visible {
|
||
return;
|
||
}
|
||
let KindState::Model(m) = &mut f.kind_state else {
|
||
return;
|
||
};
|
||
let built = m.character.as_mut().is_some_and(|c| c.copy_built(build));
|
||
if built {
|
||
m.path = None;
|
||
m.armed = None;
|
||
m.pending_seed = false;
|
||
m.icon = None;
|
||
}
|
||
}
|
||
|
||
/// The host found no unit for request `build` of the pane `h` (`0x468460` finds none at
|
||
/// `0x505b8d`/`0x505d30`): nothing was built, and nothing builds until the next request.
|
||
pub fn character_copy_missed(&mut self, h: FrameHandle, build: u64) {
|
||
let mut model = self.model_mut();
|
||
if let Some(KindState::Model(m)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
|
||
if let Some(c) = &mut m.character {
|
||
c.copy_missed(build);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The host played arm `serial` of copy `build` on the pane `h` to its natural end: the copy's
|
||
/// completion callback (`0x5062a0`) runs, never `OnAnimFinished`.
|
||
pub fn character_arm_finished(&mut self, h: FrameHandle, build: u64, serial: u64) {
|
||
let mut model = self.model_mut();
|
||
if let Some(KindState::Model(m)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
|
||
if let Some(c) = &mut m.character {
|
||
c.finished(build, serial);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The handle of the frame the global `name` names, for tests and instruments.
|
||
pub fn frame_handle(&self, name: &str) -> Option<FrameHandle> {
|
||
self.model_ref().arena.lookup(name)
|
||
}
|
||
|
||
/// The character state of the pane `h`, for tests and instruments.
|
||
pub fn character_pane(&self, h: FrameHandle) -> Option<crate::widget::CharacterPane> {
|
||
match self.model_ref().arena.frame(h).map(|f| &f.kind_state) {
|
||
Some(KindState::Model(m)) => m.character.clone(),
|
||
_ => None,
|
||
}
|
||
}
|
||
/// The scene state of the model pane named `name`, for the host that draws it, which knows the
|
||
/// pane by its stock name (`CharacterModelFrame`).
|
||
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 pane's yaw in radians, 0.0 before the pane exists; the stock `Model_OnLoad` sets
|
||
/// 0.61 (`UIParent.lua:1422`).
|
||
pub fn model_pane_facing(&self, name: &str) -> f32 {
|
||
self.model_pane(name).map_or(0.0, |m| m.facing)
|
||
}
|
||
|
||
/// The host's facts for a model file, read off the loaded asset: every pane holding it runs the
|
||
/// reference's load completion ([`ModelState::seed_from_facts`]) and takes its implicit rect.
|
||
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(|w| w.key != key);
|
||
// The panes holding this file: the waiters the reference links on its 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 aspect changed: re-derive every implicit rect, whose unit scales with `√(a²+1)`.
|
||
pub(crate) fn reapply_implicit_rects(&mut self) {
|
||
let mut model = self.model_mut();
|
||
let panes: Vec<FrameHandle> = model
|
||
.arena
|
||
.model_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);
|
||
}
|
||
}
|
||
|
||
/// Drain the model files panes named with no facts yet; the host loads each once and answers
|
||
/// through [`Self::set_model_facts`].
|
||
pub fn model_facts_wanted(&mut self) -> Vec<ModelWanted> {
|
||
std::mem::take(&mut self.model_mut().model_facts_wanted)
|
||
}
|
||
|
||
/// Whether the engine holds facts for `path`'s file.
|
||
pub fn has_model_facts(&self, path: &str) -> bool {
|
||
self.model_ref().model_facts.contains_key(&model_key(path))
|
||
}
|
||
|
||
/// The paint list: every visible model pane whose file's facts are known, with its clock and
|
||
/// play head, in the arena's registry order, which is stable across frames.
|
||
pub fn visible_model_panes(&self) -> Vec<ModelPaneFrame> {
|
||
let model = self.model_ref();
|
||
model
|
||
.arena
|
||
.model_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))?;
|
||
// A pane whose camera is still pending draws nothing (`0x76d5f0`); its
|
||
// `OnUpdateModel` still fires, from the paint hook ahead of that gate (`0x76d5ea`).
|
||
if m.camera_pending.is_some() {
|
||
return None;
|
||
}
|
||
Some(ModelPaneFrame {
|
||
handle: h,
|
||
clock_ms: m.clock_ms,
|
||
play: m.play_head(facts),
|
||
})
|
||
})
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
/// One row of [`crate::script::UiScript::character_panes`]: a visible `PlayerModel`,
|
||
/// `DressUpModel` or `TabardModel` and what its copy is built from and does.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct CharacterPaneFrame {
|
||
pub handle: FrameHandle,
|
||
pub kind: crate::widget::FrameKind,
|
||
/// The global name; `None` for an anonymous `CreateFrame("PlayerModel")`.
|
||
pub name: Option<String>,
|
||
/// The stored subject: a unit's GUID, else a creature display (0 for none of either).
|
||
pub guid: u64,
|
||
pub display: u32,
|
||
/// Builds asked so far; a host whose copy answers an older one builds again.
|
||
pub builds: u64,
|
||
/// The latest build is unanswered, and no file or clear has overtaken it.
|
||
pub pending: bool,
|
||
/// The copy the host built, and the arm its key bone plays.
|
||
pub copy: Option<crate::widget::PaneCopy>,
|
||
/// A file `SetModel` put at `+0x318` since the last copy: the pane draws that, not a copy.
|
||
pub file: bool,
|
||
/// The camera the copy draws through (`+0x31c`).
|
||
pub camera: crate::widget::CopyCamera,
|
||
/// The facing, the root's yaw (`+0x39c`).
|
||
pub facing: f32,
|
||
/// The pane's light and the fog its scene draws with.
|
||
pub light: ModelLight,
|
||
pub fog: Option<crate::widget::ModelFog>,
|
||
}
|
||
|
||
/// A model file a pane named whose facts the host has yet to load. `key` ([`model_key`]) is only
|
||
/// the match key, `.mdx`, `.mdl` and `.m2` spellings alike; `path` is the pane's own, as given to
|
||
/// `SetModel` or `file=`, which the host opens through the cache's extension rule (`0x706a50`).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct ModelWanted {
|
||
pub key: String,
|
||
pub path: String,
|
||
}
|
||
|
||
/// One row of [`UiScript::visible_model_panes`].
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub struct ModelPaneFrame {
|
||
pub handle: crate::widget::FrameHandle,
|
||
/// The pane's scene clock in ms, which also drives the file's global sequences.
|
||
pub clock_ms: u64,
|
||
/// The armed sequence's play head; `None` draws the rest pose.
|
||
pub play: Option<crate::widget::ModelPlayHead>,
|
||
}
|