Comments in benilla-assets: one line of fact each, no history, no record numbers (#95)

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) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-24 16:27:58 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d936857673
commit d69d09dfce
25 changed files with 1198 additions and 3503 deletions
@@ -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 -- <map> <x> <y> <z> [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 `<stem>_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, `<stem>_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<Vec3> = 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<f32> {
let p = Vec2::new(pin.x, pin.z);
let [a, b, c] = tri.map(|v| Vec2::new(v.x, v.z));
+33 -94
View File
@@ -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<ChunkShading>,
/// `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<Image>,
/// `texture_2d_array` of per-chunk alpha (blend-weight) maps.
pub alpha_array: Handle<Image>,
@@ -60,25 +37,20 @@ pub struct AdtTile {
pub doodads: Vec<Doodad>,
/// WMO building placements (raw WoW coords).
pub wmos: Vec<WmoInstance>,
/// 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<ChunkMesh>,
}
/// 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<Mesh> {
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<Mesh> {
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<Mesh> {
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<RawLayer> = Vec::new();
let mut layer_index: HashMap<String, u32> = HashMap::new();
@@ -201,21 +155,14 @@ impl AssetLoader for AdtLoader {
let mut shadow_buf: Vec<u8> = 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<ChunkShading> = 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<RawLayer> {
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<RawLayer> {
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 {
+18 -62
View File
@@ -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<u16> = (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();
+28 -86
View File
@@ -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<X>_<Y>` 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<X>_<Y>` 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<u8>) -> Image {
let mut image = Image::new(
Extent3d {
@@ -199,29 +162,14 @@ fn sprite_image(width: u32, height: u32, rgba: Vec<u8>) -> 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<u8>) -> 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<u8>) -> Image {
let mut image = Image::new(
Extent3d {
@@ -244,9 +192,7 @@ fn effect_image(width: u32, height: u32, rgba: Vec<u8>) -> 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<u8>) -> Image {
Image::new(
Extent3d {
+41 -136
View File
@@ -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<u32>,
/// **Global** triangle indices, ascending within each cell.
/// Global triangle indices, ascending within each cell.
items: Vec<u32>,
}
/// 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<Level>,
/// 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<u32>,
}
/// `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<bool> = 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<Self> {
// 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<u32> = (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);
+23 -74
View File
@@ -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],
+22 -80
View File
@@ -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<bool> = 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);
+43 -109
View File
@@ -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<T>` — 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<Chain>,
}
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<Self> {
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<Box<PathStream>, 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: `<model-dir>\<name>.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, `<model-dir>/<name>.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<String> {
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::<M2Model>();
app.init_asset::<WmoModel>();
app.init_asset::<AdtTile>();
app.init_asset::<WdtIndex>();
// 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<Image>.
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::<Assets<Image>>().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::<Image>();
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::<AssetServer>().clone();
let sprite_h: Handle<Image> =
@@ -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::<Assets<Image>>().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::<AssetServer>()
.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::<Assets<WmoModel>>().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::<Image>();
app.init_asset::<Mesh>();
// 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::<bevy::mesh::skinning::SkinnedMeshInverseBindposes>();
app.init_asset::<bevy::animation::AnimationClip>();
app.init_asset::<bevy::animation::graph::AnimationGraph>();
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<M2Model> = app
.world()
.resource::<AssetServer>()
@@ -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::<Mesh>();
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<WmoModel> = app
.world()
.resource::<AssetServer>()
@@ -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<Mesh> = 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()
+111 -352
View File
@@ -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<Image>` **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<ModelSubmesh>,
/// 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<M2Bounds>,
/// 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<CollisionMesh>,
/// 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<ModelEmitter>,
/// 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<ModelRibbon>,
/// 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<ModelLight>,
/// 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<SkinnedMeshInverseBindposes>,
/// 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<ModelAnimations>,
/// 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<f32>,
/// **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<M2SequenceInfo>,
/// 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<ModelAttachment>,
/// 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<crate::ModelMarker>,
/// 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<PortraitCamera>,
/// 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<PortraitCamera>,
/// The camera table's **second** record — what a 1.12 `<PlayerModel>` 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 `<PlayerModel>` (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<PortraitCamera>,
/// The **whole camera table**, in file order — the raw index space a `<Model>` 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<PaneCamera>,
/// 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<Vec3>,
/// 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 `<PlayerModel>` (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 `<Model>` 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 `<Model>` 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<Arc<benilla_formats::M2CameraTracks>>,
}
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<Image>` 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<Handle<Image>>,
/// 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<EmitterBillboard>,
/// 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<Handle<M2Model>>,
/// 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<Handle<M2Model>>,
/// 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<Handle<Image>>,
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<ModelEmitter> = 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::<M2Model>(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<ModelRibbon> = 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<M2SequenceInfo> = 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<bevy::animation::graph::AnimationNodeIndex>; 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<u16> = (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 `<Model>` widget's `SetCamera(n)` space (2027).
let cameras: Vec<PaneCamera> = 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()));
+105 -350
View File
@@ -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: <https://wowdev.wiki/M2/Rendering> § 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<StandardMaterial, TerrainExtension>;
/// 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<StandardMaterial, WowModelExt>;
/// 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<storage, read> 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<Self>,
) -> 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<StandardMaterial, WdlExt>;
/// 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<StandardMaterial, LiquidExt>;
/// 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<Image>,
/// 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<uniform> 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<Image>,
#[texture(104, dimension = "2d_array", visibility(fragment))]
pub alpha_array: Handle<Image>,
/// 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<Image>,
// ------------------------------------------------------------------------------------------
// 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<storage, read> 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<f32>(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");
}
}
+11 -30
View File
@@ -1,36 +1,21 @@
//! The WMO **interior minimap** tile grid: how one WMO group's world footprint subdivides into the
//! `<wmo>_<group>_<X>_<Y>.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
//! `<wmo>_<group>_<X>_<Y>.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);
}
+120 -355
View File
@@ -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<BoneScaleAnim>,
/// 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<RenderSubmesh>,
/// 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<Handle<Image>>,
/// 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<u8>,
/// 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<CharSkinSlot>,
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<BillboardInfo>,
/// 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<std::sync::Arc<benilla_formats::AlphaAnim>>,
/// 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<std::sync::Arc<benilla_formats::UvAnim>>,
/// 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<std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>>,
/// 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<std::sync::Arc<benilla_formats::SeqLoops<[f32; 4]>>>,
/// The scaling twin of [`Self::uv_rot_seq`].
/// The scale loop per file sequence slot, likewise.
pub uv_scale_seq: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>>,
/// 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<std::sync::Arc<benilla_formats::RgbAnim>>,
/// 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<std::sync::Arc<benilla_formats::SeqLoops<[f32; 3]>>>,
/// 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<benilla_formats::WmoBatchClass>,
/// 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<benilla_formats::GroundQuad>,
}
/// 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<benilla_formats::BillboardKind>,
/// 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<benilla_formats::ParentArm>,
}
/// 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<ModelJoint>,
/// 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<u16>,
/// 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<u16>,
}
/// 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<Mat4>) {
let pivots = skeleton_pivots(skel);
let joints = skel
@@ -357,8 +241,7 @@ pub(crate) fn build_skeleton(skel: &Skeleton) -> (ModelSkeleton, Vec<Mat4>) {
})
.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<Mat4>) {
)
}
/// 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<Vec3> {
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<usize> {
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<usize>; 2] {
let roots = |lo: i16, hi: i16| -> Vec<usize> {
skel.bones
@@ -451,7 +317,7 @@ pub(crate) fn finger_subtree_roots(skel: &Skeleton) -> [Vec<usize>; 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<T: Animatable + Clone>(
duration: f32,
keys: Vec<(f32, T)>,
@@ -560,23 +411,10 @@ fn keyframe_curve<T: Animatable + Clone>(
}
}
/// 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<BillboardInfo> {
let bb = sub.billboard.as_ref()?;
Some(BillboardInfo {
@@ -691,8 +518,7 @@ pub(crate) fn billboard_info(sub: &RenderSubmesh) -> Option<BillboardInfo> {
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<BillboardInfo> {
})
}
/// 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<T> {
pub period: f32,
@@ -717,8 +541,7 @@ pub struct GlobalSeqChannel<T> {
}
impl<T: Copy> GlobalSeqChannel<T> {
/// 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<Quat> {
}
}
/// 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<GlobalSeqChannel<Vec3>>,
}
/// 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<RenderSubmesh>, 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<benilla_formats::BoneKeys>) -> 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"
+84 -263
View File
@@ -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<AnimationNodeIndex>,
/// 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<AnimationGraph>,
pub clips: Vec<AnimClip>,
/// 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<AnimationNodeIndex>; 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<PlayableAnim>,
/// 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<u16>,
/// 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<super::GlobalBone>,
/// 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<usize>,
/// 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<super::PoseSource>,
}
/// 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<u16>) -> 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<usize> {
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<PlayableAnim>) -> 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());
+18 -47
View File
@@ -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<T> {
times: Vec<f32>,
values: Vec<T>,
}
// 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<T> Default for PoseTrack<T> {
fn default() -> Self {
Self {
@@ -35,10 +26,8 @@ impl<T> Default for PoseTrack<T> {
}
impl<T: Animatable + Clone> PoseTrack<T> {
/// 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<T: Animatable + Clone> PoseTrack<T> {
}
}
/// 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<T> {
let times = &self.times;
@@ -88,7 +74,7 @@ impl<T: Animatable + Clone> PoseTrack<T> {
}
}
/// 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<Vec3>,
}
/// 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<PoseBone>,
}
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::<Vec3>::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 {
+64 -93
View File
@@ -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<f32>,
// x = renderer (`LiquidPath`): 0 = ADT MCLQ, 1 = WMO exterior, 2 = WMO interior; yzw reserved.
path: vec4<f32>,
// 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<f32>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(102) var<uniform> 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<f32>, // 0 rgb = ambient; w = Mod2x scale
light_diffuse: vec4<f32>, // 1 rgb = sun diffuse; w = clamp flag
@@ -74,14 +67,11 @@ struct LiquidVsOut {
@location(1) world_normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@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<f32>,
// 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<f32>,
// `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<f32>, world_pos: vec3<f32>) -> vec3<f32> {
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<f32>) -> vec2<f32> {
// 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<f32>(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<f32>, world_pos: vec3<f32>, room_fog: u32) -> vec3<f32> {
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<f32>, deep: vec4<f32>, i: f32, ocean: bool) -> vec4<f32> {
// 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<f32>(round(shallow.rgb * 255.0), floor(shallow.w * 255.0));
let c1 = vec4<f32>(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<f32> {
}
}
// 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<f32> {
return vec4<f32>(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<f32> {
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<f32>(0.0), vec3<f32>(1.0));
return vec4<f32>(
apply_fog(body, in.world_position.xyz, in.room_fog),
@@ -267,12 +239,11 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
// ---- 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<f32> {
return vec4<f32>(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<f32> {
// The `ocean0_s.bls` combine.
var rgb = primary * swatch.rgb + detail.rgb + (secondary + vec3<f32>(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);
+30 -39
View File
@@ -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<uniform> 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<f32>, // rgb = row 1 ambient; w = Mod2x scale (×1).
light_diffuse: vec4<f32>, // 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<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
let count = u32(wow_light.point_count.x);
var sel = array<u32, 3>(0u, 0u, 0u);
@@ -124,11 +118,11 @@ fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
// 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<f32>) -> vec3<f32> {
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<f32> {
// 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<f32>(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<f32> {
}
}
// 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<i32>(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<f32>(0.5 / 64.0), vec2<f32>(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<f32> {
// 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<f32> {
let spec_term = in.specular * specmask * shadow_lit;
var tuned = clamp(diffuse_term + spec_term, vec3<f32>(0.0), vec3<f32>(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<f32>(in.world_position.xyz, 1.0)).z;
let denom = max(wow_light.fog_params.y - wow_light.fog_params.x, 0.001);
@@ -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<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
// 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<f32>) -> vec3<f32> {
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<f32>(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
+25 -83
View File
@@ -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<K, Handle<_>>` 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<K, Handle<_>>` 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<K, V> {
map: HashMap<K, (V, Option<Stamp>)>,
}
/// 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<K, V> Default for SpatialCache<K, V> {
}
impl<K: Eq + Hash, V> SpatialCache<K, V> {
/// 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<V>
where
V: Clone,
@@ -103,22 +70,15 @@ impl<K: Eq + Hash, V> SpatialCache<K, V> {
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<u32, u32> = 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<u32, u32> = 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<u32, Vec<u32>> = 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<u32, u32> = 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<u32, u32> = 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<T>` 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<MaterialCache>`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<u32, u32> = SpatialCache::default();
+59 -164
View File
@@ -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<u8>,
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<u8>,
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<BlpTexels> {
if !bc_supported {
return None;
@@ -170,18 +117,14 @@ fn passthrough_form(layers: &[RawLayer], bc_supported: bool) -> Option<BlpTexels
uniform.then_some(form)
}
/// Pack a tile's ground textures into its layer array — blocks straight through when the whole set
/// allows it ([`passthrough_form`]), else the decode-resample-stomp path this lane always took.
///
/// `fallback` is layer 0's solid colour: the "this texture would not load" placeholder every tile
/// carries at index 0, which is why it has to be synthesised in whichever form the tile picked.
/// Pack a tile's ground textures into its layer array, as blocks when [`passthrough_form`] allows.
/// `fallback` is layer 0, the solid placeholder for a texture that fails to load, synthesised in
/// the form the tile picked.
pub(crate) fn pack_layers(fallback: [u8; 4], layers: Vec<RawLayer>) -> 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<RawLayer>, 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<RawLayer>, 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<RawLayer>, 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<RawLayer>, 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<u8> {
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<u8> {
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<u8> {
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<u8> {
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<u8>, 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<u8>, 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<u8>) -> 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<u8>) -> 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![
+42 -136
View File
@@ -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<u32> = 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<TexFilterSetting> = 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 [
+11 -37
View File
@@ -1,30 +1,9 @@
//! The shared debug-trace sink behind `WOW_MOVE_TRACE=<path>`: 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=<unix epoch>` 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=<path>`: 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=<unix epoch>` 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<Sink>> {
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<Sink>> {
.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<Option<Vec<String>>> = 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)
}
+8 -18
View File
@@ -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_<tile_x>_<tile_y>.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_<tile_x>_<tile_y>.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();
+124 -341
View File
@@ -1,14 +1,7 @@
//! WMO → model asset loader.
//!
//! A WMO (building) is a **root** file plus N **group** files (`<stem>_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 (`<stem>_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<ModelSubmesh>,
/// 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<u16>,
/// 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<WmoPortalInfo>,
pub portal_refs: Vec<WmoPortalRef>,
/// 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<WmoGroupNav>,
/// 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<WmoFog>,
/// 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<String>,
/// 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<Vec<[[f32; 3]; 3]>>,
/// 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<Vec<[[f32; 3]; 3]>>,
/// 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<Option<([f32; 3], [f32; 3])>>,
/// 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<Option<ColumnGrid>>,
/// 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<CollisionMesh>,
/// **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<CollisionMesh>,
/// 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<WmoDoodad>,
/// 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<WmoDoodadSet>,
/// 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<WmoLight>,
/// 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<WmoGroupInfo>,
/// 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<DoodadBase>,
/// 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<Option<u16>>,
/// 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<Arc<[u16]>>,
/// 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<Option<FootprintTris>>,
/// 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<u32>,
/// 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<Option<([f32; 3], [f32; 3])>>,
/// 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<Option<ColumnGrid>>,
/// 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<Vec<u16>>,
/// 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<Option<LiquidMesh>>,
}
/// 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<u16>,
}
/// 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<FootprintTris>],
) -> Vec<Option<([f32; 3], [f32; 3])>> {
@@ -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<FootprintTris>]) -> Vec<Option<ColumnGrid>> {
footprints
.iter()
@@ -278,7 +181,7 @@ pub fn footprint_tri_grids(footprints: &[Option<FootprintTris>]) -> Vec<Option<C
.collect()
}
/// Per-group column indexes over a per-group triangle set — [`WmoModel::group_collision_grids`].
/// Per-group column indexes over a per-group triangle set.
pub fn collision_tri_grids(tris: &[Vec<[[f32; 3]; 3]>]) -> Vec<Option<ColumnGrid>> {
tris.iter()
.map(|group| {
@@ -299,9 +202,7 @@ pub fn collision_tri_grids(tris: &[Vec<[[f32; 3]; 3]>]) -> Vec<Option<ColumnGrid
.collect()
}
/// The per-group AABBs of a per-group triangle set + their union — [`WmoModel::group_collision_bounds`]
/// and [`WmoModel::collision_bounds`], derived from the faces at load (shared with the down-ray tests,
/// so fixtures and the loader can never disagree on what the bounds mean).
/// Per-group AABBs of a per-group triangle set, and their union; shared with the down-ray tests.
#[allow(clippy::type_complexity)]
pub fn collision_tri_bounds(
tris: &[Vec<[[f32; 3]; 3]>],
@@ -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<u16>]) -> Vec<Option<u16>> {
let mut owner: Vec<Option<u16>> = 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<u16>]) -> Vec<Optio
owner
}
/// Invert MODR the other way: MODD index → **every** group whose refs name it. The draw loop is
/// per *visible* group over that group's own MODR (`0x695aa0` from the visible-group walk
/// `0x698720`), so a doodad named by N groups is reached by N independent chances to draw — it is
/// on screen when ANY of them is in the portal PVS, not only its first. Collapsing this to the
/// first owner is what made Blackrock's and the Great Forge's lava falls blink out by camera
/// angle: `BLACKROCKSTATUELAVAFLOW` is named by 9–19 groups apiece and `LAVAPOTS` by 13, so all
/// but one referrer's rooms hid a prop that the reference draws from every one of them.
/// MODD index to every group whose MODR names it: the reference draws per visible group
/// (`0x695aa0` from the walk at `0x698720`), so a prop shows while any of them is visible.
fn modr_refs(doodad_count: usize, group_doodad_refs: &[Vec<u16>]) -> Vec<Arc<[u16]>> {
let mut refs: Vec<Vec<u16>> = 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<u16>]) -> Vec<Arc<[u1
refs.into_iter().map(Arc::from).collect()
}
/// Resolve every MODD placement's lighting base once, at load. Ownership follows the faithful
/// create path: a doodad belongs to the group(s) whose **MODR** references it (the instantiate loop
/// is per-group over MODR — never a spatial test), and the interior lane's point-light candidate
/// set is its owning group's **MOLR** list. A MODD referenced by no group is never instantiated by
/// the reference at all — Exterior here (the harmless default if our spawner shows it anyway).
///
/// **EXTERIOR WINS.** The class is *not* the first referrer's: the reference caches one
/// `CMapDoodadDef` per (MODD index, **placement instance**) — `0x694e90` matches on
/// `[def+0xb4] == modd` *and* `[def+0xc8] == [placement+0x7c]+1` — so every group of a placed
/// building writes the lane of the *same* def, and the classify at `0x695aa0` makes exterior
/// absorbing: an interior group refuses to mark a def already flagged exterior
/// (`695ba0 test al,0x4; 695ba2 jne` → the exterior leg), while an exterior group **clears** the
/// interior bit and sets its own (`695bbd and ecx,0xfffd; 695bc3 or ecx,0x4`). So a prop any
/// exterior group names is sky-lit from the first frame that group is walked, and never goes back.
/// Taking the first referrer instead pinned Booty Bay's entrance arch — named by g22 (interior) and
/// g42 (exterior) — to the interior lane, whose whole base is the MODD colour: `#000000` there, i.e.
/// a pure black silhouette at the town gate (decision 0969; `benilla-extract darkpropscan` is the
/// corpus census).
///
/// `owner` is [`modr_owners`]' inversion and `refs` is [`modr_refs`]' — the latter shared with the
/// portal-cull key ([`WmoModel::doodad_groups`]), so the lane and the cull read the same relation.
/// Every MODD doodad's lighting base, from MODR ownership, never a spatial test; an unnamed MODD
/// is Exterior. Exterior wins: the reference keeps one def per (MODD, placement) (`0x694e90`), and
/// its classify (`0x695aa0`) never re-marks an exterior def interior while an exterior group
/// clears the interior bit, so any exterior referrer makes the prop sky-lit.
fn resolve_doodad_bases(
doodads: &[WmoDoodad],
groups: &[WmoGroupInfo],
@@ -462,7 +329,7 @@ fn resolve_doodad_bases(
let Some(gi) = owner.get(di).copied().flatten() else {
return DoodadBase::Exterior;
};
// Exterior is absorbing across the whole referrer set — not just the first referrer.
// Exterior is absorbing across the whole referrer set.
let all_interior = refs
.get(di)
.is_some_and(|gs| !gs.is_empty() && gs.iter().all(interior_group));
@@ -482,7 +349,7 @@ fn resolve_doodad_bases(
.collect()
}
/// Bevy [`AssetLoader`] decoding a WMO root `*.wmo` (+ its group files) → [`WmoModel`].
/// Loads a WMO root and its group files into a [`WmoModel`].
#[derive(Default, TypePath)]
pub struct WmoModelLoader;
@@ -502,8 +369,7 @@ impl AssetLoader for WmoModelLoader {
let to_io = |e: anyhow::Error| std::io::Error::other(format!("{e:#}"));
let root = parse_wmo_root(&bytes).map_err(to_io)?;
// Root path → stem for the group files. Lowercased so the suffix strip + group URLs are
// consistent (MPQ resolution is case-insensitive regardless).
// Lowercased so the `.wmo` strip matches; MPQ lookup is case-insensitive anyway.
let root_path = ctx.path().path().to_string_lossy().to_ascii_lowercase();
let stem = root_path
.strip_suffix(".wmo")
@@ -511,13 +377,9 @@ impl AssetLoader for WmoModelLoader {
.to_string();
let mut submeshes = Vec::new();
// Group index per submesh (parallel to `submeshes`) — the portal cull's handle on which group a
// render batch belongs to.
let mut submesh_group: Vec<u16> = 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<WmoGroupNav> = (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<[[f32; 3]; 3]>> =
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<[[f32; 3]; 3]>> =
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<u32> = 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<u32> = 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<u16>> = vec![Vec::new(); root.group_count() as usize];
let mut group_light_refs: Vec<Vec<u16>> = vec![Vec::new(); root.group_count() as usize];
let mut group_liquids: Vec<Option<LiquidMesh>> =
@@ -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<u32> = 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<u32>) = (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::<Image>(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<LiquidMesh>]) {
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());
}
+55 -178
View File
@@ -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<BlpMipChain>, 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<BlpMipChain>, 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<(usize, usize)>> = 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<BlpMipChain>, 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<BlpMipChain>, 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<BlpMipChain>, 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<u8>) -> Image {
Image::new(
@@ -295,10 +222,8 @@ pub fn cursor_texture(width: u32, height: u32, rgba: Vec<u8>) -> 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<u8>) -> Image {
let mut image = Image::new(
Extent3d {
@@ -321,12 +246,8 @@ pub fn sprite_image(width: u32, height: u32, rgba: Vec<u8>) -> 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<u8>) -> Image {
let mut image = Image::new(
Extent3d {
@@ -349,12 +270,8 @@ pub fn mask_image(width: u32, height: u32, rgba: Vec<u8>) -> 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<u8>) -> 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<u8>) -> 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<u8>) -> 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<u8>) -> 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<u8>, 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<u8>, 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<u8>, 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<u8>, 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<BlpMipChain> {
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]);
+105 -344
View File
@@ -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<Mutex<…>>` 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<Mutex<Chain>>,
/// 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<Image>>,
/// 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<String, Option<Handle<Image>>>,
/// 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<Handle<Image>>>,
/// 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<Handle<Image>>>,
/// 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<String, Option<(u32, u32, Vec<u8>)>>,
/// 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<String, Option<Handle<Image>>>,
/// 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<String, Option<Handle<Image>>>,
/// **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<Image>>,
/// 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 `<Texture file="Interface\AddOns\Atlas\Images\…"/>` 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<PathBuf>,
/// Materials deduped by their identity (texture path + blend, or the untextured fallback).
/// Materials deduped by [`MaterialKey`].
pub model_materials: SpatialCache<MaterialKey, Handle<WowModelMaterial>>,
/// 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<Chain>,
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<Chain>,
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<u8>)> {
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<u8>)> {
}
}
/// Map a **normalized** addon-relative path (`interface\addons\<addon>\<…>`, 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<Chain>,
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<PathBuf> {
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<PathBuf> {
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<M: bevy::asset::Asset>(
assets: &mut Assets<M>,
handle: &Handle<M>,
@@ -368,17 +227,13 @@ pub fn write_gated<M: bevy::asset::Asset>(
}
}
/// 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<T> {
fn lock_recover(&self) -> MutexGuard<'_, T>;
}
@@ -405,13 +255,8 @@ impl<T> LockRecover<T> for Mutex<T> {
}
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<Vec<u8>> {
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<PathBuf>) {
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<Image>,
) -> Option<Handle<Image>> {
// 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<u8>)> {
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 `<ColorWheelTexture>` and
/// `<ColorValueTexture>` 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
/// `<ColorWheelTexture>` and `<ColorValueTexture>`, 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<Image>,
materials: &mut Assets<WowModelMaterial>,
) -> Handle<WowModelMaterial> {
// 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()
);