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 6b812913f4
commit 73ead4e4d2
58 changed files with 4127 additions and 729 deletions
@@ -357,6 +357,9 @@ fn splice_simple(eq: &benilla_ui::script::ExtractedQuad) -> bool {
QuadContent::Frame
| QuadContent::Cooldown { .. }
| QuadContent::Backdrop { .. }
// A model pane writes at most one quad and one idempotent tile request (decision 2008)
// — and the map's arrow changes facing on every turn while the map is open.
| QuadContent::ModelPane { .. }
// Both colour-picker arms write one quad and nothing else — and they change on every
// step of a drag, which is exactly the traffic the splice exists for.
| QuadContent::ColorWheel
@@ -615,6 +618,8 @@ pub(super) fn drive_script(
// the atlas's bake generation, which followed the same two terms; with nothing to re-bake, the
// honest thing to watch is the term itself.)
let dpi = window.scale_factor();
// The tile renderer sizes its cells in device pixels off this (decision 2008).
booths.tiles.dpi = dpi;
let generation = font_atlas.as_deref().map(|a| a.generation);
let seam_moved = *last_seam != s || *last_dpi != dpi;
if seam_moved {
@@ -1346,10 +1351,14 @@ fn convert_entry(
// A pane with no name, or one no window claims, draws nothing (`SetModel` panes included:
// this engine holds their scene and renders no M2 into it). Nothing is stubbed white.
QuadContent::ModelPane {
handle,
name,
model,
facing,
model_scale: _,
model_scale,
position,
own_alpha,
icon,
} => {
use crate::portrait::PortraitSource;
// The world-map arrow (decision 1980): the stock `WorldMapFrame.lua` /
@@ -1390,6 +1399,58 @@ fn convert_entry(
});
return;
}
// A FILE pane (decision 2008): publish what the tile renderer needs — the pane's
// device-pixel size and the render law's unit ladder off it — and draw the tile's
// atlas cell when the renderer has one. The request is idempotent, so the memoized
// conversion may re-publish it freely; which panes draw is the engine's paint list.
if let Some(path) = model.as_deref() {
let tiles = &mut booths.tiles;
let dpi = tiles.dpi.max(0.01);
let aspect = if h > 0.0 { w / h } else { 4.0 / 3.0 };
let diag = (aspect * aspect + 1.0).sqrt();
let layout = eq.scale;
let size_px = UVec2::new(
(rect.width() * dpi).round().max(1.0) as u32,
(rect.height() * dpi).round().max(1.0) as u32,
);
tiles.requests.insert(
handle,
crate::ui_models::TileRequest {
path: path.to_string(),
size_px,
// `1 model unit = 1280 · modelScale · layoutScale` FrameXML units (§3).
px_per_unit: 1280.0 * model_scale * layout * s * dpi,
// `SetPosition` is in layout units: `768 · √(a²+1)` FrameXML per unit.
pos_px_per_unit: 768.0 * diag * layout * s * dpi,
// A particle's half-extent: eye space, neither scale (§6).
star_px_per_unit: 768.0 * diag * s * dpi,
facing,
position: Vec3::new(position.0, position.1, position.2),
icon: icon.clone(),
},
);
if let (Some(cell), Some(atlas)) = (tiles.cells.get(&handle), tiles.atlas.clone()) {
let a = tiles.atlas_size.as_vec2();
let (u0, v0) = (cell.origin.x as f32 / a.x, cell.origin.y as f32 / a.y);
let (u1, v1) = (
(cell.origin.x + cell.size.x) as f32 / a.x,
(cell.origin.y + cell.size.y) as f32 / a.y,
);
out.push(UiQuad {
rect,
z_key: eq.z,
texture: Some(atlas),
uv: UvRect::from_tex_coords([u0, u1, v0, v1]),
// The instance draws at the widget's OWN alpha (render law §4.4).
color: [1.0, 1.0, 1.0, own_alpha],
// A render target: premultiplied by construction (`UiQuad` doc).
premultiplied: true,
clip,
..default()
});
}
return;
}
let Some(slot) = name.as_deref().and_then(crate::portrait::model_pane_booth) else {
return;
};
@@ -1998,6 +2059,7 @@ mod clip_plumb_tests {
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::autocast_shine::ShineSites>();
app.init_resource::<crate::ui_script::UiFrameCost>();
@@ -2085,6 +2147,7 @@ mod clip_plumb_tests {
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::autocast_shine::ShineSites>();
app.init_resource::<crate::ui_script::UiFrameCost>();
@@ -2292,6 +2355,7 @@ mod extract_gate_tests {
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::autocast_shine::ShineSites>();
app.init_resource::<crate::ui_script::UiFrameCost>();