From d69d09dfce13c593eea7f97bf63a2a9a18063553 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:27:58 +0700 Subject: [PATCH] Comments in benilla-assets: one line of fact each, no history, no record numbers (#95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #41: the comments of `benilla-assets`, Rust and WGSL, rewritten to the rule in `docs/METHOD.md`: what the code does and the reference fact behind it, in a line. 3,872 comment lines become 1,568. - The citations that were only a private record number go first, mechanically, each edit shown to be a pure removal. - The rewrite corrects the comments that stated wrong facts: the alpha-key cutoff now cites the reference's per-blend-mode table (`0x85ad20`), the texture-filter note names the subsystems that ignore the global setting, a test doc said an exhausted variation roll returns the last variation (it returns the first), and about twenty more found on the way (renamed or deleted items, a shader flag the doc misread, docs sitting on the wrong function). "Deviation:" now marks only a choice with a reason; an unbuilt behaviour says so plainly. - Verified: every file strips to identical code, and `cargo fmt --check` is clean. A second reader compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the reference. String literals are untouched; they are #92. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../benilla-assets/examples/face_facing_at.rs | 40 +- crates/benilla-assets/src/adt.rs | 127 ++--- crates/benilla-assets/src/anim_rng.rs | 80 +-- crates/benilla-assets/src/blp.rs | 114 ++--- crates/benilla-assets/src/column_grid.rs | 177 ++----- crates/benilla-assets/src/coords.rs | 97 +--- crates/benilla-assets/src/gpu_blp.rs | 102 +--- crates/benilla-assets/src/lib.rs | 152 ++---- crates/benilla-assets/src/m2.rs | 463 ++++------------- crates/benilla-assets/src/materials.rs | 455 ++++------------- crates/benilla-assets/src/minimap_grid.rs | 41 +- crates/benilla-assets/src/model.rs | 475 +++++------------- crates/benilla-assets/src/model/anims.rs | 347 ++++--------- crates/benilla-assets/src/model/pose.rs | 65 +-- crates/benilla-assets/src/shaders/liquid.wgsl | 157 +++--- .../benilla-assets/src/shaders/terrain.wgsl | 69 ++- .../benilla-assets/src/shaders/wow_model.wgsl | 10 +- crates/benilla-assets/src/spatial_cache.rs | 108 +--- crates/benilla-assets/src/terrain.rs | 223 +++----- crates/benilla-assets/src/tex_filter.rs | 178 ++----- crates/benilla-assets/src/trace.rs | 48 +- crates/benilla-assets/src/wdt.rs | 26 +- crates/benilla-assets/src/wmo.rs | 465 +++++------------ .../benilla-assets/src/world_assets/images.rs | 233 ++------- crates/benilla-assets/src/world_assets/mod.rs | 449 ++++------------- 25 files changed, 1198 insertions(+), 3503 deletions(-) diff --git a/crates/benilla-assets/examples/face_facing_at.rs b/crates/benilla-assets/examples/face_facing_at.rs index c568e6e6..6e935093 100644 --- a/crates/benilla-assets/examples/face_facing_at.rs +++ b/crates/benilla-assets/examples/face_facing_at.rs @@ -1,25 +1,13 @@ -//! Which way do the collidable faces under a world pin actually FACE β€” and would the reference -//! block on them? +//! Every walking-collidable WMO face under a world pin, with its height, its authored normal +//! against a fall, and whether the reference blocks on it. //! `cargo run -p benilla-assets --example face_facing_at -- [wmo-substring]` //! e.g. `face_facing_at Kalimdor -8137.9 -4897.2 2.0 caverns`. //! -//! The reference's movement collision is **one-sided** (decision 0967): `0x671cc0` emits each -//! candidate face's plane at the **unflipped file winding**, and `0x632700` then processes it -//! only if `nΒ·dir <= -1e-5`. A player falling straight down has `dir` = βˆ’up, so **a face blocks -//! the fall iff its authored normal points up**. Ours is a parry trimesh β€” two-sided β€” so it -//! blocks either way. -//! -//! This tool turns that difference into a number at a specific `.go xyz` pin: every walking-collidable -//! WMO face whose footprint contains the pin, with its world height, its authored normal's up -//! component, and the reference's verdict. Where a face reads `PASSES`, the reference falls through -//! it and we stand on it. -//! -//! It goes through the **same transform the app bakes colliders with** β€” `wow_to_bevy` per vertex -//! then the placement `Transform`, exactly as `terrain_stream::collider::placement_collider_data` -//! does β€” so the geometry here is the geometry the player collides with, not a re-derivation. Both -//! maps are proper rotations, so a face's winding (and the sign of `nΒ·dir`) survives them. -//! -//! Output is Blizzard data β€” pipe it to the scratchpad, never into the repo. +//! The reference's movement collision is one-sided: `0x671cc0` emits each face's plane at the file +//! winding and `0x632700` processes it only if `nΒ·dir <= -1e-5`, so a face blocks a fall only if +//! its authored normal points up. Faces take the app's collider transform, `wow_to_bevy` then the +//! placement `Transform`; both are proper rotations, so the winding and the sign of `nΒ·dir` +//! survive. Output is Blizzard data: never commit it. use benilla_assets::coords::{placement_rotation, wow_to_bevy}; use bevy::prelude::*; @@ -71,8 +59,7 @@ fn main() -> anyhow::Result<()> { for (model, transform, uid) in &placements { let stem = model.to_ascii_lowercase().replace('\\', "/"); let stem = stem.strip_suffix(".wmo").unwrap_or(&stem).to_string(); - // Walk group files by index β€” the same `_NNN.wmo` naming the loader uses. A gap is not - // expected, but tolerate a few misses rather than trusting a count we did not read. + // Group files by index, `_NNN.wmo` as the loader names them, tolerating a few misses. let (mut gi, mut misses) = (0u32, 0u32); while misses < 4 { let Ok(gbytes) = chain.read_file(&format!("{stem}_{gi:03}.wmo")) else { @@ -81,8 +68,7 @@ fn main() -> anyhow::Result<()> { continue; }; misses = 0; - // The WALKING gather β€” the player-body audience, skip DETAIL (`0x04`). Identical call to - // the one the WMO asset loader makes. + // The walking gather, DETAIL (`0x04`) skipped, as the WMO loader makes it. let (mut pos, mut idx) = (Vec::new(), Vec::new()); benilla_formats::accumulate_wmo_group_collision(&gbytes, &mut pos, &mut idx); let verts: Vec = pos @@ -131,7 +117,7 @@ fn main() -> anyhow::Result<()> { ); let (mut blocks, mut passes) = (0usize, 0usize); for h in &hits { - // The pin is Bevy-space; its Y is the WoW Z the tester read off the panel. + // The pin is Bevy-space; its Y is WoW Z. let reference_blocks = h.dot <= FACING_EPS; if reference_blocks { blocks += 1; @@ -161,15 +147,15 @@ fn main() -> anyhow::Result<()> { struct Hit { /// World height (Bevy +Y = WoW +Z) where the pin's vertical line meets this face's plane. height: f32, - /// The authored winding normal dotted with the fall direction β€” the reference's gate input. + /// The authored winding normal dotted with the fall direction, the reference's gate input. dot: f32, group: u32, uid: u32, model: String, } -/// Where the vertical line through `pin` meets the triangle's plane β€” `None` unless the pin's -/// horizontal footprint is inside the triangle (the XZ projection in Bevy space, since +Y is up). +/// Where the vertical through `pin` meets the triangle's plane, if the pin is inside its XZ +/// projection. fn plane_height_under(pin: Vec3, tri: [Vec3; 3]) -> Option { let p = Vec2::new(pin.x, pin.z); let [a, b, c] = tri.map(|v| Vec2::new(v.x, v.z)); diff --git a/crates/benilla-assets/src/adt.rs b/crates/benilla-assets/src/adt.rs index 86955f9c..249228c7 100644 --- a/crates/benilla-assets/src/adt.rs +++ b/crates/benilla-assets/src/adt.rs @@ -1,27 +1,9 @@ -//! ADT β†’ terrain-tile asset loader. -//! -//! Decodes one vanilla ADT tile into an [`AdtTile`]: the decoded per-MCNK chunks + a -//! [`ChunkShading`] per chunk (the splat-array indices resolved while packing), plus the tile's -//! three `texture_2d_array`s β€” ground layers, per-chunk alpha (blend) maps, per-chunk MCSH shadow -//! maps β€” and the raw doodad/WMO placement lists (for the spawn system to load as -//! `M2Model`/`WmoModel`). -//! -//! **The loader ships no meshes.** A tile's ~256 chunk meshes used to be labeled sub-assets built -//! here β€” which made a tile's landing atomic: the whole set hit the render world's -//! extract/prepare in ONE frame (~80–105 ms of process CPU per fresh row, measured β€” the B181 -//! first-contact spike), and no app-side budget downstream of the load could spread work the -//! loader had already packaged. The app builds each cell's mesh itself via [`chunk_to_mesh`], a -//! few cells per frame (`terrain_stream::furnish`), so the render-world cost of a landing is -//! paced at the only place pacing is possible: mesh-asset creation. -//! -//! Each chunk mesh carries its array indices on every one of its vertices β€” `COLOR` (4 layer -//! indices) and `UV1` (alpha index `.x`, shadow index `.y`, `-1` = none) β€” which is why all 256 -//! chunks of a tile still share **one** material; the app's terrain material reads them. -//! -//! Layer textures are read via [`LoadContext::read_asset_bytes`] (raw bytes, dependency-tracked) and -//! packed by [`crate::terrain`] β€” the BLP's authored mips verbatim (the C2 fidelity invariant). -//! Materials, the collider, and the placement *models* are built by the app at spawn β€” this loader -//! produces decoded data only. +//! ADT terrain tile loader: the decoded MCNK chunks with a [`ChunkShading`] each, the tile's three +//! `texture_2d_array`s (ground layers, per-chunk alpha, per-chunk MCSH shadow) and the raw doodad +//! and WMO placements. It builds no meshes: a tile's chunk meshes built here would all land in one +//! frame, so the app builds them a few per frame. Every vertex carries its chunk's array indices, +//! `COLOR` the 4 layers and `UV1` the alpha (`.x`) and shadow (`.y`, `-1` none), so a whole tile +//! shares one material. use std::collections::HashMap; @@ -40,17 +22,12 @@ use crate::terrain::{ alpha_array_image, layer_array_image, pack_layers, shadow_array_image, RawLayer, LAYER_TEX_SIZE, }; -/// A loaded ADT terrain tile: the decoded chunks + per-chunk shading indices + the tile's three -/// texture arrays, plus the raw doodad/WMO placements (which the spawn system resolves to -/// `M2Model`/`WmoModel` handles). Cell meshes are built by the app, per chunk, via -/// [`chunk_to_mesh`] β€” see the module doc for why the loader ships none. +/// A loaded ADT tile; the app builds its meshes with [`chunk_to_mesh`]. #[derive(Asset, TypePath)] pub struct AdtTile { - /// The splat-array indices for each entry of `chunks` (index-parallel; a hole-emptied chunk - /// carries the default, which [`chunk_to_mesh`] never reads because it returns `None` first). - /// Resolved here because only the loader knows the arrays' layout β€” it packs them. + /// The array indices of each entry of `chunks`; a hole-emptied chunk carries the default. pub shading: Vec, - /// `texture_2d_array` of the tile's ground textures (the C2-faithful authored mip chains). + /// `texture_2d_array` of the tile's ground textures, authored mips verbatim. pub layer_array: Handle, /// `texture_2d_array` of per-chunk alpha (blend-weight) maps. pub alpha_array: Handle, @@ -60,25 +37,20 @@ pub struct AdtTile { pub doodads: Vec, /// WMO building placements (raw WoW coords). pub wmos: Vec, - /// The tile's decoded per-MCNK chunks β€” the source the arrays were built from and the cell - /// meshes are built from ([`chunk_to_mesh`]), kept resident so the app can derive everything - /// else it owes the tile: the terrain collider, the MCLQ liquid surfaces (each - /// `ChunkMesh.liquid`) and the ground-clutter scatter (per-chunk layers/normals/MCSH + the - /// app-side ground-effect catalog). Bounded by the loaded-tile count; the heaviest field on the - /// asset, carried because clutter/liquid are app concerns (the loader stays catalog-independent). + /// The decoded MCNK chunks, kept resident: the app derives the collider, the MCLQ liquids and + /// the ground clutter from them. pub chunks: Vec, } -/// One drawn chunk's resolved splat indices β€” everything [`chunk_to_mesh`] needs beyond the -/// decoded [`ChunkMesh`] itself. Uniform across the chunk's own vertices by construction. +/// One drawn chunk's array indices, uniform over its vertices. #[derive(Clone, Copy)] pub struct ChunkShading { - /// The chunk's up-to-4 layer-array indices (vertex `COLOR`; unused slots repeat slot 0, whose - /// alpha weight is 0, so they never show). + /// Up to 4 layer-array indices (vertex `COLOR`); an unused slot repeats slot 0 and never shows, + /// as its alpha weight is 0. pub layers: [u32; 4], - /// The chunk's alpha-array index (vertex `UV1.x`; 0 β€” a weight-0 slot β€” when it has no map). + /// The alpha-array index (vertex `UV1.x`); 0 when the chunk has no map. pub alpha: u32, - /// The chunk's shadow-array index (vertex `UV1.y`; `-1.0` = no MCSH shadow map). + /// The shadow-array index (vertex `UV1.y`); `-1.0` when there is no MCSH map. pub shadow: f32, } @@ -92,33 +64,17 @@ impl Default for ChunkShading { } } -/// Build one drawn MCNK chunk's render mesh (Bevy space, absolute world coords) β€” called by the -/// app a few cells per frame, never by the loader (see the module doc: a loader-built set lands -/// atomically, and the landing IS the B181 first-contact spike). -/// -/// **Per chunk, not per tile, because the cull's unit is the chunk** (decision 0780): the -/// exterior-scene cull tests one AABB per drawn object, and the reference's object there is the -/// 33.333 yd cell (`0x683bf0`), never the 533 yd tile β€” a slab the camera stands on intersects -/// every portal window, so it could never be hidden from inside a building. Nothing is duplicated -/// by the split: the merged form never shared a vertex across a chunk boundary either. -/// -/// `RenderAssetUsages::RENDER_WORLD`: the render world takes the buffers on extract β€” no clone on -/// landing, no resident main-world copy per cell. Nothing reads the *mesh* main-side: the -/// collider, liquid, and clutter all derive from the decoded [`AdtTile::chunks`]. The one thing -/// the main world does need from it β€” the cell's `Aabb`, which the exterior cull FAILS OPEN -/// without β€” the caller takes via `Mesh::compute_aabb` before handing the mesh over. -/// -/// Returns `None` for a chunk the hole mask emptied (it draws nothing: no mesh, no entity). +/// One drawn MCNK chunk's mesh in Bevy space and absolute world coords; `None` for a chunk the +/// hole mask emptied. The reference's exterior cull tests one AABB per 33.333 yd chunk +/// (`0x683bf0`). The mesh is `RENDER_WORLD` only, so the caller takes its `Aabb` first +/// (`Mesh::compute_aabb`): the exterior cull fails open without one. pub fn chunk_to_mesh(chunk: &ChunkMesh, shading: &ChunkShading) -> Option { chunks_to_mesh(&[(chunk, shading)]) } -/// [`chunk_to_mesh`] over several chunks of ONE tile as one mesh β€” the terrain CELL (decision -/// 1944): every per-chunk fact the shader reads (the layer indices, the alpha/shadow slot) is -/// already baked per vertex, and a tile's chunks share its material, so concatenating them -/// changes nothing a pixel can see and turns sixteen draws into one. Positions are absolute, -/// so no per-chunk transform is folded. A hole-emptied chunk contributes nothing; a cell of -/// nothing but holes is `None`. +/// [`chunk_to_mesh`] over several chunks of one tile as one mesh, the terrain cell: the indices +/// the shader reads are per vertex and the tile shares one material, so the merge draws the same +/// pixels in one draw. `None` when every chunk is a hole. pub fn chunks_to_mesh(parts: &[(&ChunkMesh, &ChunkShading)]) -> Option { let live: Vec<&(&ChunkMesh, &ChunkShading)> = parts.iter().filter(|(c, _)| c.indices.len() >= 3).collect(); @@ -169,7 +125,7 @@ pub fn chunks_to_mesh(parts: &[(&ChunkMesh, &ChunkShading)]) -> Option { Some(mesh) } -/// Bevy [`AssetLoader`] decoding a vanilla `*.adt` tile β†’ [`AdtTile`]. +/// Loads a `*.adt` tile into an [`AdtTile`]. #[derive(Default, TypePath)] pub struct AdtLoader; @@ -189,10 +145,8 @@ impl AssetLoader for AdtLoader { let to_io = |e: anyhow::Error| std::io::Error::other(format!("{e:#}")); let tile = adt_to_tile_mesh(&bytes).map_err(to_io)?; - // Layer array: a solid-green fallback at index 0, then each unique referenced layer texture. - // The layers are collected in their **authored** form and packed only once the tile is fully - // read β€” one `texture_2d_array` has one format, so whether this tile's DXT blocks go up - // untouched is a question about the whole set (`terrain::pack_layers`, decision 1646). + // Layer array: a solid-green fallback at index 0, then each unique layer texture, packed + // once the whole tile is read. let mut layers: Vec = Vec::new(); let mut layer_index: HashMap = HashMap::new(); @@ -201,21 +155,14 @@ impl AssetLoader for AdtLoader { let mut shadow_buf: Vec = Vec::new(); let mut shadow_count = 0u32; - // One shading entry per chunk (holes get the default). COLOR carries the chunk's 4 layer - // indices, UV1 its alpha/shadow β€” uniform over the chunk's own vertices, which is what - // keeps ONE material per tile. The MESH built from these lands later, app-side and paced - // (`chunk_to_mesh`); only the array-index resolution belongs here, with the arrays. let mut shading: Vec = Vec::with_capacity(tile.chunks.len()); for chunk in tile.chunks.iter() { - // A chunk the hole mask emptied draws nothing: no mesh, no entity, and no alpha/shadow - // array slot either (the indices below are running counters, so skipping here is free). + // A hole-emptied chunk takes no alpha or shadow slot either. if chunk.indices.len() < 3 { shading.push(ChunkShading::default()); continue; } - // Resolve up to 4 layer textures to array indices (appending new ones). Missing slots - // reuse layer 0 (its alpha weight is 0, so it never shows). let mut li = [0u32; 4]; for (slot, name) in chunk.layer_textures.iter().take(4).enumerate() { let key = normalize_path(name); @@ -270,10 +217,7 @@ impl AssetLoader for AdtLoader { let layer_count = layers.len() as u32 + 1; let packed = pack_layers([107, 133, 82, 0], layers); - // Which lane this tile took, and what it cost. Per-tile rather than aggregated because the - // question after a perf report is always "which tiles fell back, and where am I standing" β€” - // and one greppable format name per tile answers it from a player's log with no machinery - // (`grep -c Bc2RgbaUnorm` against `grep -c Rgba8Unorm`) (1646). + // Per tile, so a log shows which tiles fell back to the decoded format. debug!( "adt {}: layer array {layer_count} x {:?}, {} KiB", ctx.path(), @@ -300,13 +244,8 @@ impl AssetLoader for AdtLoader { } } -/// Read a layer texture in its authored form. Prefers the `_s` specular variant (sheen mask in -/// alpha); else falls back to the base BLP, flagged `matte` so no sheen rides it. `key` is a -/// normalized internal path. -/// -/// **Native, not decoded**: the blocks are kept here so [`pack_layers`] still has the choice. A -/// Raw1/Raw3 BLP has no block form and comes back decoded anyway, which is why the caller never has -/// to ask β€” it is [`benilla_formats::BlpMipChain::texels`] that says what arrived. +/// Read a layer texture with its blocks intact, preferring the `_s` specular variant (sheen mask in +/// alpha); the base BLP alone is flagged `matte`. async fn read_layer(ctx: &mut LoadContext<'_>, key: &str) -> Option { if let Some(spec) = key.strip_suffix(".blp").map(|stem| format!("{stem}_s.blp")) { if let Ok(bytes) = ctx.read_asset_bytes(mpq_url(&spec)).await { @@ -323,17 +262,17 @@ async fn read_layer(ctx: &mut LoadContext<'_>, key: &str) -> Option { Some(RawLayer { chain, matte: true }) } -/// An internal (`\`/lowercase) path β†’ an `mpq://` URL. +/// An internal path as an `mpq://` URL. fn mpq_url(key: &str) -> String { format!("mpq://{}", key.replace('\\', "/")) } -/// Normalize an internal asset path so case/slash variants share one layer-array index. +/// Normalize an internal asset path so case and slash variants share one layer-array index. fn normalize_path(path: &str) -> String { path.replace('/', "\\").to_ascii_lowercase() } -/// Flat per-chunk normals for the rare chunk lacking authored MCNR (most carry it). +/// Normals for the rare chunk without authored MCNR. fn computed_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> { let mut acc = vec![Vec3::ZERO; positions.len()]; for tri in indices.as_chunks::<3>().0 { diff --git a/crates/benilla-assets/src/anim_rng.rs b/crates/benilla-assets/src/anim_rng.rs index 70c54866..77bf6f13 100644 --- a/crates/benilla-assets/src/anim_rng.rs +++ b/crates/benilla-assets/src/anim_rng.rs @@ -1,84 +1,48 @@ -//! The client's **one** `rand()` stream β€” the MSVC-CRT LCG at `0x7400e5`. +//! The client's one `rand()` stream, the MSVC CRT LCG at `0x7400e5`, in this leaf crate because +//! the engine's placed doodads and the game's creature, GameObject and portrait-booth arms all +//! draw from it. The reference keeps one cell per thread (`[ptd+0x14]`) and runs every arm on the +//! main thread (the frame loop `0x420c00`); that interleaving de-syncs a stand of identical props, +//! so it must stay one stream. //! -//! **It lives down here, below both sides of decision 1160's line, because both sides draw from -//! it** β€” `trace`'s placement argument, verbatim, for the same reason. The engine's placed-doodad -//! host and the game's creature, GameObject and portrait-booth arms all roll a variation off this -//! sequence, and in the reference they roll it off the *same cell*: `rand` reads and rewrites -//! `[ptd+0x14]`, one per thread, and every animation arm in the client runs on the primary thread -//! (`_initterm` is pre-`WinMain`; the frame loop `0x420c00` is inline, not spawned). A leaf with no -//! dependencies can be reached by everything; an owner at the top of the stack cannot be reached by -//! the engine at all. -//! -//! **Why one stream and not one per consumer** (decision 0768, and 2301 which found the drift): -//! a shared sequence is what de-syncs a stand of identical props. Not a per-placement seed β€” just -//! consecutive draws. benilla had four independent streams for a while, three of them seeded 0 -//! against the fourth's 1, while all four docstrings claimed to be this one; interleaving is the -//! whole mechanism, so splitting it is not a harmless refactor. -//! -//! **The seed is the reference's, and it is not 1.** `1` is real β€” the CRT's per-thread default at -//! `_initptd 0x40aca8`, which is what [`AnimRng::default`] returns β€” but the client overwrites it -//! during CRT static initialization, before `WinMain`: `_initterm` slot **3691** (`.data -//! 0x82a9ac` β†’ the `_dynamic_initializer_for_*` trampoline `0x5b7ff0` β†’ `0x5b8000`) calls -//! `0x5d1c70`, which calls `srand(GetTickCount())` at `0x5d1c8b`. So the reference's variation -//! sequence differs on every run, and [`AnimRng::seed_for_session`] is where benilla says the same -//! thing. -//! -//! **Sequence parity with the reference is not achievable and is not the goal.** Where the stream -//! stands at any given arm depends on every intervening draw β€” the noise table `Model2::Initialize` -//! builds, the sky LUT, every `CParticleEmitter2` constructor, and FrameXML's own `random()` (bound -//! as a bare global by the embedded `compat.lua`, and called in ordinary play). What benilla -//! reproduces is the *structure*: one stream, wall-clock seeded, shared, non-repeating across runs. +//! The reference seeds it with `srand(GetTickCount())` in CRT static init (`0x5d1c70`, the call +//! at `0x5d1c8b`), so each run rolls differently. Its exact sequence is out of reach: every other +//! draw moves the stream, FrameXML's `random()` and each particle emitter among them. use bevy::prelude::Resource; -/// The shared `rand()` state β€” see the module note. Seeded by [`Self::seed_for_session`] at -/// startup; [`Default`] is the CRT's own pre-`srand` value so a test or a headless build that -/// never seeds still walks a defined sequence. +/// The shared `rand()` state, seeded once per session by [`Self::seed_for_session`]. #[derive(Resource)] pub struct AnimRng(u32); impl Default for AnimRng { fn default() -> Self { - Self(1) // `_initptd 0x40aca8` β€” the CRT's per-thread default, before `srand` runs + Self(1) // `_initptd 0x40aca8`: the CRT's per-thread default, before `srand` runs } } impl AnimRng { - /// One `rand()` draw: `seed = seedΒ·214013 + 2531011`, result `(seed >> 16) & 0x7fff` β€” the - /// closed range `[0, 32767]`, 32768 equiprobable outcomes, taken with no scale, mask, modulo - /// or divide (`0x7400ed`-`0x740101`). + /// One `rand()` draw, in `[0, 32767]` (`0x7400ed`-`0x740101`). pub fn draw(&mut self) -> u16 { self.0 = self.0.wrapping_mul(214_013).wrapping_add(2_531_011); ((self.0 >> 16) & 0x7fff) as u16 } - /// The play-window's **replay count** `R = max(1, min + ((rand()Β·(maxβˆ’min)) >> 15))` β€” the - /// reference's `windowHi = now + spanΒ·R` (`0x712692`-`0x7126cd`). `replay = (0, 0)`, the - /// overwhelming majority, always yields `R = 1`: one loop per window, so the variation - /// re-rolls every single pass. - /// - /// The draw is taken **unconditionally**, even when it cannot change the answer: it is one - /// sub-expression of the reference's formula, so the shared stream has to advance the same way - /// whatever the span. A caller that skips it de-phases every later draw in the process. + /// The play window's replay count `R`, as in the reference's `windowHi = now + spanΒ·R` + /// (`0x712692`-`0x7126cd`). It draws even when the range cannot change `R`, as the reference + /// does; skipping the draw de-phases every later roll. pub fn replay_count(&mut self, replay: (u32, u32)) -> u32 { let (lo, hi) = replay; let r = lo + ((u64::from(self.draw()) * u64::from(hi.saturating_sub(lo))) >> 15) as u32; r.max(1) } - /// The reference's `srand(GetTickCount())` from CRT static init (module note) β€” a **wall-clock - /// seed, once per process**, so a fresh session does not replay the last one's variations. - /// - /// `deterministic` is the capture carve-out and the only place benilla differs: a golden frame - /// has to be reproducible, so a capture run keeps the CRT's own pre-`srand` value. That is the - /// same trade every other sim in the harness makes, at the one site that knows a capture is - /// running rather than smeared through the lanes that draw. + /// The reference's `srand(GetTickCount())`: a wall-clock seed, once per process. Deviation: a + /// capture run (`deterministic`) keeps the default seed, so its frames reproduce. pub fn seed_for_session(&mut self, deterministic: bool) { if deterministic { return; } - // `GetTickCount` is milliseconds since boot; the wall clock's sub-second span is the same - // kind of number and is what a non-Windows host can offer. + // `GetTickCount` counts milliseconds since boot; wall-clock milliseconds stand in for it. self.0 = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(1, |d| d.as_millis() as u32); @@ -89,20 +53,15 @@ impl AnimRng { mod tests { use super::*; - /// The LCG itself, against the bytes at `0x7400ed`-`0x740101` β€” and against the CRT's own - /// documented sequence from seed 1, which is the one value a reader can check anywhere. + /// Seed 1 gives the MSVC CRT's documented sequence. #[test] fn the_draw_is_the_crt_lcg() { let mut rng = AnimRng::default(); let first: Vec = (0..6).map(|_| rng.draw()).collect(); assert_eq!(first, vec![41, 18_467, 6_334, 26_500, 19_169, 15_724]); - // Every draw is inside the closed range the `and eax,0x7fff` produces. assert!(first.iter().all(|&r| r <= 0x7fff)); } - /// `(0, 0)` β€” the overwhelming majority of shipped sequences β€” is one pass, and it still costs - /// a draw. The second half is the part a "tidy" rewrite breaks: skipping the draw when it - /// cannot change the answer de-phases every later roll in the process. #[test] fn a_zero_replay_pair_is_one_pass_and_still_advances_the_stream() { let mut rng = AnimRng::default(); @@ -116,7 +75,6 @@ mod tests { ); } - /// The window scales with the range, and never below one pass. #[test] fn a_replay_range_scales_the_window() { let mut rng = AnimRng::default(); @@ -130,8 +88,6 @@ mod tests { assert_eq!(rng.replay_count((2, 1)), 2); } - /// A seeded session does not replay the default sequence β€” the whole point of - /// `srand(GetTickCount())` β€” and a capture run does. #[test] fn a_capture_keeps_the_fixed_seed_and_a_session_does_not() { let mut capture = AnimRng::default(); diff --git a/crates/benilla-assets/src/blp.rs b/crates/benilla-assets/src/blp.rs index 199c33d9..cce75de5 100644 --- a/crates/benilla-assets/src/blp.rs +++ b/crates/benilla-assets/src/blp.rs @@ -1,17 +1,4 @@ -//! BLP β†’ Bevy [`Image`] loader. -//! -//! Decodes a WoW BLP texture into an `Image` in one of several variants (selected by loader -//! settings): -//! - [`BlpVariant::WorldArt`] β€” tiling world/model albedo: `Rgba8Unorm` (the RE'd gamma-space -//! invariant β€” the GPU does *not* linearize albedo on sample, so shader math stays in WoW's -//! gamma/byte space), the BLP's **authored** mip pyramid used verbatim (the real 1.12 client cannot -//! regenerate mips β€” it uploads the stored ones), repeat + the process filter policy -//! ([`crate::tex_filter`]). -//! - [`BlpVariant::Sprite`] β€” emissive billboards (sun/moon discs): `Rgba8UnormSrgb`, clamp, mip 0. -//! - [`BlpVariant::Cursor`] β€” the OS cursor image: `Rgba8UnormSrgb`, single mip. -//! -//! Fidelity rationale (authored mips, the gamma-space `Unorm` invariant) is anchored in the -//! reference client; here we only build the GPU resource. +//! BLP to Bevy [`Image`] loader, in the [`BlpVariant`] the load settings choose. use bevy::asset::io::Reader; use bevy::asset::{AssetLoader, LoadContext, RenderAssetUsages}; @@ -24,32 +11,25 @@ use benilla_formats::{blp_bytes_to_native_chain, blp_to_rgba}; use crate::gpu_blp::{for_upload, UploadChain}; -/// Which on-GPU form a BLP decodes to. See the module docs. +/// Which on-GPU form a BLP decodes to. #[derive(Default, Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] pub enum BlpVariant { - /// Tiling world/model albedo β€” `Rgba8Unorm`, authored mips, repeat + the filter policy. The default. + /// World and model albedo, the default: non-sRGB, so shader math stays in gamma bytes; the + /// authored mips as stored (the 1.12 client never regenerates them); the process filter policy. #[default] WorldArt, - /// Emissive billboard (celestial disc) β€” `Rgba8UnormSrgb`, clamp, mip 0 only. + /// Emissive billboard (the sun and moon discs): `Rgba8UnormSrgb`, clamp, mip 0 only. Sprite, - /// Gamma-lane effect sprite (rain splashes, weather mist) β€” `Rgba8Unorm` like - /// [`Self::WorldArt`] but **mip 0 only** + clamp: these draw as world-space quads that are - /// magnified far more often than minified, and their thin cut-out arms collapse to near-zero - /// alpha under the authored mip chain. + /// Gamma-lane effect sprite (rain splashes, weather mist): `Rgba8Unorm`, clamp, mip 0 only, as + /// these quads are mostly magnified and their thin cut-out arms fade out down the mips. Effect, - /// A **point sprite** β€” [`Self::Effect`]'s clamp and gamma lane, but with the BLP's - /// **authored mip pyramid** kept (trilinear, no anisotropy). The snow flake's lane: the - /// reference draws it through `glDrawArrays(GL_POINTS)` with `GL_COORD_REPLACE`, so the whole - /// texture maps across a sprite that is **14 px at the eye and 1 px past 46 yd** β€” a 4.5×–64Γ— - /// *minification* of a 64Γ—64 texture, which is why the asset ships 7 mip levels. Mip-0-only - /// here is not "crisp", it is aliased: a 1-px flake samples one arbitrary texel of a dendrite - /// and the field becomes flickering speckle. - /// (The point-sprite leg `0x678610`; `GL_COORD_REPLACE` at device init, `0x59cf30`–`0x59cf58`.) + /// The snow flake: [`Self::Effect`] with the authored mips kept. The reference draws it as + /// `GL_POINTS` with `GL_COORD_REPLACE` (`0x678610`; device init `0x59cf30`-`0x59cf58`), 14 px + /// at the eye and 1 px past 46 yd, where mip 0 alone aliases into speckle. PointSprite, - /// A **minimap tile** (ADT `map_` or a WMO group's interior tile) β€” clamp, mip 0, linear, - /// and filtered in **gamma space**, the reference's `GL_SKIP_DECODE_EXT`. See [`map_tile_image`]. + /// A minimap tile (ADT `map_` or a WMO group's interior tile): [`map_tile_image`]. MapTile, - /// OS cursor image β€” `Rgba8UnormSrgb`, single mip. + /// OS cursor image: `Rgba8UnormSrgb`, single mip. Cursor, } @@ -59,7 +39,7 @@ pub struct BlpLoaderSettings { pub variant: BlpVariant, } -/// Bevy [`AssetLoader`] decoding `*.blp` β†’ [`Image`]. +/// Bevy [`AssetLoader`] decoding `*.blp` to [`Image`]. #[derive(Default, TypePath)] pub struct BlpImageLoader; @@ -79,11 +59,9 @@ impl AssetLoader for BlpImageLoader { let to_io = |e: anyhow::Error| std::io::Error::other(format!("{e:#}")); Ok(match settings.variant { BlpVariant::WorldArt => world_art_image( - // The blocks go straight to the GPU where it can take them (decision 1626); - // `for_upload` decodes only when it cannot, and hands back the matching format. for_upload(blp_bytes_to_native_chain(&bytes).map_err(to_io)?), - // The address mode rides the asset path (`crate::texture_url`, decision 0763): the - // sampler is a property of this upload, and one `.blp` legitimately has two. + // The address mode rides the asset path (`crate::texture_url`): one `.blp` can be + // uploaded under two samplers. crate::sampler_mode_of(&ctx.path().to_string()), ), BlpVariant::Sprite => { @@ -113,13 +91,9 @@ impl AssetLoader for BlpImageLoader { } } -/// The gamma-byte lane's uncompressed format β€” non-sRGB, so the GPU does not linearize on sample -/// and shader math stays in WoW's byte space (the RE'd faithful invariant). Every decoded lane -/// below uploads as this. +/// The gamma lanes' uncompressed format: non-sRGB, so the GPU does not linearize on sample. const GAMMA_BYTES: TextureFormat = TextureFormat::Rgba8Unorm; -/// World/model albedo: the BLP's authored mip pyramid laid in verbatim, repeat + the process -/// filter policy ([`crate::tex_filter`]). (Port of the old `repeat_texture_authored`.) fn world_art_image(upload: UploadChain, wrap: (bool, bool)) -> Image { let UploadChain { chain, format } = upload; let levels = chain.mips.len() as u32; @@ -127,9 +101,8 @@ fn world_art_image(upload: UploadChain, wrap: (bool, bool)) -> Image { for mip in &chain.mips { data.extend_from_slice(mip); } - // `new_uninit` rather than `new`: `Image::new`'s length assert is written for uncompressed - // formats (it skips itself when `pixel_size()` errors, which is every BC format), and feeding - // it mip 0 just to satisfy that assert cost a clone of the largest level on every load. + // `new_uninit`: `Image::new` would clone mip 0 only to feed a length assert meant for + // uncompressed formats. let mut image = Image::new_uninit( Extent3d { width: chain.width, @@ -138,15 +111,8 @@ fn world_art_image(upload: UploadChain, wrap: (bool, bool)) -> Image { }, TextureDimension::D2, format, - // `RENDER_WORLD`: the render world TAKES the mip chain on extract instead of cloning it and - // leaving a main-world copy resident for the asset's life (bevy_render `render_asset.rs` β€” - // the `asset_usage == RENDER_WORLD` branch moves, every other usage clones). This is the - // variant carrying every terrain layer, model albedo and WMO albedo in the game, so the - // default usage was paying a full mip-chain memcpy on every landing frame and holding a - // second copy of the whole world's art in system RAM. Nothing reads a world texture - // main-side (the minimap, colliders and clutter all derive from other sources) β€” same - // reasoning, and now the same usage, as the terrain tile arrays (`terrain.rs`) and the - // Sprite/Effect/PointSprite variants below. + // The render world takes the chain on extract rather than cloning it and keeping a + // main-world copy (bevy_render `render_asset.rs`); nothing reads world art main-side. RenderAssetUsages::RENDER_WORLD, ); image.data = Some(data); @@ -158,10 +124,8 @@ fn world_art_image(upload: UploadChain, wrap: (bool, bool)) -> Image { ImageAddressMode::ClampToEdge } }; - // The mip filter and the anisotropy are NOT this lane's to choose: the reference forces both - // from two process globals at every `TextureCreate` (`0x449ae0`), and what a virgin install - // gets from `hwDetect` is trilinear with anisotropy OFF. See [`crate::tex_filter`] β€” this used - // to read `Linear` / `8`, which is mode 5 shipped as if it were the default. + // The reference's terrain, WMO and M2 textures take the mip filter and anisotropy from the + // process policy (`0x449ae0`, `crate::tex_filter`). let filter = crate::tex_filter::tex_filter(); image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { address_mode_u: mode(wrap.0), @@ -175,8 +139,7 @@ fn world_art_image(upload: UploadChain, wrap: (bool, bool)) -> Image { image } -/// Emissive billboard (celestial disc): sRGB so an unlit pass round-trips the authored gamma bytes to -/// screen; clamp + linear, mip 0 only (the disc maps to one quad and never tiles/minifies far). +/// Emissive billboard: sRGB, so an unlit pass returns the authored gamma bytes to screen. fn sprite_image(width: u32, height: u32, rgba: Vec) -> Image { let mut image = Image::new( Extent3d { @@ -199,29 +162,14 @@ fn sprite_image(width: u32, height: u32, rgba: Vec) -> Image { image } -/// A **minimap tile** β€” clamp, mip 0, **LINEAR**, and filtered in **GAMMA** space. -/// -/// The reference binds one sampler object for every minimap tile it draws, interior and outdoor -/// alike, and the trace reads it whole: `CLAMP_TO_EDGE` both axes, `MAG_FILTER = MIN_FILTER = -/// GL_LINEAR` with no mip term, LOD bias 0, anisotropy 1, and β€” the part that is not the default β€” -/// **`GL_TEXTURE_SRGB_DECODE_EXT = GL_SKIP_DECODE_EXT`**, plus `GL_TEXTURE_MAX_LEVEL = 0` per tile. -/// (A reference capture, matching the binary derivation at the tile loader `0x6d9ed0`; a positive -/// control in the same trace emits `GL_NEAREST` for a different sampler, so the layer would have -/// shown point sampling had the client asked for it.) -/// -/// SKIP_DECODE is the whole reason this is not [`sprite_image`]: it means the hardware **filters the -/// authored bytes**, not their linearisation. An `Rgba8UnormSrgb` upload decodes each texel to -/// linear before the filter weights it, so every blend across an edge lands on a different colour -/// than the reference's β€” darker, because averaging in linear space and re-encoding is darker than -/// averaging the gamma bytes. On a 1-bit-alpha bake whose edges sit against black that is a visible -/// dark fringe at every alpha boundary. So the tile arrives as gamma bytes ([`GAMMA_BYTES`], no -/// decode) and the shader converts AFTER the filter. +/// A minimap tile, sampled as the reference samples every tile (the tile loader `0x6d9ed0`): clamp, +/// `GL_LINEAR` with no mips, anisotropy 1 and `GL_SKIP_DECODE_EXT`, so the hardware filters the +/// gamma bytes. An sRGB upload would darken every alpha edge against black, so the tile uploads as +/// [`GAMMA_BYTES`] and the shader converts after the filter. fn map_tile_image(width: u32, height: u32, rgba: Vec) -> Image { effect_image(width, height, rgba) } -/// Gamma-lane effect sprite: `Rgba8Unorm` (the shader does WoW's byte-space combine), clamp, -/// **mip 0 only** β€” see [`BlpVariant::Effect`]. fn effect_image(width: u32, height: u32, rgba: Vec) -> Image { let mut image = Image::new( Extent3d { @@ -244,9 +192,7 @@ fn effect_image(width: u32, height: u32, rgba: Vec) -> Image { image } -/// Point sprite: [`effect_image`]'s gamma lane and clamp, with the **authored mip pyramid** β€” -/// see [`BlpVariant::PointSprite`]. No anisotropy: a point sprite is always screen-axis-aligned -/// and square, so there is no anisotropic footprint to correct for. +/// No anisotropy: a point sprite is square and screen-aligned, with no anisotropic footprint. fn point_sprite_image(upload: UploadChain) -> Image { let UploadChain { chain, format } = upload; let levels = chain.mips.len() as u32; @@ -271,16 +217,12 @@ fn point_sprite_image(upload: UploadChain) -> Image { address_mode_v: ImageAddressMode::ClampToEdge, mag_filter: ImageFilterMode::Linear, min_filter: ImageFilterMode::Linear, - // A mip chain means the policy owns the mip filter here too β€” the reference's override is - // unconditional across every texture it creates, with no per-class exemption. The aniso - // stays absent by the argument above, and the policy agrees in modes 3 and 4. mipmap_filter: crate::tex_filter::tex_filter().mipmap_filter(), ..Default::default() }); image } -/// OS cursor image: sRGB, single mip (cursors are tiny and need neither a mip chain nor repeat). fn cursor_image(width: u32, height: u32, rgba: Vec) -> Image { Image::new( Extent3d { diff --git a/crates/benilla-assets/src/column_grid.rs b/crates/benilla-assets/src/column_grid.rs index 8fc4c036..719a7b55 100644 --- a/crates/benilla-assets/src/column_grid.rs +++ b/crates/benilla-assets/src/column_grid.rs @@ -1,56 +1,26 @@ -//! The **column index**: which of a face set's triangles can own a vertical (x, y) column. -//! -//! Every position question this client asks a WMO is a column query β€” which room is the camera in -//! (`wmo_portal::seed`), which room is a unit in (`track_unit_interiors`), which floor's baked MOCV -//! lights an entity (`interior::classify_entity_interior`), what the zone text should say. All of -//! them cast straight down and want "the triangles whose XY projection contains this point". -//! -//! Until now the narrow phase for that was **a linear scan of every triangle in every group whose -//! AABB contains the column** (decisions 0330/0364 built the per-group bounds; the group is where -//! the culling stopped). That is fine for a cottage and catastrophic for a dungeon: Blackrock -//! Spire's groups hold ~11–16k faces each, and a vertically stacked spire puts several of them in -//! any given column. Measured in LBRS on 2026-07-27: **~1.2 M triangle tests per frame** to light -//! **37 moving NPCs** β€” ~32k per unit per frame, 11 ms of a 29 ms frame, the second-largest term in -//! the NPC-population collapse (B31/B06 + the BWL/LBRS reports). -//! -//! So each group's faces get a uniform XY grid, built once at load: a column tests one cell's -//! worth of triangles instead of the group's whole face list. -//! -//! **The index never changes a verdict.** It is a pure accelerator with two properties the callers -//! depend on, both pinned by tests: -//! -//! - **Superset** β€” every triangle whose XY projection contains the column is a candidate. A -//! triangle is inserted into every cell its XY AABB touches (inclusive both ends), a query point -//! outside the grid clamps into the edge cell rather than missing, and a triangle whose AABB -//! spans more cells than [`MAX_SPAN_CELLS`] goes in [`ColumnGrid::spanning`], tested always. -//! - **Order** β€” candidates come back in **ascending triangle index**, the order a linear scan -//! visits them, so every tie-break downstream (`down_ray_claim`'s first-wins, `footprint_sample`'s -//! later-wins) resolves exactly as it did before the index existed. +//! The column index: which of a face set's triangles can own a vertical (x, y) column, for the +//! down-cast position queries against a WMO (the camera's room, a unit's room, the floor MOCV that +//! lights an entity). It never changes a verdict: the candidates are a superset of the triangles +//! whose XY box holds the column, in ascending index order, the order a linear scan visits them, +//! so the first-wins and later-wins tie-breaks downstream resolve the same. use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::OnceLock; -/// A triangle whose XY AABB touches more cells than this is not binned at the level being built β€” -/// it is handed to the next level down (the coarse grid), and only a face too large for *that* -/// becomes a [`ColumnGrid::spanning`] always-tested residue. Big floor slabs are the case: at a -/// ~1-triangle-per-cell sizing a 100-yd slab would otherwise be copied into hundreds of cells. +/// A triangle touching more cells than this goes to the next level down, and past the coarse level +/// to [`ColumnGrid::spanning`], so a big floor slab is not copied into hundreds of cells. const MAX_SPAN_CELLS: u32 = 32; -/// Below this many oversized faces, a second grid is not worth its own allocation pair and lookup β€” -/// they stay an always-tested list. Well under the per-query counts 1351 measured (~480), so the -/// pin's groups all take the coarse level. +/// Below this many oversized faces they stay an always-tested list instead of a second grid. const MIN_COARSE_TRIS: usize = 24; -/// Never build cells finer than this (yd) β€” a degenerate face set (all triangles stacked in one -/// spot) would otherwise ask for an unbounded grid. +/// The finest cell, yd, so a face set stacked in one spot cannot ask for an unbounded grid. const MIN_CELL: f32 = 0.25; -/// Cell budget per group: the grid is at most this many cells regardless of face count, so a -/// 500-yd dungeon group costs tens of KB of index, not tens of MB. +/// The most cells one level holds, whatever the face count. const MAX_CELLS: usize = 1 << 16; -/// One uniform XY grid over some set of triangles β€” CSR (`starts`/`items`), so one allocation -/// pair per level rather than a `Vec` per cell. +/// One uniform XY grid in CSR form (`starts`, `items`). #[derive(Debug, Clone)] struct Level { /// Grid origin (the indexed set's XY minimum). @@ -61,69 +31,38 @@ struct Level { ny: u32, /// CSR row offsets into [`Self::items`], length `nx * ny + 1`. starts: Vec, - /// **Global** triangle indices, ascending within each cell. + /// Global triangle indices, ascending within each cell. items: Vec, } -/// A face set's column index: **two grids and a residue**. -/// -/// One grid cannot serve a dungeon group. Sizing cells at ~1 triangle each is right for the ~11–16k -/// small faces, and catastrophic for the handful of 40–100 yd floor slabs sharing the group: a slab -/// would be copied into hundreds of cells, so [`MAX_SPAN_CELLS`] sets it aside instead β€” and -/// "aside" used to mean *tested on every query*. -/// -/// 1351 measured what that cost at the LBRS pin: **99.26 %** of all triangles tested per frame were -/// those set-aside slabs β€” 93,624 of 94,322, against 698 that came from a cell list. The index was -/// accelerating the 0.7 %. -/// -/// So the oversized set gets its own grid, sized from its own extent and its own count (0711's -/// named fix, deferred there and confirmed by 1351). Cells at that level are ~an order of magnitude -/// wider, which is exactly what a slab needs to bin. Whatever is still too big for the coarse level -/// β€” a face spanning the whole group β€” falls to [`Self::spanning`] and keeps the old always-tested -/// behaviour, which is now a residue rather than the main term. -/// -/// **Two levels, not recursion.** A recursive grid would need a boxed iterator per query on the -/// hottest path in the interior lane; two explicit levels keep [`ColumnGrid::candidates`] a -/// three-way merge of three ascending *slices* β€” no allocation, no indirection, and the -/// superset+ascending invariants hold at each level independently. +/// A face set's column index: a fine grid at about one triangle a cell, a coarse grid over the +/// faces too large for it (dungeon floor slabs), and a residue too large for both. #[derive(Debug, Clone)] pub struct ColumnGrid { /// The ~1-triangle-per-cell grid over the ordinary faces. fine: Level, - /// The coarse grid over the faces `fine` set aside. `None` when there were too few of them to - /// be worth a second index β€” then they are all in [`Self::spanning`], as before. + /// The grid over the faces `fine` set aside; `None` when too few, all in [`Self::spanning`]. coarse: Option, - /// Faces too large to bin at **either** level, ascending β€” candidates for every query. + /// Faces too large for either level, ascending: candidates for every query. spanning: Vec, } -/// `WOW_COLUMN_COST=1`: how many triangles a column query actually tests, split by **source** β€” -/// `binned` (the fine level's cell), `coarse` (the coarse level's cell), and `spanning` (the -/// residue too large for either, tested on every query). -/// -/// This is 0711's residual, and the counter that confirmed it. That hunt cut the lane from ~1.2 M -/// triangle tests/frame to an 88 k remainder, wrote down what the remainder was β€” big floor slabs, -/// too large to bin, merged into every column query β€” named the fix, and left it. 1351 measured the -/// remainder three weeks later: **99.26 %** of tested triangles were that set, and `resolve_ms` was -/// still 1.14, byte for byte the number 0711 recorded, because nothing had touched the file. -/// -/// The counter stays as the **regression watch** on the two-level index: `spanning_pct` is now the -/// residue's share and should read near zero. If it climbs, some face set has a slab too big for -/// even the coarse level and the lane is quietly reverting to 0711's shape. +/// `WOW_COLUMN_COST=1`: triangles tested per column query by source (fine cell, coarse cell, +/// residue). The residue's share should read near zero; a climb means a slab too big for the +/// coarse level. static COLUMN_QUERIES: AtomicU64 = AtomicU64::new(0); static COLUMN_BINNED: AtomicU64 = AtomicU64::new(0); static COLUMN_COARSE: AtomicU64 = AtomicU64::new(0); static COLUMN_SPANNING: AtomicU64 = AtomicU64::new(0); -/// Whether the column-query meter is armed (`WOW_COLUMN_COST`). Read once, then a relaxed bool. +/// Whether the column-query meter is armed (`WOW_COLUMN_COST`), read once. pub fn column_cost_enabled() -> bool { static ON: OnceLock = OnceLock::new(); *ON.get_or_init(|| std::env::var_os("WOW_COLUMN_COST").is_some()) } -/// Take and zero the column-query counters: `(queries, binned, coarse, spanning)` triangles tested. -/// -/// One caller per frame β€” the meter that prints them β€” so the numbers are always per-frame. +/// Take and zero the counters, `(queries, binned, coarse, spanning)`; one caller a frame keeps them +/// per frame. pub fn take_column_query_stats() -> (u64, u64, u64, u64) { ( COLUMN_QUERIES.swap(0, Ordering::Relaxed), @@ -134,11 +73,8 @@ pub fn take_column_query_stats() -> (u64, u64, u64, u64) { } impl Level { - /// Index `ids` (ascending, global triangle indices) into one uniform grid. - /// - /// Returns the level plus the **overflow**: the ids whose XY AABB spans more than - /// [`MAX_SPAN_CELLS`] cells at this level's sizing, ascending, for the next level down. - /// `None` when the set has no finite extent to grid. + /// Index ascending `ids` into one grid, also returning, ascending, the ids that span more than + /// [`MAX_SPAN_CELLS`] cells, for the next level; `None` when the set has no finite extent. fn build( ids: &[u32], xy_aabb: &impl Fn(usize) -> ([f32; 2], [f32; 2]), @@ -155,10 +91,8 @@ impl Level { if !w.is_finite() || !h.is_finite() || (w <= 0.0 && h <= 0.0) { return None; } - // Aim for ~one triangle per cell, then clamp both ways: never finer than MIN_CELL, never - // more than MAX_CELLS cells. Sizing from THIS set's own count and extent is what makes the - // coarse level coarse: a few hundred slabs over the same group span give cells ~an order of - // magnitude wider than the fine level's, which is what lets a slab bin at all. + // About one triangle a cell, clamped by MIN_CELL and MAX_CELLS. Sizing from this set's own + // count and extent is what makes the coarse level coarse enough for a slab to bin. let target_cells = ids.len().clamp(1, MAX_CELLS) as f32; let area = (w * h).max(f32::MIN_POSITIVE); let mut cell = (area / target_cells).sqrt().max(MIN_CELL); @@ -170,7 +104,7 @@ impl Level { let inv_cell = 1.0 / cell; let cells = nx as usize * ny as usize; - // Pass 1 β€” count per cell, and set the oversized triangles aside for the next level. + // Pass 1: count per cell, and set the oversized triangles aside. let mut counts = vec![0u32; cells + 1]; let mut overflow = Vec::new(); let span_of = |i: u32| -> Option<(u32, u32, u32, u32)> { @@ -203,8 +137,7 @@ impl Level { } starts[cells] = acc; - // Pass 2 β€” fill. `ids` is ascending, so each cell fills in ascending triangle order, which - // is the order guarantee the tie-breaks downstream rest on. + // Pass 2: fill. `ids` is ascending, so each cell is too, which the tie-breaks rest on. let mut items = vec![0u32; acc as usize]; let mut cursor = starts.clone(); for &i in ids { @@ -231,7 +164,7 @@ impl Level { )) } - /// This level's own cell list for the column at `(x, y)` β€” ascending, possibly empty. + /// This level's cell list for the column at `(x, y)`, ascending. fn cell(&self, x: f32, y: f32) -> &[u32] { let cx = cell_of(x, self.min[0], self.inv_cell, self.nx); let cy = cell_of(y, self.min[1], self.inv_cell, self.ny); @@ -241,12 +174,9 @@ impl Level { } impl ColumnGrid { - /// Build the index for `count` triangles, given each one's XY AABB by index. - /// - /// Returns `None` when there is nothing to accelerate β€” an empty face set, or one small enough - /// that a linear scan beats the indirection (the caller then keeps its plain scan). + /// Index `count` triangles by their XY AABBs; `None` for a set small enough that the caller's + /// linear scan is cheaper. pub fn build(count: usize, xy_aabb: impl Fn(usize) -> ([f32; 2], [f32; 2])) -> Option { - // Below this a linear scan is the cheaper answer and the grid is pure overhead. const MIN_TRIS: usize = 64; if count < MIN_TRIS { return None; @@ -254,9 +184,6 @@ impl ColumnGrid { let all: Vec = (0..count as u32).collect(); let (fine, oversized) = Level::build(&all, &xy_aabb)?; - // The oversized set gets its own grid when there are enough of them to pay for one. What - // *that* level cannot bin stays an always-tested residue β€” the old behaviour, now applied - // to a set 1351 predicts is tiny rather than to 99 % of the traffic. let (coarse, spanning) = if oversized.len() >= MIN_COARSE_TRIS { match Level::build(&oversized, &xy_aabb) { Some((level, residue)) => (Some(level), residue), @@ -273,7 +200,7 @@ impl ColumnGrid { }) } - /// The triangles that can own the column at `(x, y)` β€” a superset, in ascending index order. + /// The triangles that can own the column at `(x, y)`: a superset, ascending. pub fn candidates(&self, x: f32, y: f32) -> ColumnCandidates<'_> { let binned = self.fine.cell(x, y); let coarse = self.coarse.as_ref().map_or(&[][..], |c| c.cell(x, y)); @@ -290,7 +217,7 @@ impl ColumnGrid { } } - /// Index size in triangle slots β€” the memory the acceleration costs, for the load-time log. + /// Index size in triangle slots, for the load-time log. pub fn slots(&self) -> usize { self.fine.items.len() + self.coarse.as_ref().map_or(0, |c| c.items.len()) @@ -298,13 +225,8 @@ impl ColumnGrid { } } -/// Ascending merge of the three sources a column can draw from: the fine level's cell, the coarse -/// level's cell, and the always-tested residue. All three are ascending, so this is a three-pointer -/// walk and the output order matches a linear scan exactly β€” which is the guarantee every downstream -/// tie-break (`down_ray_claim`'s first-wins, `footprint_sample`'s later-wins) rests on. -/// -/// A triangle appears in exactly one source by construction: a face is binned at the fine level, or -/// handed down to the coarse level, or in the residue β€” never two. So the merge cannot duplicate. +/// Ascending merge of the fine cell, the coarse cell and the residue. A face is in exactly one of +/// them, so the merge never duplicates, and its order is a linear scan's. pub struct ColumnCandidates<'a> { binned: &'a [u32], coarse: &'a [u32], @@ -344,9 +266,8 @@ fn dims(w: f32, h: f32, cell: f32) -> (u32, u32) { (n(w), n(h)) } -/// Cell coordinate of a world value, clamped into the grid β€” a column outside the face set's -/// bounds lands in the edge cell rather than missing, which keeps the candidate set a superset -/// under float error at the boundary. +/// A value's cell, clamped into the grid so a column just outside the bounds (float error at the +/// edge) still lands in the edge cell. fn cell_of(v: f32, min: f32, inv_cell: f32, n: u32) -> u32 { let i = ((v - min) * inv_cell).floor(); if i < 0.0 { @@ -360,7 +281,6 @@ fn cell_of(v: f32, min: f32, inv_cell: f32, n: u32) -> u32 { mod tests { use super::*; - /// A triangle's XY AABB, the shape the callers hand `build`. fn aabb(tri: [[f32; 3]; 3]) -> ([f32; 2], [f32; 2]) { let xs = [tri[0][0], tri[1][0], tri[2][0]]; let ys = [tri[0][1], tri[1][1], tri[2][1]]; @@ -376,8 +296,7 @@ mod tests { ) } - /// A deterministic pseudo-random field of small triangles plus a few slabs β€” the dungeon shape - /// (many small faces, a handful of huge floors) the index has to survive. + /// Small pseudo-random triangles with a slab every 97th: the dungeon shape. fn field(n: usize) -> Vec<[[f32; 3]; 3]> { let mut out = Vec::with_capacity(n); let mut s = 12345u32; @@ -388,7 +307,7 @@ mod tests { for i in 0..n { let (x, y) = (rnd() * 200.0 - 100.0, rnd() * 200.0 - 100.0); if i % 97 == 0 { - // A slab spanning a large area β€” the MAX_SPAN_CELLS path. + // A slab: the MAX_SPAN_CELLS path. out.push([[x, y, 0.0], [x + 80.0, y, 0.0], [x, y + 80.0, 0.0]]); } else { let (dx, dy) = (rnd() * 3.0, rnd() * 3.0); @@ -398,9 +317,6 @@ mod tests { out } - /// The load-bearing property: for any column, the index's candidates are a SUPERSET of the - /// triangles a linear scan would find containing it β€” an index that can miss a face silently - /// moves a unit's room, its light, and the zone name. #[test] fn candidates_are_a_superset_of_the_linear_scan() { let tris = field(4000); @@ -428,8 +344,6 @@ mod tests { assert!(probes > 500, "the sweep must actually probe"); } - /// The other half of "never changes a verdict": candidates arrive in the order a linear scan - /// visits them, so first-wins / later-wins tie-breaks downstream are unaffected. #[test] fn candidates_are_ascending() { let tris = field(2000); @@ -447,8 +361,6 @@ mod tests { } } - /// A column far outside the face set still returns a valid (edge-cell) candidate list rather - /// than panicking or indexing out of range. #[test] fn columns_outside_the_bounds_are_safe() { let tris = field(200); @@ -458,17 +370,13 @@ mod tests { } } - /// The two-level structure actually engages on the dungeon shape, and the residue it leaves is - /// small. Without this, a regression that quietly stopped building the coarse level would still - /// pass every invariant test above β€” the index would just be slow again, which is exactly how - /// 0711's residual survived ~600 decision records unnoticed. + /// The tests above would still pass with no coarse level at all; this one would not. #[test] fn the_coarse_level_takes_the_slabs() { let tris = field(4000); let grid = ColumnGrid::build(tris.len(), |i| aabb(tris[i])).expect("field indexes"); - // `field` plants a slab every 97th triangle; every one of them is far too wide to bin at - // the fine level's ~1-triangle-per-cell sizing. + // Every 97th triangle is a slab, too wide for the fine level. let slabs = tris.len().div_ceil(97); let coarse = grid .coarse @@ -486,8 +394,6 @@ mod tests { ); } - /// The merge draws from three sources; a face must appear in exactly one of them, or a column - /// would test it twice and `later-wins` tie-breaks would read the same triangle as two hits. #[test] fn no_triangle_is_in_two_sources() { let tris = field(3000); @@ -508,7 +414,6 @@ mod tests { } } - /// Small face sets opt out β€” the caller keeps its linear scan rather than paying indirection. #[test] fn tiny_face_sets_are_not_indexed() { let tris = field(8); diff --git a/crates/benilla-assets/src/coords.rs b/crates/benilla-assets/src/coords.rs index 92c9865d..7f649db9 100644 --- a/crates/benilla-assets/src/coords.rs +++ b/crates/benilla-assets/src/coords.rs @@ -1,30 +1,23 @@ -//! The WoW ⇄ Bevy coordinate transform β€” the single documented boundary between raw WoW -//! coordinates (kept everywhere in `benilla-formats` / `benilla-protocol`, matching wowdev.wiki + vmangos) -//! and Bevy's render space. -//! -//! WoW is right-handed with **+X north, +Y west, +Z up**, 1 unit = 1 yard. Bevy is right-handed -//! with **+Y up, βˆ’Z forward**. The map is the pure rotation `bevy = (βˆ’y, z, βˆ’x)` β€” determinant +1, -//! so it never mirrors (winding/normals are preserved) and 1 unit stays 1 yard. See decision 0002. -//! -//! Extracted from `main.rs` into its own module so the transform β€” including the quaternion -//! conjugation used for model placement, historically the trickiest part β€” is unit-tested in -//! isolation (no game assets, no GPU, no running app). +//! The WoW to Bevy coordinate transform, the one boundary between raw WoW coordinates (kept in +//! `benilla-formats` and `benilla-protocol`) and Bevy's render space. WoW is right-handed with +X +//! north, +Y west, +Z up; Bevy is +Y up, βˆ’Z forward. The map is the rotation `bevy = (βˆ’y, z, βˆ’x)`, +//! determinant +1, so it never mirrors a winding and 1 unit stays 1 yard. use bevy::math::{Mat3, Quat, Vec3}; use bevy::transform::components::Transform; -/// WoW `[x, y, z]` β†’ Bevy. Pure rotation, 1 unit = 1 yard. +/// WoW `[x, y, z]` to Bevy. pub fn wow_to_bevy(p: [f32; 3]) -> Vec3 { Vec3::new(-p[1], p[2], -p[0]) } -/// Inverse of [`wow_to_bevy`]: Bevy coords β†’ raw WoW `[x, y, z]` (for sending our position upstream). +/// Inverse of [`wow_to_bevy`], for sending our position upstream. pub fn bevy_to_wow(b: Vec3) -> [f32; 3] { [-b.z, -b.x, b.y] } -/// The fixed WoWβ†’Bevy basis rotation (the rotation part of [`wow_to_bevy`]); used to conjugate -/// placement rotations, since the model meshes are baked into Bevy space. +/// The rotation of [`wow_to_bevy`], which conjugates placement rotations into Bevy space, where +/// the meshes are baked. fn wow_to_bevy_quat() -> Quat { // Columns = Bevy images of the WoW basis: Xβ†’βˆ’Z, Yβ†’βˆ’X, Zβ†’+Y. Quat::from_mat3(&Mat3::from_cols( @@ -34,18 +27,10 @@ fn wow_to_bevy_quat() -> Quat { )) } -/// Convert an MDDF/MODF Euler rotation (degrees) into the Bevy quaternion for a baked (Bevy-space) -/// mesh. -/// -/// Derived from wowdev.wiki's ADT/v18 `createPlacementMatrix`, whose output frame is β€” verified -/// against our own MDDFβ†’position math β€” exactly the WoW gameplay frame (Z up). The modelβ†’world -/// rotation it gives is `Rx(90Β°)Β·Ry(90Β°)Β·Ry(ryβˆ’270Β°)Β·Rz(βˆ’rx)Β·Rx(rzβˆ’90Β°)`, which simplifies to -/// `Rx(90Β°)Β·Ry(ryβˆ’180Β°)Β·Rz(βˆ’rx)Β·Rx(rzβˆ’90Β°)`; we then conjugate it into Bevy space (the meshes are -/// baked there). `ry` is the heading about the up axis; `rx`/`rz` are pitch/roll. -/// -/// The leading `Rx(90Β°)` is the **M2-localβ†’world base reorientation** the old formula skipped β€” it -/// only changes the result when pitch/roll (`rx`/`rz`) are non-zero, so flat doodads and symmetric -/// trees are unaffected, but leaning/tilted segments (fences) now orient correctly. +/// An MDDF/MODF Euler rotation (degrees) as the Bevy quaternion of a baked mesh: wowdev.wiki's ADT +/// `createPlacementMatrix`, `Rx(90Β°)Β·Ry(ryβˆ’180Β°)Β·Rz(βˆ’rx)Β·Rx(rzβˆ’90Β°)` in the WoW frame, conjugated +/// into Bevy space. `ry` is the heading, `rx` and `rz` pitch and roll; the leading `Rx(90Β°)` is the +/// M2-local base reorientation, which only shows on a tilted placement. pub fn placement_rotation(rotation_deg: [f32; 3]) -> Quat { use std::f32::consts::{FRAC_PI_2, PI}; let (rx, ry, rz) = ( @@ -61,17 +46,9 @@ pub fn placement_rotation(rotation_deg: [f32; 3]) -> Quat { to_bevy * in_wow * to_bevy.inverse() } -/// The Bevy-space local [`Transform`] of a **WMO doodad** (an MODD entry) within its WMO's model -/// space. Compose it onto the WMO instance's world transform β€” `wmo_world.mul_transform(this)` β€” to -/// place the doodad in the world. -/// -/// Unlike MDDF/MODF map placements (Euler angles + the client's base reorientation, see -/// [`placement_rotation`]), an MODD carries the doodad's **full orientation as a quaternion** -/// `(x, y, z, w)` directly β€” no separate base reorientation β€” alongside a position and uniform scale, -/// all in WMO model space (WoW axes, Z up; the same space the WMO's own geometry lives in, which we -/// already bake into Bevy via [`wow_to_bevy`]). So we map position via `wow_to_bevy`, conjugate the -/// rotation by the WoWβ†’Bevy basis (the meshes are baked there), and keep the uniform scale (a uniform -/// scale is basis-invariant). This mirrors the conjugation in [`placement_rotation`]. +/// An MODD doodad's Bevy-space [`Transform`] in its WMO's model space, to compose onto the +/// instance's (`wmo_world.mul_transform(this)`). Unlike MDDF and MODF, an MODD carries its full +/// orientation as a quaternion `(x, y, z, w)`, with no base reorientation. pub fn wmo_doodad_local(position: [f32; 3], orientation: [f32; 4], scale: f32) -> Transform { Transform { translation: wow_to_bevy(position), @@ -80,17 +57,12 @@ pub fn wmo_doodad_local(position: [f32; 3], orientation: [f32; 4], scale: f32) - } } -/// A rotation authored in **WoW model space** `(x, y, z, w)` β†’ the equivalent rotation of a mesh -/// already baked into Bevy space: the basis conjugation `B Β· q Β· B⁻¹`. -/// -/// The conjugation (rather than a component shuffle) is the whole point β€” [`wow_to_bevy`] is a -/// change of basis, and a rotation changes basis by similarity, not by permuting its parts. Every -/// caller that has a stored WoW quaternion wants this: an MODD doodad's orientation -/// ([`wmo_doodad_local`]) and an M2 bone track's rotation key alike. +/// A WoW-space rotation `(x, y, z, w)` as the rotation of a mesh baked into Bevy space: the basis +/// conjugation `B Β· q Β· B⁻¹`, not a component shuffle. pub fn wow_rotation_to_bevy(q: [f32; 4]) -> Quat { let q_wow = Quat::from_xyzw(q[0], q[1], q[2], q[3]); let to_bevy = wow_to_bevy_quat(); - // q and βˆ’q are the same rotation; normalize guards a denormalized stored quat. + // Normalized against a denormalized stored quaternion. (to_bevy * q_wow * to_bevy.inverse()).normalize() } @@ -102,8 +74,6 @@ mod tests { (a - b).length() < 1e-5 } - /// `bevy_to_wow` must exactly undo `wow_to_bevy` for arbitrary points (a sign/axis slip in - /// either direction would desync our outbound position from the server's frame). #[test] fn wow_bevy_round_trips() { for p in [ @@ -120,11 +90,8 @@ mod tests { } } - /// The yaw-conjugation law the transport platform frame rests on (decision 0438 phase 2): - /// a WoW orientation ΞΈ about WoW-up (+Z) is exactly a Bevy yaw ΞΈ about Bevy-up (+Y) through - /// the basis map β€” `wow_to_bevy(rot_z(ΞΈ)Β·v) == rot_y(ΞΈ)Β·wow_to_bevy(v)`. This is what lets a - /// rider's boat-local pose convert between spaces with the plain linear map and lets the - /// wire's local orientation be `face_yaw βˆ’ boat_yaw` with no correction term. + /// `wow_to_bevy(rot_z(ΞΈ)Β·v) == rot_y(ΞΈ)Β·wow_to_bevy(v)`, which lets a transport rider's wire + /// orientation be `face_yaw βˆ’ boat_yaw` with no correction term. #[test] fn yaw_conjugates_through_the_basis_map() { for theta in [0.0f32, 0.7, 2.4, -1.1, 5.9] { @@ -138,7 +105,6 @@ mod tests { } } - /// Pin the basis mapping with hand-verified golden values: north/west/up map where the docs say. #[test] fn wow_bevy_basis_is_golden() { // WoW +X (north) β†’ Bevy βˆ’Z (forward). @@ -158,8 +124,6 @@ mod tests { )); } - /// The conjugation quaternion must reproduce `wow_to_bevy` on every vector β€” otherwise placement - /// rotations are conjugated by the wrong basis and asymmetric doodads/buildings face wrong. #[test] fn placement_quat_matches_the_position_transform() { for w in [ @@ -173,7 +137,6 @@ mod tests { } } - /// Determinant +1 β‡’ a proper rotation (no mirroring); the whole coordinate decision rests on this. #[test] fn transform_is_a_proper_rotation() { let m = Mat3::from_quat(wow_to_bevy_quat()); @@ -184,9 +147,6 @@ mod tests { ); } - /// A pure MDDF/MODF heading (`ry`) is a yaw about WoW +Z, which conjugates to a yaw about - /// Bevy +Y β€” so it must leave the vertical axis fixed. This is the invariant that actually - /// validates the conjugation (a wrong basis would tilt the model when it should only spin). #[test] fn heading_only_placement_spins_about_vertical() { for deg in [0.0, 30.0, 90.0, 250.0] { @@ -199,8 +159,6 @@ mod tests { } } - /// An MODD with identity orientation is a pure translate (by `wow_to_bevy(pos)`) + uniform - /// scale β€” no rotation. A wrong conjugation basis would leak rotation in here. #[test] fn wmo_doodad_identity_is_translate_scale() { let t = wmo_doodad_local([3.0, -2.0, 5.0], [0.0, 0.0, 0.0, 1.0], 2.5); @@ -213,8 +171,6 @@ mod tests { assert!((t.scale - Vec3::splat(2.5)).length() < 1e-5); } - /// A doodad at the WMO origin, composed onto the WMO instance's world transform, must land - /// exactly at the instance's world position β€” the property the whole compose relies on. #[test] fn wmo_doodad_at_origin_lands_on_instance() { let wmo_world = Transform { @@ -227,9 +183,6 @@ mod tests { assert!(close(world.translation, wmo_world.translation)); } - /// An MODD heading β€” a rotation about WoW +Z (up) β€” must conjugate to a yaw about Bevy +Y, so it - /// leaves the vertical axis fixed (the same invariant that validates `placement_rotation`'s - /// conjugation, but via the quaternion path). A wrong basis would tilt the doodad. #[test] fn wmo_doodad_heading_spins_about_vertical() { for deg in [0.0_f32, 35.0, 90.0, 215.0] { @@ -243,7 +196,6 @@ mod tests { } } - /// Placement rotations are always unit quaternions (a non-unit quat would scale/shear the mesh). #[test] fn placement_rotations_are_unit_quaternions() { for r in [[0.0, 0.0, 0.0], [12.0, 34.0, 56.0], [-90.0, 180.0, 45.0]] { @@ -252,14 +204,11 @@ mod tests { } } - /// Golden snapshot of the full placement rotation, including pitch/roll β€” captured after the - /// fence orientation was confirmed correct on screen. Locks the MDDF convention so a future - /// change to the formula can't silently regress doodad/building orientation. The first case is a - /// real leaning fence segment (`[rx, ry, rz]` degrees). Quaternions double-cover rotations, so - /// compare by |dot| β‰ˆ 1 (q and βˆ’q are the same rotation). + /// The first case is a real leaning fence segment, `[rx, ry, rz]` in degrees. q and βˆ’q are the + /// same rotation, so the check is |dot| β‰ˆ 1. #[test] fn placement_rotation_goldens() { - use std::f32::consts::FRAC_1_SQRT_2; // 0.70710678… + use std::f32::consts::FRAC_1_SQRT_2; let cases = [ ( [86.0_f32, 252.0, 93.5], diff --git a/crates/benilla-assets/src/gpu_blp.rs b/crates/benilla-assets/src/gpu_blp.rs index 6a115ca1..e1c01f73 100644 --- a/crates/benilla-assets/src/gpu_blp.rs +++ b/crates/benilla-assets/src/gpu_blp.rs @@ -1,31 +1,8 @@ -//! Where a BLP's stored form meets the GPU: **can we hand the blocks over untouched?** -//! -//! WoW ships almost all world art as S3TC (DXT1/DXT3/DXT5) blocks, and the reference client uploads -//! those blocks verbatim β€” `glCompressedTexImage2DARB`, with the software DXT decoder existing only -//! as a 16-bit-device fallback (the device-format query `0x58a230`, where DXT is a raw passthrough -//! the device eats; the per-level upload formats at `0x59f270`). -//! benilla used to decode every one of them to `Rgba8Unorm` on the CPU and upload that: **8x the -//! bytes of DXT1, 4x of DXT3/DXT5**, on the whole resident working set and on every texture fetch -//! that misses the cache. On a bandwidth-starved integrated GPU that is the frame's dominant cost, -//! and a Steam Deck is one (decision 1626). -//! -//! Two things have to be true before the blocks can go straight over, and this module owns both: -//! -//! 1. **The device must accept BC.** [`bc_supported`] is a process-wide fact published once from -//! the render device (see [`publish_bc_support`]). Not a guess: `wgpu-hal`'s Metal backend gates -//! `TEXTURE_COMPRESSION_BC` on `format_bc: os_is_mac` and the Vulkan backend on the device's -//! `textureCompressionBC`, and bevy's `WgpuSettingsPriority::Functionality` (the default) requests -//! every feature the adapter reports. Every desktop target we ship has it β€” but "every target we -//! ship" is not "every device", so the fallback stays. -//! 2. **The dimensions must be whole 4x4 blocks.** A BC texture is addressed in blocks; a BLP whose -//! mip 0 is not a multiple of 4 on both axes cannot be uploaded as one. Real 1.12 world art is -//! power-of-two, so this practically never fires β€” it exists so that when it does, the texture -//! renders decoded instead of failing. -//! -//! When either is false the caller decodes, exactly as before. That is why the fallback lives here -//! as one function and not as a flag threaded through two loaders: there is one rule, and both the -//! async `AssetLoader` lane ([`crate::blp`]) and the synchronous `WorldAssets` lane -//! ([`crate::world_assets`]) ask it the same question. +//! Whether a BLP's stored S3TC blocks can go to the GPU untouched, as the reference uploads them +//! (`glCompressedTexImage2DARB`; its software DXT decoder is a 16-bit-device fallback, device +//! formats `0x58a230`, per-level upload formats `0x59f270`). They can when the device accepts BC +//! ([`bc_supported`]) and mip 0 is whole 4x4 blocks; otherwise the chain decodes to `Rgba8Unorm`. +//! Both the async loader ([`crate::blp`]) and `WorldAssets` ([`crate::world_assets`]) ask here. use std::sync::OnceLock; @@ -36,42 +13,29 @@ use bevy::render::render_resource::TextureFormat; static BC_SUPPORTED: OnceLock = OnceLock::new(); -/// Publish whether this run's GPU accepts BC textures. Called once, from the app's plugin -/// `finish()`, after `RenderPlugin` has inserted `CompressedImageFormatSupport` into the main world. -/// -/// **Ordering is why this is a `OnceLock` and not a resource.** An `AssetLoader` runs on the async -/// task pool with no world access, and the synchronous `WorldAssets` lane is called from ordinary -/// systems β€” neither can read a resource the other can. But every plugin's `finish()` completes -/// before the runner's first update, and no BLP is loaded before then, so a single process-wide -/// publish is both sufficient and race-free. Absent a render device (headless tests), nothing -/// publishes and [`bc_supported`] reports `false` β€” the decoded path, which is what a test that -/// inspects pixels wants anyway. +/// Publish whether this run's GPU accepts BC, once, from a plugin's `finish()`. A `OnceLock`, not +/// a resource, because the async loader has no world access; every `finish()` runs before the +/// first update, so before any BLP loads. Headless, nothing publishes and the decoded path runs. pub fn publish_bc_support(supported: bool) { let _ = BC_SUPPORTED.set(supported); } -/// Does this run's GPU accept BC? `false` until [`publish_bc_support`] says otherwise. +/// Whether this run's GPU accepts BC; `false` until [`publish_bc_support`]. pub fn bc_supported() -> bool { *BC_SUPPORTED.get().unwrap_or(&false) } -/// Publishes [`bc_supported`] from the live render device. Added by -/// [`crate::register_asset_loaders`], so both the client and the world viewer get it. -/// -/// **All the work is in `finish`, deliberately.** `RenderPlugin::finish` is what inserts -/// `CompressedImageFormatSupport` into the main world (bevy_render `lib.rs`, the -/// `FutureRenderResources` block) β€” it does not exist at `build` time, and it never exists at all -/// in a headless app. Reading it here, one plugin later, is the earliest honest moment. +/// Publishes [`bc_supported`] from the render device; added by [`crate::register_asset_loaders`]. +/// The work is in `finish`: `RenderPlugin::finish` inserts `CompressedImageFormatSupport`, which +/// does not exist at `build` time or in a headless app. pub struct BlpGpuSupportPlugin; impl Plugin for BlpGpuSupportPlugin { fn build(&self, _app: &mut App) {} fn finish(&self, app: &mut App) { - // `WOW_NO_BC=1` β€” force the decoded path on a device that could take blocks. The A/B lever - // for this whole change: it is the only way to price the passthrough against what it - // replaced on one machine in one session, and the first thing to try if a texture ever - // looks wrong (hardware block decode is not bit-identical to `texpresso`'s β€” see 1626). + // `WOW_NO_BC=1` forces the decoded path: the A/B lever, and the first thing to try when a + // texture looks wrong (hardware block decode is not bit-identical to `texpresso`'s). let forced_off = std::env::var("WOW_NO_BC").as_deref() == Ok("1"); let device_can = app .world() @@ -79,9 +43,7 @@ impl Plugin for BlpGpuSupportPlugin { .is_some_and(|s| s.0.contains(CompressedImageFormats::BC)); let supported = device_can && !forced_off; publish_bc_support(supported); - // At `info`, not `debug`: "which texture lane am I on?" is the first question any perf or - // look report about this change has to answer, and a line that only exists in a debug build - // cannot answer it from a player's log. + // At `info`, so a player's log says which texture lane the run is on. if supported { info!("blp: DXT blocks upload natively (BC)"); } else if forced_off { @@ -92,7 +54,6 @@ impl Plugin for BlpGpuSupportPlugin { } } -/// Can `chain` be uploaded in the form it is already in? fn uploadable_as_blocks(chain: &BlpMipChain) -> bool { chain.texels.is_block_compressed() && bc_supported() @@ -100,24 +61,15 @@ fn uploadable_as_blocks(chain: &BlpMipChain) -> bool { && chain.height.is_multiple_of(4) } -/// A chain and the `TextureFormat` it uploads as β€” **decided together, returned together.** -/// -/// This pairing is the whole point of the type. The first cut of this module had two functions, one -/// that picked the chain and one that picked the format, and a caller reached the second without -/// going through the first: it uploaded DXT3 blocks under an `Rgba8Unorm` descriptor and wgpu -/// panicked slicing 262144 bytes out of an 87392-byte chain. There is no way to hold one of these -/// without the other, so that class of mistake cannot recur. +/// A chain and the `TextureFormat` it uploads as, decided together so block bytes can never meet +/// an uncompressed descriptor. pub struct UploadChain { pub chain: BlpMipChain, pub format: TextureFormat, } -/// A BLP's authored mip chain in the best form this GPU can take, with the format that matches it: -/// **blocks if it can, pixels if it cannot.** -/// -/// The format is always in the **gamma-byte (non-sRGB) lane** every world/model albedo lives in β€” -/// the RE'd invariant that the GPU must not linearize albedo on sample. `Bc1RgbaUnorm` and friends -/// carry that rule exactly as `Rgba8Unorm` does, which is what makes the passthrough look-neutral. +/// A BLP's authored mip chain in the best form this GPU takes, with its format: blocks if it can, +/// pixels if not. Always non-sRGB, so the GPU never linearizes albedo on sample. pub fn for_upload(chain: BlpMipChain) -> UploadChain { if uploadable_as_blocks(&chain) { let format = match chain.texels { @@ -129,9 +81,7 @@ pub fn for_upload(chain: BlpMipChain) -> UploadChain { }; return UploadChain { chain, format }; } - // Either it was never blocks, or this device/texture cannot take them. `into_rgba8` is a no-op - // in the first case and the decode we were trying to avoid in the second β€” and it cannot fail, - // which is why the format below is unconditionally right. + // `into_rgba8` is a no-op on pixels and an infallible decode of blocks. UploadChain { chain: chain.into_rgba8(), format: TextureFormat::Rgba8Unorm, @@ -152,8 +102,6 @@ mod tests { } } - /// Without a published device fact we decode. A headless test that reads pixels depends on - /// this, and so does any run whose GPU genuinely lacks BC. #[test] fn no_published_support_means_the_decoded_path() { // `publish_bc_support` is never called in this test binary, so the lock stays empty. @@ -163,9 +111,6 @@ mod tests { assert!(up.chain.is_rgba8(), "blocks must have been decoded"); } - /// **The invariant this module exists to hold**: whatever comes back, the bytes and the format - /// agree about how big mip 0 is. This is the assertion whose absence let block bytes be - /// uploaded under an `Rgba8Unorm` descriptor. #[test] fn the_returned_format_always_matches_the_returned_bytes() { for texels in [ @@ -194,10 +139,8 @@ mod tests { } } - /// The block-alignment rule is on mip 0's dimensions, both axes. wgpu-core rejects a compressed - /// texture whose size is not a whole number of blocks (`NotMultipleOfBlockWidth/Height`), and - /// its default error handler makes that a process panic β€” so this is a hard gate, not a - /// preference. + /// wgpu-core rejects a compressed texture that is not whole blocks + /// (`NotMultipleOfBlockWidth/Height`), and its default error handler panics. #[test] fn non_block_aligned_dimensions_are_refused() { for (w, h) in [(258u32, 256u32), (256, 258), (6, 6), (1, 1)] { @@ -208,7 +151,6 @@ mod tests { } } - /// An already-decoded chain is never re-encoded, and never copied through a decode path. #[test] fn an_rgba_chain_passes_through_untouched() { let c = chain(BlpTexels::Rgba8Unorm, 8, 8); diff --git a/crates/benilla-assets/src/lib.rs b/crates/benilla-assets/src/lib.rs index fd8411af..c412f149 100644 --- a/crates/benilla-assets/src/lib.rs +++ b/crates/benilla-assets/src/lib.rs @@ -1,12 +1,5 @@ -//! `benilla-assets` β€” the bridge from the WoW 1.12.1 MPQ patch chain into Bevy's asset system. -//! -//! Registers an `mpq://` [`AssetSource`](bevy::asset::io::AssetSource) backed by -//! [`benilla_formats::Chain`], so every WoW asset loads through the standard -//! [`AssetServer`](bevy::asset::AssetServer) as a `Handle` β€” gaining async loading, handle dedup, -//! a dependency graph, and hot-reload for free, instead of the bespoke caches and hand-rolled -//! worker/finalize pipeline the old client carried (decision 0005). The per-format -//! [`AssetLoader`](bevy::asset::AssetLoader)s (BLPβ†’`Image`, M2/WMOβ†’model, ADTβ†’tile, DBCβ†’catalog) -//! build on this foundation. +//! `benilla-assets`: the WoW 1.12.1 MPQ patch chain as an `mpq://` Bevy asset source backed by +//! [`benilla_formats::Chain`], and the per-format loaders (BLP, M2, WMO, ADT, WDT) on top of it. use std::path::Path; use std::sync::Arc; @@ -48,7 +41,7 @@ pub use adt::{chunk_to_mesh, chunks_to_mesh, AdtLoader, AdtTile, ChunkShading}; pub use wdt::{WdtIndex, WdtIndexLoader}; mod blp; pub use blp::{BlpImageLoader, BlpLoaderSettings, BlpVariant}; -/// Whether this run's GPU can eat WoW's stored DXT blocks, and the chain form that follows. +/// Whether this GPU takes WoW's stored DXT blocks, and the chain form that follows. mod gpu_blp; pub use gpu_blp::{bc_supported, for_upload, publish_bc_support, BlpGpuSupportPlugin, UploadChain}; mod tex_filter; @@ -68,16 +61,14 @@ pub use wmo::{ /// The asset-source id for MPQ-backed assets: load paths look like `mpq://World/Azeroth/foo.adt`. pub const MPQ_SOURCE: &str = "mpq"; -/// A Bevy [`AssetReader`] over the vanilla MPQ patch chain. Cheap to clone β€” every clone shares the -/// one open chain ([`Chain`] reads through fresh per-call handles, so this is `Send + Sync`). +/// A Bevy [`AssetReader`] over the MPQ patch chain; every clone shares the one open [`Chain`]. #[derive(Clone)] pub struct MpqAssetReader { chain: Arc, } impl MpqAssetReader { - /// Open the patch chain from a `Data` directory (or a single `.MPQ`), ready to back an - /// `mpq://` source. + /// Open the patch chain from a `Data` directory or a single `.MPQ`. pub fn open(data_dir: &Path) -> Result { Ok(Self { chain: Arc::new(Chain::open(data_dir)?), @@ -90,8 +81,7 @@ impl AssetReader for MpqAssetReader { let raw = path .to_str() .ok_or_else(|| AssetReaderError::NotFound(path.to_path_buf()))?; - // Strip the sampler-mode marker ([`texture_url`]) before the archive lookup β€” it is part of - // the ASSET identity, not of the file name. + // The sampler-mode marker (`texture_url`) names the asset, not the archive file. let stripped = strip_sampler_marker(raw); let internal = stripped.as_deref().unwrap_or(raw); if !self.chain.contains(internal) { @@ -113,7 +103,7 @@ impl AssetReader for MpqAssetReader { &'a self, path: &'a Path, ) -> Result, AssetReaderError> { - // We load by explicit path; directory enumeration / folder-watching isn't supported. + // Loads are by explicit path; there is no directory listing. Err(AssetReaderError::NotFound(path.to_path_buf())) } @@ -122,22 +112,12 @@ impl AssetReader for MpqAssetReader { } } -/// The sampler-mode marker separator in an `mpq://` texture URL (see [`texture_url`]). +/// The sampler-mode marker in an `mpq://` texture URL. const SAMPLER_MARKER: char = '@'; -/// Build the `mpq://` URL for a model texture at a given sampler address mode. -/// -/// The address mode lives on the GPU sampler, which in Bevy rides the `Image`, which is keyed by -/// asset path β€” so two modes of one `.blp` need two asset paths. **243 of the corpus's 4677 texture -/// paths are asked for more than one mode** (`benilla-extract texmodescan`), so this is not a -/// hypothetical: without it, whichever model loaded a shared sheet first would decide the mode for -/// every later one. -/// -/// Repeat/repeat β€” the overwhelming majority β€” keeps the bare path, so the common case is one upload -/// and every pre-existing URL is unchanged. Any other mode gets a marker **before the extension** -/// (`…\leaves01@cc.blp`), because Bevy selects the loader by extension and a trailing marker would -/// stop `.blp` resolving. [`MpqAssetReader::read`] strips it back off for the archive lookup. -/// Decision 0763. +/// The `mpq://` URL of a model texture at a sampler address mode. Bevy keys the sampler to the +/// `Image` and the `Image` to its path, so a mode other than repeat/repeat marks the path before +/// the extension (`…/leaves01@cc.blp`), which must stay `.blp` to pick the loader. pub fn texture_url(internal: &str, wrap: (bool, bool)) -> String { let path = internal.replace('\\', "/").to_ascii_lowercase(); if wrap == (true, true) { @@ -154,27 +134,13 @@ pub fn texture_url(internal: &str, wrap: (bool, bool)) -> String { } } -/// Bevy resource wrapper around the format crate's [`MapCatalog`] (`mapId` -> directory + -/// `LoadingScreenID`), read out of `Map.dbc`. -/// -/// The newtype exists because the orphan rule forbids `Resource` on a foreign type, and it lives -/// here rather than in the client for the same reason the rest of this crate does: a `.dbc` table -/// turned into something Bevy can hold is exactly this layer's job, and the three readers are the -/// WDL streamer, the world-map UI and the loading screen β€” one engine, two game (decision 1164). -/// The *loader* stays up top with the patch chain's plugin shell, which is what inserts it. +/// The `Map.dbc` [`MapCatalog`] (`mapId` to directory and `LoadingScreenID`) as a Bevy resource; a +/// newtype because the orphan rule forbids `Resource` on a foreign type. #[derive(Resource)] pub struct MapCatalogRes(pub benilla_formats::MapCatalog); -/// The `mpq://` URL an authored **model** path resolves to. -/// -/// The archive holds 1.12.1 `.mdx`/`.mdl`, the loader produces an `M2Model`, and Bevy picks a loader -/// by extension β€” so every reference is normalised to one lowercase `mpq://…m2`, which is also what -/// makes the handle dedup work (a path differing only in case or slash would load twice). The WMO and -/// skin builders are the same rewrite for their own extensions. They sit beside [`texture_url`] -/// because they are the same act: turning a path the game files wrote into the one URL this crate's -/// asset source answers to (decision 1164). -/// A model reference path (`.mdx`/`.mdl`, mixed case, backslashes) β†’ its `mpq://…m2` load URL. -/// Lowercased so case variants share one `AssetServer` handle; the physical archive file is `.m2`. +/// A model reference path (`.mdx`/`.mdl`, any case, backslashes) as its lowercase `mpq://…m2` URL: +/// the archive file is `.m2`, and one spelling per model keeps the handle dedup working. pub fn m2_url(raw: &str) -> String { let p = raw.to_ascii_lowercase().replace('\\', "/"); let stem = p @@ -185,13 +151,13 @@ pub fn m2_url(raw: &str) -> String { format!("mpq://{stem}.m2") } -/// A WMO root path β†’ its `mpq://…wmo` load URL (already `.wmo`; lowercased for handle dedup). +/// A WMO root path as its `mpq://…wmo` URL, lowercased for handle dedup. pub fn wmo_url(raw: &str) -> String { format!("mpq://{}", raw.to_ascii_lowercase().replace('\\', "/")) } -/// A creature skin variation β†’ its `mpq://…blp` URL: `\.blp`. `model_dir` is the -/// directory of the creature's model path (where its `Monster1/2/3` skins live). +/// A creature skin variation's `mpq://` URL, `/.blp`: the `Monster1/2/3` skins +/// live beside the model. pub fn skin_url(model_dir: &str, name: &str) -> String { let dir = model_dir.replace('\\', "/").to_ascii_lowercase(); let name = name.to_ascii_lowercase(); @@ -215,17 +181,15 @@ pub fn sampler_mode_of(path: &str) -> (bool, bool) { } } -/// Remove a [`texture_url`] marker, yielding the real archive path. `None` when there is none. +/// The archive path under a [`texture_url`] marker; `None` when unmarked. fn strip_sampler_marker(path: &str) -> Option { let (stem, ext) = path.rsplit_once('.')?; let (base, tag) = stem.rsplit_once(SAMPLER_MARKER)?; matches!(tag, "rc" | "cr" | "cc").then(|| format!("{base}.{ext}")) } -/// Register the `mpq://` asset source on `app`, backed by the patch chain at `data_dir`. -/// -/// Must be called **before** Bevy's `AssetPlugin` builds (i.e. before `DefaultPlugins`): asset -/// sources are read when the plugin initializes the [`AssetServer`](bevy::asset::AssetServer). +/// Register the `mpq://` source on `app`, backed by the patch chain at `data_dir`. Call it before +/// `AssetPlugin` builds (before `DefaultPlugins`), which reads the sources. pub fn register_mpq_source(app: &mut App, data_dir: &Path) -> Result<()> { let reader = MpqAssetReader::open(data_dir)?; app.register_asset_source( @@ -235,24 +199,20 @@ pub fn register_mpq_source(app: &mut App, data_dir: &Path) -> Result<()> { Ok(()) } -/// Register benilla's asset loaders on `app`. Call **after** Bevy's `AssetPlugin` (loaders register -/// into the live [`AssetServer`](bevy::asset::AssetServer)); pair with [`register_mpq_source`], which -/// must run *before* `AssetPlugin`. +/// Register benilla's asset loaders on `app`, after `AssetPlugin`, into its live `AssetServer`. pub fn register_asset_loaders(app: &mut App) { app.init_asset::(); app.init_asset::(); app.init_asset::(); app.init_asset::(); - // Publishes whether this device can eat DXT blocks (decision 1626). A plugin, because the - // answer only exists after `RenderPlugin::finish` β€” see `BlpGpuSupportPlugin`. + // A plugin: whether the device takes DXT blocks is known only after `RenderPlugin::finish`. app.add_plugins(BlpGpuSupportPlugin); app.register_asset_loader(BlpImageLoader); app.register_asset_loader(M2ModelLoader); app.register_asset_loader(WmoModelLoader); app.register_asset_loader(AdtLoader); app.register_asset_loader(WdtIndexLoader); - // The render materials' WGSL, compiled in rather than served off the host binary's asset root - // (decision 1164) β€” same "after AssetPlugin" requirement as the loaders above, so it rides here. + // The materials' WGSL, compiled in; it too needs `AssetPlugin` first. materials::register_shaders(app); } @@ -262,25 +222,21 @@ mod tests { use bevy::camera::primitives::MeshAabb; use bevy::tasks::block_on; - /// The sampler-mode URL round-trips, and repeat/repeat stays byte-identical to the old bare - /// path β€” so the common case keeps ONE upload and no pre-existing URL changed (decision 0763). #[test] fn sampler_mode_rides_the_asset_path_and_round_trips() { let tex = "World\\KhazModan\\Ironforge\\PassiveDoodads\\Trees\\IronForgeleaves01.blp"; - // The default is the bare path β€” unchanged from before this scheme existed. let repeat = texture_url(tex, (true, true)); assert_eq!( repeat, "mpq://world/khazmodan/ironforge/passivedoodads/trees/ironforgeleaves01.blp" ); assert_eq!(sampler_mode_of(&repeat), (true, true)); - // Every other mode marks the STEM, so the `.blp` extension still selects the loader. + // Every other mode marks the stem, so `.blp` still picks the loader. for wrap in [(false, false), (true, false), (false, true)] { let url = texture_url(tex, wrap); assert!(url.ends_with(".blp"), "extension must survive: {url}"); assert_ne!(url, repeat, "a marked mode is a distinct asset path"); assert_eq!(sampler_mode_of(&url), wrap, "round-trip {wrap:?}"); - // ...and the marker comes back off for the archive lookup. assert_eq!( strip_sampler_marker(url.strip_prefix("mpq://").unwrap()).as_deref(), Some("world/khazmodan/ironforge/passivedoodads/trees/ironforgeleaves01.blp"), @@ -297,7 +253,6 @@ mod tests { let data = benilla_formats::wow_data_or_skip!(); let reader = MpqAssetReader::open(&data).expect("open mpq reader"); - // A real file resolves and drains to its bytes through the Bevy AssetReader/Reader traits. let bytes = block_on(async { let mut r = AssetReader::read(&reader, Path::new("DBFilesClient/Spell.dbc")) .await @@ -309,7 +264,7 @@ mod tests { assert_eq!(&bytes[..4], b"WDBC", "Spell.dbc starts with the WDBC magic"); assert!(bytes.len() > 1_000_000, "Spell.dbc should be sizable"); - // A missing path yields NotFound β€” the variant Bevy relies on for meta fallback. + // A missing path is `NotFound`, which Bevy's meta fallback relies on. let missing = block_on(AssetReader::read(&reader, Path::new("does/not/exist.blp"))); assert!( matches!(missing, Err(AssetReaderError::NotFound(_))), @@ -320,7 +275,6 @@ mod tests { #[test] fn loads_a_blp_image_through_the_full_mpq_pipeline() { let data = benilla_formats::wow_data_or_skip!(); - // Full pipeline, headless: mpq:// source β†’ AssetServer β†’ BlpImageLoader β†’ Handle. let mut app = App::new(); app.add_plugins(MinimalPlugins); register_mpq_source(&mut app, &data).expect("register mpq source"); @@ -334,7 +288,7 @@ mod tests { .load("mpq://Interface/Icons/Spell_Holy_ArcaneIntellect.blp"); let mut got = None; - // Same generous ceiling as the WMO test below β€” parallel-session load starves the IO pool. + // A generous ceiling: a parallel gate run starves the IO pool. for _ in 0..15_000 { app.update(); if let Some(img) = app.world().resource::>().get(&handle) { @@ -356,12 +310,8 @@ mod tests { ); } - /// Regression guard: `load_with_settings(Sprite)` must reach the loader on the async `mpq://` - /// path so a streamed tile lands as `Rgba8UnormSrgb`, not the loader's `WorldArt` default - /// (`Rgba8Unorm`). The minimap streams its tiles this way (`minimap.rs`), and the UI pass's - /// contract is sRGB textures (linearize β†’ multiply β†’ re-encode); a silent regression to the - /// gamma-space default re-encodes every tile ~2Γ— brighter β€” the decision-0178 over-bright class - /// of bug, but for the minimap. Skips without the vanilla client data. + /// The minimap streams its tiles with `load_with_settings(Sprite)`; the UI pass wants sRGB, and + /// the `WorldArt` default would draw every tile about twice as bright. #[test] fn minimap_tile_settings_reach_the_async_loader() { use bevy::render::render_resource::TextureFormat; @@ -373,9 +323,7 @@ mod tests { app.init_asset::(); register_asset_loaders(&mut app); - // IN ISOLATION β€” the real app (minimap.rs) only ever loads a tile via - // `load_with_settings(Sprite)`, never a bare `load()` of the same path. Loading both here - // would let Bevy's path-dedup hand the second call the first's settings, masking the truth. + // In isolation: a bare `load()` of the same path would share the handle and its settings. let tile = "mpq://textures/Minimap/ea283abc0bf9637c3fad5e840a65b38b.blp"; let server = app.world().resource::().clone(); let sprite_h: Handle = @@ -384,7 +332,7 @@ mod tests { }); let mut sprite_fmt = None; - // Same generous ceiling as the WMO test below β€” parallel-session load starves the IO pool. + // A generous ceiling: a parallel gate run starves the IO pool. for _ in 0..15_000 { app.update(); if let Some(img) = app.world().resource::>().get(&sprite_h) { @@ -401,10 +349,7 @@ mod tests { ); } - /// The WMO interior-minimap tile grid (`crate::minimap::group_axis_grid`) verified against real - /// authored data: load Ironforge and check the per-group tile count each axis matches the - /// `md5translate.trs` ground truth β€” group 66 = 2Γ—2, 44 = 1(X)Γ—2(Y), 89 = 2(X)Γ—1(Y), and the - /// small groups 1Γ—1. Grounds the (RE-inferred) footprintβ†’grid bake the interior renderer rests on. + /// Ironforge's interior minimap tile counts per group, against `md5translate.trs`. #[test] fn ironforge_group_grid_matches_trs() { let data = benilla_formats::wow_data_or_skip!(); @@ -420,9 +365,7 @@ mod tests { .resource::() .load("mpq://World/wmo/KhazModan/Cities/Ironforge/Ironforge.wmo"); let mut model = None; - // A generous ~30 s ceiling (healthy runs finish in well under a second): the async IO - // pool gets starved when a parallel session runs its own full gates on this machine, and - // the old ~4 s budget flaked exactly then (2026-07-10, two concurrent workspace runs). + // About 30 s, though a healthy run takes under one: a parallel gate run starves IO. for _ in 0..15_000 { app.update(); if let Some(m) = app.world().resource::>().get(&h) { @@ -432,7 +375,7 @@ mod tests { std::thread::sleep(std::time::Duration::from_millis(2)); } let m = model.expect("Ironforge loads"); - // (group index, expected X-count, expected Y-count) β€” read off the trs tile names. + // (group, X tiles, Y tiles), read off the trs tile names. let grid = |ext: f32| crate::minimap_grid::group_axis_grid(ext).0; for &(g, ex_n, ey_n) in &[ (1, 1, 1), @@ -458,16 +401,14 @@ mod tests { app.add_plugins(bevy::asset::AssetPlugin::default()); app.init_asset::(); app.init_asset::(); - // The M2 loader emits labeled sub-assets the skinned + animated path needs (decision 0019): the - // inverse bind poses, plus the idle `AnimationClip` + `AnimationGraph`. The real app registers - // these via `DefaultPlugins` (bevy_mesh's `MeshPlugin` + `AnimationPlugin`); the minimal harness - // registers them here so the campfire's M2 (which has bones + a Stand sequence) loads. + // The M2 loader emits inverse bind poses, an `AnimationClip` and an `AnimationGraph`, which + // `DefaultPlugins` would register. app.init_asset::(); app.init_asset::(); app.init_asset::(); - register_asset_loaders(&mut app); // inits M2Model + registers BLP/M2 loaders + register_asset_loaders(&mut app); - // A doodad with embedded (hardcoded) textures β€” the campfire (`.mdx` ref β†’ `.m2` physical file). + // The campfire: a doodad with embedded textures. let handle: Handle = app .world() .resource::() @@ -493,9 +434,8 @@ mod tests { assert!(has_bounds, "M2 carries authored bounds"); assert!(textured > 0, "campfire batches reference embedded textures"); - // The loader ships geometry, no meshes (decision 0834) β€” the app builds the render form. - // Exercise both builders per batch: non-empty, and the static form must yield the Aabb - // the spawn side inserts explicitly (RENDER_WORLD meshes race `calculate_bounds`). + // The loader ships geometry; the spawn side inserts the static form's Aabb itself, as + // `RENDER_WORLD` meshes race `calculate_bounds`. for g in &geometries { let mesh = submesh_to_static_mesh(g); assert!(mesh.count_vertices() > 0, "submesh has vertices"); @@ -520,7 +460,7 @@ mod tests { app.init_asset::(); register_asset_loaders(&mut app); - // The Goldshire Inn β€” a root + group WMO; the loader reads the groups via read_asset_bytes. + // The Goldshire Inn: a root and its group files. let handle: Handle = app .world() .resource::() @@ -545,7 +485,6 @@ mod tests { ); assert!(textured > 0, "WMO batches reference textures"); - // Geometry, no meshes (decision 0834): the static build is the WMO's one render form. for g in &geometries { let mesh = submesh_to_static_mesh(g); assert!(mesh.count_vertices() > 0, "group submesh has vertices"); @@ -556,7 +495,6 @@ mod tests { #[test] fn loads_an_adt_terrain_tile_through_the_pipeline() { let data = benilla_formats::wow_data_or_skip!(); - // Find an existing Elwynn-area Azeroth tile (don't hardcode exact coords). let reader = benilla_formats::Chain::open(&data).expect("open chain"); let mut url = None; 'find: for tx in 28..36u32 { @@ -601,11 +539,7 @@ mod tests { let (cells, n_shading, layer_h, alpha_h, shadow_h, n_doodads) = info.expect("the ADT tile should load via mpq:// + AdtLoader"); - // One drawn mesh per MCNK cell, never one merged slab (decision 0780) β€” the exterior-scene - // cull's unit is the chunk, and a tile that loads as a single object cannot be culled from - // inside a building. The loader ships the shading (index-parallel with the chunks); the - // mesh itself is the app's paced `chunk_to_mesh` build β€” exercised per cell here. Every - // drawn cell must be a 145-vertex 9Γ—9+8Γ—8 grid. + // One mesh per drawn MCNK chunk, a 145-vertex 9Γ—9 + 8Γ—8 grid. assert_eq!(n_shading, cells.len(), "one ChunkShading per decoded chunk"); let drawn: Vec = cells .iter() @@ -640,7 +574,7 @@ mod tests { ); assert!(images.get(&alpha_h).is_some(), "alpha array present"); assert!(images.get(&shadow_h).is_some(), "shadow array present"); - // Placement lists are carried as data (count is tile-dependent β€” some tiles are bare). + // Some tiles have no placements, so the count is only printed. eprintln!( "ADT tile loaded: {} MCNK cells, {n_doodads} doodad placements", drawn.len() diff --git a/crates/benilla-assets/src/m2.rs b/crates/benilla-assets/src/m2.rs index 93fb2e7d..f8c9788f 100644 --- a/crates/benilla-assets/src/m2.rs +++ b/crates/benilla-assets/src/m2.rs @@ -1,21 +1,6 @@ -//! M2 β†’ model asset loader. -//! -//! Decodes a WoW M2 (`*.m2`) into an [`M2Model`]: one [`ModelSubmesh`] per render batch, each -//! carrying the batch's **decoded geometry** plus its texture as a `Handle` **dependency** -//! (through the `mpq://` source). **The loader ships no meshes** (decision 0834, the model-lane -//! twin of 0832's terrain rule): a labeled mesh sub-asset lands the whole model's render form in -//! ONE frame β€” the city first-contact spike β€” so the app builds each batch's `Mesh` paced at the -//! spawn side (`benilla`'s `model_forms`, via [`submesh_to_static_mesh`](crate::submesh_to_static_mesh) -//! / [`submesh_to_skinned_mesh`](crate::submesh_to_skinned_mesh)). -//! -//! Deliberately app-independent: ships **geometry + texture handles + metadata only**, not materials -//! (the WoW lighting material is an app concern built at spawn) β€” so this asset is shared verbatim by -//! doodads, creatures, GameObjects, and the star model. Creature **skin variations** (`Monster1/2/3`) -//! are blank here (no `skins` passed) and resolved at the spawn site from `CreatureDisplayInfo`; this -//! loads the model's own embedded (hardcoded) textures. -//! -//! References use `.mdx`/`.mdl`, but the physical archive file is `.m2`; callers map the extension when -//! forming the `mpq://…m2` load path (the loader registers for `m2`). +//! The M2 asset loader: one [`ModelSubmesh`] per render batch, with its decoded geometry and a +//! texture dependency but no `Mesh` (see [`ModelSubmesh`]), plus the model's rig, animations, +//! effects and cameras. Materials and creature skins are applied at the spawn site. use std::sync::Arc; @@ -41,171 +26,105 @@ use crate::model::{ upper_subtree_root, AnimClip, ModelAnimations, ModelAttachment, ModelSkeleton, ModelSubmesh, }; -/// A loaded M2 model: its render batches + authored bounds (the distance-fade size source) + the -/// coarse collision hull (the app bakes a collider from it per placement) + its particle emitters. +/// A loaded M2: its batches, bounds, collision hull, rig, animations, effects and cameras. #[derive(Asset, TypePath, Clone)] pub struct M2Model { pub submeshes: Vec, - /// Authored bounding sphere/box (model-local yards) β€” the reference's distance-fade size. `None` - /// if the header bounds couldn't be read. + /// The authored bounds (model-local yards), the reference's distance-fade size. pub bounds: Option, - /// The doodad's collision hull in raw WoW model space (trunk/solid; β‰ͺ render mesh). `None` when the - /// model carries no hull (`nBoundingTriangles == 0` β€” many small props are collide-iff-hull). + /// The collision hull in WoW model space; `None` means the model does not collide. pub collision: Option, - /// Particle emitters (flames, glows, smoke). Each carries its parsed def (positions in raw WoW - /// model space) + its resolved texture handle. Empty for most models; one or two for campfires. + /// Particle emitters, positions in WoW model space. pub emitters: Vec, - /// Ribbon emitters (weapon trails, wisp streamers, missile trails β€” the reference's - /// `CRibbonEmitter`, simulated per frame at `0x7b7e60`). Same shape as the particle emitters: - /// parsed def + resolved trail texture + the host bone's pivot for joint riding. Empty for - /// nearly everything (176 models corpus-wide author one). + /// Ribbon emitters (trails), the reference's `CRibbonEmitter` (simulated at `0x7b7e60`). pub ribbons: Vec, - /// M2 light blocks (raw WoW model space + the host bone's rest pivot). The spawn site turns each - /// casting `type==1` (point) light into a Bevy `PointLight` that lays the faithful dynamic - /// hot-spot on every lit surface (decisions 0016/0273). Empty for nearly all models; one on fire - /// props (campfire/firepit/cooker/forge) β€” and one on the held torch every torch-bearing NPC - /// carries. + /// M2 light blocks in WoW model space; fire props and held torches author a point light. pub lights: Vec, - /// The model's rest skeleton in Bevy space (decision 0019) β€” the joint tree the skinned creature - /// path spawns one entity per bone from. Empty for a boneless model. + /// The rest skeleton in Bevy space; empty for a boneless model. pub skeleton: ModelSkeleton, - /// The matching inverse bind poses (`translate(βˆ’pivot)` per bone), shared across every instance of - /// this model; each skinned submesh references it from its `SkinnedMesh`. A labeled sub-asset. + /// The inverse bind poses, `translate(βˆ’pivot)` per bone, as a labeled sub-asset. pub inverse_bindposes: Handle, - /// The model's animations, ready to play (decision 0019) β€” one shared `AnimationGraph` with every - /// sequence's clip. `None` for a model with no animated sequences (static props, boneless models). - /// Built once here; each creature instance drives it with its own `AnimationPlayer`. + /// The animations; `None` when no bone, global sequence, emitter or material loop animates. pub animations: Option, - /// The **file-order-first sequence's authored duration** (seconds, one full pass) β€” carried even - /// when no clip builds ([`ModelAnimations`] drops sequences that move no bone, and a model whose - /// sequences all sit still has no `animations` at all). The real client's effect-completion - /// clock runs one pass of the armed sequence regardless of bone keys or the LOOP flag (the fire - /// is the CM2 advance `0x7194f8`, a pure end-boundary compare; loop is tested only *after* it) - /// β€” the spell-fx self-termination reads this. Named approximation: the client arms - /// `animationLookup[0]`'s sequence, not strictly the file-order-first one; they coincide on - /// every effect model probed (single-sequence models). `None` for a model with no sequences at - /// all. + /// The first sequence's length in seconds, the spell-fx completion clock: the reference ends + /// an effect after one pass of its armed sequence, whatever its bones or loop flag + /// (`0x7194f8`). The reference arms `animationLookup[0]`'s sequence, not the first; the two + /// coincide on every effect model checked. pub first_seq_span: Option, - /// **Every sequence the file owns, in file order** β€” id, slot, length, loop β€” whatever - /// [`Self::animations`] kept a clip for. The UI model pane's clock needs exactly this - /// (decision 2008): which `AnimationData` ids the file answers to, how long each runs and - /// whether it clamps β€” for sequences that key no bone too, which `ModelAnimations` drops - /// (the cooldown indicator's sweep keys a constant on its one bone; its texture transforms - /// and colour tracks are what move). Empty for a file with no sequences. + /// Every parsed sequence in file order, listed even when [`Self::animations`] is `None`: the + /// UI model pane's clock reads them. pub sequences: Vec, - /// The model's attachment points (decision 0072 β€” held items): weapon/shield hand slots, sheath - /// points, etc. Each carries the bone it rides + its bind-pose-relative offset (see - /// [`ModelAttachment`]). Empty for a model with no attachment table (most doodads/WMO props). + /// Attachment points (hands, sheaths), each on its bone. pub attachments: Vec, - /// The animation-event table's positional markers (`$CSL`/`$CSR`/`$CST` casting hands, `$BWR` - /// ranged release, …), baked like [`Self::attachments`] and queried by 4CC first-match β€” the - /// client's `0x7130e0`/`0x7131b0` surface. Empty for a model with no events. + /// The event table's positional markers, queried by 4CC, first match (`0x7130e0`/`0x7131b0`). pub markers: Vec, - /// The model's authored **portrait camera** (`benilla_formats::M2PortraitCamera` baked to Bevy - /// space) β€” the exact rig the real client renders the unit-frame portrait through - /// (`cameraLookup[0]` via `0x713540`, `lookAt` + authored perspective at `0x7ac640`, no - /// engine-side framing on top). `None` for a model with no camera table (props, a few - /// creatures). + /// The unit-frame portrait camera, `cameraLookup[0]` (`0x713540`), rendered as authored: + /// `lookAt` and the record's perspective, with no framing on top (`0x7ac640`). pub portrait_camera: Option, - /// The camera **table's first record** β€” the glue screens' `Model:SetCamera(0)` rig (the - /// create/select background scenes; their camera *lookup* is the 0xffff none sentinel, so - /// `portrait_camera` sees nothing there). Same Bevy-space conversion as `portrait_camera`. + /// The camera table's first record, the glue scenes' `Model:SetCamera(0)`; their + /// `cameraLookup[0]` is the `0xffff` none sentinel. pub camera0: Option, - /// The camera table's **second** record β€” what a 1.12 `` UI widget (the paper doll, - /// the inspect pane, the pet page) renders through (`0x505b30` β†’ the chooser `0x505890` selects - /// **raw index 1**, the `type == 1` "characterinfo" camera, and freezes it at `0x7acf10`). - /// - /// **Raw, not through `cameraLookup`** β€” that array is the portrait bake's path and is not - /// consulted here; the index is a literal 1 whatever the record's `type` says. `None` for a - /// model with fewer than two cameras, where the client synthesizes a *fixed* camera instead - /// (transcribed at the framing site). Same Bevy-space conversion as `portrait_camera`. + /// The camera table's second record, which a `` (paper doll, inspect, pet page) + /// renders through: raw index 1, not through `cameraLookup` (`0x505b30`, chooser `0x505890`, + /// frozen at `0x7acf10`). `None` with fewer than two cameras, where the reference builds a + /// fixed camera. pub pane_camera: Option, - /// The **whole camera table**, in file order β€” the raw index space a `` widget's - /// `SetCamera(n)` walks (decision 2027). `camera0` and `pane_camera` above are the two fixed - /// indices two other paths take; this is the general one, and it is the only one that can - /// answer "how many cameras does this file have", which is the question that decides whether - /// a pane renders through the perspective leg at all (an index past the count installs a NULL - /// camera and the pane falls back to the orthographic leg). + /// The whole camera table by raw index, for `SetCamera(n)`; an index past its end leaves the + /// pane on the orthographic leg. pub cameras: Vec, - /// A bow's `$WTT`/`$WTB` bowstring anchors (the endpoints of the string drawer `0x611ff0`), - /// baked to Bevy space: `[top, bottom]` as `(bone index, model-local position)` β€” the two - /// limb-tip points the engine-drawn string spans. `None` for every non-bow model. + /// A bow's `$WTT`/`$WTB` string ends `[top, bottom]` as (bone, Bevy-space point), for the + /// string drawer `0x611ff0`. pub string_anchors: Option<[(u16, Vec3); 2]>, - /// The fishing pole's `$CCH` line anchor (read by the line draw `0x61f780`), baked to Bevy - /// space in the mesh frame β€” the rod-tip point the engine-drawn fishing line starts from. - /// `None` for every model that doesn't author it (exactly one weapon model in the chain does). + /// The fishing pole's `$CCH` rod tip, Bevy space, where the line draw `0x61f780` starts. pub cch_marker: Option, - /// MD20 header `GlobalModelFlags` (`+0x10`). Bits `&3` are the **terrain-conform gate** - /// (read by the root-matrix builder `0x7106c0`): `1` = pitch to the ground slope (every - /// mount + most quadrupeds), `3` = pitch **and** roll (kodo/crab/spider), `0`/`2` = level. - /// The entity layer reads it to conform a standing model to the terrain under it. + /// The header's `GlobalModelFlags` (`+0x10`); bits `&3` conform the model to the ground + /// (`0x7106c0`): 1 pitch, 3 pitch and roll, else level. pub global_flags: u32, } -/// One sequence of an M2, as the file table has it β€” see [`M2Model::sequences`]. +/// One sequence as the file's table has it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct M2SequenceInfo { /// The `AnimationData.dbc` id (`M2Sequence+0x00`). pub anim_id: u16, - /// The sequence's file slot β€” the axis the per-sequence material bakes are keyed on - /// ([`benilla_formats::ModelAnimation::seq_index`]). + /// The sequence's file slot, which the per-sequence bakes key on. pub seq_index: usize, /// `end βˆ’ start` on the file's timeline, ms. pub duration_ms: u32, - /// The band's **start** on the file's global timeline, ms (`M2Sequence+0x04`) β€” what a - /// per-sequence cursor is added to before an absolute-timeline track is sampled. The camera - /// tracks are the one consumer: their keys are absolute like every other M2 track's, while a - /// pane's play head is a cursor inside the armed band. + /// The band's start on the file's timeline, ms (`M2Sequence+0x04`): add it to a cursor before + /// sampling an absolute track, such as a camera's. pub start_ms: u32, /// Loops (flag bit 0 clear); a clamped sequence holds its last frame. pub looping: bool, } -/// An M2's authored portrait-camera rig in Bevy space (see [`M2Model::portrait_camera`]): eye/target -/// in model-local yards at scale 1 (the client bakes portraits with root scale reset to 1), the -/// projection scalars straight from the record. +/// An authored camera in Bevy space, at model scale 1 as the reference bakes portraits. #[derive(Clone, Copy)] pub struct PortraitCamera { pub eye: Vec3, pub target: Vec3, - /// Roll about the eyeβ†’target axis (radians) β€” rotates the up vector; `0.0` on every portrait - /// camera audited (kept for fidelity, not observed nonzero). + /// Roll about the eye-to-target axis, radians. pub roll: f32, - /// The record's FOV (radians), verbatim β€” a **diagonal** angle in the client's convention; - /// the consumer builds the client's diagonal-FOV projection from it at whichever aspect its - /// path uses (`benilla`'s `WowPortraitProjection`) β€” `1.0` for the round portrait bake, so - /// `fovy = fov/√2` (decision 1543), the pane's own rect for a `` (1089). + /// The record's field of view in radians, a diagonal angle: at aspect 1, `fovy = fov/√2`. pub fov: f32, pub near: f32, pub far: f32, } -/// One record of the file's camera table, by **raw index** β€” see [`M2Model::cameras`]. -/// -/// The rest rig ([`Self::still`]) is Bevy space like every other camera on this asset; the -/// authored tracks stay raw WoW space behind [`Self::at`], which does the conversion, so there is -/// no mixed-convention field to misread. +/// One camera-table record: [`Self::still`] in Bevy space, tracks in WoW space behind [`Self::at`]. #[derive(Clone)] pub struct PaneCamera { - /// The record's `type` word β€” `0` portrait, `1` "characterinfo", `-1` on the fly-bys and the - /// six glue scenes. Carried, never selected on: the widget's index is raw. + /// The record's `type` (0 portrait, 1 characterinfo, -1 fly-bys and glue); never selected on. pub camera_type: i32, - /// The rig at rest (bases + each track's first key), Bevy space. The whole answer for every - /// model a `` pane can name β€” a census over the composite's 9691 `.m2` found every one - /// of those cameras keying a single `(0,0,0)` on all three tracks. + /// The rig at rest (bases plus first keys); every camera a `` pane can name is static. pub still: PortraitCamera, - /// The authored tracks, raw WoW space, kept only when one of them actually moves β€” the - /// `Cameras\*.m2` fly-bys. Read through [`Self::at`]. + /// The tracks in WoW space, kept only when one moves (the `Cameras\*.m2` fly-bys). tracks: Option>, } impl PaneCamera { - /// The rig at absolute file-timeline `ms`, Bevy space β€” [`Self::still`] for a static camera, - /// the cubically sampled tracks for a moving one (`benilla_m2::M2Track::sample_ms`, the - /// reference's own four-way `interp` dispatch). - /// - /// `wow_to_bevy` is linear, so converting the summed `base + track(t)` is the same as - /// converting each β€” which is why the tracks can stay raw behind this one door. + /// The rig at file-timeline `ms`, Bevy space, sampled with the reference's four-way `interp`. + /// `wow_to_bevy` is linear, so converting `base + track(t)` once is exact. pub fn at(&self, ms: u32) -> PortraitCamera { let Some(t) = self.tracks.as_deref() else { return self.still; @@ -223,110 +142,54 @@ impl PaneCamera { } } -/// The **billboard frame** an emitter's bone chain reaches β€” the host bone's arm, its pivot in raw -/// WoW model space, and **which bone it is**. See [`ModelEmitter::billboard`] for when it is `Some` -/// and why the emitter's live origin is then `pivot + camBasisΒ·(def.position βˆ’ pivot)`. -/// -/// The bone index is what lets a **rigged** consumer be exactly right rather than approximately: -/// seating the replacement frame on that bone's own joint puts it at the composed pivot with the -/// chain above it already folded in, where placing it at `pivot` under the model root silently -/// assumes every ancestor is at rest. +/// The billboard bone an emitter's chain reaches; see [`ModelEmitter::billboard`]. #[derive(Clone, Copy, Debug)] pub struct EmitterBillboard { pub kind: benilla_formats::BillboardKind, - /// The host bone's pivot, **raw WoW model space**. + /// The bone's pivot, WoW model space. pub pivot: [f32; 3], - /// The host bone's index β€” its seat in the skeleton, for a consumer that has a rig. + /// The bone, so a rigged consumer seats the frame on its joint, the chain above folded in. pub bone: u16, } -/// One particle emitter of an [`M2Model`]: the parsed [`ParticleEmitterDef`] plus its resolved -/// particle texture as a `Handle` dependency (loaded through the `mpq://` source, like a -/// submesh albedo). The app's particle system simulates + renders it at the spawn site. +/// One particle emitter: its parsed def and its texture dependency. #[derive(Clone)] pub struct ModelEmitter { pub def: ParticleEmitterDef, pub texture: Option>, - /// The host bone's (`def.bone`) pivot, **raw WoW model space** (0130 phase 4). `def.position` is - /// model-space; `position βˆ’ bone_pivot` is the same point in the bone's own frame β€” the offset - /// an emitter riding a live joint composes through the joint's transform. Rest pose is identity - /// about the pivot, so the static path (`placement Β· position`) is the exact special case. - /// `[0; 3]` when the bone index is out of range (or the model is boneless). + /// The host bone's pivot, WoW model space: `def.position βˆ’ bone_pivot` is the emitter in the + /// bone's frame. Zero for an out-of-range bone. pub bone_pivot: [f32; 3], - /// The **billboard frame** this emitter rides, when its bone chain reaches a billboard bone - /// (`benilla_formats::Skeleton::billboard_host`): that bone's arm and its pivot, **raw WoW - /// model space**. The emitter's live origin is then camera-dependent β€” - /// `pivot + camBasisΒ·(def.position βˆ’ pivot)` β€” because the reference folds the record position - /// through the *replaced* palette matrix (`0x718960` at `0x7190a9`–`0x71910c`; the billboard - /// tail `0x715868` writes that matrix). - /// - /// A rigged host in the WORLD needs nothing from this: its joint palette already carries the - /// replacement (`billboard::billboard_joint_palette`) and the emitter rides the joint. Two - /// lanes still do: the spawn paths with **no rig** β€” an equipped item's model - /// (helm/shoulders/held), where the whole affected population's chains are otherwise rest-pose - /// (corpus-verified: 95 item models / 197 emitters ride a billboard chain, **zero** with an - /// animating one β€” `benilla-extract bbscan`) β€” and every **booth** bake, rigged or not, whose - /// pose deliberately drops the camera arms (`RigPose::without_camera_billboards`: the world - /// pass would face them at the *world* camera, which is not the one drawing a booth). `None` - /// for the ordinary case. + /// The billboard bone this emitter's chain reaches. The reference folds the position through + /// the replaced palette matrix (`0x718960` at `0x7190a9`-`0x71910c`, written by `0x715868`), + /// so the origin is `pivot + camBasisΒ·(def.position βˆ’ pivot)`. A rigged world host has it in + /// its palette already; unrigged item models and booth bakes, whose pose drops the camera + /// arms, need it. pub billboard: Option, - /// The **recursion model** (`def.recursion_model`) as a loaded dependency: its own emitters - /// become this emitter's CHILD emitters (the reference's load-completion wiring `0x7b5dd0`), - /// wired by the app's particle system once the asset resolves. `None` when unauthored. + /// The recursion model, whose emitters become this one's children (`0x7b5dd0`). pub recursion: Option>, - /// The **geometry model** (`def.geometry_model`) as a loaded dependency: this emitter's - /// particles render as 3-D instances of it instead of billboard quads (the per-particle - /// instance draw `0x7b4840`). `None` when unauthored. + /// The geometry model, drawn per particle in place of a quad (`0x7b4840`). pub geometry: Option>, - /// How far the OWNER model's own transparent-pass batches sort from its origin, model-local - /// yards β€” the bound the draw-order rung is sized from (`particles::owner_last_bias`, - /// decisions 0719/0721). Computed by [`benilla_formats::m2_owner_reach`], which is where the - /// reasoning lives. + /// How far the owner model's transparent batches sort from its origin, model-local yards: + /// the owner-last draw-order rung's size ([`benilla_formats::m2_owner_reach`]). pub owner_reach: f32, - /// The OWNER model's authored bound sphere β€” the water-plane classification input - /// (`0x7083df`–`0x7084dd`): the reference dots `world_matrix Γ— (bbox_min+bbox_max)/2` against - /// the plane once per MODEL and every emitter reads that one verdict, with the slack - /// `above ⇔ d β‰₯ βˆ’r`, `r = |matrix row 0| Γ— sphere radius`. Centre in **Bevy model space** (the - /// same frame the instance transform maps), radius model-local yards. `(ZERO, 0)` when the - /// header carries no bounds β€” the sign test at the anchor. + /// The owner model's bound sphere for the water-plane side, Bevy model space: the reference + /// classifies once per model, above when `d β‰₯ βˆ’r`, `r = |world matrix row 0| Γ— radius` + /// (`0x7083df`-`0x7084dd`). `(ZERO, 0)` without header bounds. pub water_bound: (Vec3, f32), - /// The OWNER model's **loader-idle file sequence slot** - /// ([`crate::ModelAnimations::idle_seq`]) β€” the sequence every M2 instance is playing when no - /// rig has armed anything, and therefore the slot this emitter's per-sequence rate/gate/param - /// bakes sample against by default (decision 0936). Baked onto the emitter, not asked of the - /// spawn site, because the lanes that need it most are exactly the ones with no rig to ask: - /// a content-gated GameObject (`spawn_emitter`'s `EmitClock::Host` whose player armed nothing) - /// and an unrigged placed doodad (`EmitClock::Pinned`). `0` for a model with no sequences β€” - /// the same degrade as before, now stated once instead of falling out of an `unwrap_or`. + /// The owner's loader-idle file slot ([`crate::ModelAnimations::idle_seq`]), baked here for + /// the lanes with no rig to ask; 0 when the model builds no animations. pub idle_seq: usize, } -/// Whether looping `anim` would look like anything other than the **static mesh** β€” the doodad -/// content gate (decision 0130, premise corrected by 0637). -/// -/// The original gate asked "does some track have >1 key", reasoning that a constant pose renders -/// identically to the un-rigged mesh. That premise is false: the static mesh is the **bind** pose, -/// and a constant clip only equals it when the constant *is* rest. `DuelingFlag.m2` is the -/// counter-example that cost us the bug β€” its Stand band brackets to a constant bone-0 translation -/// of `βˆ’9.124` (the flag planted in the ground) while its bind pose is 9 yards up in the air, so a -/// "constant β‡’ skip the rig" gate renders it floating. -/// -/// So: a track that MOVES qualifies, and so does a constant sitting away from rest β€” translation -/// β‰  0 (the M2 translation track is a delta on the pivot offset), rotation β‰  identity, scale β‰  1. -/// Genuinely rest-posed idles β€” the ~90% of placed doodads decision 0130 measured β€” still take the -/// static path, so that optimization survives intact. -/// -/// The predicate itself is `ModelAnimation::is_rest_pose`, beside the parse, so -/// `benilla-extract idleslotscan`'s corpus census of this gate's reach can't drift from the gate -/// (decision 0936). What it decides is **whether to build a rig**, and nothing else β€” the sequence -/// the instance is playing is [`ModelAnimations::idle_seq`], which has an answer either way. +/// The doodad content gate: whether looping `anim` differs from the static mesh, the bind pose. +/// A constant pose away from rest counts: `DuelingFlag.m2`'s Stand holds the flag 9 yd below its +/// bind pose. It decides the rig only; the playing sequence is [`ModelAnimations::idle_seq`]. fn idle_pose_differs(anim: &benilla_formats::ModelAnimation) -> bool { !anim.is_rest_pose() } -/// A model reference inside an M2 record (`.mdx`/`.mdl`, mixed case, backslashes) β†’ its -/// `mpq://…m2` load URL β€” the same extension remap the client's loader does (the physical -/// archive file is `.m2`). +/// A model path inside an M2 record (`.mdx`/`.mdl`, backslashes) as its `mpq://….m2` URL. fn m2_dep_url(raw: &str) -> String { let p = raw.to_ascii_lowercase().replace('\\', "/"); let stem = p @@ -337,32 +200,20 @@ fn m2_dep_url(raw: &str) -> String { format!("mpq://{stem}.m2") } -/// One ribbon emitter of an [`M2Model`]: the parsed [`benilla_formats::RibbonEmitterDef`] plus its -/// resolved trail texture and β€” like [`ModelEmitter::bone_pivot`] β€” the host bone's rest pivot, so -/// the spawn site can ride the bone's joint with `position βˆ’ pivot` in the joint frame (the -/// reference transforms the bone-local position by the live bone matrix each frame, `0x718960`). +/// One ribbon emitter, riding its bone like [`ModelEmitter::bone_pivot`] (`0x718960`). #[derive(Clone)] pub struct ModelRibbon { pub def: benilla_formats::RibbonEmitterDef, pub texture: Option>, pub bone_pivot: [f32; 3], - /// The owner model's reach β€” same field, same bound, same reason as - /// [`ModelEmitter::owner_reach`]. A trail is one of its model's emitters and takes the same - /// owner-last draw-order rung: measured on the wisp (14 blend batches, 3 streamers), its - /// trails otherwise interleave with its own body, 6 batches deep, and which batches are over - /// which streamer *changes frame to frame* as they whip (decision 0721). + /// As [`ModelEmitter::owner_reach`]: a trail takes its model's owner-last rung too. pub owner_reach: f32, - /// The owner model's bound sphere for the water-plane side β€” same field, same law as - /// [`ModelEmitter::water_bound`]: the ribbon leg reads the MODEL's side-A boolean verbatim - /// (`cmp [ebp-0x24],0` at `0x7081f1`), slack included. + /// As [`ModelEmitter::water_bound`]; the ribbon leg reads the model's verdict too (`0x7081f1`). pub water_bound: (Vec3, f32), } -/// One M2 light block of an [`M2Model`]: the raw [`M2Light`] record plus β€” like -/// [`ModelEmitter::bone_pivot`] β€” its host bone's rest pivot, so a light on an ANIMATING model can -/// ride that bone's joint with `position βˆ’ pivot` in the joint frame. The reference does exactly -/// this every frame (`0x718960` transforms the def position by the live bone matrix before -/// registering the light), which is how a torch's glow tracks the hand that swings it. +/// One M2 light and its bone's rest pivot: the reference moves the light with the live bone each +/// frame (`0x718960`), so a torch's glow follows the hand. #[derive(Clone, Copy)] pub struct ModelLight { pub def: M2Light, @@ -388,16 +239,12 @@ impl AssetLoader for M2ModelLoader { reader.read_to_end(&mut bytes).await?; let to_io = |e: anyhow::Error| std::io::Error::other(format!("{e:#}")); - // `dir` only feeds creature skin-variation resolution, which is inert here (we pass no skins β€” - // skins are applied at the spawn site from CreatureDisplayInfo), so an empty dir is correct. + // Skins fill at the spawn site, so no skin directory or list is passed. let subs = parse_m2_render_submeshes(&bytes, "", &[]).map_err(to_io)?; let bounds = parse_m2_bounds(&bytes).ok(); - // The owner-last draw-order bound, once for the whole model: every effect this model - // authors (quad emitters, ribbon trails, model-particle instances) takes the rung it sizes, - // because the reference draws them all in the one post-batch bracket. + // One owner bound for every effect: the reference draws them all after the batches. let owner_reach = benilla_formats::m2_owner_reach(&subs); - // The water-plane classification sphere, once for the whole model β€” the header AABB's - // midpoint (Bevy model frame) + the header sphere radius. See [`ModelEmitter::water_bound`]. + // The water-plane sphere: the header box's midpoint and the header sphere's radius. let water_bound = bounds.as_ref().map_or((Vec3::ZERO, 0.0), |b| { ( wow_to_bevy([ @@ -408,25 +255,15 @@ impl AssetLoader for M2ModelLoader { b.sphere_radius, ) }); - // The collision hull (collide-iff-hull): keep only a non-empty mesh so the spawn site can skip - // hull-less props cheaply. + // An empty hull is no hull: the model does not collide. let collision = parse_m2_collision_hull(&bytes) .ok() .filter(|c| !c.is_empty()); let mut submeshes = Vec::with_capacity(subs.len()); for sub in subs { - // No meshes built here (decision 0834): the geometry ships on the submesh and the app - // builds the render form paced β€” the static mesh on demand, the skinned twin only for - // the lanes that rig (which also retires the old always-built, mostly-unused twin). - // - // Embedded texture β†’ an mpq:// dependency (WorldArt, the loader default). Lowercase the - // path: Bevy's loader lookup is case-sensitive, and these tables carry uppercase `.BLP` - // (the `BlpImageLoader` registers `blp`), so an uppercase extension silently falls back to - // type-based resolution β€” which is ambiguous with Bevy's built-in image loader and spams a - // "Multiple AssetLoaders found" warning per texture. Lowercasing also dedupes case variants. - // The URL carries the batch's authored sampler address mode, because two modes of one - // `.blp` are two different `Image` uploads (decision 0763 β€” `crate::texture_url`). + // The URL is lowercased, since Bevy matches the `blp` extension case-sensitively, and + // carries the wrap modes, since two modes of one texture are two uploads. let texture = sub .texture .as_deref() @@ -461,19 +298,11 @@ impl AssetLoader for M2ModelLoader { geometry: std::sync::Arc::new(sub), }); } - // M2 light blocks (raw β€” wow-m2's light parse reads the wrong vanilla record shape). The spawn - // site filters to casting point lights ([`M2Light::casts`]). Empty for nearly all models. - // The bone-pivot bake is below, once the skeleton is parsed. + // The spawn site keeps the casting point lights (`M2Light::casts`). let light_defs = parse_m2_lights(&bytes); - // The rest skeleton (decision 0019): bake the raw bone tree to Bevy-space joints + the matching - // inverse bind poses. A boneless model yields an empty skeleton + an empty bindpose set (the - // skinned mesh then carries no joint attributes, so it never takes the SKINNED path). let skeleton_raw = parse_m2_skeleton(&bytes).unwrap_or_default(); - // Particle emitters: resolve each emitter's texture to an `mpq://` image dependency (same - // lowercase normalisation as submesh albedos), and bake its host bone's pivot so the spawn - // site can ride a live joint (see [`ModelEmitter::bone_pivot`]). Absent on most models. let mut emitters: Vec = parse_m2_particle_emitters(&bytes) .unwrap_or_default() .into_iter() @@ -488,8 +317,6 @@ impl AssetLoader for M2ModelLoader { .bones .get(def.bone as usize) .map_or([0.0; 3], |b| b.pivot); - // The recursion/geometry models load as ordinary mpq:// M2 dependencies β€” - // children wiring and model-particle instancing happen app-side on resolve. let recursion = def .recursion_model .as_deref() @@ -498,7 +325,6 @@ impl AssetLoader for M2ModelLoader { .geometry_model .as_deref() .map(|p| ctx.load::(m2_dep_url(p))); - // The billboard frame the emitter's bone chain inherits, if any (see the field). let billboard = skeleton_raw.billboard_host(def.bone).and_then(|h| { let b = skeleton_raw.bones.get(h)?; Some(EmitterBillboard { @@ -521,8 +347,6 @@ impl AssetLoader for M2ModelLoader { }) .collect(); - // Ribbon emitters (trails): same texture resolution, bone-pivot bake and owner-reach bake - // as the particles β€” a trail is one of the model's emitters and draws under the same law. let ribbons: Vec = benilla_formats::parse_m2_ribbon_emitters(&bytes) .unwrap_or_default() .into_iter() @@ -547,8 +371,6 @@ impl AssetLoader for M2ModelLoader { }) .collect(); - // M2 lights: the same host-bone pivot bake, so a light on an animating model (the torch in - // an NPC's hand) can ride its bone's joint instead of freezing at the rest-pose spot. let lights = light_defs .into_iter() .map(|def| ModelLight { @@ -565,25 +387,17 @@ impl AssetLoader for M2ModelLoader { SkinnedMeshInverseBindposes::from(inverse_bindposes), ); - // The attachment-point table (decision 0072 β€” held items): the same bind-pose pivots - // `build_skeleton` used above, so a held item's offset is exactly consistent with the joint - // it's spawned under. Empty for a boneless/attachment-less model. + // Attachments, markers and clip events all take the pivots the joints were built from. let attachments = build_attachments( &parse_m2_attachments(&bytes).unwrap_or_default(), &skeleton_pivots(&skeleton_raw), ); - // The event-marker table, same bake (the missile-launch $CSL/$CSR/$CST/$BWR points). let markers = crate::model::build_markers( &benilla_formats::parse_m2_event_markers(&bytes).unwrap_or_default(), &skeleton_pivots(&skeleton_raw), ); - // …and the same pivots again for the per-clip event keys below ([`crate::ClipEvent`]), - // so a fired key's point is bake-consistent with the joint it composes through. let pivots = skeleton_pivots(&skeleton_raw); - // The animations (decision 0019): build each sequence's clip into one shared `AnimationGraph` - // (the bench scrubs them; gameplay plays Stand). All as labeled sub-assets. `None` unless at - // least one sequence produced an animated clip. let sequences = parse_m2_animations(&bytes); let first_seq_span = sequences.first().map(|a| a.duration).filter(|d| *d > 0.0); let sequence_infos: Vec = sequences @@ -599,19 +413,13 @@ impl AssetLoader for M2ModelLoader { let animations = { let mut graph = AnimationGraph::new(); let root = graph.root; - // The direct pose evaluator's bake (decision 0712) β€” filled beside every graph - // mutation below so the table mirrors the graph by construction: `bone_masks` beside - // each `add_target_to_mask_group`, `set_node` beside each `add_clip*`. + // The pose bake mirrors the graph: update it beside every mask group and `add_clip*`. let mut pose = crate::model::PoseSource { bone_masks: vec![0u64; skeleton_raw.bones.len()], ..Default::default() }; - // Per-arm mask groups for the client's per-slot one-shots (the draw/stow plays each - // hand's ceremony on its own arm subtree, over the gait). Each arm's subtree root is the - // **shoulder-keybone ancestor of the hand attachment's bone** (attachment id 1 = right - // hand, 2 = left; keybones 2/3 = the shoulders β€” data-driven, no left/right guessing). - // Every joint *outside* an arm joins that arm's mask group, so a node masked with group - // 0 (or 1) animates only the right (or left) arm. + // Groups 0 and 1 hold every joint outside the right and the left arm, so a node masked + // with one animates only that arm. let arm_roots = arm_subtree_roots(&skeleton_raw, &attachments); if let Some((right_root, left_root)) = arm_roots { for i in 0..skeleton_raw.bones.len() { @@ -626,9 +434,7 @@ impl AssetLoader for M2ModelLoader { } } } - // The upper-body one-shot mask group (decision 0087): group 2 collects every joint - // *outside* the SpineLow subtree (the legs + pelvis), so a clip masked with it animates - // only the upper body β€” the general form of the per-arm ceremony above, one subtree wider. + // Group 2 holds every joint outside the upper body: the legs and the pelvis. let upper_root = upper_subtree_root(&skeleton_raw); if let Some(upper_root) = upper_root { for i in 0..skeleton_raw.bones.len() { @@ -638,10 +444,8 @@ impl AssetLoader for M2ModelLoader { } } } - // Per-hand **finger** mask groups (3 = right hand, 4 = left) for the weapon grip: a clip - // masked with group 3 (or 4) animates only that hand's finger key-bone subtrees, so the - // `HandsClosed` pose curls the fingers while the arm keeps its gait (`CloseHand` - // `0x479660`). Empty per hand for a model with no finger key-bones (beasts). + // Groups 3 and 4 hold every joint outside the right and the left fingers, for the + // weapon grip (`CloseHand`, `0x479660`). let finger_roots = finger_subtree_roots(&skeleton_raw); for i in 0..skeleton_raw.bones.len() { let target = bone_target_id(i as u16); @@ -662,39 +466,24 @@ impl AssetLoader for M2ModelLoader { pose.bone_masks[i] |= 1 << 4; } } - // The model's baked PlayableAnimationLookup (decision 0082): parsed straight off the M2 - // header alongside the sequences above, so a model lacking a requested clip can resolve - // to its own baked substitute at play time (`ModelAnimations::resolve`) instead of the - // creature driver silently doing nothing. Read BEFORE the clip walk because the - // loader-idle seed below resolves through it. + // Read before the clip walk: the loader-idle seed resolves through it. let playable_animation_lookup = parse_m2_playable_animation_lookup(&bytes).unwrap_or_default(); - // The model's own "do I author id X" table (the reference's `0x711960`) β€” the - // GameObject arm's missing-sequence remap branches on it (`ModelAnimations::owns`). let animation_lookup = benilla_formats::parse_m2_animation_lookup(&bytes).unwrap_or_default(); let mut clips = Vec::new(); - // The loader-idle seed (decision 0637, `0x71019b`): the loader arms **animation id 0 - // ("Stand")** resolved through the model's own `playableAnimationLookup`, NOT the - // file-order-first sequence. For the overwhelming majority of models those coincide, - // which is why the old file-order read survived; they diverge exactly on models whose - // first sequence is a Spawn β€” `DuelingFlag.m2` is Spawn(145)/Stand(0)/Despawn(157), and - // looping its Spawn band left the flag hanging 9 yards in the air on a 3.3 s cycle. + // The loader-idle seed (`0x71019b`): id 0 via the playable lookup, not file slot 0. let idle_id = playable_animation_lookup .first() .map_or(0, |p: &benilla_formats::PlayableAnim| p.resolved_id); let mut first_seq = None; for (i, anim) in sequences.iter().enumerate() { - // EVERY sequence becomes a clip β€” the clip carries the instance's sequence CLOCK, - // not just a bone pose (decision 0941, [`build_animation_clip`]). `poses_bones` - // separates the two meanings: the clock is free, the rig is not. + // Every sequence becomes a clip, since the clip is the sequence clock; + // `poses_bones` says whether it also needs a rig. { let (clip, pose_clip, poses_bones) = build_animation_clip(anim, &skeleton); - // `first_seq` stays the RENDERING question β€” "is this seed worth a skin + - // player" (0130's content gate, and decision 0936's split). The identity - // question, "which sequence does the loader arm", is - // [`ModelAnimations::idle_seq`]/`idle_clip`, which asks the same selection - // without the gate. + // `first_seq` asks whether the seed needs a rig; `ModelAnimations::idle_seq` + // makes the same selection without that gate. if first_seq.is_none() && anim.anim_id == idle_id && idle_pose_differs(anim) { first_seq = Some(clips.len()); } @@ -703,7 +492,7 @@ impl AssetLoader for M2ModelLoader { let clip = ctx.add_labeled_asset(format!("clip{i}"), clip); let node = graph.add_clip(clip.clone(), 1.0, root); pose.set_node(node, pose_idx, 0); - // The sheath family gets per-arm masked variants ([`AnimClip::arm_nodes`]). + // The sheath family (89/90) gets per-arm masked nodes. let arm_nodes = (matches!(anim.anim_id, 89 | 90) && arm_roots.is_some()).then(|| { let nodes = ( @@ -714,10 +503,8 @@ impl AssetLoader for M2ModelLoader { pose.set_node(nodes.1, pose_idx, 1 << 1); nodes }); - // The upper-body masked variant ([`AnimClip::upper_node`]): the masked destination - // for a one-shot routed over a live base (swing/emote β€” the route is chosen per play - // in `creature_anim`). Built for every clip when the model has a split key-bone, so - // any one-shot id can take the masked route; `None` on the βˆ’1-sentinel models. + // With an upper-body root, every clip gets an upper-body node, so a one-shot + // can play over a live base. let upper_node = upper_root.map(|_| { let n = graph.add_clip_with_mask(clip.clone(), 1 << 2, 1.0, root); pose.set_node(n, pose_idx, 1 << 2); @@ -737,10 +524,8 @@ impl AssetLoader for M2ModelLoader { // The axis mapping flips signs, so min/max are re-derived componentwise. bounds_min: wow_to_bevy(anim.bounds_min).min(wow_to_bevy(anim.bounds_max)), bounds_max: wow_to_bevy(anim.bounds_min).max(wow_to_bevy(anim.bounds_max)), - // The kernel's own quantity, baked (see [`ClipEvent`]): the bone-local - // offset for a rigged model and the model-space point for one whose bone - // chain never moves β€” both from the record's OWN bone, never a by-4CC - // re-find, because a model may author the tag more than once. + // `offset` and `point` both use the record's own bone, never a by-4CC + // lookup: a model may author a tag more than once. events: anim .events .iter() @@ -763,16 +548,11 @@ impl AssetLoader for M2ModelLoader { }); } } - // The per-hand weapon-grip nodes ([`ModelAnimations::hand_close`]): a purpose-built grip clip - // β€” each finger key-bone posed at its **clamped** `HandsClosed` value β€” masked to one hand's - // finger subtrees (group 3 = right, 4 = left). Built separately from the sequence clips above - // because the general in-band read drops the weapon-hand fingers (keyed off the HandsClosed - // frame); [`hand_grip_finger_poses`] clamps them, scoped to the grip (`CloseHand` - // `0x479660`). `None` per hand for a model with no finger key-bones / no grip pose. + // The grip is its own clip of clamped `HandsClosed` finger poses: the sequence read + // drops fingers keyed off that frame, which `hand_grip_finger_poses` clamps. let mut hand_close: [Option; 2] = [None, None]; - // Every bone in a finger subtree β€” the key-bone AND its child segments (the fingertip joints), - // so the whole finger curls, not just its base (HandsClosed keys the tips too, ~51Β° vs ~30Β°). + // Every bone of each finger subtree, tips included, since `HandsClosed` keys them too. let finger_bones: Vec = (0..skeleton_raw.bones.len()) .filter(|&i| { finger_roots @@ -799,22 +579,11 @@ impl AssetLoader for M2ModelLoader { hand_close[1] = Some(n); } } - // Global-sequence channels alone are enough to carry `ModelAnimations`: a model whose - // sequences produced no clips can still pulse/flicker on its free-running loops (the - // doodad `GlobalSeqOnly` tier, decision 0130) β€” dropping them because `clips` came up - // empty would silently freeze it. + // Global-sequence channels alone earn `ModelAnimations` (the `GlobalSeqOnly` tier). let global_bones = build_global_bones(&parse_m2_global_sequence_bones(&bytes), &skeleton); - // The gate is "does ANYTHING in this model vary with the sequence clock" β€” and its - // consumer list was incomplete (decision 0941). A bone-posing clip and a global - // sequence were counted; the **per-sequence samplers** were not: the emitters' - // rate/enable/params tracks, a ribbon's, and the material alpha/colour/UV loops all - // resolve through the instance's playing sequence. 807 corpus models animate ONLY - // through those, and denying them `ModelAnimations` denied them the clock itself β€” - // no player, no slot, no time β€” so every one of those tracks read file slot 0 at - // t=0 for ever. Models with none of the three keep the static path: the optimization - // decision 0130 measured (~90% of placed doodads) is untouched, and 6342 boneless - // models with nothing to sample stay out of the animation lane entirely. + // A model animates if a bone poses, a global sequence runs, or something samples the + // sequence clock: emitters, ribbons, or a material's alpha, colour or UV loop. let samples_sequence = !emitters.is_empty() || !ribbons.is_empty() || submeshes @@ -838,19 +607,13 @@ impl AssetLoader for M2ModelLoader { }) }; - // Stamp every emitter with the model's loader-idle slot (decision 0936) β€” the sequence a - // rig-less instance is playing, and so the slot its rate/gate/param bakes sample by - // default. Done here rather than in the map above because the selection needs the built - // clips (their `seq_index` + the `playableAnimationLookup` resolve), and only `None` when - // the model builds no clip at all β€” nothing to name, so the historical slot 0 stands. + // Stamped only now: the idle selection needs the built clips. let idle_seq = animations.as_ref().and_then(ModelAnimations::idle_seq); for e in &mut emitters { e.idle_seq = idle_seq.unwrap_or(0); } - // The authored portrait camera (raw WoW model space β†’ Bevy, same pure rotation the meshes - // bake through, so eye/target land in exactly the submeshes' frame) β€” and the camera - // table's first record, the glue screens' `SetCamera(0)` rig. + // Cameras take the meshes' WoW-to-Bevy rotation, so eye and target share their frame. let to_bevy = |c: benilla_formats::M2PortraitCamera| PortraitCamera { eye: wow_to_bevy(c.position), target: wow_to_bevy(c.target), @@ -862,7 +625,6 @@ impl AssetLoader for M2ModelLoader { let portrait_camera = parse_m2_portrait_camera(&bytes).map(to_bevy); let camera0 = benilla_formats::parse_m2_camera(&bytes, 0).map(to_bevy); let pane_camera = benilla_formats::parse_m2_camera(&bytes, 1).map(to_bevy); - // The whole table, raw index β€” the `` widget's `SetCamera(n)` space (2027). let cameras: Vec = benilla_formats::parse_m2_pane_cameras(&bytes) .into_iter() .map(|c| PaneCamera { @@ -872,7 +634,6 @@ impl AssetLoader for M2ModelLoader { }) .collect(); - // The bowstring anchors (bows only): raw WoW positions β†’ Bevy, the meshes' frame. let string_anchors = benilla_formats::parse_m2_string_anchors(&bytes).map(|a| { [ (a.top.0, wow_to_bevy(a.top.1)), @@ -880,11 +641,9 @@ impl AssetLoader for M2ModelLoader { ] }); - // The fishing line's near anchor (mesh frame, like the bowstring anchors). let cch_marker = benilla_formats::parse_m2_cch_marker(&bytes).map(|(_, p)| wow_to_bevy(p)); - // The header GlobalModelFlags word sits at a fixed offset (MD20 magic @0, +0x10) β€” a - // bare scalar the sectioned parsers never touch. + // `GlobalModelFlags` sits at MD20 `+0x10`, outside every sectioned parser. let global_flags = bytes .get(0x10..0x14) .map_or(0, |b| u32::from_le_bytes(b.try_into().unwrap())); diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs index dbcb2531..421def70 100644 --- a/crates/benilla-assets/src/materials.rs +++ b/crates/benilla-assets/src/materials.rs @@ -1,29 +1,8 @@ -//! **The render materials** β€” the four `ExtendedMaterial`s a WoW 1.12.1 world is drawn with -//! (terrain splat, M2/WMO model, WDL far band, liquid surface) and the WGSL each one binds. +//! The render materials: the four `ExtendedMaterial`s the world is drawn with (terrain splat, +//! M2/WMO model, WDL far band, liquid surface) and the WGSL each one binds. //! -//! They live *below* the renderer, in the crate that turns WoW files into GPU resources, because -//! that is what they are: the thing an `.m2` submesh or an `.adt` chunk BECOMES. Decision 1164 β€” -//! the material type is the value type of the shared dedup cache (`WorldAssets::model_materials`), -//! and 26 gameplay files name `WowModelMaterial` for spell effects, equipment and portraits with -//! no terrain streamer anywhere in sight. Verified before the move: this file names nothing from -//! the client at all β€” its whole import surface is `bevy::*` β€” so it is a leaf that belongs here, -//! not a piece of the renderer dragged down with the caches. -//! -//! **The WGSL is embedded, not served.** `shaders/*.wgsl` next to this file are compiled in via -//! [`register_shaders`] and addressed as `embedded://benilla_assets/shaders/…`. A relative -//! `"shaders/x.wgsl"` would resolve against whatever `AssetPlugin::file_path` the *host binary* -//! happened to set, so a second program standing on this crate would get a material that silently -//! renders nothing β€” the same trap `boot.rs` already documents for the capture harness. Embedding -//! makes the crate answer for its own shaders. -//! -//! Phase 6 terrain splat material: an [`ExtendedMaterial`] over `StandardMaterial` that blends up -//! to 4 tiled layer textures by a packed alpha map, keeping PBR lighting/shadows/fog. -//! -//! **Per-tile, not per-chunk.** A whole ADT tile shares ONE material: its ground textures are stacked -//! into a `layer_array` (`texture_2d_array`) and its per-chunk alpha maps into an `alpha_array`. Each -//! merged-mesh vertex carries its chunk's 4 layer indices (vertex `COLOR`) + alpha-layer index -//! (`UV1.x`), so all 256 chunks draw from one material β€” letting Bevy batch the tile into a single -//! draw instead of 256. (The old design bound 4 textures + an alpha map per chunk β†’ a material each.) +//! The WGSL is embedded (`embedded://benilla_assets/shaders/…`), not served: a relative path would +//! resolve against the host binary's `AssetPlugin::file_path` and render nothing elsewhere. use bevy::image::Image; use bevy::mesh::MeshVertexBufferLayoutRef; @@ -38,9 +17,8 @@ use bevy::render::render_resource::{ }; use bevy::shader::ShaderRef; -/// Compile the four WGSL files into the binary and register them under -/// `embedded://benilla_assets/shaders/…`. Call **after** Bevy's `AssetPlugin` (it fills the -/// registry that plugin creates); [`crate::register_asset_loaders`] already does. +/// Compile the four WGSL files into the binary under `embedded://benilla_assets/shaders/…`. Call +/// after Bevy's `AssetPlugin`, whose registry this fills; [`crate::register_asset_loaders`] does. pub fn register_shaders(app: &mut App) { bevy::asset::embedded_asset!(app, "shaders/terrain.wgsl"); bevy::asset::embedded_asset!(app, "shaders/wow_model.wgsl"); @@ -48,84 +26,46 @@ pub fn register_shaders(app: &mut App) { bevy::asset::embedded_asset!(app, "shaders/liquid.wgsl"); } -/// The WDL far-band shader's source, for the law tests that live beside the renderer rather than -/// beside the material (`wdl.rs`). Exported instead of letting a sibling crate `include_str!` its -/// way across the workspace by relative path. +/// The WDL far-band shader's source, for the tests that live beside the renderer (`wdl.rs`). pub const WDL_WGSL: &str = include_str!("shaders/wdl.wgsl"); -/// Alpha-test reference for `Blend_AlphaKey` (M2 blend mode 1) materials β€” the value below which a -/// fragment is discarded (`D3DCMP_GREATEREQUAL`). Per wowdev.wiki M2/Rendering this is **version -/// dependent**: `224/255 β‰ˆ 0.878` on **≀ WotLK** (our target is 1.12.1 build 5875), vs `128/255 β‰ˆ -/// 0.5` on Cata+. We initially hardcoded the Cata value (0.5), which left foliage too dense and a -/// white fringe on cutout edges. The spec multiplies this by the element's animated alpha (1.0 for -/// static doodads), so the bare constant is correct until we add doodad alpha fades. -/// Source: Β§ Alpha Testing. +/// Alpha-test reference for blend mode 1 (`Blend_AlphaKey`): 224 in the reference's +/// per-blend-mode table at `0x85ad20`, `{0, 224, 1, 1, 1, 1, 1, 0, 0, 0, 0}`. Must stay in sync +/// with `VANILLA_ALPHA_KEY` in `shaders/wow_model.wgsl`. pub const VANILLA_ALPHA_KEY_REF: f32 = 224.0 / 255.0; -/// `StandardMaterial` + our per-tile layer-blend extension. +/// `StandardMaterial` plus the per-tile layer-blend extension. pub type TerrainMaterial = ExtendedMaterial; -/// World models (doodads/WMO/creatures/GameObjects) lit by the **same WoW lighting as terrain** β€” -/// `tex Γ— saturate(ambient + diffuseΒ·NΒ·L) Γ— scale` in gamma space β€” instead of PBR. Reuses -/// `StandardMaterial` for the texture/alpha/cull (set in `model_material`); the extension just -/// carries the shared light. Keeping models and terrain on one lighting model is what makes the -/// scene coherent (PBR couldn't clamp, so it tinted models orange). +/// World models (doodads, WMOs, creatures, GameObjects) lit in gamma space like terrain, not by +/// PBR; `StandardMaterial` carries only the texture, alpha mode and culling. pub type WowModelMaterial = ExtendedMaterial; -/// Pipeline-specialization key for [`WowModelExt`] β€” picks the two distance-fade pipeline tweaks Bevy -/// has no built-in `AlphaMode` for. Both make the fade **OPACITY** with **depth-write ON**, matching the -/// reference: -/// - `fade` (`model_flags.y`) = the **M2-doodad** fade blend twin: `AlphaMode::Blend` (transparent pass, -/// depth-write normally OFF) β†’ `specialize` forces depth-write back ON so a fading haystack's near -/// cross-quads occlude its far ones. Silhouette stable for an AlphaKey source (the shader re-applies -/// the 224/255 cutout via `clutter_fade.z` bit 10 β€” an Opaque source never alpha-tests; 0842). -/// - `clutter` (`clutter_fade.w`) = ground clutter: an `AlphaMode::Mask` (alpha-mask pass, depth-write -/// already ON) that the reference also **blends** (prog 201: `SRC_ALPHA/ONE_MINUS_SRC_ALPHA`) so the -/// ~70 yd ramp fades opacity. `specialize` forces that over-blend on in place; the 128/255 discard + -/// `tex Γ— ramp` output alpha are already done in the shader. +/// Pipeline-specialization key for [`WowModelExt`]: the depth and blend states Bevy's `AlphaMode` +/// cannot express. `fade` (`model_flags.y`) is the M2 doodad fade twin and `clutter` +/// (`clutter_fade.w`) ground clutter; both fade opacity with depth-write on, as the reference does. #[derive(Clone, Copy, PartialEq, Eq, Hash)] pub struct WowModelKey { fade: bool, clutter: bool, - /// Additive glow card (`model_flags.w == 2.0`) β€” `specialize` forces a `SrcAlpha`-additive blend - /// state (`rgbΒ·Ξ± + dst`) so the glow texture's radial alpha shapes a soft halo. See `model_render`. + /// Additive glow card (`clutter_fade.z` bit 2). additive: bool, - /// M2 render flag 0x10 β€” disable depth WRITE for this batch (packed into `clutter_fade.z` bit 0 by - /// `model_material`; `clutter_fade.z` is unread by the shader, so it carries this Rust-side marker). + /// M2 render flag 0x10, no depth write (`clutter_fade.z` bit 0). no_depth_write: bool, - /// M2 render flag 0x08 β€” disable depth TEST (packed into `clutter_fade.z` bit 1). + /// M2 render flag 0x08, no depth test (`clutter_fade.z` bit 1). no_depth_test: bool, - /// The MULTIPLY blends (clutter_fade.z bits 7/8; decision 0528) β€” `specialize` swaps the blend - /// state to the reference's factors: Mod `DST_COLOR/ZERO`, Mod2x `DST_COLOR/SRC_COLOR` - /// (`0x70c190`). Exact on the 0161 gamma lane β€” the framebuffer holds gamma values, so the - /// hardware multiply IS the reference's byte multiply. + /// The multiply blends, Mod and Mod2x (`clutter_fade.z` bits 7/8). modulate: bool, modulate2x: bool, - /// Depth-prime twin (`clutter_fade.z` bit 9 β€” `model_render::zfill_material`, the reference's - /// `M2UseZFill` clone command, emitted at `0x707f7d`–`0x708072`): `specialize` masks the - /// colour writes off, turns blend off, and forces depth-write ON. + /// The depth-prime twin (`clutter_fade.z` bit 9, `model_render::zfill_material`). zfill: bool, - /// Far side of the water plane (`clutter_fade.z` bit 11 β€” `model_render`'s far twin, the - /// water-plane interleave's mesh lane): the material's huge negative SORT bias must stay - /// sort-only, so `specialize` zeroes the rasterizer `DepthBiasState` constant the base - /// `StandardMaterial` derived from the same field (at βˆ’4e4 that constant is a ~0.5% relative - /// depth pull β€” enough to clip a blade's coplanar sheen/glow layers behind the blade itself). + /// Far side of the water plane (`clutter_fade.z` bit 11). far_side: bool, - /// The **WMO-skybox lane** (`clutter_fade.z` bit 13 β€” `model_render::SKY_DEPTH_MARKER`): the - /// building-owned painted sky, drawn as the ordinary M2 it is. `specialize` compiles the - /// `WOW_SKY_DEPTH` def into BOTH stages: the vertex pins clip z to 0 β€” reverse-Z far, the sky - /// depth law every other sky shader obeys (`benilla_world::sky_order`) β€” and the fragment - /// writes no depth at all (it did until 2016, at the cost of the pipeline's early-Z). It is a - /// key axis because the pin is one model's: every other draw on this lane keeps its real - /// depth. Like [`Self::far_side`] it also zeroes the rasterizer bias constant: the lane's rung - /// is a SORT rung (`sky_order::WMO_SKYBOX_BIAS`, ~βˆ’6e4), and the pinned depth is a constant - /// the rasterizer must not be perturbing behind the shader's back. + /// The WMO-skybox lane (`clutter_fade.z` bit 13, `model_render::SKY_DEPTH_MARKER`), a key axis + /// because only this model's depth is pinned. sky_depth: bool, - // NB: the WMO authored batch order is deliberately NOT a key axis. It used to be (a - // per-batch-index `DepthBiasState` constant), which made every batch index its own pipeline β€” - // the city first-sight compile stall (decision 0837). The coplanar-layering nudge now rides - // `sun_scale.y` into `wow_model.wgsl`'s vertex stage as uniform data; `model_render::MatKey` - // still dedups materials per order, so per-batch identity is intact. + // The WMO batch order is deliberately not a key axis (a pipeline per batch index stalls a + // city's first sight): the file-order layering (`0x6b4f10`/`0x6b5190`) rides `sun_scale.y`. } impl From<&WowModelExt> for WowModelKey { @@ -146,90 +86,43 @@ impl From<&WowModelExt> for WowModelKey { } } -/// The WoW light, shared by every model material. **All Vec4 uniforms merge onto one binding -/// (100)** β€” `AsBindGroup` packs multiple `#[uniform(N)]` fields at the same `N` into a single -/// buffer entry, which is what keeps the model pipeline under Metal's 16-buffer vertex-stage cap. -/// (Bevy 0.18's AsBindGroup macro hardcodes uniform-binding visibility to `FRAGMENT|VERTEX|COMPUTE` -/// regardless of `visibility(fragment)`, so the only effective lever for the BUFFER count is the -/// number of bindings β€” pack, don't narrow.) The WGSL struct order **must** match the field order -/// below (each Vec4 is 16 bytes, no implicit padding). +/// The model material's extension. Every `Vec4` uniform packs into binding 100, one buffer entry, +/// to stay under Metal's 16-buffer vertex-stage cap (Bevy 0.18 ignores `visibility` on uniforms); +/// `ModelParams` in `wow_model.wgsl` must match the field order. #[derive(Asset, AsBindGroup, Clone, TypePath)] #[bind_group_data(WowModelKey)] pub struct WowModelExt { - /// Ground-clutter distance fade: `x` = full-opacity radius (yd), `y` = fully-faded radius (yd), - /// `w` = enabled (>0.5). `Vec4::ZERO` (the default for trees/WMOs/creatures) disables it; clutter - /// sets it to the detail-doodad horizon (~52.5β†’70 yd) so grass erodes out with distance. Per-material - /// (set at creation, not light) so it stays on the cheap packed uniform, not the shared buffer. + /// Clutter fade: `x`, `y` = full-opacity and fully-faded view depth (yd; the ~70 yd horizon), + /// `w` = clutter, all zero off clutter; `z` = the batch marker bits. #[uniform(100)] pub clutter_fade: Vec4, - /// Per-material flags (set at material creation, NOT light). `x` = **is_wmo** (>0.5 β‡’ FFP directional - /// `ambient + sunΒ·NΒ·L` Γ— MOCV at sun-scale 1, no exterior terrain-shade). `y` = **fade variant** (>0.5 β‡’ - /// the distance-fade BLEND twin β†’ `specialize` forces depth-write ON; the 224/255 stable-silhouette - /// cutout rides `clutter_fade.z` bit 10 separately, set only for AlphaKey sources β€” 0842; see - /// [`WowModelKey`]). `zw` reserved. + /// `x` = WMO, `y` = the fade blend twin, `z` = interior, `w` = unlit fullbright. #[uniform(100)] pub model_flags: Vec4, - /// Per-material MCSH terrain-shade **selector** (`wow_model.wgsl`, the exterior doodad matte). `x` picks - /// which live doodad sun LEVEL scales the FFP matte's diffuse/sun term β€” `1.0` β‡’ lit ground, `0.2` β‡’ - /// MCSH-shadowed ground (the shader thresholds at 0.5), so a doodad inherits the shade it stands in like - /// the clutter/terrain beside it. Set at creation (a doodad doesn't move, so its shade is static β‡’ it - /// rides the deduped material, not per-instance `MeshTag`). Clutter/WMO ignore it (sun-scale 1); - /// creatures/player default `1.0` until the live-sample pass. `y` = the WMO authored batch order - /// (shader-unread; `WowModelKey` reads it back for the per-batch depth bias). `zw` = the batch's - /// live **UV-animation offset** (decision 0130 phase 3): added to the stage UVs in - /// `wow_model.wgsl`; `0` for static batches, re-sampled per frame by - /// `doodad_anim::tick_anim_materials` for texanim batches (flowing waterfalls). + /// `x` = the terrain-shade selector, `y` = the WMO batch order (the clip-z layering nudge), + /// `zw` = the UV-animation offset. #[uniform(100)] pub sun_scale: Vec4, - /// Per-material **RGB tint** (`xyz`): the M2Color colour multiplied into the albedo exactly - /// where the static vertex-colour tint folds β€” carrying the tint of batches whose colour track - /// ANIMATES (the vertex bake is skipped for those, `benilla-formats` `m2_batches`). Seeded at - /// the track's first key (pixel-identical to the old static bake for a lane that never ticks - /// it); the effect lane clones the material per instance and re-samples per frame (a spell - /// effect's white-hot flash cooling to red). `xyz = 1` β€” the identity β€” for the overwhelming - /// majority. **`w` = the WMO interior batch-class lane** (`0` = exterior law, `1` = interior - /// INT β‡’ unlit `tex Γ— MOCV`, `2` = interior TRANS β‡’ the per-vertex MOCV-alpha lit↔bake lerp β€” - /// the interior draw `0x6b5190`); `0` for every non-WMO batch. + /// `xyz` = the M2Color tint of a batch whose colour track animates, identity otherwise; `w` = + /// the WMO interior batch class (`0x6b5190`): `0` exterior, `1` INT, `2` TRANS. #[uniform(100)] pub tint: Vec4, - /// The WMO window/glass law (SIDN ramp `0x6b4090`, WINDOW light `0x6d37e0`). - /// `xyz` = the MOMT **SIDN** authored emissive colour (gamma bytes /255; `0` for non-SIDN and every - /// M2): the shader multiplies it by the live night fraction (`wow_light.grade.x`) and adds it inside - /// the lit sum on lit lanes β€” windows glow warm at night, nothing by day. `w` = the MOMT **WINDOW** - /// flag (>0.5): an interior-group batch swaps to the brighter Direct/Ambient-midpoint light - /// (ambient +16/255) β€” the warm pane seen from inside a building. + /// WMO glass, `0` on M2: `xyz` = the MOMT SIDN night emissive (ramp `0x6b4090`), `w` = the MOMT + /// WINDOW flag, the brighter interior light (`0x6d37e0`, ambient +16/255). #[uniform(100)] pub sidn: Vec4, - /// The shared mat-anim TABLE slots (decision 1381): `x` = the UV-scroll slot + 1, `y` = the - /// animated-tint slot + 1 β€” `0` = not table-animated, and the shader uses the static lanes - /// (`sun_scale.zw` / `tint.xyz`) exactly as before. Baked when the batch registers its - /// sampler; the per-frame samples live in the shared light buffer's `matanim` region, so an - /// animating material is never mutated again (no per-frame `Modified`, no bind-group - /// rebuild, no whole-population `AssetChanged` walks β€” B131's chain, severed at the root). - /// `z` = the **affine** slot (decision 2019): its row is the texture transform's rotation - /// and scale as deltas from the identity, `[cos βˆ’ 1, sin, sx βˆ’ 1, sy βˆ’ 1]` - /// (`mat_anim_table::affine_row`), composed about the pivot `(Β½, Β½)` after the translation β€” - /// the reference's `uv' = R((uv + t βˆ’ p) βŠ™ s) + p`. Row 0 is the identity, so a material - /// with no rotation reads exactly the translated UV it always did. Written by the lanes that - /// own a material per instance (the UI model tiles); `w` free. + /// Rows of the shared light buffer's `matanim` region, `0` = identity: `x` = UV scroll, `y` = + /// tint, `z` = the texture-transform affine, `w` = the UI tile's cell clip. #[uniform(100)] pub anim_slots: Vec4, - /// **The shared global light** (`lighting::global_light`): one storage buffer every material reads, - /// updated once/frame in place β€” replaces the per-material light/fog/SH uniforms (ambient/diffuse/ - /// sun/spec + fog + the 7 Model2.bls SH-probe coeffs) the old `apply_wow_lighting` re-pushed every - /// frame (re-creating every bind group). `wow_model.wgsl` reads it as `var wow_light` - /// (rows 0-12 + the point-light table). Both stages: the fragment does the custom shading, the - /// vertex evaluates the Gouraud point-light term from the appended table (decision 0278 β€” bevy's - /// own clusterable buffer is fragment-only in the view layout, so the lights ride our buffer). - /// Set once, never mutated. + /// The shared global light (`lighting::global_light`), updated in place once a frame; the + /// vertex stage reads its point-light table, since Bevy's clusterable lights are fragment-only. #[storage(90, read_only, buffer, visibility(vertex, fragment))] pub light_buf: Buffer, } impl MaterialExtension for WowModelExt { - /// Custom vertex stage (decision 0278): bevy's mesh vertex verbatim plus the GOURAUD point-light - /// term β€” the dynamic light sum evaluates per VERTEX like the reference FFP and interpolates, - /// which is what spreads a fixture's floor pool and keeps the forge hood dim. + /// Bevy's mesh vertex plus the point-light term, per vertex like the reference's FFP. fn vertex_shader() -> ShaderRef { "embedded://benilla_assets/shaders/wow_model.wgsl".into() } @@ -238,25 +131,16 @@ impl MaterialExtension for WowModelExt { "embedded://benilla_assets/shaders/wow_model.wgsl".into() } - /// Per-batch depth + blend overrides Bevy's `AlphaMode` can't express. **Depth:** the real client - /// writes depth for EVERY M2 batch β€” opaque or transparent β€” and tests `LEQUAL`, *unless* the - /// material's render flag 0x10 (no-write) / 0x08 (no-test) clears it (`0x70c190`). Bevy's - /// transparent pass defaults depth-write OFF, so a model's own transparent cards bleed - /// through / flicker from some angles; we set it per-flag. The distance-fade blend twin - /// (`fade`) additionally forces depth-write ON (benilla's feather pass) regardless. + /// The reference writes depth for every M2 batch, transparent ones too, and tests `LEQUAL`, + /// unless render flag 0x10 (no write) or 0x08 (no test) clears it (`0x70c190`). fn specialize( _pipeline: &MaterialExtensionPipeline, descriptor: &mut RenderPipelineDescriptor, layout: &MeshVertexBufferLayoutRef, key: MaterialExtensionKey, ) -> Result<(), SpecializedMeshPipelineError> { - // The owned-palette skinning path (decision 0720): a mesh carrying the WOW joint - // attributes (the skinned twin β€” `crate::build_skinned_submesh_mesh`) compiles - // the WOW_RIG_SKIN vertex path, which skins from the shared buffer's palette region by - // the instance's MeshTag rig field. The base mesh pipeline built the vertex buffer - // layout without the joint attributes (they're not Bevy's, so it doesn't know them); - // rebuild it with the same conditionals (mesh.rs `specialize`, locations 0-5) plus ours - // at 10/11. Bevy's own SKINNED branch never fires for these meshes β€” that's the point. + // `WOW_RIG_SKIN` skins from the shared buffer's palette. The base layout lacks our joint + // attributes: rebuild it with Bevy's conditionals (mesh.rs, locations 0-5) plus 10/11. if layout.0.contains(crate::ATTRIBUTE_WOW_JOINT_INDEX) { descriptor.vertex.shader_defs.push("WOW_RIG_SKIN".into()); let mut attrs = Vec::with_capacity(7); @@ -276,12 +160,8 @@ impl MaterialExtension for WowModelExt { attrs.push(crate::ATTRIBUTE_WOW_JOINT_WEIGHT.at_shader_location(11)); descriptor.vertex.buffers = vec![layout.0.get_layout(&attrs)?]; } - // The merged fader blob (decisions 1418/1420): a mesh carrying the per-vertex fade - // sphere compiles the WOW_MERGED_FADE path β€” the faithful doodad fade curve computed - // per vertex (folded into the tag fade the fragment already consumes) and a clip-space - // collapse at zero (the Hidden channel). The blob's material is its blend TWIN, so the - // feather is the reference's own translucent ramp. Same rebuild pattern as the joints - // above; the two attribute sets never co-occur (merged blobs never skin). + // A merged blob computes the doodad fade per vertex from its fade sphere; same rebuild, + // and merged blobs never skin. if layout.0.contains(crate::ATTRIBUTE_WOW_FADE_SPHERE) { descriptor.vertex.shader_defs.push("WOW_MERGED_FADE".into()); if let Some(fragment) = descriptor.fragment.as_mut() { @@ -301,8 +181,7 @@ impl MaterialExtension for WowModelExt { } } attrs.push(crate::ATTRIBUTE_WOW_FADE_SPHERE.at_shader_location(12)); - // The interior-prop half (1418 lane 3): the baked SH-probe slot replaces the - // per-entity MeshTag payload. + // An interior-prop blob's baked SH-probe slot replaces the per-entity MeshTag payload. if layout.0.contains(crate::ATTRIBUTE_WOW_MERGED_SLOT) { descriptor.vertex.shader_defs.push("WOW_MERGED_SLOT".into()); if let Some(fragment) = descriptor.fragment.as_mut() { @@ -312,31 +191,20 @@ impl MaterialExtension for WowModelExt { } descriptor.vertex.buffers = vec![layout.0.get_layout(&attrs)?]; } - // M2 render flags 0x10 (no depth-write) / 0x08 (no depth-test), per batch. Default: write depth - // (LEQUAL) like the real client β€” including transparent batches, which fixes the bleed-through. if let Some(ds) = descriptor.depth_stencil.as_mut() { ds.depth_write_enabled = !key.bind_group_data.no_depth_write; if key.bind_group_data.no_depth_test { ds.depth_compare = CompareFunction::Always; } } - // A far-side-of-water twin's bias is a SORT rung only (the water-plane interleave, - // `sky_order::FAR_SIDE_BIAS`): the base `StandardMaterial::specialize` has just packed the - // same f32 into the rasterizer `DepthBiasState` constant (bevy 0.18 `pbr_material.rs`, - // `depth_stencil.bias.constant`), where βˆ’4e4 ULPs would pull every far fragment ~0.5% - // deeper and clip a blade's coplanar sheen/glow against the blade's own opaque depth. - // Zero it back β€” the effect lane splits sort from raster by construction; this bit is the - // mesh lane's split. + // A sort bias is not a raster bias: the base `StandardMaterial::specialize` also packs it + // into `depth_stencil.bias.constant`, where the far side's βˆ’4e4 would clip coplanar layers. if key.bind_group_data.far_side { if let Some(ds) = descriptor.depth_stencil.as_mut() { ds.bias.constant = 0; } } - // The WMO-skybox lane: pin the sky's far depth in the vertex stage (the def reaches the - // fragment too, for symmetry with the other lane defs β€” it reads nothing there), and β€” - // like the far-side twin above β€” keep its big negative rung sort-only. The rung - // (`sky_order::WMO_SKYBOX_BIAS`) exists to sink a camera-anchored backdrop under every - // world transparent; as a rasterizer constant it would be perturbing the pinned depth. + // The skybox pins clip z to 0, reverse-Z far, in the vertex stage; its bias is sort-only. if key.bind_group_data.sky_depth { descriptor.vertex.shader_defs.push("WOW_SKY_DEPTH".into()); if let Some(fragment) = descriptor.fragment.as_mut() { @@ -346,29 +214,15 @@ impl MaterialExtension for WowModelExt { ds.bias.constant = 0; } } - // The WMO authored-batch-order depth nudge (the coplanar MOBA layering determinism) is NOT - // here any more: as a fixed-function `DepthBiasState` constant it made every batch index its - // own PIPELINE β€” Stormwind alone queued ~3000 variants of this one shader, each a - // synchronous render-thread compile on macOS (the city first-sight stall, decision 0837). - // The nudge now lives in `wow_model.wgsl`'s vertex stage, an exact relative scale of clip z - // driven by `sun_scale.y` (uniform DATA, no pipeline axis) β€” same one-ULP-per-index - // semantics, intent unchanged (the file-order batch walks `0x6b4f10`/`0x6b5190`). if key.bind_group_data.fade && !key.bind_group_data.sky_depth { - // The distance-fade blend twin needs depth-write ON so near geometry occludes far within the - // same fading model β€” force it regardless of the per-flag rule above. EXCEPT on the - // WMO-skybox lane: its twin exists for the 4-second crossfade, where every fragment - // forces the one far depth β€” there is no within-model occlusion to preserve, and the - // sky's own law is depth-write off, always (the fragment leaves the z-buffer at its - // clear value so the world and the forced-far glare quads order by depth alone). + // A fading model's near cards occlude its far ones; the sky never writes depth. if let Some(ds) = descriptor.depth_stencil.as_mut() { ds.depth_write_enabled = true; } } if key.bind_group_data.clutter { - // Ground clutter (AlphaMode::Mask β†’ alpha-mask pass, depth-write already ON): force the - // reference's over-blend on in place, so the ~70 yd ramp fades OPACITY instead of a hard - // alpha-test cut. The 128/255 discard (crisp blade silhouette) + `tex Γ— ramp` output alpha - // are already produced in the shader; we only flip the blend state. + // Clutter blends in the alpha-mask pass, as the reference does, so the ~70 yd ramp + // fades opacity; the shader keeps the 128/255 discard. if let Some(target) = descriptor .fragment .as_mut() @@ -379,11 +233,8 @@ impl MaterialExtension for WowModelExt { } } if key.bind_group_data.additive { - // Additive glow cards: a PURE (ONE, ONE) add β€” the shader has already folded the - // radial alpha into the colour IN GAMMA SPACE (wow_model.wgsl, decision 0160: a - // hardware `SrcAlpha` factor would multiply after the linear conversion, inflating - // every soft skirt by Ξ±^(1/2.2) β€” the fat hard-disc halo). Not Bevy's `AlphaMode::Add` - // (its premultiply is linear-side too). + // A pure (ONE, ONE) add: the shader weights by alpha in gamma space, where a `SrcAlpha` + // factor or `AlphaMode::Add` would weight after linearising and fatten soft edges. if let Some(target) = descriptor .fragment .as_mut() @@ -405,15 +256,9 @@ impl MaterialExtension for WowModelExt { } } if key.bind_group_data.zfill { - // The depth-prime twin (`model_render::zfill_material` β€” the reference's `M2UseZFill` - // clone, emitted at `0x707f7d`–`0x708072`): a translucent model's z-writing - // batches draw once colour-masked-off, blend-off, z-write ON, sorted before its colour - // batches (the material's negative sort bias) β€” so each colour fragment passes - // GreaterEqual only at the model's own nearest surface, and interior/overlapped layers - // fail. One blended layer everywhere: no self-overlap darkening on a stealthed body. - // The fragment still runs its discards (farclip, the bit-10 twin cutout); - // its colour output is computed and masked (an early return would be the natural - // spelling, but naga's MSL backend miscompiles the dead tail β€” see the shader note). + // The reference's `M2UseZFill` pre-pass (`0x707f7d`-`0x708072`): depth only, so a + // translucent model shows one blended layer. The colour is computed and masked, not + // skipped by an early return, which naga's MSL backend miscompiles. if let Some(frag) = descriptor.fragment.as_mut() { if let Some(target) = frag.targets.get_mut(0).and_then(|t| t.as_mut()) { target.blend = None; @@ -424,11 +269,7 @@ impl MaterialExtension for WowModelExt { ds.depth_write_enabled = true; } } - // The straddle split's waterline clip (decision 2188 β€” `benilla_world::straddle`): only a - // transparent-pass batch is ever classified against the water plane, so only its - // pipelines carry the clip. Keyed on the blend pass Bevy already specializes on, so it - // mints no pipeline of its own; the fragment clips nothing unless the instance's slot - // says it straddles. + // The waterline clip (`benilla_world::straddle`) keys on the blend pass: no new pipelines. if key .mesh_key .intersection(MeshPipelineKey::BLEND_RESERVED_BITS) @@ -440,12 +281,8 @@ impl MaterialExtension for WowModelExt { } let key = &key.bind_group_data; if key.modulate || key.modulate2x { - // The MULTIPLY blends (decision 0528, `0x70c190`): - // Mod (M2 mode 5 / WMO 4) = DST_COLOR/ZERO β†’ out = srcΒ·dst; Mod2x (M2 6 / WMO 5) = - // DST_COLOR/SRC_COLOR β†’ out = 2Β·srcΒ·dst β€” the ARMORREFLECT weapon/armor sheen (neutral - // at mid-grey, brightening at the streak). The framebuffer holds gamma (0161), so these - // factors reproduce the reference's byte math exactly. Alpha: the equation reads no - // source alpha β€” keep the destination's. + // `0x70c190`: Mod (M2 mode 5, WMO 4) is `DST_COLOR/ZERO`, Mod2x (M2 6, WMO 5) + // `DST_COLOR/SRC_COLOR`; on a gamma framebuffer this is the reference's byte math. if let Some(target) = descriptor .fragment .as_mut() @@ -474,21 +311,14 @@ impl MaterialExtension for WowModelExt { } } -/// Distant low-detail terrain (WDL): unlit white geometry under a saturated fog of its own β€” flat -/// scene-fog colour, the "fog hull" (decision 1521) β€” the horizon hills the reference draws beyond the -/// streamed detailed tiles. Both shader stages are custom (`shaders/wdl.wgsl`): white verts Γ— the -/// scene fog COLOUR with the hull pass's own start-0 / end-1.0 pair (never the scene's distances), -/// opaque (`AlphaMode::Opaque` β‡’ depth-LEQUAL + depth-write, no blend β€” the verified WoW.8 state). +/// Distant low-detail terrain (WDL), the horizon hills beyond the streamed tiles: opaque white +/// geometry under the scene fog colour at the hull pass's own start 0 and end 1.0. pub type WdlMaterial = ExtendedMaterial; -/// WDL reads the scene fog COLOUR straight off the shared global light β€” it has no per-material input -/// of its own (the band is white geometry whose entire colour is the fog, saturated). +/// WDL has no per-material input: its whole colour is the scene fog, off the shared global light. #[derive(Asset, AsBindGroup, Clone, TypePath)] pub struct WdlExt { - /// **The shared global light** (`lighting::global_light`), the same buffer terrain and the models - /// bind. `wdl.wgsl` reads rows 4/5 only β€” the SCENE fog colour (block 1) plus the farclip wall, which is - /// right by construction: the band *is* the horizon, so it is never inside a WMO. Set once at - /// startup, never mutated (this replaces the per-material `apply_wow_lighting` push). + /// The shared global light, rows 4/5 only (scene fog colour, farclip wall). #[storage(90, read_only, buffer)] pub light_buf: Buffer, } @@ -502,61 +332,35 @@ impl MaterialExtension for WdlExt { } } -/// MCLQ liquid surfaces (lakes/rivers/ocean): a port of the reference's `ocean0_s.bls` shader path. -/// The animated frame set is a `texture_2d_array` whose RGB is near-black and whose **ALPHA is the -/// ripple** (β†’ surface transparency); the visible colour is the dark texture **hardware-lit** -/// (`ambient + NΒ·LΒ·sun`) and **fog-tinted by distance** β€” NOT a flat tint (the blue/teal is the blue -/// ambient + fog). Two-sided, alpha-blended, depth-write off (Bevy's transparent pass = the verified -/// WoW state). P1 omits the unit-1 detail ripple + specular term. +/// Liquid surfaces, one `liquid.wgsl` arm per reference liquid renderer: ADT MCLQ (the +/// `ocean0_s.bls` combine), WMO exterior and interior water, and magma and slime. pub type LiquidMaterial = ExtendedMaterial; -/// Liquid material inputs: the animated frame array, the per-material kind lanes, and the current -/// animation frame index. Everything that is *light* β€” ambient/diffuse/sun/spec, both water swatches -/// and both fog blocks β€” comes off the shared global-light buffer, exactly like terrain and the -/// models; this material carries only what genuinely varies per material. **The Vec4 uniforms merge -/// onto one binding (102)** (same packing trick as the other extensions). WGSL struct order must -/// match the field order. +/// Liquid inputs: the animated frames and the per-material lanes; all light comes off the shared +/// global light. The `Vec4` uniforms pack into binding 102, in the WGSL struct's field order. #[derive(Asset, AsBindGroup, Clone, TypePath)] pub struct LiquidExt { - /// The kind's animated frames (`lake_a`/`fast_a`/`ocean_h`), stacked as `2d_array` layers - /// (`Rgba8Unorm`, repeat-sampled). RGB near-black; alpha = the ripple/wave β†’ transparency. + /// The kind's animated frames (`lake_a`, `fast_a`, `ocean_h`): RGB near-black, alpha ripple. #[texture(100, dimension = "2d_array", visibility(fragment))] #[sampler(101, visibility(fragment))] pub frames: Handle, - /// The per-material constants that pick which *lanes* of the shared light this surface reads. - /// None of them is a light value; all four are fixed at material creation. - /// - /// - `x` = **fullbright** (>0.5 β‡’ magma/slime): the animated texture IS the opaque body β€” skip - /// the depth swatch and the NΒ·L term. Not "skip the fog"; see `liquid.wgsl` and decision 0691. - /// - `y` = **ocean** (>0.5): read the ocean water swatch (shared-light rows 15/16, `Light.dbc` - /// IntBand 14/15) instead of the river/lake one (rows 13/14, IntBand 16/17). - /// - `z` = **interior fog** (>0.5): this surface is a WMO *interior* group's own liquid, so it - /// fogs with the interior block (rows 18/19) like the walls around it, not the scene fog. The - /// reference gates the WMO liquid pass's fog submit (`0x6b6323`–`0x6b6342`) on exactly the same - /// `[0xca7f00]` as the WMO *geometry* pass (`0x6b51d9`/`0x6b51ea`) β€” one flag, so the pool and - /// the room can never disagree. ADT liquid is 0: the ADT pass submits no fog of its own and - /// draws under the once-a-frame scene submit. - /// - `w` = the sun-sheen **shininess** (`lighting::WATER_SHININESS`) for the `ocean0_s.bls` - /// `secondary` Blinn term β€” water's own exponent, not the shared row-3 terrain shininess. + /// Which lanes of the shared light this surface reads: + /// - `x` = fullbright (magma, slime): the sheet is the opaque body. + /// - `y` = ocean: rows 15/16 (`Light.dbc` IntBand 14/15), else rows 13/14 (IntBand 16/17). + /// - `z` = a WMO interior group's liquid, fogged with the interior block (rows 18/19): the + /// reference gates its fog (`0x6b6323`-`0x6b6342`) on the same `[0xca7f00]` as the group's + /// geometry (`0x6b51d9`/`0x6b51ea`). + /// - `w` = the sun-sheen shininess (`lighting::WATER_SHININESS`). #[uniform(102)] pub kind: Vec4, - /// - `x` = **which renderer** this surface belongs to (`liquid::surface::LiquidPath`): `0` = ADT - /// MCLQ (`ocean0_s.bls`), `1` = WMO exterior (`MapObjExtWater0.bls`), `2` = WMO interior - /// (fixed-function, unlit). The reference has three liquid renderers with genuinely different - /// combines, stage counts and opacity sources; this is which one `liquid.wgsl` runs. - /// - `y`/`z`/`w` reserved. + /// `x` = the renderer (`liquid::surface::LiquidPath`): `0` ADT MCLQ, `1` WMO exterior, `2` WMO + /// interior. #[uniform(102)] pub path: Vec4, - /// `x` = reserved (frame 0), `y` = frame count, `z` = scroll flag, `w` = clock enable β€” - /// the shader derives frame index and scroll from `globals.time` (liquid.wgsl `anim_time`); - /// nothing mutates this uniform after build. + /// `y` = frame count, `z` = scroll flag, `w` = clock enable. #[uniform(102)] pub anim: Vec4, - /// **The shared global light** (`lighting::global_light`): the one storage buffer terrain and the - /// models already read, now liquid's source too. `liquid.wgsl` reads rows 0-5 (light + scene fog + - /// farclip), 13-16 (the two water swatches) and 18/19 (the interior fog block). Read in BOTH - /// stages β€” the vertex stage evaluates the faithful per-vertex sun sheen. Set once at material - /// creation, never mutated. + /// The shared global light, read in both stages (the vertex stage evaluates the sun sheen). #[storage(90, read_only, buffer, visibility(vertex, fragment))] pub light_buf: Buffer, } @@ -569,18 +373,9 @@ impl MaterialExtension for LiquidExt { "embedded://benilla_assets/shaders/liquid.wgsl".into() } - /// **`WATER_BIAS` is a SORT rung; keep it out of the rasterizer** β€” the same split the far-side - /// twin and the WMO skybox make above, for the one surface that most needs it. - /// - /// The water pass has a fixed frame slot (`sky_order::WATER_BIAS`, βˆ’2e4), and the base - /// `StandardMaterial::specialize` packs that same f32 into `depth_stencil.bias.constant` (bevy - /// 0.18 `pbr_material.rs`). At βˆ’2e4 ULPs that shoves every water fragment ~0.24 % of its view - /// distance *away* from the eye β€” and the water plane is the one world surface that meets the - /// ground at a grazing angle over tens of yards, so a relative depth pull there does not settle - /// a tie, it moves the waterline. Worse, a float depth buffer's bias unit is the ULP at the - /// primitive's own depth, so the pull **doubles at every exponent boundary**: two water - /// triangles either side of one land in different buckets and their shorelines stop agreeing. - /// The ordering job needs none of it β€” the rung exists for the transparent phase's sort key. + /// `sky_order::WATER_BIAS` (βˆ’2e4) is a sort rung, kept out of the rasterizer: as a depth-bias + /// constant it would move the waterline, by an amount that doubles at every float exponent + /// boundary, so neighbouring triangles' shorelines would disagree. fn specialize( _pipeline: &MaterialExtensionPipeline, descriptor: &mut RenderPipelineDescriptor, @@ -594,25 +389,11 @@ impl MaterialExtension for LiquidExt { } } -/// Per-tile splat inputs: a `texture_2d_array` of the tile's ground textures, a `texture_2d_array` -/// of its per-chunk alpha maps, and the tiling factor. Which array layers a fragment blends comes -/// from the merged mesh's vertex `COLOR` (4 layer indices) and `UV1.x` (alpha index). +/// Per-tile terrain splat, one material per ADT tile: each merged-mesh vertex carries its chunk's +/// four layer indices (`COLOR`) and alpha layer (`UV1.x`), so a tile is one draw. /// -/// **One shared sampler** (repeating, on `layer_array`) covers both arrays: `StandardMaterial` -/// already uses ~6 of Metal's 16 fragment samplers and the view adds more, so extra samplers risk -/// the per-stage limit. Layer UVs are tiled. The alpha map is sampled in 0..1, where linear -/// filtering on a repeating sampler would wrap at a chunk edge; `terrain.wgsl` insets its UVs by -/// half a texel, which clamps instead. -/// **All terrain Vec4 uniforms merge onto one binding (106).** AsBindGroup packs multiple -/// `#[uniform(N)]` fields at the same `N` into a single buffer entry; the WGSL declares one -/// `var t: TerrainParams;` whose fields land in the SAME order as the Rust declaration -/// (each Vec4 is 16 bytes, no padding). One buffer entry per pipeline stage instead of eleven β€” -/// which is what keeps us under Metal's 16-buffer vertex-stage cap once Step 5's fog uniforms are -/// added. Visibility-narrowing via `visibility(fragment)` doesn't work on uniforms (Bevy 0.18 -/// hardcodes them to all stages), so packing is the effective lever for the buffer count. -/// -/// Textures + sampler ARE fragment-narrowable (the macro respects `visibility(fragment)` there) -/// so the splat / alpha / shadow arrays stay fragment-only. +/// One repeating sampler (105) serves every array, to stay under Metal's 16 fragment samplers; +/// `terrain.wgsl` insets the alpha UVs by half a texel so they do not wrap at a chunk edge. #[derive(Asset, AsBindGroup, Clone, TypePath)] pub struct TerrainExtension { #[texture(100, dimension = "2d_array", visibility(fragment))] @@ -620,32 +401,22 @@ pub struct TerrainExtension { pub layer_array: Handle, #[texture(104, dimension = "2d_array", visibility(fragment))] pub alpha_array: Handle, - /// Per-chunk MCSH baked shadow maps (one R8 layer each, `R` = shadowed). The merged mesh's - /// `UV1.y` carries the layer index, or `-1` for a chunk with no shadow map. Shares sampler 105. + /// Per-chunk MCSH shadow maps (`R` = shadowed); `UV1.y` is the layer, `-1` for none. #[texture(110, dimension = "2d_array", visibility(fragment))] pub shadow_array: Handle, - // ------------------------------------------------------------------------------------------ - // Packed uniform buffer at binding 106 β€” fields below appear in the WGSL `TerrainParams` struct - // in the SAME order (each Vec4 16 bytes, no padding). Reordering is a breaking change. - // ------------------------------------------------------------------------------------------ + // Binding 106's uniforms, in the field order of the WGSL `TerrainParams`. /// `x` = texture tiling factor (repeats per chunk); other lanes unused. #[uniform(106)] pub params: Vec4, - /// **The shared global light** (`lighting::global_light`): one persistent storage buffer all - /// materials reference, updated once/frame in place β€” the faithful replacement for the old - /// per-material light/fog uniforms (ambient/diffuse/sun/spec + fog) that `apply_wow_lighting` - /// re-pushed every frame (re-creating every bind group). Set once at tile spawn, never mutated. - /// `terrain.wgsl` reads it as `var wow_light` (rows 0-5: light + fog + farclip). + /// The shared global light, rows 0-5 (light, fog, farclip). #[storage(90, read_only, buffer)] pub light_buf: Buffer, } impl MaterialExtension for TerrainExtension { - // Custom VERTEX shader too (not just fragment): the sun specular is computed per-vertex (the - // reference's fixed-function light flush, `0x59c820`, keeps slot 0's specular live), and - // Bevy's `VertexOutput` has no slot to carry the interpolated result. + // The sun specular is per vertex (the reference's light flush `0x59c820`). fn vertex_shader() -> ShaderRef { "embedded://benilla_assets/shaders/terrain.wgsl".into() } @@ -656,18 +427,11 @@ impl MaterialExtension for TerrainExtension { #[cfg(test)] mod tests { - /// The sky depth law, for the one sky element that draws on the MODEL lane: the WMO skybox - /// ([`WowModelKey::sky_depth`]). Every other sky shader is checked the same way, together, in - /// `benilla_world::sky_order::the_sky_depth_is_pinned_at_the_vertex_and_nowhere_else` β€” this - /// half lives here because the shader does. Without it a skybox silently goes back to being - /// occluded by its own 94-yard shell radius instead of by world geometry (the regression - /// decision 0588 fixed) β€” or, the other way, a `frag_depth` write comes back and every - /// doodad, creature and wall in the frame loses its early-Z with it (decision 2016). + /// The sky depth law for the WMO skybox, the one sky element on the model lane. #[test] fn the_sky_lane_pins_the_far_depth_at_the_vertex() { let src = include_str!("shaders/wow_model.wgsl"); - // The pin, BEHIND its ifdef β€” matched with the guard attached, so ungating it (every model - // draw at the far plane) fails here rather than as a blank world. + // The pin, matched behind its ifdef: ungated, every model draw sits at the far plane. let pin = src .find("#ifdef WOW_SKY_DEPTH") .expect("the sky-depth branch is gone"); @@ -685,41 +449,32 @@ mod tests { ); } - /// The ADT depth swatch, pinned two ways: the WGSL still carries the reference's own row - /// arithmetic, and that arithmetic reproduces the byte vectors the reference builds. - /// - /// The mirror below is a **spec anchor**, not a second implementation β€” nothing else calls it. - /// It exists because WGSL has no unit-test harness here (Bevy's `#import`/`#ifdef` mean naga - /// cannot validate these files standalone), so the source-text pin above it is what ties the two - /// together: change the shader's formula and this fails, pointing at the numbers it has to keep - /// producing. - /// - /// The vectors are the row-fill `0x68a830`'s output on real data β€” `Light.dbc` id 4, map 0, - /// t = 1440, the ocean pair `LightIntBand` sub-14 β†’ sub-15. + /// The WGSL carries the reference's swatch row arithmetic, and a Rust mirror of it reproduces + /// the row fill `0x68a830` on `Light.dbc` id 4, map 0, t = 1440, ocean `LightIntBand` 14 to 15. #[test] fn liquid_swatch_reproduces_the_reference_row_ramp() { let src = include_str!("shaders/liquid.wgsl"); - // The row accumulator: `c0 + floor(i*(c1 - c0)/64)`, NOT a lerp to the deep endpoint. + // The row accumulator `c0 + floor(i*(c1 - c0)/64)`, not a lerp to the deep endpoint. assert!( src.contains("let row = c0 + floor(i * (c1 - c0) / 64.0);"), "the swatch stopped building its rows the way FUN_0068a830 does β€” a plain lerp to the \ deep endpoint runs a 64th of a ramp that does not exist (decision 2074)" ); - // The ocean-only tail: floor(0.9*byte) on the LAST row, alpha forced opaque. + // The ocean-only tail: floor(0.9*byte) on the last row, alpha forced opaque. assert!( src.contains("if ocean && i >= 63.0 {") && src.contains("vec4(floor(row.rgb * 0.9), 255.0)"), "the ocean's last-row darkening is gone β€” ~80% of the world's ocean vertices sample \ that row, so this is the open sea's colour (decision 2074)" ); - // LINEAR sampling across the two rows V falls between, at texel `V*64 - 0.5`. + // Linear sampling across the two rows V falls between, at texel `V*64 - 0.5`. assert!( src.contains("let t = clamp(v * 64.0 - 0.5, 0.0, 63.0);"), "the swatch stopped sampling as an 8x64 LINEAR/CLAMP texture β€” the ocean darkening \ would step instead of ramping across the final 1/64 of V (decision 2074)" ); - /// `row(i)` for one channel, mirroring `swatch_row`'s byte arithmetic exactly. + /// `row(i)` for one channel, `swatch_row`'s byte arithmetic. fn row(c0: i32, c1: i32, i: i32) -> i32 { c0 + (i * (c1 - c0)).div_euclid(64) } @@ -732,7 +487,7 @@ mod tests { let (shallow, deep) = ([69, 123, 99], [11, 34, 40]); let at = |i| [0, 1, 2].map(|k| row(shallow[k], deep[k], i)); - // Row 63 is one short of the endpoint on G β€” the ramp never arrives. + // Row 63 is one short of the endpoint on G: the ramp never arrives. assert_eq!(at(63), [11, 35, 40], "row 63 before the ocean tail"); assert_ne!( at(63)[1], @@ -749,7 +504,7 @@ mod tests { [9, 31, 36], "row 63 after the ocean tail (HSV V *= 0.9, which is floor(0.9*byte) per channel)" ); - // The shallow end is the endpoint exactly β€” the tail and the shortfall are deep-end only. + // The tail and the shortfall are deep-end only. assert_eq!(at(0), shallow, "row 0 is the shallow endpoint verbatim"); } } diff --git a/crates/benilla-assets/src/minimap_grid.rs b/crates/benilla-assets/src/minimap_grid.rs index 6bf307f9..82db6707 100644 --- a/crates/benilla-assets/src/minimap_grid.rs +++ b/crates/benilla-assets/src/minimap_grid.rs @@ -1,36 +1,21 @@ -//! The WMO **interior minimap** tile grid: how one WMO group's world footprint subdivides into the -//! `___.blp` minimap tiles the client streams inside a building (decision 0203's -//! interior arc). Pure geometry β€” no Bevy β€” so the app renderer and the against-real-data test share -//! one source of truth. -//! -//! Mechanism (the reference's tile producer `0x6a5270`, in the CMapObj/WMO TU; the grid -//! **corrected + verified against Ironforge's authored tiles this session**, see -//! `ironforge_group_grid_matches_trs`): the bake is a fixed **0.5 yd/texel**, each tile a -//! power-of-two texel square clamped to `[32, 256]` px, sized to cover the group's footprint extent, -//! and the tiles tile across that extent. -//! -//! Both roundings go **up**: the binary's round-to-int (`FUN_0073fdf5`) loads a control word with -//! the RC field = round-toward-+∞, i.e. `ceil` β€” so the pixel edge is `2^ceil(log2(extentΒ·2))` and -//! the count is `ceil(extent / 128)` (a full 256-px tile = 128 yd). `floor` would under-count -//! (group 1 β†’ 0 columns, group 66 β†’ 1Γ—1); `ceil` matches the authored data. +//! The WMO interior minimap tile grid: how one group's footprint splits into the +//! `___.blp` tiles the client streams inside a building (tile producer +//! `0x6a5270`). Each tile is a power-of-two texel square clamped to 32..256 px at 0.5 yd a texel, +//! and both roundings go up: the round-to-int (`FUN_0073fdf5`) runs round-toward-+∞, so the edge +//! is `2^ceil(log2(extentΒ·2))` px and the count `ceil(extent / 128)`. -/// The minimap tile bake resolution: world yards per texel (RE constant `0xca7ebc`). +/// World yards per minimap texel (`0xca7ebc`). pub const YD_PER_TEXEL: f32 = 0.5; -/// Smallest / largest authored tile edge in texels (RE clamp; a full tile = 256 px = 128 yd). +/// The tile edge clamp in texels; a full 256 px tile spans 128 yd. const MIN_TILE_PX: u32 = 32; const MAX_TILE_PX: u32 = 256; -/// World yards a full (256-px) tile spans β€” the fixed divisor of the `ceil(extent/128)` tile count. +/// World yards a full tile spans, the divisor of the tile count. const TILE_SPAN_YD: f32 = MAX_TILE_PX as f32 * YD_PER_TEXEL; -/// For one axis of a WMO group whose footprint `extent` (yards) is known: `(tile_count, tile_world)` -/// β€” how many minimap tiles span that axis, and the world size (yards) each tile covers (its stride). -/// The tile world size is the next-pow2 texel square (clamped) that covers `extent` at `0.5 yd/texel`; -/// the count is `ceil(extent / 128)`, both per the binary's round-toward-+∞. -/// -/// Verified against Ironforge: group 66 β†’ 2Γ—2, 44 β†’ 1Γ—2, 89 β†’ 2Γ—1, small groups β†’ 1Γ—1 -/// (`benilla-assets` test `ironforge_group_grid_matches_trs`). +/// One axis of a group's footprint `extent` (yd) as `(tile_count, tile_world)`: the tiles across +/// it and the yards each one covers. pub fn group_axis_grid(extent: f32) -> (u32, f32) { let texels = (extent / YD_PER_TEXEL).max(1.0); let px = next_pow2(texels.ceil() as u32).clamp(MIN_TILE_PX, MAX_TILE_PX); @@ -63,15 +48,11 @@ mod tests { #[test] fn grid_counts_and_sizes_match_ironforge_footprints() { - // Extents (yd) read off the real Ironforge groups; expected counts read off the trs tiles. - // grp 1 (20.6): 1 tile, sized 64 px = 32 yd. + // Extents (yd) of Ironforge groups 1, 44 (X, Y), 66 and 89 (X, Y); counts from the trs. assert_eq!(group_axis_grid(20.6), (1, 32.0)); - // grp 44 X (51.3): 1 tile of 128 px = 64 yd; Y (201.0): 2 tiles of 256 px = 128 yd. assert_eq!(group_axis_grid(51.3), (1, 64.0)); assert_eq!(group_axis_grid(201.0), (2, 128.0)); - // grp 66 (185.6): 2 tiles of 128 yd. assert_eq!(group_axis_grid(185.6), (2, 128.0)); - // grp 89 X (128.4): just over one tile β†’ 2; Y (107.7): 1. assert_eq!(group_axis_grid(128.4).0, 2); assert_eq!(group_axis_grid(107.7).0, 1); } diff --git a/crates/benilla-assets/src/model.rs b/crates/benilla-assets/src/model.rs index 2ec50162..02dd5130 100644 --- a/crates/benilla-assets/src/model.rs +++ b/crates/benilla-assets/src/model.rs @@ -1,7 +1,5 @@ -//! Shared building blocks for the M2 and WMO model loaders: the per-batch submesh type and the -//! WoWβ†’Bevy mesh bake. Both formats decode to a list of these (geometry + texture + blend/sidedness), -//! with materials deferred to the spawn site (see the loader module docs) so the assets stay -//! app-independent. +//! What the M2 and WMO loaders share: the per-batch submesh and the WoW-to-Bevy bakes of its mesh, +//! skeleton, attachments and animation clips. Materials are built at the spawn site. use benilla_formats::{ BillboardKind, BoneScaleAnim, CharSkinSlot, M2Attachment, ModelAnimation, ModelBlend, @@ -22,149 +20,83 @@ mod pose; pub use anims::{AnimClip, ClipEvent, ModelAnimations}; pub use pose::{PoseBone, PoseClip, PoseNode, PoseSource, PoseTrack}; -/// A billboarded submesh's render data (Bevy space): the bone pivot the card rotates about (model-local -/// β€” compose with the instance transform) and the billboard kind. The mesh is built **centred at -/// `pivot`** so the spawn site can rotate it about the pivot to face the camera each frame. +/// A billboarded batch's card in Bevy space; its mesh is centred on `pivot` (model-local). #[derive(Clone)] pub struct BillboardInfo { pub pivot: Vec3, - /// The billboard bone's index β€” the joint the card rides on an animated host (swinging lamp, - /// mount lights). On a rest-pose host the pivot alone places the card. + /// The billboard bone, the joint the card rides on an animated host. pub bone: u16, pub kind: BillboardKind, - /// The billboard bone's global-sequence scale animation (the looping glow-card pulse), if any β€” - /// sampled per-frame at the spawn site and folded into the card scale. `None` for a static card. + /// The bone's global-sequence scale loop (a glow card's pulse), folded into the card's scale. pub scale_anim: Option, - /// The bone's per-sequence translation loops `(anim id, loop)`, keys baked to **Bevy axes** β€” - /// a model-local offset the card adds at its pivot when the spawn site arms one. Anim 0 is the - /// load arm's default (the questgiver `?` marker's low bob); anim 190 the marker's raised bob, - /// armed while the unit shows an overhead name (only the marker arms these today; see - /// `benilla_formats::Billboard::seq_translations`). + /// The bone's per-sequence translation loops `(anim id, loop)`, keys in Bevy axes: the quest + /// marker bobs low on anim 0, and higher on 190 while its unit shows an overhead name. pub seq_translations: Vec<(u16, BoneScaleAnim)>, } -/// One render batch of a loaded model (an M2 batch or a WMO group batch): the decoded geometry, -/// the batch's albedo texture (WorldArt), and its blend/sidedness. Carries data + metadata only β€” -/// **no `Mesh` assets** (decision 0834, the model-lane twin of 0832's terrain rule): a labeled -/// mesh sub-asset lands the instant the decode completes and the render world ingests the whole -/// model in ONE frame, which is the city first-contact spike. The app builds each batch's render -/// form paced (`benilla`'s `model_forms`) via [`submesh_to_static_mesh`] / -/// [`submesh_to_skinned_mesh`]; the material stays a spawn-site concern as before. +/// One render batch of an M2 or a WMO group: decoded geometry, texture and draw state, but no +/// `Mesh` asset, since a labeled mesh sub-asset uploads the whole model in one frame. The app +/// builds meshes paced, through [`submesh_to_static_mesh`] and [`submesh_to_skinned_mesh`]. #[derive(Clone)] pub struct ModelSubmesh { - /// The batch's decoded geometry (model space, WoW axes) β€” everything the mesh builders below - /// need, shared across every instance of the model. `Arc` keeps the submesh clone cheap and is - /// the model's single resident CPU copy of the vertex data (the static render form is - /// GPU-only; see [`submesh_to_static_mesh`]). + /// The decoded geometry in WoW model space, the model's one resident CPU copy of its vertices. pub geometry: std::sync::Arc, - /// Embedded albedo texture, if any. `None` for creature skin slots (filled at spawn from the - /// display's skin variation β€” see [`Self::skin_slot`]). + /// The embedded albedo texture; `None` for a creature skin slot, filled at spawn. pub texture: Option>, - /// The creature skin variation this batch draws from (`Some(0/1/2)` β‡’ `Monster1/2/3`) when its - /// [`Self::texture`] is `None` β€” so the entity spawn site can fill it from `CreatureDisplayInfo`. - /// `None` for batches with their own embedded texture (all doodads/WMOs; most M2 batches). + /// The creature skin variation this batch draws from (`0/1/2` for `Monster1/2/3`). pub skin_slot: Option, - /// The batch's `skinSectionId` (geoset / mesh-part ID, see - /// [`benilla_formats::RenderSubmesh::geoset_id`]) β€” `group*100 + variant`. The character compositor - /// selects which geosets a player shows by this; `0` for a single-geoset model (creatures/doodads). + /// The geoset id, `group * 100 + variant`, by which the character compositor picks parts. pub geoset_id: u16, - /// The character runtime texture slot this batch carries (see - /// [`benilla_formats::RenderSubmesh::char_slot`]) β€” the spawn site fills its texture per-player from - /// the appearance (decisions 0041 / 0044 / 0045). `None` for everything with its own texture. + /// The character texture slot the spawn site fills from the player's appearance. pub char_slot: Option, pub blend: ModelBlend, pub two_sided: bool, - /// This batch is a WMO **interior** group (see [`benilla_formats::RenderSubmesh::interior`]) β€” lit - /// by its baked MOCV with the directional sun off. `false` for M2 doodads and exterior WMO groups. + /// A WMO interior group's batch, lit by its baked MOCV with the sun off. pub interior: bool, - /// This batch is unlit (see [`benilla_formats::RenderSubmesh::emissive`]) β€” rendered fullbright: - /// M2 `UNLIT (0x01)` glass/glow, or WMO `UNLIT` on an exterior-group batch. `false` otherwise. + /// Unlit, drawn fullbright: M2 render flag `UNLIT` (0x01), or WMO `UNLIT` on an exterior group. pub emissive: bool, - /// This batch's texture is the **icon slot** (M2 texture type 14, see - /// [`benilla_formats::RenderSubmesh::icon_slot`]): the spawn site fills it from - /// `Model:ReplaceIconTexture`'s path. `false` for every other batch and all of WMO. + /// The icon slot (M2 texture type 14), filled from `Model:ReplaceIconTexture`'s path. pub icon_slot: bool, - /// The WMO MOMT **SIDN** night-glow colour (see [`benilla_formats::RenderSubmesh::sidn`]) β€” the - /// authored emissive RGB the shader ramps by the night fraction on lit lanes. `None` for M2. + /// The WMO MOMT SIDN night-glow colour, ramped by the night fraction. pub sidn: Option<[u8; 3]>, - /// WMO MOMT **WINDOW** (see [`benilla_formats::RenderSubmesh::window`]) β€” an interior-group - /// batch takes the brighter Direct/Ambient-midpoint light. `false` for M2. + /// WMO MOMT WINDOW: an interior batch lit by the Direct/Ambient midpoint. pub window: bool, - /// This batch blends **additively** (M2 glow cards / coronae β€” see - /// [`benilla_formats::RenderSubmesh::additive`]): the spawn site builds it with `AlphaMode::Add` so - /// its warm colour is added on top of the scene, not mixed with the background. `false` otherwise. + /// Blends additively (M2 glow cards and coronae). pub additive: bool, - /// This batch's texture coordinates are **generated, not authored** (see - /// [`benilla_formats::RenderSubmesh::env_map`]): a sphere-map environment coordinate off the - /// view-space reflection vector. The spawn site marks the material so `wow_model.wgsl` derives - /// the UV instead of reading the (meaningless) vertex one. `false` for WMO batches and for the - /// M2 batches that name a real UV channel. + /// Sphere-mapped: `wow_model.wgsl` derives the UV from the view-space reflection. pub env_map: bool, - /// M2 render flag **0x10 β€” disable depth write** (see [`benilla_formats::RenderSubmesh::no_depth_write`]): - /// `specialize` keeps depth-write ON for transparent batches *unless* this is set, matching the real - /// client (so a model's transparent cards occlude each other instead of bleeding through). `false` for WMO. + /// M2 render flag 0x10; unset, a transparent batch writes depth, as in the reference. pub no_depth_write: bool, - /// M2 render flag **0x08 β€” disable depth test** (drawn over everything). `false` for WMO. + /// M2 render flag 0x08: no depth test. pub no_depth_test: bool, - /// The batch's fog COLOUR policy (see [`benilla_formats::RenderSubmesh::fog_policy`]) β€” `Scene` - /// for WMO and every non-M2 batch. + /// The batch's fog colour policy; `Scene` for WMO. pub fog_policy: benilla_formats::FogPolicy, - /// Set when this batch rides an M2 billboard bone (glow cards, chains): the spawn site faces it to - /// the camera each frame. The [`Self::mesh`] is built centred at the pivot so it rotates in place. + /// Set when the batch rides an M2 billboard bone; its mesh is built centred on the pivot. pub billboard: Option, - /// The batch's **animated material alpha** (decision 0130 phase 2, combined in `0x707680`): - /// time-varying colour-alpha/transparency-weight loops (or a dimming constant) the doodad spawn - /// site samples per instance into the render-alpha channel. `None` for the overwhelming majority - /// (both factors static-1 or statically culled). `Arc` keeps the submesh clone cheap. + /// The animated material alpha, colour alpha times transparency weight (`0x707680`). pub alpha_anim: Option>, - /// The batch's **UV-animation** loop (decision 0130 phase 3; sampled in `0x714260`, applied at - /// `0x70b740`): the texture transform's translation track β€” sampled per frame into the batch - /// material's UV offset (flowing waterfalls, scrolling energy). `None` for the ~98.6% of models - /// with no texture transform and all WMO batches. The `Arc` doubles as the material-dedup - /// identity: every instance of a loaded model shares this allocation. + /// The texture transform's translation loop (sampled at `0x714260`, applied at `0x70b740`). + /// The `Arc` is also the material-dedup identity, shared by every instance. pub uv_anim: Option>, - /// The batch's UV loop **per file sequence slot**, `Some` only where the slots disagree β€” the - /// batches for which [`Self::uv_anim`]'s single loop is structurally unable to be right, - /// because which loop applies depends on the sequence the *instance* is playing (decision - /// 1408). Like [`Self::uv_anim`], the `Arc` doubles as a material-dedup identity. + /// The UV loop per file sequence slot, `Some` only where slots disagree; also a dedup identity. pub uv_seq: Option>>, - /// The batch's texture-transform **rotation** loop per file sequence slot (decision 2019) - /// β€” the UI model tiles sample it off the pane's play head into the material's affine row; - /// no world lane reads it (no placed doodad authors one). `None` for the rest. + /// The texture-transform rotation loop per file sequence slot, read only by the UI model tiles. pub uv_rot_seq: Option>>, - /// The scaling twin of [`Self::uv_rot_seq`]. + /// The scale loop per file sequence slot, likewise. pub uv_scale_seq: Option>>, - /// The batch's **animated RGB tint** (the M2Color colour track, time-varying only β€” a spell - /// effect's white-hot flash cooling to red). When `Some`, the static vertex tint was skipped - /// at parse (`benilla-formats`): the render side seeds the material's tint at the first key β€” - /// pixel-identical to the old static bake β€” and the material-animating lanes tick it per - /// frame. `None` for constant/keyless tints (the vertex bake). + /// The time-varying M2Color tint; when `Some`, the material carries it, not the vertex colour. pub rgb_anim: Option>, - /// The tint twin of [`Self::uv_seq`], on the same rule. + /// The tint loop per file sequence slot, on [`Self::uv_seq`]'s rule. pub rgb_seq: Option>>, - /// The batch's MOBA section for WMO group batches ([`benilla_formats::WmoBatchClass`] β€” an - /// interior group's per-batch lighting law: INT = unlit `tex Γ— MOCV`, TRANS = the MOCV-alpha - /// lit↔bake lerp, EXT = the exterior day/night law). `None` for every M2 batch. + /// The WMO batch's MOBA section, which picks an interior batch's lighting (INT, TRANS, EXT). pub wmo_batch: Option, - /// The batch's flat **ground-plane quad** shape ([`benilla_formats::GroundQuad`], detected at - /// load β€” corners stay in WoW model space; consumers map through `coords::wow_to_bevy`). The - /// ground-fx decal lane (`benilla::ground_fx`) re-renders such parts of a base-anchored spell - /// effect as projected surface decals. `None` for ordinary geometry and all WMO batches. + /// A flat ground quad (WoW model space), which the ground-fx lane redraws as a projected decal. pub ground_quad: Option, } -/// Turn a [`RenderSubmesh`] (model space) into a Bevy [`Mesh`] baked to Bevy space β€” decision 0002, -/// applied here at the render boundary. Prefers the authored normals (soft, outward β€” how WoW lights -/// foliage); recomputes flat ones only when absent. WMO per-vertex MOCV colour is folded in when -/// present (M2 has none). The WoWβ†’Bevy map is a pure rotation, so it applies to normals too. -/// -/// `usages` is the caller's lane split (decision 0834): the **static** form is `RENDER_WORLD`-only -/// (nothing reads it main-side; the render world takes the buffers at extract, no resident CPU -/// copy), while the **skinned** twin keeps the default `MAIN_WORLD | RENDER_WORLD` because the -/// mouseover picker rays its vertices on the main world (`target::hover::ray_posed_mesh`). +/// Bake a [`RenderSubmesh`] to a Bevy-space [`Mesh`]: authored normals, else flat ones. fn build_submesh_mesh(sub: &RenderSubmesh, usages: RenderAssetUsages) -> Mesh { - // A billboard batch is built centred at its bone pivot, so the spawn site can rotate it about that - // point to face the camera (the entity transform then re-places it at the pivot in the world). + // A billboard batch is centred on its bone pivot, so the spawn site turns it in place. let center = sub .billboard .as_ref() @@ -182,15 +114,9 @@ fn build_submesh_mesh(sub: &RenderSubmesh, usages: RenderAssetUsages) -> Mesh { mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, sub.vertex_colors.clone()); } if sub.normals.len() == sub.positions.len() { - // A billboard CARD is lit on the face it PRESENTS (decision 0788): a card authored - // back-to-front against the law's `+X`-at-the-viewer gets its plane's normals turned round - // here, so its shading stops swinging with the camera (the shape, the reference's own - // behaviour and the 279-batch population are on - // [`RenderSubmesh::billboard_card_faces_away`]). - // - // **Normals only β€” never the winding.** The 177 away-facing SINGLE-sided cards are - // authored placeholder geometry the reference backface-culls from every angle - // (`bbfacescan`), and our `cull_mode` reproduces that; re-winding them would reveal it. + // A card authored facing away from the viewer is lit on the face it presents, so flip its + // normals, never its winding: the reference culls the single-sided ones from every angle, + // and re-winding would show them. let flip = sub.billboard_card_faces_away(); let normals: Vec<[f32; 3]> = sub .normals @@ -207,22 +133,16 @@ fn build_submesh_mesh(sub: &RenderSubmesh, usages: RenderAssetUsages) -> Mesh { mesh } -/// The skinned twin's per-vertex joint indices (`Uint16x4` β€” M2 bone indices, used directly as -/// palette indices). **Deliberately NOT `Mesh::ATTRIBUTE_JOINT_INDEX`** (decision 0720): Bevy's -/// `SKINNED` pipeline path triggers on the standard attributes being in the mesh LAYOUT β€” while -/// its draw-time skin bind group resolves per ENTITY registration β€” so a mesh carrying them can -/// only ever render through Bevy's skin lane. Our own attribute ids keep Bevy's `is_skinned()` -/// false everywhere; `WowModelExt::specialize` sees them in the layout and compiles the -/// `WOW_RIG_SKIN` path instead, which skins from the owned palette region of the shared light -/// buffer (`rig_palette`). Shader locations 10/11 (Bevy's builtin set ends at 7). +/// The skinned mesh's per-vertex M2 bone indices, at shader location 10. Not Bevy's +/// `ATTRIBUTE_JOINT_INDEX`, whose presence in a layout forces Bevy's own skinning: this id makes +/// `WowModelExt::specialize` compile the `WOW_RIG_SKIN` path, which reads our own palette. pub const ATTRIBUTE_WOW_JOINT_INDEX: bevy::mesh::MeshVertexAttribute = bevy::mesh::MeshVertexAttribute::new( "Wow_JointIndex", 988_540_917, bevy::render::render_resource::VertexFormat::Uint16x4, ); -/// The skinned twin's per-vertex joint weights (`Float32x4`, normalised) β€” see -/// [`ATTRIBUTE_WOW_JOINT_INDEX`]. +/// The skinned mesh's per-vertex joint weights, normalized, at shader location 11. pub const ATTRIBUTE_WOW_JOINT_WEIGHT: bevy::mesh::MeshVertexAttribute = bevy::mesh::MeshVertexAttribute::new( "Wow_JointWeight", @@ -230,11 +150,8 @@ pub const ATTRIBUTE_WOW_JOINT_WEIGHT: bevy::mesh::MeshVertexAttribute = bevy::render::render_resource::VertexFormat::Float32x4, ); -/// A merged fader blob's per-vertex fade sphere (`center.xyz` world-space, `w` = fade radius β€” -/// decision 1418): every vertex of one baked placement carries the placement's sphere, and -/// `wow_model.wgsl` computes the faithful `doodad_fade_alpha` from it per vertex (the erode -/// lane), replacing the per-entity MeshTag/twin/Hidden fade channels a merged draw cannot -/// express. Own attribute id for the same reason as the joints above; shader location 12. +/// A merged blob's per-vertex fade sphere (world centre, fade radius), the same on every vertex +/// of one placement, from which `wow_model.wgsl` fades each placement. Shader location 12. pub const ATTRIBUTE_WOW_FADE_SPHERE: bevy::mesh::MeshVertexAttribute = bevy::mesh::MeshVertexAttribute::new( "Wow_FadeSphere", @@ -242,10 +159,8 @@ pub const ATTRIBUTE_WOW_FADE_SPHERE: bevy::mesh::MeshVertexAttribute = bevy::render::render_resource::VertexFormat::Float32x4, ); -/// A merged interior-prop blob's per-vertex SH-probe slot (decision 1418 lane 3): the slot -/// index every vertex of one baked placement carries, replacing the per-entity `MeshTag` -/// payload (`wow_model.wgsl`'s `WOW_MERGED_SLOT` reads it instead of the tag bits). Shader -/// location 13. +/// A merged interior-prop blob's per-vertex SH-probe slot, in place of the `MeshTag` payload. +/// Shader location 13. pub const ATTRIBUTE_WOW_MERGED_SLOT: bevy::mesh::MeshVertexAttribute = bevy::mesh::MeshVertexAttribute::new( "Wow_MergedSlot", @@ -253,22 +168,14 @@ pub const ATTRIBUTE_WOW_MERGED_SLOT: bevy::mesh::MeshVertexAttribute = bevy::render::render_resource::VertexFormat::Uint32, ); -/// The app-facing **static** mesh build (decision 0834): geometry only, `RENDER_WORLD`-only -/// usages β€” the render world takes the vertex buffers at extract and the main world keeps no -/// copy. Consumers must pair it with an explicit `Aabb` computed at build time (the exterior -/// cull fails OPEN on a missing bound, and `RENDER_WORLD` races Bevy's `calculate_bounds` β€” -/// the same rule 0832 set for terrain cells). +/// The static mesh, render-world only. Pair it with an explicit `Aabb`: a render-world mesh races +/// Bevy's `calculate_bounds`, and the exterior cull draws a mesh that has no bound. pub fn submesh_to_static_mesh(sub: &RenderSubmesh) -> Mesh { build_submesh_mesh(sub, RenderAssetUsages::RENDER_WORLD) } -/// Build the **skinned** twin of [`submesh_to_static_mesh`]: the same baked geometry plus the -/// per-vertex [`ATTRIBUTE_WOW_JOINT_INDEX`] + [`ATTRIBUTE_WOW_JOINT_WEIGHT`]. These two -/// attributes are the entire trigger for the owned-palette skinning path (decisions 0019/0720). -/// When the submesh carries no skin (WMO / a boneless batch) this is identical to the static -/// mesh β€” harmless, but the rigged paths are the only consumers regardless. Keeps the default -/// `MAIN_WORLD | RENDER_WORLD` usages: the mouseover picker skins these vertices on the CPU -/// (`target::hover`), so the main-world copy is read, not waste. +/// The skinned mesh: the static one plus the joint attributes. It keeps its main-world copy, +/// which the mouseover picker (`target::hover`) skins on the CPU. pub fn submesh_to_skinned_mesh(sub: &RenderSubmesh) -> Mesh { let mut mesh = build_submesh_mesh(sub, RenderAssetUsages::default()); if sub.joints.len() == sub.positions.len() && !sub.joints.is_empty() { @@ -284,59 +191,36 @@ pub fn submesh_to_skinned_mesh(sub: &RenderSubmesh) -> Mesh { mesh } -/// One joint of a model's rest skeleton, baked to Bevy space (decision 0019): its parent joint index -/// (`-1` = root β†’ parented to the entity) and its **local** rest translation. The skinned creature -/// path spawns one entity per joint carrying this translation; animation later drives each joint's TRS. +/// One rest-skeleton joint in Bevy space: its parent (`-1` for the root) and local translation. #[derive(Clone, Copy)] pub struct ModelJoint { pub parent: i16, pub local_translation: Vec3, - /// The bone's billboard arm, if authored (M2 bone flags `0x08/0x10/0x20/0x40`). The byte law - /// replaces the billboarded bone's rotation IN THE PALETTE, so children β€” and any geometry - /// skinned to them β€” inherit the camera facing (the frost-armor sheets skin to the scale-in - /// CHILD of a lock-Z bone). Rigged hosts feed this to the billboard joint pass; the per-batch - /// card split only covers geometry skinned to the billboard bone itself. - /// - /// Authored on every bone that carries the flag, welded seam or not: the animate kernel - /// `0x714260` reads no vertex, weight or triangle data at all, so there is no topology gate on - /// the arm (decision - /// 0945 superseding 0935). A welded seam stays welded because the replacement is built about - /// the bone's own pivot, which is what makes the blended seam ring move by millimetres. + /// The bone's billboard arm (bone flags 0x08/0x10/0x20/0x40). The reference replaces the + /// bone's rotation in the palette, so its children inherit the facing, and arms every flagged + /// bone: the animate kernel `0x714260` reads no vertex data, so a welded seam is no gate. The + /// seam holds because the replacement is built about the bone's own pivot. pub billboard: Option, - /// Bone flags `0x1/0x2/0x4` (see [`benilla_formats::SkeletonBone::parent_arm`]): how the - /// bone's effective PARENT matrix is rebuilt from the model's own root frame before it - /// composes. Applied by the same palette passes as `billboard`, and *before* them. + /// Bone flags 0x1/0x2/0x4: how the parent matrix is rebuilt from the model's root frame, + /// applied before the billboard arm. pub parent_arm: Option, } -/// A model's rest skeleton in Bevy space (decision 0019). Built once per M2 asset; the creature path -/// spawns a joint-entity hierarchy per instance from `joints` and shares the matching inverse bind -/// poses ([`build_skeleton`]'s second return) across instances. +/// A model's rest skeleton in Bevy space, shared by every instance. #[derive(Clone, Default)] pub struct ModelSkeleton { pub joints: Vec, - /// The bone whose `KeyBoneID == 4` (SpineLow) β€” the client's display-facing counter-twist spine - /// channel (`0x711f10(4, …)` in the `0x607ed0` body-facing tail): while a unit's rendered root - /// yaw is offset from its aim (a strafe), this subtree counter-rotates back toward the aim. - /// `None` when the model lacks the key-bone (beasts, props). + /// The KeyBoneID 4 (SpineLow) bone, which counter-twists toward the aim while the body's yaw + /// is off it, as in a strafe (`0x711f10(4, …)` in `0x607ed0`). pub spine_bone: Option, - /// The bone whose `KeyBoneID == 6` (Head) β€” the twist's head channel (`0x711f10(6, …)`), taking - /// the gap the spine channel leaves so the head lands back on the aim. `None` when absent. + /// The KeyBoneID 6 (Head) bone, which takes the rest of that twist (`0x711f10(6, …)`). pub head_bone: Option, } -/// Bake a raw [`Skeleton`] into the Bevy-space [`ModelSkeleton`] + its inverse-bind-pose matrices. -/// -/// Rig math per the reference (vanilla M2 has no inverse-bind array, rest pose is identity TRS, -/// the **pivot encodes bind position**) composed with Bevy's joint formula -/// (`joint_matrix = joint_global Β· inverse_bindpose`, which *becomes* `world_from_local`): -/// - **inverse bind pose** `i = translate(βˆ’pivot_i)` (modelβ†’bone-local at bind), -/// - **joint rest-local translation** = `pivot_i βˆ’ pivot_parent` (pure translations, so they telescope -/// to `pivot_i` up the chain). At rest every joint matrix collapses to the entity transform, so the -/// creature renders exactly where the static mesh did, undeformed β€” even with a scaled entity -/// transform (the two pivot translations cancel before scale acts). -/// -/// Pivots map WoWβ†’Bevy via [`wow_to_bevy`] (a pure rotation), so the whole skeleton lives in mesh space. +/// Bake a [`Skeleton`] to Bevy space, with its inverse bind poses. A vanilla M2 has no +/// inverse-bind array and an identity rest pose, and a bone's pivot is its bind position, so the +/// inverse bind pose is `translate(βˆ’pivot)` and a joint's rest translation `pivot βˆ’ parent pivot`: +/// at rest every joint matrix is the entity transform. pub(crate) fn build_skeleton(skel: &Skeleton) -> (ModelSkeleton, Vec) { let pivots = skeleton_pivots(skel); let joints = skel @@ -357,8 +241,7 @@ pub(crate) fn build_skeleton(skel: &Skeleton) -> (ModelSkeleton, Vec) { }) .collect(); let inverse_bindposes = pivots.iter().map(|p| Mat4::from_translation(-*p)).collect(); - // A bone carries KeyBoneID `k` iff `keyBoneLookup[k]` points at it (same rule as - // [`upper_subtree_root`]); 4 = SpineLow, 6 = Head β€” the display-facing twist channels. + // A bone carries KeyBoneID `k` iff `keyBoneLookup[k]` points at it. let key_bone = |k: i16| { skel.bones .iter() @@ -375,18 +258,13 @@ pub(crate) fn build_skeleton(skel: &Skeleton) -> (ModelSkeleton, Vec) { ) } -/// Each bone's Bevy-space bind-pose pivot (`wow_to_bevy(bone.pivot)`), in file order β€” the one -/// source [`build_skeleton`] (inverse bind poses) and [`build_attachments`] (attach-point offsets) -/// both derive from, so the two never compute a divergent pivot. +/// Each bone's Bevy-space pivot, the one source for the joints and the attachment offsets. pub(crate) fn skeleton_pivots(skel: &Skeleton) -> Vec { skel.bones.iter().map(|b| wow_to_bevy(b.pivot)).collect() } -/// One M2 attachment point (decision 0072 β€” held items), baked to a **Bevy-space bone-local -/// offset**: a child spawned under the bone's joint entity at `Transform::from_translation(offset)` -/// sits exactly at the attach point at bind pose and rides the bone's animation thereafter. -/// `offset` is `β‰ˆ Vec3::ZERO` on character models (the attach bones are leaves sitting exactly at -/// the attach point β€” VERIFIED, direct dump of `HumanMale.m2`, `decisions/0072`). +/// One M2 attachment point as an offset from its bone's pivot, Bevy space; about zero on +/// character models, whose attach bones sit on the point. #[derive(Clone, Copy)] pub struct ModelAttachment { pub id: u16, @@ -394,11 +272,8 @@ pub struct ModelAttachment { pub offset: Vec3, } -/// The right/left **arm subtree roots** for the per-arm animation masks: each hand attachment's -/// (id 1 = right, id 2 = left) bone walked up to its shoulder-keybone ancestor (keybones 2/3 β€” -/// either; the walk just finds "the shoulder above this hand", so no left/right convention is -/// assumed). `None` when the model lacks either hand attachment or a shoulder ancestor (beasts, -/// props β€” they never play per-slot one-shots). +/// The right and left arm subtree roots: each hand attachment's bone (1 right, 2 left) walked up +/// to its shoulder key-bone (2 or 3). pub(crate) fn arm_subtree_roots( skel: &Skeleton, attachments: &[ModelAttachment], @@ -419,26 +294,17 @@ pub(crate) fn arm_subtree_roots( Some((shoulder_of(1)?, shoulder_of(2)?)) } -/// The model's **upper-body split key-bone** subtree root β€” the client's `CGUnit+0xd5c`, selected by -/// the capability probe `0x60ce70` (`keyBoneLookup[4]` preferred, `[6]` fallback, else the βˆ’1 -/// sentinel = no split). On HumanMale `keyBoneLookup[4]` is bone 20 (SpineLow: chest/shoulders/arms/ -/// hands/head β€” a subtree that provably **excludes** the legs, which hang off the pelvis sibling -/// bone 21), so a clip masked to this subtree moves the upper body only. A bone carries KeyBoneID -/// `k` iff `keyBoneLookup[k]` points at it, so the SpineLow bone is the one whose `key_bone == 4` -/// (else `== 6`). `None` when the model has neither (the βˆ’1 sentinel) β€” the one-shot route then -/// falls back to full-body. +/// The upper-body subtree root, the reference's `CGUnit+0xd5c` from the probe `0x60ce70`: +/// key-bone 4 (SpineLow), else 6 (Head). SpineLow's subtree leaves out the legs, which hang off +/// the pelvis. `None` means no split, and a one-shot plays full-body. pub(crate) fn upper_subtree_root(skel: &Skeleton) -> Option { let bone_with = |k: i16| skel.bones.iter().position(|b| b.key_bone == k); bone_with(4).or_else(|| bone_with(6)) } -/// The model's **finger key-bone subtree roots** per hand `(right, left)` β€” the client's `CloseHand` -/// targets. WoW rigs each hand's fingers under **key-bones 8–12 (mainhand/right)** and **13–17 (offhand/ -/// left)**; the grip pose (`HandsClosed`, AnimationData 15) is armed on exactly these when a weapon is -/// held in that hand (`0x479660`/`0x60b590`). Only the *rigged* ones exist (HumanMale carries 11/12 -/// and 16/17; the rest are the βˆ’1 sentinel), so each entry is the set of bones actually carrying a -/// finger key-bone in that hand's range β€” **empty** for a model with no finger key-bones -/// (beasts/props), which never grip. +/// The finger key-bones per hand `(right, left)`, 8-12 and 13-17: the reference's `CloseHand` +/// targets, posed with `HandsClosed` (AnimationData 15) while that hand holds a weapon +/// (`0x479660`/`0x60b590`). Empty for a model without them. pub(crate) fn finger_subtree_roots(skel: &Skeleton) -> [Vec; 2] { let roots = |lo: i16, hi: i16| -> Vec { skel.bones @@ -451,7 +317,7 @@ pub(crate) fn finger_subtree_roots(skel: &Skeleton) -> [Vec; 2] { [roots(8, 12), roots(13, 17)] } -/// Whether bone `i` is `root` or one of its descendants (a parent-chain walk). +/// Whether bone `i` is `root` or one of its descendants. pub(crate) fn in_subtree(skel: &Skeleton, i: usize, root: usize) -> bool { let mut cur = i; loop { @@ -469,10 +335,7 @@ pub(crate) fn in_subtree(skel: &Skeleton, i: usize, root: usize) -> bool { } } -/// Bake the raw [`M2Attachment`] table into [`ModelAttachment`]s: `offset = wow_to_bevy(position) βˆ’ -/// pivot_bevy(bone)`, the bone-local offset from the joint's bind position. Records whose `bone` -/// indexes past `pivots` are dropped (defence in depth β€” `benilla-m2` already range-checks against -/// its own bone array; this guards against the skeleton/attachment parses ever disagreeing). +/// Bake the [`M2Attachment`] table to bone-local offsets, dropping a record with a bad bone. pub(crate) fn build_attachments( attachments: &[M2Attachment], pivots: &[Vec3], @@ -490,11 +353,8 @@ pub(crate) fn build_attachments( .collect() } -/// One M2 animation-event **positional marker**, baked exactly like [`ModelAttachment`] (same -/// bone-local Bevy-space offset convention β€” a consumer transforms it by the bone joint's live -/// global). The client's by-4CC position queries (`0x7130e0`/`0x7131b0`) read this table first -/// match: the missile launch points `$CSL`/`$CSR`/`$CST` (casting hand) and `$BWR` (ranged -/// release) are the consumers here. +/// One animation-event position, baked like [`ModelAttachment`]. The reference's by-4CC queries +/// (`0x7130e0`/`0x7131b0`) take the first match: `$CSL`/`$CSR`/`$CST` hands, `$BWR` ranged release. #[derive(Clone, Copy)] pub struct ModelMarker { /// The identifier 4CC, stored forward (`*b"$CSL"`). @@ -503,10 +363,7 @@ pub struct ModelMarker { pub offset: Vec3, } -/// Bake the raw [`benilla_formats::EventMarker`] table into [`ModelMarker`]s β€” the same -/// `offset = wow_to_bevy(position) βˆ’ pivot_bevy(bone)` bake and the same defence-in-depth bone -/// range check as [`build_attachments`]. File order is preserved (queries take the first ident -/// match, the client's scan order). +/// Bake the event markers like [`build_attachments`], in file order: queries take the first match. pub(crate) fn build_markers( markers: &[benilla_formats::EventMarker], pivots: &[Vec3], @@ -524,17 +381,12 @@ pub(crate) fn build_markers( .collect() } -/// A stable [`AnimationTargetId`] for bone index `bone` (decision 0019). The idle clip's curves and the -/// joint entities' target ids both derive from the bone index (via a synthetic name), so they match -/// without a real `Name` hierarchy. Scoped to one creature's joints + its shared clip. +/// A bone's stable [`AnimationTargetId`], shared by the clips' curves and the joint entities. pub fn bone_target_id(bone: u16) -> AnimationTargetId { AnimationTargetId::from_name(&Name::new(format!("benilla_bone_{bone}"))) } -/// The WoWβ†’Bevy basis change as a quaternion. DR 0002's map is a proper rotation (det +1); a bone -/// rotation track is a WoW-space quat, and a rotation transforms under a basis change by **conjugation** -/// `rΒ·qΒ·r⁻¹`, so this `r` is what [`build_idle_clip`] conjugates each rotation keyframe by. Derived from -/// `wow_to_bevy` itself (its images of the basis vectors are `R`'s columns) so it can't drift from it. +/// The WoW-to-Bevy basis change as a quaternion `r`; a rotation key converts as `rΒ·qΒ·r⁻¹`. fn wow_to_bevy_quat() -> Quat { Quat::from_mat3(&Mat3::from_cols( wow_to_bevy([1.0, 0.0, 0.0]), @@ -543,8 +395,7 @@ fn wow_to_bevy_quat() -> Quat { )) } -/// Build a keyframe curve, handling Bevy's β‰₯2-sample requirement: a single key (a constant channel) -/// becomes a flat curve spanning `[0, duration]`. `None` for no keys (the channel stays at rest). +/// A keyframe curve; one key becomes a flat curve over `[0, duration]`, as Bevy needs two. fn keyframe_curve( duration: f32, keys: Vec<(f32, T)>, @@ -560,23 +411,10 @@ fn keyframe_curve( } } -/// Build one sequence's [`AnimationClip`] β€” **and its [`PoseClip`] twin** (decision 0712, one walk -/// so the two cannot drift) β€” from parsed raw-WoW keyframes (decision 0019), transforming each -/// channel into the joints' Bevy-space `Transform`: -/// - **translation** = the bone's rest local (`pivot βˆ’ pivot_parent`) **plus** `wow_to_bevy(track)` β€” -/// the M2 translation track is a delta on the pivot offset; -/// - **rotation** = the WoW quat conjugated into Bevy space (`rΒ·qΒ·r⁻¹`); -/// - **scale** = the WoW scale with axes permuted to Bevy's (magnitudes; near-always uniform anyway). -/// -/// Always returns a clip, and a flag for whether any channel produced a **curve**. A sequence -/// whose bones all hold bind pose still gets one β€” an empty clip carrying the sequence's own -/// duration β€” because the clip is not only a bone pose: it is this model's carrier for the -/// instance's **sequence clock** (which slot is playing, and how far into it), which the emitter -/// rate/params tracks, the material-alpha loops and the GameObject state arm all resolve through. -/// Dropping the track-less ones left 807 corpus models β€” the ones whose animation is authored -/// entirely in the *emitter* tracks β€” with no clock at all, frozen on slot 0 at t=0 for ever -/// (decision 0941). The flag is what the doodad content gate reads instead (`poses_bones`): a clip -/// with no curves can only ever render the bind-pose mesh, so it must not spawn a rig. +/// Build one sequence's [`AnimationClip`] and its [`PoseClip`] in one walk. The M2 translation +/// track is a delta on the rest offset, a rotation converts as `rΒ·qΒ·r⁻¹`, and scale permutes axes. +/// A sequence that poses no bone still gets a clip of its own duration, since the clip is the +/// instance's sequence clock; the flag says whether any channel made a curve for a rig to pose. pub(crate) fn build_animation_clip( anim: &ModelAnimation, skeleton: &ModelSkeleton, @@ -643,19 +481,13 @@ pub(crate) fn build_animation_clip( }); } if !any { - // Bevy derives a clip's duration from its curves; with none, the sequence's own band - // length is the clock's period β€” without it the player would wrap on a 0-second loop - // (a modulo by zero) and the clock would never leave t=0, which is the whole defect. + // Bevy takes a clip's duration from its curves; with none, the clock would never leave 0. clip.set_duration(anim.duration.max(1e-3)); } (clip, pose, any) } -/// Build the weapon-grip **overlay clip** from clamped finger poses ([`benilla_formats::hand_grip_finger_poses`]): -/// a single constant rotation key per finger key-bone, conjugated into Bevy space with the same `rΒ·qΒ·r⁻¹` -/// as [`build_animation_clip`]. Played masked to one hand's finger subtrees, it holds those fingers curled -/// over the gait (rotation only β€” the fingers don't translate). Empty `poses` β†’ an empty clip (the caller -/// builds no grip node). +/// The weapon-grip overlay: one constant rotation per finger bone, played masked to one hand. pub(crate) fn build_grip_clip(poses: &[(u16, [f32; 4])]) -> (AnimationClip, PoseClip) { let r = wow_to_bevy_quat(); let mut clip = AnimationClip::default(); @@ -678,12 +510,7 @@ pub(crate) fn build_grip_clip(poses: &[(u16, [f32; 4])]) -> (AnimationClip, Pose (clip, pose) } -/// The [`BillboardInfo`] for a submesh that rides a billboard bone (Bevy space): the pivot to rotate -/// about. `None` for ordinary geometry. Pairs with [`build_submesh_mesh`], which centres the mesh. -/// -/// It used to also derive the card's resting plane normal from its first triangle. Nothing has read -/// that since the camera-basis law replaced the facet-normal card (0788's family), so it is deleted -/// rather than left as a field that reads as load-bearing. +/// The [`BillboardInfo`] of a submesh that rides a billboard bone, in Bevy space. pub(crate) fn billboard_info(sub: &RenderSubmesh) -> Option { let bb = sub.billboard.as_ref()?; Some(BillboardInfo { @@ -691,8 +518,7 @@ pub(crate) fn billboard_info(sub: &RenderSubmesh) -> Option { bone: bb.bone, kind: bb.kind, scale_anim: bb.scale_anim.clone(), - // Translation values are model-space offsets β€” bake each key to Bevy axes here (scale keys - // above stay raw: per-axis factors, not vectors). + // Translation keys are vectors and take Bevy axes; the scale keys stay per-axis factors. seq_translations: bb .seq_translations .iter() @@ -707,9 +533,7 @@ pub(crate) fn billboard_info(sub: &RenderSubmesh) -> Option { }) } -/// A global-sequence bone channel baked to Bevy space: the free-clock loop the runtime samples at -/// `(model_time mod period)` and writes onto the joint. `period` and key times are **seconds**; values -/// are already in the joints' Bevy `Transform` frame. See [`GlobalBone`]. +/// A global-sequence bone channel in Bevy space, a free-running loop; times in seconds. #[derive(Clone)] pub struct GlobalSeqChannel { pub period: f32, @@ -717,8 +541,7 @@ pub struct GlobalSeqChannel { } impl GlobalSeqChannel { - /// The two keys bracketing `t` (wrapped into `[0, period]`) and the fraction between them. Clamps to - /// the end keys (WoW authors the first/last key at the loop endpoints, so there is no wrap gap). + /// The keys around `t` mod the period, clamped at the end keys, which sit on the loop's ends. fn bracket(&self, t: f32) -> (T, T, f32) { let period = self.period.max(1e-3); let t = t.rem_euclid(period); @@ -751,9 +574,7 @@ impl GlobalSeqChannel { } } -/// A bone's global-sequence channels baked to Bevy space (see [`GlobalSeqChannel`]) β€” any of -/// translation / rotation / scale, each on its own free clock. The runtime writes only the driven -/// components onto the bone's joint entity, leaving the rest to the playing animation. +/// A bone's global-sequence channels; only the driven ones override the playing animation. #[derive(Clone)] pub struct GlobalBone { pub bone: u16, @@ -762,10 +583,7 @@ pub struct GlobalBone { pub scale: Option>, } -/// Bake the raw [`benilla_formats::GlobalSeqBone`] channels into Bevy-space [`GlobalBone`]s, applying -/// the same per-channel transforms as [`build_animation_clip`]: translation = the bone's rest-local -/// (`pivot βˆ’ pivot_parent`) plus `wow_to_bevy(track)`; rotation conjugated `rΒ·qΒ·r⁻¹`; scale axis-permuted -/// to Bevy's. Times/periods convert ms β†’ seconds. +/// Bake the global-sequence channels with [`build_animation_clip`]'s transforms, ms to seconds. pub(crate) fn build_global_bones( gseq: &[benilla_formats::GlobalSeqBone], skeleton: &ModelSkeleton, @@ -814,27 +632,12 @@ pub(crate) fn build_global_bones( .collect() } -/// Build ONE mesh from many placed static submeshes β€” the transforms baked into the vertices -/// (the production blob build, 1418; the retired `WOW_MEGA_STATIC` bracket's unfaded twin died -/// with it): same attribute recipe as [`submesh_to_static_mesh`], concatenated, with each part's -/// placement transform applied to positions and its rotation to normals (placements scale -/// uniformly, so directions survive), plus the per-vertex [`ATTRIBUTE_WOW_FADE_SPHERE`] (one -/// sphere per PART, replicated onto each of its vertices). `slots` (index-parallel with `parts` -/// when `Some`) additionally bakes each part's SH-probe slot as [`ATTRIBUTE_WOW_MERGED_SLOT`] β€” -/// the interior-prop lane. Parts lacking vertex colours pad white when any part has them β€” one -/// attribute set must cover the whole concatenation. -/// -/// **The mesh is recentred on its union-AABB centre** and that world centre is returned last: -/// the caller places the entity AT the centre (`Transform::from_translation`), and the returned -/// min/max are mesh-LOCAL for its `Aabb`. A world-baked mesh on `Transform::IDENTITY` sorts the -/// entity at the WORLD ORIGIN in Bevy's transparent phase β€” ~9 400 yd out, drawn first among -/// ALL transparent content with the twin's depth-write ON, so a feathering blob's translucent -/// pixels depth-killed every transparent entity drawn after it (a per-entity fader behind a -/// 20 %-alpha merged fence vanished outright, popping in only when the sightline cleared the -/// segment β€” decision 1422). Centre-sort restores the same location-sort semantics every -/// per-entity draw has (whose sort key is its placement origin). The fade spheres stay -/// WORLD-space β€” the shader compares them against the camera, never against mesh-local -/// positions β€” so the recentring does not touch them. +/// One mesh from many placed static parts, transforms baked in (normals take only the rotation, +/// as placements scale uniformly), each vertex carrying its part's fade sphere and, with `slots`, +/// its SH-probe slot; missing normals and colours pad (up, white). Returns the mesh, its local +/// bounds and its world centre, where the caller must place the entity: Bevy sorts transparent +/// draws by entity origin, and a mesh left at the world origin draws first and depth-hides the +/// transparent draws behind it. The fade spheres stay world-space. pub fn merged_static_mesh_faded( parts: &[(std::sync::Arc, Transform)], spheres: &[Vec4], @@ -873,9 +676,6 @@ fn merged_static_mesh_impl( normals.push(if flip { -b } else { b }.normalize_or_zero().to_array()); } } else { - // The lone compute_normals fallback path never merges (its models are rare and the - // bracket tolerates flat shading there): pad up, face normals are close enough for a - // measurement build. normals.extend(std::iter::repeat_n([0.0, 1.0, 0.0], sub.positions.len())); } uvs.extend(sub.uvs.iter().copied()); @@ -920,10 +720,7 @@ fn merged_static_mesh_impl( } } mesh.insert_indices(Indices::U32(indices)); - // Recentre on the union-AABB centre (see `merged_static_mesh`'s doc): positions become - // mesh-local, min/max follow, the centre goes back to the caller for the entity Transform. - // Guard the empty accumulation (no parts) β€” a MAX/MIN sentinel centre would explode. - // (`mn > mx` iff the vertex loop never ran; `positions` has been moved into the mesh.) + // Recentre on the union-AABB centre; with no vertices (`mn > mx`) the centre is the origin. let center = if mn.x > mx.x { Vec3::ZERO } else { @@ -948,7 +745,7 @@ fn merged_static_mesh_impl( mod tests { use super::*; - /// One sequence record, `bones` supplied by the caller β€” everything else at a neutral value. + /// One sequence record with the given bones, the rest neutral. fn sequence(duration: f32, bones: Vec) -> ModelAnimation { ModelAnimation { anim_id: 0, @@ -971,13 +768,7 @@ mod tests { } } - /// A sequence whose bones hold bind pose still becomes a clip β€” an EMPTY one carrying the - /// sequence's own duration β€” because the clip is this model's carrier for the instance's - /// **sequence clock** (decision 0941). Dropping it left 807 corpus models whose animation is - /// authored purely in their emitter tracks with no clock at all: no player, so no slot and no - /// time, so every per-sequence track read file slot 0 at t = 0 for ever. The duration is the - /// load-bearing half β€” Bevy derives a clip's period from its curves, and a 0-second period - /// would wrap the player's `seek_time` by a modulo-zero and never leave t = 0. + /// A clip with no curves still carries its sequence's duration, or its clock never advances. #[test] fn a_boneless_sequence_still_yields_a_clock_clip() { let skeleton = ModelSkeleton { @@ -993,7 +784,7 @@ mod tests { "the clock's period is the sequence's own band, not 0: {}", clip.duration() ); - // The flag is what the doodad content gate reads: a clock-only clip must never spawn a rig. + // The flag gates the rig: a clock-only clip must never spawn one. let moving = benilla_formats::BoneKeys { bone: 0, translation: vec![(0.0, [0.0, 0.0, 0.0]), (0.5, [0.0, 0.0, 1.0])], @@ -1004,9 +795,7 @@ mod tests { assert!(poses_bones, "a keyed track β‡’ a real pose"); } - /// A [`GlobalSeqChannel`] samples with linear interpolation, clamps at the endpoint keys, and wraps - /// on its period β€” the eye-blink shape: `0` (open) held, a fast ramp to `1` (shut), back to `0`, and - /// the whole thing repeating every `period`. Models the real eyelid keys (ms β†’ seconds). + /// The eye-blink shape from a real eyelid track: open, a fast ramp shut, open, repeating. #[test] fn global_seq_channel_samples_and_wraps() { let ch = GlobalSeqChannel { @@ -1040,8 +829,6 @@ mod tests { ); } - /// The WoWβ†’Bevy rotation quat must reproduce `wow_to_bevy` on vectors β€” it's the same rotation, so - /// `rΒ·v` equals the coordinate map. (Guards against `wow_to_bevy_quat` drifting from `wow_to_bevy`.) #[test] fn rotation_quat_matches_the_coordinate_map() { let r = wow_to_bevy_quat(); @@ -1055,9 +842,7 @@ mod tests { } } - /// A bone rotation about WoW **up** (+Z) must conjugate to a rotation about Bevy **up** (+Y) by the - /// same angle β€” the load-bearing check for the `rΒ·qΒ·r⁻¹` conjugation + the `[x,y,z,w]` quat order. - /// (A wrong conjugation/sign/order is exactly the "plausible but twisted creature" failure.) + /// Pins the conjugation and the `[x, y, z, w]` key order. #[test] fn wow_up_yaw_conjugates_to_bevy_up_yaw() { let r = wow_to_bevy_quat(); @@ -1074,7 +859,6 @@ mod tests { ); } - /// Conjugating the identity rotation is the identity (a sanity floor for the conjugation). #[test] fn identity_rotation_conjugates_to_identity() { let r = wow_to_bevy_quat(); @@ -1082,21 +866,9 @@ mod tests { assert!(id.dot(Quat::IDENTITY).abs() > 0.9999, "got {id:?}"); } - /// **A welded billboard bone still reaches the palette with its arm** (decision 0945, - /// superseding 0935's gate) β€” on the real shipped asset, the director's own pauldron. - /// - /// `LShoulder_Plate_PVPAlliance_A_01.m2` authors 3 bones: a plain root and two spherical - /// billboard spikes whose 8-vertex 50/50 seam rings stitch them to the body. 0935 dropped - /// their arm on exactly that welding. The animate kernel `0x714260` reads no vertex, weight or - /// triangle data at all β€” over its whole extent there is a single `movzx byte ptr` - /// (`0x7152ea`), the arm-selector map β€” so no such gate exists in the reference. The seam - /// survives because the replacement is built about the bone's own pivot, not because the arm - /// is skipped. - /// - /// The predicate itself is still live and still correct where it belongs: the per-batch **card - /// split** refuses to lift a welded bone's geometry into its own draw (0839). This test pins - /// that the two lanes now disagree *on purpose* β€” welded for the card split, faced by the - /// palette. Skips when the client isn't installed (the repo ships no assets). + /// The shipped pauldron `LShoulder_Plate_PVPAlliance_A_01.m2` welds two spherical billboard + /// bones to the body with 50/50 seam rings: the card split refuses to lift them, and the + /// palette faces them anyway, since `0x714260` reads no vertex data. #[test] fn a_welded_billboard_bone_still_reaches_the_palette_with_its_arm() { let data = benilla_formats::wow_data_or_skip!(); @@ -1104,8 +876,7 @@ mod tests { let path = "Item\\ObjectComponents\\Shoulder\\LShoulder_Plate_PVPAlliance_A_01.m2"; let bytes = chain.read_file(path).expect("read the pauldron"); let raw = benilla_formats::parse_m2_skeleton(&bytes).expect("parse its skeleton"); - // The asset really does author two spherical arms β€” otherwise this test would pass on a - // build that had simply stopped parsing bone flags. + // Two authored arms, so the test cannot pass on a build that stopped parsing bone flags. assert_eq!( raw.bones.iter().filter(|b| b.billboard.is_some()).count(), 2, @@ -1128,15 +899,9 @@ mod tests { ); } - /// **The mount seat's parent arm survives the bake** β€” the load-bearing case, on the asset - /// that decides it. - /// - /// `RidingHorse.m2` attachment 0 (the rider seat) rides bone 30, `flags = 0x6` = ignore parent - /// rotation + scale. That is why a galloping horse never rocks its rider: the seat's basis is - /// replaced by the mount's own root basis before the rider's frame is built from it, so the - /// spine's ~21Β° stride swing is discarded at the saddle while the seat still *translates* with - /// it (the copy-root leg `0x714a18`). Every vanilla player mount is `0x4`, `0x6`, or has a - /// seat whose ancestors don't rotate. + /// `RidingHorse.m2`'s rider seat (attachment 0) rides bone 30, flags 0x6: the mount's root + /// basis replaces the seat's, so the gallop's spine swing reaches the rider as translation + /// only (the copy-root leg `0x714a18`). #[test] fn the_riding_horse_seat_bone_carries_the_root_basis_arm() { let data = benilla_formats::wow_data_or_skip!(); @@ -1160,7 +925,7 @@ mod tests { }), "the seat takes the mount's own root basis β€” the gallop reaches it as translation only" ); - // The spine bones it hangs off carry no arm: they are what supplies the discarded swing. + // The spine bones above it carry no arm; they supply the swing. assert!( built.joints[23].parent_arm.is_none() && built.joints[25].parent_arm.is_none(), "the swinging spine bones are ordinary" diff --git a/crates/benilla-assets/src/model/anims.rs b/crates/benilla-assets/src/model/anims.rs index 3be2236c..3c2bd54a 100644 --- a/crates/benilla-assets/src/model/anims.rs +++ b/crates/benilla-assets/src/model/anims.rs @@ -1,238 +1,125 @@ -//! The playable-animation surface: the types + resolution logic the game queries at runtime -//! ([`AnimClip`], [`ModelAnimations`], [`ResolvedAnim`], and `ModelAnimations`'s `find`/ -//! `pick_variation`/`resolve`) β€” kept separate from the bake-side builders in [`super`] (meshes, -//! skeleton, attachments, masks/subtree helpers, grip poses, billboard info). +//! A model's playable animations, and how a requested animation id resolves to one of them. use benilla_formats::AnimDataCatalog; pub use benilla_formats::PlayableAnim; use bevy::animation::graph::{AnimationGraph, AnimationNodeIndex}; use bevy::prelude::*; -/// One **event keyframe** on a clip β€” the baked twin of [`benilla_formats::AnimEvent`], exactly as -/// [`super::ModelMarker`] is the baked twin of `EventMarker`, and carrying the same bone-local -/// Bevy-space offset convention. -/// -/// It carries **where** as well as **when**, because the reference's event kernel does: `0x719370` -/// computes `placementMatrix Β· (boneMatrix[event.bone] Β· event.position)` and snapshots it by -/// value into the deferred callback record every dispatcher reads. A consumer that plays at the -/// model root is using the placement alone, which is the same point only where the record sits at -/// the origin β€” and corpus-wide it does not: 149 of 244 `$DSL` records are off-origin, out to -/// 67.6 yd (`Maraudon_Waterfall01.m2`), and every player model's six `$CSD` records ride the head -/// (`benilla-extract eventmarkerscan`). -/// -/// **Two points, because there are two cases and both are exact.** [`Self::offset`] is bone-local -/// and wants the joint's live global ([`benilla_world::rig_anim::RigPose::posed_point`]); -/// [`Self::point`] is the model-space rest position and wants the model's own world frame. The -/// second is not an approximation of the first: with no keys a bone's matrix composes to the -/// identity, so `placement Β· position` *is* the kernel's quantity β€” which is the whole placed-doodad -/// population, where 0 of 244 `$DSL` records ride a bone any sequence keys. +/// One event keyframe on a clip, with where as well as when: the reference's event kernel +/// (`0x719370`) fires at `placement Β· boneMatrix[bone] Β· position`, and many records sit far from +/// the model origin. [`Self::offset`] serves a posed joint, [`Self::point`] a bone chain that +/// never moves; both are exact. #[derive(Debug, Clone, Copy)] pub struct ClipEvent { /// Seconds from the clip start. pub time: f32, /// The forward-stored 4CC (`*b"$SND"`). pub ident: [u8; 4], - /// The payload β€” a SoundEntries id for `$SND`/`$DSL`/`$DSO`, else 0. + /// The payload: a SoundEntries id for `$SND`/`$DSL`/`$DSO`, else 0. pub data: u32, /// The bone this record rides. pub bone: u16, - /// The authored point **relative to [`Self::bone`]'s pivot**, Bevy space β€” compose it with the - /// joint's live global. + /// The point relative to [`Self::bone`]'s pivot, Bevy space, for the joint's live global. pub offset: Vec3, - /// The authored point in the model's own space, Bevy space β€” compose it with the model's world - /// frame. Equals the kernel's quantity whenever the bone chain is unanimated. + /// The point in model space, Bevy axes, for the model's world frame when the chain is still. pub point: Vec3, } -/// One animation in a model's [`ModelAnimations`] graph (decision 0019): its `AnimationData.dbc` id, -/// the graph node an `AnimationPlayer` plays to run it, and whether it loops. +/// One sequence in a model's [`ModelAnimations`] graph. #[derive(Clone)] pub struct AnimClip { pub anim_id: u16, - /// The sequence's **file slot** ([`benilla_formats::ModelAnimation::seq_index`]) β€” not this - /// clip's index in [`ModelAnimations::clips`]. The key into a batch's per-sequence - /// material-alpha loops (`benilla_formats::AlphaAnim::seq`), so the lane that knows which clip - /// a unit is playing can sample the batch visibility that sequence authored. + /// The file slot the per-sequence bakes key on, not the index in [`ModelAnimations::clips`]. pub seq_index: usize, pub node: AnimationNodeIndex, pub looping: bool, - /// Clip length in seconds β€” used to seek a one-shot clip (Death) to its end pose, e.g. for a unit - /// that streamed in already dead so it shows the settled corpse instead of replaying the collapse. + /// Length in seconds; a unit streamed in dead seeks its one-shot to the end pose. pub duration: f32, - /// The sequence's authored design movement speed (`ModelAnimation::move_speed`, yd/s) β€” the - /// denominator the locomotion playback-rate scaler divides the unit's live speed by, so a fast - /// mover cycles its legs proportionally faster. `0.0` for a non-locomotion clip (left at rate 1Γ—). - /// - /// **Signed, and the sign is load-bearing β€” never `abs()` it.** A backwards gait is authored - /// NEGATIVE (`RidingKodo.m2` seq 14, WalkBackwards: `-2.5`), and the client's rate guard is a - /// strict `divisor > 0` (`0x5fe2f0` β€” decisions 0903/0910/0912). So an authored - /// backwards clip plays at a flat 1Γ—, while a model that has *no* WalkBackwards and falls back - /// to Walk (`+2.5`) gets speed-scaled. That asymmetry is the reference's behaviour; taking the - /// magnitude here β€” an inviting-looking tidy-up β€” inverts it on every model authoring a reverse - /// gait. + /// The authored gait speed (yd/s) the playback rate divides the live speed by; 0 if none. + /// Signed: a backwards gait is authored negative and the reference's guard is `divisor > 0` + /// (`0x5fe2f0`), so it plays at 1Γ—. Never take `abs()`. pub move_speed: f32, - /// The sequence's blend-in time (`ModelAnimation::blend_time`, seconds) β€” the cross-fade duration the - /// spawn site uses when transitioning *into* this clip, so a gait change eases instead of snapping. + /// The cross-fade into this clip, in seconds. pub blend_time: f32, - /// The sequence's bounds-sphere centre, **Bevy model space** (the raw `M2Sequence` CAaBox centre - /// mapped like the mesh vertices, so it shares the rendered parts' frame). With - /// [`Self::bounds_radius`], the mouse-pick **broad phase**: the real client tests the cursor ray - /// against the *current* animation's sphere β€” world-placed + scaled, no pad β€” before the posed - /// per-triangle narrow test (`0x7089c0`). + /// The sequence's bounds-sphere centre, Bevy model space: the reference's mouse-pick broad + /// phase, ahead of the posed per-triangle test (`0x7089c0`). pub bounds_center: Vec3, - /// The sequence's bounds-sphere radius (model-local yards). `0.0` β‡’ unauthored (callers fall back). + /// The bounds-sphere radius in model-local yards; 0 when unauthored. pub bounds_radius: f32, - /// The sequence's CAaBox, **Bevy model space** (componentwise min/max of the WoW-space corners - /// mapped like the mesh vertices). The unit **blob shadow**'s projection box: the real client - /// sizes the shadow from the *current* animation's box β€” clamped into Β±5 per axis, scaled by - /// the world matrix, yaw-rotated then axis-aligned (`0x711a20` + the `0x6d7920` corner build). - /// `(Vec3::ZERO, Vec3::ZERO)` β‡’ unauthored. + /// The sequence's box, Bevy model space, which sizes the blob shadow: clamped to Β±5 per axis, + /// scaled, yaw-rotated, then axis-aligned (`0x711a20`, corners at `0x6d7920`); zero when + /// unauthored. pub bounds_min: Vec3, pub bounds_max: Vec3, - /// The sequence's event keyframes (`$SND`/footsteps/`$CSS`… β€” decision 0070 slice 3), sorted by - /// time, seconds on the clip clock. Shared (`Arc`) so cloning a clip stays cheap; empty for the - /// overwhelming majority of sequences. + /// The sequence's event keyframes, by time on the clip clock. pub events: std::sync::Arc<[ClipEvent]>, - /// Per-arm masked variants `(right, left)` of this clip β€” graph nodes that animate **only** the - /// right/left arm subtree (shoulder keybone down), for the client's per-slot one-shots played - /// *over* the gait (the draw/stow: mainhand ceremony on the right arm, offhand on the left, - /// legs untouched). Built only for the sheath family (ids 89/90) on models whose hand - /// attachments + shoulder keybones resolve; `None` otherwise. + /// Right- and left-arm masked nodes for the sheath family (89/90), so a draw or stow plays + /// on one arm over the gait. pub arm_nodes: Option<(AnimationNodeIndex, AnimationNodeIndex)>, - /// The **upper-body masked variant** of this clip: a graph node that animates only the - /// SpineLow-subtree ([`upper_subtree_root`] β€” keyBoneLookup[4]/[6], the legs excluded), for the - /// one-shot route's masked destination (decision 0087 β€” a swing/emote played *over* a live base: - /// legs keep running/sitting/jumping while the torso swings or emotes). The general form of the - /// per-arm [`Self::arm_nodes`] ceremony machinery, one subtree wider. `None` for a model with no - /// split key-bone (the βˆ’1 sentinel) β€” the route falls back to full-body on the base node. + /// The upper-body masked node, so a swing or emote plays over the legs' own animation. pub upper_node: Option, - /// This variation's weight in the per-play roll (`M2Sequence.frequency` @+0x14): sequences - /// sharing an `anim_id` are **variations** (the alternating 1H swing arcs, the wound recoils), - /// and the real client picks one per one-shot play by a `_rand()`-weighted walk of the chain - /// (op4 `0x71248a..`) β€” [`ModelAnimations::pick_variation`]. + /// This variation's weight in the reference's per-play roll (`M2Sequence+0x14`, `0x71248a`). pub frequency: u16, - /// The sequence's `(minReplay, maxReplay)` range (`M2Sequence` +0x18/+0x1c): the client rolls a - /// play count `R = max(1, min + ((rand()Β·(maxβˆ’min)) >> 15))` at every arm and multiplies it into - /// the play window (`windowHi = now + spanΒ·R`), so a clamp-flag one-shot runs `R` times before - /// freezing. Benilla expresses that as a `RepeatAnimation::Count(R)` on the one-shot play. - /// `(0, 0)` β€” the overwhelming majority β€” rolls to `R = 1`. - /// - /// **A loop-flag sequence does NOT ignore it** (this doc said so, and decision 0768 corrected - /// it): the window still governs, and for a *placed doodad* its expiry is what fires the re-arm - /// that rolls a fresh variation β€” `(0, 0)` β‡’ `R = 1` β‡’ a re-roll every single loop. That is the - /// whole mechanism behind the Blasted Lands lightning wandering instead of strobing from one - /// fixed spot. + /// `(minReplay, maxReplay)` (`M2Sequence+0x18/+0x1c`): each arm rolls a play count `R` from + /// it, so a one-shot runs `R` times. A loop uses it too: a placed doodad re-rolls its + /// variation when the window expires, every loop for `(0, 0)`. pub replay: (u32, u32), - /// Whether this clip actually **poses bones** β€” some bone track produced a curve. `false` for a - /// sequence whose bones all hold bind pose: it still exists as a clip (playing it is how the - /// instance's sequence clock names a slot and advances a time, which the emitter tracks, the - /// material loops and the GameObject arm all read), but skinning to it can only ever reproduce - /// the static mesh. So a rig is spawned for a *posing* clip, never for a clock-only one β€” - /// decision 0941's split between the clock (free) and the rig (a palette slot, a skinned twin). + /// Whether a bone track made a curve; a clip that poses none is only a clock and gets no rig. pub poses_bones: bool, } -/// A model's animations as a playable bundle (decision 0019): one shared `AnimationGraph` holding every -/// sequence's clip, plus the per-sequence node/id/loop info. Built once per M2 asset and shared across -/// instances; each creature instance drives this graph with its own `AnimationPlayer`. Also attached to -/// the creature **as a component**, so the model bench can scrub `clips` and gameplay (Milestone C) can -/// select by `AnimationData.dbc` id (Stand now, walk/run later) β€” both query it on the entity. +/// A model's animations: one `AnimationGraph` shared by its instances, each with its own player. #[derive(Clone, Component)] pub struct ModelAnimations { pub graph: Handle, pub clips: Vec, - /// The **`HandsClosed` finger-grip masked nodes** `(right, left)` β€” graph nodes that animate only one - /// hand's finger key-bone subtrees ([`finger_subtree_roots`]) with the model's `HandsClosed` - /// (AnimationData 15) pose, played as a persistent weighted overlay over the gait while a weapon is - /// held in that hand (`0x479660`/`0x60b590`: the fingers curl for a held weapon; a - /// forearm-mounted shield or an empty hand stays open). `None` per hand for a model with no finger - /// key-bones or no `HandsClosed` clip (beasts, props β€” they never grip). + /// The `HandsClosed` grip nodes `(right, left)`, overlaid while that hand holds a weapon + /// (`0x479660`/`0x60b590`); an empty hand or a forearm shield stays open. pub hand_close: [Option; 2], - /// The model's baked **PlayableAnimationLookup** (decision 0082, M2 header `+0x2c/+0x30`, see - /// [`PlayableAnim`]): row `i` is the model's own precomputed substitute for requested - /// `AnimationData.dbc` id `i`. Empty for a model whose header carries no table (a boneless - /// doodad-shaped cube fallback, a malformed M2) β€” [`Self::resolve`] then degrades to identity. + /// The header's PlayableAnimationLookup (`+0x2c/+0x30`): row `i` substitutes for requested id + /// `i`. Empty without a table, and [`Self::resolve`] then returns the id itself. pub playable_animation_lookup: Vec, - /// The model's **AnimationLookup** (M2 header `+0x24`/`+0x28`): `AnimationData.dbc` id β†’ the - /// model's first sequence slot for it, `0xffff` where it authors none. Kept beside the playable - /// table because it is a *different question* β€” "does this model own id X at all", the - /// reference's `0x711960` β€” and the GameObject arm's missing-sequence remap branches on it four - /// times (`0x5f3930`). See [`Self::owns`]. + /// The header's AnimationLookup (`+0x24/+0x28`): each id's first slot, `0xffff` for none. pub animation_lookup: Vec, - /// The model's **global-sequence bone channels** baked to Bevy space (the eye-blink eyelid scale and - /// kin): free-clock loops the runtime samples independently of the playing animation and composes - /// over the sequence pose (see [`super::GlobalBone`]). Empty for a model with no global-sequence - /// tracks. + /// The global-sequence bone channels (an eyelid's blink), free-running over the sequence pose. pub global_bones: Vec, - /// Index into [`Self::clips`] of the model's **loader-idle seed** β€” the sequence the client's - /// M2-instance loader arms once at load and plays forever (`0x71019b`): **animation id 0 - /// ("Stand")**, resolved through the model's own `playableAnimationLookup`. NOT the - /// file-order-first sequence (0637). The two coincide for almost every model and diverge - /// exactly on one whose first sequence is a Spawn: `DuelingFlag.m2` is Spawn/Stand/Despawn, - /// and arming file-order-0 looped its Spawn band, leaving the duel flag hanging 9 yards in the - /// air on a 3.3 s cycle. - /// - /// `None` when the resolved idle leaves every bone at **bind pose** β€” looping that really does - /// render identically to the static mesh, so the spawn site skips the skin + player (the - /// content gate, decision 0130; the ~90% of placed doodads `doodadscan` measured). A *constant - /// but non-rest* pose does NOT qualify for that skip β€” see `m2::idle_pose_differs`. + /// Index into [`Self::clips`] of the loader-idle seed, the sequence the reference arms at load + /// (`0x71019b`): id 0 through the playable lookup, not the first file slot, which on + /// `DuelingFlag.m2` is Spawn. `None` when that idle is the rest pose and needs no rig. pub first_seq: Option, - /// The direct pose evaluator's baked data (decision 0712): the pose twin of every clip above, - /// the node β†’ (clip, mask) table, and the per-bone mask bits β€” filled by the same code that - /// builds [`Self::graph`], so it mirrors the graph by construction. `Arc`: the component is - /// cloned per instance; the bake is shared. + /// The pose evaluator's bake, mirroring [`Self::graph`]. pub pose: std::sync::Arc, } -/// A requested `AnimationData.dbc` id resolved to what a specific model actually plays (decision -/// 0082, `0x711bf0`) β€” see [`ModelAnimations::resolve`]. +/// A requested animation id resolved to what this model plays (`0x711bf0`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ResolvedAnim { - /// The `AnimationData.dbc` id to look up via [`ModelAnimations::find`]. + /// The id to look up with [`ModelAnimations::find`]. pub id: u16, - /// PATH 1's high16 direction/variant playback code (`0` from PATH 2 / the no-table degrade, which - /// have no such code of their own). **Plumbed through, not applied**: decision 0082 flags the - /// exact playback consumption (`~0x7126d2`) as untraced β€” no consumer reads this yet. + /// PATH 1's high-16 direction code, else 0. Carried, not applied: the reference's arm negates + /// the play rate for code 1 and zeroes it for 2 and 3 (`0x7126d2`-`0x712712`). pub dir_flags: u16, } impl ModelAnimations { - /// The clip for an `AnimationData.dbc` id (0 Stand, 4 Walk, 5 Run, …), or `None` if this model has - /// no sequence with that id (e.g. a static-Stand creature has no animated id-0 clip β€” the caller - /// then falls back to Stand or to bind pose rather than to an arbitrary clip). Returns the id's - /// **head variation** β€” the stable identity callers compare/loop on; a one-shot play that should - /// alternate variations goes through [`Self::pick_variation`] instead. + /// The id's head variation; a one-shot that alternates uses [`Self::pick_variation`]. pub fn find(&self, anim_id: u16) -> Option<&AnimClip> { self.clips.iter().find(|c| c.anim_id == anim_id) } - /// **Does the model author `anim_id`?** β€” the reference's `0x711960`: a bounds-checked read of - /// [`Self::animation_lookup`] against the `0xffff` sentinel. Deliberately NOT `find(..).is_some()`: - /// `clips` drops sequences we can't build (zero-duration ones among them), so a clip scan can - /// answer "no" where the reference answers "yes" and takes a different branch. Use this to decide - /// what the reference would *request*; use [`Self::find`] to get something we can actually play. + /// Whether the model authors `anim_id` (`0x711960`), which the GameObject arm's remap branches + /// on (`0x5f3930`). Not `find(..).is_some()`: `clips` lacks the zero-length sequences the parse + /// skips, which the reference still counts. pub fn owns(&self, anim_id: u16) -> bool { self.animation_lookup .get(anim_id as usize) .is_some_and(|&slot| slot != 0xffff) } - /// The clip a free/effect model instance runs: the caller's `preferred` id when the model has - /// it (the thrown-weapon missile asks for InFlight), else the reference's **model-load - /// bootstrap** β€” animation id **0 (`Stand`)**, variation 0 (`0x710153`–`0x71019b`: - /// `0x711bf0(reqId = 0)` β†’ `0x711960` β†’ op4 with an explicit `push 0x0` variation), falling - /// back to the first sequence record's own id only when the model authors no `Stand` (the - /// guarded `*(dword*)&sequences[0]` at `0x710181`). - /// - /// **Not the file-order-first slot**, which is what this used to be: the two differ on 583 - /// models corpus-wide (`benilla-extract fxlifescan`), and arming slot 0 hands - /// `Spells\LightningShield_State_Base` its `Decay` sequence from frame one. It is the same - /// correction `first_seq` already carries for the placed-doodad lane (0637, the duel flag) β€” - /// but taken *without* that field's bind-pose content gate, because an effect's emitters read - /// the posed joints even when the mesh would render identically. - /// - /// One selector so the rig (`entities::spell_fx::arm_effect_rig`) and its per-sequence riders - /// agree on which sequence is playing. + /// The clip a free or effect model runs: `preferred` when the model has it, else the + /// reference's load bootstrap (`0x710153`-`0x71019b`), id 0 `Stand` at variation 0, else the + /// first record's id (`0x710181`). Unlike [`Self::first_seq`], no bind-pose gate: an effect's + /// emitters read the posed joints. pub fn preferred_clip(&self, preferred: Option) -> Option<&AnimClip> { preferred .and_then(|id| self.find(id)) @@ -240,33 +127,14 @@ impl ModelAnimations { .or_else(|| self.clips.first()) } - /// The **FILE sequence slot** the loader-idle seed arms β€” [`Self::first_seq`]'s selection taken - /// *without* its bind-pose content gate, and expressed as the slot the per-sequence bakes are - /// keyed on rather than as a `clips` index. - /// - /// The two fields answer different questions and must not be conflated (decision 0936). The - /// reference arms this sequence on **every** M2 instance the moment it goes LIVE (`0x70ebd0`'s - /// tail β€” no instance in the client ever exists with nothing armed), so "which sequence is this - /// model playing" always has an answer. `first_seq` answers the narrower *rendering* question - /// "is it worth building a skin + `AnimationPlayer` for it", and says `None` when looping the - /// idle would render identically to the static mesh. That skip is sound for the mesh β€” and - /// silently wrong for every consumer keyed on the sequence *identity*, which then degraded to - /// file slot 0 (`EmitTiming::idx`/`EmitParams::sample`/`AlphaAnim::seq` all resolve `None` that - /// way). Slot 0 is only accidentally the idle one: on the Spawn/Stand/Despawn GameObjects - /// (`DuelingFlag`-shaped β€” the battlefield banners, the goobers, `ArenaFlag`) it is the **Spawn** - /// flourish, whose emitters then pour for ever at its opening frame. - /// - /// `None` only for a model with no buildable clip at all (the `GlobalSeqOnly` tier), where there - /// is no sequence to name. + /// The file slot the loader-idle seed arms, [`Self::first_seq`]'s selection without its + /// bind-pose gate: the reference arms it on every instance (`0x70ebd0`), so a consumer keyed on + /// the playing sequence needs it, rig or not. `None` only for a model with no clip. pub fn idle_seq(&self) -> Option { self.idle_clip().map(|c| c.seq_index) } - /// The loader-idle seed's **clip** β€” [`Self::idle_seq`]'s selection, before it is reduced to a - /// slot number. What a spawn site plays to put an instance on that sequence: since every - /// sequence builds a clip (decision 0941), a model whose bones never move still has one to arm, - /// and arming it is what gives its emitters/material loops a running clock instead of a frozen - /// slot-0 read. Distinct from [`Self::first_seq`], which stays the *rendering* question. + /// The loader-idle seed's clip, which a spawn site arms to start the instance's sequence clock. pub fn idle_clip(&self) -> Option<&AnimClip> { let idle_id = self .playable_animation_lookup @@ -278,15 +146,10 @@ impl ModelAnimations { .or_else(|| self.clips.first()) } - /// Pick a **variation** of `anim_id` for one play, given a fresh `roll` (the client's `_rand()`, - /// `0..0x7fff`): the weighted walk (op4 `0x7121a0` with variationIdx βˆ’1) β€” `roll < frequency` - /// picks the current node, else `roll -= frequency` and advance; chain exhaustion falls back to - /// the **head** (the client leaves the resolved seqIndex untouched on the exhaust path β€” `edi` - /// set at `0x71247a`, only the win path overwrites it at `0x7124e9`; correcting decision 0114's - /// clamp-to-last). - /// Variations are the sequences sharing `anim_id`, taken in file order (retail exporters bake - /// the `variationNext` chain contiguously and in order β€” the same assumption [`Self::find`]'s - /// head = variation 0 already rests on). `None` iff the model has no sequence for `anim_id`. + /// Pick a variation of `anim_id` for a `rand()` roll, the reference's weighted walk + /// (`0x7121a0`, variation βˆ’1). Exhaustion keeps the head: only the win path (`0x7124e9`) + /// overwrites the slot set at `0x71247a`. Variations are the sequences sharing `anim_id` in + /// file order, as retail files lay out the `variationNext` chain. pub fn pick_variation(&self, anim_id: u16, roll: u16) -> Option<&AnimClip> { let mut roll = i32::from(roll); let mut head = None; @@ -300,17 +163,10 @@ impl ModelAnimations { head } - /// Resolve a *requested* `AnimationData.dbc` id to the id this model actually plays (decision - /// 0082 β€” the two-path `0x711bf0` resolver the real client runs before every sequence lookup, - /// ahead of [`Self::find`]): - /// - **PATH 1** (`requested < playable_animation_lookup.len()`): the model's own baked table β€” - /// one dword read, already the precomputed `AnimationData.dbc` Fallback-column walk's result for - /// THIS model's actual sequence set (a chicken lacking Attack2H bakes `[18] = 16`). - /// - **PATH 2** (`requested` β‰₯ the table length, table non-empty): the guarded `AnimationData.dbc` - /// Fallback-column walk over `catalog` β€” reachable only for the 5 ids β‰₯ 203 on a normal retail - /// asset set (`nPlayableAnimationLookup` is a fixed 203; `AnimationData.dbc` has 208 rows). - /// - **No table at all** (`playable_animation_lookup` empty β€” a cube fallback / malformed M2): - /// degrades to identity; [`Self::find`] then does its own direct lookup (today's behavior). + /// Resolve a requested id as the reference does before every sequence lookup (`0x711bf0`): + /// - PATH 1, inside the playable lookup: the model's own baked substitute. + /// - PATH 2, past it (ids 203-207 on retail data): the `AnimationData.dbc` fallback walk. + /// - No table: the id itself. pub fn resolve(&self, requested: u16, catalog: &AnimDataCatalog) -> ResolvedAnim { if let Some(row) = self.playable_animation_lookup.get(requested as usize) { return ResolvedAnim { @@ -327,16 +183,10 @@ impl ModelAnimations { self.resolve_path2(requested, catalog) } - /// PATH 2 β€” the `AnimationData.dbc` Fallback-column walk (`0x711bf0` at `0x711c1f`): follow - /// `catalog.fallback` from `requested` until this model actually has a sequence for the current id - /// (mirrors the real walk's `animationLookup[eax] != 0xffff` success test via [`Self::find`]), - /// guarded by a 208-entry visited set (`AnimationData.dbc`'s row count β€” the reference's cycle - /// guard). `catalog.fallback` already collapses the walk's NULL-row guard, self-fallback - /// termination, and "Fallback is 0 (Stand)" into a single `None` β€” each of those is a dead end that - /// leads to the same exhaustion default, so folding them costs nothing here. Exhaustion β†’ Stand(0), - /// matching decision 0082's stated simplification (the real client's fuller default β€” the - /// GameObject-rest pose 147, or the model's first sequence id β€” is a further-out RE gap no benilla - /// id exercises: every id benilla requests resolves through PATH 1). + /// PATH 2 (`0x711c1f`): follow `catalog.fallback` until the model has a clip for the id, with + /// the reference's 208-row cycle guard. Exhaustion gives Stand (0), where the reference takes + /// Stand only if the model authors it, else 147 if authored, else the first sequence's id + /// (`0x711c22`-`0x711c4e`); every id benilla requests resolves in PATH 1. fn resolve_path2(&self, requested: u16, catalog: &AnimDataCatalog) -> ResolvedAnim { const STAND: ResolvedAnim = ResolvedAnim { id: 0, @@ -364,7 +214,7 @@ impl ModelAnimations { mod tests { use super::*; - // ── ModelAnimations::resolve (decision 0082) ────────────────────────────────────────────── + // ── ModelAnimations::resolve ────────────────────────────────────────────── use bevy::animation::graph::AnimationNodeIndex; @@ -414,8 +264,7 @@ mod tests { AnimDataCatalog::from_rows([]) } - /// PATH 1: a table entry within range is read verbatim, low16 as the id and high16 as the dir - /// code β€” the chicken-lacking-Attack2H shape (`[18] = {16, 0}`), model has clip 16 not 18. + /// A chicken without Attack2H bakes `[18] = 16`. #[test] fn path1_reads_the_baked_table_entry() { let mut table = vec![playable(0, 0); 19]; @@ -431,9 +280,7 @@ mod tests { ); } - /// PATH 1 also carries the dir-flags code β€” the real HumanMale example, read off the shipped - /// asset (`playableAnimationLookup[6] = 0x00030001`): resolved id 1, dir code 3. PATH 1 never - /// consults the catalog at all (an empty one is fine here). + /// HumanMale's `playableAnimationLookup[6]` is `0x00030001`: id 1, direction code 3. #[test] fn path1_carries_dir_flags_through_unapplied() { let mut table = vec![playable(0, 0); 7]; @@ -449,8 +296,6 @@ mod tests { ); } - /// PATH 2: id β‰₯ the table length walks the DBC Fallback chain (via `catalog`) until the model - /// actually has a sequence for the current id. #[test] fn path2_walks_the_dbc_fallback_past_the_table() { let cat = AnimDataCatalog::from_rows([ @@ -480,7 +325,6 @@ mod tests { ); } - /// PATH 2's cycle guard: a Fallback loop (5β†’6β†’5→…) terminates at Stand(0) rather than spinning. #[test] fn path2_cycle_guard_terminates_at_stand() { let cat = AnimDataCatalog::from_rows([ @@ -510,9 +354,7 @@ mod tests { ); } - /// PATH 2's self-fallback / NULL-row termination: an id with no usable Fallback (an unknown row, - /// or one whose Fallback is itself/0 β€” `AnimDataCatalog::fallback` already collapses all three to - /// `None`) exhausts to Stand(0) on the first hop. + /// `AnimDataCatalog::fallback` gives `None` for an unknown row, a self-fallback or Stand. #[test] fn path2_self_or_null_fallback_exhausts_to_stand() { let anims = test_anims(&[], vec![playable(0, 0); 3]); @@ -526,8 +368,7 @@ mod tests { ); } - /// PATH 2's NULL/max-id guard: an id at or past the 208-row `AnimationData.dbc` bound can't even - /// be marked visited β€” it exhausts to Stand(0) immediately, not just when it recurs. + /// An id past the 208 rows cannot be marked visited, so it exhausts at once. #[test] fn path2_out_of_dbc_range_id_exhausts_to_stand() { let anims = test_anims(&[], vec![playable(0, 0); 3]); @@ -541,8 +382,6 @@ mod tests { ); } - /// A model with no baked table at all (a cube fallback / malformed M2) degrades to identity β€” - /// `resolve` hands the id straight back so the caller's own `find` does today's direct lookup. #[test] fn no_table_degrades_to_identity() { let anims = test_anims(&[0], Vec::new()); @@ -558,25 +397,20 @@ mod tests { // ── preferred_clip: the model-load bootstrap (`0x710153`–`0x71019b`) ───────────────────── - /// The bootstrap arms **id 0 `Stand`**, not the file's first slot. `LightningShield_State`'s - /// real shape: sequence 0 is its `Decay`(159), and arming file-order-first handed a freshly - /// cast shield its fade-out from frame one. + /// `LightningShield_State`'s shape: slot 0 is its Decay (159). #[test] fn preferred_clip_arms_stand_over_the_file_order_first_slot() { let anims = test_anims(&[159, 0, 158], Vec::new()); assert_eq!(anims.preferred_clip(None).map(|c| c.anim_id), Some(0)); } - /// The guarded fallback (`0x710181`): a model authoring no `Stand` at all β€” Cripple, Curse of - /// Tongues, both Holy Word: Shields, whose slot 0 is `Hold` β€” plays that first record's own id. + /// The guarded fallback (`0x710181`), as on Cripple's effect: no Stand, slot 0 Hold (158). #[test] fn preferred_clip_falls_back_to_the_first_record_without_a_stand() { let anims = test_anims(&[158, 159], Vec::new()); assert_eq!(anims.preferred_clip(None).map(|c| c.anim_id), Some(158)); } - /// A caller-named id still wins (the thrown-weapon missile's InFlight) β€” and when the model - /// lacks it, the resolve falls through to the bootstrap rather than to slot 0. #[test] fn preferred_clip_honours_a_named_id_then_falls_to_stand() { let anims = test_anims(&[159, 0, 158], Vec::new()); @@ -587,10 +421,9 @@ mod tests { assert_eq!(anims.preferred_clip(Some(42)).map(|c| c.anim_id), Some(0)); } - // ── idle_seq: the loader-idle FILE slot, split from the render content gate ────────────── + // ── idle_seq: the loader-idle file slot, apart from the rig gate ───────────────────────── - /// `test_anims` in file order, with each clip's `seq_index` set to its slot β€” the axis the - /// per-sequence bakes (`EmitTiming`/`EmitParams`/`AlphaAnim`) are keyed on. + /// `test_anims` with each clip's `seq_index` set to its file slot. fn slotted(ids: &[u16], table: Vec) -> ModelAnimations { let mut a = test_anims(ids, table); for (i, c) in a.clips.iter_mut().enumerate() { @@ -599,18 +432,13 @@ mod tests { a } - /// The Spawn/Stand/Despawn GameObject shape β€” `DuelingFlag`, the battlefield banners, - /// `ArenaFlag`, the goobers. The idle slot is **1**, not 0: slot 0 is the Spawn flourish, whose - /// emitters pour at its opening frame for ever if a consumer degrades to it. + /// The Spawn/Stand/Despawn GameObject shape (`DuelingFlag`, `ArenaFlag`, battlefield banners). #[test] fn idle_seq_is_the_stand_slot_not_file_slot_zero() { let anims = slotted(&[145, 0, 157], Vec::new()); assert_eq!(anims.idle_seq(), Some(1)); } - /// It is `first_seq`'s selection **without** the bind-pose content gate: the field stays `None` - /// (looping a constant-pose idle renders identically to the static mesh, so the rig tier skips - /// the arm) while the slot is still known. Conflating the two is the whole defect. #[test] fn idle_seq_answers_even_when_the_content_gate_skipped_the_arm() { let anims = slotted(&[145, 0, 157], Vec::new()); @@ -622,31 +450,25 @@ mod tests { ); } - /// The idle id is resolved through the model's own `playableAnimationLookup` β€” the same - /// `0x711bf0(reqId = 0)` chain `first_seq` takes (0637). 268 models corpus-wide remap requested - /// id 0 (`Cripple_State_Base` β†’ 158 Hold), and slot 0 is not their idle either. + /// `Cripple_State_Base` remaps requested id 0 to 158 (Hold). #[test] fn idle_seq_resolves_id_zero_through_the_playable_lookup() { let anims = slotted(&[159, 158], vec![playable(158, 0)]); assert_eq!(anims.idle_seq(), Some(1)); } - /// No sequence for the resolved idle id β‡’ the first record's own slot, the guarded - /// `*(dword*)&sequences[0]` fallback β€” and `None` only when there is no clip at all to name. #[test] fn idle_seq_falls_back_to_the_first_record_then_to_none() { assert_eq!(slotted(&[158, 159], Vec::new()).idle_seq(), Some(0)); assert_eq!(slotted(&[], Vec::new()).idle_seq(), None); } - /// The variation pick is the client's weighted walk (op4 `0x7121a0` with variationIdx βˆ’1): - /// `roll < frequency` picks, else `roll -= frequency` and advance; chain exhaustion clamps to - /// the last variation; a lone variation always wins. + /// A roll past every weight gives the head variation, not the last. #[test] fn pick_variation_walks_the_weighted_chain() { let mut anims = test_anims(&[16, 16, 5], Vec::new()); - anims.clips[0].frequency = 0x6000; // Attack1H variation 0 β€” the common arc - anims.clips[1].frequency = 0x1fff; // variation 1 β€” the off-side swing + anims.clips[0].frequency = 0x6000; // Attack1H variation 0, the common arc + anims.clips[1].frequency = 0x1fff; // variation 1, the off-side swing let pick = |anims: &ModelAnimations, id, roll| { let c = anims.pick_variation(id, roll).unwrap(); anims.clips.iter().position(|x| std::ptr::eq(x, c)).unwrap() @@ -655,8 +477,7 @@ mod tests { assert_eq!(pick(&anims, 16, 0x5fff), 0); // last roll inside variation 0's weight assert_eq!(pick(&anims, 16, 0x6000), 1); // first roll past it assert_eq!(pick(&anims, 16, 0x7ffe), 1); - // Weights under-cover the roll β†’ the HEAD, not the last (the client's exhaust path leaves - // the resolved seqIndex untouched β€” `0x7124de`, corrects decision 0114). + // A roll past the weights keeps the head (the reference's exhaust path, `0x7124de`). assert_eq!(pick(&anims, 16, 0x7fff), 0); assert_eq!(pick(&anims, 5, 0x7fff), 2); // lone variation wins by exhaustionβ†’head (weight 0) assert!(anims.pick_variation(99, 0).is_none()); diff --git a/crates/benilla-assets/src/model/pose.rs b/crates/benilla-assets/src/model/pose.rs index ea64c736..692cd318 100644 --- a/crates/benilla-assets/src/model/pose.rs +++ b/crates/benilla-assets/src/model/pose.rs @@ -1,30 +1,21 @@ -//! The pose source β€” the direct M2 pose evaluator's baked data (decision 0712). -//! -//! Bevy's `animate_targets` prices every bone at graph-walk + hash-lookup + boxed-curve rates -//! (~1.9 Β΅s/bone β€” 0711's second lane). These types hold the *same* Bevy-space keyframes as the -//! `AnimationClip`s β€” emitted by the same bake walk, so they cannot drift β€” in the flat shape a -//! direct sampler wants: per clip, bone-sorted channel tracks; per graph node, the (clip, mask) -//! pair; per bone, its mask-group bits. The runtime evaluator (`benilla::creature_anim::pose`) -//! folds these with Bevy's own `Animatable::interpolate`, reproducing `animate_targets`' output -//! exactly (goldens there assert it bone-for-bone). +//! The direct M2 pose evaluator's baked data: the `AnimationClip`s' keyframes from the same bake +//! walk, flattened per clip, graph node and bone. The evaluator (`benilla_world::rig_anim::pose`) +//! reproduces Bevy's `animate_targets` output exactly, so these must match the clips key for key. use bevy::animation::animatable::Animatable; use bevy::animation::graph::AnimationNodeIndex; use bevy::prelude::*; -/// One channel's keyframes: times ascending and strictly deduplicated β€” the exact normalization -/// `UnevenCore::new` applies to the twin `AnimatableKeyframeCurve` (stable sort by time, dedup -/// keeping the FIRST sample at each time), so [`Self::sample`] and `sample_clamped` agree -/// key-for-key. Empty β‡’ the channel is absent (the clip never keys it β€” Bevy leaves the joint -/// component untouched, and so does the evaluator). +/// One channel's keyframes, normalized as `UnevenCore::new` does (stable sort by time, the first +/// sample at a time kept), so [`Self::sample`] matches the clip's curve. Empty: the clip does not +/// key the channel, and the joint keeps its value. #[derive(Clone)] pub struct PoseTrack { times: Vec, values: Vec, } -// Manual: the derive would bound `T: Default`, which `Quat` channels don't need β€” an empty track -// has no values of `T` at all. +// Manual: the derive would add a needless `T: Default` bound. impl Default for PoseTrack { fn default() -> Self { Self { @@ -35,10 +26,8 @@ impl Default for PoseTrack { } impl PoseTrack { - /// Bake a channel from the clip walk's key list, mirroring `keyframe_curve`'s presence rules - /// exactly: no keys β‡’ absent; one key β‡’ a constant (Bevy builds a flat 2-key curve β€” same - /// sampled value everywhere); β‰₯2 keys β‡’ `UnevenCore`'s sort + dedup, and absent if fewer than - /// 2 distinct times remain (Bevy's constructor errors there and the channel is dropped). + /// Mirrors `keyframe_curve`: one key is a constant, and keys that dedup to fewer than two + /// times are absent, since Bevy's constructor refuses them. pub fn new(keys: &[(f32, T)]) -> Self { match keys.len() { 0 => Self::default(), @@ -59,10 +48,7 @@ impl PoseTrack { } } - /// Sample at `t`, clamped into the key span β€” `uneven_interp`'s law verbatim (binary search; - /// exact hit returns that key; before/after the span clamps to the end keys; between keys - /// interpolates with `Animatable::interpolate` β€” `Vec3::lerp` / `Quat::slerp`, the same calls - /// the Bevy curve makes). `None` iff the channel is absent. + /// Sample at `t`, clamped into the key span, as Bevy's `uneven_interp` does. #[inline] pub fn sample(&self, t: f32) -> Option { let times = &self.times; @@ -88,7 +74,7 @@ impl PoseTrack { } } -/// One bone's channels in one clip β€” only bones with at least one present channel appear. +/// One bone's channels in one clip; a bone with no channel is left out. #[derive(Clone)] pub struct PoseBone { pub bone: u16, @@ -97,17 +83,14 @@ pub struct PoseBone { pub scale: PoseTrack, } -/// One clip's keyed bones, sorted ascending by bone index (the evaluator's merge walk relies on -/// it). The same channels, values, and presence as the twin `AnimationClip`. +/// One clip's keyed bones, ascending by bone index, which the evaluator's merge walk needs. #[derive(Clone, Default)] pub struct PoseClip { pub bones: Vec, } impl PoseClip { - /// Push a bone's channels, keeping [`Self::bones`] sorted (the bake walks file bone order, - /// which is ascending for every retail M2 β€” the insertion sort is a no-op there and a - /// correctness backstop elsewhere). Bones with no present channel are skipped. + /// Insert a bone in bone order, skipping one with no channel. pub fn push(&mut self, bone: PoseBone) { if bone.translation.is_empty() && bone.rotation.is_empty() && bone.scale.is_empty() { return; @@ -117,18 +100,15 @@ impl PoseClip { } } -/// One graph node the evaluator understands: which [`PoseClip`] it plays and its mask bits (the -/// `add_clip_with_mask` argument; a node whose mask bit intersects a bone's mask groups skips -/// that bone β€” Bevy's `computed_masks` law with a flat graph, where ancestors contribute 0). -/// Node weights are all 1.0 by construction (`add_clip(_, 1.0, root)`), so none is stored. +/// One graph node: the [`PoseClip`] it plays and its mask bits, which skip a bone whose mask +/// groups they intersect. Every node's weight is 1.0, so none is stored. #[derive(Clone, Copy)] pub struct PoseNode { pub clip: u32, pub mask: u64, } -/// A model's baked pose source (decision 0712): everything the runtime evaluator needs, filled by -/// the same code that builds the `AnimationGraph` so the two cannot drift. +/// A model's baked pose source, filled beside the `AnimationGraph` it mirrors. #[derive(Clone, Default)] pub struct PoseSource { /// The pose twin of each built clip (sequence clips in build order, then the grip clip). @@ -140,8 +120,7 @@ pub struct PoseSource { } impl PoseSource { - /// Record `node` as playing `clip` under `mask` β€” called right after the corresponding - /// `graph.add_clip*` so the table mirrors the graph by construction. + /// Record `node` as playing `clip` under `mask`; call it beside each `graph.add_clip*`. pub fn set_node(&mut self, node: AnimationNodeIndex, clip: u32, mask: u64) { let i = node.index(); if self.nodes.len() <= i { @@ -163,10 +142,6 @@ mod tests { use bevy::animation::animation_curves::AnimatableKeyframeCurve; use bevy::math::curve::Curve; - /// The channel golden (decision 0712): [`PoseTrack::sample`] must equal - /// `AnimatableKeyframeCurve::sample_clamped` β€” the exact curve the twin `AnimationClip` - /// carries β€” over a dense time sweep spanning before-first, between-keys, exact-hit, and - /// past-last, for both Vec3 (lerp) and Quat (slerp) channels. #[test] fn track_matches_the_bevy_keyframe_curve() { let vkeys = [ @@ -204,9 +179,6 @@ mod tests { } } - /// The presence rules mirror `keyframe_curve`: a single key is a constant everywhere (Bevy's - /// flat 2-key curve), duplicate-time keys dedup keeping the FIRST (`UnevenCore::new`), and a - /// list that dedups below 2 keys is absent (Bevy's constructor errors β†’ the channel drops). #[test] fn presence_and_dedup_mirror_the_curve_constructor() { assert!(PoseTrack::::new(&[]).sample(0.5).is_none()); @@ -234,13 +206,12 @@ mod tests { assert_eq!(dup.sample(t).unwrap(), curve.sample_clamped(t), "t={t}"); } - // Two keys at one time: Bevy's constructor would fail β€” the channel must be absent. + // Two keys at one time: Bevy's constructor fails, so the channel is absent. assert!(PoseTrack::new(&[(0.5, Vec3::X), (0.5, Vec3::Y)]) .sample(0.5) .is_none()); } - /// `PoseClip::push` keeps bones ascending and drops all-absent bones. #[test] fn clip_bones_stay_sorted() { let bone = |i: u16| PoseBone { diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl index 4b4d5636..bb7e5fcb 100644 --- a/crates/benilla-assets/src/shaders/liquid.wgsl +++ b/crates/benilla-assets/src/shaders/liquid.wgsl @@ -1,32 +1,26 @@ -// Liquid shader: every liquid surface, one arm per reference liquid renderer. -// ADT MCLQ river/ocean (`0x6851b0`/`0x685010`): the `ocean0_s.bls` combine of the depth swatch -// on stage 0 and the animated sheet on stage 1; the only arm with a depth ramp. +// Liquid shader, one arm per reference liquid renderer: +// ADT MCLQ river/ocean (`0x6851b0`/`0x685010`): `ocean0_s.bls`, the depth swatch on stage 0 and +// the animated sheet on stage 1; the only arm with a depth ramp. // WMO MLIQ water (`0x6b62e0` category 0), split on `MOGP.flags & 0x48`: exterior `0x6b6630` -// binds `MapObjExtWater0.bls`, interior `0x6b6420` is fixed-function and unlit; one texture -// stage, alpha from the per-vertex authored byte. -// Magma/slime (`0x6b68f0` WMO, `0x68dca0` ADT): the sheet is the opaque body, ADT or WMO alike. +// binds `MapObjExtWater0.bls`, interior `0x6b6420` is fixed-function and unlit. +// Magma/slime (`0x6b68f0` WMO, `0x68dca0` ADT): the sheet is the opaque body. // -// The ADT combine, `Shaders\Pixel\ocean0_s.bls` in `patch.MPQ` (0.25 is the program's own `PARAM`): +// The ADT combine, `Shaders\Pixel\ocean0_s.bls` (0.25 is the program's own `PARAM`): // rgb = primaryΒ·colorTex.rgb + detail.rgb + (secondary + 0.25)Β·detail.a // alpha = colorTex.a -// colorTex is the depth swatch (`swatch_row`) off the zone's `Light.dbc` water bands, taken raw -// (IntBand 16/17 river/lake, 14/15 ocean) and rebuilt every frame (`0x680b90`, refill `0x58acd0`). -// detail is the `lake_a`/`ocean_h` frame: RGB near-black, alpha the ripple, which the authored mips -// fade out with distance, so the sampler's mips and anisotropy matter. -// primary = clamp(ambient + diffuseΒ·NΒ·L): the vertex has no colour, so the default white is tracked -// into ambient+diffuse by `glColorMaterial`, and lighting is on at both water draws. -// secondary is the sun sheen (`sun_sheen`). +// colorTex is the depth swatch off the zone's `Light.dbc` water bands (IntBand 16/17 river/lake, +// 14/15 ocean), rebuilt every frame (`0x680b90`, refill `0x58acd0`); detail is the `lake_a` or +// `ocean_h` frame, near-black RGB and the ripple in alpha, which its authored mips fade with +// distance, so the sampler's mips and anisotropy matter; primary is the lit default white vertex +// (`glColorMaterial`); secondary is the sun sheen. // Deviation: the reference's ADT water alpha is the `0xc7fbc0` LUT curve `1.6Β·(i/63)^8`; this keeps // the linear swatch alpha, because switching changes the look of every ADT water surface. +// Deviation: this is the `specular`/`pixelShaders` = 1 leg the reference install runs; both CVars +// (`0x6886a0`/`0x688712`) default to 0, where water has no program, no specular, a plain ADD +// combine and no blend. An active ARB program bypasses the texture environment. // -// Deviation: this is the `specular`/`pixelShaders` = 1 leg. Both CVars (`0x6886a0`/`0x688712`) -// default to 0, where water has no program and no specular, a plain ADD combine and no blend; the -// reference install runs both at 1. An active ARB program bypasses the texture environment. -// -// The material turns culling off for every kind: all four reference liquid passes disable -// GL_CULL_FACE at entry. Blend is per kind, set CPU-side: water blends with depth write off, -// magma/slime are opaque and write depth. Every kind takes planar eye-Z GL_LINEAR fog in gamma -// space (`apply_fog`, as terrain.wgsl); output is raw gamma. +// Culling is off for every kind: all four reference liquid passes disable GL_CULL_FACE. Water +// blends with depth write off; magma and slime are opaque and write depth. Output is raw gamma. #import bevy_pbr::{ mesh_functions, @@ -43,14 +37,13 @@ struct LiquidParams { kind: vec4, // x = renderer (`LiquidPath`): 0 = ADT MCLQ, 1 = WMO exterior, 2 = WMO interior; yzw reserved. path: vec4, - // x reserved; y = frame count; z = scroll flag (1 only on WMO magma/slime, liquid nibbles 6/7); - // w = clock enable (0 on a deterministic run: frame 0, no scroll). + // y = frame count; z = scroll flag (WMO magma/slime only, liquid nibbles 6/7); w = clock + // enable (0 on a deterministic run). anim: vec4, }; @group(#{MATERIAL_BIND_GROUP}) @binding(102) var w: LiquidParams; -// The shared global light (`lighting::global_light`) that terrain and the models also read, -// mirrored as the prefix of its row layout, which must match row for row. +// The prefix of the shared global light (`lighting::global_light`); it must match row for row. struct WowLight { light_ambient: vec4, // 0 rgb = ambient; w = Mod2x scale light_diffuse: vec4, // 1 rgb = sun diffuse; w = clamp flag @@ -74,14 +67,11 @@ struct LiquidVsOut { @location(1) world_normal: vec3, @location(2) uv: vec2, @location(3) depth: f32, - // The sun sheen, evaluated per vertex and interpolated, as the reference's fixed-function - // vertex stage computes it. + // The sun sheen, per vertex and interpolated, as the reference's fixed-function stage does. @location(4) secondary_vtx: vec3, - // Vertex colour: a WMO interior pool's `MOMT.diffColor`, as the reference's interior water - // vertex carries it; white elsewhere. + // A WMO interior pool's `MOMT.diffColor`, as its reference vertex carries; white elsewhere. @location(5) vcolor: vec4, - // `MeshTag` bit 30: the room's per-frame interior-fog gate, the reference's `[0xca7f00]`; flat, - // since each surface is one instance. + // `MeshTag` bit 30: the room's per-frame interior-fog gate, the reference's `[0xca7f00]`. @location(6) @interpolate(flat) room_fog: u32, } @@ -89,51 +79,40 @@ struct LiquidVsOut { fn sun_sheen(world_normal: vec3, world_pos: vec3) -> vec3 { let n = normalize(world_normal); let to_light = -normalize(wow_light.light_sun.xyz); - // Local viewer: the reference sets `GL_LIGHT_MODEL_LOCAL_VIEWER = 1` at `0x59cf89`, so the eye - // vector is per vertex. With an infinite viewer NΒ·H is nearly constant over flat water and the - // whole sheet saturates at once. + // Local viewer, per vertex: the reference sets `GL_LIGHT_MODEL_LOCAL_VIEWER = 1` at `0x59cf89`. let to_view = normalize(view.world_position.xyz - world_pos); let half_v = normalize(to_light + to_view); let ndoth = max(dot(n, half_v), 0.0); - // The fixed-function `NΒ·L > 0` specular gate is left out: the lighting sun's elevation stays - // between +20Β° and +37Β° (`DayNight::SetDirection`), so NΒ·L on the flat up normal is always > 0. - // - // Shininess is water's own `w.kind.w` (6.0, `[0x8102e8]`), not the terrain exponent; material - // specular is white (`SetRenderState(3, 0xffffffff)`), so `light_spec.rgb` is the whole scale. - // The reference's specular light colour (`CGLight+0x48` to `glLightfv(GL_SPECULAR)`) is not - // pinned; row 9 stands in for it. + // No `NΒ·L > 0` specular gate: the sun stays between +20Β° and +37Β° (`DayNight::SetDirection`), + // so NΒ·L on the flat up normal is always > 0. Shininess is water's own (6.0, `[0x8102e8]`) and + // material specular is white (`SetRenderState(3, 0xffffffff)`). The reference's specular light + // colour (`CGLight+0x48` to `glLightfv(GL_SPECULAR)`) is not pinned; row 9 stands in for it. return wow_light.light_spec.rgb * pow(ndoth, max(w.kind.w, 1.0)); } -// The liquid clock: `globals.time` seconds under the build-time enable `anim.w`. fn anim_time() -> f32 { return w.anim.w * globals.time; } -// The 24 fps frame flip, 30 frames over 1.25 s (`FUN_0068aac0`), floored to an integer frame as in -// the reference. +// The 24 fps flip, 30 frames over 1.25 s (`FUN_0068aac0`), floored to a whole frame. fn frame_layer() -> i32 { return i32(floor(anim_time() * 24.0) % max(w.anim.y, 1.0)); } fn apply_scroll(uv: vec2) -> vec2 { - // Magma/slime scroll (liquid nibbles 6/7): the reference's stage-0 texture matrix (built at - // `0x6b68f0`, pushed at `0x6b6ae3`) is the identity with element 13, the v translate, set to - // `fmod(t, 10) Β· 0.1` (rate `[0x801620]`, period `[0x80e5a0]`), which is `t += phase`. It is - // rebuilt per draw off a millisecond clock, so the scroll is continuous; its phase is machine - // uptime (`GetTickCount`), so only rate and period match. REPEAT wrapping hides the reset. + // Magma/slime scroll (liquid nibbles 6/7): the reference's stage-0 texture matrix (`0x6b68f0`, + // pushed at `0x6b6ae3`) translates v by `fmod(t, 10) Β· 0.1` (rate `[0x801620]`, period + // `[0x80e5a0]`), continuously; its phase is machine uptime, so only rate and period match. + // REPEAT wrapping hides the reset. return vec2(uv.x, uv.y + w.anim.z * fract(anim_time() / 10.0)); } -// Distance fog: planar eye-Z GL_LINEAR in gamma space, as terrain.wgsl. Every liquid kind is -// fogged: GL_FOG defaults on (`0x593bf0` sets state `0x0f` = 1) and no liquid pass turns it off. -// The device holds two fog blocks: block 1 (`+0x70/74/78`) is the scene fog, submitted each frame -// from `WorldFrame::Render` (`0x66ff20`); block 2 (`+0x80/84/88`) is the interior haze, block 1 -// eased toward the MFOG/zone target over about 4 s (`0x6cf054`). Only the WMO geometry pass -// (`0x6b51d9`/`0x6b51ea`) and the WMO liquid pass (`0x6b6323` to `0x6b6342`) submit block 2, both -// under the room gate `[0xca7f00]`, so an interior pool fogs like its walls and ADT liquid takes -// block 1. `w.kind.z` is the gate's static half (the group's `MOGI & 0x48`), `room_fog` the -// per-frame half. +// Planar eye-Z GL_LINEAR fog in gamma space, as terrain.wgsl; GL_FOG defaults on (`0x593bf0`) and +// no liquid pass turns it off. Block 1 (`+0x70/74/78`) is the scene fog (`0x66ff20`); block 2 +// (`+0x80/84/88`) is the interior haze, eased toward the MFOG or zone target over about 4 s +// (`0x6cf054`). Only the WMO geometry pass (`0x6b51d9`/`0x6b51ea`) and the WMO liquid pass +// (`0x6b6323` to `0x6b6342`) submit block 2, under the room gate `[0xca7f00]`: `w.kind.z` is its +// static half (`MOGI & 0x48`), `room_fog` the per-frame half. fn apply_fog(rgb: vec3, world_pos: vec3, room_fog: u32) -> vec3 { var fog_color = wow_light.fog_color; var fog_span = wow_light.fog_params.xy; @@ -174,21 +153,17 @@ fn vertex(in: Vertex) -> LiquidVsOut { // ── The ADT depth swatch ───────────────────────────────────────────────────────────────────── // -// `FUN_0068a830` fills an 8Γ—64 texture, each row replicated across the 8 columns, with an exact -// byte-space integer accumulator rather than a float lerp (`step = ((c1 - c0) << 8) >> 6` loses -// no bits): `row(i) = c0 + floor(i * (c1 - c0) / 64)`, i = 0..63, so row 63 stops short of the -// deep endpoint. -// On the ocean only (selector 0), the last row's HSV value channel is scaled by 0.9 (`0x68aa13 -// fmul [0x8102ec]`), which `floor(0.9 * byte)` per channel reproduces to within 1/255, and its -// alpha is forced to 255 (`0x7bbec0`/`0x7bbec8`). -// Sampling is LINEAR/LINEAR, no mip, clamped (flags `0x201`), so V maps to texel `V*64 - 0.5` and -// the ocean darkening ramps in over the last 1/64 of V instead of stepping. -// The WMO arms do not use this: their opacity is the 256-entry ramp `0xca7f10`, which a plain lerp +// `FUN_0068a830` fills an 8Γ—64 texture, each row the same across its 8 columns, with an exact +// byte-space integer accumulator, `row(i) = c0 + floor(i * (c1 - c0) / 64)` for i = 0..63, so +// row 63 stops short of the deep endpoint. On the ocean (selector 0) the last row's HSV value is +// scaled by 0.9 (`0x68aa13`, `[0x8102ec]`), which `floor(0.9 * byte)` reproduces within 1/255, +// and its alpha is forced to 255 (`0x7bbec0`/`0x7bbec8`). Sampling is LINEAR, no mip, clamped +// (flags `0x201`), so V maps to texel `V*64 - 0.5` and the ocean darkening ramps over the last +// 1/64 of V. The WMO arms use the 256-entry ramp `0xca7f10` instead, which a plain lerp // reproduces. fn swatch_row(shallow: vec4, deep: vec4, i: f32, ocean: bool) -> vec4 { - // RGB endpoints are bytes already (`0x68a8fb`/`0x68a902` read packed dwords from DayNight - // state), so they round back exactly; alpha endpoints are `LightParams` floats the reference - // quantizes with `floor(v*255)`. + // RGB endpoints are bytes already (`0x68a8fb`/`0x68a902`), so they round back exactly; alpha + // endpoints are `LightParams` floats the reference quantizes with `floor(v*255)`. let c0 = vec4(round(shallow.rgb * 255.0), floor(shallow.w * 255.0)); let c1 = vec4(round(deep.rgb * 255.0), floor(deep.w * 255.0)); let row = c0 + floor(i * (c1 - c0) / 64.0); @@ -219,8 +194,7 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 { } } - // The animated frame: the ripple on water, the opaque body on magma/slime. `view.mip_bias` is - // the render-scale LOD compensation, 0 at native scale and above. + // The animated frame; `view.mip_bias` is the render-scale LOD compensation, 0 at native. let detail = textureSampleBias( frames, frames_samp, @@ -235,11 +209,10 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 { return vec4(apply_fog(detail.rgb, in.world_position.xyz, in.room_fog), 1.0); } - // V, computed CPU-side from the authored depth byte: clamp(byte/42) on river/lake (LUT - // `0xc81768`, `FUN_0068d790`, saturating near 5 yd), clamp(byte/255) on ocean (LUT `0xc7fcd8`, - // `FUN_0068d690`); both LUTs are built in `FUN_0068c4c0`. One V indexes colour and alpha alike. + // V, from the authored depth byte CPU-side: clamp(byte/42) on river/lake (LUT `0xc81768`, + // `FUN_0068d790`, saturating near 5 yd), clamp(byte/255) on ocean (LUT `0xc7fcd8`, + // `FUN_0068d690`), both built in `FUN_0068c4c0`. One V indexes colour and alpha alike. let depth = clamp(in.depth, 0.0, 1.0); - // The kind's swatch endpoints, packed every frame by `build_light_data`. var shallow = wow_light.water_river[0]; var deep = wow_light.water_river[1]; if (w.kind.y > 0.5) { @@ -247,16 +220,15 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 { deep = wow_light.water_ocean[1]; } // ---- The WMO water arms: opacity is the per-vertex authored byte through the zone's linear - // alpha ramp (`wmo_water_alpha_v`), carried in `in.depth`. + // alpha ramp, carried in `in.depth`. let vtx_alpha = mix(shallow.w, deep.w, depth); if (w.path.x > 1.5) { - // ---- WMO interior (`0x6b6420`): fixed-function whatever the CVars (`[0xc9607c]` is never - // read here), lighting off (`0x0e = 0`), fog on (`0x0f = 1`), combine preset `(0x1f, 3)`: + // ---- WMO interior (`0x6b6420`): fixed-function whatever the CVars (`[0xc9607c]` unread), + // lighting off (`0x0e = 0`), fog on (`0x0f = 1`), combine preset `(0x1f, 3)`: // rgb = clamp(Cf + Ct) alpha = clamp(Af + At) - // Cf is the pool's `MOMT.diffColor`, raw, carried in the vertex colour; the vertex has no - // normal, so no sun term and no sheen. Preset 3 is `GL_ADD` through `GL_COMBINE` on both - // channels (`COMBINE_ALPHA` at `0x85c2fc`), not the legacy `GL_TEXTURE_ENV_MODE = GL_ADD` - // whose alpha would be `Af Β· At`: the ripple adds to the pool's opacity. + // Cf is the pool's raw `MOMT.diffColor`; the vertex has no normal, so no sun and no sheen. + // Preset 3 is `GL_ADD` through `GL_COMBINE` on both channels (`COMBINE_ALPHA` at + // `0x85c2fc`), so the ripple adds to the pool's opacity rather than multiplying it. let body = clamp(in.vcolor.rgb + detail.rgb, vec3(0.0), vec3(1.0)); return vec4( apply_fog(body, in.world_position.xyz, in.room_fog), @@ -267,12 +239,11 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 { // ---- WMO exterior (`0x6b6630`): `Shaders\Pixel\MapObjExtWater0.bls` (bound `0x6b6654`), // the shader leg of the `[0xc9607c]` gate: // rgb = primary.rgb + detail.rgb + secondaryΒ·detail.a alpha = primary.a - // There is no `+0.25`: that is the ADT program's own `PARAM`. Lighting is on (the kernel - // never sets `0x0e`) with the vertex colour tracked into ambient+diffuse, so - // primary = band Β· clamp(ambient + diffuseΒ·max(NΒ·L, 0)). The band is one flat colour for - // every nibble, the deep river row `LightIntBand` sub-17 (`water_river[1]`), a hard - // immediate at `0x6b66be`. A DayNight slot is not a sub: `0x6d64d0` moves sub-8 out to - // `+0x4c`, so slots 8 to 16 are subs 9 to 17 and the kernel's slot 16 is sub-17. + // No `+0.25`, which is the ADT program's own. Lighting is on (`0x0e` never set), so + // primary = band Β· clamp(ambient + diffuseΒ·max(NΒ·L, 0)), the band one flat colour for + // every nibble: the deep river row, `LightIntBand` sub-17 (`water_river[1]`), an immediate + // at `0x6b66be`. A DayNight slot is not a sub: `0x6d64d0` moves sub-8 out to `+0x4c`, so + // the kernel's slot 16 is sub-17. let n_ext = normalize(in.world_normal); let to_light_ext = -normalize(wow_light.light_sun.xyz); let primary_ext = clamp( @@ -287,7 +258,7 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 { return vec4(apply_fog(rgb_ext, in.world_position.xyz, in.room_fog), vtx_alpha); } - // The ADT arm. `primary`: the lit white vertex, clamp(ambient + diffuseΒ·NΒ·L). + // The ADT arm. `primary`: the lit white vertex. let n = normalize(in.world_normal); let to_light = -normalize(wow_light.light_sun.xyz); let ndotl = max(dot(n, to_light), 0.0); @@ -305,7 +276,7 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 { // The `ocean0_s.bls` combine. var rgb = primary * swatch.rgb + detail.rgb + (secondary + vec3(0.25)) * detail.a; - // Alpha is `colorTex.a`, over the same V as the colour: deeper water is more opaque. + // `colorTex.a`, over the same V as the colour: deeper water is more opaque. let alpha = swatch.w; rgb = apply_fog(rgb, in.world_position.xyz, in.room_fog); diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl index 11e898f9..638ac1db 100644 --- a/crates/benilla-assets/src/shaders/terrain.wgsl +++ b/crates/benilla-assets/src/shaders/terrain.wgsl @@ -4,14 +4,10 @@ // and clamped per vertex (GL T&L), Gouraud-interpolated, modulated 1Γ— into the texture; // specular = clamp(row 9 Β· max(NΒ·H, 0)^20) per vertex (local viewer), times the sheen mask (the // `_s` texture's per-texel alpha, blended like the colour), added after the modulate. -// The per-vertex evaluation and the mask both bound the sheen: evaluated per pixel, or unmasked, -// the near-white row 9 washes lit ground to cream. +// Evaluated per pixel, or unmasked, the near-white row 9 would wash lit ground to cream. // // Bevy's `VertexOutput` has no slot for the interpolated specular, so the vertex transform and IO // struct are our own and `main_pass_post_lighting_processing` is dropped; fog is in-shader. -// -// One material serves an ADT tile: layer textures in one `texture_2d_array` (binding 100), -// per-chunk alpha maps in another (104). #import bevy_pbr::{ mesh_functions, @@ -32,9 +28,8 @@ struct TerrainParams { }; @group(#{MATERIAL_BIND_GROUP}) @binding(106) var t: TerrainParams; -// The shared global light (`lighting::global_light`), one storage buffer updated in place once a -// frame; this mirrors its row layout, which must match. Read in both stages: the per-vertex sheen -// needs the sun and specular rows. +// The shared global light (`lighting::global_light`), updated in place once a frame; this mirrors +// its row layout, which must match. struct WowLight { light_ambient: vec4, // rgb = row 1 ambient; w = Mod2x scale (Γ—1). light_diffuse: vec4, // rgb = row 0 sun diffuse; w = clamp-light flag (>0.5 β‡’ saturate). @@ -70,19 +65,18 @@ struct TerrainVsOut { // Terrain's point-light candidacy half-width (yd): `w + 10`, `w` being the chunk's bounding-sphere // radius `sqrt(2Β·16.666666Β² + (zExtent/2)Β²)`, 23.570166 on flat ground (constant at `0x68dfac`). -// Deviation: the flat `w` everywhere, so steep chunks gather slightly narrower than the reference, -// because the lights that differ are 33 yd out or more, where att β‰ˆ 0.02. +// This takes the flat `w` everywhere, so steep chunks gather slightly narrower than the reference; +// the lights that differ are 33 yd out or more, where att β‰ˆ 0.02. const TERRAIN_REACH: f32 = 33.570166; -// The dynamic point-light term at a world-space point. The reference commits at most three point -// lights per draw, the nearest by squared distance to the receiving unit, into GL slots 1-3; -// terrain's unit is the MCNK chunk, so `anchor` is its cell centre. Candidacy is a 20-yd -// spatial-hash sweep over `[floor((c - w - 10)/20), floor((c + w + 10)/20)]`, a Chebyshev box -// (`w` is `chunk+0x68`, copied at `0x71bc47`), whose guaranteed half-width `w + 10` is -// `TERRAIN_REACH`. -// A committed light is diffuse only (the staging ambient/specular slots are zeroed at `0x71c7e3`), -// falls off as `1/(0.7Β·d + 0.03Β·dΒ²)` on the vertex normal, and has no distance cutoff. -// The M2 lane in wow_model.wgsl uses the packed per-light range: the lanes differ on purpose. +// The dynamic point-light term. The reference commits at most three point lights per draw, the +// nearest by squared distance to the receiving unit, into GL slots 1-3; terrain's unit is the MCNK +// chunk, so `anchor` is its cell centre. Candidacy is a 20-yd spatial-hash sweep over +// `[floor((c - w - 10)/20), floor((c + w + 10)/20)]`, a Chebyshev box (`w` is `chunk+0x68`, +// copied at `0x71bc47`) of half-width `w + 10`, at least `TERRAIN_REACH`. A committed light is +// diffuse only (the ambient and specular slots are zeroed at `0x71c7e3`), falls off as +// `1/(0.7Β·d + 0.03Β·dΒ²)` on the vertex normal, and has no distance cutoff. The M2 lane in +// wow_model.wgsl uses the packed per-light range: the lanes differ on purpose. fn point_light_sum(P: vec3, N: vec3, anchor: vec3) -> vec3 { let count = u32(wow_light.point_count.x); var sel = array(0u, 0u, 0u); @@ -124,11 +118,11 @@ fn point_light_sum(P: vec3, N: vec3, anchor: vec3) -> vec3 { // The MCNK chunk cell centre under a world point: the reference's light anchor, the chunk's world // AABB centre `CMapChunk+0x5c..0x64` (written by `0x6b0e50`). The grid is fixed world-wide (chunk -// 533.33333/16 yd, half-extent 32 tiles) and symmetric; WoW x/y are Bevy βˆ’z/βˆ’x, so snapping Bevy -// x/z lands on the same cells. Mirrored in wow_model.wgsl, where clutter snaps to the same cells. -// Deviation: the anchor height is the vertex's own y, not the chunk's mid-height -// `(minH + maxH)/2`, so on relief one chunk's vertices can select different lights; the per-chunk -// constant is not plumbed to the vertex stage. On flat ground the two agree. +// 533.33333/16 yd, half-extent 32 tiles) and symmetric, and WoW x/y are Bevy βˆ’z/βˆ’x, so snapping +// Bevy x/z lands on the same cells; wow_model.wgsl mirrors it for clutter. +// The anchor height is the vertex's own y, not the chunk's mid-height `(minH + maxH)/2`, so on +// relief one chunk's vertices can select different lights: the per-chunk constant is not plumbed +// to the vertex stage. On flat ground the two agree. fn mcnk_cell_anchor(P: vec3) -> vec3 { let cell = 533.33333 / 16.0; let half = 32.0 * 533.33333; @@ -172,9 +166,8 @@ fn vertex(in: Vertex) -> TerrainVsOut { @fragment fn fragment(in: TerrainVsOut) -> @location(0) vec4 { - // The far-clip wall: the reference clips the detailed world per pixel at its projection far - // plane (`farclip`, about 777 yd). Discard beyond `fog_params.w` (0 disables) on planar eye-Z, - // the fog's coordinate; WDL and sky draw behind it. + // The far-clip wall: the reference clips the detailed world per pixel at its far plane + // (`farclip`, about 777 yd). Discard beyond `fog_params.w` (0 disables) on planar eye-Z. if (wow_light.fog_params.w > 0.0) { let clip_z = -(view.view_from_world * vec4(in.world_position.xyz, 1.0)).z; if (clip_z > wow_light.fog_params.w) { @@ -182,22 +175,21 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 { } } - // Per-chunk array indices, baked into the merged mesh: COLOR = 4 layer indices, UV1.x = alpha; - // constant across a chunk, so they interpolate back to the integer. + // Per-chunk array indices baked per vertex, COLOR the 4 layers and UV1.x the alpha; constant + // across a chunk, so they interpolate back to the integer. let li = vec4(round(in.color)); let ai = i32(round(in.uv_b.x)); let tiled = in.uv * t.params.x; - // `.rgb` = diffuse, `.a` = the `_s` texture's sheen mask (the base `.blp` has no alpha; matte - // without an `_s`). `view.mip_bias` undoes the coarser mip a render scale below 1 picks, 0 at - // native and above; the alpha and shadow arrays have a single mip, so only the layers take it. + // `.a` is the `_s` texture's sheen mask, 0 without an `_s`. `view.mip_bias` undoes the coarser + // mip a render scale below 1 picks; the alpha and shadow arrays have one mip, so only the + // layers take it. let s0 = textureSampleBias(layer_array, splat_samp, tiled, li.x, view.mip_bias); let s1 = textureSampleBias(layer_array, splat_samp, tiled, li.y, view.mip_bias); let s2 = textureSampleBias(layer_array, splat_samp, tiled, li.z, view.mip_bias); let s3 = textureSampleBias(layer_array, splat_samp, tiled, li.w, view.mip_bias); - // The alpha and shadow maps (one 64Β² grid per chunk, in 0..1, not tiled) share the layers' - // repeat sampler, and under linear filtering the footprint at a chunk edge wraps to the map's - // opposite edge; the half-texel inset clamps it instead. + // The alpha and shadow maps (one untiled 64Β² grid per chunk) share the layers' repeat sampler, + // so a linear footprint at a chunk edge would wrap to the far edge; a half-texel inset clamps. let auv = clamp(in.uv, vec2(0.5 / 64.0), vec2(1.0 - 0.5 / 64.0)); let a = textureSample(alpha_array, splat_samp, auv, ai); @@ -218,7 +210,7 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 { // MCSH baked shadow. With `pixelShaders` and `specular` on, terrain is one pass through // `terrainp_s.bls`, which reads the MCSH bit from the blend texture's alpha (1 lit, 0 shadowed) // and uses it twice, below; with them off the reference draws a separate ambient-tint overlay. - // Our `shadow_array` R8 is 255 shadowed, 0 lit. A chunk with no MCSH map (`uv_b.y < 0`) is lit. + // Our `shadow_array` is 255 shadowed, 0 lit; a chunk with no MCSH map (`uv_b.y < 0`) is lit. var shadow_lit = 1.0; let si = in.uv_b.y; if (si >= 0.0) { @@ -233,9 +225,8 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 { let spec_term = in.specular * specmask * shadow_lit; var tuned = clamp(diffuse_term + spec_term, vec3(0.0), vec3(1.0)); - // Gamma-space GL_LINEAR fog on planar eye-Z, not radial distance: the reference computes - // `fogcoord` from eye-Z, and radial distance over-fogs the screen edges. Per pixel matches the - // reference's per-vertex fog, since a linear factor interpolates exactly. + // Gamma-space GL_LINEAR fog on planar eye-Z, as the reference computes `fogcoord`, not radial + // distance. Per pixel matches its per-vertex fog, since a linear factor interpolates exactly. if (wow_light.fog_color.w > 0.5) { let eye_z = -(view.view_from_world * vec4(in.world_position.xyz, 1.0)).z; let denom = max(wow_light.fog_params.y - wow_light.fog_params.x, 0.001); diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl index ca7e305c..51f9fe92 100644 --- a/crates/benilla-assets/src/shaders/wow_model.wgsl +++ b/crates/benilla-assets/src/shaders/wow_model.wgsl @@ -6,7 +6,7 @@ // 1); M2's one FFP light site (`70bdf6`) runs only with that cvar off. Clutter and WMO are the FFP // light (GL_LIGHTING, GL_LIGHT0, GL_COLOR_MATERIAL); clutter's normal is the terrain normal under // the tuft, which the reference writes onto the clutter vertex. -// Deviation: no specular term (the M2 per-material shininess is only inferred) and no WMO +// Not built: a specular term (the M2 per-material shininess is only inferred) and the WMO // per-group authored colour. #import bevy_pbr::{ @@ -185,8 +185,8 @@ fn point_light_sum(P: vec3, N: vec3, anchor: vec3) -> vec3 { // The centre of the MCNK chunk under P, the light anchor for world-merged clutter, since the // reference gathers lights per terrain chunk (mirrored in terrain.wgsl). WoW x/y are Bevy βˆ’z/βˆ’x -// and the grid is symmetric, so snapping Bevy x/z lands on the same cells. Deviation: the height -// is the vertex's own, not the chunk record's, because lights sit near the surface. +// and the grid is symmetric, so snapping Bevy x/z lands on the same cells. The height is the +// vertex's own, not the chunk record's: lights sit near the surface. fn mcnk_cell_anchor(P: vec3) -> vec3 { let cell = 533.33333 / 16.0; let half = 32.0 * 533.33333; @@ -695,8 +695,8 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut { // The hover/target highlight (tag bit 31): the scene's committed ambient // (`0x614576`-`0x6145bd`), added to the batch colour inside the final clamp, lit or unlit - // (`c29`). Deviation: sampled live, where the reference holds the value sampled when the - // highlight began; a unit's tag has no slot to hold a colour, and the ambient moves slowly. + // (`c29`). Sampled live, where the reference holds the value sampled when the highlight + // began: a unit's tag has no slot to hold a colour, and the ambient moves slowly. let highlight = select(vec3(0.0), wow_light.light_ambient.rgb, highlighted); // The FFP combine: the light sum (lit, point lights, emission) clamps first and the texture // modulates it, `tex Γ— clamp(CΒ·sum + emission)`, with C the GL_COLOR_MATERIAL colour (MOCV on diff --git a/crates/benilla-assets/src/spatial_cache.rs b/crates/benilla-assets/src/spatial_cache.rs index aced3fd8..80e6daa1 100644 --- a/crates/benilla-assets/src/spatial_cache.rs +++ b/crates/benilla-assets/src/spatial_cache.rs @@ -1,60 +1,29 @@ -//! **The spatial dedup cache** β€” a `HashMap` whose entries remember *where they were last used -//! from*, so a sweep can drop the art of a place you have left. -//! -//! A drop-in for the `HashMap>` these caches used to be: the only difference at a call -//! site is that a lookup is [`SpatialCache::fetch`] (it counts as a use). `None` in an entry's stamp -//! means "used since the last sweep" β€” that inverted spelling is what removes all the plumbing, -//! because *not* being stamped is the cheap default and stamping happens once per sweep instead of -//! once per hit. -//! -//! It lives here, below the renderer, because it is the cache the asset foundation itself is built -//! out of (`WorldAssets`' textures and model materials) and because it knows nothing about a world: -//! [`SpatialCache::scope`] is handed the focus, the radius and the clock as three plain scalars and -//! never asks anyone for them. Decision 1164. The *instrumentation* around it β€” which caches exist, -//! what a census row is, where the focus comes from β€” stayed up in `art_scope`, which is the half -//! that reads the camera and the character roster. +//! The spatial dedup cache: a `HashMap` whose entries remember where they were last used from, so +//! a sweep can drop the art of a place you have left. use std::collections::HashMap; use std::hash::Hash; -/// The **dwell floor**: nothing is dropped within this many wall seconds of its last use, however far -/// away you have gone. Distance decides *what* expires; this decides how soon it is allowed to. -/// -/// Found by the director's first real run, and it is the case the distance rule alone gets wrong. The -/// detached free-fly camera (`F`, `FlyCam::speed` 40–100 yd/s, Γ—5 on Ctrl) travels at up to 500 yd/s β€” -/// so it crosses the 2667 yd radius in **five seconds**, and a sweep then drops a whole city's dedup -/// that the same camera re-creates on the way back. Their 48-second leg evicted 25 570 entries and -/// rebuilt `pmat` 3086 β†’ 15354 doing exactly that: the eviction was *correct* by the radius and -/// useless in effect, because nothing had finished being wanted. -/// -/// 30 s covers a 15 000 yd round trip at boosted fly speed. Under gameplay speed it never binds at -/// all (a gryphon needs ~107 s to cover the radius), so B131's own case is unchanged β€” this only -/// stops the thrash at speeds the radius was never sized for. Residency stays bounded either way: -/// what you hold is at most the art of the last 30 seconds of travel, which does not grow with how -/// long the process has run. +/// The dwell floor: nothing is dropped within this many wall seconds of its last use, however far +/// away. A free-fly camera at 500 yd/s crosses the radius in seconds, and distance alone would +/// drop art it rebuilds on the way back; at gameplay speed the floor never binds. const MIN_DWELL_SECS: f32 = 30.0; -/// Horizontal (tile-plane) distance between two wow-space points. Height is deliberately ignored: -/// residency is a 2D question β€” the streamer's window is a tile square, and art 200 yd below a -/// gryphon is in front of you, not away from you. +/// Horizontal distance between two WoW-space points; height is ignored, as the streamer's window +/// is a tile square. fn plan_dist(a: [f32; 3], b: [f32; 3]) -> f32 { let (dx, dy) = (a[0] - b[0], a[1] - b[1]); (dx * dx + dy * dy).sqrt() } -/// A dedup cache whose entries remember **where they were last used from**, so a sweep can drop the -/// art of a place you have left. A drop-in for the `HashMap>` these caches used to be: -/// the only difference at a call site is that a lookup is [`Self::fetch`] (it counts as a use). -/// -/// `None` in the stamp means "used since the last sweep" β€” see the module docs for why that inverted -/// spelling is what removes all the plumbing. +/// A dedup cache that a sweep can empty of the art of places left behind. A lookup is +/// [`Self::fetch`], which counts as a use; a `None` stamp means used since the last sweep, so a +/// hit costs no stamping. pub struct SpatialCache { map: HashMap)>, } -/// Where and when an entry was last wanted: the view focus at the first sweep after its last use. -/// Both halves are load-bearing β€” `at` decides whether it is far enough to expire, `t` whether it has -/// been idle long enough to be allowed to ([`MIN_DWELL_SECS`]). +/// The view focus and the time of the first sweep after an entry's last use. #[derive(Clone, Copy)] struct Stamp { at: [f32; 3], @@ -70,25 +39,23 @@ impl Default for SpatialCache { } impl SpatialCache { - /// Live entry count β€” what the census reports and the map-change evictors log. + /// Live entry count. pub fn len(&self) -> usize { self.map.len() } - /// Whether the cache holds nothing. Here because a `pub len` without it is a lint, and because - /// "did the map-change teardown actually empty this?" is a real question at a call site. + /// Whether the cache holds nothing. pub fn is_empty(&self) -> bool { self.map.is_empty() } - /// Drop everything (the map-scope teardown, decision 0729 β€” still the hard reset). + /// Drop everything: the map-change teardown. pub fn clear(&mut self) { self.map.clear(); } - /// Fetch a deduped value, **counting the hit as a use** so it restarts its distance grace. - /// Clones out rather than lending a reference: every caller wants an owned `Handle` anyway, and - /// ending the borrow at the call is what lets the miss path insert in the same expression. + /// Fetch a value, counting the hit as a use. It clones out so the miss path can insert in the + /// same expression. pub fn fetch(&mut self, key: &K) -> Option where V: Clone, @@ -103,22 +70,15 @@ impl SpatialCache { self.map.insert(key, (value, None)); } - /// Get-or-build in place, counted as a use β€” for the caches whose value is too big to clone out - /// ([`crate::clutter::ClutterGeometry`]'s decoded submeshes). + /// Get or build in place, counted as a use, for a value too big to clone out. pub fn or_insert_with(&mut self, key: K, build: impl FnOnce() -> V) -> &mut V { let slot = self.map.entry(key).or_insert_with(|| (build(), None)); slot.1 = None; &mut slot.0 } - /// Stamp everything used since the last sweep at `focus`/`now`, then drop every entry that is - /// **both** more than `radius` yards away **and** idle for at least [`MIN_DWELL_SECS`]. Returns - /// how many were dropped. - /// - /// The two conditions are not redundant. Distance alone expires art the moment a fast camera - /// pulls away from it, which is how the director's free-fly leg lost a city's dedup and rebuilt - /// it seconds later; the dwell floor is what makes "not immediate" true at any speed, not just at - /// gameplay speed. + /// Stamp everything used since the last sweep at `focus` and `now`, then drop every entry both + /// more than `radius` yd away and idle for [`MIN_DWELL_SECS`]; returns how many were dropped. pub fn scope(&mut self, focus: [f32; 3], radius: f32, now: f32) -> usize { let before = self.map.len(); self.map.retain(|_, (_, stamp)| match *stamp { @@ -139,23 +99,18 @@ mod tests { /// When the art is used and stamped. const NOW: f32 = 1000.0; - /// A sweep past the dwell floor β€” the earliest moment distance is *allowed* to expire anything. - /// Every "…is dropped" assertion sweeps here, which is itself the dwell floor's regression test: - /// swap this back to `NOW` and three of them fail (verified). + /// A sweep just past the dwell floor, the earliest that distance can expire anything. const LATER: f32 = NOW + MIN_DWELL_SECS + 1.0; const HOME: [f32; 3] = [0.0, 0.0, 0.0]; const FAR: [f32; 3] = [4000.0, 0.0, 0.0]; - /// Five ADT tiles β€” `art_scope`'s default eviction radius at the time these were written. Any - /// radius exercises the same code; the number is here only so the distances read naturally. + /// Five ADT tiles; any radius exercises the same code. const R: f32 = 2667.0; - /// A fresh entry survives its first sweep β€” that is the sweep that *stamps* it. Without this the - /// grace period would depend on where in the second the art happened to load. #[test] fn the_first_sweep_stamps_rather_than_judges() { let mut c: SpatialCache = SpatialCache::default(); c.insert(1, 10); - // Swept from the far side immediately: the entry has no stamp yet, so it cannot be too far. + // No stamp yet, so it cannot be too far. assert_eq!(c.scope(FAR, R, NOW), 0); assert_eq!(c.len(), 1); // Now it is stamped at FAR, so a later sweep from HOME drops it. @@ -176,8 +131,6 @@ mod tests { assert_eq!(c.scope(FAR, R, LATER), 1, "beyond it"); } - /// A hit restarts the grace, which is what keeps art in the direction of travel resident: the - /// same tree material is re-fetched by every new placement that streams in ahead of you. #[test] fn a_use_restarts_the_grace() { let mut c: SpatialCache = SpatialCache::default(); @@ -195,7 +148,6 @@ mod tests { } } - /// `or_insert_with` builds once and counts as a use, like `fetch`. #[test] fn or_insert_with_builds_once_and_counts_as_a_use() { let mut c: SpatialCache> = SpatialCache::default(); @@ -215,9 +167,6 @@ mod tests { ); } - /// **The director's free-fly case.** A camera at boosted fly speed crosses the radius in ~5 s; if - /// distance alone decided, the art it just streamed would be dropped and then rebuilt as the same - /// camera came back. Nothing expires inside the dwell floor however far away the focus goes. #[test] fn a_fast_camera_cannot_outrun_the_dwell_floor() { let mut c: SpatialCache = SpatialCache::default(); @@ -232,15 +181,14 @@ mod tests { "dropped {s} s out β€” the free-fly thrash" ); } - // Fly all the way back inside the radius and the entry is still the same one: no rebuild. + // Back inside the radius, the same entry: no rebuild. assert_eq!(c.scope(HOME, R, NOW + 6.0), 0); assert_eq!(c.fetch(&1), Some(10)); - // Stay away past the floor and it does expire β€” the bound is delayed, not removed. + // Away past the floor, it does expire. c.scope(HOME, R, NOW + 7.0); assert_eq!(c.scope(FAR, R, NOW + 7.0 + MIN_DWELL_SECS), 1); } - /// Height is not distance: flying over a city must not evict it. #[test] fn altitude_is_not_distance() { let mut c: SpatialCache = SpatialCache::default(); @@ -249,11 +197,7 @@ mod tests { assert_eq!(c.scope([0.0, 0.0, 10_000.0], R, LATER), 0); } - /// **The mechanism the whole design rests on**: dropping the cache's handle really does free the - /// asset. If `Assets` kept the value alive past its last strong handle, eviction would cost us - /// the dedup and recover no memory β€” a failure indistinguishable, from the outside, from "the fix - /// did nothing". Checked here rather than assumed, because the *reason* B131's `images` column - /// never fell is precisely that these caches held the last handle. + /// Eviction frees memory only if dropping the last handle frees the asset. #[test] fn dropping_the_last_handle_frees_the_asset() { let mut app = App::new(); @@ -287,8 +231,6 @@ mod tests { ); } - /// A cache nobody sweeps (the `Local`s in the glue booth / portrait bake) expires - /// nothing: without a `scope` call the stamps stay `None` forever. #[test] fn an_unswept_cache_never_expires() { let mut c: SpatialCache = SpatialCache::default(); diff --git a/crates/benilla-assets/src/terrain.rs b/crates/benilla-assets/src/terrain.rs index f5505724..b214132b 100644 --- a/crates/benilla-assets/src/terrain.rs +++ b/crates/benilla-assets/src/terrain.rs @@ -1,29 +1,8 @@ -//! Terrain-tile texture-array packing for the ADT loader. -//! -//! A tile's ground textures are stacked into one repeating, mipmapped `texture_2d_array`; its -//! per-chunk alpha (blend-weight) and MCSH shadow maps into two more arrays. The fidelity-critical -//! rule (an invariant of the reference client): the BLP's **authored** mip -//! pyramid is laid in **verbatim** β€” never CPU-regenerated by averaging texels in gamma byte space, -//! which darkens mid-tones and cools foliage. Off-Elwynn BLPs authored below [`LAYER_TEX_SIZE`] fill -//! the top mips by `NEAREST` upsample (texel replication β€” gamma-safe, no averaging). -//! -//! # The array's format is a property of the TILE, not of any one texture -//! -//! `Tileset\` is ~98 % S3TC, and 1626 handed the blocks straight to the GPU for model and WMO -//! albedo β€” but not here, because this lane resamples and stomps texels on the CPU. [`pack_layers`] -//! is what unblocks it: it holds every layer in its **authored** form until the whole tile has been -//! read, then asks one question of the set β€” *can all of these go up as blocks, together?* One -//! `texture_2d_array` has exactly one format and one mip-0 size, so the answer is all-or-nothing per -//! tile, and the fallback is the decode this lane always did. -//! -//! **The rule earns its narrowness from the install** (censused across all 2 275 textured ADTs): -//! a tile passes when every chosen layer shares one block form, is natively [`LAYER_TEX_SIZE`]Β² with -//! a full [`LAYER_MIP_COUNT`]-level chain, and needs no matte stomp. That is **87 % of Azeroth's -//! tiles and 92 % of Kalimdor's**, worth 2.5x / 2.9x off their layer bytes. The two obvious -//! extensions were measured and are deliberately **not** built: doing the matte stomp in block space -//! (legal on Bc2/Bc3 β€” zero the alpha half) buys **0** further tiles, and a block-space power-of-two -//! NEAREST upsample buys **8**. What is left out is left out because it is worth nothing, not -//! because it is hard. +//! Terrain tile texture arrays: the ground layers in one repeating, mipmapped `texture_2d_array`, +//! the per-chunk alpha and MCSH shadow maps in two more. As in the reference, each BLP's authored +//! mips go in verbatim, never regenerated by averaging gamma bytes, which darkens mid-tones; a BLP +//! below [`LAYER_TEX_SIZE`] fills the top mips by NEAREST upsample. One array has one format and +//! one mip-0 size, so a tile uploads its layers as S3TC blocks only when all of them can. use benilla_formats::{BlpMipChain, BlpTexels}; use bevy::asset::RenderAssetUsages; @@ -34,22 +13,19 @@ use bevy::render::render_resource::{ TextureViewDimension, }; -/// Edge length every layer BLP is uploaded at for the per-tile array (uniform so they share one -/// `texture_2d_array`). The Elwynn A/B target's layer BLPs are all natively 256Β² with 8 authored mips. +/// Every layer's edge in the tile array; Elwynn's ground BLPs are natively 256Β² with 8 mips. pub(crate) const LAYER_TEX_SIZE: u32 = 256; -/// Per-layer mip count for the array β€” the authored count at 256Β². Fewer-mip BLPs repeat the last -/// authored level; sub-256Β² BLPs upsample (NEAREST) to fill the top. +/// Per-layer mip count, the authored count at 256Β². pub(crate) const LAYER_MIP_COUNT: u32 = 8; -/// Albedo format: non-sRGB `Rgba8Unorm` β€” the GPU does not linearize on sample, so shader math stays -/// in WoW's gamma/byte space (the RE'd invariant; matches the model/BLP albedo format). +/// Non-sRGB, so sampling does not linearize and shader math stays in gamma byte space, as in the +/// reference. fn color_format() -> TextureFormat { TextureFormat::Rgba8Unorm } -/// The block form's albedo format β€” the same **non-sRGB** gamma-byte lane [`color_format`] is in -/// (0161). `Bc*RgbaUnormSrgb` would linearize on sample and is the one wrong answer here. +/// The block form's albedo format, non-sRGB like [`color_format`]; an `Srgb` one would linearize. fn block_color_format(texels: BlpTexels) -> TextureFormat { match texels { BlpTexels::Bc1 => TextureFormat::Bc1RgbaUnorm, @@ -59,24 +35,17 @@ fn block_color_format(texels: BlpTexels) -> TextureFormat { } } -/// One ground texture packed for the per-tile array: its authored mip chain at [`LAYER_TEX_SIZE`]Β², -/// concatenated mip-major (level 0 first), `mip_count` levels. +/// One ground texture's mip chain at [`LAYER_TEX_SIZE`]Β², level 0 first. pub(crate) struct LayerTexture { pub(crate) chain: Vec, pub(crate) mip_count: u32, } -/// Pack a BLP's authored mip pyramid into a [`LayerTexture`] for the tile array β€” authored levels -/// used verbatim (the C2 fix). When the BLP's mip 0 is smaller than `target_size` (off-Elwynn) the -/// top of the chain is NEAREST-upsampled (texel replication, gamma-safe), with the BLP's authored -/// smaller mips slotting into the deeper array levels; when its chain runs out the last authored mip -/// is reused (NEAREST-resampled if the dims still differ). +/// Pack a BLP's authored levels verbatim; a mip 0 below `target_size` is NEAREST-upsampled into the +/// top levels, and a chain that runs out repeats its last level. pub(crate) fn chain_to_layer(blp: BlpMipChain, target_size: u32) -> LayerTexture { - // **This function cannot take DXT blocks.** It NEAREST-resamples levels to a uniform array size, - // and [`pack_layers`] stomps the alpha byte after it for the matte case β€” both are per-texel CPU - // writes, which is exactly what a block form does not have. That is why the passthrough is a - // decision taken *before* this point, in [`pack_layers`], rather than a flag threaded through - // here: a chain that reaches this function has already been ruled out of it (1646). + // Decoded chains only: the resample and the matte stomp are per-texel writes, so the block + // passthrough is decided before this, in `pack_layers`. debug_assert!( blp.is_rgba8(), "terrain layer packing indexes texels; it needs a decoded chain" @@ -86,14 +55,13 @@ pub(crate) fn chain_to_layer(blp: BlpMipChain, target_size: u32) -> LayerTexture let level_w = (target_size >> level).max(1); let level_h = level_w; // tilesets are square in 1.12 if blp.width >= level_w { - // `BLP width >> j == level_w` ⟹ `j = log2(BLP width / level_w)`, clamped to the BLP's - // available mips (if it doesn't go that deep, repeat the last authored level). + // Source level `log2(width / level_w)`, clamped to the last authored level. let ratio = (blp.width / level_w).max(1); let src_level = (ratio.trailing_zeros() as usize).min(blp.mips.len() - 1); let (sw, sh) = blp.mip_size(src_level as u32); extend_nearest(&mut chain, &blp.mips[src_level], sw, sh, level_w, level_h); } else { - // Authored smaller than this level β€” NEAREST-upsample mip 0 (texel replication). + // Authored smaller than this level: NEAREST-upsample mip 0. extend_nearest( &mut chain, &blp.mips[0], @@ -110,46 +78,25 @@ pub(crate) fn chain_to_layer(blp: BlpMipChain, target_size: u32) -> LayerTexture } } -/// One of a tile's ground textures as it was read, **before** the tile has decided what form its -/// layer array takes β€” held in its authored form (DXT blocks kept) because that decision belongs to -/// the whole set, not to any one texture. See [`pack_layers`]. +/// One ground texture as read, blocks intact, until the whole tile picks its array's form. pub(crate) struct RawLayer { - /// The BLP's authored chain, blocks intact ([`benilla_formats::blp_bytes_to_native_chain`]). pub(crate) chain: BlpMipChain, - /// No `_s` specular variant existed for this layer, so its sheen mask must read 0 (matte). On - /// the decoded path that is an alpha stomp; on the block path it is why the tile is not eligible. + /// No `_s` specular variant exists, so the sheen mask must read 0. pub(crate) matte: bool, } -/// A tile's layer array, packed: LayerMajor bytes plus the format and level count that describe them. +/// A tile's layer array: LayerMajor bytes, their format and level count. pub(crate) struct PackedLayers { pub(crate) data: Vec, pub(crate) format: TextureFormat, pub(crate) mip_count: u32, } -/// The block form this whole tile's layer array can be uploaded in, or `None` if it must be decoded. -/// -/// Every clause is a property one `texture_2d_array` cannot vary across its layers, plus the two -/// CPU texel writes this lane used to do unconditionally: -/// -/// - **one block form** β€” an array has one `TextureFormat`, and `Bc1`/`Bc2` are different widths; -/// - **natively [`LAYER_TEX_SIZE`]Β²** β€” an array has one mip-0 size, and NEAREST-resampling a level -/// to reach it is a per-texel write blocks do not support; -/// - **a full [`LAYER_MIP_COUNT`]-level chain, every level exactly the size its form implies** β€” the -/// levels are laid in verbatim, so a short chain would have to repeat one at the wrong size (the -/// same resample again), and a level whose byte count disagreed with the format would corrupt -/// every layer after it rather than fail. `decode_native` pads to whole blocks, so this always -/// holds β€” it is checked in **release** anyway, because the one bug 1626 shipped was bytes -/// uploaded under a format that implied a different count, and a `debug_assert` cannot catch that -/// in a player's build; -/// - **no matte layer** β€” zeroing the sheen mask is a per-texel alpha write. (It *is* expressible in -/// Bc2/Bc3 block space β€” memset the alpha half β€” and that was measured to buy **zero** further -/// tiles, because a layer with no `_s` variant is a `Bc1` base and its tile is mixed-format anyway.) -/// -/// [`crate::gpu_blp::bc_supported`] gates the whole thing, so `WOW_NO_BC=1` and a device without BC -/// both land on the decoded path exactly as before β€” and so does every headless test, which is what -/// keeps the pixel-reading ones honest. +/// The block form the whole tile's layer array can upload in, or `None` to decode. Every layer +/// needs the same block form, natively [`LAYER_TEX_SIZE`]Β², a full [`LAYER_MIP_COUNT`] chain of +/// exactly the sizes the form implies (checked in release: a wrong byte count corrupts every later +/// layer instead of failing), and no matte stomp. [`crate::gpu_blp::bc_supported`] gates it all, +/// so `WOW_NO_BC=1`, a device without BC and every headless test take the decoded path. fn passthrough_form(layers: &[RawLayer], bc_supported: bool) -> Option { if !bc_supported { return None; @@ -170,18 +117,14 @@ fn passthrough_form(layers: &[RawLayer], bc_supported: bool) -> Option) -> PackedLayers { pack(fallback, layers, crate::gpu_blp::bc_supported()) } -/// [`pack_layers`] with the device fact passed in rather than read from the process β€” so the block -/// path is exercisable in a headless test, where [`crate::gpu_blp::bc_supported`] is always false -/// and would otherwise leave the whole passthrough untested. +/// [`pack_layers`] with device support passed in, so a headless test can reach the block path. fn pack(fallback: [u8; 4], layers: Vec, bc_supported: bool) -> PackedLayers { if let Some(form) = passthrough_form(&layers, bc_supported) { let per_layer: usize = (0..LAYER_MIP_COUNT) @@ -190,8 +133,6 @@ fn pack(fallback: [u8; 4], layers: Vec, bc_supported: bool) -> PackedL let mut data = Vec::with_capacity((layers.len() + 1) * per_layer); data.extend_from_slice(&solid_block_chain(form, fallback)); for layer in &layers { - // A straight copy: `passthrough_form` has already established that each of these levels - // is exactly what `format` implies, so the upload's own slicing cannot disagree. for level in layer.chain.mips.iter().take(LAYER_MIP_COUNT as usize) { data.extend_from_slice(level); } @@ -209,8 +150,6 @@ fn pack(fallback: [u8; 4], layers: Vec, bc_supported: bool) -> PackedL let mut data = fb.chain; for layer in layers { let matte = layer.matte; - // `into_rgba8` is free for a chain that was never blocks and is the decode we were trying to - // avoid for one that was β€” but it cannot fail, so this path is always available. let mut packed = chain_to_layer(layer.chain.into_rgba8(), LAYER_TEX_SIZE); if matte { packed @@ -229,23 +168,15 @@ fn pack(fallback: [u8; 4], layers: Vec, bc_supported: bool) -> PackedL } } -/// Bytes one array level occupies per layer in `form` β€” the arithmetic the eligibility check, the -/// packer and the fallback synthesiser all have to agree on, stated once. +/// Bytes of one layer's array level in `form`, shared by the check, the packer and the fallback. fn form_level_bytes(form: BlpTexels, level: u32) -> usize { let w = (LAYER_TEX_SIZE >> level).max(1); form.level_bytes(w, w) } -/// One solid-colour 4x4 block in `form`. -/// -/// The colour half is a DXT1 colour block with `c0` = the colour, `c1` = 0 and every 2-bit index 0, -/// so all 16 texels read `c0` β€” quantised to RGB565, which is what a solid colour costs in any block -/// form. `Bc2`/`Bc3` carry the alpha exactly (explicit 4-bit / a constant 8-bit endpoint pair). -/// -/// **`Bc1` cannot carry alpha at all**, so a `Bc1` tile's placeholder samples as opaque and would -/// take the sun sheen the `Rgba8Unorm` placeholder (alpha 0) suppresses. That is accepted, not -/// overlooked: layer 0 is only ever sampled when a BLP named by MTEX fails to read, which no healthy -/// install does β€” the census resolved all 1 048 `Tileset\` names β€” and it is a placeholder either way. +/// One solid 4x4 block in `form`: a DXT1 colour half with `c0` the RGB565 colour, `c1` 0 and every +/// index 0, after `Bc2`/`Bc3`'s alpha. `Bc1` has no alpha, so its placeholder samples opaque and +/// takes the sheen; layer 0 is only sampled when an MTEX texture fails to read. fn solid_block(form: BlpTexels, rgba: [u8; 4]) -> Vec { let c = ((rgba[0] as u16 >> 3) << 11) | ((rgba[1] as u16 >> 2) << 5) | (rgba[2] as u16 >> 3); let color = [c as u8, (c >> 8) as u8, 0, 0, 0, 0, 0, 0]; @@ -260,14 +191,12 @@ fn solid_block(form: BlpTexels, rgba: [u8; 4]) -> Vec { block } -/// A solid-colour block chain at [`LAYER_TEX_SIZE`]Β² with [`LAYER_MIP_COUNT`] levels β€” the block-form -/// twin of [`solid_layer_chain`], for layer 0 of a passthrough array. +/// The block form of [`solid_layer_chain`], for layer 0 of a passthrough array. fn solid_block_chain(form: BlpTexels, rgba: [u8; 4]) -> Vec { let block = solid_block(form, rgba); let mut out = Vec::new(); for level in 0..LAYER_MIP_COUNT { - // Levels below 4x4 still occupy one whole block β€” `form_level_bytes` rounds up, which is - // exactly what the authored chains next to this one do at their 2x2 tail. + // A level below 4x4 still takes one whole block, as the authored chains do. for _ in 0..(form_level_bytes(form, level) / block.len()) { out.extend_from_slice(&block); } @@ -275,8 +204,7 @@ fn solid_block_chain(form: BlpTexels, rgba: [u8; 4]) -> Vec { out } -/// A solid-colour layer chain at [`LAYER_TEX_SIZE`]Β² with [`LAYER_MIP_COUNT`] levels β€” the per-tile -/// array's fallback layer 0 (a solid colour downsamples to itself, so every mip is the same colour). +/// The decoded fallback layer 0: one colour at every level. pub(crate) fn solid_layer_chain(rgba: [u8; 4]) -> LayerTexture { let mut chain = Vec::with_capacity(mip_chain_byte_size(LAYER_TEX_SIZE, LAYER_MIP_COUNT)); for level in 0..LAYER_MIP_COUNT { @@ -301,9 +229,8 @@ pub(crate) fn mip_chain_byte_size(top: u32, mip_count: u32) -> usize { .sum() } -/// Append `src` (RGBA8 `sw Γ— sh`) resized to `dw Γ— dh` to `out` using `NEAREST` β€” pure texel -/// replication/decimation, so gamma-encoded bytes pass through unchanged (no averaging β†’ no -/// darkening). Identity at `(sw, sh) == (dw, dh)`. +/// Append RGBA8 `src` resized from `sw Γ— sh` to `dw Γ— dh` by NEAREST, which never averages gamma +/// bytes. pub(crate) fn extend_nearest(out: &mut Vec, src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) { if sw == dw && sh == dh { out.extend_from_slice(src); @@ -317,8 +244,7 @@ pub(crate) fn extend_nearest(out: &mut Vec, src: &[u8], sw: u32, sh: u32, dw out.extend_from_slice(resized.as_raw()); } -/// Stack the tile's ground textures into a repeating, mipmapped `2d_array`. `rgba` holds each layer's -/// full `mip_levels`-deep chain (LayerMajor order); `layer_count` layers at `sizeΒ²` mip 0. +/// The tile's packed ground layers as a repeating, mipmapped `2d_array`. pub(crate) fn layer_array_image(size: u32, layer_count: u32, packed: PackedLayers) -> Image { let PackedLayers { data, @@ -329,11 +255,8 @@ pub(crate) fn layer_array_image(size: u32, layer_count: u32, packed: PackedLayer image.data = Some(data); image.texture_descriptor.mip_level_count = mip_count; image.data_order = TextureDataOrder::LayerMajor; - // Ground is the surface the filter policy costs the most on β€” a tiling texture at a grazing - // angle is the anisotropy axis's worst case, and it fills most of the frame. The reference - // does not let this lane choose: both values come from the process globals - // ([`crate::tex_filter`]) β€” trilinear with anisotropy off, which is what `hwDetect` hands a - // virgin install (1645). + // As in the reference, ground filtering comes from the process globals (`crate::tex_filter`): + // trilinear with anisotropy off on a fresh install (`hwDetect`). let filter = crate::tex_filter::tex_filter(); image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { address_mode_u: ImageAddressMode::Repeat, @@ -347,35 +270,24 @@ pub(crate) fn layer_array_image(size: u32, layer_count: u32, packed: PackedLayer image } -/// Stack `layer_count` per-chunk alpha maps (each `sizeΒ²`, concatenated) into a linear `2d_array` β€” -/// the tile's blend weights (R/G/B = layers 1/2/3 opacity). Non-colour, so `Rgba8Unorm`. +/// The per-chunk alpha maps as a linear `2d_array`: R, G and B are layers 1, 2 and 3's opacity. pub(crate) fn alpha_array_image(size: u32, layer_count: u32, rgba: Vec) -> Image { let mut image = array_image(size, layer_count, TextureFormat::Rgba8Unorm); image.data = Some(rgba); image } -/// Stack `layer_count` MCSH shadow maps (each `sizeΒ²`, one byte/texel) into a single-channel -/// `2d_array` β€” WoW's pre-baked static terrain shadows (`R` = shadowed, 0 = lit). +/// The MCSH baked shadow maps as a one-byte `2d_array`: `R` is shadowed, 0 lit. pub(crate) fn shadow_array_image(size: u32, layer_count: u32, data: Vec) -> Image { let mut image = array_image(size, layer_count, TextureFormat::R8Unorm); image.data = Some(data); image } -/// Build a `2d_array` `Image` from concatenated per-layer bytes, with a `D2Array` view so the -/// `dimension = "2d_array"` bindings resolve. -/// -/// `RenderAssetUsages::RENDER_WORLD`: the render world takes the bytes on extract β€” a tile's -/// three arrays are megabytes each, and nothing reads them main-side (they flow into the terrain -/// material as handles; the minimap, collider, clutter all derive from other sources). The -/// default usage cloned every array on landing AND kept a main-world copy resident per tile β€” -/// pure cost on both the B181 landing frame and the resident-set memory. +/// A `2d_array` image with a `D2Array` view. `RENDER_WORLD` only: nothing reads a tile's arrays +/// main-side, so no main-world copy stays resident. fn array_image(size: u32, layer_count: u32, format: TextureFormat) -> Image { - // `new_uninit`, not `new`: `Image::new`'s length assert is written for uncompressed formats - // (it skips itself when `pixel_size()` errors, which is every BC format) and it would force a - // mip-0-sized dummy allocation on every tile just to be thrown away. The caller sets `data`. - // Same reasoning, same call, as the model lane's `world_art_image` (`blp.rs`). + // `new_uninit`, not `new`, which would allocate a mip-0 dummy per tile; the caller sets `data`. let mut image = Image::new_uninit( Extent3d { width: size, @@ -425,8 +337,7 @@ mod tests { #[test] fn chain_to_layer_upsamples_a_small_blp_without_darkening() { - // A 64Β² BLP packed to 256Β²: the top levels are NEAREST-upsampled. A solid 200 in must come - // out solid 200 β€” averaging (the C2 bug) would dim it below 200. + // Averaging would dim a solid 200 below 200. let layer = chain_to_layer(synthetic_chain(64, 7), 256); assert_eq!(layer.chain.len(), mip_chain_byte_size(256, LAYER_MIP_COUNT)); assert!( @@ -467,8 +378,7 @@ mod tests { assert_eq!(img.texture_descriptor.mip_level_count, LAYER_MIP_COUNT); } - /// A synthetic block chain: `mips` authored levels at `sizeΒ²` in `form`, padded to whole blocks - /// exactly as `benilla_blp::decode_native` delivers them. + /// Zeroed levels in `form`, padded to whole blocks as `benilla_blp::decode_native` delivers. fn block_chain(form: BlpTexels, size: u32, mips: u32) -> BlpMipChain { let levels = (0..mips) .map(|i| { @@ -491,8 +401,6 @@ mod tests { } } - /// The whole eligibility rule, clause by clause β€” each `None` here is a tile that keeps paying - /// 4 bytes a texel, so each one is worth stating. #[test] fn a_tile_passes_its_blocks_through_only_when_every_clause_holds() { let ok = || { @@ -503,22 +411,21 @@ mod tests { }; assert_eq!(passthrough_form(&ok(), true), Some(BlpTexels::Bc2)); - // No device support (every headless test, and `WOW_NO_BC=1`). + // No device support. assert_eq!(passthrough_form(&ok(), false), None); - // Mixed block forms β€” one array, one format. This is the common mixed tile: a `_s` variant - // is DXT3 while a base without one is DXT1 (413 of 416 pairs in the install). + // Mixed block forms, the common case: a `_s` variant is DXT3, a base without one DXT1. let mixed = vec![ raw(BlpTexels::Bc1, 256, 8, false), raw(BlpTexels::Bc2, 256, 8, false), ]; assert_eq!(passthrough_form(&mixed, true), None); - // Not natively 256Β² β€” reaching the array size is a NEAREST resample, a per-texel write. + // Not natively 256Β². let small = vec![raw(BlpTexels::Bc2, 128, 7, false)]; assert_eq!(passthrough_form(&small, true), None); - // A short chain would have to repeat a level at the wrong size β€” the same resample. + // A short chain. let short = vec![raw(BlpTexels::Bc2, 256, 5, false)]; assert_eq!(passthrough_form(&short, true), None); - // A matte layer needs its alpha stomped to 0, which is a per-texel write. + // A matte layer. let matte = vec![raw(BlpTexels::Bc2, 256, 8, true)]; assert_eq!(passthrough_form(&matte, true), None); // Raw1/Raw3 tilesets have no block form at all. @@ -527,20 +434,15 @@ mod tests { matte: false, }]; assert_eq!(passthrough_form(&raw1, true), None); - // A tile with no layers at all is the fallback-only tile β€” nothing to pass through. + // The fallback-only tile. assert_eq!(passthrough_form(&[], true), None); } - /// The bug 1626 shipped for twenty minutes was blocks uploaded under a format that implied a - /// different byte count. This asserts the pairing directly, for every block form: the packed - /// buffer must be exactly what `format` says a `LAYER_TEX_SIZEΒ²` Γ— `mip_count` Γ— `layers` array - /// holds β€” nothing else can be sliced out of it correctly. #[test] fn a_packed_block_array_is_exactly_the_size_its_format_implies() { for form in [BlpTexels::Bc1, BlpTexels::Bc2, BlpTexels::Bc3] { let layers = vec![raw(form, 256, 8, false), raw(form, 256, 8, false)]; - // A level that lies about its size must not reach the packer: it would corrupt every - // layer after it in the array rather than fail (1626's own twenty-minute bug). + // A level of the wrong size must not reach the packer. let mut short = raw(form, 256, 8, false); short.chain.mips[3].truncate(4); assert_eq!(passthrough_form(&[short], true), None, "{form:?}"); @@ -552,9 +454,8 @@ mod tests { .sum(); // 3 layers: the synthesised fallback at index 0, then the two real ones. assert_eq!(packed.data.len(), 3 * per_layer, "{form:?}"); - // And the point of the whole change, pinned: DXT1 is 0.5 bytes a texel and DXT3/DXT5 - // are 1, against `Rgba8Unorm`'s 4. (The sub-4x4 tail mips cost a whole block in either - // form, which is the entire 0.01 the ratio falls short of the texel arithmetic.) + // DXT1 is 0.5 bytes a texel, DXT3 and DXT5 are 1, decoded is 4; the sub-4x4 tail + // levels take a whole block, the 0.01 the ratio falls short. let decoded = 3 * mip_chain_byte_size(LAYER_TEX_SIZE, LAYER_MIP_COUNT); let ratio = decoded as f64 / packed.data.len() as f64; let want = if form == BlpTexels::Bc1 { 8.0 } else { 4.0 }; @@ -565,8 +466,6 @@ mod tests { } } - /// Layer 0 is synthesised, not read, so it is the one part of a passthrough array whose bytes we - /// author ourselves β€” decode them back and check they are the colour we asked for. #[test] fn the_synthesised_fallback_block_decodes_to_its_colour() { let rgba = [107u8, 133, 82, 0]; @@ -585,8 +484,7 @@ mod tests { .into_rgba8() .mips .remove(0); - // RGB565 is what a solid colour costs in any block form; assert within one quantisation - // step per channel rather than pretending the round trip is exact. + // Within one RGB565 step per channel. for px in top.as_chunks::<4>().0 { for (got, want) in px[..3].iter().zip(&rgba[..3]) { assert!( @@ -595,8 +493,7 @@ mod tests { ); } } - // Alpha is the sheen mask: `Bc2`/`Bc3` carry the requested 0; `Bc1` has no alpha channel - // and samples opaque, which is the documented limitation of the placeholder. + // `Bc1` has no alpha and samples opaque. let alpha_ok = match form { BlpTexels::Bc1 => true, _ => top.as_chunks::<4>().0.iter().all(|px| px[3] == rgba[3]), @@ -605,8 +502,6 @@ mod tests { } } - /// The decoded path must still be byte-for-byte what this lane always produced β€” including the - /// matte stomp, which moved out of the ADT loader and into `pack`. #[test] fn the_decoded_path_still_stomps_a_matte_layers_alpha() { let layers = vec![ diff --git a/crates/benilla-assets/src/tex_filter.rs b/crates/benilla-assets/src/tex_filter.rs index 8eed620b..1f55e8fa 100644 --- a/crates/benilla-assets/src/tex_filter.rs +++ b/crates/benilla-assets/src/tex_filter.rs @@ -1,90 +1,27 @@ -//! The frame's **texture filter policy** β€” one process-global pair, exactly as the reference holds -//! it, and the divergence that pair was hiding. +//! The process texture-filter policy: `trilinear` and `anisotropic` resolved into one filter mode, +//! as the reference holds it in two globals. //! -//! ## What the reference actually does +//! `TextureCreate` (`0x449d90`) hands `0x449ae0` an `applyGlobal` flag; when it is 1, the filter +//! bits 0-2 are overwritten from `[0x835250]` (static 3) and the aniso bits 9-13 from +//! `(mode == 5 ? [0x835254] : 1)`, globals set only from the CVar callbacks (`0x449ac0`, +//! `0x449ad0`). Terrain (`0x6c4c77`), WMO (the same wrapper `0x6c4c20`), M2 (`0x71da72`) and the +//! liquid sheets (`0x68abab`) pass 1. Six subsystems pass 0 and keep their own mode: weather +//! sprites (a hard-coded mode 4), the minimap, glue and loading screens, Lua texture widgets, the +//! sky and footprint decals (`0x699bd0`). //! -//! The real 1.12.1 client does not let a texture choose how it is filtered. Every -//! `TextureCreate` (`0x449d90`) runs the caller's request through **`0x449ae0`** with a force flag -//! that is a literal `1` at every observed call site, and `0x449ae0` **overwrites** it from two -//! globals: filter bits 0-2 from `[0x835250]`, aniso bits 9-13 from -//! `(mode == 5 ? [0x835254] : 1)`. Their static `.data` values are **`[0x835250] = 3`** and -//! **`[0x835254] = 1`**, and a full image-wide caller census of their only two writers -//! (`0x449ac0` / `0x449ad0`) finds exactly three call sites, all inside the CVar callbacks below. -//! WMO, M2/character and terrain textures all share the one policy; there is no per-draw filter -//! override anywhere. (`0x449ae0`/`0x59f170`/`0x58a980`/`0x6c4c20` + the registration block -//! `0x6885b0`-`0x688840`.) +//! Mode 3 is `GL_LINEAR_MIPMAP_NEAREST`, 4 `GL_LINEAR_MIPMAP_LINEAR`, 5 trilinear with anisotropy +//! N (applied at `0x59f170`). `trilinear` (`0x688608`, registered `"0"`) sets 4; `anisotropic` +//! (`0x6887d0`, registered `"1"`) above 1 sets 5 and outranks it. //! -//! Mode 3 β€” the static `.data` value β€” is `GL_LINEAR_MIPMAP_NEAREST` / `GL_LINEAR`: bilinear with -//! nearest-mip select, anisotropy off. Two CVars move it, and only upward: +//! A fresh install renders at mode 4: `hwDetect` (`0x641260`, `0x639a60`) sets sixteen video CVars +//! from `VideoHardware.dbc`, and any current GPU takes the fallback scan (`0x641610`, its tier from +//! `0x58bc90`), whose rows 169 and 170 set `trilinear` 1 at both CPU tiers. `anisotropic` is not +//! one of the sixteen (`0xc7f2e4` is absent from `[0x639a60, 0x639b80)`), so anisotropy stays off. //! -//! - **`trilinear`** (reg `0x688608`, registered default `"0"`) β€” nonzero sets mode 4, -//! `GL_LINEAR_MIPMAP_LINEAR`. -//! - **`anisotropic`** (reg `0x6887d0`, registered default **`"1"`**) β€” a value >1 sets mode 5 -//! (trilinear *plus* real aniso N, clamped to the device cap) and **outranks** `trilinear`. -//! -//! ## …and why the registrar's string is not what the client renders at -//! -//! **`hwDetect` overwrites it before the first frame.** `hwDetect` registers `"1"`, and -//! `DetectHardware 0x641260` β†’ `0x639a60` then `CVar::Set`s **sixteen** video CVars from the -//! matched `VideoHardware.dbc` row, self-clears to `"0"`, and persists β€” so the registrar's string -//! is only ever what an install renders at if the hardware table has nothing to say. -//! -//! It always has something to say. Every GPU this client runs on now is unlisted in a 2004 table, -//! so the row comes from the **fallback** scan (`0x641610`, from record index 1, vendor `0xFFFF` -//! against a D3D9 capability tier from `CGxDevice::DeviceAdapterInfer 0x58bc90`), whose reachable -//! set is exactly three rows: **168** (tier 0), **169** (tier 1), **170** (tier 2). Tier 2 needs -//! HW T&L, >2 simultaneous textures and PS β‰₯ 1.1 β€” which any D3D9 part saturates. -//! -//! **`trilinear` is 1 on rows 169 and 170, at both CPU tiers, and carries no CPU bias term.** So -//! the mode a real machine gets from a virgin install is **4**, not 3, and that is what benilla -//! registers (decision 1645, superseding 1642's `"0"`). Measured, not only derived: the reference -//! client's own `WoW/Logs/gx.log` on the director's machine reads `VID: 106b` (unlisted) β†’ -//! `DeviceAdapterInfer … DID: 2` β†’ `DetectHardware(): videoID: 170`. -//! -//! **`anisotropic` is NOT one of the sixteen** (verified by scanning `[0x639a60, 0x639b80)`: -//! sixteen record-pointer reads, `0xc7f2e4` absent). It keeps its registered `"1"` on every path, -//! so anisotropy really is off out of the box β€” 1642's load-bearing half stands. -//! -//! ## One correction to how wide the policy reaches -//! -//! 1642 said there is no per-texture filter override anywhere in the reference. That is **too -//! broad**. `applyGlobal` is a per-*call* argument to `0x449ae0`, and while terrain (`0x6c4c77`), -//! WMO (the same wrapper `0x6c4c20`) and M2 (`0x71da72`) all pass `1` β€” which is what this module -//! needs and is confirmed β€” six subsystems pass `0` and keep their own mode: weather sprites -//! (hard-coded mode 4), the minimap, glue/loading, Lua texture widgets and the sky. None of them -//! is a lane this module builds a sampler for, so the code is right; the claim was not. -//! -//! ## The divergence this module closes -//! -//! benilla hardcoded `mipmap_filter: Linear` + `anisotropy_clamp: 8` at every sampler it built β€” -//! mode 5 at aniso 8, on every terrain layer array, model albedo, WMO albedo and static-GX array -//! in the game, with no CVar and no way to turn it off. That is the *maximum* of what the -//! reference will do on request, shipped as the thing it does before anybody asks. -//! -//! It is [`decisions/1624`]'s mistake one sampler over, and it was made the same way. `farclip` -//! shipped at 777 because that was "what most players' `Config.wtf` had"; the filtering shipped at -//! trilinear+aniso because an apitrace of the repo's reference install showed trilinear+aniso16 β€” -//! and that install's `Config.wtf` carries `SET anisotropic "16"`. Both read a *set* value as a -//! *registered* one. Verifying that a mechanism exists is not verifying that it is the default. -//! -//! The cost is not incidental. Mode 3 takes one bilinear tap per sample. Mode 5 at aniso 8 takes up -//! to **sixteen** β€” eight along the anisotropy axis, doubled by the trilinear mip blend β€” and the -//! worst case for the anisotropy axis is a tiling surface at a grazing angle, which is what ground -//! is from a third-person camera. On the GPU-bound Steam Deck frame behind 1624 (B329) the texture -//! path is where the frame already lives. -//! -//! ## Why a process global here too -//! -//! Not imitation: the same constraint the reference had. A sampler is baked into the [`Image`] -//! asset at load time, and the lanes that build one are an async `AssetLoader` with no world -//! access ([`crate::blp`]) and ordinary systems ([`crate::world_assets`], `static_gx`) β€” neither -//! can read the other's resource. This is exactly the shape [`crate::gpu_blp`]'s `bc_supported` -//! already has, for exactly the same reason. -//! -//! **Latched, like `gxMultisample`** (decision 1629): read once at boot and never again, because a -//! sampler already baked into an uploaded texture cannot be changed without rebuilding it β€” which -//! is also why the reference's own UI says "enabled upon restart". The CVar holds the pending -//! value; the textures keep what they were born with. +//! A process global because a sampler is baked into the [`Image`] at load, by an async loader +//! ([`crate::blp`]) and by systems ([`crate::world_assets`]) that share no resource. Latched at +//! boot like `gxMultisample`: a baked sampler cannot change without a rebuild, which is why the +//! reference's UI says "enabled upon restart". use std::sync::OnceLock; @@ -92,32 +29,25 @@ use bevy::image::ImageFilterMode; use bevy::prelude::*; use bevy::render::render_resource::FilterMode; -/// The reference's `anisotropic` clamp β€” its callback parses 1-16 before clamping to the device -/// cap (`0x689110`). +/// The reference's `anisotropic` range: its callback parses 1-16, then clamps to the device cap +/// (`0x689110`). pub const ANISO_RANGE: std::ops::RangeInclusive = 1..=16; -/// The two globals, as one value: `trilinear` and `anisotropic` resolved into the policy every -/// sampler in the process takes. -/// -/// The precedence is the reference's own, not a choice: `anisotropic >= 2` writes mode 5 at -/// `0x68919f` and `trilinear` writes mode 4 at `0x688d0f` *only* when the aniso option bit is -/// clear (`0x688d0d js` skips it), so aniso outranks trilinear whichever order the callbacks run. +/// `trilinear` and `anisotropic` resolved into the policy every sampler takes. Aniso outranks +/// trilinear in either callback order: `anisotropic >= 2` writes mode 5 (`0x68919f`), and +/// `trilinear` writes mode 4 (`0x688d0f`) only while the aniso bit is clear (`0x688d0d`). #[derive(Resource, Clone, Copy, PartialEq, Eq, Debug)] pub struct TexFilterSetting { - /// `trilinear` β€” mode 4 when set and aniso is off. + /// `trilinear`: mode 4 when set and aniso is off. pub trilinear: bool, - /// `anisotropic` β€” 1 is off; >=2 is mode 5 at that level. + /// `anisotropic`: 1 is off; 2 and up is mode 5 at that level. pub aniso: u32, } impl Default for TexFilterSetting { - /// What a virgin install actually renders at β€” `trilinear` **on**, `anisotropic` off β€” with - /// `$WOW_TRILINEAR` / `$WOW_ANISO` overriding **session-only**, the same law `$WOW_MSAA` and - /// `$WOW_FARCLIP` run under: this is the A/B lever for pricing the filter policy on one machine - /// in one session, and a value pinned into `config.toml` would make a measurement sticky. - /// - /// `trilinear` is `true` here and not the registrar's `"0"` because `hwDetect` sets it on both - /// fallback rows a real GPU can reach β€” see the module doc, and decision 1645. + /// A fresh install: `trilinear` on, as `hwDetect` sets it, and `anisotropic` off. + /// `$WOW_TRILINEAR` and `$WOW_ANISO` override for the session only, like `$WOW_MSAA`, so an A/B + /// never sticks in `config.toml`. fn default() -> Self { let env_flag = |k: &str| { std::env::var(k) @@ -135,8 +65,7 @@ impl Default for TexFilterSetting { } impl TexFilterSetting { - /// The reference's filter mode 0-5 (`[0x835250]`) this policy resolves to. Only 3, 4 and 5 are - /// reachable from the two CVars; the lower three are set by nothing in the shipped image. + /// The reference's filter mode (`[0x835250]`); the two CVars reach only 3, 4 and 5. pub fn mode(self) -> u8 { if self.aniso >= 2 { 5 @@ -147,7 +76,7 @@ impl TexFilterSetting { } } - /// The mip filter this policy asks for: mode 3 selects the nearer mip, 4 and 5 blend the two. + /// Mode 3 selects the nearer mip; 4 and 5 blend the two. pub fn mipmap_filter(self) -> ImageFilterMode { match self.mode() { 3 => ImageFilterMode::Nearest, @@ -155,8 +84,7 @@ impl TexFilterSetting { } } - /// The same, for the lanes that build a raw wgpu [`SamplerDescriptor`]( - /// bevy::render::render_resource::SamplerDescriptor) instead of an [`Image`]'s. + /// The same, for a lane that builds a raw wgpu `SamplerDescriptor`. pub fn gpu_mipmap_filter(self) -> FilterMode { match self.mode() { 3 => FilterMode::Nearest, @@ -164,10 +92,8 @@ impl TexFilterSetting { } } - /// The max anisotropy β€” `(mode == 5 ? [0x835254] : 1)`, `0x449afc`. - /// - /// wgpu additionally requires all three filters to be `Linear` before it will accept a clamp - /// above 1, which mode 5 satisfies by construction and modes 3/4 never reach. + /// The max anisotropy, `(mode == 5 ? [0x835254] : 1)` (`0x449afc`). wgpu takes a clamp above 1 + /// only with every filter `Linear`, which mode 5 always has. pub fn anisotropy_clamp(self) -> u16 { if self.mode() == 5 { self.aniso.clamp(*ANISO_RANGE.start(), *ANISO_RANGE.end()) as u16 @@ -179,18 +105,14 @@ impl TexFilterSetting { static POLICY: OnceLock = OnceLock::new(); -/// Publish the resolved policy for the process. Called once, from the CVar load -/// (`benilla_app::cvars::load_config`, the `CvarLoad` set) β€” the earliest moment `config.toml` has -/// been folded in, and before any system that can request a texture has run. +/// Publish the resolved policy once, from the CVar load (`benilla_app::cvars::load_config`, the +/// `CvarLoad` set): after `config.toml` is folded in, before any system can request a texture. pub fn publish_tex_filter(setting: TexFilterSetting) { let _ = POLICY.set(setting); } -/// The policy every sampler in the process takes. -/// -/// Unpublished β€” the world viewer, a headless test, or a texture somehow requested ahead of -/// `CvarLoad` β€” falls back to [`TexFilterSetting::default`], which is what a virgin install -/// renders at. A race can therefore only ever produce the faithful value, never a wrong one. +/// The policy every sampler takes; unpublished (the world viewer, a headless test), it is +/// [`TexFilterSetting::default`], a fresh install's value. pub fn tex_filter() -> TexFilterSetting { *POLICY.get_or_init(TexFilterSetting::default) } @@ -199,14 +121,8 @@ pub fn tex_filter() -> TexFilterSetting { mod tests { use super::*; - /// **What benilla actually ships** β€” `hwDetect`'s row-170 outcome, not the registrar's string: - /// trilinear on, anisotropy off, mode 4 (decision 1645). If this ever reads mode 5, we are - /// shipping the divergence 1642 removed; if it reads mode 3, we are back to reading the - /// registrar's string as if `hwDetect` did not exist. - /// - /// `Default` is the assertion's subject deliberately: it is the value every unpublished lane - /// falls back to, so this pins the fallback and the shipped default in one place. It reads the - /// env, hence the guard β€” the levers are session-only and a set one must not fail the suite. + /// `hwDetect`'s row-170 outcome, mode 4. `Default` is also the unpublished fallback; it reads + /// the env, so a set lever skips the test. #[test] fn what_ships_is_trilinear_with_anisotropy_off() { if std::env::var_os("WOW_TRILINEAR").is_some() || std::env::var_os("WOW_ANISO").is_some() { @@ -226,10 +142,7 @@ mod tests { assert_eq!(shipped.anisotropy_clamp(), 1); } - /// The registrar's own string, which is a real thing that is simply not what a machine gets: - /// `[0x835250] = 3` with `trilinear "0"` is the state a client would render at if the hardware - /// table said nothing. Kept as a test because the mode-3 arm is still reachable β€” any run whose - /// `config.toml` or `$WOW_TRILINEAR` says 0 takes it. + /// The registered strings alone, mode 3, reached when `config.toml` or `$WOW_TRILINEAR` says 0. #[test] fn the_registrars_own_string_is_still_mode_three() { let registrar = TexFilterSetting { @@ -242,7 +155,6 @@ mod tests { assert_eq!(registrar.anisotropy_clamp(), 1); } - /// `trilinear 1` alone is mode 4: the mip blend arrives, the aniso does not. #[test] fn trilinear_alone_is_mode_four() { let s = TexFilterSetting { @@ -254,8 +166,6 @@ mod tests { assert_eq!(s.anisotropy_clamp(), 1); } - /// `anisotropic >= 2` is mode 5 and **outranks** `trilinear` in both directions β€” the mip - /// blend comes with it whether or not `trilinear` was ever set. #[test] fn aniso_outranks_trilinear_in_both_directions() { for trilinear in [false, true] { @@ -269,8 +179,6 @@ mod tests { } } - /// `anisotropic 1` is off, not "aniso at one sample" β€” the reference's own reading of the - /// registered string, and the reason the default is not mode 5. #[test] fn aniso_one_is_off() { let s = TexFilterSetting { @@ -280,8 +188,6 @@ mod tests { assert_eq!(s.mode(), 3); } - /// wgpu rejects a clamp above 1 unless every filter is `Linear`; modes 3 and 4 must therefore - /// never report one, whatever the CVar holds. #[test] fn a_clamp_above_one_only_ever_rides_all_linear_filters() { for s in [ diff --git a/crates/benilla-assets/src/trace.rs b/crates/benilla-assets/src/trace.rs index becac458..5df4eeba 100644 --- a/crates/benilla-assets/src/trace.rs +++ b/crates/benilla-assets/src/trace.rs @@ -1,30 +1,9 @@ -//! The shared debug-trace sink behind `WOW_MOVE_TRACE=`: one file, one clock, written by -//! the player mover's frame lines (`benilla::player`'s `move_trace`), its outbound wire lines -//! (`snd`), the anim driver's event lines (`benilla::creature_anim`), and the remote-replay lines -//! (`rly`/`run`, `benilla::net::motion`) β€” so the layers interleave on a common timeline and a feel -//! report ("it snaps when I land") can be read across all of them. -//! -//! **The two wire tags are `in` and `out`, and between them every packet is accounted for by -//! name.** `in` is the full inbound opcode stream (decision 0624); `out` is the full outbound one -//! (decision 1901), written by the write thread from the writer's own post-write log β€” so an `out` -//! line is a *transmission*, where the mover's `snd` line is a decision taken before the command is -//! even queued and `wire` carries only failures. Each file opens with a -//! `# t0=` header so **two clients' traces align with each other**, which is what a -//! sender-vs-observer question needs. Costs one `OnceLock` read per call when the env var is unset. -//! -//! **It lives down here, below both sides of decision 1160's line, because both sides write to it** -//! β€” the engine's billboard/zfill/particle traces and the game's mover and wire traces share one -//! file and one clock, which is the whole point of it. An instrument at the *top* of the stack -//! could not be called by the engine at all; a leaf with no dependencies can be called by -//! everything. It carries no domain knowledge: a tag, a message, a mutex and a file. -//! -//! **`WOW_MOVE_TRACE_TAGS="move,snd,in"` narrows it to those tags** (unset = everything). This is -//! not tidiness: every line is an unbuffered `write` under one global mutex on the main thread, so a -//! busy tag distorts the run it is meant to measure. A 600-yd fall probe (decision 0880) wrote -//! ~4,200 lines/s, nearly all of them `card`, and the `move` line's own `t=` then carried whatever -//! the queue ahead of it cost β€” frames read back as 0.011 s with 2.86 yd of travel, or 0.040 s with -//! 0.96 yd, against a physics integration that was exact. A trace whose clock cannot be trusted for -//! per-frame timing is half an instrument; filtering to the tags the question needs gives it back. +//! The shared debug-trace sink behind `WOW_MOVE_TRACE=`: one file and one clock for every +//! layer's tagged lines, engine and game alike, so it sits below both. `in` and `out` are the full +//! inbound and outbound opcode streams; `out` is written after the send, where the mover's `snd` +//! is a decision before queueing. Each file opens with a `# t0=` header so two +//! clients' traces align. `WOW_MOVE_TRACE_TAGS="move,snd,in"` keeps only those tags: every line is +//! an unbuffered write under one mutex on the main thread, so a busy tag skews every `t=`. use std::fs::File; use std::io::Write; @@ -44,10 +23,7 @@ fn sink() -> Option<&'static Mutex> { let mut out = File::create(&path) .map_err(|e| eprintln!("dbg-trace: cannot create {path}: {e}")) .ok()?; - // The wall-clock epoch of this file's `t=0`, so **two traces can be read against each other** - // β€” a sender's `snd` lines beside an observer's `rly`/`run` lines from a different process - // (decision 0619). `t=` alone is per-process seconds and says nothing across clients; with - // this header, wall time is `t0 + t`. + // Wall time is `t0 + t`, which aligns the traces of two processes. let t0_wall = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0.0, |d| d.as_secs_f64()); @@ -60,11 +36,10 @@ fn sink() -> Option<&'static Mutex> { .as_ref() } -/// The optional tag allow-list from `WOW_MOVE_TRACE_TAGS`; `None` (unset or empty) = keep everything. +/// The tag allow-list from `WOW_MOVE_TRACE_TAGS`; `None`, unset or empty, keeps everything. static TAGS: OnceLock>> = OnceLock::new(); -/// Whether `tag` survives the allow-list. Checked **before** the mutex, so a filtered-out tag costs -/// a slice compare rather than a lock and a syscall β€” which is the whole point (see the module note). +/// Whether `tag` survives the allow-list, checked before the mutex so a filtered tag takes no lock. fn tag_allowed(tag: &str) -> bool { TAGS.get_or_init(|| { let raw = std::env::var("WOW_MOVE_TRACE_TAGS").ok()?; @@ -79,13 +54,12 @@ fn tag_allowed(tag: &str) -> bool { .is_none_or(|allow| allow.iter().any(|t| t == tag)) } -/// Whether the trace is enabled β€” lets callers skip building their line when it's off. +/// Whether the trace is on, so a caller can skip building its line. pub fn enabled() -> bool { sink().is_some() } -/// Whether this *tag* is being written β€” [`enabled`] plus the allow-list, for a caller whose line is -/// expensive to build (a per-frame `format!` over a hot loop) and which is filtered out anyway. +/// [`enabled`] plus the allow-list, for a caller whose line is expensive to build. pub fn enabled_for(tag: &str) -> bool { enabled() && tag_allowed(tag) } diff --git a/crates/benilla-assets/src/wdt.rs b/crates/benilla-assets/src/wdt.rs index d54eddb2..519f0519 100644 --- a/crates/benilla-assets/src/wdt.rs +++ b/crates/benilla-assets/src/wdt.rs @@ -1,12 +1,6 @@ -//! WDT β†’ map tile-index asset loader. -//! -//! Decodes a map's tiny `.wdt` into a [`WdtIndex`] β€” the 64Γ—64 `MAIN` tile-existence grid the -//! terrain streamer consults before requesting any `.adt` (decision 0476): open ocean authors no -//! tiles, and probing them wholesale spammed asset-server NotFound errors on every boat crossing. -//! It also carries the map's **global WMO** where there is one (decision 0688): on the 20 shipped -//! maps that author no terrain at all, that single placement is the entire world. -//! The parse itself is `benilla-wdt`'s [`WdtReader`] (oracle-tested in the 0021 migration); this -//! module only wraps it in the Bevy asset machinery. +//! WDT loader: a map's 64Γ—64 `MAIN` tile-existence grid, which the terrain streamer checks before +//! requesting an `.adt` (open ocean authors none), and its global WMO, the whole world on a map +//! with no terrain. use benilla_formats::{GlobalWmo, WdtFile, WdtReader, WowVersion}; use bevy::asset::io::Reader; @@ -18,23 +12,21 @@ use bevy::reflect::TypePath; pub struct WdtIndex(WdtFile); impl WdtIndex { - /// Whether tile `(tile_x, tile_y)` has an `.adt` (`MAIN` flag bit 0) β€” the same index order as - /// the `Map__.adt` file names and [`benilla_formats::world_to_tile`]. - /// Out-of-range β‡’ `false`. + /// Whether tile `(tile_x, tile_y)` has an `.adt` (`MAIN` flag bit 0), in the order of the + /// `Map__.adt` names. pub fn has_tile(&self, tile_x: u32, tile_y: u32) -> bool { self.0 .get_tile(tile_x as usize, tile_y as usize) .is_some_and(|t| t.has_adt) } - /// The map's single global building, on a map with no terrain (`MPHD` bit 0) β€” see - /// [`GlobalWmo`]. `None` on an ADT map. + /// The single global building of a map with no terrain (`MPHD` bit 0); `None` on an ADT map. pub fn global_wmo(&self) -> Option<&GlobalWmo> { self.0.global_wmo() } } -/// Bevy [`AssetLoader`] decoding a vanilla `*.wdt` β†’ [`WdtIndex`]. +/// Loads a `*.wdt` into a [`WdtIndex`]. #[derive(Default, TypePath)] pub struct WdtIndexLoader; @@ -64,9 +56,7 @@ impl AssetLoader for WdtIndexLoader { mod tests { use super::*; - /// A minimal synthetic WDT: MVER + a MAIN grid with exactly two tiles flagged present. - /// Pins the parse AND the index order ([`WdtIndex::has_tile`] is `(tile_x, tile_y)`, the - /// on-disk grid is row-major `y * 64 + x`). + /// The on-disk grid is row-major, `y * 64 + x`. #[test] fn parses_main_grid_in_tile_index_order() { let mut buf = Vec::new(); diff --git a/crates/benilla-assets/src/wmo.rs b/crates/benilla-assets/src/wmo.rs index 2b9a5dcb..31b11fc5 100644 --- a/crates/benilla-assets/src/wmo.rs +++ b/crates/benilla-assets/src/wmo.rs @@ -1,14 +1,7 @@ -//! WMO β†’ model asset loader. -//! -//! A WMO (building) is a **root** file plus N **group** files (`_NNN.wmo`). The loader reads the -//! root from the [`Reader`], then reads each group via [`LoadContext::read_asset_bytes`] (raw bytes, -//! recorded as a dependency β€” *not* `load()`, which would recurse back into this loader), building one -//! [`ModelSubmesh`] per group render batch with its texture resolved against the root's material -//! tables. -//! -//! Same app-independent shape as the M2 loader (geometry + texture handles + metadata; meshes are -//! built paced app-side β€” decision 0834 β€” and materials at spawn). WMOs don't size-fade, so there -//! are no bounds. +//! WMO asset loader: a root file plus its group files (`_NNN.wmo`), one [`ModelSubmesh`] per +//! group render batch. Groups are read with [`LoadContext::read_asset_bytes`], not `load()`, which +//! would recurse into this loader. The app builds the meshes, paced, as a city's thousands of +//! batches would land in one frame. use crate::column_grid::ColumnGrid; use std::sync::Arc; @@ -27,11 +20,8 @@ use bevy::reflect::TypePath; use crate::model::ModelSubmesh; -/// Per-WMO-group navigation data for the portal visibility cull (`crate::wmo_portal`): the group's -/// MOGP flags (the EXTERIOR `0x8` bit the flood defers on), its bounding box (WMO model space, WoW -/// axes β€” the camera current-group containment test), and its slice of [`WmoModel::portal_refs`]. -/// Indexed by **absolute group index** (parallel gaps for unreadable groups carry a degenerate box + -/// zero refs, so they're never a current-group candidate and never flood). +/// One WMO group's portal-cull data: MOGP flags (the flood defers on EXTERIOR `0x8`), the MOGI box +/// and its slice of [`WmoModel::portal_refs`]; an unreadable group never floods. #[derive(Clone, Copy)] pub struct WmoGroupNav { pub flags: u32, @@ -39,196 +29,111 @@ pub struct WmoGroupNav { pub bbox_max: [f32; 3], pub ref_start: u16, pub ref_count: u16, - /// The group's `WMOAreaTable.WMOGroupID` key (MOGP `uniqueID`; 0 = none carried). + /// The `WMOAreaTable.WMOGroupID` key, MOGP `uniqueID`; 0 when none. pub area_table_id: u32, - /// The group's MOGP fog indices (disk `+0x30`, empirically pinned against the Goldshire inn) β€” - /// up to four indices into [`WmoModel::fogs`], walked by the camera-in-interior fog selector - /// (`0x69de20`). + /// MOGP fog indices (disk `+0x30`) into [`WmoModel::fogs`], for the fog selector (`0x69de20`). pub fog_indices: [u8; 4], - /// MOGP `groupLiquid` (`0xf` = none) β€” the **whole-group submersion override**. On the 13 - /// shipped groups that set it, the client's liquid probe answers "submerged" for the entire - /// group at every Z with no grid involved at all; see - /// [`benilla_formats::WmoGroupHeader::group_liquid`]. + /// MOGP `groupLiquid` (`0xf` = none): when set, the whole group reads submerged at every Z. pub group_liquid: u32, } -/// A loaded WMO building: the render batches across all its groups, flattened, plus the flattened -/// collidable triangles (the app bakes a collider from these per placement). -/// `Default` = the empty building (every field vacuous) β€” a test scaffold, never a loader product. +/// A loaded WMO building; `Default` is an empty test scaffold, never a loader product. #[derive(Asset, TypePath, Clone, Default)] pub struct WmoModel { - /// The building's `WMOAreaTable.WMOID` key (root `MOHD.wmoID`; NSabbey β†’ 59). + /// The `WMOAreaTable.WMOID` key (root `MOHD.wmoID`). pub wmo_id: u32, pub submeshes: Vec, - /// Group index per entry in [`Self::submeshes`] (parallel) β€” which WMO group each render batch - /// belongs to, so the portal cull can hide a whole group by toggling its submeshes' `Visibility`. + /// The group of each entry in [`Self::submeshes`], so the portal cull can hide a whole group. pub submesh_group: Vec, - /// The portal graph (MOPV vertices / MOPT infos / MOPR refs), WMO model space (WoW axes). The - /// per-frame cull floods this from the camera's current group (`crate::wmo_portal`). Empty β‡’ no - /// portals (single-group props) β‡’ every group stays visible. + /// The portal graph (MOPV, MOPT, MOPR) in model space; with none, every group stays visible. pub portal_vertices: Vec<[f32; 3]>, pub portal_infos: Vec, pub portal_refs: Vec, - /// Per-group nav (flags + bbox + portal-ref slice), indexed by absolute group index. + /// Per-group cull data, indexed by absolute group index. pub group_nav: Vec, - /// The root's MFOG fog records β€” record 0 is the WMO default (the interior-fog selector's - /// seed); a group's [`WmoGroupNav::fog_indices`] point here. Consumed by the camera-in-interior - /// fog resolve (`crate::wmo_portal` in the app), per the reference (`0x69de20`). + /// The root's MFOG records; record 0 is the building default. pub fogs: Vec, - /// The root's **MOSB** skybox model (`.m2`), drawn as the sky backdrop while the camera stands in - /// a group whose flags carry `0x40000` β€” see `crate::skybox` in the app for the gate and - /// `benilla_formats::WmoGroupInfo::show_skybox` for how that bit was identified. `None` for all - /// but a handful of roots (`benilla-extract skyboxscan`); carrying it here costs one `Option` - /// per building and saves the sky lane a second root parse. + /// The root's MOSB skybox model, drawn while the camera is in a group flagged `0x40000`. pub skybox: Option, - /// Per-group **walking-collision** triangles (WoW model space, indexed by absolute group index) β€” - /// the Leg-A face set for the current-group **down-ray** (`crate::wmo_portal`). The faithful "which - /// room is the camera in" test casts a ray straight down from the eye against the same face set the - /// player's walking collision uses β€” every non-DETAIL face, **no orientation filter** (the - /// client's Leg A, `0x6be250`, is the walking-collision BSP, mask `0x84`; an earlier - /// render-face `|n.z|` floor heuristic mis-seeded doorways and slab edges). Same triangles as - /// [`Self::collision`], kept per group for group attribution. + /// Per-group walking-collision faces (non-DETAIL, no orientation filter): the reference's + /// walking BSP set (`0x6be250`, mask `0x84`) that the current-group down-ray casts on. pub group_collision_tris: Vec>, - /// Per-group **camera-only** triangles (parallel to [`Self::group_collision_tris`]): the faces - /// the camera gather keeps but the walking gather drops β€” DETAIL (`0x04`) set, NOCAMCOLLIDE - /// (`0x02`) clear. The down-ray's **camera-void fallback** (decision 0692) races this set only - /// after the faithful walking Leg A and portal Leg B both miss AND no terrain surface sits at or - /// below the eye: an eye held between camera-collidable surfaces (the Deadmines entrance pocket, - /// whose floor is all-DETAIL) then still names its room instead of blanking the building. + /// Per-group camera-only faces (DETAIL `0x04` set, NOCAMCOLLIDE `0x02` clear). Deviation: the + /// down-ray tries them when the walking and portal legs miss and no terrain lies below the eye, + /// because the reference reads outside there and blanks the building around a camera sealed in + /// an all-DETAIL pocket. pub group_camera_only_tris: Vec>, - /// Per-group AABB of [`Self::group_collision_tris`] (parallel; `None` = no collision faces), - /// computed at load **from the triangles themselves** β€” the broad phase for the faces-only - /// down-ray (`area_down_ray`). Bounds derived from the faces can only summarize the geometry, - /// never understate it the way an authored MOGI box can (NSabbey group 3's authored bottom - /// floats 1.5 yd above its own floor polys β€” the 2026-07-12 indoor flap), so culling on them - /// is exact: a group is skipped only when no face of it could own the probe column. + /// Per-group AABB of [`Self::group_collision_tris`], from the faces rather than MOGI: an + /// authored MOGI box can sit above its own floor (Northshire Abbey group 3, by 1.5 yd). pub group_collision_bounds: Vec>, - /// Per-group column index over [`Self::group_collision_tris`] (parallel; `None` = a face set - /// too small to be worth indexing, and the caller keeps its linear scan). The narrow phase the - /// per-group bounds above never had: a dungeon group holds ~11–16k faces, so bounds alone - /// still left every down-ray scanning tens of thousands of triangles per unit per frame - /// (LBRS, 2026-07-27). See [`crate::column_grid`]. + /// Per-group [`ColumnGrid`] over [`Self::group_collision_tris`]; `None` when too few to index. pub group_collision_grids: Vec>, - /// The union of [`Self::group_collision_bounds`] β€” the whole-building broad phase: a probe - /// column outside it cannot hit any collision face, so the per-frame indoor trackers skip the - /// placed instance outright (a camera in open country pays one AABB test per building, not a - /// face scan β€” the 2026-07-12 fps regression). + /// The union of [`Self::group_collision_bounds`]: the whole-building broad phase. pub collision_bounds: Option<([f32; 3], [f32; 3])>, - /// **Walking** collidable triangles across all groups in raw WMO-local coords β€” the player-body - /// gather (excludes DETAIL faces). `None` when the building carries no collidable geometry. + /// Walking collision across all groups in WMO-local coords, DETAIL faces excluded. pub collision: Option, - /// **Camera/LOS** collidable triangles β€” the third-person-camera gather (excludes NOCAMCOLLIDE, - /// *keeps* DETAIL). Distinct from [`Self::collision`]; the app bakes a separate collider on the - /// camera collision layer so the camera can't slip behind visible decals/overhangs (e.g. forge - /// pipes) the player walks under. `None` when the building has no camera-collidable geometry. + /// Camera and line-of-sight collision: DETAIL kept, NOCAMCOLLIDE dropped. pub collision_camera: Option, - /// The root's placed doodads (MODD props β€” candle stands, banners), in WMO model space; the app - /// spawns each as an M2 composed with the WMO instance transform, selected by doodad set. + /// The root's MODD doodads in WMO model space, spawned by doodad set. pub doodads: Vec, - /// The doodad sets (MODS) β€” ranges into [`Self::doodads`]; set 0 is global, a placement picks one more. + /// The MODS ranges into [`Self::doodads`]: set 0 always, plus the one a placement picks. pub doodad_sets: Vec, - /// The root's MOLT lights (the interior fixture lights β€” fireplaces, forge, candles, chandeliers), - /// in WMO model space. Spawned as dynamic point lights (decisions 0016/0273) that warm nearby - /// doodads/NPCs, terrain, and the building's own walls/floor over their baked MOCV. + /// The root's MOLT fixture lights in WMO model space, spawned as point lights. pub lights: Vec, - /// Per-group interior flag + bounding box (MOGI), WMO model space β€” classifies which placed doodads - /// are interior (lit off the room/fixtures) vs exterior (lit off the sky). See [`WmoGroupInfo`]. + /// Per-group MOGI interior flag and box, in WMO model space. pub group_bounds: Vec, - /// Per-MODD lighting base, parallel to [`Self::doodads`] β€” resolved ONCE at load (placements are - /// fixed in the root). See [`DoodadBase`]: an interior-group doodad's base is its own MODD - /// entry's baked colour, and its point light comes only from its owning group's MOLR list. The - /// footprint down-ray this replaced (decision 0290) is byte-real code, but it is the ADT-MDDF - /// attach path β€” a WMO MODD doodad never reaches it (create `0x694e90` never calls SetMatrix - /// `0x698d20`). + /// Per-MODD lighting base, parallel to [`Self::doodads`]. A MODD doodad never takes the + /// footprint down-ray: its create (`0x694e90`) never calls `SetMatrix` (`0x698d20`). pub doodad_base: Vec, - /// Per-MODD **instantiating group** (the first group whose MODR refs name it), parallel to - /// [`Self::doodads`]; `None` for a MODD no group references. The reference creates a doodad - /// once, on the first visible-group walk that reaches it, and that create is what fills its - /// baked colour words β€” so this names the MOLR set the interior fold gates on - /// ([`Self::doodad_base`]). It is **not** the interior/exterior class: every referrer writes the - /// lane of the same def and exterior is absorbing, so the class reads [`Self::doodad_groups`] - /// (see [`resolve_doodad_bases`]). + /// Per-MODD first referencing group, whose MOLR set the interior lane folds; the + /// interior/exterior class reads [`Self::doodad_groups`]. pub doodad_owner: Vec>, - /// Per-MODD **referencing groups** β€” every group whose MODR names it, parallel to - /// [`Self::doodads`]; empty for a MODD no group references. - /// - /// This is the prop's portal-cull key. The reference commits a WMO's doodads **per visible - /// group** β€” `0x695aa0` loops the group's own MODR refs at `group+0xe8`/count `+0x144`, called - /// from the visible-group walk `0x698720` β€” so a group the portal flood culled draws none - /// of its furniture, and a prop **any** of whose referrers is visible is drawn. Without the key - /// at all, props outlive their own building: cull every group of a dungeon and its lanterns, - /// crates and cobwebs hang in the void (decision 0689). With the key collapsed to one owner, a - /// prop that hangs through several rooms β€” a lava fall β€” blinks out on every camera angle whose - /// flood reaches a different one of its referrers. + /// Per-MODD every group whose MODR names it: the portal-cull key, as the reference draws + /// doodads per visible group (`0x695aa0`, MODR at `+0xe8`, count `+0x144`, walk `0x698720`). pub doodad_groups: Vec>, - /// Per-group FOOTPRINT face set (render tris + baked MOCV + MOPY flags), indexed by absolute - /// group index; `None` for exterior groups / groups without MOCV. The **GameObject M2** light - /// lane samples it: the entity node's env-update attach down-rays the render mesh under the - /// object and bakes the hit's barycentric MOCV, floor-168/cap-96, on the fixed interior axis - /// (`0x69e4c0`; a reference capture of the abbey benches decides it). + /// Per-group footprint faces (render faces, MOCV, MOPY flags); `None` for exterior groups and + /// groups without MOCV. A GameObject M2 down-rays them for its MOCV lighting (`0x69e4c0`). pub group_footprints: Vec>, - /// Root MOMT `ground_type` per material β€” the `TerrainType.dbc` id a face's - /// `FootprintTris::mopy_material` resolves to. Shared by every group (MOMT lives on the root), - /// and the tail of the footstep chain's WMO leg (decision 1161). + /// Root MOMT `ground_type` per material: the `TerrainType.dbc` id of a face's MOPY material. pub material_ground_type: Vec, - /// Root MOMT `diffColor` per material, RGB 0..1 β€” the body colour an **interior** MLIQ pool - /// takes, indexed by its `LiquidMesh::material_id`. MOMT lives in the root, so a group file - /// cannot resolve its own pool's colour; the spawner does it from here. + /// Root MOMT `diffColor` per material, RGB 0..1: an interior MLIQ pool's colour, by material. pub material_diff_color: Vec<[f32; 3]>, - /// Per-group AABB of the faces [`Self::group_footprints`] actually references (parallel; - /// `None` = no footprint faces), computed at load from the triangles themselves β€” the broad - /// phase for the footprint down-ray, the same exact-cull argument as - /// [`Self::group_collision_bounds`]: a probe column outside a group's face bounds in XY, or - /// wholly below its lowest vertex, can hit none of its faces. Without it every re-rayed - /// entity scanned EVERY interior group of the whole model β€” at Stormwind (one WMO, all - /// districts) that was the live-session frame (decision 0364). + /// Per-group AABB of the faces [`Self::group_footprints`] uses: the footprint broad phase. pub group_footprint_bounds: Vec>, - /// Per-group column index over [`Self::group_footprints`] (parallel; `None` = not indexed). - /// The footprint sample is the heavier of the two column rays β€” it walks render faces, not - /// collision ones. See [`crate::column_grid`]. + /// Per-group [`ColumnGrid`] over [`Self::group_footprints`]; `None` when not indexed. pub group_footprint_grids: Vec>, - /// Per-group MOLR light refs (indices into [`Self::lights`]), indexed by absolute group index β€” - /// the point lights that fold into an interior GameObject's committed light are gated by the - /// footprint-hit group's list, same as a MODD prop's by its owning group. + /// Per-group MOLR refs into [`Self::lights`]: a MODD prop folds its owning group's list, a + /// GameObject its footprint-hit group's. pub group_light_refs: Vec>, - /// Per-group MLIQ liquid surface (the animated water/lava/slime a group embeds β€” Stormwind's - /// canals + fountains, the Ironforge lava, dungeon pools), indexed by absolute group index; - /// `None` for a group with no `MLIQ`. Built in WMO model space (WoW axes); the app spawns each at - /// the placement transform, on the shared per-kind liquid material. + /// Per-group MLIQ liquid surface in WMO model space; `None` for a group without one. pub group_liquids: Vec>, } -/// The load-resolved lighting base of one placed MODD doodad. The real client fills an -/// interior-group doodad's slot-0 words at CREATE, from the MODD record's own colour field -/// (`0x694e90` β†’ `6950de call 0x6a77e0(&MODD.colour, &diffuse, 0x70, &ambient, 0x60)`), and read -/// live off the reference's abbey draws, where both decoded stands' diffuse words equal their MODD -/// colour bytes verbatim. +/// One MODD doodad's lighting base. The reference fills an interior doodad's words at create from +/// the MODD colour: `0x694e90` calls `0x6a77e0`, diffuse floor `0x70`, ambient cap `0x60`. #[derive(Clone, PartialEq, Debug)] pub enum DoodadBase { - /// Exterior-group doodad β€” the sky-lit lane (day/night sun; a WMO prop takes the plain matte). + /// The sky-lit lane of an exterior-group doodad. Exterior, - /// Interior-group doodad: the MODD-colour base + the owning group's light refs. + /// An interior-group doodad: the MODD-colour base and the owning group's light refs. Interior(InteriorPropBase), } -/// An interior MODD doodad's resolved base light β€” computed once at load, day/night-independent. +/// An interior MODD doodad's base light, independent of the time of day. #[derive(Clone, PartialEq, Debug)] pub struct InteriorPropBase { - /// The ambient word: `cap96(MODD.colour)` (0–1 RGB). See [`cap96`]. + /// The ambient word, [`cap96`] of the MODD colour, RGB 0..1. pub ambient: [f32; 3], - /// The diffuse word: `floor112(MODD.colour)` (0–1 RGB), committed as a directional lobe on the - /// fixed engine axis (βˆ’0.30822, βˆ’0.30822, βˆ’0.9) β€” never the day/night sun. See [`floor112`]. + /// The diffuse word, [`floor112`] of the MODD colour, lit along the fixed axis + /// (βˆ’0.30822, βˆ’0.30822, βˆ’0.9), never the sun. pub diffuse: [f32; 3], - /// The owning group's MOLR light refs (indices into [`WmoModel::lights`]): the ONLY point lights - /// this doodad's committed light folds (range-gated by each light's disk attenStart/attenEnd at - /// spawn). Empty β€” a group with no MOLR β€” means NO point light at all, its own flame included. + /// The owning group's MOLR refs into [`WmoModel::lights`], the only point lights it folds + /// (range-gated by attenStart and attenEnd); empty means none, its own flame included. pub light_refs: Vec, } -/// Per-group AABBs of the footprint faces each group actually references β€” -/// [`WmoModel::group_footprint_bounds`], derived at load from the indexed vertices (not the whole -/// position array, which may carry vertices no footprint face uses). Shared with the fixture -/// builders so tests and the loader can never disagree on what the bounds mean. +/// Per-group AABBs of the vertices the footprint faces index, not of every position; shared with +/// the test fixtures. pub fn footprint_tri_bounds( footprints: &[Option], ) -> Vec> { @@ -252,9 +157,7 @@ pub fn footprint_tri_bounds( .collect() } -/// Per-group column indexes over the footprint faces β€” [`WmoModel::group_footprint_grids`]. -/// Shared with the fixture builders for the same reason the bounds are: a test model and a loaded -/// one must be indexed by identical code, or an exactness test proves nothing about the real path. +/// Per-group column indexes over the footprint faces, shared with the test fixtures. pub fn footprint_tri_grids(footprints: &[Option]) -> Vec> { footprints .iter() @@ -278,7 +181,7 @@ pub fn footprint_tri_grids(footprints: &[Option]) -> Vec]) -> Vec> { tris.iter() .map(|group| { @@ -299,9 +202,7 @@ pub fn collision_tri_grids(tris: &[Vec<[[f32; 3]; 3]>]) -> Vec], @@ -341,9 +242,8 @@ pub fn collision_tri_bounds( (per_group, union) } -/// The ambient-word CAP of `0x6a77e0` (exact fixed-point arithmetic, bit-exact against both decoded -/// abbey stands): a colour whose max channel exceeds 96 is scaled down so max = 96 β€” per channel -/// `(cΒ·scale + 255) >> 8` with `scale = round(96Β·255/max βˆ’ 0.5)`; max ≀ 96 passes through raw. +/// The ambient cap of `0x6a77e0`, in its fixed point: a colour whose max channel exceeds 96 scales +/// per channel to `(cΒ·scale + 255) >> 8`, `scale = round(96Β·255/max βˆ’ 0.5)`; otherwise it is raw. pub fn cap96(c: [u8; 3]) -> [f32; 3] { let max = c[0].max(c[1]).max(c[2]); if max <= 96 { @@ -353,41 +253,30 @@ pub fn cap96(c: [u8; 3]) -> [f32; 3] { c.map(|v| ((u32::from(v) * scale + 255) >> 8) as f32 / 255.0) } -/// The diffuse-word FLOOR of `0x6a77e0`: a colour whose max channel is below `thresh` is raised β€” -/// HSV round-trip re-emitting at value `thresh`, which for an RGB triple is a hue/saturation- -/// preserving scale by `thresh/max`, **truncated** per channel. The abbey INNBENCH decode (a -/// reference capture) exercises the raise leg live: bench MOCVs (59,65,92)/(69,63,83) raise to -/// diffuse (107,118,168)/(139,127,168) β€” 63Β·(168/83) = 127.52 lands on 127, which truncation gives -/// and nearest-rounding does not (machine-zero fits). +/// The diffuse floor of `0x6a77e0`: a colour whose max channel is below `thresh` is raised by the +/// reference's HSV round trip at value `thresh`, a scale by `thresh/max` truncated per channel. fn floor_raise(c: [u8; 3], thresh: u8) -> [f32; 3] { let max = c[0].max(c[1]).max(c[2]); if max >= thresh || max == 0 { return c.map(|v| f32::from(v) / 255.0); } - // Integer scale β€” truncating by construction AND exact on the max channel (a float - // `v Β· (thresh/max)` can land a hair under `thresh` and truncate to `thresh βˆ’ 1`). + // Integer math: a float `v Β· (thresh/max)` can land under `thresh`, truncating to `thresh βˆ’ 1`. c.map(|v| ((u32::from(v) * u32::from(thresh)) / u32::from(max)) as f32 / 255.0) } -/// [`floor_raise`] at the MODD create site's diffuse threshold `0x70` (112). Max β‰₯ 112 passes -/// through RAW β€” both decoded abbey stands do (maxes 134/141 β†’ the diffuse word IS the MODD colour -/// verbatim). +/// [`floor_raise`] at the MODD create site's diffuse threshold `0x70` (112). pub fn floor112(c: [u8; 3]) -> [f32; 3] { floor_raise(c, 112) } -/// [`floor_raise`] at the entity/footprint attach site's diffuse threshold `0xA8` (168) β€” the -/// GameObject M2 lane (`0x69e4c0`, the entity twin of the ADT-MDDF `0x6a8410`). Both decoded abbey -/// benches take the raise leg (MOCV maxes 92/83 β†’ diffuse max exactly 168 on the wire). +/// [`floor_raise`] at the GameObject footprint attach site's diffuse threshold `0xA8` (168), +/// `0x69e4c0`, the entity twin of the ADT-MDDF attach at `0x6a8410`. pub fn floor168(c: [u8; 3]) -> [f32; 3] { floor_raise(c, 168) } -/// Invert MODR: MODD index β†’ its **instantiating** group. First referencing group wins β€” the -/// reference creates a doodad once, on the first visible-group walk that names it, and that create -/// is what fills its baked colour words. `None` = referenced by no group at all (the reference never -/// instantiates such a MODD). This picks the interior lane's **MOLR light set**; the interior/exterior -/// *class* is the whole referrer set's ([`resolve_doodad_bases`]), and the *cull* key is [`modr_refs`]. +/// MODD index to its first referencing group, whose MOLR set the interior lane uses. The reference +/// creates a doodad on the first visible-group walk that names it, and never one no group names. fn modr_owners(doodad_count: usize, group_doodad_refs: &[Vec]) -> Vec> { let mut owner: Vec> = vec![None; doodad_count]; for (gi, refs) in group_doodad_refs.iter().enumerate() { @@ -400,13 +289,8 @@ fn modr_owners(doodad_count: usize, group_doodad_refs: &[Vec]) -> Vec]) -> Vec> { let mut refs: Vec> = vec![Vec::new(); doodad_count]; for (gi, group) in group_doodad_refs.iter().enumerate() { @@ -422,27 +306,10 @@ fn modr_refs(doodad_count: usize, group_doodad_refs: &[Vec]) -> Vec = Vec::new(); - // Per-group nav, pre-sized to the full group count and indexed by absolute group index so the - // MOPR `group` references line up even when a group file is skipped below. Bounds come from the - // root MOGI (available for every group); flags + portal-ref span are filled from each group - // header as it's read. + // Pre-sized to the group count so MOPR group indices line up when a group file is skipped; + // boxes from the root MOGI, the rest from each MOGP header. let mut group_nav: Vec = (0..root.group_count() as usize) .map(|gi| { let (bbox_min, bbox_max) = root @@ -538,24 +400,14 @@ impl AssetLoader for WmoModelLoader { } }) .collect(); - // Per-group walking-collision triangles for the current-group down-ray, indexed by absolute - // group index (the flat collider below is fed from the same gather). let mut group_collision_tris: Vec> = vec![Vec::new(); root.group_count() as usize]; - // Per-group camera-only triangles (DETAIL set, NOCAMCOLLIDE clear) β€” the down-ray's - // camera-void fallback set (decision 0692), disjoint from the walking gather above. let mut group_camera_only_tris: Vec> = vec![Vec::new(); root.group_count() as usize]; - // Collidable triangles flattened across every group (raw WMO-local coords), accumulated as we - // read each group's bytes β€” no second pass / second read. let mut col_pos: Vec<[f32; 3]> = Vec::new(); let mut col_idx: Vec = Vec::new(); - // Camera/LOS gather (keeps DETAIL, drops NOCAMCOLLIDE) β€” a separate mesh from the walking one. let mut cam_pos: Vec<[f32; 3]> = Vec::new(); let mut cam_idx: Vec = Vec::new(); - // Per-group MODR (doodad ownership, transient β€” consumed into the doodad bases) + MOLR - // (light refs β€” kept on the asset: the GameObject footprint lane gates its point lobes by - // the hit group's list) + footprint face sets (interior groups only). let mut group_doodad_refs: Vec> = vec![Vec::new(); root.group_count() as usize]; let mut group_light_refs: Vec> = vec![Vec::new(); root.group_count() as usize]; let mut group_liquids: Vec> = @@ -565,10 +417,8 @@ impl AssetLoader for WmoModelLoader { for gi in 0..root.group_count() { let group_url = format!("mpq://{stem}_{gi:03}.wmo"); let Ok(gbytes) = ctx.read_asset_bytes(group_url).await else { - continue; // a missing/unreadable group is skipped, like the chain reader + continue; // a missing or unreadable group is skipped }; - // Fill this group's flags + portal-ref span from its MOGP header (what the client's - // visibility pass reads). Bounds were seeded from MOGI above. if let (Some(h), Some(nav)) = (wmo_group_header(&gbytes), group_nav.get_mut(gi as usize)) { @@ -579,8 +429,7 @@ impl AssetLoader for WmoModelLoader { nav.fog_indices = h.fog_indices; nav.group_liquid = h.group_liquid; } - // Walking gather once per group: the per-group down-ray face set AND the flat collider - // (appended with an index offset) come from the same buffers. + // One walking gather feeds both the per-group faces and the flat collider. let mut gpos: Vec<[f32; 3]> = Vec::new(); let mut gidx: Vec = Vec::new(); accumulate_wmo_group_collision(&gbytes, &mut gpos, &mut gidx); @@ -599,8 +448,6 @@ impl AssetLoader for WmoModelLoader { col_pos.extend_from_slice(&gpos); col_idx.extend(gidx.iter().map(|i| i + base)); accumulate_wmo_group_camera_collision(&gbytes, &mut cam_pos, &mut cam_idx); - // Camera-only gather (the walking gather's DETAIL complement) β€” kept per group for the - // down-ray's camera-void fallback (decision 0692). let (mut dpos, mut didx): (Vec<[f32; 3]>, Vec) = (Vec::new(), Vec::new()); accumulate_wmo_group_camera_only_collision(&gbytes, &mut dpos, &mut didx); if let Some(tris) = group_camera_only_tris.get_mut(gi as usize) { @@ -615,9 +462,7 @@ impl AssetLoader for WmoModelLoader { } } let subs = wmo_group_submeshes(&gbytes, &root).map_err(to_io)?; - // This group's MODR (which MODD placements it owns/instantiates) + MOLR (which MOLT - // lights fold into its doodads' committed light) β€” the per-doodad base resolution below - // consumes both (MODR walked at `0x695aa0`, MOLR at `0x695c00`). + // The reference walks MODR at `0x695aa0` and MOLR at `0x695c00`. if let Some(slot) = group_doodad_refs.get_mut(gi as usize) { *slot = wmo_group_doodad_refs(&gbytes); } @@ -631,14 +476,8 @@ impl AssetLoader for WmoModelLoader { *slot = wmo_group_liquid_mesh(&gbytes); } for sub in subs { - // No meshes built here (decision 0834): a city root's ~thousands of group batches - // as labeled sub-assets landed in ONE frame β€” the geometry ships on the submesh - // and the app builds each batch's mesh paced (`benilla`'s `model_forms`). - // - // Lowercase the path: Bevy's loader lookup is case-sensitive and these tables carry - // uppercase `.BLP`, so an uppercase extension falls back to type-based resolution β€” - // ambiguous with Bevy's built-in image loader, which spams a "Multiple AssetLoaders - // found" warning per texture. Lowercasing matches `BlpImageLoader`'s `blp` + dedupes case. + // Lowercased: Bevy's loader lookup is case-sensitive, and an uppercase `.BLP` falls + // to type-based resolution, ambiguous with Bevy's own image loader. let texture = sub.texture.as_deref().map(|t| { ctx.load::(format!( "mpq://{}", @@ -647,41 +486,36 @@ impl AssetLoader for WmoModelLoader { }); submeshes.push(ModelSubmesh { texture, - skin_slot: sub.skin_slot, // always None for WMO groups (no creature skins) - geoset_id: 0, // WMO has no M2 geoset concept - char_slot: None, // WMO is never a character body - icon_slot: false, // M2-only (texture type 14) + skin_slot: sub.skin_slot, + geoset_id: 0, + char_slot: None, + icon_slot: false, blend: sub.blend, two_sided: sub.two_sided, interior: sub.interior, emissive: sub.emissive, - sidn: sub.sidn, // MOMT SIDN (0x10) β€” the authored night-glow colour - window: sub.window, // MOMT WINDOW (0x20) β€” the interior midpoint light - additive: sub.additive, // always false for WMO (additive WMO batches deferred) - env_map: false, // M2-only (the WMO env/specular overlay is a separate path) - no_depth_write: false, // WMO uses the standard opaque/transparent depth state + sidn: sub.sidn, // MOMT SIDN (0x10): the authored night-glow colour + window: sub.window, // MOMT WINDOW (0x20): the interior midpoint light + additive: sub.additive, + env_map: false, + no_depth_write: false, no_depth_test: false, - fog_policy: sub.fog_policy, // always Scene for WMO (remap_submesh's default) - billboard: None, // WMO geometry isn't billboarded - alpha_anim: None, // WMO batches carry no M2 colour/weight tracks - uv_anim: None, // …nor texture transforms - uv_seq: None, // …so no per-sequence set either (1408) + fog_policy: sub.fog_policy, + billboard: None, + alpha_anim: None, + uv_anim: None, + uv_seq: None, uv_rot_seq: None, uv_scale_seq: None, rgb_anim: None, - rgb_seq: None, // …nor M2Color tints - wmo_batch: sub.wmo_batch, // the MOBA section β€” an interior group's lighting law - ground_quad: None, // the fx decal lane is M2-only + rgb_seq: None, + wmo_batch: sub.wmo_batch, // the MOBA section: an interior group's lighting + ground_quad: None, geometry: std::sync::Arc::new(sub), }); submesh_group.push(gi as u16); } } - // Resolve every MODD placement's base once β€” placements, colours, MODR ownership, and MOLR - // light lists are all fixed in the root/groups, so nothing here is ever re-derived. Both - // MODR inversions come off the same refs: the first-owner one names the MOLR set the - // interior fold gates on, the full-set one is both the per-visible-group cull key and the - // exterior-wins lane test. let doodad_owner = modr_owners(root.doodads().len(), &group_doodad_refs); let doodad_groups = modr_refs(root.doodads().len(), &group_doodad_refs); let doodad_base = resolve_doodad_bases( @@ -746,21 +580,11 @@ impl AssetLoader for WmoModelLoader { } } -/// Apply the MLIQ **shared-tile gate** across a model's groups: a cell flagged `0x80` -/// ([`LiquidMesh::shared`]) is claimed by TWO groups and authored in both, and exactly one of them -/// may draw it. Both drawing means the translucent sheet composites twice over the overlap band β€” -/// the visible line along the Stormwind canals (B141's water half). -/// -/// The reference picks the winner by 2-colouring the portal graph on the depth parity of the flood -/// that reached each group, recomputed every frame (`0x6b41c0`/`0x6b4074`/`0x6b61a0`). We pick -/// **the lowest group index**, which is pixel-identical and needs no flood: measured over all 415 -/// corners of Stormwind's 190 shared cells, the two claimants agree exactly β€” the same per-vertex -/// alpha byte (415/415) and heights within 5e-6 yd. Which one draws cannot be seen; that two draw -/// can. -/// -/// Cells are matched on their XY centre quantised to a hundredth of a yard β€” the two authors share -/// the same MLIQ lattice, so the coordinates agree to float exactness and the quantisation is only -/// there to make them hashable. +/// The MLIQ shared-cell gate: a cell flagged `0x80` ([`LiquidMesh::shared`]) is authored in two +/// groups and only one may draw it, or the sheet composites twice. The reference picks the drawer +/// by the portal flood's depth parity each frame (`0x6b41c0`, `0x6b4074`, `0x6b61a0`); the lowest +/// group index draws the same pixels, as both claimants carry the same alpha and heights. Cells +/// match on their XY centre, quantised to 0.01 yd only to hash. fn resolve_shared_liquid_cells(group_liquids: &mut [Option]) { let key = |m: &LiquidMesh, c: usize| -> Option<(i32, i32)> { let (cols, rows) = (m.grid[0] as usize, m.grid[1] as usize); @@ -828,49 +652,35 @@ mod doodad_base_tests { } } - /// GOLDEN β€” the two abbey stands the reference trace decoded: MODD[18] colour (78,76,134) β†’ - /// ambient (56,55,96) diffuse raw; MODD[24] colour (90,86,141) β†’ ambient (61,59,96) diffuse - /// raw. Both bit-exact through the `0x6a77e0` fixed-point cap (scales 182 and 173). + /// Northshire Abbey's candle stands MODD[18] and MODD[24], as the reference commits them + /// through `0x6a77e0` (cap scales 182 and 173). #[test] fn cap96_matches_the_decoded_abbey_stands_bit_exact() { let as_bytes = |c: [f32; 3]| c.map(|v| (v * 255.0).round() as u8); assert_eq!(as_bytes(cap96([78, 76, 134])), [56, 55, 96]); assert_eq!(as_bytes(cap96([90, 86, 141])), [61, 59, 96]); - // max ≀ 96 passes through raw. assert_eq!(as_bytes(cap96([96, 40, 20])), [96, 40, 20]); - // The diffuse floor: max β‰₯ 112 is RAW (both stands), below 112 raises hue-preserving. assert_eq!(as_bytes(floor112([78, 76, 134])), [78, 76, 134]); assert_eq!(as_bytes(floor112([90, 86, 141])), [90, 86, 141]); let raised = as_bytes(floor112([56, 28, 14])); - assert_eq!(raised[0], 112); // max channel lands on the 112 value - assert_eq!(raised, [112, 56, 28]); // pure scale β€” hue/saturation preserved - // Black stays black β€” and that IS the reference, not just our divide-by-zero guard. The - // raise re-emits through HSV, and `RGBtoHSV(0,0,0)` gives S=0/H=βˆ’1 (`7bbcb5`/`7bbccd`) so - // `HSVtoRGB` would return the grey `(V,V,V)` (`7bbd76`) β€” but the caller SCALES the value - // rather than setting it (`6a78a5 fild thresh Β· 6a78ab fidiv max Β· 6a78b1 fmul V`), and - // `VΒ·thresh/max` is 0 when V is. `6a780e cmp dl,1 / 6a7813 mov bl,1` forces max to 1 only - // to keep that `fidiv` finite. So a zero-colour interior prop commits black in the real - // client too, and the black Booty Bay arch was a LANE bug, never this (decision 0969). + assert_eq!(raised[0], 112); + assert_eq!(raised, [112, 56, 28]); + // Black stays black in the reference too: it scales the HSV value by `thresh/max` + // (`0x6a78a5`) and forces max to 1 only to keep that divide finite (`0x6a780e`). assert_eq!(as_bytes(floor112([0, 0, 0])), [0, 0, 0]); } #[test] fn floor168_matches_the_decoded_abbey_benches_bit_exact() { - // The reference trace's INNBENCH GameObject draws (14150796/14150859) decode to - // ambient = the raw MOCV (max ≀ 96 β†’ cap96 pass-through) and diffuse = floor168(MOCV) with - // TRUNCATING rounding β€” 63Β·168/83 = 127.52 lands on 127 (nearest would give 128). + // The abbey inn benches as the reference commits them; 63Β·168/83 = 127.52 truncates to 127. let as_bytes = |c: [f32; 3]| c.map(|v| (v * 255.0).round() as u8); assert_eq!(as_bytes(cap96([59, 65, 92])), [59, 65, 92]); assert_eq!(as_bytes(cap96([69, 63, 83])), [69, 63, 83]); assert_eq!(as_bytes(floor168([59, 65, 92])), [107, 118, 168]); assert_eq!(as_bytes(floor168([69, 63, 83])), [139, 127, 168]); - // max β‰₯ 168 passes through raw. assert_eq!(as_bytes(floor168([200, 30, 10])), [200, 30, 10]); } - /// Ownership is MODR, never spatial: an interior-group doodad takes its MODD colour (cap96 - /// ambient / floor112 diffuse) + its OWNING group's MOLR; an exterior-group doodad is sky-lit; - /// an unreferenced MODD (never instantiated by the reference) defaults exterior. #[test] fn modr_ownership_picks_the_lane_and_the_light_refs() { let doodads = vec![ @@ -883,8 +693,6 @@ mod doodad_base_tests { let molr = vec![vec![7u16, 9u16], vec![3u16]]; let owner = modr_owners(doodads.len(), &modr); let refs = modr_refs(doodads.len(), &modr); - // The same inversion the portal cull keys props on (decision 0689): owned props name their - // group, the unreferenced one names none β€” so it is the one prop the cull can't hide. assert_eq!(owner, vec![Some(0), Some(1), None]); let bases = resolve_doodad_bases(&doodads, &groups, &owner, &refs, &molr); match &bases[0] { @@ -899,12 +707,6 @@ mod doodad_base_tests { assert_eq!(bases[2], DoodadBase::Exterior); } - /// EXTERIOR WINS over every interior referrer, whatever the MODR order β€” the reference's def is - /// per (MODD, placement) and its classify makes the exterior bit absorbing (`0x695aa0`: - /// `695ba2 jne` refuses to re-mark interior, `695bbd/695bc3` clears interior and sets exterior). - /// Booty Bay's entrance arch is the live case: g22 (interior) names it FIRST and g42 (exterior) - /// second, its MODD colour is `#000000`, and first-referrer-wins therefore drew it as a pure - /// black silhouette at the town gate (decision 0969). #[test] fn one_exterior_referrer_makes_the_whole_prop_exterior() { let doodads = vec![ @@ -929,9 +731,6 @@ mod doodad_base_tests { "the exterior referrer wins even though an interior group names it first" ); match &bases[1] { - // Interior-only: the lane still applies, and a zero MODD colour still commits black β€” - // the reference's own arithmetic (`0x6a77e0` forces max=1 only to dodge the divide, so - // the HSV raise re-emits value 0Β·112/1 = 0). Only its MOLR fixtures can light it. DoodadBase::Interior(b) => { assert_eq!(b.ambient, [0.0; 3]); assert_eq!(b.diffuse, [0.0; 3]); @@ -941,13 +740,8 @@ mod doodad_base_tests { } } - /// GOLDEN, on the shipped asset that produced the report: Booty Bay's entrance arch - /// (`BootyBay.wmo` MODD[3], `BootyBayEntrance_02`, MODF uid 118213 at world - /// (-14258.29, 330.26, 44.00)) resolves **Exterior**. Its MODR referrers are g22 (interior, - /// first) and g42 (exterior), and its MODD colour is `#000000` β€” so first-referrer-wins put it - /// on the interior lane with an all-zero base and drew it as a pure black silhouette over the - /// town gate. This reads the real root + group files through the same inversions the loader - /// calls, so it fails the moment the lane law regresses (decision 0969). + /// Booty Bay's entrance arch, `BootyBay.wmo` MODD[3], named first by interior g22 and also by + /// exterior g42, with a black MODD colour. #[test] fn booty_bays_entrance_arch_is_sky_lit_not_a_black_silhouette() { let data = benilla_formats::wow_data_or_skip!(); @@ -1007,9 +801,8 @@ mod doodad_base_tests { ); } - /// A group with NO MOLR chunk gives its doodads an EMPTY light set β€” the abbey's group 3 case: - /// the director's stand receives zero point light, its own flame included (machine-zero in the - /// reference decode). + /// Northshire Abbey's group 3 has no MOLR, and its candle stand takes no point light in the + /// reference, its own flame included. #[test] fn no_molr_means_no_point_lights_at_all() { let bases = resolve_doodad_bases( @@ -1025,30 +818,21 @@ mod doodad_base_tests { } } - /// A MODD named by several groups resolves to its **first** referencing group, and the mapping is - /// dense over the doodad list β€” the interior fold's MOLR set keys off this (the create that fills - /// the baked words runs once, on the first visible-group walk to reach it). Out-of-range MODR - /// entries (a malformed group) are ignored rather than panicking. #[test] fn a_shared_modd_takes_its_first_referencing_groups_molr() { - // g0 names doodad 2; g1 names 0 and 2; g2 names 1 β€” plus a ref past the end of the list. + // g0 names doodad 2; g1 names 0 and 2; g2 names 1, plus a ref past the end of the list. let modr = vec![vec![2u16], vec![0u16, 2u16], vec![1u16, 99u16]]; assert_eq!( modr_owners(3, &modr), vec![Some(1), Some(2), Some(0)], "doodad 2 is named by g0 and g1 β€” g0 wins" ); - // No groups at all β‡’ nothing is owned, and nothing is cullable. assert_eq!(modr_owners(2, &[]), vec![None, None]); } - /// The CULL key is the whole referrer set, not the first: the draw loop runs per *visible* group - /// over that group's MODR, so a prop several rooms name is drawn from every one of them. Keying - /// it to the first owner instead is what blinked Blackrock's and the Great Forge's lava falls - /// out by camera angle β€” real files name those props from 9–19 groups apiece. #[test] fn the_cull_key_is_every_referencing_group() { - // Same fixture as above: g0 names 2; g1 names 0 and 2; g2 names 1 + an out-of-range ref. + // The fixture above: g0 names 2; g1 names 0 and 2; g2 names 1 and an out-of-range ref. let modr = vec![vec![2u16], vec![0u16, 2u16], vec![1u16, 99u16]]; let refs = modr_refs(3, &modr); assert_eq!(&*refs[0], &[1], "doodad 0 is named by g1 alone"); @@ -1058,8 +842,7 @@ mod doodad_base_tests { &[0, 1], "doodad 2 hangs in two rooms β€” either one draws it" ); - // A group naming the same doodad twice contributes it once; no groups β‡’ no key at all (the - // prop is uncullable, matching `modr_owners`' `None`). + // A doodad named twice by one group counts once. assert_eq!(&*modr_refs(1, &[vec![0u16, 0u16]])[0], &[0]); assert!(modr_refs(2, &[])[0].is_empty()); } diff --git a/crates/benilla-assets/src/world_assets/images.rs b/crates/benilla-assets/src/world_assets/images.rs index ec8f0515..4ca61b1b 100644 --- a/crates/benilla-assets/src/world_assets/images.rs +++ b/crates/benilla-assets/src/world_assets/images.rs @@ -1,6 +1,5 @@ -//! Texture/image helpers: turn decoded BLP mip chains + raw RGBA into Bevy `Image`s (correct format, -//! sampler, mip layout) plus the small solid-colour / cursor / liquid-frame textures. Pure helpers the -//! asset foundation (`super`) and other subsystems call. Split out of `assets/mod.rs`. +//! Image helpers: BLP mip chains and raw RGBA into Bevy `Image`s (format, sampler, mip layout), +//! for world art, liquid frames, sprites, masks and the cursor. use bevy::asset::RenderAssetUsages; use bevy::image::{Image, ImageAddressMode, ImageFilterMode, ImageSampler, ImageSamplerDescriptor}; @@ -11,28 +10,13 @@ use bevy::render::render_resource::{ use benilla_formats::BlpMipChain; -/// Build a full mip chain for an RGBA texture (mip 0 = `mip0`), halving each level to 1Γ—1 with a -/// triangle (box-ish) downsample. Returns the concatenated mip data + the level count. For a single -/// layer this is the `LayerMajor`/`MipMajor`-agnostic order (mip0, mip1, …). -/// Color (albedo) texture format: **non-sRGB `Rgba8Unorm`**, so the GPU does NOT linearize albedo on -/// sample. Vanilla's fixed-function pipeline multiplied gamma (sRGB) texture bytes by gamma light -/// bytes in **gamma/byte space**; loading albedo as `…Unorm` keeps our shader math in that same space. -/// This is the RE'd faithful **invariant** (no sRGB↔linear round-trip anywhere). Used for model + -/// terrain albedo. +/// Albedo format, non-sRGB: the reference multiplies gamma texture bytes by gamma light bytes. pub fn color_texture_format() -> TextureFormat { TextureFormat::Rgba8Unorm } -/// A repeating, **mipmapped + anisotropically-filtered** texture built from the BLP's **authored** -/// mip pyramid β€” the levels the real 1.12 client uploads verbatim (`WoW.exe` has no GLU / -/// `glGenerateMipmap`; it cannot regenerate mips, only upload the stored ones). Using the authored -/// levels instead of CPU-resampling mip 0 with a box/Triangle filter in gamma byte space is the -/// model-side of the C2 fix: gamma-byte averaging darkens mid-tones and, on alpha-cutout foliage, -/// skews the leaf colour cool (it averages the dark *transparent* texels' RGB into the leaves, 5–18 -/// LSB darker in R/G) and drifts coverage with distance. The authored mips already encode WoW's -/// coverage-correct, leaf-coloured downsample, so we lay them in as-is under the alpha test β€” no -/// re-filter, no alpha-to-coverage (1.12 does neither). Mips + anisotropy give the no-shimmer look -/// at distance without softening the up-close art. +/// World or model albedo from the BLP's authored mips as stored: the 1.12 client has no GLU or +/// `glGenerateMipmap`, and does no re-filter or alpha-to-coverage. pub fn repeat_texture_authored(upload: crate::gpu_blp::UploadChain, wrap: (bool, bool)) -> Image { let crate::gpu_blp::UploadChain { chain, format } = upload; let levels = chain.mips.len() as u32; @@ -40,9 +24,6 @@ pub fn repeat_texture_authored(upload: crate::gpu_blp::UploadChain, wrap: (bool, for mip in &chain.mips { data.extend_from_slice(mip); } - // Taking an `UploadChain` rather than a bare chain is deliberate: the format and the bytes are - // decided together by `gpu_blp::for_upload`, so this function cannot be handed blocks under an - // uncompressed descriptor. It was, once β€” see `UploadChain`'s doc (decision 1626). let mut image = Image::new_uninit( Extent3d { width: chain.width, @@ -51,22 +32,14 @@ pub fn repeat_texture_authored(upload: crate::gpu_blp::UploadChain, wrap: (bool, }, TextureDimension::D2, format, - // `RENDER_WORLD` β€” see [`image_gpu_bytes`] and the `blp.rs` `WorldArt` variant this is the - // synchronous twin of: the render world takes the chain rather than cloning it and keeping - // a main-world copy. Its callers are `WorldAssets::texture` and the character-skin - // composite, and neither reads the result back; a composite is built CPU-side and handed - // over whole. + // The synchronous twin of `blp.rs`'s `WorldArt` lane: the render world takes the chain, and + // neither caller (`WorldAssets::texture`, the character-skin composite) reads it back. RenderAssetUsages::RENDER_WORLD, ); image.data = Some(data); image.texture_descriptor.mip_level_count = levels; - // The address mode is the M2 texture record's own (`flags & 0x1/0x2`), not a blanket Repeat. - // On a cutout sheet it decides the SILHOUETTE: content authors a card's UVs past `0..1` so the - // margin clamps to the transparent border and the card ends there β€” repeat instead folds that - // margin into the opposite edge, drawing the sheet's opaque middle as solid geometry with a hard - // seam at the crossing. 1250 batches across 775 models author clamp and sample outside the unit - // square (`benilla-extract uvwrapscan`); 802 are cutout/blend, where it changes shape and not - // merely colour. Decision 0763 (bugs B52/B96 β€” Dun Morogh's snow-firs, the Plaguelands bush). + // The M2 texture record's own address mode (`flags & 0x1/0x2`): a cutout card authors UVs + // past `0..1` to clamp onto its transparent border, where repeat folds its opaque middle in. let mode = |repeat: bool| { if repeat { ImageAddressMode::Repeat @@ -74,10 +47,7 @@ pub fn repeat_texture_authored(upload: crate::gpu_blp::UploadChain, wrap: (bool, ImageAddressMode::ClampToEdge } }; - // The reference forces the mip filter and the anisotropy from two process globals at every - // `TextureCreate`, and a virgin install lands on trilinear with anisotropy OFF - // ([`crate::tex_filter`]). This read `Linear` / `8` β€” mode 5 β€” under the comment "the vanilla - // look", which was the look of an install with `SET anisotropic "16"` in its `Config.wtf`. + // M2, WMO and character textures take the process filter policy (`0x449ae0`, applyGlobal 1). let filter = crate::tex_filter::tex_filter(); image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { address_mode_u: mode(wrap.0), @@ -91,42 +61,21 @@ pub fn repeat_texture_authored(upload: crate::gpu_blp::UploadChain, wrap: (bool, image } -/// `WOW_LIQUID_DC=raw` restores the shipped frames' own per-frame DC β€” the A/B lever for -/// [`flatten_frame_dc`] while the fidelity question behind it is open (see that function). +/// `WOW_LIQUID_DC=raw` keeps the shipped frames' own DC, the A/B lever for [`flatten_frame_dc`]. fn dc_normalization_enabled() -> bool { std::env::var("WOW_LIQUID_DC").as_deref() != Ok("raw") } -/// Flatten each frame's **DC** (per level, per channel) onto the loop's mean β€” the fix for the -/// water sheet breathing once per animation loop. +/// Shift each frame's DC (per level, per channel) onto the loop's mean. /// -/// The shipped ocean frames do not share a mean. Across `ocean_h`'s 30-frame loop the full-texture -/// mean alpha runs 55.985..57.628, and DXT3's 4-bit alpha quantisation makes the small levels drift -/// harder still: measured swing is 1.64 at mip 0, 3.72 at mip 5 and 6.38 at mip 6. Near the camera -/// that is invisible, because each pixel lands on a different texel and the variation scatters as -/// ripple shimmer. At distance mipping averages the whole level into one value, so every pixel of -/// the far sheet moves *together* and the surface visibly brightens and dims once per loop β€” -/// 1.25 s at the 24 fps flip. Director-reported at Ratchet (2026-09-06); measured off their capture -/// at 0.8..1.84/255 with an autocorrelation period of exactly 1.250 s, and confirmed coherent -/// rather than noise (a 0.82/255 swing in a 104k-pixel mean cannot come from per-pixel scatter). -/// It reads as a flicker only at a high `farclip` β€” at 300 that band is fogged out or past the -/// wall, which is why the report came with "not visible at 300". -/// -/// The correction is a per-level **additive** offset, not a gain: the drift is a DC shift, and an -/// offset removes it without touching the ripple's contrast β€” where a multiplicative fit would -/// scale the crests (alpha reaches 255) and clip them. Applied per channel because both terms of -/// the ADT combine carry the drift (`detail.rgb`, and `detail.a` through the sheen factor). -/// -/// **Water/ocean only.** Magma and slime take the animated sheet as their opaque BODY colour, where -/// a per-frame brightness swing is the intended pulse, so the caller passes `false` for the -/// fullbright kinds and their DC is left alone. -/// -/// **This is a deliberate divergence from the shipped art**, pending whether the reference shows -/// the same breathing (it uploads the same authored, same-quantised mips, so it -/// plausibly does). `WOW_LIQUID_DC=raw` restores the frames verbatim for the A/B. +/// Deviation: the reference uploads the shipped frames as they are; this evens them because their +/// means differ (`ocean_h` alpha 55.985..57.628 over its 30 frames, more down the DXT3 mips), and +/// at distance, where mipping averages a level to one value, the far sheet brightens and dims once +/// per 1.25 s loop. An additive offset leaves the ripple's crests unscaled; per channel, since +/// `detail.rgb` and `detail.a` both drift. Water only: magma and slime keep their pulse. fn flatten_frame_dc(data: &mut [u8], spans: &[Vec<(usize, usize)>], levels: usize) { for level in 0..levels { - // The loop's target sum-per-texel for this level, over every frame. + // The loop's target sum per texel for this level, over every frame. let mut sums = [0i64; 4]; let mut texels = 0usize; for frame in spans { @@ -160,11 +109,9 @@ fn flatten_frame_dc(data: &mut [u8], spans: &[Vec<(usize, usize)>], levels: usiz if delta == 0 { continue; } - // Spend the delta in Β±1 steps, walking the level on a stride coprime with its - // texel count so the touched texels scatter instead of forming a patch at the - // start. Repeated passes each add at most 1 LSB per texel, so a large correction - // stays a faint uniform lift rather than a few blown texels; texels already at the - // 0/255 rail are skipped, and a pass that moves nothing ends the walk. + // Spend the delta in Β±1 steps on a stride coprime with the texel count, so the + // touched texels scatter and each pass adds at most 1 LSB per texel; railed texels + // are skipped, and a pass that moves nothing ends the walk. let stride = if n % 7 == 0 { 1 } else { 7 }; let mut progress = true; while delta != 0 && progress { @@ -190,18 +137,12 @@ fn flatten_frame_dc(data: &mut [u8], spans: &[Vec<(usize, usize)>], levels: usiz } } -/// Stack the animated liquid frames into one **mipmapped + anisotropic** `texture_2d_array` from each -/// frame's BLP **authored** mip pyramid β€” same fidelity rule as [`repeat_texture_authored`]: the real -/// 1.12 client uploads the BLP's stored mips verbatim (no client-side regeneration). Without mips, the -/// 256Β² ripple tiled over ~16 yd aliases into dense sparkle "noise" at distance; the authored mips + -/// anisotropy give the reference's smooth, streaky wave highlights instead. Data is laid out -/// **LayerMajor** (`frame0[mip0..mipN], frame1[mip0..], …`, the wgpu/Bevy default) with a full -/// `sizeβ†’1` chain; a frame whose authored chain is shorter/odd is filled by `NEAREST` from its nearest -/// authored level (gamma-safe, no averaging β€” mirrors [`chain_to_layer`]). All frames must share mip-0 -/// `size` (the caller enforces this). Returns a `D2Array`-viewed image with `mip_level_count` set. +/// The animated liquid frames stacked layer-major into one `texture_2d_array` from their authored +/// mips, a full chain to 1Γ—1; a level a frame lacks is filled `NEAREST` from its nearest authored +/// level, so no gamma averaging. Every frame shares mip 0's `size` (the caller enforces it). pub fn liquid_frame_array(frames: Vec, normalize_dc: bool) -> Image { let size = frames[0].width; - // Full mip chain down to 1Γ—1 (square 1.12 frames): levels = log2(size) + 1. + // Square 1.12 frames: levels = log2(size) + 1. let mip_level_count = { let (mut n, mut s) = (1u32, size); while s > 1 { @@ -211,15 +152,13 @@ pub fn liquid_frame_array(frames: Vec, normalize_dc: bool) -> Image n }; let mut data = Vec::with_capacity(frames.len() * mip_chain_byte_size(size, mip_level_count)); - // Where each (frame, level) region landed, so the DC pass below can address one level of one - // frame without re-deriving the LayerMajor arithmetic. + // Where each (frame, level) region landed, for the DC pass. let mut spans: Vec> = Vec::with_capacity(frames.len()); for blp in &frames { let mut per_level = Vec::with_capacity(mip_level_count as usize); for level in 0..mip_level_count { let lw = (size >> level).max(1); - // Match the array level to the authored mip of the same size (normally identity β€” water - // frames carry a full chain); fall back to the nearest authored level via NEAREST. + // The authored mip of this size (water frames carry a full chain), else the nearest. let src_level = if blp.width >= lw { let ratio = (blp.width / lw).max(1); (ratio.trailing_zeros() as usize).min(blp.mips.len() - 1) @@ -244,10 +183,7 @@ pub fn liquid_frame_array(frames: Vec, normalize_dc: bool) -> Image }, TextureDimension::D2, color_texture_format(), - // `RENDER_WORLD`: 136 frames of a 256Β² nine-level RGBA8 pyramid is 47,535,264 B (the figure - // `gpu_bytes_count_every_array_layer` pins), built at `Startup` and resident for the whole - // process. The default usage held that twice β€” once on the GPU and once in system RAM, - // which nothing ever read. + // 47,535,264 B resident for the whole process, never read main-side. RenderAssetUsages::RENDER_WORLD, ); image.data = Some(data); @@ -256,14 +192,7 @@ pub fn liquid_frame_array(frames: Vec, normalize_dc: bool) -> Image dimension: Some(TextureViewDimension::D2Array), ..default() }); - // **The apitrace that pinned this was read one step too far.** WoW.20 showed the ripple - // sampler at LINEAR_MIPMAP_LINEAR + MAX_ANISOTROPY 16 and it was recorded here as "VERIFIED - // the reference ripple sampler" β€” but that capture came off the repo's reference install, - // whose `Config.wtf` carries `SET anisotropic "16"`. It proves the *mechanism* (the global - // reaches the liquid sampler like every other), not the *default*, which is aniso off. There - // is no per-texture filter override anywhere in the reference, so water takes the same policy - // as ground ([`crate::tex_filter`]) β€” and at `anisotropic 16` this lane is byte-identical to - // what it was. + // The liquid sheets take the process filter policy (`0x68abab`, applyGlobal 1), as ground does. let filter = crate::tex_filter::tex_filter(); image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { address_mode_u: ImageAddressMode::Repeat, @@ -277,9 +206,7 @@ pub fn liquid_frame_array(frames: Vec, normalize_dc: bool) -> Image image } -/// A single-mip RGBA8 image for winit's custom OS cursor (`CursorIcon::Custom`). Vanilla cursors are -/// tiny (`Point.blp` is 32Γ—32), so unlike [`repeat_texture`] this needs neither a mip chain nor a -/// repeat sampler. macOS doesn't use this path (it builds an `NSCursor` directly β€” see `crate::cursor`). +/// A single-mip sRGB image for winit's custom OS cursor; macOS builds an `NSCursor` instead. #[cfg(not(target_os = "macos"))] pub fn cursor_texture(width: u32, height: u32, rgba: Vec) -> Image { Image::new( @@ -295,10 +222,8 @@ pub fn cursor_texture(width: u32, height: u32, rgba: Vec) -> Image { ) } -/// A single-mip, **sRGB**, clamp-sampled image for an emissive celestial billboard (the sun/moon -/// discs β€” see [`WorldAssets::sprite_texture`]). sRGB so an unlit pass round-trips the authored gamma -/// bytes to screen; clamp-to-edge + linear (no repeat, no mips) since the whole texture maps to one -/// quad and the disc never tiles or minifies far. +/// A single-mip sRGB sprite, clamped and linear, for the celestial discs and the UI: sRGB so an +/// unlit pass returns the authored gamma bytes to screen. pub fn sprite_image(width: u32, height: u32, rgba: Vec) -> Image { let mut image = Image::new( Extent3d { @@ -321,12 +246,8 @@ pub fn sprite_image(width: u32, height: u32, rgba: Vec) -> Image { image } -/// A coverage **mask** image (the minimap's `MinimapMask.blp`, decision 0203): identical sampling -/// to [`sprite_image`] (clamp, linear filtering, no mips) but uploaded **`Rgba8Unorm`** (not -/// sRGB) β€” the shader reads coverage, not color, so no gamma decode belongs anywhere in the path. -/// The coverage itself rides the ALPHA channel (`MinimapMask.blp` is DXT3: white color plane, the -/// circle ramp authored in its 8-bit alpha; alpha is gamma-free in either format, but the linear -/// upload keeps the color plane byte-exact too, and documents the intent). +/// A coverage mask (the minimap's `MinimapMask.blp`, its circle ramp in the alpha): sampled like +/// [`sprite_image`] but uploaded `Rgba8Unorm`, since the shader reads coverage, not colour. pub fn mask_image(width: u32, height: u32, rgba: Vec) -> Image { let mut image = Image::new( Extent3d { @@ -349,12 +270,8 @@ pub fn mask_image(width: u32, height: u32, rgba: Vec) -> Image { image } -/// A portrait sprite: [`sprite_image`] with the RGBA pre-masked to its **inscribed circle** β€” the -/// alpha of every texel outside the largest centred circle is zeroed (with a 1px anti-aliased edge), -/// so a square icon or model reads as the round portrait the unit-frame ring expects. The ring in -/// `UI-TargetingFrame` is a thin band with transparent corners; without the mask the square texture's -/// corners poke past it (the placeholder `INV_Misc_QuestionMark` even bakes a grey border, so its -/// corners read as metal brackets). Same sRGB/clamp/linear/no-mip setup as [`sprite_image`]. +/// A portrait: [`sprite_image`] with alpha zeroed outside the inscribed circle (1 px anti-aliased), +/// so square art does not poke past the unit frame's round ring (`UI-TargetingFrame`). pub fn portrait_image(width: u32, height: u32, mut rgba: Vec) -> Image { if width > 0 && height > 0 && rgba.len() == (width * height * 4) as usize { let (cx, cy) = (width as f32 / 2.0, height as f32 / 2.0); @@ -364,7 +281,6 @@ pub fn portrait_image(width: u32, height: u32, mut rgba: Vec) -> Image { let dx = x as f32 + 0.5 - cx; let dy = y as f32 + 0.5 - cy; let dist = (dx * dx + dy * dy).sqrt(); - // Coverage: 1 well inside, 0 well outside, a 1px linear ramp across the edge. let coverage = (radius - dist).clamp(0.0, 1.0); let a = ((y * width + x) * 4 + 3) as usize; rgba[a] = (f32::from(rgba[a]) * coverage).round() as u8; @@ -374,34 +290,18 @@ pub fn portrait_image(width: u32, height: u32, mut rgba: Vec) -> Image { sprite_image(width, height, rgba) } -/// A single-mip, **sRGB**, **repeat**-sampled image for a frame `Backdrop`'s tiled pieces (the -/// tooltip/panel border edges + a tiling background β€” `0x77e8d0`/`0x77f0c0`). Identical to -/// [`sprite_image`] except the address mode is `Repeat`: a backdrop edge strip runs UVs `[0..N]` -/// (N = the edge count = frame-side / edgeSize βˆ’ 2) and a tiled bg runs `[0..w/period]`, so the -/// slice must *wrap*, not clamp-stretch. The real client flags exactly this on the backdrop's -/// `SetTexture` (arg2 pushed twice into the load descriptor β€” `0x770200`, inferred U+V wrap, not -/// traced to the sampler). sRGB + no mips, same as the clamp sprite (UI art, one authored gamma -/// round-trip). -/// -/// Both axes wrap β€” the backdrop's bg tiles both ways, and its edge strips are atlas crops on -/// their bounded axis, kept off the image edge by `inset_atlas_bleed`. A texture that tiles -/// along ONE axis and spans the whole image on the other takes [`sprite_image_wrapped`] instead -/// (decision 2000). +/// [`sprite_image`] wrapping on both axes, for a frame `Backdrop`, whose `SetTexture` pushes one +/// argument twice into the load descriptor (`0x770200`), read as U and V wrap but not traced to +/// the sampler: an edge strip runs UVs `[0..N]` (N = frame side / edgeSize βˆ’ 2) and a tiled +/// background `[0..w/period]` (`0x77e8d0`, `0x77f0c0`). The edge strips are atlas crops kept off +/// the image edge (`inset_atlas_bleed`), so their bounded axis does not bleed. pub fn sprite_image_tiled(width: u32, height: u32, rgba: Vec) -> Image { sprite_image_wrapped(width, height, rgba, (true, true)) } -/// [`sprite_image`]'s decode with the address mode chosen **per axis**: `Repeat` where `wrap` -/// says so, `ClampToEdge` elsewhere. -/// -/// The reference's tiling idiom, `SetTexCoord(0, n, 0, 1)` on an n-slot strip, runs past the -/// texture along one axis only; the other spans exactly `[0, 1]`. Sampling that bounded axis -/// with `Repeat` is a bleed: bilinear filtering at `v = 0` weighs in the texture's LAST row, so a -/// strip whose bottom row is opaque draws that row as a faint hairline along its own top edge β€” -/// the stance shelf's middle piece (`ShapeshiftBarMiddle.blp`: rows 0-7 transparent, row 31 -/// opaque grey) wore a one-device-px grey line across the top of the strip at every four-form -/// bar, over the world. Wrapping only the axis that actually tiles is the fix at the root: the -/// bounded axis clamps at its edge, exactly as a stand-alone clamped sprite would (decision 2000). +/// [`sprite_image`] with `Repeat` on the axes `wrap` names and clamp elsewhere. The reference's +/// `SetTexCoord(0, n, 0, 1)` strips tile along one axis only; repeat on the bounded axis would +/// filter the last row into the first as a hairline along the strip's edge. pub fn sprite_image_wrapped(width: u32, height: u32, rgba: Vec, wrap: (bool, bool)) -> Image { let mut image = Image::new( Extent3d { @@ -431,21 +331,14 @@ pub fn sprite_image_wrapped(width: u32, height: u32, rgba: Vec, wrap: (bool, image } -/// The bytes an [`Image`] occupies **on the GPU** β€” every mip level of every array layer, derived -/// from the `texture_descriptor` alone. -/// -/// Read from the descriptor, never from `image.data.len()`: an asset declared -/// [`RenderAssetUsages::RENDER_WORLD`] has its bytes *moved* into the render world on extract -/// (`bevy_render::render_asset`), so main-side `data` is `None` for its whole life. A meter that -/// measures `data` therefore reads 0 for exactly the textures that cost the most β€” the terrain -/// arrays, the sprite sheets, the world albedo. This is the arithmetic bevy_image itself uses, and -/// it is block-aware, so it stays right if a format is ever uploaded compressed. +/// The bytes an [`Image`] occupies on the GPU, every mip of every layer, from the +/// `texture_descriptor` alone: a `RENDER_WORLD` asset's `data` moves to the render world on +/// extract, so main-side it reads `None`. Block-aware, as bevy_image computes it. pub fn image_gpu_bytes(image: &Image) -> usize { let d = &image.texture_descriptor; let format = d.format; let (bw, bh) = format.block_dimensions(); - // A copy is defined per block, and a block is only whole: a 1Γ—1 mip of a 4Γ—4-block format still - // costs one full block. + // A block is whole: a 1Γ—1 mip of a 4Γ—4-block format costs a full block. let Some(block_bytes) = format.block_copy_size(None) else { return 0; // multi-planar/depth-stencil: no single block size, and we upload neither }; @@ -476,16 +369,15 @@ pub fn mip_chain_byte_size(top: u32, mip_count: u32) -> usize { .sum() } -/// Append `src` (RGBA8 `sw Γ— sh`) resized to `dw Γ— dh` to `out`, using `NEAREST` filtering in -/// gamma byte space. NEAREST = pure texel-replication (or decimation), so no averaging happens β€” -/// preserves gamma-encoded bytes verbatim (no darkening). Identity at `(sw, sh) == (dw, dh)`. +/// Append `src` (RGBA8 `sw Γ— sh`) resized to `dw Γ— dh` by `NEAREST`, which only replicates or +/// decimates texels and so keeps the gamma bytes verbatim. pub fn extend_nearest(out: &mut Vec, src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) { if sw == dw && sh == dh { out.extend_from_slice(src); return; } let Some(buf) = image::RgbaImage::from_raw(sw, sh, src.to_vec()) else { - // Shape mismatch β€” fall back to a black layer rather than panicking. + // A shape mismatch gives a black layer rather than a panic. out.extend(std::iter::repeat_n(0u8, (dw * dh * 4) as usize)); return; }; @@ -497,8 +389,7 @@ pub fn extend_nearest(out: &mut Vec, src: &[u8], sw: u32, sh: u32, dw: u32, mod tests { use super::*; - /// Two 2x2 frames whose means differ by a lot, with a full 2-level chain each β€” the smallest - /// shape that exercises the per-level DC pass. + /// Two 2x2 frames with far-apart means and a full 2-level chain each. fn dc_frames() -> Vec { let frame = |base: u8| BlpMipChain { width: 2, @@ -531,10 +422,6 @@ mod tests { vec![frame(40), frame(80)] } - /// The whole point of [`flatten_frame_dc`]: after it, no frame's level mean differs from any - /// other's, so a mip-averaged sheet cannot brighten and dim as the loop plays. This is the - /// property the Ratchet report turned on β€” a 1.25 s whole-sheet breath measured at up to - /// 1.84/255 β€” and a still frame can never show it, so it is asserted here instead. #[test] fn flatten_frame_dc_equalizes_every_frame_at_every_level() { let frames = dc_frames(); @@ -563,12 +450,10 @@ mod tests { } } - /// The lever back to the shipped art, and the control for the test above: with - /// `WOW_LIQUID_DC=raw` the frames keep their own DC, so the drift the fix removes is still - /// there. Without this, a no-op `flatten_frame_dc` would pass the assertion above trivially. + /// The control for the test above: opted out (the magma and slime lane), the frames keep their + /// own DC, so a no-op pass cannot pass it. #[test] fn raw_lever_keeps_the_authored_per_frame_dc() { - // The pass is skipped when the caller opts out, which is the fullbright (magma/slime) lane. let frames = dc_frames(); let img = liquid_frame_array(frames, false); let data = img.data.as_ref().expect("array has data"); @@ -598,8 +483,6 @@ mod tests { image } - /// The arithmetic must agree with [`mip_chain_byte_size`], which the liquid array already - /// budgets with β€” two independent expressions of the same pyramid. #[test] fn gpu_bytes_agree_with_the_rgba8_chain_arithmetic() { for (top, mips) in [(256, 9), (128, 8), (64, 7), (1, 1)] { @@ -620,8 +503,7 @@ mod tests { } } - /// The number the liquid lane costs, stated once so it cannot drift silently: 136 frames of a - /// 256Β² RGBA8 nine-level pyramid, stacked as array layers. + /// The liquid lane: 136 frames of a 256Β² RGBA8 nine-level pyramid, stacked as array layers. #[test] fn gpu_bytes_count_every_array_layer() { let frames = 136; @@ -641,8 +523,6 @@ mod tests { ); } - /// Block-compressed formats are the point of reading the descriptor rather than a pixel size: - /// a 4Γ—4-block format still owes a WHOLE block for its 2Γ—2 and 1Γ—1 mips. #[test] fn gpu_bytes_are_block_aware_and_round_up() { let bc1 = descriptor_only( @@ -681,8 +561,6 @@ mod wrap_tests { } } - /// The stance shelf's case: tiles along its length, spans the whole texture in height β€” the - /// height axis must CLAMP, or the strip's opaque bottom row bleeds into its top edge. #[test] fn a_one_axis_tile_wraps_that_axis_and_clamps_the_other() { let img = sprite_image_wrapped(2, 2, vec![0; 16], (true, false)); @@ -697,7 +575,6 @@ mod wrap_tests { ); } - /// The backdrop's case is unchanged: both axes wrap. #[test] fn the_tiled_sprite_still_wraps_both_axes() { let img = sprite_image_tiled(2, 2, vec![0; 16]); diff --git a/crates/benilla-assets/src/world_assets/mod.rs b/crates/benilla-assets/src/world_assets/mod.rs index 147bc6be..fe66caf8 100644 --- a/crates/benilla-assets/src/world_assets/mod.rs +++ b/crates/benilla-assets/src/world_assets/mod.rs @@ -1,20 +1,6 @@ -//! **The shared asset store** β€” the one open patch chain, the dedup caches every model/texture load -//! goes through, and the helpers that turn raw BLP pixels into Bevy textures and per-submesh -//! materials. -//! -//! It lives here rather than in the client because it is a layer *under* both sides, and 1164 -//! measured how far under: `WorldAssets` alone is named by 63 files, `LockRecover` by 43 and -//! `AssetSet` by 37, and they serve UI sprites, portraits and the minimap mask exactly as much as -//! they serve terrain. Nothing in here reads game state β€” it knows what pixel shape something -//! wants, not what a unit frame *is*. -//! -//! What stayed up in the client is the 115-line plugin shell that drives it: opening the chain at -//! startup needs the shared light buffer, evicting on a map change needs the world-map message, and -//! the residency sweep needs the art-scope instrument. Those three are the only upward reaches the -//! module ever had (decision 1164) β€” the data core below has none. -//! -//! Deduping by path/material means each unique BLP is decoded + uploaded once, and submeshes sharing -//! a texture+blend share a material handle β€” which is what lets Bevy batch draws. +//! The shared asset store: the one open patch chain, the dedup caches every model and texture load +//! goes through, and the helpers that turn BLP pixels into textures and per-submesh materials. It +//! reads no game state. use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -28,112 +14,62 @@ use crate::materials::{WowModelExt, WowModelMaterial, VANILLA_ALPHA_KEY_REF}; use crate::SpatialCache; use benilla_formats::{blp_to_rgba, read_texture_native_chain, tga_to_rgba, Chain, ModelBlend}; -/// BLP/RGBA -> Bevy `Image` texture helpers (+ solid/cursor/liquid-frame textures). Split out for -/// size; re-exported so callers keep using `benilla_assets::{solid_layer_chain, …}`. +/// BLP and RGBA to `Image` helpers, re-exported. mod images; pub use images::*; -/// Shared asset store: the **one** open patch chain plus dedup caches, used by every model/texture -/// load (terrain layers, doodads, WMOs, creatures, GameObjects). Replaces the three independent -/// chains we used to open. Deduping by path/material means each unique BLP is decoded + uploaded -/// once, and submeshes sharing a texture+blend share a `StandardMaterial` handle β€” which is what -/// lets Bevy batch their draw calls. +/// The shared asset store: the one open patch chain and the dedup caches, so each BLP decodes once +/// and submeshes sharing a texture and blend share a material that Bevy batches. #[derive(Resource)] pub struct WorldAssets { - /// The **one** open patch chain, behind `Arc>` because the streaming loaders thread a - /// `&mut Chain` (the `read_file` shim); the `Mutex` is `Send + Sync` glue, not a read serializer β€” - /// `Chain` reads are `&self` and open a fresh OS handle, so they share no seek state. - /// Main-thread reads lock via [`LockRecover`] (poison-tolerant); contention is ~nil since after - /// startup the only main-thread reads are the occasional map-change WDL load + lazy clutter M2s. + /// The one open patch chain. The `Mutex` is `Send + Sync` glue for the streaming loaders' + /// `&mut Chain`, not a read serializer: `Chain` reads are `&self` on a fresh OS handle. pub chain: Arc>, - /// Decoded GPU textures by normalized path. - /// Keyed by `(normalized path, wrap_u, wrap_v)`: the SAME BLP is legitimately sampled both - /// ways by different models (a sheet that tiles on a wall and clamps on a cutout card), and the - /// address mode lives on the Bevy `Image`'s sampler β€” so each mode needs its own upload - /// (decision 0763). Before that key existed, whichever model loaded the texture first decided - /// the mode for every later one. + /// Decoded GPU textures by `(normalized path, wrap_u, wrap_v)`: one BLP can be sampled both + /// ways (tiled on a wall, clamped on a cutout card), and the address mode lives on the sampler. pub textures: SpatialCache<(String, bool, bool), Handle>, - /// Decoded UI sprite textures by resolved path (`None` = a miss, cached so a bad path never - /// re-walks the chain per frame). Separate from `textures`: sprites are sRGB/clamped, world - /// art is Unorm/repeat β€” the same BLP can legitimately live in both. + /// UI sprites by resolved path, misses cached as `None` so a bad path never re-walks the chain + /// per frame. sprites: HashMap>>, - /// Decoded UI sprite textures sampled with **repeat** addressing on one or both axes (frame - /// `Backdrop` pieces β€” tiled edges/bg β€” and the reference's `SetTexCoord(0, n, 0, 1)` strips). - /// Keyed by resolved path **and** the per-axis wrap, separate from `sprites`: the *same* BLP - /// can be wanted clamp-sampled as a plain sprite and repeat-sampled as a backdrop piece, and - /// each address-mode pair needs its own GPU image (the sampler is baked into the `Image`). - /// See [`Self::sprite_texture_wrapped`]. + /// UI sprites by path and per-axis wrap, since the sampler bakes into the `Image`. tiled_sprites: HashMap<(String, bool, bool), Option>>, - /// Sprites RESAMPLED to an exact physical size, by `(path, w, h)` β€” the nameplate border's - /// 0188 sharpen and nothing else so far. Keyed by size because that is the whole point: the - /// art is re-rasterised whenever the plate's pixel size moves, and reused on every frame in - /// between (a resize is rare; a plate is drawn 60 times a second). + /// Sprites resampled to an exact physical size, by `(path, w, h)`. resampled_sprites: HashMap<(String, u32, u32), Option>>, - /// The decoded source pixels behind [`Self::resampled_sprites`], so a resize re-samples - /// instead of re-decoding the BLP. + /// The decoded pixels behind [`Self::resampled_sprites`], so a resize does not re-decode. resample_sources: HashMap)>>, - /// Decoded **portrait** sprites β€” [`Self::sprite_texture`]'s clamp/sRGB sprite with a circular - /// alpha mask baked in ([`portrait_image`]). Its own cache, like `tiled_sprites`: the mask bakes - /// into the GPU image, so the same BLP wanted as a plain icon and as a portrait needs two images. + /// Portrait sprites, the circular mask baked in ([`portrait_image`]). portraits: HashMap>>, - /// Decoded coverage **masks** ([`mask_image`] β€” linear, not sRGB): the minimap's circular clip - /// (decision 0203). Its own cache for the same reason as the others: the format is baked into - /// the GPU image. + /// Coverage masks ([`mask_image`]): the minimap's circular clip. masks: HashMap>>, - /// **Generated** UI sprites β€” pixels this client computes rather than decodes, keyed by a - /// caller-chosen static name ([`Self::generated_sprite`]). Its own cache for the same reason as - /// every other one: the sampler bakes into the GPU image. Nothing about the *content* lives - /// here β€” the caller supplies the RGBA β€” so this stays a cache, not a second art pipeline. + /// UI sprites whose pixels the client computes ([`Self::generated_sprite`]). generated: HashMap<&'static str, Handle>, - /// The **loose-file root for `Interface\AddOns\` sprite paths** β€” the AddOns folder, installed - /// by the app ([`Self::set_loose_addon_root`]), `None` until then and in every capture run - /// (the app resolves it through `local_state`, which is hermetic under `$WOW_CAPTURE`). - /// - /// Addon art never lives in an MPQ: the reference's Storm open reads loose files from the game - /// directory, which is how `` works at all. - /// Our equivalent maps that virtual prefix onto the one addon root (decision 1185's single - /// folder; decision 1322). Only the UI-sprite decoders consult it β€” world art has no business - /// under `AddOns\`. + /// The AddOns folder, where `Interface\AddOns\` paths resolve as loose files, as the reference + /// reads them from the game directory; `None` until the app installs it and in capture runs. loose_root: Option, - /// Materials deduped by their identity (texture path + blend, or the untextured fallback). + /// Materials deduped by [`MaterialKey`]. pub model_materials: SpatialCache>, - /// The shared global-light storage buffer (`lighting::global_light`). Held here so `model_material` - /// can clone it into every deduped model material's `light_buf` without threading a `Buffer` through - /// the many call sites. One buffer for the whole scene, updated in place each frame. + /// The shared global-light buffer, cloned into every deduped model material's `light_buf`. pub shared_light: Buffer, } -/// Identity of a deduped model material: a textured material is uniquely determined by its texture -/// path, blend mode, sidedness, alpha-key cutoff, and clutter fade-distance; everything textureless -/// (missing/failed texture) shares one fallback. Cutoff + fade are in the key so ground clutter (lower -/// alpha ref + a distance fade) doesn't collide with a tree sharing the same texture. +/// Identity of a deduped model material; everything textureless shares one fallback per WMO flag. +/// The cutoff and fade distance are in it so ground clutter never shares a tree's material. #[derive(Clone, PartialEq, Eq, Hash)] pub enum MaterialKey { /// `(texture, blend, two_sided, alpha_key_u8, fade_far_yd_u16, is_wmo, is_fade_variant, - /// wrap_u, wrap_v)` β€” the address mode is part of the identity because it selects a different - /// `Image` upload (decision 0763). + /// wrap_u, wrap_v)`; the address mode selects a different `Image` upload. Textured(String, ModelBlend, bool, u8, u16, bool, bool, bool, bool), Fallback(bool), } -/// Normalize an internal asset path so case/slash variants of the same file share a cache entry -/// (MPQ lookup is case-insensitive and accepts either slash). +/// Fold case and slashes so variants of one file share a cache entry, as MPQ lookup does. pub fn normalize_path(path: &str) -> String { path.replace('/', "\\").to_ascii_lowercase() } -/// The **cache key** a UI sprite reference folds to: [`normalize_path`], then an extension of -/// **exactly three characters** stripped β€” a dot at `len - 4`. -/// -/// That is the reference's own rule (`0x449590`, which strips a 3-char extension and returns which -/// of `{".tga", ".blp"}` it was), and its texture cache is keyed on the resulting stem β€” so -/// `Foo`, `Foo.blp` and `Foo.tga` are **one** entry, not three. Ours keys the same way, which is -/// why this is separate from [`sprite_candidates`]: the key identifies the texture, the candidates -/// are the files that might hold it. -/// -/// Note a 3-char extension is stripped whatever it is: `Foo.bmp` keys as `Foo`. A longer or shorter -/// one is not an extension to the client at all β€” `Foo.jpeg` keys as `Foo.jpeg` and looks for -/// `Foo.jpeg.blp`. +/// The cache key a UI sprite reference folds to: [`normalize_path`], then any three-character +/// extension stripped, as the reference strips it (`0x449590`) and keys its texture cache on the +/// stem. `Foo`, `Foo.blp` and `Foo.tga` are one entry; `Foo.jpeg` keys as itself. pub fn sprite_key(path: &str) -> String { let key = normalize_path(path); match key.len().checked_sub(4) { @@ -142,28 +78,10 @@ pub fn sprite_key(path: &str) -> String { } } -/// The two archive names a UI sprite reference may live under, **in the order the reference tries -/// them** β€” `TextureCreate`'s fixed two-candidate chain (`0x449d90`, extension table `0x835248 = -/// {".tga", ".blp"}`). The supplied extension's pipeline goes first, the other second; both open -/// through the same Storm primitive, and a miss on the first is silent, so the second is a genuine -/// fallback rather than an error path. -/// -/// | reference | first | second | -/// |---|---|---| -/// | `Foo` (no 3-char extension) | `Foo.blp` | `Foo.tga` | -/// | `Foo.blp` | `Foo.blp` | `Foo.tga` | -/// | `Foo.tga` | `Foo.tga` | `Foo.blp` | -/// | `Foo.bmp` (any other 3-char) | `Foo.blp` | `Foo.tga` | -/// | `Foo.jpeg` (not 3 chars) | `Foo.jpeg.blp` | `Foo.jpeg.tga` | -/// -/// This chain is what makes `Interface\Icons\Ability_Druid_Mangle.tga` β€” a real macro-chooser entry, -/// since `Ability_Druid_Mangle.tga.blp` ships and the chooser stores names extension-stripped β€” -/// resolve to `…Mangle.blp` on the second try instead of drawing a white square (bug B221). The old -/// rule here passed an extensioned path through untouched and had no second try at all. -/// -/// One function, used by the decoders below *and* by the sweeps that assert our own paths resolve -/// (`ui_script::shipped_xml_tests`, `ui_macro::tests`): a sweep re-implementing this could agree -/// with itself while disagreeing with what actually draws. +/// The two files a UI sprite reference may live in, in the order of `TextureCreate`'s two-candidate +/// chain (`0x449d90`, extension table `0x835248 = {".tga", ".blp"}`): `.tga` first for a `.tga` +/// reference, `.blp` first otherwise, and a miss on the first is silent. The sweeps that assert our +/// own paths resolve call this too, so they cannot disagree with what draws. pub fn sprite_candidates(path: &str) -> [String; 2] { let stem = sprite_key(path); let normalized = normalize_path(path); @@ -175,15 +93,9 @@ pub fn sprite_candidates(path: &str) -> [String; 2] { } } -/// The **texel dimensions** of a UI sprite reference, or `None` when nothing resolves β€” the number -/// the client's `CSimpleTexture` size getters read out of `[tex+0x144]`/`[tex+0x148]` when a region -/// authored no size on an axis (`0x770720`/`0x770790`, decision 1349: one texel is one FrameXML -/// unit). -/// -/// It goes through [`decode_sprite`] rather than sniffing a header, so the size layout uses is the -/// size the screen shows: the same candidate order, the same BLP-vs-TGA content sniff, and the same -/// verdict on a candidate that reads but will not decode. The caller memoises β€” this is asked once -/// per distinct path, for the handful of regions whose rect is derived from their art. +/// The texel size of a UI sprite, which `CSimpleTexture`'s size getters read (`[tex+0x144]`, +/// `[tex+0x148]`; `0x770720`, `0x770790`) for a region with no authored size, one texel per +/// FrameXML unit. Decoded, so layout gets the size the screen shows. pub fn sprite_dimensions( chain: &Mutex, loose_root: Option<&Path>, @@ -192,32 +104,9 @@ pub fn sprite_dimensions( decode_sprite(chain, loose_root, path).map(|(w, h, _)| (w, h)) } -/// Decode a UI sprite key to RGBA8, **warning once per key that fails to resolve**. -/// -/// Every sprite cache below stores misses as well as hits, and each calls this on the miss path -/// *before* inserting β€” so the warning is self-limiting by construction (one line per distinct key -/// for the process's life), with no separate "already warned" set to keep. -/// -/// It exists because the renderer's fallback for an unresolvable path is **silent**. It used to be -/// silent *and* look like art: a `Texture` region whose file could not be found still pushed a -/// quad, which `ui_pass` drew with the shared 1Γ—1 white image tinted white β€” an opaque WHITE -/// RECTANGLE at the region's exact rect, which is how bug B221's macro-chooser icons looked. That -/// half is gone (`ui_script::extract` now drops the quad outright when the resolve misses), so the -/// miss draws *nothing* rather than a white square. It is no less silent for that: nothing on -/// screen and nothing in Lua says why, and `shipped_xml_tests` sweeps only static XML `file=` -/// attributes, never a path that arrives at runtime from a DBC or from an addon. So the report -/// still belongs here. -/// -/// Walks [`sprite_candidates`] in order and takes the first that both reads and decodes; only when -/// **all** fail is it a miss. A candidate that reads but won't decode falls through exactly like -/// one that isn't there β€” the reference's two loaders both fail silently, so a bad first candidate -/// can't poison the second. -/// -/// Each candidate is asked of **two stores**: the patch chain, then β€” for `Interface\AddOns\` -/// paths β€” the loose addon folder ([`loose_addon_file`], decision 1322). The chain never holds an -/// `AddOns\` path (Blizzard's own `Blizzard_*` stubs aside, addon art only exists on disk), so the -/// order between the stores is unobservable; chain-first keeps every non-addon path on exactly the -/// code it always ran. +/// Decode the first of [`sprite_candidates`] that reads and decodes, from the chain and then the +/// AddOns folder, as the reference's two loaders fail silently. A miss warns, once per key since +/// every cache stores its misses: a missing sprite draws nothing, and nothing else says why. fn decode_sprite( chain: &Mutex, loose_root: Option<&Path>, @@ -240,10 +129,7 @@ fn decode_sprite( } } } - // **"Not there" and "there but would not decode" are different faults, and saying only the - // first sends the reader hunting a path that is sitting on disk.** Three colour-mapped TGAs in - // the addon corpus read fine and failed to decode for years while this line asserted they did - // not resolve (decision 2128). + // "Not there" and "there but would not decode" are different faults; say which. let found_but_undecodable: Vec<&String> = candidates .iter() .filter(|c| { @@ -275,10 +161,8 @@ fn decode_sprite( None } -/// Decode one candidate's bytes to RGBA8, picking the format by content: a `BLP2` magic is a BLP, -/// anything else is offered to the TGA decoder (TGA has no magic; its header validation is the -/// gate). Content, not extension, because the ecosystem mislabels freely β€” a BLP named `.tga` and -/// the reverse both ship in real addon folders, and the pixel bytes always know what they are. +/// Decode by content, not extension, since addon folders mislabel both ways: a `BLP2` magic is a +/// BLP, anything else goes to the TGA decoder, whose header check is the gate. fn decode_sprite_bytes(bytes: &[u8]) -> anyhow::Result<(u32, u32, Vec)> { if bytes.starts_with(b"BLP2") { blp_to_rgba(bytes) @@ -287,23 +171,9 @@ fn decode_sprite_bytes(bytes: &[u8]) -> anyhow::Result<(u32, u32, Vec)> { } } -/// Map a **normalized** addon-relative path (`interface\addons\\<…>`, lowercase, -/// backslashed β€” [`normalize_path`] has run) onto a file under the loose addon root, or `None` if -/// it is not an `Interface\AddOns\` path or nothing is there. -/// -/// **Not sprite-specific, and named for that** (decision 2103): 1322 built this for addon-shipped -/// Read one file from the **two stores the client's file layer has**: the patch chain, then β€” for -/// an `Interface\AddOns\` path β€” the loose addon folder ([`loose_addon_file`]). -/// -/// The rule shared by every by-path asset an addon can ship: art (the sprite decoder), fonts (the -/// face loader) and **audio** (`PlayMusic`/`PlaySoundFile`) all reach a file the same way, and -/// three copies of that would be three things to keep in step β€” the font leg was already a -/// hand-rolled second copy of the sprite leg's order when audio became the third caller. -/// -/// The reference asks the install tree *before* the archive (`0x647e60`'s attempt #4 is the MPQ); -/// the order is flipped here for the same reason [`decode_sprite`] flips it, and it is -/// unobservable: the chain carries no `AddOns\` path, so every non-addon read stays on exactly the -/// code it always ran. +/// Read a file from the patch chain, then, for an `Interface\AddOns\` path, the loose addon folder: +/// how art, fonts and audio an addon ships all load. The reference asks the install tree first +/// (`0x647e60`'s attempt #4 is the MPQ); the chain holds no `AddOns\` path, so the order is moot. pub fn read_chain_or_loose( chain: &Mutex, loose_root: Option<&Path>, @@ -316,15 +186,9 @@ pub fn read_chain_or_loose( std::fs::read(file).ok() } -/// BLP/TGA art, and an addon's own TTFs reach the client by exactly the same route β€” a loose file -/// under the one AddOns root, named by a virtual `Interface\AddOns\…` path no MPQ carries. One -/// resolver, one prefix rule, one sandbox. -/// -/// The walk matches each component **case-insensitively** (exact join first β€” free on the -/// case-insensitive filesystems macOS installs default to β€” then a `read_dir` scan): the reference -/// is a Windows client and addons reference their own art in arbitrary case. The candidate cannot -/// escape the root β€” `normalize_path` leaves no `/`, and any dot-component is refused before a -/// filesystem call happens (same lexical posture as `ui_script::addons::read_under`). +/// Map a normalized `interface\addons\…` path onto a file under the addon root, matching each +/// component case-insensitively as the Windows reference does; a dot-component is refused, so a +/// path cannot escape the root. pub fn loose_addon_file(root: &Path, candidate: &str) -> Option { let rel = candidate.strip_prefix("interface\\addons\\")?; let mut at = root.to_path_buf(); @@ -347,14 +211,9 @@ pub fn loose_addon_file(root: &Path, candidate: &str) -> Option { at.is_file().then_some(at) } -/// Mutate an asset only when `differs` says its current state doesn't already match. -/// -/// `Assets::get_mut` alone marks the asset Modified β€” a uniform re-upload and (on the Metal -/// non-bindless path) a bind-group rebuild that frame β€” so a per-frame writer re-pushing an -/// unchanged value is pure cost (the teleport leak's CPU engine was exactly this shape; the sky -/// family idled at ~17 such writes per frame). The gate decides on the immutable view; pair it -/// with values quantized to display precision ([`quantize`]/[`quant255`]) or continuous inputs -/// defeat the compare. +/// Mutate an asset only when `differs` says it does not already match: `Assets::get_mut` alone +/// marks it Modified, a uniform re-upload and, on Metal's non-bindless path, a bind-group rebuild. +/// Pair it with values quantized to display precision ([`quantize`], [`quant255`]). pub fn write_gated( assets: &mut Assets, handle: &Handle, @@ -368,17 +227,13 @@ pub fn write_gated( } } -/// Quantize to `n`-ths β€” the write-gate's precision floor for continuously-drifting inputs -/// (time-of-day bands, camera-aim envelopes). Choose `n` at or below what the output can show: -/// the reference packs its celestial/sky lanes to bytes (`floor(255·…)`, the 0xFF broadcasts) -/// and re-submits them as per-frame vertex data, so a 1/255 step IS the reference's own -/// precision for color/alpha; geometry scalars gate at a sub-pixel step instead. +/// Quantize to `n`ths, the write gate's floor for drifting inputs: 255 for colour ([`quant255`]), +/// a sub-pixel step for geometry. pub fn quantize(x: f32, n: f32) -> f32 { (x * n).round() / n } -/// [`quantize`] each channel to the display's 1/255 LSB β€” the byte precision the reference's own -/// color lanes carry. +/// [`quantize`] each channel to 1/255: the reference packs its sky and celestial colours to bytes. pub fn quant255(c: [f32; 3]) -> [f32; 3] { [ quantize(c[0], 255.0), @@ -387,13 +242,8 @@ pub fn quant255(c: [f32; 3]) -> [f32; 3] { ] } -/// Lock a `Mutex`, recovering the guard even if a previous holder **poisoned** it by panicking. -/// -/// The shared [`WorldAssets::chain`] is read from many systems; one panic mid-read would otherwise -/// poison the lock and turn every subsequent `lock().unwrap()` β€” including the render loop's β€” into a -/// cascade. The chain read is a best-effort decode (a half-read texture is harmless), so we recover -/// the guard and carry on. (Phase 3 deleted the net subsystem's shared mutexes entirely; this remains -/// only for the asset foundation, which is its one legitimate user.) +/// Lock a `Mutex`, recovering the guard if a holder panicked, so one failed read of the shared +/// chain does not fail every later lock; a half-read texture is harmless. pub trait LockRecover { fn lock_recover(&self) -> MutexGuard<'_, T>; } @@ -405,13 +255,8 @@ impl LockRecover for Mutex { } impl WorldAssets { - /// Open the store over an already-opened patch chain. - /// - /// `shared_light` is the one global-light storage buffer, taken as a **`Buffer`** β€” a raw wgpu - /// handle β€” and not as the client's `SharedLightBuffer`. That is the whole reason this crate can - /// sit under the renderer: the store's only use of it is `clone()` into every deduped model - /// material's `light_buf`, so the parameter severs what would otherwise be a dependency on the - /// lighting layout and, through it, the rig-palette regions (decision 1164). + /// Open the store over an opened patch chain. `shared_light` is a raw `Buffer`, not the + /// client's `SharedLightBuffer`, which keeps this crate below the renderer. pub fn open(chain: Chain, shared_light: Buffer) -> Self { Self { chain: Arc::new(Mutex::new(chain)), @@ -429,16 +274,14 @@ impl WorldAssets { } } - /// Install (or clear) the loose-file root for `Interface\AddOns\` sprite paths β€” see the - /// [`Self::loose_root`] field doc. Called by the app once it knows the AddOns folder; evicts - /// cached **misses** so a path asked before the root existed gets a second look (a cached hit - /// can only have come from the chain and stays right). - /// [`read_chain_or_loose`] over this store's own two halves β€” the by-path read for an asset - /// class with no decoder of its own here (audio: `PlayMusic`, `PlaySoundFile`). + /// [`read_chain_or_loose`] over this store, for an asset class with no decoder here (audio: + /// `PlayMusic`, `PlaySoundFile`). pub fn read_file_or_loose(&self, path: &str) -> Option> { read_chain_or_loose(&self.chain, self.loose_root.as_deref(), path) } + /// Install or clear the AddOns folder. Cached misses are evicted so a path asked before it + /// existed gets a second look; a cached hit came from the chain and stays right. pub fn set_loose_addon_root(&mut self, root: Option) { if self.loose_root == root { return; @@ -450,8 +293,7 @@ impl WorldAssets { self.masks.retain(|_, v| v.is_some()); } - /// Decode + upload a BLP once per path; later requests for the same texture share the handle. - /// `None` if the texture is missing or fails to decode. + /// Decode and upload a BLP once per path and wrap; later requests share the handle. pub fn texture( &mut self, path: &str, @@ -462,33 +304,22 @@ impl WorldAssets { if let Some(handle) = self.textures.fetch(&key) { return Some(handle); } - // Passthrough: world art is uploaded in the form the BLP stores it (decision 1626). - // Nothing reads a world texture main-side β€” the same reasoning that already makes this - // lane `RenderAssetUsages::RENDER_WORLD` β€” so there are no CPU texels to lose. + // World art uploads as the BLP stores it; nothing reads it main-side. let chain = read_texture_native_chain(&mut self.chain.lock_recover(), &key.0).ok()?; let handle = images.add(repeat_texture_authored(crate::for_upload(chain), wrap)); self.textures.insert(key, handle.clone()); Some(handle) } - /// Decode a BLP into a clamp-sampled **sRGB** sprite image (mip 0 only) β€” the celestial discs - /// (sun/moon) and every player-UI quad. Unlike [`Self::texture`] (repeat-sampled, gamma-space - /// `Unorm`, mipmapped β€” for tiling world art), this is sRGB + clamp-to-edge with no mip chain. - /// Extensionless references get `.blp` appended (the FrameXML convention). Cached by resolved - /// path β€” hits and misses both β€” in its own `sprites` map, separate from the `textures` cache - /// (the same BLP can live in both, with different sampling). + /// A clamp-sampled sRGB sprite, mip 0 only: the celestial discs and every UI quad. pub fn sprite_texture( &mut self, path: &str, images: &mut Assets, ) -> Option> { - // FrameXML/DBC texture references are canonically EXTENSIONLESS - // (`Interface\Buttons\UI-Quickslot2`) β€” the real client appends `.blp` at resolve. Paths - // that already carry an extension (`textures\moon.blp`) pass through unchanged. let key = sprite_key(path); - // Cached (hits AND misses): the UI extractor asks per quad per frame β€” uncached, every - // frame re-decoded every BLP into a fresh `Image` (unbounded asset growth) and the fresh - // handles defeated the quad-list dirty check. + // Hits and misses are cached: the UI extractor asks per quad per frame, and a fresh handle + // would defeat its dirty check. if let Some(cached) = self.sprites.get(&key) { return cached.clone(); } @@ -498,24 +329,13 @@ impl WorldAssets { loaded } - /// Decode a texture to raw RGBA8 `(w, h, bytes)` **without** uploading a GPU image β€” for callers - /// that resample the pixels themselves before upload (the V-plate border sharpen, decision 0188, - /// which resizes the 128Γ—32 frame to the plate's exact size). Same extensionlessβ†’`.blp` resolve - /// as [`Self::sprite_texture`]; not cached (a one-shot decode at load, not a per-frame ask). + /// Decode a UI sprite to raw RGBA8 without uploading or caching, for a caller that resamples. pub fn decode_rgba(&mut self, path: &str) -> Option<(u32, u32, Vec)> { decode_sprite(&self.chain, self.loose_root.as_deref(), path) } - /// A UI sprite **resampled to an exact pixel size** by the caller's own kernel, cached by that - /// size β€” the nameplate border (decision 0188). - /// - /// The plate's frame art is a 128 Γ— 32 BLP drawn at whatever size the plate is, which past the - /// 1024Γ—768 knee (and always on a retina framebuffer) is a magnification: the GPU's bilinear - /// filter smears the 1 px gold bevel across several soft output pixels, which is the director's - /// "blurry border". Resampling the SAME pixels to the target size with a sharp kernel keeps the - /// art and loses the smear. This lives here rather than in the plate driver because since - /// decision 2148 the plate is a widget like any other and its border is an ordinary texture - /// region β€” the substitution has to happen where a texture path becomes an image. + /// A UI sprite resampled to an exact pixel size by the caller's kernel, cached by size: the + /// nameplate border, whose 1 px bevel bilinear magnification would smear. pub fn resampled_sprite( &mut self, path: &str, @@ -538,10 +358,7 @@ impl WorldAssets { made } - /// A UI sprite decoded with **repeat** (wrap) addressing on both axes β€” the frame `Backdrop` - /// tiled pieces (`0x77f0c0`): a border edge strip samples UVs `[0..N]` and a tiled - /// bg `[0..w/period]`, so the texture must wrap, not clamp. [`Self::sprite_texture_wrapped`] - /// with both axes on; see there for the cache and the decode. + /// A UI sprite repeating on both axes, for a frame `Backdrop`'s tiled pieces (`0x77f0c0`). pub fn sprite_texture_tiled( &mut self, path: &str, @@ -550,13 +367,7 @@ impl WorldAssets { self.sprite_texture_wrapped(path, (true, true), images) } - /// A UI sprite decoded with **repeat** addressing on the axes `wrap` names and clamp on the - /// rest β€” the reference's one-axis tiling idiom (`SetTexCoord(0, n, 0, 1)` on the stance - /// shelf's middle strip) wraps along its length and must clamp across it, or the strip's - /// bottom row bleeds into its top edge (decision 2000; [`sprite_image_wrapped`]). Same - /// sRGB/no-mip decode + extensionlessβ†’`.blp` resolve as [`Self::sprite_texture`], but its - /// own cache keyed by the wrap pair (the sampler is baked into the `Image`, so a path wanted - /// under two address modes needs two GPU images). Cached hits **and** misses. + /// A UI sprite repeating on the axes `wrap` names ([`sprite_image_wrapped`]). pub fn sprite_texture_wrapped( &mut self, path: &str, @@ -573,17 +384,8 @@ impl WorldAssets { loaded } - /// A sprite whose pixels this client **computes**, cached under `key` and built at most once. - /// `make` returns `(width, height, rgba8)` and runs only on the first ask. - /// - /// The customer is the colour picker: the reference's `` and - /// `` carry no `file=` at all, because the hue disc and the brightness ramp - /// are generated by the client and no BLP in the chain is either of them. The generation itself - /// deliberately stays with the caller β€” the pixel law belongs to the widget, not to the asset - /// layer, which knows only how to cache and upload. - /// - /// Uploaded through [`sprite_image`], so it samples exactly like every other UI sprite: - /// sRGB, clamp-to-edge, linear, no mips. + /// A sprite whose pixels the client computes, built once by `make`: the colour picker's + /// `` and ``, which name no `file=`. pub fn generated_sprite( &mut self, key: &'static str, @@ -599,10 +401,7 @@ impl WorldAssets { handle } - /// A **portrait** sprite: [`Self::sprite_texture`]'s clamp/sRGB decode with a circular alpha mask - /// baked in ([`portrait_image`]) β€” the unit-frame portrait, round like the client's. Same - /// extensionlessβ†’`.blp` resolve; its own cache (the mask bakes into the GPU image, so a path - /// wanted both plain and portrait needs two images). Cached hits **and** misses. + /// A unit-frame portrait, the circular mask baked in ([`portrait_image`]). pub fn portrait_texture( &mut self, path: &str, @@ -618,11 +417,9 @@ impl WorldAssets { loaded } - /// The tabard designer's emblem cell (decision 1977): the emblem BLP decoded and rewritten to - /// **white carrying its own alpha** β€” `(a << 24) | 0x00FFFFFF` per texel, the reference's - /// `0x503431` loop β€” as an ordinary clamped sprite the caller tints. Cached per path like a - /// sprite; a file that fails to open yields `None` (the reference installs its static array's - /// stale contents instead; nothing here has a previous emblem to show). + /// The tabard designer's emblem cell for the caller to tint: white carrying its own alpha, + /// `(a << 24) | 0x00FFFFFF` per texel (the reference's `0x503431` loop). A file that fails to + /// open gives `None`, where the reference keeps its static array's stale contents. pub fn emblem_mask_texture( &mut self, path: &str, @@ -645,9 +442,7 @@ impl WorldAssets { loaded } - /// A coverage **mask** ([`mask_image`] β€” linear/clamp, the texel bytes handed to the shader - /// 1:1): the minimap's `MinimapMask.blp` circle (decision 0203, [`crate::ui_pass::UiQuadMask`]). - /// Same extensionlessβ†’`.blp` resolve as [`Self::sprite_texture`]; its own cache, hits AND misses. + /// A coverage mask, its bytes handed to the shader 1:1 ([`mask_image`]): the minimap's circle. pub fn mask_texture( &mut self, path: &str, @@ -663,10 +458,8 @@ impl WorldAssets { loaded } - /// Decode a cursor BLP (e.g. `Interface\Cursor\Point.blp`) into a single-mip GPU image for - /// winit's custom OS cursor (`CursorIcon::Custom`) β€” *not* the mipmapped, repeat-sampled - /// [`repeat_texture_authored`] used for world art. `None` if the file is missing or fails to decode. - /// macOS doesn't use this (it drives `NSCursor` from the raw RGBA directly β€” see `crate::cursor`). + /// Decode a cursor BLP (`Interface\Cursor\Point.blp`) for winit's custom OS cursor; macOS + /// drives `NSCursor` from the raw RGBA instead. #[cfg(not(target_os = "macos"))] pub fn decode_cursor( &mut self, @@ -681,9 +474,8 @@ impl WorldAssets { Some(images.add(cursor_texture(w, h, rgba))) } - /// A `StandardMaterial` for a model submesh, deduped by (texture, blend) so submeshes/models - /// sharing a texture share one material handle (enabling draw-call batching). A missing/failed - /// texture falls back to a single shared untextured material. + /// A model submesh material, deduped by [`MaterialKey`] so submeshes sharing a texture share a + /// handle and batch; a missing texture falls back to one shared untextured material. pub fn model_material( &mut self, texture: Option<&str>, @@ -698,10 +490,9 @@ impl WorldAssets { images: &mut Assets, materials: &mut Assets, ) -> Handle { - // Alpha-test cutoff: callers may override (ground clutter uses the detail-doodad ref β‰ˆ 0.5); - // everything else uses the general vanilla model alpha key. + // Ground clutter passes its own cutoff (128/255); everything else takes the M2 alpha key. let cutoff = alpha_cutoff.unwrap_or(VANILLA_ALPHA_KEY_REF); - // Ground-clutter distance fade (None for normal models). Near = 0.75Γ— far (the client's ratio). + // Ground-clutter fade: near is 0.75 of far, the client's ratio. let clutter_fade = match fade_far { Some(far) => Vec4::new(far * 0.75, far, 0.0, 1.0), None => Vec4::ZERO, @@ -710,7 +501,7 @@ impl WorldAssets { self.texture(t, wrap, images) .map(|h| (normalize_path(t), h)) }); - // Only AlphaTest consumes the cutoff, so don't fragment opaque/blend materials by it. + // Only AlphaTest reads the cutoff, so opaque and blend materials do not split on it. let key_alpha = if blend == ModelBlend::AlphaTest { (cutoff * 255.0).round() as u8 } else { @@ -734,21 +525,15 @@ impl WorldAssets { if let Some(handle) = self.model_materials.fetch(&key) { return handle; } - // Per-submesh blend (opaque trunk/wall vs alpha-cut leaves/windows). This builder serves - // ground clutter, which never authors the multiply modes β€” Mod/Mod2x fall to plain Blend - // here rather than growing this path the marker bits `model_render::model_material` packs - // (that builder is the one every Mod-capable consumer uses; decision 0528). + // This builder serves ground clutter, which never authors Mod/Mod2x; every Mod-capable + // consumer uses `model_render::model_material`. let alpha_mode = match blend { ModelBlend::Opaque => AlphaMode::Opaque, ModelBlend::AlphaTest => AlphaMode::Mask(cutoff), ModelBlend::Blend | ModelBlend::Mod | ModelBlend::Mod2x => AlphaMode::Blend, }; - // Single-sided unless the M2's 0x04 flag is set β€” matches the real client (many canopy planes - // are visible from one direction only). + // Single-sided unless the M2's 0x04 flag is set, as in the reference. let cull_mode = if two_sided { None } else { Some(Face::Back) }; - // The base StandardMaterial carries texture/alpha/cull; our `wow_model.wgsl` extension does - // the WoW lighting (PBR fields like reflectance/roughness are ignored by it). Light uniforms - // are placeholders β€” `apply_wow_lighting` fills them from Light.dbc + the panel knobs. let base = match resolved { Some((_, image)) => StandardMaterial { base_color_texture: Some(image), @@ -757,8 +542,7 @@ impl WorldAssets { cull_mode, ..default() }, - // No resolved texture β†’ untextured WHITE draw (stage disabled in the reference β€” - // `0x59d020`; see model_render.rs for the full note). + // No texture: an untextured white draw, the reference's disabled stage (`0x59d020`). None => StandardMaterial { base_color: Color::WHITE, ..default() @@ -768,21 +552,18 @@ impl WorldAssets { base, extension: WowModelExt { clutter_fade, - // x = WMO (FFP NΒ·L Γ— MOCV, not SH); y = distance-fade blend variant (depth-write-on - // via `specialize` β€” see WowModelKey; no live caller passes it here). + // x = WMO (FFP NΒ·L Γ— MOCV), y = the fade blend twin (no caller passes it here). model_flags: Vec4::new( if is_wmo { 1.0 } else { 0.0 }, if is_fade_variant { 1.0 } else { 0.0 }, 0.0, 0.0, ), - // x = doodad-lobe sun scale; `1.0` (lit) here β€” this builder serves clutter, which lights on - // the FFP NΒ·L path with its own baked MCSH tint, so the lobe's `sun_scale` is unused. + // Clutter lights on the FFP NΒ·L path with its own MCSH tint; no selector. sun_scale: Vec4::new(1.0, 0.0, 0.0, 0.0), - tint: Vec4::ONE, // clutter never carries an animated M2Color tint (w: not a WMO batch) + tint: Vec4::ONE, // clutter has no animated M2Color tint and is not a WMO batch sidn: Vec4::ZERO, // clutter is never SIDN/WINDOW glass (WMO-only) anim_slots: Vec4::ZERO, - // The shared global light (light/fog/SH come from here, updated in place each frame). light_buf: self.shared_light.clone(), }, }); @@ -791,25 +572,18 @@ impl WorldAssets { } } -/// World/render configuration read once at startup from the environment, shared by the subsystems -/// that need it: terrain reads `unload_budget` (the release lane). -/// Inserted by [`AssetPlugin`] alongside [`WorldAssets`]; its *presence* is also the "there is a -/// client install" gate the world-side setups key on. -/// -/// How far terrain streams is deliberately **not** here: the residency window derives from the -/// live `farclip` view distance (`benilla_world::view::ViewDistance` β€” the player's Terrain -/// Distance setting), as the reference derives it (decision 1513). +/// Render configuration read once from the environment at startup, inserted beside +/// [`WorldAssets`]; its presence is the "there is a client install" gate world setups key on. +/// Terrain residency is not here: it derives from the live `farclip`, as in the reference. #[derive(Resource, Clone, Copy)] pub struct RenderConfig { - /// Stale tiles released per frame on a within-map window shift (`$WOW_TILE_UNLOAD`, - /// default 1; `0` = unbudgeted β€” the whole trailing row in one frame, the pre-B181 - /// behaviour, kept as the controlled A/B leg on one build). + /// Stale tiles released per frame on a within-map window shift (`$WOW_TILE_UNLOAD`, default 1; + /// `0` releases the whole trailing row in one frame, the unbudgeted A/B leg). pub unload_budget: usize, } -/// Startup ordering seam: [`AssetPlugin`] opens the patch chain in this set so every other -/// subsystem's startup (catalog loads, terrain/lighting setup) can run `.after(AssetSet::Open)` and -/// borrow the shared [`WorldAssets`]. +/// Startup ordering: the patch chain opens in [`AssetSet::Open`], so other startup systems run +/// after it and borrow [`WorldAssets`]. #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum AssetSet { Open, @@ -819,9 +593,6 @@ pub enum AssetSet { mod tests { use super::*; - /// The disk tree spells its names in mixed case, the candidate arrives normalized-lowercase - /// (as [`sprite_candidates`] always hands it over) β€” the walk must land on the file anyway, - /// because the reference is a Windows client and addon authors never matched their own case. #[test] fn loose_addon_file_maps_addon_paths_case_insensitively() { let root = @@ -830,9 +601,7 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("BlackrockDepths.blp"), b"x").unwrap(); - // The returned SPELLING is filesystem-dependent (a case-insensitive filesystem answers - // the exact-join fast path with the candidate's own casing), so the claim is that the - // path opens the right file, not how it is spelt. + // The returned spelling depends on the filesystem's case sensitivity, so check the file. let hit = loose_addon_file( &root, "interface\\addons\\atlas\\images\\maps\\blackrockdepths.blp", @@ -840,7 +609,6 @@ mod tests { .expect("mixed-case tree resolves a lowercase candidate"); assert_eq!(std::fs::read(&hit).unwrap(), b"x"); - // Not an AddOns path β†’ not this store's question. assert_eq!(loose_addon_file(&root, "interface\\icons\\foo.blp"), None); // A directory is not a file, and a missing file is a miss, not an error. assert_eq!(loose_addon_file(&root, "interface\\addons\\atlas"), None); @@ -848,8 +616,7 @@ mod tests { loose_addon_file(&root, "interface\\addons\\atlas\\images\\maps\\nope.blp"), None ); - // Dot-components never reach the filesystem (the read_under posture; `normalize_path` - // already forbids `/`, so this is the one lexical escape left to refuse). + // Dot-components never reach the filesystem; `normalize_path` already leaves no `/`. assert_eq!( loose_addon_file(&root, "interface\\addons\\..\\..\\etc\\passwd"), None @@ -858,14 +625,8 @@ mod tests { std::fs::remove_dir_all(&root).ok(); } - /// **The by-path read every addon-shipped asset resolves by** β€” chain first, then the one - /// loose folder ([`read_chain_or_loose`]). Audio is that rule's third caller, after art and - /// fonts, and an addon's own track is exactly the file no MPQ can hold: this leg is the whole - /// reason `PlayMusic("Interface\\AddOns\\…")` can make a sound at all. - /// - /// Against the **real** chain, because both legs matter β€” a client file must still come from - /// the archive, and only a real chain can show the loose folder is a *fallback* rather than a - /// shadow over it. + /// Against the real chain: a client file must still come from the archive, and the loose + /// folder is a fallback, not a shadow over it. #[test] fn read_chain_or_loose_asks_the_chain_then_an_addons_own_file_on_disk() { let data = benilla_formats::wow_data_or_skip!(); @@ -897,7 +658,7 @@ mod tests { ); // In neither store: a plain miss, which every caller renders as silence. assert!(read_chain_or_loose(&chain, Some(&addons), "Sound\\Music\\nope.mp3").is_none()); - // No folder configured (a capture run β€” `local_state` is hermetic there): chain only. + // No folder configured (a capture run, where `local_state` is hermetic): chain only. assert!( read_chain_or_loose(&chain, None, "Interface\\AddOns\\Jukebox\\Track.mp3").is_none() );