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:
Sam
2026-09-06 19:33:58 +07:00
co-authored by Claude Fable 5.1
parent f9293ffbe8
commit c957f98d84
58 changed files with 4127 additions and 729 deletions
+174 -43
View File
@@ -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>,
}