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:
co-authored by
Claude Opus 5.5
parent
d936857673
commit
d69d09dfce
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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()));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user