grass fades on a plane, model panes render, and the FPS journal ships
- the grass fade is a plane across the view, not a ring around the player: `0x6b2b80` texgens the camera-space position through `T(0,0,−52.5)·Ry(π/2)·S(1/17.5)`, so the fade coordinate is view-space depth, `u = (z_eye − 52.5)/17.5` — we measured `distance(eye, fragment)`, a sphere, which meets the ground as a circle centred on the player that slides as they walk and bites a piece out of every tuft it crosses, and that was the hard border; the ramp is `0x6b2320`'s quantised 64×8 table, `alpha = 4·(63 − col)`, read bilinearly as `(254 − 256·u)/255` and capped at `252/255` — plateau to 52.64 yd, zero at 69.86 yd, near grass 98.8 % opaque and never 100 — where we had a clean 1 → 0 line; the product `tex0.a × ramp` is what the 128/255 `detailDoodadAlpha` cutout tests (both stages MODULATE, `[0xc7b2fc]`, confirmed verbatim in all four apitrace captures), so the tuft eroding out at 61.11 yd is the reference's own behaviour and the "fix" that would have removed it never landed; the pass owns its render state — blend mode 2, ALPHAREF 128, cull off, depth-write on, the per-slot draw `0x6b2d60` binding nothing but the texture — where we passed the batch's authored blend and cull through, and the `clutter_state` probe counted the cost: of 322 detail batches, 2 author Opaque (no cutout, their margin drawn as a solid card) and 5 single-sided; the build gate measures the chunk's box against the frustum corner ray derived from the live projection (≈1.31× at 16:9, ≈1.45× at 21:9), not its centre plus a 24 yd margin that popped whole chunks in on relief (2004) - a `<Model>` widget's animation state is Lua-observable and lives in the engine: a private scene clock its own `OnUpdate` advances by `trunc(elapsed·1000)` ms on visible frames only (`0x76d7f0`), an arm with an anchor the sampler re-reads every frame (`0x7121a0`), `OnUpdateModel` at the top of every paint (`0x76d1a0`) and `OnAnimFinished` from the completion callback (`0x76cdc0`); the file's sequences and bounds are facts the host hands over, `SetModel` seeds Stand now or when they land (`0x70ebd0`) with a queued arm replaying after the seed, and the XML `scale=` on a model pane is the model's scale (`CSimpleModel::LoadXML` `0x76cac0`), which the loader had never read (2007); and the pane renders its M2 — every visible pane with a file gets a cell of one render-target atlas at its device size through one orthographic camera, the root at `1280·modelScale·layoutScale` FrameXML units per model unit, the tile's `AnimationPlayer` paused and seeked to the engine's play head, the light black, particles in their own unit (`768·√(a²+1)` per model unit, neither scale), composited as one premultiplied quad at the callback rank; two corrections off the bytes: `OnUpdateModel` fires only with a model installed (`[widget+0x318] ≠ 0`) and `OnAnimFinished` fires once per arm for a looping sequence too (`0x719370`); the loader now applies a `<Model>`'s `file=` at all, which every XML-declared pane had lacked; retired: the minimap ping's byte-measured sprite, since `MiniMapPing` and `WorldMapPing` draw their own file on the pane's clock (2013) - the stance feed fires `UPDATE_SHAPESHIFT_FORMS` only for a list change: the reference fires it at `0x4b28ff`/`0x4b2e43` from learn, unlearn and rank-replace alone, while ours fired on any change of the pushed view, and every fire runs the stock `ShapeshiftBar_Update`, which shows `ShapeshiftBarMiddle` unconditionally past two forms and `Show()`s an already-shown frame — no `OnShow`, no manage pass — so a stance switch or the category-47 cooldown arming and expiring left the middle strip drawn alone over a raised bottom-left bar; castability now rides `SPELL_UPDATE_USABLE` (`0x4b31c0`), active/texture/cooldown are pushed silently and repainted on `PLAYER_AURAS_CHANGED` and `SPELL_UPDATE_COOLDOWN`, the feed ordered before both handler sets so they read the fresh list (2009); and the loot-roll frame waits for its item template (B371): `GroupLootFrame_OnShow` paints once and nothing repaints it, and we fired `START_LOOT_ROLL` on the frame the packet arrived, against a snapshot whose name, texture and quality were still `None` — the reference's `0x61b310` ends in the item-template cache lookup `0x55ba30`, a hit firing the event there and a miss firing it from the arrival callback `0x61b460`, so the feed holds a fresh roll's event until the template answers, with two releases that are ours: a negative answer (a frame that never opens cannot even be passed on) and a 2 s deadline paid out of the roll's minute; `arg2` stays the wire's countdown, as `0x61b430` pushes it (2010) - the FPS journal ships to players: it had registered inside the dev-only `DevToolsPlugin` and been armed by an environment variable, so nothing we shipped could have written a row on the one GPU-bound frame in the corpus, a Steam Deck's at 94 % busy; `/console fpsJournal 1` — a CVar of our own, persisted like every CVar — opens `benilla-config/Diagnostics/fps-journal.csv` and appends one row a second, `0` closes it mid-run, and `WOW_FPS_JOURNAL=<path>` stays as the harness lever; eight GPU columns appended, `gpu_ms` then `gpu_opaque, gpu_static, gpu_transp, gpu_glow, gpu_post, gpu_ui, gpu_other`, read off bevy's `RenderDiagnosticsPlugin`, which times passes where the device offers `TIMESTAMP_QUERY_INSIDE_PASSES` (Vulkan and DX12) and leaves the cells empty on an Apple GPU, empty being the honest value; `static_gx`, the four `ffx_glow_*` passes and `ui_gamma_decode` open diagnostic spans so a city's own draw is named in the split; a fresh file opens with the adapter, backend, driver and `gpu_spans yes|no` line; and a probe's `/console` line, which skips the stock `ChatEdit_ParseText` and had landed in a parser arm that knew only `reloadUI`, now forwards to `ConsoleExec` and writes a CVar the way the typed line does (2008) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -132,10 +132,14 @@ impl UiScript {
|
||||
// because the client's `CGCharacterModelBase` extends `CSimpleModel`, and
|
||||
// both draw the same way here.
|
||||
Some(crate::widget::KindState::Model(m)) => QuadContent::ModelPane {
|
||||
handle: fh,
|
||||
name: frame.and_then(|f| f.name.clone()),
|
||||
model: m.path.clone(),
|
||||
facing: m.facing,
|
||||
model_scale: m.scale,
|
||||
position: m.position,
|
||||
own_alpha: frame.map_or(1.0, |f| f.alpha),
|
||||
icon: m.icon.clone(),
|
||||
},
|
||||
_ => QuadContent::Frame,
|
||||
};
|
||||
|
||||
@@ -202,6 +202,7 @@ pub use guild::{
|
||||
GuildMemberInfo, GuildRankEdit, GuildRankInfo, GuildRequest, GuildState, LastOnline, UnitGuild,
|
||||
MAX_RANKS, MIN_RANKS, RANK_RIGHT_BITS,
|
||||
};
|
||||
pub use modelframe::ModelPaneFrame;
|
||||
pub use petition::{
|
||||
validate_guild_name, PetitionRecordView, PetitionRequest, PetitionState, PETITION_TYPE_CHARTER,
|
||||
PETITION_TYPE_PETITION,
|
||||
@@ -447,10 +448,15 @@ pub const SCREEN: crate::layout::Handle = 0;
|
||||
/// corpus call sites across 91 addons); it removes working behaviour from the 23 sites that remain,
|
||||
/// so it is still a change to make deliberately rather than as a side effect of widening this list
|
||||
/// — but the FrameXML half of "with FrameXML fixed first" is most of the way there now.
|
||||
const SCRIPT_KINDS: [&str; 35] = [
|
||||
const SCRIPT_KINDS: [&str; 37] = [
|
||||
"OnLoad",
|
||||
"OnEvent",
|
||||
"OnUpdate",
|
||||
// The model pane's two: fired by the tick's model pass — `OnUpdateModel` at the top of every
|
||||
// paint of a visible pane, `OnAnimFinished` when a clamped sequence completes (decision 2007;
|
||||
// `object::events_regions::set_script`'s doc has the sites).
|
||||
"OnUpdateModel",
|
||||
"OnAnimFinished",
|
||||
"OnShow",
|
||||
"OnHide",
|
||||
"OnClick",
|
||||
|
||||
@@ -324,6 +324,19 @@ pub(crate) struct Model {
|
||||
/// resolve at a 3,988-frame roster, which made it the biggest phase left in the preamble
|
||||
/// (decision 1634's `[layout-pre] watched=`).
|
||||
pub(crate) on_size_changed_frames: Vec<FrameHandle>,
|
||||
/// The frames carrying an `OnUpdateModel` script — the list the tick fires at the top of
|
||||
/// every paint of a visible model pane (`0x76d1a0`'s first act; decision 2007). Same one
|
||||
/// writer as the two lists above.
|
||||
pub(crate) on_update_model_frames: Vec<FrameHandle>,
|
||||
/// What the engine knows about each model **file** a pane has named — the sequences and
|
||||
/// bounds a pane's clock needs ([`crate::widget::ModelFileFacts`]), handed over by the host
|
||||
/// once the asset is resident, keyed by [`crate::widget::model_key`]. The reference reads
|
||||
/// these off the loaded `MD20`; this engine parses no M2, so the facts arrive from the app's
|
||||
/// loader through `UiScript::set_model_facts`.
|
||||
pub(crate) model_facts: HashMap<String, std::sync::Arc<crate::widget::ModelFileFacts>>,
|
||||
/// Model files a pane named that no facts have arrived for yet — drained by the host
|
||||
/// (`UiScript::model_facts_wanted`) to load them. Deduplicated on push.
|
||||
pub(crate) model_facts_wanted: Vec<String>,
|
||||
/// Edit boxes whose text changed and whose `OnTextChanged` has **not fired yet** — the
|
||||
/// reference's `textChanged` dirty bit (`[E+0x31c]` bit 0), which `SetText`/`Insert` raise and
|
||||
/// only the dirty-word drain `0x77d3e0` clears (decision 1831).
|
||||
@@ -1749,6 +1762,9 @@ impl Model {
|
||||
scripts: HashMap::new(),
|
||||
on_update_frames: Vec::new(),
|
||||
on_size_changed_frames: Vec::new(),
|
||||
on_update_model_frames: Vec::new(),
|
||||
model_facts: HashMap::new(),
|
||||
model_facts_wanted: Vec::new(),
|
||||
dirty_editboxes: Vec::new(),
|
||||
event_to_frames: HashMap::new(),
|
||||
frame_events: HashMap::new(),
|
||||
|
||||
@@ -59,13 +59,26 @@
|
||||
//! `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
|
||||
//!
|
||||
//! 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.
|
||||
//! 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
|
||||
@@ -75,11 +88,41 @@
|
||||
//! 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::{KindState, ModelState};
|
||||
use crate::widget::{model_key, KindState, ModelFileFacts, ModelState};
|
||||
|
||||
impl Model {
|
||||
/// 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";
|
||||
@@ -186,10 +229,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
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;
|
||||
})
|
||||
// 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()))
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
@@ -198,37 +245,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
m.set(
|
||||
"ClearModel",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
with_model(lua, &this, |m| {
|
||||
m.path = None;
|
||||
m.unit = None;
|
||||
m.sequence_time = None;
|
||||
})
|
||||
})?,
|
||||
lua.create_function(|lua, this: Table| with_model(lua, &this, ModelState::clear_file))?,
|
||||
)?;
|
||||
// ── Animation ───────────────────────────────────────────────────────────────────────────
|
||||
// ── 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);
|
||||
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;
|
||||
})
|
||||
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 pair = (int(&seq), int(&ms));
|
||||
with_model(lua, &this, |m| {
|
||||
m.sequence = pair.0;
|
||||
m.sequence_time = Some(pair);
|
||||
})
|
||||
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()))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -361,23 +401,29 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// 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 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 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.
|
||||
// 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)| {
|
||||
if string_arg(&path).is_none() {
|
||||
let Some(path) = string_arg(&path) else {
|
||||
return Err(usage(lua, &this, "ReplaceIconTexture(\"texture\")"));
|
||||
}
|
||||
with_model(lua, &this, |_| ())
|
||||
};
|
||||
let path = path.to_string();
|
||||
with_model(lua, &this, |m| {
|
||||
if m.path.is_some() {
|
||||
m.icon = Some(path);
|
||||
}
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -485,4 +531,89 @@ impl crate::script::UiScript {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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))?;
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -339,11 +339,17 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
/// frame/attribute system; there is no such slot in any 1.12 resolver. That addon is asking for a
|
||||
/// later client and should hear so.
|
||||
/// * **`OnHorizontalScroll` · `OnHyperlinkEnter` · `OnHyperlinkLeave` · `OnMessageScrollChanged` ·
|
||||
/// `OnUpdateModel` · `OnAnimFinished` · `OnMovieFinished`/`ShowSubtitle`/`HideSubtitle` ·
|
||||
/// `OnInputLanguageChanged`** — **raising.** Real 1.12 slots that we do not
|
||||
/// fire, and measured at **zero** call sites across the 218-addon corpus, so there is nothing to
|
||||
/// weigh against the trap: they land when their mechanism does (horizontal scroll isn't modeled at
|
||||
/// all — see [`crate::script::scrollframe`]'s module doc).
|
||||
/// `OnMovieFinished`/`ShowSubtitle`/`HideSubtitle` · `OnInputLanguageChanged`** — **raising.**
|
||||
/// Real 1.12 slots that we do not fire, and measured at **zero** call sites across the
|
||||
/// 218-addon corpus, so there is nothing to weigh against the trap: they land when their
|
||||
/// mechanism does (horizontal scroll isn't modeled at all — see
|
||||
/// [`crate::script::scrollframe`]'s module doc).
|
||||
/// * **`OnUpdateModel` · `OnAnimFinished`** — **accepted since decision 2007**, because the tick
|
||||
/// fires them: the model pane's scene clock runs in this engine now (`tick.rs`), `OnUpdateModel`
|
||||
/// at the top of every paint of a visible pane with a file (`0x76d1a0`) and `OnAnimFinished`
|
||||
/// from a sequence's natural completion (`0x76cdc0`, mode 0 — once per arm, a loop's first
|
||||
/// pass included). The shipped
|
||||
/// `Cooldown.xml` and `MainMenuBarBagButtons.xml` declare both.
|
||||
fn set_script(lua: &Lua, this: &Table, name: &str, func: Option<Function>) -> mlua::Result<()> {
|
||||
let kind = SCRIPT_KINDS
|
||||
.iter()
|
||||
@@ -373,6 +379,7 @@ fn set_script(lua: &Lua, this: &Table, name: &str, func: Option<Function>) -> ml
|
||||
match kind {
|
||||
"OnUpdate" => model.on_update_frames.push(h),
|
||||
"OnSizeChanged" => model.on_size_changed_frames.push(h),
|
||||
"OnUpdateModel" => model.on_update_model_frames.push(h),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -387,6 +394,7 @@ fn set_script(lua: &Lua, this: &Table, name: &str, func: Option<Function>) -> ml
|
||||
"OnSizeChanged" => {
|
||||
model.on_size_changed_frames.retain(|&x| x != h);
|
||||
}
|
||||
"OnUpdateModel" => model.on_update_model_frames.retain(|&x| x != h),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,12 +621,12 @@ fn the_unfired_script_kinds_still_raise_rather_than_silently_accepting() {
|
||||
"OnCursorChanged",
|
||||
// 2.0's secure-frame system — no such slot exists in any 1.12 resolver.
|
||||
"OnAttributeChanged",
|
||||
// Real 1.12 slots we do not fire, and zero corpus call sites.
|
||||
// Real 1.12 slots we do not fire, and zero corpus call sites. (`OnUpdateModel` and
|
||||
// `OnAnimFinished` left this list with decision 2007: the tick's model pass fires
|
||||
// them — `script::tests::model_clock`.)
|
||||
"OnHorizontalScroll",
|
||||
"OnHyperlinkEnter",
|
||||
"OnMessageScrollChanged",
|
||||
"OnUpdateModel",
|
||||
"OnAnimFinished",
|
||||
"OnInputLanguageChanged",
|
||||
] {
|
||||
let err = s
|
||||
|
||||
@@ -23,6 +23,7 @@ mod keyboard;
|
||||
mod layout_gate;
|
||||
mod measure;
|
||||
mod minimap;
|
||||
mod model_clock;
|
||||
mod modelframe;
|
||||
mod movable;
|
||||
mod object_model;
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
//! **The model pane's clock, arm, and two handlers** (decision 2007; wow-re
|
||||
//! `ui/scratch/modelframe-render-law.md` §4).
|
||||
//!
|
||||
//! A `<Model>` widget owns a private scene whose clock its own `OnUpdate` advances while the
|
||||
//! frame is visible; `SetSequence`/`SetSequenceTime` arm a sequence with an anchor the sampler
|
||||
//! re-reads; `OnUpdateModel` fires at the top of every paint and `OnAnimFinished` from the
|
||||
//! completion of a clamped sequence. Every one of those is Lua-observable — the shipped cooldown
|
||||
//! sweep is nothing but those handlers driving `SetSequenceTime` — so the law is tested here
|
||||
//! against the stock `Cooldown.lua`, transcribed verbatim.
|
||||
//!
|
||||
//! The engine parses no M2, so a file's sequences and bounds are **facts** the host hands over
|
||||
//! ([`ModelFileFacts`]); these tests hand over the facts `benilla-extract m2seq` reads off the
|
||||
//! shipped files.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::{Model, UiScript};
|
||||
use crate::widget::{KindState, ModelFileFacts, ModelState, SequenceFacts};
|
||||
|
||||
/// A file's facts from `(anim_id, duration_ms, looping)` rows, in file order.
|
||||
fn facts(rows: &[(u16, u32, bool)]) -> ModelFileFacts {
|
||||
ModelFileFacts {
|
||||
sequences: rows
|
||||
.iter()
|
||||
.map(|&(anim_id, duration_ms, looping)| SequenceFacts {
|
||||
anim_id,
|
||||
duration_ms,
|
||||
looping,
|
||||
})
|
||||
.collect(),
|
||||
bbox: ([0.0; 3], [0.0; 3]),
|
||||
}
|
||||
}
|
||||
|
||||
/// `UI-Cooldown-Indicator.m2` (`m2seq`): seq 0 = id 0, 1000 ms, clamp; seq 1 = id 1, 1000 ms,
|
||||
/// clamp.
|
||||
const COOLDOWN_FILE: &str = r"Interface\Cooldown\UI-Cooldown-Indicator.mdx";
|
||||
fn cooldown_facts() -> ModelFileFacts {
|
||||
facts(&[(0, 1000, false), (1, 1000, false)])
|
||||
}
|
||||
|
||||
/// `MinimapPing.m2` (`m2seq`): seq 0 = id 127, 1333 ms, clamp; seq 1 = id 0, 833 ms, LOOP; seq
|
||||
/// 2 = id 1, 333 ms, clamp. `SetSequence(0)` is the looping one.
|
||||
const PING_FILE: &str = r"Interface\MiniMap\Ping\MinimapPing.mdx";
|
||||
fn ping_facts() -> ModelFileFacts {
|
||||
facts(&[(127, 1333, false), (0, 833, true), (1, 333, false)])
|
||||
}
|
||||
|
||||
/// The pane's scene state, read through the arena (1.12 has no getter for any of it).
|
||||
fn pane(s: &UiScript, name: &str) -> ModelState {
|
||||
let model = s.lua().app_data_ref::<Model>().expect("model");
|
||||
let fh = model.arena.lookup(name).expect("pane frame");
|
||||
match &model.arena.frame(fh).expect("live frame").kind_state {
|
||||
KindState::Model(m) => m.clone(),
|
||||
_ => panic!("{name} is not a Model"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `(anim_id, cursor_ms)` of the armed sequence under the facts the engine holds.
|
||||
fn play_head(s: &UiScript, name: &str, path: &str) -> Option<(u16, u32)> {
|
||||
let m = pane(s, name);
|
||||
let model = s.lua().app_data_ref::<Model>().expect("model");
|
||||
let facts = model.model_facts.get(&crate::widget::model_key(path))?;
|
||||
m.play_head(facts).map(|p| (p.anim_id, p.cursor_ms))
|
||||
}
|
||||
|
||||
/// The widget's `OnUpdate` (`0x76d7f0`) advances the scene clock by `trunc(elapsed · 1000)` —
|
||||
/// only while the frame is visible (the UI pump walks visible frames), so a hidden pane's clock
|
||||
/// stands still and a re-shown pane resumes where it stopped.
|
||||
#[test]
|
||||
fn the_clock_runs_only_while_the_pane_is_shown_and_truncates() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_model_facts(PING_FILE, ping_facts());
|
||||
s.run(&format!(
|
||||
r#"p = CreateFrame("Model", "Ping", UIParent) p:SetModel("{}") p:SetSequence(0)"#,
|
||||
PING_FILE.replace('\\', "\\\\")
|
||||
))
|
||||
.unwrap();
|
||||
s.tick(0.5);
|
||||
assert_eq!(pane(&s, "Ping").clock_ms, 500);
|
||||
s.tick(0.0166); // 16.6 ms → 16, not 17
|
||||
assert_eq!(pane(&s, "Ping").clock_ms, 516, "trunc, no +0.5 (76d854)");
|
||||
|
||||
s.run("Ping:Hide()").unwrap();
|
||||
s.tick(1.0);
|
||||
assert_eq!(
|
||||
pane(&s, "Ping").clock_ms,
|
||||
516,
|
||||
"hidden: the clock stands still"
|
||||
);
|
||||
s.run("Ping:Show()").unwrap();
|
||||
s.tick(0.1);
|
||||
assert_eq!(
|
||||
pane(&s, "Ping").clock_ms,
|
||||
616,
|
||||
"re-shown: resumes where it stopped"
|
||||
);
|
||||
|
||||
// The looping sequence wraps on its 833 ms and never completes.
|
||||
assert_eq!(play_head(&s, "Ping", PING_FILE), Some((0, 616)));
|
||||
s.tick(0.5); // clock 1116 → 1116 mod 833
|
||||
assert_eq!(play_head(&s, "Ping", PING_FILE), Some((0, 283)));
|
||||
}
|
||||
|
||||
/// `SetModel` runs the loader's completion (`0x70ebd0`) — arm **Stand** (id 0 if the file owns
|
||||
/// it, else `animations[0]`'s id), variation 0 — synchronously when the facts are known, and when
|
||||
/// they land otherwise; until then the pane is a waiter and the host is asked for the file.
|
||||
#[test]
|
||||
fn set_model_seeds_stand_now_or_when_the_facts_land() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
// Facts first: the arm is immediate.
|
||||
s.set_model_facts(COOLDOWN_FILE, cooldown_facts());
|
||||
s.run(&format!(
|
||||
r#"a = CreateFrame("Model", "Known", UIParent) a:SetModel("{}")"#,
|
||||
COOLDOWN_FILE.replace('\\', "\\\\")
|
||||
))
|
||||
.unwrap();
|
||||
let m = pane(&s, "Known");
|
||||
assert!(!m.pending_seed);
|
||||
assert_eq!(
|
||||
m.armed.map(|a| a.anim_id),
|
||||
Some(0),
|
||||
"Stand, armed at the call"
|
||||
);
|
||||
assert_eq!(play_head(&s, "Known", COOLDOWN_FILE), Some((0, 0)));
|
||||
|
||||
// Facts unknown: a waiter, and the file goes on the host's list once.
|
||||
s.run(&format!(
|
||||
r#"b = CreateFrame("Model", "Waiting", UIParent) b:SetModel("{0}")
|
||||
c = CreateFrame("Model", "Waiting2", UIParent) c:SetModel("{0}")"#,
|
||||
PING_FILE.replace('\\', "\\\\")
|
||||
))
|
||||
.unwrap();
|
||||
let m = pane(&s, "Waiting");
|
||||
assert!(m.pending_seed && m.armed.is_none());
|
||||
assert_eq!(
|
||||
s.model_facts_wanted(),
|
||||
vec!["interface/minimap/ping/minimapping".to_string()],
|
||||
"one request per file, case-folded, no extension"
|
||||
);
|
||||
assert!(s.model_facts_wanted().is_empty(), "drained");
|
||||
|
||||
// The facts land: every waiter holding the file arms its Stand at its own clock.
|
||||
s.tick(0.25);
|
||||
s.set_model_facts(PING_FILE, ping_facts());
|
||||
for name in ["Waiting", "Waiting2"] {
|
||||
let m = pane(&s, name);
|
||||
assert!(!m.pending_seed);
|
||||
assert_eq!(
|
||||
m.armed.map(|a| (a.anim_id, a.anchor_ms)),
|
||||
Some((0, 250)),
|
||||
"{name}: Stand (the file owns id 0), anchored at the clock the file landed on"
|
||||
);
|
||||
}
|
||||
|
||||
// A file that does not own id 0 seeds `animations[0]`'s own id.
|
||||
s.set_model_facts(
|
||||
r"Interface\Odd.mdx",
|
||||
facts(&[(204, 500, true), (166, 500, true)]),
|
||||
);
|
||||
s.run(r#"d = CreateFrame("Model", "Odd", UIParent) d:SetModel("Interface\\Odd.mdx")"#)
|
||||
.unwrap();
|
||||
assert_eq!(pane(&s, "Odd").armed.map(|a| a.anim_id), Some(204));
|
||||
}
|
||||
|
||||
/// `SetSequence(id)` with an id the file does not own **stops what was playing and arms
|
||||
/// nothing** — `0x7121a0`'s interrupt runs before its bounds check (§4.2). A queued arm on a
|
||||
/// file still loading is kept until the facts say otherwise.
|
||||
#[test]
|
||||
fn an_unowned_id_stops_the_track_and_arms_nothing() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_model_facts(PING_FILE, ping_facts());
|
||||
s.run(&format!(
|
||||
r#"p = CreateFrame("Model", "P", UIParent) p:SetModel("{}") p:SetSequence(0)"#,
|
||||
PING_FILE.replace('\\', "\\\\")
|
||||
))
|
||||
.unwrap();
|
||||
assert!(pane(&s, "P").armed.is_some());
|
||||
s.run("P:SetSequence(42)").unwrap();
|
||||
assert_eq!(pane(&s, "P").sequence, 42, "the raw id is still recorded");
|
||||
assert!(pane(&s, "P").armed.is_none(), "…but nothing plays");
|
||||
|
||||
// Queued on an unloaded file, then refused when the facts land.
|
||||
s.run(r#"q = CreateFrame("Model", "Q", UIParent) q:SetModel("Interface\\Late.mdx") q:SetSequence(7)"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
pane(&s, "Q").armed.map(|a| a.anim_id),
|
||||
Some(7),
|
||||
"kept for the replay"
|
||||
);
|
||||
s.set_model_facts(r"Interface\Late.mdx", facts(&[(0, 100, true)]));
|
||||
assert!(
|
||||
pane(&s, "Q").armed.is_none(),
|
||||
"the seed armed Stand, then the queued arm replayed and named an id the file does not \
|
||||
own — nothing plays"
|
||||
);
|
||||
|
||||
// The other order of the same replay: a queued arm the file DOES own wins over the seed,
|
||||
// at its original offset, re-anchored on the clock the file landed on.
|
||||
s.run(r#"r = CreateFrame("Model", "R", UIParent) r:SetModel("Interface\\Later.mdx") r:SetSequenceTime(5, 40)"#)
|
||||
.unwrap();
|
||||
s.tick(0.3);
|
||||
s.set_model_facts(
|
||||
r"Interface\Later.mdx",
|
||||
facts(&[(0, 100, true), (5, 900, false)]),
|
||||
);
|
||||
let a = pane(&s, "R").armed.expect("the queued arm replays");
|
||||
assert_eq!((a.anim_id, a.armed_at_ms, a.anchor_ms), (5, 300, 300 - 40));
|
||||
}
|
||||
|
||||
/// The shipped cooldown, on the two handlers and nothing else — `Cooldown.lua` verbatim:
|
||||
/// `SetTimer` arms sequence 0 and shows; every paint `OnUpdateModel` scrubs
|
||||
/// `SetSequenceTime(0, elapsed/duration · 1000)` until done, then flips to sequence 1 at 0; the
|
||||
/// flash's completion (`OnAnimFinished`, 1000 ms later) hides the frame.
|
||||
#[test]
|
||||
fn the_cooldown_machine_runs_on_the_two_handlers() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_model_facts(COOLDOWN_FILE, cooldown_facts());
|
||||
// `SetTimer`'s gate is `start > 0`: a session clock still at 0 refuses the timer, as the
|
||||
// reference's would — so the session is a second old before the button is pressed.
|
||||
s.tick(1.0);
|
||||
s.run(&format!(
|
||||
r#"
|
||||
-- Cooldown.lua, 1.12.1 (the stock file, transcribed for the test — decision 1602).
|
||||
function CooldownFrame_SetTimer(this, start, duration, enable)
|
||||
if ( start > 0 and duration > 0 and enable > 0) then
|
||||
this.start = start;
|
||||
this.duration = duration;
|
||||
this.stopping = 0;
|
||||
this:SetSequence(0);
|
||||
this:Show();
|
||||
else
|
||||
this:Hide();
|
||||
end
|
||||
end
|
||||
function CooldownFrame_OnUpdateModel()
|
||||
if ( this.stopping == 0 ) then
|
||||
local finished = (GetTime() - this.start) / this.duration;
|
||||
if ( finished < 1.0 ) then
|
||||
local time = finished * 1000;
|
||||
this:SetSequenceTime(0, time);
|
||||
return;
|
||||
end
|
||||
this.stopping = 1;
|
||||
this:SetSequence(1);
|
||||
this:SetSequenceTime(1, 0);
|
||||
else
|
||||
this:AdvanceTime();
|
||||
end
|
||||
end
|
||||
function CooldownFrame_OnAnimFinished()
|
||||
if ( this.stopping == 1 ) then
|
||||
this:Hide();
|
||||
end
|
||||
end
|
||||
cd = CreateFrame("Model", "CD", UIParent)
|
||||
cd:SetModel("{}")
|
||||
cd:SetScript("OnUpdateModel", CooldownFrame_OnUpdateModel)
|
||||
cd:SetScript("OnAnimFinished", CooldownFrame_OnAnimFinished)
|
||||
cd:Hide()
|
||||
fired = {{}}
|
||||
cd:SetScript("OnAnimFinished", function() table.insert(fired, "finished") CooldownFrame_OnAnimFinished() end)
|
||||
CooldownFrame_SetTimer(cd, GetTime(), 2, 1)
|
||||
"#,
|
||||
COOLDOWN_FILE.replace('\\', "\\\\")
|
||||
))
|
||||
.unwrap();
|
||||
assert!(s.frame_visible("CD"));
|
||||
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((0, 0)));
|
||||
|
||||
// The sweep: each paint scrubs sequence 0 to the elapsed fraction.
|
||||
s.tick(0.5);
|
||||
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((0, 250)));
|
||||
s.tick(1.0);
|
||||
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((0, 750)));
|
||||
// A scrub is an ANCHOR, not a freeze: between two paints the clock runs on from it. The
|
||||
// cooldown never sees that (it re-scrubs every paint), the ping relies on it.
|
||||
assert_eq!(pane(&s, "CD").armed.map(|a| a.anchor_ms), Some(1500 - 750));
|
||||
|
||||
// Done: the flip to the flash, sequence 1 at 0.
|
||||
s.tick(0.6);
|
||||
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((1, 0)));
|
||||
assert!(s.frame_visible("CD"));
|
||||
assert_eq!(s.eval::<i64>("return cd.stopping").unwrap(), 1);
|
||||
|
||||
// The flash runs its 1000 ms; the completion fires once, and the handler hides the frame.
|
||||
s.tick(0.5);
|
||||
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((1, 500)));
|
||||
assert!(s.eval::<bool>("return table.getn(fired) == 0").unwrap());
|
||||
s.tick(0.6);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return table.getn(fired)").unwrap(),
|
||||
1,
|
||||
"OnAnimFinished on the clamped sequence's natural completion"
|
||||
);
|
||||
assert!(
|
||||
!s.frame_visible("CD"),
|
||||
"…which the stock handler turns into Hide()"
|
||||
);
|
||||
// Hidden: no paint, no second completion, the cursor holds at the end.
|
||||
s.tick(1.0);
|
||||
assert_eq!(s.eval::<i64>("return table.getn(fired)").unwrap(), 1);
|
||||
assert_eq!(play_head(&s, "CD", COOLDOWN_FILE), Some((1, 1000)));
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `OnUpdateModel` runs with `this` set, once per paint of a **visible** pane, before the
|
||||
/// completion is read — so a handler that re-arms in the same paint completes nothing.
|
||||
#[test]
|
||||
fn on_update_model_fires_per_visible_paint_with_this() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_model_facts(COOLDOWN_FILE, cooldown_facts());
|
||||
s.run(&format!(
|
||||
r#"
|
||||
seen = {{}}
|
||||
m = CreateFrame("Model", "M", UIParent)
|
||||
m:SetModel("{}")
|
||||
m:SetScript("OnUpdateModel", function() table.insert(seen, this:GetName()) end)
|
||||
m:SetScript("OnAnimFinished", function() error("must not fire") end)
|
||||
m:SetSequence(1)
|
||||
"#,
|
||||
COOLDOWN_FILE.replace('\\', "\\\\")
|
||||
))
|
||||
.unwrap();
|
||||
s.tick(0.1);
|
||||
s.tick(0.1);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return table.getn(seen)").unwrap(),
|
||||
2,
|
||||
"one per tick"
|
||||
);
|
||||
assert_eq!(s.eval::<String>("return seen[1]").unwrap(), "M");
|
||||
s.run("M:Hide()").unwrap();
|
||||
s.tick(0.1);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return table.getn(seen)").unwrap(),
|
||||
2,
|
||||
"no paint while hidden"
|
||||
);
|
||||
// Re-arming every paint keeps the clamped sequence from ever completing.
|
||||
s.run("M:Show() M:SetScript(\"OnUpdateModel\", function() this:SetSequenceTime(1, 0) end)")
|
||||
.unwrap();
|
||||
for _ in 0..30 {
|
||||
s.tick(0.1);
|
||||
}
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A LOOPING sequence fires `OnAnimFinished` once too — at the end of its first pass — and keeps
|
||||
/// looping (`0x719370` enqueues the completion before it tests the loop flag; wow-re
|
||||
/// `modelframe-texanim-and-sequence-law.md` Q4). And a pane with no file fires no
|
||||
/// `OnUpdateModel` at all (`76d24c`'s gate), however visible.
|
||||
#[test]
|
||||
fn a_loop_completes_once_and_a_fileless_pane_paints_nothing() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_model_facts(PING_FILE, ping_facts());
|
||||
s.run(
|
||||
r#"
|
||||
done = 0 painted = 0
|
||||
p = CreateFrame("Model", "Loop", UIParent)
|
||||
p:SetScript("OnAnimFinished", function() done = done + 1 end)
|
||||
p:SetScript("OnUpdateModel", function() painted = painted + 1 end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.tick(0.1);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return painted").unwrap(),
|
||||
0,
|
||||
"no file: no paint"
|
||||
);
|
||||
s.run(&format!(
|
||||
r#"p:SetModel("{}") p:SetSequence(0)"#,
|
||||
PING_FILE.replace('\\', "\\\\")
|
||||
))
|
||||
.unwrap();
|
||||
s.tick(0.5);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return painted").unwrap(),
|
||||
1,
|
||||
"a file: one paint per tick"
|
||||
);
|
||||
assert_eq!(s.eval::<i64>("return done").unwrap(), 0);
|
||||
s.tick(0.4); // 900 ms ≥ the 833 ms loop
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return done").unwrap(),
|
||||
1,
|
||||
"the first pass completes"
|
||||
);
|
||||
assert_eq!(
|
||||
play_head(&s, "Loop", PING_FILE),
|
||||
Some((0, 67)),
|
||||
"…and the loop goes on"
|
||||
);
|
||||
s.tick(1.0);
|
||||
assert_eq!(s.eval::<i64>("return done").unwrap(), 1, "once per arm");
|
||||
s.run("p:SetSequence(0)").unwrap();
|
||||
s.tick(0.9);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return done").unwrap(),
|
||||
2,
|
||||
"a re-arm completes again"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `ReplaceIconTexture` is the type-14 texture override on the INSTANCE: stored over a file
|
||||
/// (queued and replayed while it streams), dropped with no file, released by `SetModel` and
|
||||
/// `ClearModel`.
|
||||
#[test]
|
||||
fn replace_icon_texture_lives_and_dies_with_the_instance() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
card = CreateFrame("Model", "Card", UIParent)
|
||||
card:ReplaceIconTexture("Interface\\Icons\\INV_Misc_Bag_08")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
pane(&s, "Card").icon,
|
||||
None,
|
||||
"no instance: dropped, never replayed"
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
card:SetModel("Interface\\ItemAnimations\\ForcedBackpackItem.mdx")
|
||||
card:ReplaceIconTexture("Interface\\Icons\\INV_Misc_Bag_08")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
pane(&s, "Card").icon.as_deref(),
|
||||
Some(r"Interface\Icons\INV_Misc_Bag_08")
|
||||
);
|
||||
s.run(r#"card:SetModel("Interface\\ItemAnimations\\ForcedBackpackItem.mdx")"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
pane(&s, "Card").icon,
|
||||
None,
|
||||
"a fresh instance has no override"
|
||||
);
|
||||
s.run(r#"card:ReplaceIconTexture("x") card:ClearModel()"#)
|
||||
.unwrap();
|
||||
assert_eq!(pane(&s, "Card").icon, None);
|
||||
assert!(
|
||||
s.run("card:ReplaceIconTexture(nil)").is_err(),
|
||||
"the shape-A usage raise stays"
|
||||
);
|
||||
}
|
||||
|
||||
/// The XML `scale=` on a model pane is the MODEL's scale (`0x76cac0` → `+0x3a0`), never the
|
||||
/// frame's — the cooldown template's 0.75 must not shrink the widget's rect.
|
||||
#[test]
|
||||
fn the_model_scale_attribute_is_the_models_own() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
// The pet bar's shine (`PetActionBarFrame.xml:25`), and a `scale="0"` beside it.
|
||||
let doc = crate::framexml::parse(
|
||||
r#"<Ui>
|
||||
<Frame name="Host"><Size><AbsDimension x="30" y="30"/></Size>
|
||||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||||
<Frames>
|
||||
<Model name="ShinePane" file="Interface\Buttons\UI-AutoCastButton.mdx" scale="1.2" hidden="true" setAllPoints="true"/>
|
||||
<Model name="ZeroPane" file="Interface\Buttons\UI-AutoCastButton.mdx" scale="0" hidden="true" setAllPoints="true"/>
|
||||
</Frames>
|
||||
</Frame>
|
||||
</Ui>"#,
|
||||
)
|
||||
.expect("valid FrameXML");
|
||||
let report = crate::loader::load(&s, &doc, &|_| None);
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64)>("return ShinePane:GetModelScale(), ShinePane:GetScale()")
|
||||
.unwrap(),
|
||||
(1.2_f32 as f64, 1.0),
|
||||
"the attribute lands on SetModelScale; the frame scale is untouched"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return ZeroPane:GetModelScale()").unwrap(),
|
||||
1.0,
|
||||
"≤ 0 is the reference's raise, not a clamp — the default stands"
|
||||
);
|
||||
assert!(
|
||||
report
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|w| w.contains("Invalid model scale")),
|
||||
"{:?}",
|
||||
report.warnings
|
||||
);
|
||||
}
|
||||
@@ -337,13 +337,13 @@ fn the_two_model_tables_are_the_references_own() {
|
||||
}
|
||||
}
|
||||
|
||||
/// **`SetSequenceTime` is a scrub INTO the current sequence**, so changing the sequence drops it.
|
||||
///
|
||||
/// The cooldown indicator drives this pair every frame — `SetSequence(n)` then
|
||||
/// `SetSequenceTime(n, ms)` — and carrying a stale scrub across a sequence change would park the
|
||||
/// new animation at a time belonging to the previous one.
|
||||
/// **`SetSequence` and `SetSequenceTime` are one arm** (`0x7121a0`): each interrupts what plays
|
||||
/// and anchors the new sequence's cursor — at 0, or at the caller's `ms` — on the pane's own
|
||||
/// clock. The clock law itself (advance, wrap, completion, the two handlers) is
|
||||
/// `script::tests::model_clock`; this is the binding-level shape, with no file facts known, which
|
||||
/// is the state the reference's queued replay covers.
|
||||
#[test]
|
||||
fn a_sequence_change_drops_the_scrub() {
|
||||
fn the_two_sequence_verbs_arm_the_pane_on_its_own_clock() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
@@ -356,30 +356,33 @@ fn a_sequence_change_drops_the_scrub() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 1.12 has no `GetSequence`, so the scrub is read off the model the way `simplehtml`'s tests
|
||||
// 1.12 has no `GetSequence`, so the arm is read off the model the way `simplehtml`'s tests
|
||||
// read their blocks — through the arena, because there is no Lua getter to read it through.
|
||||
let scrub = |s: &UiScript| {
|
||||
let armed = |s: &UiScript| {
|
||||
let lua = s.lua();
|
||||
let model = lua.app_data_ref::<crate::script::Model>().expect("model");
|
||||
let fh = model.arena.lookup("MSeq").expect("MSeq frame");
|
||||
match &model.arena.frame(fh).expect("live frame").kind_state {
|
||||
crate::widget::KindState::Model(m) => (m.sequence, m.sequence_time),
|
||||
crate::widget::KindState::Model(m) => {
|
||||
(m.sequence, m.armed.map(|a| (a.anim_id, a.anchor_ms)))
|
||||
}
|
||||
_ => panic!("MSeq is not a Model"),
|
||||
}
|
||||
};
|
||||
assert_eq!(scrub(&s), (0, Some((0, 250))));
|
||||
// The scrub anchors the cursor 250 ms in: `anchor = clock − ms` at clock 0.
|
||||
assert_eq!(armed(&s), (0, Some((0, -250))));
|
||||
|
||||
s.run("MSeq:SetSequence(3)").unwrap();
|
||||
assert_eq!(
|
||||
scrub(&s),
|
||||
(3, None),
|
||||
"a new sequence starts unscrubbed — the old (sequence, ms) pair is not carried across"
|
||||
armed(&s),
|
||||
(3, Some((3, 0))),
|
||||
"a new sequence starts at its own 0 — the old anchor is not carried across"
|
||||
);
|
||||
|
||||
// ...and ClearModel drops the scrub with the content.
|
||||
// ...and ClearModel releases the instance, arm included.
|
||||
s.run("MSeq:SetSequenceTime(3, 40) MSeq:ClearModel()")
|
||||
.unwrap();
|
||||
assert_eq!(scrub(&s).1, None);
|
||||
assert_eq!(armed(&s).1, None);
|
||||
}
|
||||
|
||||
/// `SetLight`'s numbers are stored and returned **verbatim**, however many there are.
|
||||
|
||||
@@ -184,6 +184,7 @@ impl super::UiScript {
|
||||
self.push_error(e);
|
||||
}
|
||||
}
|
||||
self.tick_model_panes(elapsed);
|
||||
// Advance every ScrollingMessageFrame's per-line fade (the client's OnUpdate `0x788460`).
|
||||
// Independent of the frame's own OnUpdate script — the fade is C++ behavior, not Lua. The
|
||||
// AtBottom freeze gate lives inside `ScrollingMessageState::tick`.
|
||||
@@ -260,3 +261,125 @@ impl super::UiScript {
|
||||
self.report_handler_profile(elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
impl UiScript {
|
||||
/// The model panes' per-frame pass (decision 2007; wow-re `modelframe-render-law.md` §4).
|
||||
///
|
||||
/// Three things the reference does each frame for a **visible** model pane, in this order:
|
||||
/// the widget's own `OnUpdate` (`0x76d7f0`, walked by the UI pump with the Lua OnUpdates the
|
||||
/// caller just fired) advances its private scene clock by `trunc(elapsed · 1000)` ms — a
|
||||
/// truncation, no `+0.5` (`76d854 __ftol`); the paint (`vt+0x98`, `0x76d1a0`) fires
|
||||
/// `OnUpdateModel` before it builds the scene; and the scene's animate runs the completion
|
||||
/// callback (`0x76cdc0`) when the armed sequence has run its length — `OnAnimFinished`, on
|
||||
/// natural completion only (mode 0), once per arm, for a looping sequence's first pass as
|
||||
/// much as for a clamped one's end (the flag test sits after the enqueue — wow-re
|
||||
/// `modelframe-texanim-and-sequence-law.md`, Q4). A hidden pane gets none of the three: its
|
||||
/// clock stands still and it completes nothing, which is what makes the minimap ping "resume
|
||||
/// where the last one stopped".
|
||||
///
|
||||
/// The completion is read AFTER `OnUpdateModel` because the handler may re-arm (the cooldown
|
||||
/// flips to its flash there), and a fresh arm has nothing to complete.
|
||||
fn tick_model_panes(&mut self, elapsed: f32) {
|
||||
let dt_ms = (elapsed * 1000.0).trunc().max(0.0) as u64;
|
||||
let update_ids: Vec<u32> = {
|
||||
let mut model = self.model_mut();
|
||||
let ticked: Vec<FrameHandle> = model.arena.ticked_kinds().to_vec();
|
||||
for h in ticked {
|
||||
let Some(f) = model.arena.frame_mut(h) else {
|
||||
continue;
|
||||
};
|
||||
if !f.effective_visible {
|
||||
continue;
|
||||
}
|
||||
if let crate::widget::KindState::Model(m) = &mut f.kind_state {
|
||||
m.clock_ms += dt_ms;
|
||||
}
|
||||
}
|
||||
// The handler population, maintained by `SetScript` like `on_update_frames`; a
|
||||
// destroyed frame's handle compacts out on its first miss.
|
||||
if model
|
||||
.on_update_model_frames
|
||||
.iter()
|
||||
.any(|&h| model.arena.frame(h).is_none())
|
||||
{
|
||||
let arena = &model.arena;
|
||||
let live: Vec<FrameHandle> = model
|
||||
.on_update_model_frames
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&h| arena.frame(h).is_some())
|
||||
.collect();
|
||||
model.on_update_model_frames = live;
|
||||
}
|
||||
// Visible MODEL panes WITH A MODEL INSTALLED: the handler is `CSimpleModel::LoadXML`'s
|
||||
// (`+0x3cc`) and the paint that fires it (`76d1bc`) is reached only past the
|
||||
// `[widget+0x318] ≠ 0` gate at `76d24c` — a file set, resident or still streaming;
|
||||
// a pane with no file paints nothing and fires nothing (wow-re
|
||||
// `modelframe-texanim-and-sequence-law.md`, Q4). A script of that name on any
|
||||
// other kind is inert, as it is in the reference.
|
||||
let frames: Vec<FrameHandle> = model
|
||||
.on_update_model_frames
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&h| {
|
||||
model.arena.frame(h).is_some_and(|f| {
|
||||
f.effective_visible
|
||||
&& matches!(&f.kind_state, crate::widget::KindState::Model(m) if m.path.is_some())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut ids: Vec<u32> = frames.into_iter().map(|h| model.frame_id(h)).collect();
|
||||
ids.sort_unstable(); // creation order — the OnUpdate sweep's own law
|
||||
ids
|
||||
};
|
||||
for id in update_ids {
|
||||
if let Err(e) = event::fire_widget_handler(&self.lua, id, "OnUpdateModel", Vec::new()) {
|
||||
self.push_error(e);
|
||||
}
|
||||
}
|
||||
let finished_ids: Vec<u32> = {
|
||||
let mut model = self.model_mut();
|
||||
let ticked: Vec<FrameHandle> = model.arena.ticked_kinds().to_vec();
|
||||
let mut due: Vec<FrameHandle> = Vec::new();
|
||||
for h in ticked {
|
||||
let Some(f) = model.arena.frame(h) else {
|
||||
continue;
|
||||
};
|
||||
if !f.effective_visible {
|
||||
continue;
|
||||
}
|
||||
let crate::widget::KindState::Model(m) = &f.kind_state else {
|
||||
continue;
|
||||
};
|
||||
let Some(path) = m.path.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(facts) = model.model_facts.get(&crate::widget::model_key(path)) else {
|
||||
continue;
|
||||
};
|
||||
if m.completion_due(facts) {
|
||||
due.push(h);
|
||||
}
|
||||
}
|
||||
let mut ids = Vec::with_capacity(due.len());
|
||||
for h in due {
|
||||
if let Some(crate::widget::KindState::Model(m)) =
|
||||
model.arena.frame_mut(h).map(|f| &mut f.kind_state)
|
||||
{
|
||||
if let Some(a) = &mut m.armed {
|
||||
a.finished = true;
|
||||
}
|
||||
}
|
||||
ids.push(model.frame_id(h));
|
||||
}
|
||||
ids.sort_unstable();
|
||||
ids
|
||||
};
|
||||
for id in finished_ids {
|
||||
if let Err(e) = event::fire_widget_handler(&self.lua, id, "OnAnimFinished", Vec::new())
|
||||
{
|
||||
self.push_error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,13 @@ pub enum QuadContent {
|
||||
/// and for the same reason (the scene state is [`crate::widget::ModelState`]; the render is
|
||||
/// not this crate's).
|
||||
///
|
||||
/// **The clock is deliberately NOT here.** A pane's play head moves every tick, and the
|
||||
/// host memoizes its whole quad conversion on the extracted list being unchanged — a
|
||||
/// cursor in this variant would re-convert the entire interface every frame a cooldown or a
|
||||
/// ping is showing. The host reads the play heads straight off the engine instead
|
||||
/// (`UiScript::visible_model_panes`, decision 2008); this variant carries only what the
|
||||
/// layout and the Lua setters decide.
|
||||
///
|
||||
/// **Why the NAME travels rather than the scene.** benilla draws a body pane by sampling an
|
||||
/// off-screen bake the app already keeps per *window* (the paper doll's, the inspect window's,
|
||||
/// the pet page's), and which bake a pane samples is a fact about that window, not about the
|
||||
@@ -112,6 +119,9 @@ pub enum QuadContent {
|
||||
/// invent a meaning for. A pane with no name, or one no window has claimed, draws nothing;
|
||||
/// that is also what a `SetModel` pane does today, and it is honest rather than a white slab.
|
||||
ModelPane {
|
||||
/// The pane's frame handle — the renderer's key for the tile it keeps per pane (a name
|
||||
/// is optional and shared by nothing; the handle is neither). Decision 2007.
|
||||
handle: crate::widget::FrameHandle,
|
||||
/// The pane's global frame name (`$parent`-expanded), or `None` for an anonymous
|
||||
/// `CreateFrame("Model")` — pfUI's autocast shine is the corpus example of the latter.
|
||||
name: Option<String>,
|
||||
@@ -124,6 +134,17 @@ pub enum QuadContent {
|
||||
facing: f32,
|
||||
/// `SetModelScale`'s factor (1 default).
|
||||
model_scale: f32,
|
||||
/// `SetPosition`'s offset, in the ortho leg's layout units (render law §2: the root is
|
||||
/// `T(pos · layoutScale) · R(facing) · S(…)`).
|
||||
position: (f32, f32, f32),
|
||||
/// The frame's **own** alpha — what the model instance draws at. The reference re-pushes
|
||||
/// `[widget+0xc8]/255` into the instance on every `Frame:SetAlpha` (`0x76d120`), and
|
||||
/// nothing folds the parent chain in: a pane under a faded parent draws at its own
|
||||
/// alpha (render law §4.4). [`ExtractedQuad::alpha`] carries the effective one.
|
||||
own_alpha: f32,
|
||||
/// `ReplaceIconTexture`'s path — the type-14 texture override, or `None` for the file's
|
||||
/// own textures.
|
||||
icon: Option<String>,
|
||||
},
|
||||
/// A `Texture` region: a BLP path *or* a solid/vertex color (or both — a tinted texture).
|
||||
Texture {
|
||||
|
||||
@@ -152,8 +152,16 @@ fn create(lua: &Lua, which: Arrow, parent: Value) -> mlua::Result<()> {
|
||||
// The widget's rect is the resident model's bounding box (§2.2); the footprint at this scale.
|
||||
wrapper.call_method::<()>("SetWidth", side)?;
|
||||
wrapper.call_method::<()>("SetHeight", side)?;
|
||||
// `SetModel("Interface\Minimap\MinimapArrow.mdx")` in C++ (`0x4a7a80` → `0x76c8e0`): the
|
||||
// same file set every pane takes, seeded with the file's facts when the host has them
|
||||
// (decision 2007 — the arrow's Stand loops its 3.333 s with no bone keyed, so nothing
|
||||
// moves; the arm is the reference's, not a look).
|
||||
let facts = lua
|
||||
.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.model_facts_for(ARROW_MODEL);
|
||||
super::modelframe::with_model(lua, &wrapper, |m| {
|
||||
m.path = Some(ARROW_MODEL.to_string());
|
||||
m.set_file(ARROW_MODEL.to_string(), facts.as_deref());
|
||||
m.scale = scale;
|
||||
m.position = (side * 0.5, side * 0.5, 0.0);
|
||||
})?;
|
||||
|
||||
Reference in New Issue
Block a user