diff --git a/crates/benilla-formats/examples/area_trigger_at.rs b/crates/benilla-formats/examples/area_trigger_at.rs index d2bb3f95..df358770 100644 --- a/crates/benilla-formats/examples/area_trigger_at.rs +++ b/crates/benilla-formats/examples/area_trigger_at.rs @@ -1,23 +1,12 @@ -//! Where an area trigger actually **is**, and how to walk into it: -//! `cargo run -p benilla-formats --example area_trigger_at -- [id|map…]` +//! Each area trigger's volume, with a `.go` spot outside it and the facing that walks you in. +//! `cargo run -p benilla-formats --example area_trigger_at -- ...` //! -//! The client's half of every portal is pure geometry (`crate::area_trigger`'s law), and that -//! geometry lives only in `AreaTrigger.dbc` — the server's `areatrigger_teleport` row names the -//! *destination* but never the volume you have to cross to reach it. So a probe that wants to -//! reproduce a portal the way a player meets it — **walking in**, which the module's own warning -//! says is not the same as a `.go` that lands inside — has no way to aim without this. -//! -//! Prints each row's volume (sphere radius, or the oriented box) plus a ready-made `.go` for a spot -//! `APPROACH` yards **outside** it on the box's own axis, and the facing that then walks you -//! through. An argument about whether a portal fired is otherwise unfalsifiable: landing inside the -//! volume races the server's re-check and is silently ignored about one time in six (measured, see -//! `crate::area_trigger`). -//! -//! Output is Blizzard-derived; pipe it to the scratchpad, never into the repo. +//! The volume lives only in `AreaTrigger.dbc`; the server's teleport row holds the destination. A +//! `.go` that lands inside races the server's re-check against its stored position and is +//! sometimes ignored, so a portal is tested by walking in. +//! Output is Blizzard data: never commit it. -/// How far outside the volume to park the approach `.go`, in yards. Far enough that the trigger is -/// unambiguously *not* yet entered (so the walk-in is a real crossing), close enough that a few -/// seconds of held-forward covers it. +/// Yards outside the volume for the approach spot. const APPROACH: f32 = 12.0; fn main() -> anyhow::Result<()> { @@ -47,7 +36,7 @@ fn main() -> anyhow::Result<()> { "id {:5} map {:3} SPHERE r={:.1} at ({x:.2}, {y:.2}, {z:.2})", r.id, r.map_id, r.radius ); - // A sphere has no authored axis; approach along -Y, the arbitrary but stated choice. + // A sphere has no authored axis: approach along -Y, an arbitrary choice. let sy = y + r.radius + APPROACH; println!( " walk in: .go xyz {x:.2} {sy:.2} {z:.2} {} then face -Y (yaw {:.3}) and hold forward", diff --git a/crates/benilla-formats/examples/batch_alpha.rs b/crates/benilla-formats/examples/batch_alpha.rs index 3fc405af..42248464 100644 --- a/crates/benilla-formats/examples/batch_alpha.rs +++ b/crates/benilla-formats/examples/batch_alpha.rs @@ -1,12 +1,7 @@ -//! Print every render batch's **animated material alpha** across a named sequence — the factor the -//! renderer multiplies into the instance's render alpha (`A = instanceAlpha × colourAlpha × weight`). -//! +//! Every render batch's animated alpha over one sequence, `colourAlpha × weight` in +//! `A = instanceAlpha × colourAlpha × weight`. A batch that never reaches exactly 1.0 stays on the +//! fade blend twin, which writes depth even for a `no_depth_write` batch (M2 render flag 0x10). //! `cargo run -p benilla-formats --example batch_alpha -- 'Creature\Voidwalker\Voidwalker.m2' 0` -//! -//! The number that matters is whether a batch ever reads **exactly 1.0**: benilla swaps a faded -//! entity onto its `AlphaMode::Blend` twin whenever `alpha < 1.0`, and that twin forces depth-write -//! ON. A batch whose authored weight never reaches 1.0 therefore never returns to the steady -//! material — so a `no_depth_write` batch (M2 flag 0x10) starts writing depth it must never write. fn main() -> anyhow::Result<()> { let mut args = std::env::args().skip(1); diff --git a/crates/benilla-formats/examples/batch_lit.rs b/crates/benilla-formats/examples/batch_lit.rs index 19a6c290..2e718b1d 100644 --- a/crates/benilla-formats/examples/batch_lit.rs +++ b/crates/benilla-formats/examples/batch_lit.rs @@ -1,15 +1,8 @@ -//! Print every render batch's **lighting-relevant material state** — the UNLIT (0x01) / UNFOGGED -//! (0x02) render flags as our reader resolves them, beside the blend mode and texture. -//! -//! `cargo run -p benilla-formats --example batch_lit -- 'Interface\Glues\Models\UI_Tauren\UI_Tauren.m2'` -//! -//! Built for B121: a glue scene's ground is a multi-layer stack whose overlay layers are authored -//! **UNLIT** (drawn fullbright, `colour = c28[0]·E_SH + c28[1]` with `c28[0] = 0`, `0x70c190`), -//! which is why UI_Tauren can author no ambient-fill light and still show a lit ground in the -//! reference. The question this answers is the one a grep cannot: whether -//! the flag survives *our* batch reader's render-flag indexing to the `RenderSubmesh` the material -//! builder consumes. A batch printed `LIT` here that the asset authors UNLIT is a reader bug; the -//! asset's own flag table is a separate read (the render-flag array at header 0x84). +//! Every render batch's UNLIT (0x01) and UNFOGGED (0x02) flags as our reader resolves them, with +//! blend mode and texture. A batch printed `lit` that the asset authors UNLIT (the render-flag +//! array at header 0x84) is a reader bug. Glue-scene ground overlays are authored UNLIT and drawn +//! fullbright (`0x70c190`), which is how UI_Tauren's ground is lit with no ambient light. +//! `cargo run -p benilla-formats --example batch_lit -- ` fn main() -> anyhow::Result<()> { let virt = std::env::args() diff --git a/crates/benilla-formats/examples/clutter_cut.rs b/crates/benilla-formats/examples/clutter_cut.rs index e4e12584..6e65e4d9 100644 --- a/crates/benilla-formats/examples/clutter_cut.rs +++ b/crates/benilla-formats/examples/clutter_cut.rs @@ -1,30 +1,8 @@ -//! **How the ground-clutter fade boundary cuts** — pixel by pixel, or leaf by leaf? -//! +//! Whether the ground-clutter fade erodes a tuft or flips whole leaves, per detail atlas and mip. //! `cargo run -p benilla-formats --example clutter_cut [substring]` //! -//! The detail-doodad draw alpha-tests `texel.a × ramp(view_depth)` against `detailDoodadAlpha` -//! (128/255), so as the ramp falls the surviving set is `{ texel.a ≥ 0.502 / ramp }`. Whether that -//! reads as a tuft *eroding* — the boundary sweeping through a leaf a pixel at a time — or as the -//! leaf *flipping whole* is decided entirely by the alpha distribution **at the mip the tuft is -//! sampled at**. The alpha arithmetic is settled and identical either way, so when the cut looks -//! wrong the answer is in what is being cut, not in the cutting. -//! -//! Per atlas, per mip level, three numbers: -//! -//! * **`distinct`** — how many distinct alpha values the level carries. **`2` means binary**: the -//! product `a × ramp` is then either `0` or `ramp`, so the alpha test degenerates to a global -//! on/off and *every texel of every leaf crosses the threshold at the same view depth*. No -//! erosion is possible at such a level, at any distance, ever. -//! * **coverage** across the fade band — the share of the atlas still drawn at each depth. Sliding -//! down = the tuft erodes. Holding flat then dropping to zero = the tuft pops. -//! * **cliff** — how much of the still-drawn coverage goes in the last 0.2 yd before the crossing. -//! 100% means everything that was left leaves in one step. -//! -//! The header line also prints the BLP's own `compression / alpha_bits / alpha_type / has_mips`, -//! because the shipped art is not uniform: some detail atlases carry a properly averaged mip -//! pyramid and some carry a **binary-thresholded** one, and the two behave completely differently -//! at the boundary. Parsed inline from the documented BLP2 layout rather than through the decoder, -//! so this reports what the *file* says independently of how we read it. +//! The draw alpha-tests `texel.a × ramp(view_depth)` against 128/255, so the alpha distribution at +//! the sampled mip decides the cut: a level with only 2 distinct alphas crosses all at once. use std::collections::BTreeSet; @@ -39,14 +17,11 @@ fn ramp(view_depth: f32, far: f32) -> f32 { /// The detail-doodad alpha-test reference (`detailDoodadAlpha` = 128). const CUTOUT: f32 = 128.0 / 255.0; -/// Where in the band to sample coverage — clustered at the 61.11 yd crossing, where even a fully -/// opaque texel fails the test and the whole question lives. +/// Depths to sample coverage at, clustered at 61.11 yd, where even an opaque texel fails the test. const BANDS: [f32; 8] = [52.5, 54.0, 56.0, 58.0, 60.0, 61.0, 61.1, 61.2]; -/// What the BLP2 header says about itself: `compression` (1 = palettized, 2 = DXT, 3 = BGRA8), -/// `alpha_bits`, `alpha_type` (DXT sub-form: 1 = DXT3, 7 = DXT5) and the `has_mipmaps` byte at -/// offset 0x0B — whose value tracks, in the shipped detail art, whether the mip chain is averaged -/// or thresholded. +/// The BLP2 header's `compression` (1 palettized, 2 DXT, 3 BGRA8), `alpha_bits`, `alpha_type` +/// (1 DXT3, 7 DXT5) and `has_mips` (0x0B), which in the detail art tracks a thresholded chain. fn blp_header(bytes: &[u8]) -> Option<(u8, u8, u8, u8)> { (bytes.len() >= 20 && &bytes[0..4] == b"BLP2") .then(|| (bytes[8], bytes[9], bytes[10], bytes[11])) @@ -58,7 +33,6 @@ fn main() -> anyhow::Result<()> { let mut chain = benilla_formats::open_chain(&data)?; let catalog = benilla_formats::load_ground_effect_catalog(&mut chain, true)?; - // Every distinct detail texture the catalog can place. let mut models: BTreeSet = BTreeSet::new(); for id in 0..4096u32 { if let Some(e) = catalog.effect(id) { @@ -103,9 +77,7 @@ fn main() -> anyhow::Result<()> { None => println!("\n{name}"), } - // What an AVERAGED chain would carry, for comparison — mip0's alpha box-filtered down. - // This is the control for a binary chain: it says how much of the flat coverage is the - // art's own and how much is the authored mip pyramid throwing the gradient away. + // The control: mip0's alpha box-filtered down, what an averaged chain would carry. let mip0_alpha: Vec = mips.mips[0] .as_chunks::<4>() .0 @@ -174,8 +146,6 @@ fn main() -> anyhow::Result<()> { " mip{i} {w:3}x{h:3} distinct {distinct:<4} [{}] cliff@61 {cliff:3.0}%", covs.join(" ") ); - // The averaged control, printed only where the authored level is binary — that is the - // only case where the two can disagree, and seeing them side by side is the point. if i > 0 && distinct <= 2 { if let Some(avg) = averaged.get(i) { let mut seen = [false; 256]; diff --git a/crates/benilla-formats/examples/clutter_size.rs b/crates/benilla-formats/examples/clutter_size.rs index 46bedd63..23ac95c2 100644 --- a/crates/benilla-formats/examples/clutter_size.rs +++ b/crates/benilla-formats/examples/clutter_size.rs @@ -1,28 +1,17 @@ -//! **How big is one tuft, and how much DEPTH does it span?** — the geometry half of the -//! "why does the fade cut leaf-by-leaf instead of pixel-by-pixel" question. -//! -//! `cargo run -p benilla-formats --example clutter_size` -//! -//! The fade boundary is a **plane** at a fixed view depth (the reference texgens camera-space Z). -//! Whether that plane *slices* a tuft — some of it drawn, the rest gone, a per-pixel cut — or -//! passes it by in a single frame depends on how much view depth one tuft occupies. A tuft that -//! spans 1 yd of depth is sliced for as long as it takes the player to walk 1 yd. A tuft that -//! spans 5 cm is, for practical purposes, either wholly in or wholly out: it pops. -//! -//! So this prints, per detail model: the world-space bounding box, the tuft's **depth span at the -//! worst yaw** (its horizontal diagonal — the widest it can present to the camera), and the -//! screen-space size that implies at the 61.11 yd cutout crossing. +//! Each detail model's size and the view depth one tuft spans. The fade boundary is a plane at a +//! fixed view depth (the reference texgens camera-space Z), so a deep tuft is sliced and a thin +//! one pops. `cargo run -p benilla-formats --example clutter_size` use std::collections::BTreeSet; -/// Vertical FOV benilla's camera runs at (Bevy's `PerspectiveProjection` default, ≈ the -/// reference's 44.1°) over a 1080-tall viewport — the same basis `clutter_state` uses. +/// Pixels per yard at `distance` for benilla's 45° vertical FOV (the reference's is ≈44.1° at +/// 16:9) on a 1080-tall viewport, the same basis as `clutter_state`. fn px_per_yard(distance: f32) -> f32 { let fov: f32 = std::f32::consts::PI / 4.0; (1080.0 / 2.0) / ((fov / 2.0).tan() * distance) } -/// Where the `detailDoodadAlpha` = 128 cutout erases even a fully-opaque texel. +/// The view depth where the 128/255 cutout erases even an opaque texel. const CROSSING: f32 = 61.113; fn main() -> anyhow::Result<()> { @@ -109,17 +98,10 @@ fn main() -> anyhow::Result<()> { the per-pixel slice. Below roughly a tenth of a yard it is a single-frame pop instead." ); - // **Which mip a tuft is sampled at, and why the answer moves with the window.** This is the - // number the whole look turns on: the shipped Elwynn/Westfall/AeriePeaks atlases carry a binary - // alpha pyramid, and a fragment can only hold full alpha — and so die at the same depth as - // every other fragment of its leaf — inside a 2x2 all-opaque texel neighbourhood. Those run out - // between mip 2 and mip 3 (179/56/10/0/0/0 for levels 0-5, counted on the dominant Elwynn - // cell), so sampling past ~3 erodes continuously and sampling below it takes leaves whole. - // - // The reference's `+0.25` stage-0 LOD bias buys a quarter of a mip toward that cliff — but the - // sample LOD falls as the viewport grows, so the SAME bias lands differently on a bigger - // window. The reference itself was measured at lambda 2.9-3.3 in a 1152x648 capture and - // lambda ~2.45 at 1920x1080, i.e. the reference pops leaf-by-leaf at a modern resolution too. + // The sampled mip decides the cut: on the binary-alpha atlases a fragment keeps full alpha only + // inside an all-opaque 2x2 neighbourhood, which runs out between mip 2 and 3, so past ~3 a tuft + // erodes and below it leaves cross whole. The reference biases stage 0 by +0.25 (`0x6813d0`); + // its lambda was measured at 2.9-3.3 at 1152x648, and ~2.45 at 1920x1080 is computed from that. const DENSITY: f32 = 100.0; // texels per yard, the median from `clutter_state` println!("\nsampled mip at the {CROSSING:.1} yd crossing (median {DENSITY:.0} texels/yd art):"); for height in [648.0f32, 1080.0, 1440.0, 2160.0] { diff --git a/crates/benilla-formats/examples/clutter_state.rs b/crates/benilla-formats/examples/clutter_state.rs index bf7b560e..231df571 100644 --- a/crates/benilla-formats/examples/clutter_state.rs +++ b/crates/benilla-formats/examples/clutter_state.rs @@ -1,15 +1,8 @@ -//! Dump the **render state and texture alpha profile** of every ground-clutter (detail-doodad) model -//! the `GroundEffect*` catalog resolves — the inputs benilla's clutter draw derives its material from. -//! +//! Every ground-clutter model's batch render state and each detail texture's alpha per mip, the +//! inputs to the ~70 yd clutter fade. The reference's detail pass (`0x6b2b80`) ignores the batches' +//! blend and two-sidedness and forces one state for every tuft; the fade multiplies the texture +//! alpha, so its per-mip distribution is the fade's shape. //! `cargo run -p benilla-formats --example clutter_state` -//! -//! Two questions it answers, both load-bearing for the ~70 yd distance fade: -//! * what **blend mode / two-sidedness** each detail M2's batches author — the reference's -//! detail-doodad pass (`0x6b2b80`) ignores these and forces one state for every tuft, so a -//! per-batch value benilla passes through is a divergence to see, not to assume; -//! * how each detail texture's **alpha is distributed per mip level** — a cutout whose alpha is -//! binary (0/255) snaps out of an alpha test all at once, while a smoothly-distributed one -//! erodes gradually. The fade multiplies this alpha, so the distribution *is* the fade's shape. use std::collections::{BTreeMap, BTreeSet}; @@ -18,8 +11,7 @@ fn main() -> anyhow::Result<()> { let mut chain = benilla_formats::open_chain(&data)?; let catalog = benilla_formats::load_ground_effect_catalog(&mut chain, true)?; - // Every distinct model the catalog can place. `GroundEffectCatalog` exposes rows by id only, so - // sweep the id space the DBC uses (ids are small and dense; a miss costs one hash lookup). + // Every distinct model the catalog can place; it exposes rows by id only, so sweep the ids. let mut models: BTreeSet = BTreeSet::new(); for id in 0..4096u32 { if let Some(e) = catalog.effect(id) { @@ -72,8 +64,7 @@ fn main() -> anyhow::Result<()> { } } println!("\nbatch blend modes: {blends:?}"); - // Screen-space mip level per distance: the vertical FOV benilla's camera uses (Bevy's - // `PerspectiveProjection` default, ~= the reference's 44.1 deg) over a 1080-tall viewport. + // Mip level per distance, for benilla's 45° vertical FOV on a 1080-tall viewport. let fov: f32 = std::f32::consts::PI / 4.0; let px_per_yard = |d: f32| (1080.0 / 2.0) / ((fov / 2.0).tan() * d); densities.sort_by(f32::total_cmp); @@ -137,9 +128,8 @@ fn main() -> anyhow::Result<()> { Ok(()) } -/// Texels-per-yard for a batch: the atlas footprint its UVs cover divided by the world size its -/// geometry covers. With the screen-space pixels-per-yard at a distance this gives the mip level the -/// GPU selects there — and the mip level decides the alpha the cutout test sees. +/// Texels per yard for a batch, its UV span in atlas texels over its world span; against pixels per +/// yard at a distance it gives the mip sampled there. fn texel_density(sub: &benilla_formats::RenderSubmesh, atlas: f32) -> Option { if sub.positions.is_empty() || sub.uvs.len() != sub.positions.len() { return None; diff --git a/crates/benilla-formats/examples/dbc_head.rs b/crates/benilla-formats/examples/dbc_head.rs index 0dd123eb..a01fe286 100644 --- a/crates/benilla-formats/examples/dbc_head.rs +++ b/crates/benilla-formats/examples/dbc_head.rs @@ -1,5 +1,4 @@ -//! Throwaway: dump a DBC's header (record count / field count / record size) and the first rows' -//! raw dwords, so a claimed column offset can be checked against the real shipped file. +//! Each named DBC's header and first rows' raw dwords, to check a column offset against the file. fn main() -> anyhow::Result<()> { let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); let mut chain = benilla_formats::open_chain(&data)?; diff --git a/crates/benilla-formats/examples/dump_anim_names.rs b/crates/benilla-formats/examples/dump_anim_names.rs index 451571c7..0afeb0fd 100644 --- a/crates/benilla-formats/examples/dump_anim_names.rs +++ b/crates/benilla-formats/examples/dump_anim_names.rs @@ -1,6 +1,5 @@ -//! Session probe: print AnimationData.dbc rows (id, name, weapon flags, fallback) — -//! `cargo run -p benilla-formats --example dump_anim_names [id ...]`. Without ids, -//! prints all 208 rows. Built for the bowstring round (which sequences do bow M2s carry). +//! `AnimationData.dbc` rows (id, name, weapon flags, fallback), all of them without ids. +//! `cargo run -p benilla-formats --example dump_anim_names [id ...]` fn main() { let mut args = std::env::args().skip(1); diff --git a/crates/benilla-formats/examples/dump_bone_keys.rs b/crates/benilla-formats/examples/dump_bone_keys.rs index df65e16b..2002c8f4 100644 --- a/crates/benilla-formats/examples/dump_bone_keys.rs +++ b/crates/benilla-formats/examples/dump_bone_keys.rs @@ -1,7 +1,6 @@ -//! Session probe: which bones carry keys in a given sequence — `cargo run -p benilla-formats -//! --example dump_bone_keys [bone ...]`. With bone indices, prints their -//! key counts + first rotation keys; without, lists every keyed bone. Extract the file first -//! via `mpqx` (benilla-mpq). Built for the nocked-ammo investigation (decision 2273 follow-up). +//! Which bones carry keys in one sequence, with their first keys; bone indices narrow the list. +//! `cargo run -p benilla-formats --example dump_bone_keys [bone ...]`, the +//! file extracted first with `mpqx`. fn main() { let mut args = std::env::args().skip(1); @@ -64,9 +63,7 @@ fn main() { q[0], q[1], q[2], q[3] ); } - // T/S values too, not just counts — for a flare or a rise the VALUES are the - // effect (the gseqdump lesson, here for sequence keys), and per-axis (non-)uniformity - // of a scale key is load-bearing for the billboard law's per-axis preserve. + // Translation and scale values, not only counts: a billboard preserves scale per axis. for (t, v) in bk.translation.iter().take(8) { println!( " trans t={t:.3} ({:+.3},{:+.3},{:+.3})", diff --git a/crates/benilla-formats/examples/dump_emitters.rs b/crates/benilla-formats/examples/dump_emitters.rs index 388fe798..80cb059d 100644 --- a/crates/benilla-formats/examples/dump_emitters.rs +++ b/crates/benilla-formats/examples/dump_emitters.rs @@ -1,12 +1,7 @@ -//! Print a model's particle-emitter records — the authored numbers to hold a live -//! `WOW_PARTICLE_CENSUS` line against: `cargo run -p benilla-formats --example dump_emitters -- -//! 'World\...\RubyCrystalLarge01.m2'`. -//! -//! The pair is the check that matters (decision 0653): the census says how many particles are -//! *live*, this says how many the file asks for (`rate × lifespan`), and the over-life ramp says -//! how big they get. A mismatch is our sim's bug; a match moves the question to the look. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. +//! A model's particle-emitter records, the authored side of a live `WOW_PARTICLE_CENSUS` line: +//! the file asks for `rate × lifespan` live particles, and a census that disagrees is our bug. +//! `cargo run -p benilla-formats --example dump_emitters -- 'World\...\RubyCrystalLarge01.m2'` +//! Output is Blizzard data: never commit it. fn main() -> anyhow::Result<()> { let virt = std::env::args() diff --git a/crates/benilla-formats/examples/dump_file.rs b/crates/benilla-formats/examples/dump_file.rs index 4336fb5e..7d123e5f 100644 --- a/crates/benilla-formats/examples/dump_file.rs +++ b/crates/benilla-formats/examples/dump_file.rs @@ -1,7 +1,6 @@ -//! Dump any file from the 5875 patch chain to stdout — the generic MPQ extractor for -//! reference reads (FrameXML sources, DBC layouts): `cargo run -p benilla-formats -//! --example dump_file -- 'Interface\FrameXML\TradeSkillFrame.lua'`. Output is Blizzard -//! data — pipe it to the scratchpad, never into the repo. +//! Any file from the patch chain to stdout, such as a FrameXML source or a DBC. +//! `cargo run -p benilla-formats --example dump_file -- 'Interface\FrameXML\TradeSkillFrame.lua'` +//! Output is Blizzard data: never commit it. use std::io::Write; diff --git a/crates/benilla-formats/examples/dump_model_anim.rs b/crates/benilla-formats/examples/dump_model_anim.rs index 4d02a796..4e2dbc92 100644 --- a/crates/benilla-formats/examples/dump_model_anim.rs +++ b/crates/benilla-formats/examples/dump_model_anim.rs @@ -1,19 +1,8 @@ -//! What can this model move, at all? Print the [`M2AnimSummary`](benilla_formats::M2AnimSummary) -//! — every animation channel family — plus the texture-transform (UV-scroll) tracks, for one M2 or -//! for every M2 whose path matches a substring: -//! -//! ```text -//! cargo run -p benilla-formats --example dump_model_anim -- 'World\...\RubyCrystalLarge01.m2' -//! cargo run -p benilla-formats --example dump_model_anim -- maraudon # every match -//! ``` -//! -//! This is the **falsifier for a flicker report** (decision 0653): if the summary says a model is -//! fully static and the screen shows it changing every frame, the change is not coming from the -//! model, and the hunt moves to what is drawn *over* it (particles, a second doodad) or to the -//! renderer. Answering that for the Maraudon crystal took one run of this and one of -//! `WOW_NO_PARTICLES=1`. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. +//! Every animation channel family a model has ([`M2AnimSummary`](benilla_formats::M2AnimSummary)) +//! and its texture-transform tracks, for one M2 or each animated M2 matching a substring. A fully +//! static model that changes on screen is being changed by something drawn over it, or by the +//! renderer. Output is Blizzard data: never commit it. +//! `cargo run -p benilla-formats --example dump_model_anim -- ` use std::io::Cursor; @@ -24,8 +13,7 @@ fn main() -> anyhow::Result<()> { let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); let mut chain = benilla_formats::open_chain(&data)?; - // An exact path dumps in full; anything else is a substring sweep that prints only the models - // with something to say (a listing of "all static" is noise when you asked about a zone). + // An exact path dumps in full; a substring sweep skips fully static models. let exact = chain.contains(&arg); let names: Vec = if exact { vec![arg] diff --git a/crates/benilla-formats/examples/emdump.rs b/crates/benilla-formats/examples/emdump.rs index ff93abf7..bfa0f504 100644 --- a/crates/benilla-formats/examples/emdump.rs +++ b/crates/benilla-formats/examples/emdump.rs @@ -1,15 +1,6 @@ -//! `emdump ` — every particle emitter one model authors, one line each. -//! -//! The per-emitter counterpart to `benilla-extract partcensus`, which answers "which models in the -//! corpus carry emitters matching this flag mask?" but never what an individual emitter *is*. B16 -//! needed the other direction — the voidwalker's eye glow is emitters 1 and 2 of four, bone 60/61, -//! flags `0x0001` (plain anchored), `Add`/`GLOWBALL.BLP` — and reading that off a hex dump by hand -//! is exactly the kind of asset fact that should be one command. -//! -//! The header line carries the model's **reach** (how far its own transparent-pass batches sort -//! from its origin — `m2_owner_reach`) and the `Transparent3d` draw-order rung it produces -//! (`owner_last_rung`): the rung is what keeps a model's effects drawing after its own transparent -//! batches, and it is sized from that number, so the two belong on screen together. +//! Every particle emitter one model authors, one line each, under the model's reach +//! (`m2_owner_reach`) and the `Transparent3d` rung sized from it (`owner_last_rung`), which keeps +//! its effects drawing after its own transparent batches. `emdump ` fn main() -> anyhow::Result<()> { let virt = std::env::args().nth(1).unwrap(); @@ -18,8 +9,7 @@ fn main() -> anyhow::Result<()> { let bytes = chain.read_file(&virt)?; let dir = virt.rsplit_once('\\').map_or("", |(d, _)| d); let subs = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]).unwrap_or_default(); - // The renderer's own bound and rung, not a re-derivation of them: a survey tool has to print - // what the renderer will actually do. + // The renderer's own bound and rung, not a re-derivation of them. let reach = benilla_formats::m2_owner_reach(&subs); let rung = benilla_formats::owner_last_rung(reach); println!("{virt}: reach {reach:.3} yd -> draw-order rung {rung:.0}"); diff --git a/crates/benilla-formats/examples/eye_burial_anim.rs b/crates/benilla-formats/examples/eye_burial_anim.rs index a21b4c34..619b4c9f 100644 --- a/crates/benilla-formats/examples/eye_burial_anim.rs +++ b/crates/benilla-formats/examples/eye_burial_anim.rs @@ -1,36 +1,16 @@ -//! **Is the eye emitter still proud of the face once the model ANIMATES?** -//! -//! `eye_quad_pass` answers the depth contest at *bind* pose, where benilla already matches the -//! reference. Decision 0707 measured the failing case and found the split: benilla's freshest eye -//! particle — born this frame at the current bone pose — sits **1.6–4.7 cm behind the face our -//! renderer draws**, against **2.5–34 mm** at bind. "Faithful at rest" and "faithful in motion" are -//! different claims, and this is the instrument for the second one. -//! -//! It builds the bone palette ourselves, from our own parse, at sampled times across a sequence: -//! `palette[b] = parentPalette · T(pivot) · R(rot) · S(scale) · T(-pivot)`, the M2 skinning matrix. -//! Then, per sample, it reports the two numbers that decide the contest: -//! -//! - `gap` — the distance from the animated eye-bone pivot (where our particles are born, verified -//! `birth == joint`) to the **nearest skinned vertex** of the depth-writing batches. At bind this -//! is 0.0162 for bone 60 (measured on the shipped `Voidwalker.m2`). If our animation swings the -//! skin centimetres past the bone, this grows — and that growth *is* the bug. -//! - `pass%` — the constant-depth quad's surviving area against the animated shell, front view. -//! -//! `cargo run -p benilla-formats --example eye_burial_anim -- 'Creature\Voidwalker\Voidwalker.m2' 0` -//! -//! A `gap` that stays near its bind value across the cycle says our pose is self-consistent and 0707's -//! centimetres came from somewhere else in the renderer. A `gap` that blows out says our skinning or -//! our animation moved the face off the bone, and the fix is there. +//! Whether an eye emitter stays in front of the face while the model animates; `eye_quad_pass` +//! answers it at bind pose. At samples across one sequence it builds the M2 skinning palette from +//! our own parse and prints `gap`, the emitter's distance to the nearest skinned vertex of the +//! depth-writing batches (bone 60 of `Voidwalker.m2`: 0.0162 at bind), and `pass%`, the share of +//! the constant-depth quad the animated shell leaves visible. A gap that grows over the cycle +//! means our skinning or animation moves the face off the bone. +//! `cargo run -p benilla-formats --example eye_burial_anim -- [seq slot]` use benilla_formats::{ModelAnimation, RenderSubmesh, Skeleton}; use glam::{Mat4, Quat, Vec3}; -/// The M2 skinning palette at time `t` (seconds into `anim`), in file-bone order. -/// -/// The pivot dance is the whole point: a bone's animated rotation/scale act **about its own pivot**, -/// so the matrix is `T(pivot) · R · S · T(-pivot)` composed under the parent. At rest (no keys) every -/// factor is identity and the palette is identity — which is why an emitter record `position` that -/// equals its bone pivot maps to itself at bind. +/// The M2 skinning palette at `t` seconds into `anim`, in file-bone order: rotation and scale act +/// about the bone's pivot, `T(pivot + tr) · R · S · T(-pivot)` under the parent. fn palette(skel: &Skeleton, anim: &ModelAnimation, t: f32) -> Vec { let sample_v = |keys: &[(f32, [f32; 3])], t: f32, dflt: Vec3| -> Vec3 { if keys.is_empty() { @@ -88,7 +68,6 @@ fn palette(skel: &Skeleton, anim: &ModelAnimation, t: f32) -> Vec { out } -/// Skin one vertex by its 4 bone/weight pairs (weights already normalised by the parser). fn skin(p: [f32; 3], j: [u16; 4], w: [f32; 4], pal: &[Mat4]) -> Vec3 { let v = Vec3::from(p); let mut acc = Vec3::ZERO; @@ -199,10 +178,8 @@ fn main() -> anyhow::Result<()> { anim.bones.len() ); - // Which bones does the depth-writing shell actually skin to, and does any of them sit under a - // BILLBOARD bone? A billboard joint is re-aimed at the camera every frame and its children - // inherit that rotation, so shell geometry under one moves with the *camera* — which is exactly - // the signature of a burial that appears from one camera elevation and not another. + // Shell bones under a billboard bone: a billboard re-aims at the camera each frame and its + // children inherit that, so their skin moves with the camera. { let mut shell_bones: Vec = Vec::new(); for s in &subs { @@ -252,8 +229,7 @@ fn main() -> anyhow::Result<()> { if b >= skel.bones.len() { continue; } - // Only the additive quad emitters mounted flush in the shell are the B16 subject; the - // others are printed anyway so the contrast is on the same page. + // Every emitter prints; the eye quads are the additive ones mounted flush in the shell. println!( "\nemitter bone {b} pos ({:.4},{:.4},{:.4}) blend {:?}", e.position[0], e.position[1], e.position[2], e.blend diff --git a/crates/benilla-formats/examples/eye_quad_pass.rs b/crates/benilla-formats/examples/eye_quad_pass.rs index 2fd3b406..f2e39b4e 100644 --- a/crates/benilla-formats/examples/eye_quad_pass.rs +++ b/crates/benilla-formats/examples/eye_quad_pass.rs @@ -1,24 +1,13 @@ -//! **The flush-emitter depth contest, computed offline** — what fraction of a particle emitter's -//! constant-depth billboard survives `LEQUAL` against its own model's z-writing batches, from a -//! given camera direction. No client, no camera, no capture: pure geometry over *our* parse. -//! -//! This exists to hold benilla's asset side against the table measured for the reference's quad -//! writer (`0x7b2a50` — Voidwalker bone 60, half 0.0833: -//! front 43.4 %, 30° above 38.8 %, 60° left 43.3 %). Agreement means our mesh, our bone pivots and -//! our quad model are faithful and any missing glow is the *renderer's*; disagreement localises the -//! bug to the asset side, where it can be fixed without ever launching the game. -//! -//! `cargo run -p benilla-formats --example eye_quad_pass -- 'Creature\Voidwalker\Voidwalker.m2'` -//! -//! Model space, rest pose, WoW axes (X forward, Y left, Z up) — the emitter record `position` is -//! the quad centre (it is bit-identical to its bone's pivot, and the palette is identity at rest). -//! The occluder is every batch that is **not** `no_depth_write`, exactly the set the depth buffer -//! holds when the particle pass runs. +//! The share of a particle emitter's constant-depth quad that passes `LEQUAL` against its own +//! model's depth-writing batches, per camera direction, from our parse alone. The reference's quad +//! writer (`0x7b2a50`) gives 43.4% front, 38.8% 30° above and 43.3% 60° left for `Voidwalker.m2` +//! bone 60 at half-size 0.0833; a mismatch puts the bug in our asset side, a match in the renderer. +//! Model space, rest pose, WoW axes (X forward, Y left, Z up); the emitter `position`, equal to its +//! bone's pivot, is the quad centre. `cargo run -p benilla-formats --example eye_quad_pass -- ` use benilla_formats::RenderSubmesh; -/// Möller–Trumbore. Returns the ray parameter `t` of the hit, if the ray meets the triangle ahead -/// of its origin. +/// Möller-Trumbore: the ray parameter `t` of a hit ahead of the origin. fn ray_tri(orig: [f32; 3], dir: [f32; 3], a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> Option { let sub = |p: [f32; 3], q: [f32; 3]| [p[0] - q[0], p[1] - q[1], p[2] - q[2]]; let cross = |p: [f32; 3], q: [f32; 3]| { @@ -36,8 +25,7 @@ fn ray_tri(orig: [f32; 3], dir: [f32; 3], a: [f32; 3], b: [f32; 3], c: [f32; 3]) if det.abs() < 1e-9 { return None; // ray parallel to the triangle plane } - // Two-sided on purpose: the shell's inward-facing triangles occlude too — the depth buffer - // holds whatever rasterized, and these batches are authored two-sided or seen from inside. + // Two-sided on purpose: the depth buffer holds inward-facing triangles too. let inv = 1.0 / det; let s = sub(orig, a); let u = inv * dot(s, h); @@ -53,10 +41,8 @@ fn ray_tri(orig: [f32; 3], dir: [f32; 3], a: [f32; 3], b: [f32; 3], c: [f32; 3]) (t > 1e-6).then_some(t) } -/// The camera-facing orthonormal pair for a view direction (the two screen axes the quad's corners -/// are offset along — the third axis carries the shared depth). fn screen_axes(view: [f32; 3]) -> ([f32; 3], [f32; 3]) { - // `view` points from the camera toward the model. Any up-ish reference not parallel to it. + // `view` points from the camera toward the model; any up reference not parallel to it. let up = if view[2].abs() > 0.9 { [1.0, 0.0, 0.0] } else { @@ -78,9 +64,8 @@ fn screen_axes(view: [f32; 3]) -> ([f32; 3], [f32; 3]) { (right, real_up) } -/// Fraction of the quad's area that passes `LEQUAL` — i.e. whose sample point is not occluded by -/// any z-writing triangle between it and the camera. Constant depth: every sample sits on the plane -/// through `center` perpendicular to `view`, so "occluded" is simply "a triangle lies in front". +/// The share of the quad that passes `LEQUAL`: every sample sits at the quad's one depth, so it +/// fails iff a depth-writing triangle lies between it and the camera. fn pass_fraction( tris: &[([f32; 3], [f32; 3], [f32; 3])], center: [f32; 3], @@ -89,13 +74,12 @@ fn pass_fraction( n: usize, ) -> f32 { let (right, up) = screen_axes(view); - // Cast *toward* the camera: anything hit is in front of the sample point. + // Cast toward the camera: anything hit is in front of the sample point. let back = [-view[0], -view[1], -view[2]]; let mut passed = 0usize; let mut total = 0usize; for iy in 0..n { for ix in 0..n { - // Sample cell centres so the grid is unbiased at the quad's edges. let fx = (ix as f32 + 0.5) / n as f32 * 2.0 - 1.0; let fy = (iy as f32 + 0.5) / n as f32 * 2.0 - 1.0; let p = [ @@ -115,12 +99,8 @@ fn pass_fraction( passed as f32 / total as f32 } -/// Every triangle of every batch that writes depth — the occluder set, in model space. -/// -/// `all` ignores the per-material `no_depth_write` bit, i.e. models a renderer that writes depth for -/// the whole model. That is the counterfactual worth pricing: it is what benilla's distance-fade -/// blend twin does (it forces depth-write ON regardless of the flag), so the two columns bracket -/// "did we leak the model's own transparent cards into the depth buffer?". +/// The occluder set in model space: every triangle of every depth-writing batch, or with `all` of +/// every batch, as benilla's fade blend twin writes depth regardless of `no_depth_write`. fn occluders(subs: &[RenderSubmesh], all: bool) -> Vec<([f32; 3], [f32; 3], [f32; 3])> { let mut tris = Vec::new(); for s in subs { @@ -163,8 +143,7 @@ fn main() -> anyhow::Result<()> { tris.len() ); - // Camera directions, as (label, azimuth° about +Z from +X, elevation° above the horizontal). - // `view` points camera → model, so the camera sits at -view. + // (label, azimuth° about +Z from +X, elevation°); the camera sits at -view. let views: &[(&str, f32, f32)] = &[ ("front (+X)", 0.0, 0.0), ("15 above", 0.0, 15.0), @@ -178,8 +157,7 @@ fn main() -> anyhow::Result<()> { ]; for e in benilla_formats::parse_m2_particle_emitters(&bytes)?.iter() { - // Only the plane/quad emitters we can model this way, and only those actually mounted in - // the mesh — the point of the exercise. + // Meaningful only for a quad emitter mounted in the mesh; every emitter prints. println!( "\nemitter bone {} pos ({:.4}, {:.4}, {:.4}) blend {:?}", e.bone, e.position[0], e.position[1], e.position[2], e.blend @@ -189,7 +167,6 @@ fn main() -> anyhow::Result<()> { print!(" {set:9} half {half:.4}:"); for &(label, az, el) in views { let (a, l) = (az.to_radians(), el.to_radians()); - // Camera at azimuth `az`, elevation `el`, looking at the model: view = -(camera dir) let view = [-(a.cos() * l.cos()), -(a.sin() * l.cos()), -(l.sin())]; let f = pass_fraction(occ, e.position, half, view, 64); print!(" {label} {:.1}%", f * 100.0); diff --git a/crates/benilla-formats/examples/fade_bucket.rs b/crates/benilla-formats/examples/fade_bucket.rs index a710551b..3b84cace 100644 --- a/crates/benilla-formats/examples/fade_bucket.rs +++ b/crates/benilla-formats/examples/fade_bucket.rs @@ -1,12 +1,7 @@ -//! What distance-fade band does a doodad actually land in? Prints each matching M2's authored -//! bounding-sphere radius (the reference's `rec+0x68` source) and the band the radius selects: +//! Each matching M2's bounding-sphere radius (the reference's `rec+0x68` source) and the +//! distance-fade band it selects, which depends on size alone. Radii are model-local; a +//! placement's `scale` multiplies them. Output is Blizzard data: never commit it. //! `cargo run -p benilla-formats --example fade_bucket -- fence` -//! -//! The band is chosen by SIZE, never by what the thing is — so "fences fade at 40→50" is a claim -//! about a radius, not about a fence. This prints the radius so the claim can be checked instead -//! of remembered. Radii are model-local (pre-scale); a placement's `scale` multiplies them. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. fn main() -> anyhow::Result<()> { let pat = std::env::args() @@ -38,7 +33,7 @@ fn main() -> anyhow::Result<()> { Ok(()) } -/// The `FUN_00683f80` size buckets, as `model_fade.rs` encodes them. +/// The size buckets of `0x683f80`, in sync with `model_fade.rs`. fn band(r: f32) -> &'static str { match r { r if r > 7.0 => "never fades", diff --git a/crates/benilla-formats/examples/gseqdump.rs b/crates/benilla-formats/examples/gseqdump.rs index 99c89a25..ff6f3933 100644 --- a/crates/benilla-formats/examples/gseqdump.rs +++ b/crates/benilla-formats/examples/gseqdump.rs @@ -1,9 +1,6 @@ -//! `gseqdump ` — every global-sequence bone channel with its RAW KEYS. -//! -//! `benilla-extract m2anim` names which bones carry global-sequence channels and their periods, -//! but never the key VALUES — and for a twinkle (a scale loop on a star card) the values ARE the -//! effect: a 0→1 flicker and a 0→20 flare are the same one-line summary. Output is Blizzard -//! data — pipe it to the scratchpad, never into the repo. +//! Every global-sequence bone channel with its raw keys: for a twinkle the scale values are the +//! effect, and a 0-1 flicker and a 0-20 flare can share a period. `gseqdump ` +//! Output is Blizzard data: never commit it. fn main() -> anyhow::Result<()> { let virt = std::env::args() diff --git a/crates/benilla-formats/examples/impass_here.rs b/crates/benilla-formats/examples/impass_here.rs index 42f2c12f..043c5123 100644 --- a/crates/benilla-formats/examples/impass_here.rs +++ b/crates/benilla-formats/examples/impass_here.rs @@ -1,35 +1,21 @@ -//! Where the ADT's impassable chunks are around a pin: +//! A top-down map of the MCNK impassable flag (header flag bit 1) around a pin: north up, one +//! glyph per 33.333 yd chunk, across tile seams, since a band crosses them. `what_is_here` covers +//! models. Output is Blizzard data: never commit it. //! `cargo run -p benilla-formats --example impass_here -- [radius_chunks]` -//! e.g. `impass_here Azeroth -6601.98 -531.87 10`. -//! -//! An "invisible wall" report (B129) names a *place* — a `.go xyz` pin — and the first question is -//! whether the wall is authored terrain at all: an MCNK with header flag bit 1 (`MCNK_IMPASSABLE`) -//! set, or a WMO/M2 hull the reporter walked into. This prints -//! the flag as a top-down chunk map — north up, west left, the in-game map's orientation, one -//! glyph per 33.333 yd chunk — so the answer is a picture of the band rather than a guess, and the -//! pin's own chunk is marked. `what_is_here` answers the other half (is there a model here). -//! -//! The map crosses tile seams on purpose: the flag is per chunk and a band runs across ADT -//! boundaries, so a single-tile view is exactly how you miss the wall you are standing against — -//! B129's pin sits 1.5 yd from its wall, with the flagged chunk in the *next* tile east. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. use std::collections::HashMap; use benilla_formats::{impassable_at, world_to_tile, TileMesh, CHUNK_SIZE, TILE_SIZE}; -/// The map's NW origin: world x/y both run *down* from `+32 tiles` as the grid runs south/east. +/// The map's NW corner: world x and y fall from 32 tiles as the grid runs south and east. const MAP_CENTER: f32 = 32.0 * TILE_SIZE; -/// Global chunk index of a world coordinate on either axis — row from x (grows south), column from -/// y (grows east). The same falling-from-`MAP_CENTER` addressing the client's per-chunk lookups use -/// (the MCSH texel law, `0x69b350`). +/// Global chunk index of a world coordinate on either axis, row from x and column from y, falling +/// from `MAP_CENTER` as the client's per-chunk lookups do (`0x69b350`). fn chunk_index(coord: f32) -> i32 { ((MAP_CENTER - coord) / CHUNK_SIZE).floor() as i32 } -/// The world coordinate of a global chunk index's centre (inverse of [`chunk_index`], +½ chunk). fn chunk_centre(index: i32) -> f32 { MAP_CENTER - (index as f32 + 0.5) * CHUNK_SIZE } @@ -67,7 +53,7 @@ fn main() -> anyhow::Result<()> { println!(" '#' impassable · '.' passable · '@' the pin's chunk · ' ' no tile"); print!(" "); for dc in -radius..=radius { - // A tile seam every 16 chunks: mark it so a band's tile ownership is readable at a glance. + // A tile seam every 16 chunks. print!( "{}", if (pin_col + dc).rem_euclid(16) == 0 { @@ -94,8 +80,7 @@ fn main() -> anyhow::Result<()> { println!(); } - // The pin's own verdict, and how far the wall is in each cardinal direction — the number a - // retest needs ("walk east 1.5 yd and you should stop"). + // The pin's own chunk, and the distance to the wall in each cardinal direction. let pin_flag = flag_at(&mut chain, x, y); println!( "\npin chunk: {}", @@ -111,8 +96,7 @@ fn main() -> anyhow::Result<()> { ("west (+y)", 0.0, 1.0), ("east (-y)", 0.0, -1.0), ] { - // Step chunk by chunk from the pin until the flag flips, then report the distance to that - // chunk's *near boundary* — where a mover would actually be stopped. + // Step chunk by chunk until the flag flips; a mover stops at that chunk's near boundary. let hit = (1..=radius).find(|n| { let (sx, sy) = ( x + dx * *n as f32 * CHUNK_SIZE, diff --git a/crates/benilla-formats/examples/liquid_depth_census.rs b/crates/benilla-formats/examples/liquid_depth_census.rs index e6864acb..ee05e9a1 100644 --- a/crates/benilla-formats/examples/liquid_depth_census.rs +++ b/crates/benilla-formats/examples/liquid_depth_census.rs @@ -1,26 +1,8 @@ -//! What the shipped MCLQ **depth byte** actually is, per liquid kind — the instrument behind the -//! ocean depth-ramp settle: +//! The shipped MCLQ depth byte per liquid kind over wet-cell vertices: its histogram, its slope +//! against the real `surface - terrain` depth, mid-ramp shore pins and what each divisor does to +//! the picture. The reference maps the byte to its depth swatch per kind, river `min(d/42, 1)` and +//! ocean `min(d/255, 1)`. Output is Blizzard data: never commit it. //! `cargo run --release -p benilla-formats --example liquid_depth_census -- [map ...]` -//! (default: `Azeroth Kalimdor`, i.e. every ADT ocean and river in the vanilla world.) -//! -//! The reference indexes its water depth-swatch by this one byte through a per-kind LUT — river -//! `min(d/42, 1)`, ocean `min(d/255, 1)` — so the *look* of a sea is that LUT applied to whatever -//! the artists authored into the byte. A divisor argued from the binary alone can still be wrong -//! about the picture if the authored range never reaches it, so this measures the range, and the -//! byte→yard slope that turns it into a distance a swimmer can feel: -//! -//! - the **depth-byte histogram** per kind, over vertices that touch a wet cell (dry-cell verts -//! carry the FLT_MAX height sentinel and no meaningful byte); -//! - the **byte→yard slope**, least-squares through the origin against the real geometric depth -//! `surface − terrain` at each vertex, plus the correlation — which is also the check that byte 0 -//! of the water/ocean union IS a depth at all; -//! - a handful of **shore-band pins** — world coords where the depth byte is mid-ramp, i.e. the only -//! places the divisor is visible at all. Everything else is either the pale edge or the pinned-deep -//! open sea, and an A/B aimed anywhere else photographs two identical pictures; -//! - **what each candidate divisor does to the picture**: the share of wet water that lands on the -//! deep swatch row (V ≥ 0.95), on the shallow row (V ≤ 0.05), and the mean V. -//! -//! Output is Blizzard-derived data — read it, pipe it to the scratchpad, never into the repo. use std::collections::BTreeMap; @@ -28,34 +10,27 @@ use benilla_formats::{terrain_height_at, LiquidKind, CHUNK_SIZE}; /// MCLQ vertex-grid pitch: the 9×9 grid spans one 8-cell chunk. const UNIT: f32 = CHUNK_SIZE / 8.0; -/// MCLQ grid side (9 verts) and cell side (8). const GRID: usize = 9; const CELLS: usize = 8; -/// Cell-flag low nibble meaning "dry". const DRY_NIBBLE: u8 = 0x0f; /// Verts under no liquid carry FLT_MAX; `is_finite()` is true for it, so gate on magnitude. const HEIGHT_SENTINEL: f32 = 1.0e9; /// The per-kind accumulator: the byte histogram plus the paired (byte, yard) samples. struct Kind { - /// Count per raw depth byte, 0..=255. hist: Box<[u64; 256]>, - /// Vertices whose terrain height resolved, for the byte→yard fit. + /// Vertices whose terrain height resolved, for the byte-to-yard fit. paired: u64, - /// Σ byte·yd, Σ byte², Σ yd², Σ byte, Σ yd — enough for a through-origin slope and an r. + /// Σ byte·yd, Σ byte², Σ yd², Σ byte, Σ yd, for the through-origin slope and r. sum_by: f64, sum_bb: f64, sum_yy: f64, sum_b: f64, sum_y: f64, - /// Blocks seen. blocks: u64, - /// A spread of mid-ramp sample points: `(zone, map, x, y, byte, yards)`, at most one per - /// 2000-yd neighbourhood, so the list names distinct coastlines instead of 200 vertices of one - /// bay. Named by `AreaTable`, because a pin you can walk to is worth more than a coordinate. + /// Mid-ramp points `(zone, map, x, y, byte, yards)`, at most one per 2000-yd neighbourhood. pins: Vec<(String, String, f32, f32, u8, f32)>, - /// Σ yards and count per depth byte — the empirical byte→depth curve, which the single - /// through-origin slope cannot show once a kind's byte saturates (the ocean's does, hard). + /// Σ yards and count per byte: the depth curve a single slope hides where the byte saturates. yd_sum: Box<[f64; 256]>, yd_n: Box<[u64; 256]>, } @@ -98,7 +73,6 @@ impl Kind { self.hist.iter().sum() } - /// Byte at the given share of the distribution. fn percentile(&self, p: f64) -> u8 { let target = (self.total() as f64 * p) as u64; let mut seen = 0; @@ -129,9 +103,8 @@ impl Kind { Some((slope, r)) } - /// Least-squares `byte = k · yards` through the origin over a byte window only. The ocean - /// pins 83 % of its vertices at 255, which drags a whole-range fit toward the pile and hides - /// the ramp that actually paints the shore; this reads the ramp where it varies. + /// The through-origin slope over a byte window only: the ocean pins 83% of its vertices at + /// 255, and a whole-range fit hides the ramp that paints the shore. fn slope_in(&self, lo: u8, hi: u8) -> Option<(f64, u64)> { let (mut sby, mut syy, mut n) = (0.0, 0.0, 0u64); for b in lo..=hi { @@ -180,7 +153,6 @@ fn main() -> anyhow::Result<()> { let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); let mut chain = benilla_formats::open_chain(&data)?; - // Names for the shore-band pins. Optional: a census still reads without a DBC. let areas = benilla_formats::load_area_table_catalog(&mut chain).ok(); let mut kinds: BTreeMap = BTreeMap::new(); @@ -194,8 +166,7 @@ fn main() -> anyhow::Result<()> { let Ok(bytes) = chain.read_file(&path) else { continue; }; - // The mesh is only the TERRAIN reference here — the depth bytes come off the raw parse, - // so the census can never be a restatement of the divisor it is measuring. + // The mesh gives only the terrain; the depth bytes come off the raw parse. let Ok(mesh) = benilla_formats::adt_to_tile_mesh(&bytes) else { continue; }; @@ -209,8 +180,7 @@ fn main() -> anyhow::Result<()> { if block.vertices.len() < GRID * GRID { continue; } - // Kind from the majority WET cell nibble — the verified type source, and the - // same rule `build_liquid_mesh` uses, so the census bins as the renderer draws. + // Kind from the majority wet-cell nibble, the same rule as `build_liquid_mesh`. let mut counts = [0u32; 16]; let mut wet = [false; CELLS * CELLS]; for (c, w) in wet.iter_mut().enumerate() { @@ -256,13 +226,11 @@ fn main() -> anyhow::Result<()> { } let x = wx - row as f32 * UNIT; let y = wy - col as f32 * UNIT; - // Nudge off the exact lattice line so the terrain lookup lands inside a - // triangle rather than on a chunk seam. + // Nudge off the lattice so the terrain lookup lands inside a triangle. let ground = terrain_height_at(&mesh.chunks, [x - 0.01, y - 0.01, 0.0]); let yards = ground.map(|g| h - g).filter(|d| *d > 0.0 && *d < 500.0); let byte = block.vertices[n].depth_byte(); - // One mid-ramp pin per tile: where the ramp is neither pinned deep nor at - // the pale edge, which is the only band an A/B can see. + // Mid-ramp pins, the only band where the divisor shows on screen. if (40..=200).contains(&byte) && acc.pins.len() < 64 && !acc.pins.iter().any(|p| { @@ -342,7 +310,6 @@ fn main() -> anyhow::Result<()> { println!(" {map} {x:9.1} {y:9.1} byte {b:3} depth {yd:5.1} yd {zone}"); } } - // The coarse shape, so a bimodal or pinned distribution shows without a plot. print!(" histogram (16 buckets of 16):"); for b in 0..16 { let n: u64 = k.hist[b * 16..(b + 1) * 16].iter().sum(); diff --git a/crates/benilla-formats/examples/list_chain.rs b/crates/benilla-formats/examples/list_chain.rs index 361f2115..38547383 100644 --- a/crates/benilla-formats/examples/list_chain.rs +++ b/crates/benilla-formats/examples/list_chain.rs @@ -1,16 +1,12 @@ -//! List every file in the 5875 patch chain whose path contains a substring (case-insensitive): -//! `cargo run -p benilla-formats --example list_chain -- maraudon`. The "what is this thing -//! actually called" tool — a report names a *place* ("the red crystal in Maraudon"), and finding -//! the asset behind it is the first step of every scene-render diagnosis. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. +//! Every file in the patch chain whose path contains a substring, case-insensitive, optionally of +//! one extension. `cargo run -p benilla-formats --example list_chain -- maraudon [m2]` +//! Output is Blizzard data: never commit it. fn main() -> anyhow::Result<()> { let pat = std::env::args() .nth(1) .ok_or_else(|| anyhow::anyhow!("usage: list_chain [ext]"))? .to_lowercase(); - // An optional second argument filters by extension — `list_chain maraudon m2`. let ext = std::env::args() .nth(2) .map(|e| format!(".{}", e.to_lowercase())); diff --git a/crates/benilla-formats/examples/m2_shared_section.rs b/crates/benilla-formats/examples/m2_shared_section.rs index 5f608414..1bcc80b1 100644 --- a/crates/benilla-formats/examples/m2_shared_section.rs +++ b/crates/benilla-formats/examples/m2_shared_section.rs @@ -1,29 +1,15 @@ -//! **Which M2 batches share their triangles with another batch** — the coplanar-sibling census: -//! `cargo run -p benilla-formats --example m2_shared_section -- [path-substring]`. -//! -//! An M2 skin *section* is a run of triangles; a *batch* is one draw of one section. When two -//! batches name the SAME section they rasterize the SAME triangles — a base layer and a shine / -//! reflect layer authored on top of it (`ARMORREFLECT3`, `BALISTASHINE02`). The reference draws -//! both from one vertex array under depth-write + LEQUAL, so the second always wins the tie -//! exactly (`0x70b3f6`, `0x70c190`). -//! -//! We only match that if both batches take the SAME vertex-transform path. They do not when a -//! consolidator (`static_gx`, `terrain_stream::merge`) admits one and refuses the other: the -//! retained lane bakes world positions on the CPU and the entity lane rotates on the GPU, and the -//! two agree only to the last few ULPs — enough, on coplanar triangles, to turn the depth test -//! into a per-pixel coin flip that re-rolls whenever the camera moves. That is the ballista's -//! flickering bolt heads and shields. -//! -//! So this prints, per model, every multi-batch section and whether its batches DISAGREE on the -//! facts the consolidators exclude on (env-mapped UVs, render flags `0x10` no-depth-write / `0x08` -//! no-depth-test, additive blend 3/4). A `SPLIT` line is a model that can z-fight against itself. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. +//! M2 batches that share a skin section, and so the same triangles, with another batch. The +//! reference draws both from one vertex array under depth-write and LEQUAL, so the later one wins +//! the tie exactly (`0x70b3f6`, `0x70c190`). We match that only when both take the same vertex +//! path: a consolidator (`static_gx`, `terrain_stream::merge`) bakes positions on the CPU, which +//! differ from the GPU's in the last ULPs, so a section it splits z-fights (a `SPLIT` line). +//! Output is Blizzard data: never commit it. +//! `cargo run -p benilla-formats --example m2_shared_section -- [path-substring]` use std::io::Cursor; -/// The facts every consolidator excludes on — a batch answering `true` can never ride the -/// retained/merged lanes, so it stays on the entity path whatever its siblings do. +/// What every consolidator excludes on: a `true` batch stays on the entity path whatever its +/// siblings do. fn refusable(model: &benilla_m2::M2Model, batch: &benilla_m2::SkinBatch) -> bool { let mat = model.materials.get(batch.material_index as usize); let flags = mat.map_or(0, |m| m.flags.bits()); diff --git a/crates/benilla-formats/examples/mm_seam_probe.rs b/crates/benilla-formats/examples/mm_seam_probe.rs index ef93d0a2..1ae79700 100644 --- a/crates/benilla-formats/examples/mm_seam_probe.rs +++ b/crates/benilla-formats/examples/mm_seam_probe.rs @@ -1,11 +1,7 @@ -//! TEMP (B141): rasterise the WMO-interior minimap **offline**, from the shipped tiles and the -//! client's own placement + alpha test, so "where do the black lines come from" is a measurement on -//! the data rather than a reading of a screenshot. -//! -//! Writes three PNGs: `colour` (what the composite would show), `cover` (white where SOME tile -//! passes the 224/255 test), and `any` (white where some tile has alpha > 0 at all). A line that is -//! black in `cover` but white in `any` is the alpha test cutting art that exists; black in both is -//! a genuine hole in the bake — or a placement that never covers that spot. +//! The WMO-interior minimap rasterised offline from the shipped tiles, with the client's placement +//! and 224/255 alpha test, written as `mm_{colour,cover,any,owner,seam}.png` under `MM_OUT`. Black +//! in `cover` (some tile passes the test) but white in `any` (some tile has alpha) is the test +//! cutting art; black in both is a hole in the bake or a spot no placement covers. use std::collections::BTreeMap; use std::io::Cursor; @@ -54,7 +50,6 @@ fn model_to_world(rot_deg: [f32; 3], p: [f32; 3]) -> [f32; 3] { let p = rot_y(p, ry - std::f32::consts::PI); rot_x(p, std::f32::consts::FRAC_PI_2) } -/// Inverse of [`model_to_world`] (the rotation is orthonormal, so: same angles, reversed order). fn world_to_model(rot_deg: [f32; 3], p: [f32; 3]) -> [f32; 3] { let (rx, ry, rz) = ( rot_deg[0].to_radians(), @@ -81,7 +76,6 @@ fn world_to_model(rot_deg: [f32; 3], p: [f32; 3]) -> [f32; 3] { struct Tile { gi: usize, - /// The drawn rect (grid cell + outer-edge bleed). x0: f32, y0: f32, tw: f32, @@ -106,7 +100,7 @@ fn main() -> anyhow::Result<()> { let mut chain = benilla_formats::open_chain(&data)?; let trs = benilla_formats::load_minimap_translate(&mut chain)?; - // Find the placement whose model bbox contains the pin, over every WMO placed in this ADT. + // The WMO placement in this ADT whose origin is nearest the pin. let (tc, tr) = benilla_formats::world_to_tile(pin[0], pin[1]); let adt_name = format!("World\\Maps\\Azeroth\\Azeroth_{tc}_{tr}.adt"); let bytes = chain.read_file(&adt_name)?; @@ -127,8 +121,7 @@ fn main() -> anyhow::Result<()> { } } let (model, origin, rot, _) = best.expect("no WMO placement in this ADT"); - // MM_MODEL="x,y[,z]" pins in the WMO's MODEL space instead — how a captured reference frame - // states the player's position, so a capture can be reproduced exactly. + // `MM_MODEL="x,y[,z]"` pins in the WMO's model space instead, as a reference capture states it. let pm = match std::env::var("MM_MODEL") { Ok(v) => { let n: Vec = v.split(',').map(|t| t.trim().parse().unwrap()).collect(); @@ -163,8 +156,7 @@ fn main() -> anyhow::Result<()> { } eprintln!("{} groups, model bbox {glo:?} .. {ghi:?}", infos.len()); - // MM_AUDIT: our computed per-group tile grid against the one actually authored in the trs. - // A short grid leaves world the composite can never cover, whatever the group selection does. + // `MM_AUDIT`: our per-group tile grid against the one authored in the translate table. if std::env::var("MM_AUDIT").is_ok() { let (mut bad, mut tiles_ours, mut tiles_theirs) = (0usize, 0usize, 0usize); for (gi, info) in infos.iter().enumerate() { @@ -203,10 +195,7 @@ fn main() -> anyhow::Result<()> { return Ok(()); } - // MM_BLP: what the tile art actually declares and holds — the alpha histogram of every tile - // near the pin, so "transparent" is a fact about the file rather than about our decoder. - // MM_FIT: the authored tile's PIXEL size against the group's world extent — i.e. what the bake's - // real yards-per-texel is, per group, instead of the 0.5 we assume. + // `MM_FIT`: each group's real yards per texel, from its authored tile size and its extent. if std::env::var("MM_FIT").is_ok() { println!(" gi authored px grid extent yd yd/texel (x, y)"); let mut ratios: Vec = Vec::new(); @@ -237,7 +226,7 @@ fn main() -> anyhow::Result<()> { info.bbox_max[0] - info.bbox_min[0], info.bbox_max[1] - info.bbox_min[1], ); - // Tile art is transposed vs the model axes: image WIDTH runs along model Y, HEIGHT along X. + // This read pairs model X with image height and rows: the layout below, transposed. let (nx, ny) = ((mc + 1) as f32, (mr + 1) as f32); let ypt_x = ex / (dim.1 as f32 * ny); let ypt_y = ey / (dim.0 as f32 * nx); @@ -265,9 +254,7 @@ fn main() -> anyhow::Result<()> { return Ok(()); } - // MM_ANCHOR: where inside its power-of-two texture does a single-tile group's OPAQUE art sit, - // and how big is it — against the group's own extent at 0.5 yd/texel. This is what says whether - // the bake pads (art at one corner, transparent remainder) or stretches, and which corner. + // `MM_ANCHOR`: where a single-tile group's opaque art sits in its power-of-two texture. if std::env::var("MM_ANCHOR").is_ok() { println!(" gi tex opaque box (u0,u1)x(v0,v1) extent/0.5 texels fill"); for (gi, info) in infos.iter().enumerate().take(40) { @@ -311,8 +298,7 @@ fn main() -> anyhow::Result<()> { return Ok(()); } - // MM_MULTI: the same anchor read on MULTI-tile groups, which is what says which name index runs - // along which model axis and where the power-of-two padding lands across a row of tiles. + // `MM_MULTI`: the same on multi-tile groups, for which name index runs along which axis. if std::env::var("MM_MULTI").is_ok() { let mut shown = 0; for (gi, info) in infos.iter().enumerate() { @@ -369,6 +355,7 @@ fn main() -> anyhow::Result<()> { return Ok(()); } + // `MM_BLP`: each tile's declared header and alpha histogram, as the file holds them. if std::env::var("MM_BLP").is_ok() { for gi in 0..infos.len() { for c in 0..4u32 { @@ -414,14 +401,10 @@ fn main() -> anyhow::Result<()> { let (ny, twy) = group_axis_grid(info.bbox_max[1] - info.bbox_min[1]); for col in 0..nx { for row in 0..ny { - // MEASURED tile layout (MM_ANCHOR / MM_MULTI, read off the shipped art): image u - // runs along model +X with u_global = 0 at the group's bbox X-MIN, column index - // advancing with +X; image v runs along model Y INVERTED, the grid anchored at its - // BOTTOM so the last row's last texel sits at the group's Y anchor. - // The client's rect: cells stride `tw` and share their interior edges, and a cell - // on the grid's boundary is grown 1.0 yd on that side alone (`0x6a5270`). - // `MM_NOBLEED` drops the bleed, which is what B141 was: without it two groups' art - // abuts instead of overlapping by 2 yd. + // Measured off the shipped art: image u runs along model +X from the bbox X-min, + // columns advancing with +X; v runs along model Y inverted. Cells stride `tw` and + // share interior edges; a boundary cell grows 1.0 yd on its outer side + // (`0x6a5270`), and `MM_NOBLEED` drops that, so neighbouring groups only abut. let bleed = if std::env::var("MM_NOBLEED").is_ok() { 0.0 } else { @@ -474,7 +457,8 @@ fn main() -> anyhow::Result<()> { for k in cache.iter().filter(|(_, v)| v.is_none()).take(4) { eprintln!(" MISS {}", k.0); } - // Same order the app draws in: by group bbox Z-mid relative to the player, ascending. + // Farthest group Z-mid first. The reference paints ascending by signed `Zmid - playerZ`, the + // player's own group last (`0x4ebeb0`). tiles.sort_by(|a, b| { (a.midz - pm[2]) .abs() @@ -488,8 +472,7 @@ fn main() -> anyhow::Result<()> { let mut anyv = vec![0u8; (px * px) as usize]; let mut owner = vec![0u8; (px * px * 3) as usize]; let bilinear = std::env::var("MM_BILINEAR").is_ok(); - // OPTIONAL UNDERLAY: the outdoor ADT minimap tiles (`Azeroth\\map_.blp`), sampled through - // the placement so they land under the WMO tiles in the same frame. `MM_TERRAIN=1`. + // `MM_TERRAIN`: an outdoor ADT tile underlay; the reference's interior composite has none. if std::env::var("MM_TERRAIN").is_ok() { let mut adt_cache: BTreeMap<(u32, u32), Option>> = BTreeMap::new(); for iy in 0..px { @@ -533,12 +516,8 @@ fn main() -> anyhow::Result<()> { if mx < t.x0 || mx >= t.x0 + t.tw || my < t.y0 || my >= t.y0 + t.th { continue; } - // MM_BILINEAR reproduces the reference's LINEAR sampler (CLAMP_TO_EDGE), so the - // alpha the 224/255 test sees is the interpolated one — which is what decides how - // far a tile's opaque art actually reaches past its last opaque texel CENTRE - // (0.122 of a texel at ref 224, against nearest's half-texel to the texel EDGE). - // The client samples `[0.5/W, 1−0.5/W]` across the drawn rect, so the W texel - // CENTRES span it exactly: step = rect / (W−1), texel 0's centre on the rect edge. + // `MM_BILINEAR` is the reference's LINEAR, CLAMP_TO_EDGE sampler. The client + // samples `[0.5/W, 1-0.5/W]` across the drawn rect, so its W texel centres span it. let fu = (mx - t.x0) / (t.tw / (t.w as f32 - 1.0)); let fv = (t.y0 + t.th - my) / (t.th / (t.h as f32 - 1.0)); let (u, v) = ( @@ -572,9 +551,7 @@ fn main() -> anyhow::Result<()> { } } } - // A HAIRLINE is an uncovered run at most 4 px wide with coverage on BOTH sides — a seam - // between two tiles, as opposed to the large unauthored gaps (streets, the exterior group) - // that are simply not part of any interior group's bake. + // A hairline is an uncovered run of at most 4 px with coverage on both sides: a tile seam. let mut seam = colour.clone(); let mut hair = 0usize; let mut runs: BTreeMap = BTreeMap::new(); @@ -620,8 +597,8 @@ fn main() -> anyhow::Result<()> { px, image::ColorType::Rgb8, )?; - // The VISIBLE DISC — what the blit actually shows: radius `MM_DISC` yards about the player. - // The reference capture reports 95.84% painted / 4.16% clear over exactly this. + // `MM_DISC`: the painted share of the visible disc of that radius in yards; the reference + // leaves 4.16% of it clear in its Stormwind capture. if let Ok(v) = std::env::var("MM_DISC") { let rd: f32 = v.parse().unwrap(); let c0 = px as f32 * 0.5; diff --git a/crates/benilla-formats/examples/mm_water_pin.rs b/crates/benilla-formats/examples/mm_water_pin.rs index 8c337ee1..77f63720 100644 --- a/crates/benilla-formats/examples/mm_water_pin.rs +++ b/crates/benilla-formats/examples/mm_water_pin.rs @@ -1,6 +1,6 @@ -//! TEMP (B141/N05): what the WMO's liquid actually looks like around a pin — which groups hold -//! water there, their MOGP flags/liquid type, their MLIQ base heights, and whether two surfaces -//! abut, overlap or step. Run: `cargo run -p benilla-formats --example mm_water_pin -- x y z [r]`. +//! The WMO liquid around a pin: the groups holding water there, their MOGP flags, liquid type and +//! MLIQ heights, and how many surfaces claim each point. +//! `cargo run -p benilla-formats --example mm_water_pin -- x y z [r]` use std::io::Cursor; const MAP_CENTER: f32 = 17066.666; @@ -146,9 +146,7 @@ fn main() -> anyhow::Result<()> { ); } - // How many DISTINCT groups claim each point as wet? Two coplanar surfaces over the same ground - // are drawn twice — a transparent water quad composited twice is visibly darker, and at equal - // depth they also z-fight. Sampled on a 0.25 yd lattice over the pin's window. + // Groups claiming each point as wet; a point claimed twice draws darker and z-fights. let step = 0.25f32; let n = (2.0 * r / step) as i32; let mut hist = std::collections::BTreeMap::::new(); @@ -201,7 +199,6 @@ fn main() -> anyhow::Result<()> { Ok(()) } -/// The cell index `(mx, my)` falls in, if it is inside the grid at all. fn cell_at(m: &benilla_formats::LiquidMesh, mx: f32, my: f32) -> Option { let (cols, rows) = (m.grid[0] as usize, m.grid[1] as usize); if cols < 2 || rows < 2 { diff --git a/crates/benilla-formats/examples/order_probe.rs b/crates/benilla-formats/examples/order_probe.rs index 0f890f5e..1b5d7d47 100644 --- a/crates/benilla-formats/examples/order_probe.rs +++ b/crates/benilla-formats/examples/order_probe.rs @@ -1,10 +1,7 @@ -//! Throwaway: the sort/admission inputs the trainer & craft windows key on. Two modes: -//! -//! * `line ...` — a line's abilities with their raw -//! `SkillLineAbility.dbc` columns (`req_skill_value` col 7, `reqtrainpoints` col 14), their -//! `Spell.dbc` name/rank, `castUI`, `attributes` and `spellLevel`. -//! * `castui ` — every spell whose `castUI == n`, with the skill line (if any) its first -//! `SkillLineAbility` row names: the client's craft-admission key vs benilla's SLA join. +//! The inputs the trainer and craft windows sort and admit on. `line ...` prints a +//! line's abilities with `SkillLineAbility.dbc` columns 7 (`req_skill_value`) and 14 +//! (`reqtrainpoints`) and their spell's `castUI`, attributes and level; `castui ` prints every +//! spell with that `castUI`, the client's craft admission key, beside its skill line. fn main() -> anyhow::Result<()> { let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); let mut chain = benilla_formats::open_chain(&data)?; diff --git a/crates/benilla-formats/examples/png_disc.rs b/crates/benilla-formats/examples/png_disc.rs index 1c673603..f04a7f39 100644 --- a/crates/benilla-formats/examples/png_disc.rs +++ b/crates/benilla-formats/examples/png_disc.rs @@ -1,6 +1,6 @@ -//! TEMP (B141): what fraction of a captured minimap disc is the composite's black clear. -//! `cargo run -p benilla-formats --example png_disc -- [cx cy r]` — defaults to the whole -//! image's inscribed circle, which is what the `WOW_MM_PROBE` crop is. +//! The share of a captured minimap disc that is the composite's black clear; the default circle is +//! the image's inscribed one, which is the `WOW_MM_PROBE` crop. +//! `cargo run -p benilla-formats --example png_disc -- [cx cy r]` fn main() -> anyhow::Result<()> { let a: Vec = std::env::args().skip(1).collect(); let img = image::open(&a[0])?.to_rgb8(); diff --git a/crates/benilla-formats/examples/scan_events.rs b/crates/benilla-formats/examples/scan_events.rs index db55a8ac..f4440e0c 100644 --- a/crates/benilla-formats/examples/scan_events.rs +++ b/crates/benilla-formats/examples/scan_events.rs @@ -1,12 +1,7 @@ -//! Which models author a given M2 animation event? `cargo run -p benilla-formats --example -//! scan_events -- '$CCH' weapon` — scans every `.m2` in the 5875 chain whose path contains the -//! (optional, case-insensitive) substring and prints each model authoring the 4CC, with the -//! event's bone and raw WoW position. The sibling of `dump_attach`: that answers "what does THIS -//! model author", this answers "who else authors THIS event" — the question every event-keyed -//! consumer (bowstring `$WTT`, fishing line `$CCH`, GO sound slots `$GC0`) eventually asks when -//! deciding whether the ident alone is a safe gate. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. +//! Every M2 authoring a given animation-event 4CC, with the event's bone and raw position, to +//! check whether the ident alone is a safe gate (`$WTT` bowstring, `$CCH` fishing line). +//! `cargo run -p benilla-formats --example scan_events -- '$CCH' [path-substring]` +//! Output is Blizzard data: never commit it. fn le_u32(b: &[u8], o: usize) -> u32 { u32::from_le_bytes(b[o..o + 4].try_into().unwrap()) diff --git a/crates/benilla-formats/examples/seamswing.rs b/crates/benilla-formats/examples/seamswing.rs index cdb65c66..6ba18fe6 100644 --- a/crates/benilla-formats/examples/seamswing.rs +++ b/crates/benilla-formats/examples/seamswing.rs @@ -1,26 +1,14 @@ -//! Where does a welded billboard bone's geometry SIT relative to its pivot — how far, and along -//! which local axis? -//! -//! Two readings, and the second is the one that decides anything. **Distance** answers the seam -//! question: a partially-weighted seam vertex is dragged by its weight times the re-orientation and -//! linear-blend skinning shrinks it toward the pivot on the way (the candy-wrapper), so a seam ring -//! sitting ON the pivot hinges cleanly and one sitting far out tears. -//! -//! **Direction answers whether the geometry moves at all.** A spherical billboard maps the bone's -//! local axes onto fixed camera axes, so an offset lying along a single local axis lands on the same -//! camera axis from every angle: it does not sweep, it only loses its roll and its foreshortening. -//! Only an offset spread across the axes sweeps. Magnitude alone cannot tell those apart, and -//! reading a 0.29 yd radius as a 0.29 yd arc is exactly how decision 0847 withdrew a correct change -//! — the spikes it measured run **along** their bone (worst vertex 12° off axis), so the sweep it -//! described never existed (the spherical basis `0x71547c`, and this tool's own output). -//! -//! Usage: `cargo run -p benilla-formats --example seamswing -- ` +//! Where a welded billboard bone's geometry sits relative to its pivot: how far, and along which +//! local axis. Distance decides the seam, since linear-blend skinning drags a partly weighted +//! vertex toward the pivot. Direction decides whether the geometry moves at all: a spherical +//! billboard (`0x71547c`) maps local axes onto fixed camera axes, so an offset along one axis only +//! loses its roll and foreshortening, while one spread across the axes sweeps. +//! `cargo run -p benilla-formats --example seamswing -- ` use benilla_formats::open_chain; fn main() -> anyhow::Result<()> { let mut args = std::env::args().skip(1); - // An explicit dir still wins; with none, ask the one resolver (decision 1175). let data = args .next() .map(std::path::PathBuf::from) @@ -37,8 +25,7 @@ fn main() -> anyhow::Result<()> { for &b in &denied { let bone = &model.bones[b as usize]; let piv = [bone.pivot.x, bone.pivot.y, bone.pivot.z]; - // Every vertex with any weight on this bone: its weight, and its distance from the pivot — - // which is the radius the re-orientation sweeps it around. + // Every vertex weighted to this bone, with its weight and its distance from the pivot. let (mut pure, mut seam) = (Vec::new(), Vec::new()); for v in &model.vertices { for i in 0..4 { @@ -83,12 +70,7 @@ fn main() -> anyhow::Result<()> { if let Some(&(w, _)) = seam.first() { println!(" seam weight on this bone: {w:.2}"); } - // DIRECTION, which is what actually decides whether re-orientation moves the geometry. - // A spherical billboard maps the bone's local axes to fixed camera axes, so an offset that - // lies along ONE local axis lands on that camera axis from every angle: it does not sweep, - // it only loses its roll and its foreshortening. An offset spread across the axes does - // sweep. Magnitude alone cannot tell these apart — decision 0847 read a 0.29 yd radius as a - // 0.29 yd arc and withdrew a correct change on the strength of it. + // Direction: the local axis the offsets run along, and how far the worst vertex strays. if !pure.is_empty() { let mut lo = [f32::MAX; 3]; let mut hi = [f32::MIN; 3]; @@ -98,10 +80,8 @@ fn main() -> anyhow::Result<()> { hi[k] = hi[k].max(o[k]); } } - // The DIRECTION axis is the one the offsets are largest along — NOT the one they are - // most spread across. Those differ here and confusing them inverts the reading: this - // spike runs along z (|z| ≈ 0.29) but is widest across y (spread 0.105, its cross - // section). Report both, named, so neither can be mistaken for the other. + // The axis the offsets are largest along is not the one they spread widest across (a + // spike's cross section); both print, named. let mean_abs = |k: usize| pure.iter().map(|&(_, o)| o[k].abs()).sum::() / pure.len() as f32; let along = (0..3) @@ -110,8 +90,6 @@ fn main() -> anyhow::Result<()> { let widest = (0..3) .max_by(|&a, &c| (hi[a] - lo[a]).total_cmp(&(hi[c] - lo[c]))) .unwrap(); - // How far the WORST vertex strays from that one axis — the number that decides whether - // "runs along a single axis" is a fair description of the whole batch or only its mean. let worst_deg = pure .iter() .filter(|&&(d, _)| d > 1e-6) diff --git a/crates/benilla-formats/examples/spell_interrupt.rs b/crates/benilla-formats/examples/spell_interrupt.rs index 7cf57532..238583fa 100644 --- a/crates/benilla-formats/examples/spell_interrupt.rs +++ b/crates/benilla-formats/examples/spell_interrupt.rs @@ -1,5 +1,4 @@ -//! Throwaway: print the interrupt-flag columns for named spell ids, so a claim about what -//! cancels a spell locally can be checked against the real shipped Spell.dbc. +//! The interrupt-flag and attribute columns of `Spell.dbc` for the given spell ids. fn main() -> anyhow::Result<()> { let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); let mut chain = benilla_formats::open_chain(&data)?; diff --git a/crates/benilla-formats/examples/spell_row.rs b/crates/benilla-formats/examples/spell_row.rs index e08de5b0..375f586a 100644 --- a/crates/benilla-formats/examples/spell_row.rs +++ b/crates/benilla-formats/examples/spell_row.rs @@ -1,7 +1,5 @@ -//! Throwaway: print the cast-arm targeting facts for named spell ids — `Targets`, -//! `EffectImplicitTargetA[0]`, the three effects, and the item-target gate columns — so a claim -//! about which targeting seam a spell arms can be checked against the real shipped Spell.dbc -//! instead of reasoned about. +//! The targeting columns of `Spell.dbc` for the given spell ids: `Targets`, +//! `EffectImplicitTargetA[0]`, the first effect and the equipped-item gate columns. fn main() -> anyhow::Result<()> { let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); let mut chain = benilla_formats::open_chain(&data)?; diff --git a/crates/benilla-formats/examples/srci_dump.rs b/crates/benilla-formats/examples/srci_dump.rs index 59279abb..be56dfd3 100644 --- a/crates/benilla-formats/examples/srci_dump.rs +++ b/crates/benilla-formats/examples/srci_dump.rs @@ -1,6 +1,6 @@ -//! Throwaway: dump `SkillRaceClassInfo.dbc` rows admitting a race/class, with the flags word in -//! hex — the table the client's `GetSkillLineInfo` reads for its 0x400 single-rank cap and 0x20 -//! unlearnable bit. Usage: `cargo run -p benilla-formats --example srci_dump -- `. +//! `SkillRaceClassInfo.dbc` rows admitting a race and class, flags in hex: `GetSkillLineInfo` reads +//! its 0x400 single-rank cap and 0x20 unlearnable bit here. +//! `cargo run -p benilla-formats --example srci_dump -- ` fn main() -> anyhow::Result<()> { let mut args = std::env::args().skip(1); let race: u32 = args.next().unwrap_or_else(|| "4".into()).parse()?; @@ -8,7 +8,6 @@ fn main() -> anyhow::Result<()> { let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); let mut chain = benilla_formats::open_chain(&data)?; - // Skill line names, so the dump reads. let lines = chain.read_file("DBFilesClient\\SkillLine.dbc")?; let name_of = |id: u32| -> String { let g = |b: &[u8], o: usize| u32::from_le_bytes(b[o..o + 4].try_into().unwrap()); @@ -48,7 +47,7 @@ fn main() -> anyhow::Result<()> { g(base + 20), g(base + 24), ); - // race/class 0 = dump EVERY row (masks unfiltered, duplicates kept). + // Race or class 0 dumps every row, unfiltered, duplicates kept. let all = race == 0 || class == 0; if !all && (race_mask & rmask == 0 || class_mask & cmask == 0 || seen.contains(&skill)) { continue; diff --git a/crates/benilla-formats/examples/surface_here.rs b/crates/benilla-formats/examples/surface_here.rs index 01e3c51d..2ee75e2b 100644 --- a/crates/benilla-formats/examples/surface_here.rs +++ b/crates/benilla-formats/examples/surface_here.rs @@ -1,28 +1,14 @@ -//! What surface the footstep chain resolves under a patch of world: +//! The footstep surface the ADT leg resolves around a pin, as a top-down map of `TerrainType`s +//! (north up, west left), and at the pin the whole chain to the sound kit. The client arbitrates +//! this against a WMO probe and the nearer surface wins, so indoors this is the ground under the +//! floor; `RUST_LOG=benilla_app::sound=debug` logs which leg answered in a live run. Output is +//! Blizzard data: never commit it. //! `cargo run -p benilla-formats --example surface_here -- [radius_yd] [class]` -//! e.g. `surface_here Azeroth -5595.89 -529.48 24`. -//! -//! A footstep bug is reported as a *sound* ("I'm walking on snow indoors") but it is decided far -//! upstream, by which surface the down-ray claims under the foot. This prints that claim as a -//! top-down map — north up, west left — so the answer is a picture of the ground rather than an -//! impression of the audio: per cell the `TerrainType` the chain lands on, and at the pin the full -//! walk through to the `SoundEntries` kit that would actually play. -//! -//! **This is the ADT leg only** — deliberately. The client (and, since decision 1161, benilla) -//! arbitrates this against a WMO probe and the nearer surface wins, so inside a building the live -//! answer comes from the floor, not from here. What this map shows indoors is the ground *under* -//! that floor: the counterfactual. That is what makes a wrong-surface report legible — B236's -//! sequel was "snow indoors", and this printed 1681 of 1681 cells Snow beneath the Kharanos inn. -//! To see which leg actually answered in a live run, use `RUST_LOG=benilla_app::sound=debug` and -//! read the `footstep: wmo|adt terrain N` line. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. use std::collections::HashMap; -/// `TerrainType.SoundID` → its authored name and map glyph. The domain is closed and verified -/// against build 5875 (`benilla_formats::footsteps` module docs): the eleven rows collapse to ten -/// sound classes, `DustyGrass` sharing `Grass`'s. +/// `TerrainType.SoundID` to its name and map glyph; the eleven shipped rows use ten sound classes, +/// `DustyGrass` sharing `Grass`'s. fn class_glyph(sound_class: u32) -> (char, &'static str) { match sound_class { 0 => ('.', "None"), @@ -47,8 +33,7 @@ fn main() -> anyhow::Result<()> { let x: f32 = args.next().ok_or_else(usage)?.parse()?; let y: f32 = args.next().ok_or_else(usage)?.parse()?; let radius: f32 = args.next().map_or(Ok(20.0), |r| r.parse())?; - // 7 is the humanoid/character class (its ten rows are the `CharacterMediumLarge*` kits); 8 is - // the small-character class. Both reach it as ordinary data, never a code default. + // Class 7 is the character class (the `CharacterMediumLarge*` kits), 8 the small character. let class: u32 = args.next().map_or(Ok(7), |c| c.parse())?; let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); @@ -56,8 +41,7 @@ fn main() -> anyhow::Result<()> { let cat = benilla_formats::load_footstep_catalog(&mut chain)?; let tiles = benilla_formats::MapTiles::load(&mut chain, &map)?; - // One sample per cell of a square centred on the pin, north (+x) up and west (+y) left — the - // orientation the in-game map uses, so a spot on this grid is a spot on their screen. + // One sample per cell of a square on the pin, north (+x) up and west (+y) left, as the map. let cols = 41usize; let step = (radius * 2.0) / (cols - 1) as f32; let mut meshes: HashMap<(u32, u32), Option> = HashMap::new(); @@ -90,7 +74,7 @@ fn main() -> anyhow::Result<()> { *census.entry(terrain).or_default() += 1; let glyph = match terrain.and_then(|t| cat.sound_class_of(t)) { Some(sc) => class_glyph(sc).0, - // No effect layer / unknown terrain — the client's −1 sentinel, a SILENT footfall. + // No effect layer or unknown terrain: the client's -1 sentinel, a silent footfall. None => ' ', }; line.push(glyph); @@ -118,7 +102,7 @@ fn main() -> anyhow::Result<()> { println!(" {n:5} {label}"); } - // The pin itself, walked end to end — the line a bug report can be checked against. + // The pin itself, walked end to end. let effect = sample(&mut chain, &mut meshes, x, y); println!("\nat the pin exactly:"); println!(" GroundEffectTexture: {effect:?}"); diff --git a/crates/benilla-formats/examples/water_here.rs b/crates/benilla-formats/examples/water_here.rs index 8ef2c692..b0f66157 100644 --- a/crates/benilla-formats/examples/water_here.rs +++ b/crates/benilla-formats/examples/water_here.rs @@ -1,43 +1,15 @@ -//! What the ADT liquid actually **is** under a pin — the shoreline instrument: +//! The ADT liquid under a pin against the terrain it lies on: the MCLQ blocks, overlapping and +//! stepped cells, and where the 4.167-yd wet-cell lattice ends short of or past the waterline. +//! Output is Blizzard data: never commit it. //! `cargo run -p benilla-formats --example water_here -- [radius_yd]` -//! e.g. `water_here Azeroth -12512.69 -180.21 40`. -//! -//! A water-at-the-shore report ("there's a step in it", "the foam runs past the waterline") is a -//! question about three surfaces that only ever agree in the middle of a lake: the MCLQ **wet-cell -//! lattice** (4.167-yd cells, the granularity everything on the water path clips to), the liquid -//! **surface height** on those cells, and the **terrain** underneath. This prints all three over one -//! neighbourhood so the geometry can be read instead of guessed: -//! -//! - the **block census** — one line per MCLQ block: kind, wet cells, surface-height span, depth-byte -//! span. Where a step would be *authored*, this is where it shows: two blocks over one chunk, or a -//! block whose own plane has relief; -//! - the **overlap** scan — cells claimed by two blocks at once, which would composite the -//! translucent water twice; -//! - the **seam** scan — every pair of adjacent wet cells whose planes disagree, so an authored step -//! can be told apart from a rendered one before anyone opens the renderer; -//! - the **lattice-vs-waterline** scan — dry cells the ground still runs under. Whole wet cells are -//! the only thing the renderer draws, so wherever the lattice runs out before the ground climbs -//! through the plane, the water ends on a 4.167-yd straight edge in open water: a shape no -//! shoreline has; -//! - its **mirror** — wet cells the ground climbs out of, which is the skirt of liquid-lattice -//! geometry that lies over dry sand. Nothing renders there only because the terrain drew first -//! and won the depth test, so this is the exact budget a depth bias spends: a decal pulled `b` -//! yards toward the eye paints `b / slope` yards of that skirt; -//! - the **shoreline slope** at the pin, and what a coplanarity *lift* costs there. A decal held `l` -//! yards above the water plane keeps painting for `l / slope` yards past the waterline, on dry -//! ground — the arithmetic behind B348's foam-on-the-sand, and the reason that settle is a depth -//! bias now and not a lift. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. use std::collections::BTreeMap; use benilla_formats::{terrain_height_at, ChunkMesh, CHUNK_SIZE}; -/// One MCLQ cell edge, in yards — the granularity of `wet`, and of every clip on the water path. +/// One MCLQ cell edge in yards, the granularity of `wet` and of every clip on the water path. const CELL: f32 = CHUNK_SIZE / 8.0; -/// How far past the reported radius the block census still enumerates, so that a cell at the edge -/// of the answer has its real neighbours to be judged against (one chunk clears it). +/// How far past the radius the census enumerates, so an edge cell has its real neighbours. const MARGIN: f32 = CHUNK_SIZE; /// One seam between two adjacent wet cells whose surface planes disagree. @@ -50,14 +22,13 @@ struct Seam { /// One MCLQ cell of one block, resolved into world terms. struct Cell { - /// Cell-centre world XY. x: f32, y: f32, - /// The block's surface height over the cell (bilinear at the centre, as the queries sample it). + /// Surface height at the cell centre, bilinear, as the queries sample it. surface: f32, /// Terrain height at the same point, `None` where the tile has a hole. ground: Option, - /// Which chunk/block this cell came from — `(chunk index, block index)`. + /// `(chunk index, block index)`. owner: (usize, usize), kind: benilla_formats::LiquidKind, } @@ -86,10 +57,7 @@ fn main() -> anyhow::Result<()> { let Some(nw) = chunk.positions.first() else { continue; }; - // The chunk's own footprint against the pin box (chunk spans south/east of its NW corner), - // widened by MARGIN so every cell inside the radius has its real neighbours loaded — a cell - // at the box edge would otherwise read as "the lattice stops here" when it is only the - // enumeration that stopped. + // The chunk's footprint (south and east of its NW corner) against the box plus `MARGIN`. if nw[0] - CHUNK_SIZE > x + radius + MARGIN || nw[0] < x - radius - MARGIN || nw[1] - CHUNK_SIZE > y + radius + MARGIN @@ -158,8 +126,7 @@ fn main() -> anyhow::Result<()> { } // ── Overlap: two blocks claiming one patch of world, at two heights ────────────────────── - // Key a cell by its lattice index, floored — two DIFFERENT cells of one block must never - // collide, or the overlap report cries wolf on its own rounding. + // Key a cell by its floored lattice index, so two cells of one block never collide. let key = |c: &Cell| ((c.x / CELL).floor() as i32, (c.y / CELL).floor() as i32); let mut by_key: BTreeMap<(i32, i32), Vec> = BTreeMap::new(); for (n, c) in cells.iter().enumerate() { @@ -212,12 +179,9 @@ fn main() -> anyhow::Result<()> { println!(" {:?} -> {:?} dz {:+.3} yd", s.from, s.to, s.dz); } - // ── Where the LATTICE stops vs where the WATERLINE is ──────────────────────────────────── - // The renderer draws whole wet cells and nothing else, so the water's visible boundary is the - // *lattice* edge wherever the lattice runs out before the ground climbs through the plane. That - // edge is a 4.167-yd straight line in open water — the shape a shoreline never has. Sample every - // dry cell that touches a wet one on a 0.25-yd grid and report how deep the water would have - // been there. + // ── Where the lattice stops vs where the waterline is ──────────────────────────────────── + // The renderer draws whole wet cells only, so where the lattice ends before the ground rises + // through the plane the water ends on a straight cell edge; sample each dry neighbour's depth. let mut short_cells: Vec<(f32, (i32, i32))> = Vec::new(); for k in by_key.keys().copied().collect::>() { for nb in [ @@ -260,13 +224,9 @@ fn main() -> anyhow::Result<()> { ); } - // ── The mirror: WET cells the ground climbs out of ─────────────────────────────────────── - // The scan above finds water that is missing; this one finds water geometry that is *over dry - // land*. Whole wet cells are the unit the renderer draws, so wherever the ground climbs through - // the plane inside a cell, the liquid mesh — and every decal built on the same lattice — carries - // a skirt of geometry up the beach. Nothing renders there only because the terrain drew first - // and won the depth test, which makes this number the exact **budget a depth bias spends**: a - // decal pulled `b` yards toward the eye paints `b / slope` yards of that skirt onto the sand. + // ── The mirror: wet cells the ground climbs out of ─────────────────────────────────────── + // The liquid mesh and every decal on its lattice carry a skirt up the beach that only the depth + // test hides, so a decal pulled `b` yards toward the eye paints `b / slope` yards of it. let mut skirts: Vec<(f32, f32, (i32, i32))> = Vec::new(); // (height above plane, run inland, cell) for (k, idxs) in by_key.iter() { let c = &cells[idxs[0]]; @@ -319,9 +279,8 @@ fn main() -> anyhow::Result<()> { } // ── The waterline's slope, and what a coplanarity lift costs horizontally ──────────────── - // A decal on the water plane, lifted `l` yards to win the coplanar tie, keeps painting for - // `l / slope` yards past the waterline — up the beach, on dry ground. Measure the slope where - // the plane actually meets the ground nearest the pin. + // A decal lifted `l` yards off the water plane paints `l / slope` yards past the waterline; the + // slope is measured where the plane meets the ground nearest the pin. let mut best: Option<(f32, f32, f32, f32)> = None; // (dist to pin, x, y, slope) for c in &cells { let Some(g) = c.ground else { continue }; diff --git a/crates/benilla-formats/examples/what_is_here.rs b/crates/benilla-formats/examples/what_is_here.rs index f9ed651d..a8bf63e6 100644 --- a/crates/benilla-formats/examples/what_is_here.rs +++ b/crates/benilla-formats/examples/what_is_here.rs @@ -1,14 +1,7 @@ -//! What is actually standing at a world coordinate: +//! The WMO and M2 doodad placements within a radius of a pin, nearest first. Two batches of one WMO +//! share its authored MOBA order; a WMO and a nearby doodad have no ordering relationship. +//! Output is Blizzard data: never commit it. //! `cargo run -p benilla-formats --example what_is_here -- [radius_yd]` -//! e.g. `what_is_here Kalimdor 295.72 -3689.61 60`. -//! -//! A bug report names a *place* — a `.go xyz` pin — and every scene-render diagnosis starts by -//! turning that pin into the **placements** behind it: which WMOs and which M2 doodads, with their -//! distance from the pin. The distinction is load-bearing for draw-order defects: two batches of one -//! WMO share the authored MOBA order the pipeline biases by, while a WMO and a nearby M2 doodad are -//! separate lanes with no ordering relationship at all. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. fn main() -> anyhow::Result<()> { let mut args = std::env::args().skip(1); @@ -24,8 +17,7 @@ fn main() -> anyhow::Result<()> { let (tx, ty) = tiles.tile_at(x, y); println!("{map}: pin ({x}, {y}) is tile {tx}_{ty}; scanning r={radius} yd across 3x3"); - // The pin can sit near a tile seam and the building it names be authored in the neighbour, so - // sweep the 3x3 — a placement is deduped by uniqueId, which is stable across the tiles it spans. + // Sweep the 3x3 tiles; a placement spanning several is deduped by its uniqueId. let mut seen_wmo = std::collections::HashSet::new(); let mut seen_doodad = std::collections::HashSet::new(); let mut wmos: Vec<(f32, String, [f32; 3], u32)> = Vec::new(); diff --git a/crates/benilla-formats/examples/where.rs b/crates/benilla-formats/examples/where.rs index 445dec61..930a350c 100644 --- a/crates/benilla-formats/examples/where.rs +++ b/crates/benilla-formats/examples/where.rs @@ -1,16 +1,6 @@ -//! `cargo run -p benilla-formats --example where` — **where does benilla think the install is?** -//! -//! The resolver's own answer, printed (decision 1175). Two callers, and both are the reason this -//! is a program rather than a comment: -//! -//! - `scripts/gates.sh`, to decide whether the enforcer gate can run. It used to hand-roll half -//! the rule (`[ -n "$WOW_DATA" ] || [ -d WoW/Data ]`), which is exactly the duplication 1175 -//! exists to end — a gate that disagrees with the client about where the install is will skip -//! on a machine where the client would have run, or run on one where it cannot. -//! - a human whose client came up with no world. "It looked in these four places and none of them -//! existed" is actionable; "the terrain just doesn't appear" is not. -//! -//! Exits **0** when an install was found and **1** when none was, so a shell can branch on it. +//! The install benilla's resolver finds, or every place it looked; exits 1 when there is none. +//! `scripts/gates.sh` asks this rather than repeating the rule. +//! `cargo run -p benilla-formats --example where` fn main() -> std::process::ExitCode { match benilla_formats::wow_data() { diff --git a/crates/benilla-formats/examples/wmo_antiportal_census.rs b/crates/benilla-formats/examples/wmo_antiportal_census.rs index 5badef28..be9f00fa 100644 --- a/crates/benilla-formats/examples/wmo_antiportal_census.rs +++ b/crates/benilla-formats/examples/wmo_antiportal_census.rs @@ -1,63 +1,13 @@ -//! **Do any 1.12.1 WMOs author an ANTIPORTAL group, and if so which MOGP bit says so?** -//! `cargo run -p benilla-formats --example wmo_antiportal_census`. +//! Whether any shipped WMO group has the wowdev `ANTIPORTAL` shape (no MOBA render batches, only a +//! MOGI box) and which MOGP flag bit, if any, marks it: a histogram of every bit, the batch-less +//! groups found without reading flags, and the two cross-tabulated. +//! `cargo run -p benilla-formats --example wmo_antiportal_census` //! -//! Later WoW documentation (wowdev.wiki `SMOGroupFlags`) names a high MOGP `flags` bit -//! `ANTIPORTAL`: a group with **no render batches at all**, kept only for its bounding box, which -//! the portal flood uses as an occluder rather than something it ever draws. Nothing confirms that -//! bit for build 5875 — the portal flood -//! ([`benilla_world::wmo_portal`]) only ever branches on `flags & 0x48` (EXTERIOR / EXTERIOR_LIT) -//! and never treats any group as occlusion-only. So the question is purely empirical: does the -//! shipped 5875 data contain such a group at all, and which bit(s) does it carry? -//! -//! **Bit-agnostic by construction.** Rather than assume the wowdev bit number applies unchanged to -//! this build, this walks every group of every root WMO in the install and builds two independent -//! signals, then cross-tabulates them: -//! -//! 1. A histogram of **every** MOGP flag bit (0..31) — how many groups set it, how many distinct -//! roots contain at least one. -//! 2. The **batch-less set** — every group whose MOGP-owned MOBA chunk has zero render batches, -//! found without looking at flags at all. This is the wowdev ANTIPORTAL *shape*: no geometry to -//! draw, kept only for `MOGI`'s bounding box. -//! -//! If 1.12 ships any antiportal-shaped groups, they land in the batch-less set regardless of which -//! bit (if any) names them, and the cross-tab against the flag histogram is what tells us — from -//! the data, not a doc — which bit (if any) 5875 actually uses for it. -//! -//! **The run (2026-09-01, 815/815 roots, 5220/5220 groups, zero read/parse failures): yes, but no -//! single bit names it.** 15 groups across 9 roots ship with zero MOBA render batches — Naxxramas' -//! `frostwyrm_final01.wmo` (7 tiny connector groups), three of AQ40's boss-encounter "enterance" -//! shells, and the three Hyjal `worldtreeroots` collision volumes among them (see the batch-less -//! census below for the full list) — so the wowdev *shape* is real in 5875. But the cross-tab finds -//! **no MOGP bit set by all 15 and rare among the other 5205**: bit 0 (`0x1`) is set on literally -//! every group in the corpus (batch-less or not) and so distinguishes nothing; EXTERIOR (`0x8`) -//! covers 13/15 but also 1052/5205 batch-having groups; every other bit covers fewer than a third of -//! the batch-less set. Whatever these 15 groups are for — several read as pure collision volumes by -//! name/folder (`collidabledoodads\hyjal\worldtreeroots`) rather than deliberate portal occluders — -//! 5875 does not flag them with a dedicated ANTIPORTAL bit the way later clients' `SMOGroupFlags` -//! documentation describes. That is a fact about the *data*, not yet about the *engine*: whether -//! `WoW.exe` 5875's occluder pass (if it has one) singles these 15 out by shape rather than by flag -//! is a question about the binary this census doesn't answer. -//! -//! Only three MOGP bits are named anywhere in benilla's own code today (verified by grep over -//! `benilla-world/src/wmo_portal`, `benilla-assets/src/wmo.rs`, and -//! `benilla-formats/src/models/wmo`, 2026-09-01): `0x8` EXTERIOR, `0x40` EXTERIOR_LIT -//! ([`benilla_world::wmo_portal`]'s flood-defer/lighting-class bits), and `0x40000` SHOW_SKYBOX -//! ([`benilla_formats::WmoGroupInfo::show_skybox`]). The other candidate bits a hypothetical -//! ANTIPORTAL census might reach for — `0x1` (HAS_BSP/MOBN), `0x4` (VERTEXCOLOR), `0x200` -//! (HAS_LIGHTS/MOLR), `0x800` (HAS_LIQUID/MLIQ), `0x2000` (INTERIOR), `0x800000`/`0x1000000` -//! (second MOCV/MOTV) — are *not* named or tested anywhere in our reader: we discover MOLR/MLIQ/a -//! second MOTV by the sub-chunk's presence, never by these flag bits, and INTERIOR is our own -//! `flags & 0x48 == 0` derived test, not a single bit. None of those six get a name in the table -//! below; only a bit our code already states gets one, per the census's own charter. -//! -//! Every field per group comes from the same bytes the runtime reads: [`wmo_group_header`]'s -//! `flags` (MOGP `+0x08`, full 32 bits — **not** the two-bit-derived `interior`/`show_skybox` bools -//! `parse_wmo_root`'s MOGI reader exposes), `benilla_wmo::parse_wmo`'s `render_batches`/ -//! `vertex_positions` (MOBA/MOVT), `wmo_group_header`'s `portal_ref_count` (MOPR, sliced from the -//! group's own header span), and the root's [`WmoGroupInfo`] bounding box (MOGI, the same box the -//! portal code treats as a loose AABB of the group's volume). -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. +//! 15 shipped groups are batch-less, and no bit singles them out. The 1.12 client has no antiportal +//! branch on the group flags (the only bits above `0x10000` it tests are `0x40000` at `0x6b42e0` +//! and `0x20000` at `0x6c46a6`), so a batch-less group is invisible geometry, not an occluder. +//! Flags are MOGP's own 32 bits at `+0x08`, in every shipped group a superset of the MOGI copy. +//! Output is Blizzard data: never commit it. use std::collections::{BTreeMap, BTreeSet}; use std::io::Cursor; @@ -65,48 +15,39 @@ use std::io::Cursor; use benilla_formats::{parse_wmo_root, wmo_group_header, Chain}; use benilla_wmo::{parse_wmo, ParsedWmo}; -/// A bit counts as "rare" — and earns an example listing — below this many groups. Chosen well -/// above the size of any plausible antiportal set (a handful of buildings at most) and well below -/// EXTERIOR/EXTERIOR_LIT, which sit in the thousands. +/// Below this many groups a bit is rare and gets an example listing. const RARE_THRESHOLD: u32 = 200; -/// Example rows kept per rare bit. const MAX_EXAMPLES: usize = 40; -/// Batch-less rows printed in full before the report falls back to "N more, elided". const MAX_BATCHLESS_PRINTED: usize = 200; -/// Root-failure examples kept for the audit trail. const MAX_FAILURE_EXAMPLES: usize = 20; -/// Rows in the "most EXTERIOR groups" table (see its section for why that ranking is worth having). const MAX_EXTERIOR_RANK: usize = 25; -/// MOGP EXTERIOR — the bit the deferred-window worklist and its Pass-2 replay key on. +/// The MOGP EXTERIOR bit. const EXTERIOR: u32 = 0x8; -/// One group's full fact row — what every example listing and the batch-less census print. +/// One group's facts, as the listings print them. #[derive(Clone)] struct GroupRow { - /// The ROOT's chain path (e.g. `world\wmo\...\foo.wmo`); the group file is `{stem}_{NNN}.wmo`. + /// The root's chain path; the group file is `{stem}_{NNN}.wmo`. root_path: String, group_index: u32, - /// MOGP `flags` @ `+0x08`, all 32 bits, unfiltered. + /// MOGP `flags` at `+0x08`, all 32 bits. flags: u32, /// MOBA render-batch count. batches: usize, /// MOVT vertex count. vertices: usize, - /// MOPR portal-ref count (this group's slice, from its own MOGP header span). + /// MOPR portal-ref count, this group's slice from its own MOGP header. portal_refs: u16, - /// MOGI bounding box, WMO model space (WoW axes) — `None` if the root's MOGI table is shorter - /// than its declared group count (never observed, but not asserted away). + /// MOGI bounding box in model space; `None` if the MOGI table is shorter than the group count. bbox: Option<([f32; 3], [f32; 3])>, } impl GroupRow { - /// `{root}#{group}` for compact example rows. fn label(&self) -> String { format!("{}#{:03}", self.root_path, self.group_index) } - /// Bounding-box extent (max − min) per axis, or `?` if MOGI didn't carry one. fn bbox_str(&self) -> String { match self.bbox { Some((min, max)) => { @@ -121,8 +62,7 @@ impl GroupRow { } } -/// Per-bit tally: how many groups/roots set it, split by batch-less vs. batch-having, plus a -/// bounded example list for the rare-bit report. +/// Per-bit tally of groups and roots, batch-less against batch-having, with examples. #[derive(Default)] struct BitStat { groups: u32, @@ -132,14 +72,12 @@ struct BitStat { examples: Vec, } -/// Normalize a chain path the way the MPQ hash compares them (matches `wmo_ownerless_pools`). +/// A chain path normalised as the MPQ hash compares it. fn key(name: &str) -> String { name.replace('/', "\\").to_ascii_lowercase() } -/// Bench a known-name label for a MOGP bit — filled in ONLY for bits benilla's own code already -/// names as a flag test (verified 2026-09-01; see the module doc for what was checked and ruled -/// out). Everything else prints blank: this census must not invent names the data alone can't back. +/// A name only for the MOGP bits benilla's own code tests as flags; the rest print blank. fn known_name(bit: u32) -> &'static str { match bit { 3 => "EXTERIOR (0x8)", @@ -154,12 +92,8 @@ fn main() -> anyhow::Result<()> { .ok_or_else(|| anyhow::anyhow!("no 1.12.1 install found (set $WOW_DATA)"))?; let chain = Chain::open(&data)?; - // Every ROOT `.wmo` the chain lists, across every mounted archive — a group file's stem ends - // `_NNN`, a root's does not (same heuristic as `wmo_ownerless_pools`). The listfile is not a - // provably complete index of every archive (`Chain::list`'s own doc), so a root reachable ONLY - // by name and absent from every listfile would be missed here; nothing in the corpus is known - // to do that, but the failure counts below exist so a gap like it would show up as a number - // rather than silence. + // Every root `.wmo` the chain lists (a group file's stem ends `_NNN`, a root's does not); a + // root absent from every listfile is missed. let roots: BTreeSet = chain .list()? .into_iter() @@ -219,10 +153,8 @@ fn main() -> anyhow::Result<()> { continue; } }; - // Two independent reads of the same bytes, deliberately: `wmo_group_header` for the raw - // MOGP `flags`/MOPR span (the load-bearing 32 bits this census exists to histogram), and - // `benilla_wmo::parse_wmo` for MOBA/MOVT counts — the same split `wmo_group_submeshes` - // itself makes. + // `wmo_group_header` for the raw MOGP flags and MOPR span, `parse_wmo` for the MOBA and + // MOVT counts, the split `wmo_group_submeshes` makes. let Some(header) = wmo_group_header(&gbytes) else { group_parse_fail += 1; continue; @@ -306,17 +238,10 @@ cross-tab below for which flag bit(s) the data implicates.", } } - // ==== which buildings own MANY exterior groups ================================================ - // - // Not an antiportal question — a *consumer* one, and the reason it sits in this census rather - // than in a throwaway script. The reference draws a building's EXTERIOR groups only through the - // deferred portal windows the interior flood leaves behind, each group tested against its own - // window's sub-frustum (`0x6b3c73`–`0x6b3d6f`; benilla decision 1826). - // A building with ONE exterior group cannot show that law at work: a single whole-envelope shell - // has a box wide enough to intersect any window, so it draws from everywhere and looks identical - // either way — which is exactly why Stormwind (306 groups, one `0x8`) is the wrong subject to - // eyeball the behaviour on. The buildings below, whose shells are split into many groups, are - // where per-window culling is visible at all, and so where a retest belongs. + // ==== which buildings own many exterior groups ============================================ + // The reference draws exterior groups only through the deferred portal windows the interior + // flood leaves, each against its own window's sub-frustum (`0x6b3c73`-`0x6b3d6f`); only a + // building whose shell is split into many groups shows that culling. println!("\n---- roots by EXTERIOR (0x8) group count, top {MAX_EXTERIOR_RANK} ----"); let mut ranked: Vec<(&String, &u32)> = exterior_per_root.iter().collect(); ranked.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0))); @@ -432,11 +357,7 @@ cross-tab below for which flag bit(s) the data implicates.", } // ==== cross-tab: which bit(s) does the batch-less set actually carry? ========================= - // For every bit, how it splits across the batch-less/batch-having populations — the - // flag-agnostic and flag-based signals collide here. A bit set by (close to) ALL batch-less - // groups and by (close to) none of the batch-having ones is the data's own answer to "which bit - // means antiportal in 5875"; the reverse (never set on a batch-less group) rules a candidate out - // regardless of what later documentation calls it. + // A bit set by every batch-less group and rare among the rest would mark antiportals. let other_total = groups_scanned - batchless.len() as u32; println!( "\n---- cross-tab: bit occurrence, batch-less ({} groups) vs. batch-having ({} groups) ----", diff --git a/crates/benilla-formats/examples/wmo_bsp.rs b/crates/benilla-formats/examples/wmo_bsp.rs index 371dad88..78da8ef9 100644 --- a/crates/benilla-formats/examples/wmo_bsp.rs +++ b/crates/benilla-formats/examples/wmo_bsp.rs @@ -1,35 +1,20 @@ -//! Which WMO faces the reference's collision BSP can actually **reach** — and which ones we collide -//! that it never tests: +//! Which WMO faces the reference's collision BSP can reach, against the faces we collide. //! `cargo run -p benilla-formats --example wmo_bsp -- ` -//! e.g. `wmo_bsp cavernsoftime`. //! -//! The reference never brute-forces a WMO's triangles. Every collision query descends the group's -//! **MOBN** BSP and tests only the faces its leaves list in **MOBR**, then applies the per-face MOPY -//! reject mask (leaf `0x6bc700`: a BSP-leaf face is skipped iff `(MOPY.flags & rejectMask) != 0` -//! — walking `0x84`, camera `0x82`). We parse no BSP at all: `accumulate_wmo_group_faces` walks -//! **every** MOPY-passing triangle. So the two agree only where MOBR happens to reference -//! everything the mask keeps. -//! -//! This tool measures that gap per group — how many faces we collide that the reference's BSP cannot -//! reach, and where they are — which would be the difference between "solid wall" and "walk straight -//! through" at a WMO the file never asked to be solid. **Measured on CavernsOfTime (decision 0963): -//! the gap is ZERO across all 35 groups** — MOBR's leaves reach every non-DETAIL face, so our -//! brute-force set is exactly the reference's reachable set and the shortcut costs nothing but time. -//! Re-run it on any WMO whose collision is under suspicion before assuming the same. -//! -//! `inward` is the other half of the same question, for *rendering*: the share of a group's faces -//! whose winding normal points at the group's own centroid. A hollow room hull authored to be seen -//! from inside runs high (CoT's 34 interior groups: 59–100%), which is why the reference — single-sided since -//! MOMT `0x04` is clear — is see-through from outside, and why we were not until 0680. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. +//! The reference tests only the faces a group's MOBN leaves list in MOBR, skipping one iff +//! `MOPY.flags & rejectMask != 0`: walking's box query at leaf `0x6bca50` with mask `0x84`, the +//! camera and LOS segment query at leaf `0x6bc700` with `0x82`. `accumulate_wmo_group_faces` +//! walks every face the mask keeps, so the two agree only where MOBR reaches them all, as it does +//! on CavernsOfTime. `inward` counts faces turned toward the centroid: with MOMT `0x04` clear the +//! reference culls back faces, so a hull built to be seen from inside is see-through from outside. +//! Output is Blizzard data: never commit it. use std::collections::HashSet; use benilla_wmo::{parse_wmo, ParsedWmo}; -/// The walking gather's persistent MOPY reject bit (DETAIL/decal) — mask `0x84` (`0x6315f0`) minus -/// the transient `0x80` visited bit. +/// Walking's persistent MOPY reject bit (DETAIL): mask `0x84` (`0x6315f0`) minus the transient +/// `0x80` visited bit. const MOPY_DETAIL: u8 = 0x04; fn main() -> anyhow::Result<()> { @@ -96,16 +81,12 @@ fn main() -> anyhow::Result<()> { .collect() }) .unwrap_or_default(); - // Reachability, not mere presence: a face listed in MOBR but sitting under a leaf the - // descent never enters is never tested. Walk MOBN from node 0 and union the leaves' spans. + // Reachability, not presence: a face under a leaf the descent never enters is never tested. let (bsp_reach, bad) = descend(nodes, &mobr); - // What the reference's walking query can ever hit: a face the BSP lists AND whose MOPY - // clears DETAIL. What we hit: every face whose MOPY clears DETAIL. + // Walking in the reference hits reachable faces that clear DETAIL; ours hits all that do. let (mut bsp_hit, mut we_hit, mut gap) = (0usize, 0usize, 0usize); let mut gap_aabb = [[f32::MAX; 3], [f32::MIN; 3]]; - // Which way the shell presents. A hollow interior hull is authored to be seen from INSIDE: - // its winding normals point at the group's own centroid, so single-sided it is invisible - // (and see-through) from outside. Counted over every face, render or not. + // Faces whose winding normal points at the group's centroid, over every face. let mut centroid = [0.0f64; 3]; for p in &group.vertex_positions { for (c, v) in [p.x, p.y, p.z].into_iter().enumerate() { @@ -182,7 +163,6 @@ fn main() -> anyhow::Result<()> { Ok(()) } -/// A face's three positions, or `None` if any index is out of range. fn face_verts(group: &benilla_wmo::WmoGroup, f: usize) -> Option<[[f32; 3]; 3]> { let mut out = [[0.0f32; 3]; 3]; for (k, slot) in out.iter_mut().enumerate() { @@ -193,8 +173,8 @@ fn face_verts(group: &benilla_wmo::WmoGroup, f: usize) -> Option<[[f32; 3]; 3]> Some(out) } -/// The right-hand-rule normal of a face in its authored winding — the direction the face PRESENTS -/// under the reference's `GL_BACK`/CCW cull (and ours: `wow_to_bevy` is a proper rotation). +/// A face's right-hand normal in its authored winding: the side it presents under the reference's +/// CCW back-face cull, and ours, since `wow_to_bevy` is a proper rotation. fn winding_normal(group: &benilla_wmo::WmoGroup, f: usize) -> Option<[f32; 3]> { let [a, b, c] = face_verts(group, f)?; let (u, v) = ( @@ -208,7 +188,6 @@ fn winding_normal(group: &benilla_wmo::WmoGroup, f: usize) -> Option<[f32; 3]> { ]) } -/// A face's centroid. fn face_centroid(group: &benilla_wmo::WmoGroup, f: usize) -> Option<[f32; 3]> { let vs = face_verts(group, f)?; let mut out = [0.0f32; 3]; @@ -218,13 +197,10 @@ fn face_centroid(group: &benilla_wmo::WmoGroup, f: usize) -> Option<[f32; 3]> { Some(out) } -/// Walk the MOBN tree from node 0 and return the set of MOBR **face indices the descent can -/// actually reach**, plus a count of malformed references (out-of-range children or face spans). -/// -/// Node layout (wowdev, stride `0x10` — the stride itself is the client's, `0x692f20`): -/// `u16 flags · i16 negChild · i16 posChild · u16 nFaces · u32 faceStart · f32 planeDist`. -/// `flags & 0x4` = leaf. The `bad` counter is the falsifier: read the layout wrong and -/// children/spans go out of range immediately rather than quietly. +/// The MOBR face indices a descent from MOBN node 0 can reach, and the count of out-of-range +/// children or spans, which a misread layout drives up at once. A node is `u16 flags, i16 negChild, +/// i16 posChild, u16 nFaces, u32 faceStart, f32 planeDist`, stride `0x10` (`0x692f20`), and +/// `flags & 0x4` marks a leaf. fn descend(nodes: &[u8], mobr: &[u16]) -> (HashSet, usize) { let n = nodes.len() / 0x10; let (mut seen, mut reach, mut bad) = (vec![false; n], HashSet::new(), 0usize); diff --git a/crates/benilla-formats/examples/wmo_chunk_census.rs b/crates/benilla-formats/examples/wmo_chunk_census.rs index 73880a56..506a328d 100644 --- a/crates/benilla-formats/examples/wmo_chunk_census.rs +++ b/crates/benilla-formats/examples/wmo_chunk_census.rs @@ -1,20 +1,11 @@ -//! Chunk-integrity census over WMO files: walk the top-level chunk list the way the loader's -//! [`find_wmo_chunk`] walk does and report every file whose chunk stream does not tile exactly — a -//! chunk whose declared size overruns EOF. The reference **clamps** such a chunk (`0x6c3a60` / -//! `0x6c3f80` read chunks while the 8-byte header is in-bounds and clamp the last chunk to EOF — -//! e.g. `Undercity_144.wmo`'s MOGP runs 1 B past EOF, tolerated), so an overrun here must cost us -//! nothing; a file listed as losing a chunk is a bug. -//! -//! ```text -//! cargo run -p benilla-formats --example wmo_chunk_census -- # one building -//! cargo run -p benilla-formats --example wmo_chunk_census -- --all # the whole corpus -//! ``` -//! -//! Output is Blizzard-derived — pipe it to the scratchpad, never into the repo. +//! WMO files whose top-level chunk stream does not tile exactly, a chunk's declared size running +//! past EOF, and whether our loader still reads them. The reference clamps the last chunk to EOF +//! (`0x6c3a60`, `0x6c3f80`: chunks are read while the 8-byte header is in bounds), so +//! `Undercity_144.wmo`'s MOGP one byte past EOF loads, and a file our loader loses is a bug. +//! `cargo run -p benilla-formats --example wmo_chunk_census -- | --all` +//! Output is Blizzard data: never commit it. -/// Walk `b`'s chunk stream as a pure **data** question: is it exactly tiled, or does some chunk -/// declare more bytes than the file holds? Returns `(tag, declared_end, file_len)` for the first -/// over-declared chunk. Whether the loader survives it is asked separately, of the loader itself. +/// The first chunk declaring more bytes than the file holds, as `(tag, declared_end, file_len)`. fn overrun(b: &[u8]) -> Option<(String, usize, usize)> { let mut off = 0usize; while off + 8 <= b.len() { @@ -66,8 +57,8 @@ fn main() -> anyhow::Result<()> { continue; } checked += 1; - // The loader's own verdict, not a re-implementation of it: a group file must yield its MOGP - // header (flags + portal-ref span — losing it dead-ends the portal flood), a root must parse. + // The loader's own verdict: a group must yield its MOGP header, which the portal flood + // needs, and a root must parse. let is_group = b.len() > 16 && &b[12..16] == b"PGOM"; let loads = if is_group { benilla_formats::wmo_group_header(&b).is_some() diff --git a/crates/benilla-formats/examples/wmo_collision_cost.rs b/crates/benilla-formats/examples/wmo_collision_cost.rs index b23ef2e2..e70bcbe6 100644 --- a/crates/benilla-formats/examples/wmo_collision_cost.rs +++ b/crates/benilla-formats/examples/wmo_collision_cost.rs @@ -1,34 +1,15 @@ -//! What a WMO's **collision** actually costs, and whether its walking gather has a floor at all: +//! Each WMO's collision triangle counts, walking against camera gather, and the groups whose +//! walking gather (MOPY DETAIL `0x04` dropped) has no walkable face where the camera's does. The +//! triangle count drives how long a placement takes to turn solid, against the mover's +//! `SETTLE_TIMEOUT`. Output is Blizzard data: never commit it. //! `cargo run -p benilla-formats --example wmo_collision_cost -- ...` -//! e.g. `wmo_collision_cost stratholme.wmo zulgurubcity`. -//! -//! The falsifier for a **fall-through** report, which has exactly two shapes and they take opposite -//! fixes: -//! -//! * **No floor to stand on** — the walking gather (`flags & 0x04` DETAIL dropped) came back with no -//! upward-facing faces in a group the camera gather *does* see. Then the collider is present and -//! correct and still cannot hold you: the authored floor is flagged decal-only. A data fact, fixed -//! by widening the gather, and visible here without entering the game. -//! * **A floor that arrives too late** — the faces are there, but the placement's trimesh is big -//! enough that its off-thread build plus the per-frame attach budget runs past the mover's settle -//! timeout, so gravity switches on over geometry that is not solid yet. A *timing* fact; this -//! prints the triangle count that drives it, so the margin can be compared against -//! `SETTLE_TIMEOUT` without guessing which buildings are the heavy ones. -//! -//! Both readings are properties of the shipped file, so read them here rather than inferring them -//! from a fall you may or may not be able to reproduce on your own machine — the timing shape -//! reproduces only on a machine slow enough, which is exactly why it reaches players and not us. -//! -//! Output is Blizzard-derived; pipe it to the scratchpad, never into the repo. use benilla_formats::{accumulate_wmo_group_camera_collision, accumulate_wmo_group_collision}; -/// Faces at least this upward-facing count as a **floor** — the mover's own walkable test -/// (`GROUND_COS`, ~50° from vertical). A group with collidable faces but none of them walkable is -/// a wall/ceiling shell you slide down, not something you can stand on. +/// A face at least this upward-facing is a floor, as the mover's `GROUND_COS` (about 50° from +/// vertical) judges it. const WALKABLE_COS: f32 = 0.64; -/// One gather's shape: how many triangles, and how many of them you could stand on. struct Gather { tris: usize, walkable: usize, diff --git a/crates/benilla-formats/examples/wmo_coplanar.rs b/crates/benilla-formats/examples/wmo_coplanar.rs index 0a06920e..8eed3dd7 100644 --- a/crates/benilla-formats/examples/wmo_coplanar.rs +++ b/crates/benilla-formats/examples/wmo_coplanar.rs @@ -1,34 +1,23 @@ -//! Which batches of a WMO are **coplanar with each other**, and by how much: +//! Which batches of a WMO are coplanar, with each other or with themselves: per pair, the smallest +//! plane gap between near-parallel overlapping faces. A `0.000` gap is a true tie the depth test +//! cannot decide; anything else is a separation to set against the depth buffer's resolution at +//! that range. Output is Blizzard data: never commit it. //! `cargo run -p benilla-formats --example wmo_coplanar -- [gap_yd]` -//! e.g. `wmo_coplanar orctower 0.05`. -//! -//! The falsifier for a z-fighting report. Two surfaces that swap which one is in front do it for one -//! of two reasons, and they take different fixes: a **true tie** (the author put both faces on the -//! same plane, so the depth test has nothing to decide with and the winner is whoever drew last) or -//! **depth precision** (a real but tiny separation that quantises away). The distinction is a -//! property of the *file*, not of the frame — so read it here rather than inferring it from pixels. -//! -//! Prints, for every pair of batches whose geometry overlaps in space, the smallest plane gap between -//! near-parallel faces that actually overlap. A `0.000` gap is a tie; anything else is a separation -//! to compare against the depth buffer's resolution at that range. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. use benilla_formats::RenderSubmesh; /// Faces this close to parallel are treated as the same plane orientation (≈2.6°). const PARALLEL_DOT: f32 = 0.999; -/// One triangle reduced to its plane plus the box it occupies — everything the gap test needs. +/// One triangle as its plane and bounding box. struct Face { normal: [f32; 3], /// Plane offset: `normal · vertex`. d: f32, min: [f32; 3], max: [f32; 3], - /// The triangle's own corners, kept so a batch can be compared against **itself**: two faces of - /// one flat quad share an edge and are trivially coplanar, which is not a defect. Real overlapping - /// sheets share no vertex. + /// The corners, so a batch can be compared with itself: two halves of one flat quad share a + /// vertex, overlapping sheets do not. verts: [[f32; 3]; 3], } @@ -76,12 +65,10 @@ fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } -/// Do the two boxes overlap, allowing `slack` of separation on every axis? fn boxes_touch(a: &Face, b: &Face, slack: f32) -> bool { (0..3).all(|c| a.min[c] - slack <= b.max[c] && b.min[c] - slack <= a.max[c]) } -/// Do the triangles share a corner? Two halves of a quad do; two stacked sheets do not. fn shares_a_vertex(a: &Face, b: &Face) -> bool { a.verts.iter().any(|p| { b.verts @@ -90,9 +77,8 @@ fn shares_a_vertex(a: &Face, b: &Face) -> bool { }) } -/// The smallest plane gap between near-parallel, spatially-overlapping faces of `a` and `b`, or -/// `None` if no such pair sits within `gap`. When `a` and `b` are the same batch, edge-sharing -/// neighbours are skipped — they are how a flat surface is built, not a defect. +/// The smallest plane gap within `gap` between near-parallel overlapping faces of `a` and `b`; +/// within one batch, faces sharing a vertex are skipped. fn closest_coplanar(a: &[Face], b: &[Face], gap: f32, same_batch: bool) -> Option { let mut best: Option = None; for (i, fa) in a.iter().enumerate() { @@ -122,8 +108,7 @@ fn main() -> anyhow::Result<()> { let pat = args .next() .ok_or_else(|| anyhow::anyhow!("usage: wmo_coplanar [gap_yd]"))?; - // Report pairs whose faces sit within this gap. The default is generous — a real z-fight needs a - // gap far under a centimetre, but seeing the near misses tells you where the margin actually is. + // Pairs within this gap; the default is wide enough to show near misses too. let gap: f32 = args.next().map_or(Ok(0.05), |g| g.parse())?; let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); diff --git a/crates/benilla-formats/examples/wmo_dark_floors.rs b/crates/benilla-formats/examples/wmo_dark_floors.rs index 44f6e6b8..a5813ca6 100644 --- a/crates/benilla-formats/examples/wmo_dark_floors.rs +++ b/crates/benilla-formats/examples/wmo_dark_floors.rs @@ -1,21 +1,9 @@ -//! **Every WMO in the chain that authors a surface too dark to render on its own** — the world sweep -//! behind decision 0956's Dire Maul report: +//! Every WMO surface baked near-black at `MOCV.α = 0` that only the doorway fade +//! (`FixColorVertexAlpha`) lights: without the fade the TRANS and INT batches render the bake as +//! is, a black floor. A vertex is dark at luminance < 48 and alpha 0 when the fade lifts it past +//! 200; a triangle counts when all three are, and batches rank by that area. +//! Output is Blizzard data: never commit it. //! `cargo run --release -p benilla-formats --example wmo_dark_floors [-- ]` -//! -//! The bug 0956 fixed was never Dire Maul's: a WMO transition group may bake a surface at near-black -//! with `MOCV.α = 0`, leaving the runtime bright-doorway fade (`FixColorVertexAlpha`) to light it. -//! Skip the fade and the interior TRANS/INT law renders the bake literally — a black floor. One -//! building was *reported*; this asks the question the report could not, which is how many others -//! were sitting there unreported. -//! -//! Severity is **area, not vertex count**. A vertex is *dark* when the file bakes it near-black at -//! zero alpha (luminance < 48, `α == 0`) and the fade lifts it past 200 — it would have drawn black -//! and now draws lit. A **triangle** counts when all three of its vertices are, and its model-space -//! area is summed: that is literally the square yardage of black floor a player would have walked -//! over (floor, wall or ceiling alike). Batches are reported with their share of their own surface, so a wholly-black walkway -//! (Dire Maul: 100%) sorts above a darkened seam. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. use benilla_wmo::{parse_wmo, ParsedWmo}; @@ -36,9 +24,8 @@ struct Hit { batch: usize, class: &'static str, texture: String, - /// Square yards of triangle whose every vertex was dark. + /// Square yards of triangles whose every vertex is dark. area: f32, - /// That area as a share of the batch's own total. share: f32, } @@ -100,7 +87,7 @@ fn main() -> anyhow::Result<()> { if !is_dark.iter().any(|&d| d) { continue; } - // MOBA is laid out TRANS, INT, EXT — only the first two render the bake. + // MOBA runs TRANS, INT, EXT; only the first two render the bake. let (trans_n, int_n) = benilla_formats::wmo_group_header(&gbytes) .map(|_| ()) .and_then(|()| { diff --git a/crates/benilla-formats/examples/wmo_doubled.rs b/crates/benilla-formats/examples/wmo_doubled.rs index 265a0aa1..76253877 100644 --- a/crates/benilla-formats/examples/wmo_doubled.rs +++ b/crates/benilla-formats/examples/wmo_doubled.rs @@ -1,46 +1,28 @@ -//! Which faces of a WMO are **authored twice** — two triangles on the same three positions: +//! Which faces of a WMO are authored twice, two triangles on the same three positions, with each +//! pair's MOMT flags. Opposite winding is how 1.12 authors cloth seen from both sides on a +//! single-sided material: the reference culls each copy from its back, so one covers any pixel, +//! and a renderer that draws the batches two-sided makes the copies z-fight. MOMT `0x04` +//! (UNCULLED) set means the file asks for two-sided drawing. `wmo_coplanar` cannot see these, as it +//! skips same-batch faces that share a vertex. Output is Blizzard data: never commit it. //! `cargo run -p benilla-formats --example wmo_doubled -- ` -//! e.g. `wmo_doubled orctower`. -//! -//! The falsifier `wmo_coplanar` structurally cannot be. That tool skips same-batch face pairs that -//! share a vertex ("two halves of a quad") — but a thin sheet whose two *sides* are authored by -//! reusing the same vertices with **reversed winding** shares all three, so a doubled canvas awning -//! is invisible to it by construction (it reported the B38 tent's nearest partner at 1.91 yd while -//! the tent's own two sides sat at 0.000). This tool asks the opposite question: which triangles -//! occupy the SAME three positions? -//! -//! Doubled-with-opposite-winding is not a defect — it is how 1.12 authors visible-from-both-sides -//! cloth when the material is single-sided: the real client backface-culls each copy from the wrong -//! side, so exactly one covers any pixel. It *becomes* a defect the moment the renderer draws WMO -//! batches two-sided — ours did until `3af8854f` honoured MOMT `0x04` (B38, decision 0680): both -//! copies then rasterize at depths equal to the ulp, and the winner per pixel is floating-point -//! noise — latched while the camera is bit-still, flickering while anything creeps. That is the -//! defect this tool was built to name, and it is now closed; it stays as the census. Each pair -//! prints with its -//! batches' MOMT flags: `0x04` (UNCULLED) set means the file *asked* for two-sided and the double -//! draw is authored intent; clear means the renderer's shortcut is manufacturing a z-tie the file -//! never contained. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. use std::collections::HashMap; use benilla_wmo::{parse_wmo, ParsedWmo}; -/// Positions are matched to a tenth of a millimetre — far under authoring precision, far over f32 -/// noise at building scale. +/// Positions match to a tenth of a millimetre, under authoring precision and over f32 noise. const QUANT: f32 = 10_000.0; fn key(p: [f32; 3]) -> [i64; 3] { p.map(|c| (c * QUANT).round() as i64) } -/// A triangle's identity (order-free) plus the data needed to classify its partner. +/// A triangle's batch and facing, to classify its partner. struct Tri { batch: usize, - /// Geometric normal in original winding order — two copies with `dot < 0` are opposite-wound. + /// Geometric normal in authored winding; two copies with `dot < 0` are opposite-wound. normal: [f32; 3], - /// Mean authored (MONR) normal, to name which side of the sheet this copy is. + /// Summed authored (MONR) normals, naming which side of the sheet this copy is. authored: [f32; 3], } @@ -66,8 +48,7 @@ fn main() -> anyhow::Result<()> { }; println!("{path}\n"); - // The root's MOMT table, raw — the submeshes don't carry material flags, and the flags decide - // whether a doubled pair is authored intent (0x04 UNCULLED) or a renderer-manufactured tie. + // The root's raw MOMT table: the submeshes carry no material flags. let root_bytes = chain.read_file(&path.to_ascii_lowercase())?; let (materials, textures, tex_map) = match parse_wmo(&mut std::io::Cursor::new(&root_bytes))? { ParsedWmo::Root(r) => (r.materials, r.textures, r.texture_offset_index_map), @@ -89,8 +70,7 @@ fn main() -> anyhow::Result<()> { let subs = benilla_formats::load_wmo(&mut chain, &path)?; - // One global bucket per geometric triangle, across every batch: any bucket holding two entries - // is a doubled face, wherever its copies live. + // One bucket per geometric triangle across every batch; two entries make a doubled face. let mut buckets: HashMap<[[i64; 3]; 3], Vec> = HashMap::new(); for (bi, s) in subs.iter().enumerate() { for t in s.indices.as_chunks::<3>().0 { @@ -117,9 +97,8 @@ fn main() -> anyhow::Result<()> { } } - // Doubled-face census per batch pair, split by winding — opposite is the two-sided-sheet - // authoring; same-winding true duplicates would be a different (and stranger) finding. - /// Per batch pair: doubled-face count + each side's summed authored normal. + // Per batch pair, split by winding: opposite is a two-sided sheet, same is a true duplicate. + /// Per batch pair: the doubled-face count and each side's summed authored normal. type OppositeCensus = HashMap<(usize, usize), (usize, [f32; 3], [f32; 3])>; let mut opposite: OppositeCensus = HashMap::new(); let mut same: HashMap<(usize, usize), usize> = HashMap::new(); @@ -131,8 +110,7 @@ fn main() -> anyhow::Result<()> { if dot(a.normal, b.normal) < 0.0 { let e = opposite.entry(pair).or_insert((0, [0.0; 3], [0.0; 3])); e.0 += 1; - // Accumulate each side's authored normal by batch order, so the report can say - // "batch A faces up, batch B faces down" rather than just "they differ". + // Each side's authored normal in batch order, to say which way each faces. let (first, second) = if a.batch <= b.batch { (a, b) } else { (b, a) }; for c in 0..3 { e.1[c] += first.authored[c]; diff --git a/crates/benilla-formats/examples/wmo_liquid_arms.rs b/crates/benilla-formats/examples/wmo_liquid_arms.rs index 31d1bd95..f89ad538 100644 --- a/crates/benilla-formats/examples/wmo_liquid_arms.rs +++ b/crates/benilla-formats/examples/wmo_liquid_arms.rs @@ -1,38 +1,18 @@ -//! **Which WMO water ARM each pool takes**, over the whole shipped corpus: -//! `cargo run -p benilla-formats --example wmo_liquid_arms`. -//! -//! The reference does not have *one* WMO water renderer. `0x6b62e0` dispatches the type nibble to a -//! category, and category 0 (river/water — nibbles 0/4/8) then splits again on the owning group's -//! `MOGP.flags & 0x48`: -//! -//! * `& 0x48 != 0` → **EXTERIOR**, kernel `0x6b6630`. A 9-float (`0x24`) vertex — position, an up -//! normal, a colour dword, `u,v` — and the kernel binds the pixel program -//! `Shaders\Pixel\MapObjExtWater0.bls` at `0x6b6654` (`GxRsSet(0x3f, …)`, gated on `[0xc9607c]`). -//! * `& 0x48 == 0` → **INTERIOR**, kernel `0x6b6420`. A 6-float (`0x18`) vertex — position, a colour -//! dword, `u,v` — with **no normal at all**, the colour coming from the map object's own light -//! records (`CMapObj+0x1d8`). -//! -//! The arm split is **category 0 only**. Magma and slime (nibbles 2/3/6/7) go to category 1 and one -//! shared kernel `0x6b68f0` whatever their group's flags say, so their rows below are context, not -//! two arms — they are reported because the same building often holds both, and because a reader -//! comparing group counts would otherwise assume the split is universal. -//! -//! Neither water arm is the **ADT** path (`0x6851b0`/`0x685010` → `ocean0_s.bls`), which is the only -//! one with a depth-ramp texture on stage 0 and the only one whose vertex carries no colour. -//! -//! benilla renders all three through one material and one shader — the ADT one. This census says how -//! much content each arm actually owns, so the divergence can be sized instead of guessed: the two -//! reported sites (B136 Blackfathom Deeps, and Stormwind's canals) land on opposite arms, and -//! neither is the path we implement. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. +//! Which WMO water arm each shipped liquid group takes. The reference's dispatch `0x6b62e0` splits +//! water (nibbles 0/4/8) on the owning group's `MOGP.flags & 0x48`: set runs the exterior kernel +//! `0x6b6630`, a 9-float vertex with an up normal, binding `MapObjExtWater0.bls` at `0x6b6654`; +//! clear runs the interior kernel `0x6b6420`, a 6-float vertex with no normal, coloured from the +//! map object's lights (`CMapObj+0x1d8`). Magma and slime (nibbles 2/3/6/7) take `0x6b68f0` +//! whatever the flags, and neither arm is the ADT path (`0x6851b0`, `0x685010`, `ocean0_s.bls`). +//! Output is Blizzard data: never commit it. +//! `cargo run -p benilla-formats --example wmo_liquid_arms` use std::collections::BTreeMap; use benilla_formats::{wmo_group_header, wmo_group_liquid_mesh, Chain}; -/// MOGP `flags & 0x48` — the reference's interior test (`0x6b3f90`, and the liquid dispatch's own -/// `[owner+0x10] & 0x48` at `0x6b62e0`). Zero ⇒ interior. +/// The reference's interior test, zero meaning interior (`0x6b3f90`; the liquid dispatch +/// `0x6b62e0` tests `[owner+0x10] & 0x48`). const EXTERIOR_MASK: u32 = 0x48; #[derive(Default, Clone, Copy)] @@ -46,11 +26,8 @@ fn main() -> anyhow::Result<()> { .ok_or_else(|| anyhow::anyhow!("no 1.12.1 install found (set $WOW_DATA)"))?; let reader = Chain::open(&data)?; - // Group files are `_NNN.wmo`; the roots are what the chain lists, so walk the listing - // once and keep the group files directly — a root's group count is not knowable without parsing - // it, and the names are self-describing. let mut by_arm: BTreeMap<(bool, String), Tally> = BTreeMap::new(); - // Per building, so a site can be looked up rather than re-derived. + // Per building: (interior, exterior) water groups. let mut buildings: BTreeMap = BTreeMap::new(); for entry in reader.list()? { @@ -125,7 +102,7 @@ fn main() -> anyhow::Result<()> { } ); if !water { - continue; // the totals below are the WATER arms; fullbright takes neither + continue; // the totals below are the water arms; fullbright takes neither } if *interior { int_g += t.groups; @@ -139,9 +116,7 @@ fn main() -> anyhow::Result<()> { "\nWATER arms — INTERIOR {int_g} groups / {int_t} wet tiles EXTERIOR {ext_g} groups / {ext_t} wet tiles" ); - // Buildings carrying WATER on both arms: a single-shader renderer cannot be right for the whole - // building. (Undercity holds both flags too, but its liquid is slime, which takes neither arm — - // hence the water-only filter.) + // Buildings with water on both arms; Undercity's liquid is slime, which takes neither. let mixed: Vec<_> = buildings .iter() .filter(|(_, (i, e))| *i > 0 && *e > 0) diff --git a/crates/benilla-formats/examples/wmo_liquid_scope.rs b/crates/benilla-formats/examples/wmo_liquid_scope.rs index dbc27e78..f2544594 100644 --- a/crates/benilla-formats/examples/wmo_liquid_scope.rs +++ b/crates/benilla-formats/examples/wmo_liquid_scope.rs @@ -1,53 +1,34 @@ -//! Where liquid **scoping** can still get the wrong answer, read off the shipped files rather than -//! argued: `cargo run -p benilla-formats --example wmo_liquid_scope`. +//! Where WMO liquid scoping can claim the wrong position, read off every placed WMO (from an ADT +//! or a WDT), each hit with a `.go`. `cargo run -p benilla-formats --example wmo_liquid_scope` //! -//! The "swim in air" family (0634 → 0635 → 0696 → 0701) is one defect met at successively finer -//! grain: a liquid footprint has no bound of its own, so each round found a wider set of positions -//! it was still claiming. This is the falsifier that says which bounds are actually load-bearing on -//! shipped content, and where — an argument about WMO liquid is otherwise unfalsifiable from inside -//! the game, where you can only stand in one room at a time. +//! - STOREY: a pool's wet cells over another group's floor, submerged by placement scoping alone. +//! - OVERHANG: how far wet cells reach past their own group's XY box, up to 25 yd, which is why +//! benilla bounds a pool by that box's Z floor only where the reference tests the whole box +//! (`0x6a4e00`). +//! - DRY-GROUP: a group with a `groupLiquid` other than `0xf` and no MLIQ, which the reference +//! answers with an unconditional hit at height `FLT_MAX` (`0x6b9f10`). //! -//! * **STOREY** — a placement holding a pool in one group and a **floor below it** in another. -//! Placement scoping alone claims the lower room (submerged on dry stone, the B60 shape). This is -//! what found decision 0701's Undercity site, and it now also reports whether 0701's floor rule -//! closes each hit. Tested against the pool's real **wet cells**, not its bounding box: a liquid -//! grid is sparse, so a bbox routinely spans dry ground it never touches (decision 0635). -//! * **OVERHANG** — how far a pool's wet cells reach *outside* their own group's box. This is why -//! 0701 bounds a pool by its group's Z **floor** and not by the reference's whole per-group AABB: -//! on shipped content a pool overhangs its own room by up to 25 yd in XY, so an XY box test would -//! newly reject pools that work today. -//! * **DRY-GROUP** — a group whose MOGP `groupLiquid` (@0x34) is not the `0xf` no-liquid sentinel -//! but that carries **no MLIQ chunk**. The reference's `0x6b9f10` is reported to answer such a -//! group with an unconditional hit at height `FLT_MAX`; we answer *nothing*, and since 0634/0696 -//! suppress the ADT leg indoors, the room reads DRY. **Still open** — three placed groups. -//! -//! Only placements found in a shipped ADT are reported — an unplaced WMO cannot be stood in — and -//! each hit prints a `.go` for the lower room's floor so it can be looked at. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. +//! Output is Blizzard data: never commit it. use std::collections::BTreeMap; use std::io::Cursor; use benilla_formats::LiquidMesh; -/// MOGP `groupLiquid` @ 0x34: the value meaning "this group holds no liquid". +/// MOGP `groupLiquid` (`+0x34`) for a group holding no liquid. const NO_LIQUID: u32 = 0xf; -/// Half the 34133⅓-yard map (32 tiles × 533⅓) — the MODF→world origin shift. +/// Half the 34133⅓-yard map (32 tiles × 533⅓), the MODF-to-world origin shift. const MAP_CENTER: f32 = 17066.666; -/// What one group file says about liquid. struct Group { liquid_type: u32, - /// The group's MLIQ surface in WMO model space, if it has one. liquid: Option, - /// MOGP bounding box (model space), @0x0c. + /// MOGP bounding box in model space, at `+0x0c`. lo: [f32; 3], hi: [f32; 3], } -/// One placement of a model in the world. struct Site { map: String, /// Placement origin in WoW world coords. @@ -93,16 +74,14 @@ fn read_group(bytes: &[u8]) -> Option { }) } -/// Does this group file carry an MLIQ sub-chunk at all? (The MOGP payload is a 68-byte header -/// followed by its own sub-chunks.) Distinct from [`Group::liquid`], which is `None` for an -/// all-holes grid too. +/// Whether the group carries an MLIQ sub-chunk after MOGP's 68-byte header; [`Group::liquid`] is +/// also `None` for an all-holes grid. fn has_mliq(bytes: &[u8]) -> bool { chunk(bytes, b"PGOM").is_some_and(|m| m.len() > 0x44 && chunk(&m[0x44..], b"QILM").is_some()) } -/// The model→world rotation for a MODF placement, in the **WoW** frame (Z up) — the `in_wow` half -/// of `benilla_assets::coords::placement_rotation`, as a plain 3×3 so this example needs no math -/// dependency. `Rx(90°)·Ry(ry−180°)·Rz(−rx)·Rx(rz−90°)`, applied to a model-space point. +/// A MODF placement's model-to-world rotation in the WoW frame (Z up), the `in_wow` half of +/// `benilla_assets::coords::placement_rotation`: `Rx(90°)·Ry(ry-180°)·Rz(-rx)·Rx(rz-90°)`. fn model_to_world(rot_deg: [f32; 3], p: [f32; 3]) -> [f32; 3] { let (rx, ry, rz) = ( rot_deg[0].to_radians(), @@ -214,11 +193,7 @@ fn main() -> anyhow::Result<()> { } } } - // A WMO-only map has no ADT at all — the whole world is the WDT's single `MODF` (20 of the 43 - // shipped maps, decision 0688). Scanning only ADTs therefore declared the Deeprun Tram, the - // jails and every instance-shaped dungeon *unplaced*, which is the one thing this census uses - // to decide a group is unreachable. The tram's own flooded sections are among the sites this - // check was written for, so the blind spot was hiding exactly the sites asked about. + // A WMO-only map has no ADT: its world is the WDT's single `MODF` (20 of the 43 shipped maps). let wdts: Vec = chain .list()? .into_iter() @@ -239,9 +214,8 @@ fn main() -> anyhow::Result<()> { let Some(g) = wdt.global_wmo() else { continue }; let lower = name.to_lowercase(); let map = lower.split(['\\', '/']).nth(2).unwrap_or("?").to_string(); - // No `MAP_CENTER − v` remap here: a global WMO's MODF is authored in world coords already - // (0688, falsified against all 26 of the server's entry points). Every shipped one is - // (0,0,0), so a `.go` printed for one of these is the model-space offset itself. + // No `MAP_CENTER - v` remap: a global WMO's MODF is in world coords already, and every + // shipped one sits at (0,0,0). placed .entry(g.model.to_lowercase().replace('/', "\\")) .or_default() @@ -259,8 +233,7 @@ fn main() -> anyhow::Result<()> { let mut storey: Vec<(f32, String)> = Vec::new(); let mut overhang: Vec<(f32, String)> = Vec::new(); let mut floor_misses = 0usize; - // groupLiquid value -> (groups with an MLIQ chunk, groups without) — the sentinel sanity check: - // `0xf` is only the "no liquid" marker if it is what the overwhelming majority of dry groups say. + // groupLiquid value -> (groups with an MLIQ chunk, groups without). let mut type_census: BTreeMap = BTreeMap::new(); for (model, sites) in &placed { @@ -277,9 +250,7 @@ fn main() -> anyhow::Result<()> { } else { e.1 += 1; if g.liquid_type != NO_LIQUID { - // The ROOM, not the building origin: a placement origin routinely sits - // inside rock, so a `.go` to it is unusable for an eyeball check. Aim at the - // group box's own centre, just off its floor. + // Aim at the room's box centre, just off its floor, not the placement origin. let mid = |a: usize| (g.lo[a] + g.hi[a]) * 0.5; dry_groups.push(format!( " {model} group {gi:03} liquidType {} box z [{:.1}..{:.1}]\n room {}\n origin{}", @@ -294,7 +265,7 @@ fn main() -> anyhow::Result<()> { groups.push(g); } - // STOREY: for each pool, the lower groups its WET CELLS actually hang over. + // STOREY: for each pool, the lower groups its wet cells hang over. for (ai, a) in groups.iter().enumerate() { let Some(lq) = a.liquid.as_ref() else { continue; @@ -314,27 +285,23 @@ fn main() -> anyhow::Result<()> { if ai == bi { continue; } - // The claim only fires where the query would: a point in B under the pool's height - // and inside a wet cell's XY. Take B's own floor as the standing surface. + // Where the query fires: under the pool's height, in a wet cell, on B's floor. let stand = b.lo[2] + 1.5; let mut best: Option<([f32; 4], f32)> = None; for (xy, h) in &cells { if *h <= stand { - continue; // the pool is at or below this floor — no false submersion + continue; // the pool is at or below this floor: no false submersion } let overlap = xy[0] < b.hi[0] && xy[2] > b.lo[0] && xy[1] < b.hi[1] && xy[3] > b.lo[1]; - // …and B must not simply BE the pool's own room: skip when B's box contains the - // liquid height, i.e. the pool is inside B and the submersion is real. + // Skip B when its box holds the liquid height: that submersion is real. if overlap && *h > b.hi[2] && best.is_none_or(|(_, bh)| *h > bh) { best = Some((*xy, *h)); } } if let Some((xy, h)) = best { let pt = [(xy[0] + xy[2]) * 0.5, (xy[1] + xy[3]) * 0.5, stand]; - // Would a FLOOR under the pool — "a pool cannot claim anything below its own - // group's box" — close this one? The candidate fix, checked per hit rather - // than assumed to generalize from the site that motivated it. + // Whether the floor rule (nothing below the pool's group box) closes this. let floored = stand < a.lo[2]; if !floored { floor_misses += 1; @@ -368,12 +335,8 @@ fn main() -> anyhow::Result<()> { println!(" {d:>7.2} yd {line}"); } - // ---- the ARCHIVE-WIDE census. ----------------------------------------------------------- - // The placed-model census below answers "what does shipped, reachable content use". This one - // answers the prior question: is `groupLiquid` a field the 1.12 authors USED at all? If every - // MLIQ-carrying group in the whole archive says `0xf`, then the type-override branch never - // fires on real data and a stray non-`0xf` value on a group with no liquid is content noise, - // not a feature we are failing to implement. + // ---- the archive-wide census. ----------------------------------------------------------- + // `groupLiquid` over every group file in the archive, placed or not. let mut all_census: BTreeMap = BTreeMap::new(); let mut odd: Vec = Vec::new(); for name in chain.list()?.into_iter().map(|e| e.name) { diff --git a/crates/benilla-formats/examples/wmo_mocv.rs b/crates/benilla-formats/examples/wmo_mocv.rs index 1fbb68bc..4a3567d4 100644 --- a/crates/benilla-formats/examples/wmo_mocv.rs +++ b/crates/benilla-formats/examples/wmo_mocv.rs @@ -1,27 +1,14 @@ -//! What a WMO's surfaces are **baked** to — the per-group MOCV census behind a "this floor is black" -//! report: +//! Each WMO group's MOCV bake per batch, up-facing vertices apart from the rest, with its MOGP +//! flags, batch class and the bake after the doorway fade. The reference lights an interior surface +//! only from the bake: an INT batch draws `tex × MOCV` and a TRANS batch lerps to it by `MOCV.a`, +//! and `FixColorVertexAlpha` (`0x6c43d0`) brightens interior vertices toward white within 6.67 yd +//! of a portal to an exterior group. A model-space `x,y,z` keeps only the groups whose box holds +//! it; `--survey` prints only the fade per group. Output is Blizzard data: never commit it. //! `cargo run -p benilla-formats --example wmo_mocv -- [gN | x,y,z]` -//! -//! An interior WMO surface takes no exterior light in the reference: an INT-class batch draws -//! `tex × MOCV` and a TRANS-class batch lerps to it by `MOCV.a`, so the *only* thing between a black -//! floor and a lit one is the bake in the file — plus the runtime portal fixup the client applies -//! over it (`FixColorVertexAlpha` `0x6c43d0`, which brightens interior verts -//! toward white within 6.67 yd of an exterior-neighbour portal). A "dark floor here" report therefore -//! has exactly two shapes, told apart by numbers rather than by looking: either the file bakes it -//! dark (and the fixup is what lights it), or our reader/classifier mislabels the batch. This prints -//! both halves — the group's MOGP flags and per-batch class, and the MOCV spread of each batch's -//! vertices split by facing, so a FLOOR (n.z up) reads apart from the walls sharing its batch. -//! -//! With a model-space `x,y,z` it reports only the groups whose bounding box contains the point (a -//! `.gps` pin, inverse-transformed by the placement, names the group the director is standing in). -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. use benilla_wmo::{parse_wmo, ParsedWmo}; -/// Walk a WMO file's top-level chunks for `magic` (FourCC reversed on disk), returning its payload. -/// The examples can't use the crate-private reader, and a group file's MOGP header is the only thing -/// here that isn't already on a parsed struct. +/// The payload of a WMO file's top-level chunk `magic`, FourCC reversed as on disk. fn chunk<'a>(bytes: &'a [u8], magic: &[u8; 4]) -> Option<&'a [u8]> { let mut off = 0usize; while off + 8 <= bytes.len() { @@ -93,11 +80,7 @@ fn main() -> anyhow::Result<()> { let filter = args.next(); let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); let mut chain = benilla_formats::open_chain(&data)?; - // A `.wmo` argument is taken as a literal chain path, but only if the chain HAS it: `stormwind.wmo` - // ends in `.wmo` and is not a path, and the old "ends_with ⇒ literal" rule turned that into a hard - // read error. Read as "no groups affected" from a grepped log, it cost a wrong all-clear on - // Stormwind, Ironforge and Stratholme. Falling back to the substring search cannot mislead: the - // resolved path is printed, and a genuine miss still errors. + // A `.wmo` argument is a literal path only if the chain has it; `stormwind.wmo` is a substring. let lower = pat.to_lowercase(); let path = if lower.ends_with(".wmo") && chain.read_file(&lower).is_ok() { pat.clone() @@ -205,8 +188,7 @@ fn main() -> anyhow::Result<()> { if has_colors { "yes" } else { "ABSENT" }, group.render_batches.len(), ); - // The fade's blast radius on this group: how many authored slots it rewrites, and by how much - // — the number that says whether the doorway fixup is a targeted lift or a wash over the room. + // How many authored slots the doorway fade rewrites on this group, and by how much. if let Some(fixed) = benilla_formats::wmo_group_fixed_colors(&gbytes, &wmo_root) { let lum = |c: [f32; 3]| 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]; let (mut changed, mut before, mut after) = (0usize, 0.0f32, 0.0f32); @@ -251,7 +233,7 @@ fn main() -> anyhow::Result<()> { .vertex_indices .get(start..start + batch.count as usize) .unwrap_or(&[]); - // Split by facing: a floor's up-facing verts are what a "dark floor" report is about. + // Split by facing, so a floor's up-facing verts read apart from its walls. let (mut up, mut other) = (Stat::new(), Stat::new()); for &i in idx { let i = i as usize; @@ -274,10 +256,8 @@ fn main() -> anyhow::Result<()> { println!(" up-facing {}", up.line()); println!(" other {}", other.line()); } - // The same census *after* the reader's bright-doorway fade — what the renderer actually - // uploads. A batch that reads black above and white here is a floor the file never baked and - // the runtime fixup lights (a WMO transition corridor); one that stays black in both is a - // genuinely dark room. + // The same census after the reader's doorway fade, as the renderer uploads it: black above + // and white here is a surface only the fade lights. let subs = benilla_formats::wmo_group_submeshes(&gbytes, &wmo_root)?; println!( " --- after FixColorVertexAlpha ({} submeshes) ---", diff --git a/crates/benilla-formats/examples/wmo_ownerless_pools.rs b/crates/benilla-formats/examples/wmo_ownerless_pools.rs index 92daac53..3e470ea7 100644 --- a/crates/benilla-formats/examples/wmo_ownerless_pools.rs +++ b/crates/benilla-formats/examples/wmo_ownerless_pools.rs @@ -1,49 +1,14 @@ -//! **Which buildings' embedded pools have no visibility authority**, over the whole shipped corpus: -//! `cargo run -p benilla-formats --example wmo_ownerless_pools`. +//! Which WMO roots have embedded pools but no `WmoPortalInstance` to own them. A placement gets an +//! instance only with a portal graph or an authored `WMOAreaTable` identity, the spawn site's +//! `has_portals || m.wmo_id != 0` (`terrain_stream::spawn`), and a pool's `WmoGroupVis`/`WmoRoom` +//! scope is keyed to it. No shipped root is both portal-less and unnamed: the only `wmoID == 0` +//! root, `pvp_alterac_ent01.wmo`, has portals. +//! `cargo run -p benilla-formats --example wmo_ownerless_pools` //! -//! A placed WMO gets a `WmoPortalInstance` only when it has a portal graph **or** an authored -//! `WMOAreaTable` identity — `has_portals || m.wmo_id != 0`, the spawn site in -//! `benilla-world/src/terrain_stream/spawn/mod.rs`. Everything about a building's *rooms* hangs off -//! that instance: it is the per-placement entity the group-visible set lives on, and it is what a -//! pool's `WmoGroupVis`/`WmoRoom` scope is keyed to. -//! -//! So a root that has **neither** portals **nor** a wmoID is instance-less — and until decision 1652 -//! that meant its embedded MLIQ pools were tagged with *neither* `WmoGroupVis` nor `ExteriorScene`, -//! i.e. no system ever wrote their `Visibility` at all, while the same placement's walls were -//! tagged `ExteriorScene` unconditionally and window-culled normally. A sealed well room drawing a -//! disc of water in mid-air is what that *would* look like from outside. -//! -//! Whether any shipped root actually falls in that cell is the question — an unreachable branch and -//! a widespread defect are the same code, and only the corpus tells them apart. -//! -//! **The answer is zero** (run 2026-08-27, decision 1652): of 815 roots, 448 carry no portal graph -//! and exactly **one** carries `wmoID == 0` — `pvp_alterac_ent01.wmo` — and that one *does* have -//! portals. The two conditions never coincide in shipped data, so the instance-less branch is dead -//! and 1652's unconditional tag on pools closes a hole no building reaches. That is worth a census -//! rather than a guess in either direction: it is the difference between a fix that earns a sighting -//! and one that must never be credited with one. -//! -//! Two facts per root, both read from the bytes the runtime reads: -//! -//! * **instance** — `MOPT` and `MOPR` both non-empty (`benilla_assets::WmoModel::portal_infos` / -//! `portal_refs`), or `MOHD.wmoID @0x20 != 0` ([`benilla_formats::wmo_root_id`], the -//! `WMOAreaTable.WMOID` key). -//! * **liquid** — any group file for which [`benilla_formats::wmo_group_liquid_mesh`] yields a -//! surface. That is the *same* call `WmoModel::group_liquids` makes, so a root counted wet here is -//! exactly a root that spawns pool entities at placement. (`resolve_shared_liquid_cells` can drop -//! one group's mesh afterwards, but only in favour of a sibling group of the same root, so it -//! cannot change a root's wet/dry verdict.) A group whose MOGP `groupLiquid` @`0x34` declares -//! whole-group submersion but carries no `MLIQ` grid (13 in the archive, decision 1000) is **not** -//! counted: it spawns no pool entity, so there is nothing for an instance to own. The liquid-group -//! total cross-checks against `wmo_liquid_arms`, which reaches the same groups from the other side -//! — the listfile's `_NNN` group files rather than `MOHD.nGroups` per root. -//! -//! Roots are also split by whether the world actually **places** them — an unplaced WMO can never be -//! stood next to, so it cannot show the defect. Placements come from every ADT's `MODF` plus the -//! WDT global-WMO of the 20 WMO-only maps (decision 0688 — scanning only ADTs would call the Deeprun -//! Tram and every instance-shaped dungeon unplaced). -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. +//! A root has liquid when [`benilla_formats::wmo_group_liquid_mesh`] yields a surface for any of +//! its groups, the call `WmoModel::group_liquids` makes; a group flooded by `groupLiquid` alone +//! spawns no pool and is not counted. Placements come from every ADT's `MODF` and each WMO-only +//! map's WDT global WMO. Output is Blizzard data: never commit it. use std::collections::{BTreeMap, BTreeSet}; use std::io::Cursor; @@ -54,38 +19,35 @@ use benilla_formats::{ /// What one root says about the two axes. struct Root { - /// `MOHD.wmoID @0x20` — the `WMOAreaTable.WMOID` key; `0` ⇒ the building has no authored identity. + /// `MOHD.wmoID` (`+0x20`), the `WMOAreaTable.WMOID` key; 0 means no authored identity. wmo_id: u32, - /// `MOPT` ∧ `MOPR` both non-empty — the spawn site's `has_portals`. + /// `MOPT` and `MOPR` both non-empty, the spawn site's `has_portals`. has_portals: bool, /// Group files the root declares (`MOHD.nGroups`). groups: u32, /// Groups whose MLIQ resolves to a liquid surface. liquid_groups: u32, - /// Wet cells across those surfaces — the size of what draws. wet_tiles: u32, - /// Maps the root is placed on (empty ⇒ never placed). + /// Maps the root is placed on; empty if never placed. maps: BTreeSet, } impl Root { - /// The spawn site's test, verbatim: no instance ⇒ nothing owns this building's rooms. + /// The spawn site's test: without an instance nothing owns the building's rooms. fn has_instance(&self) -> bool { self.has_portals || self.wmo_id != 0 } } -/// Normalize a chain path the way the MPQ hash compares them, so ADT `MWMO` names and listfile -/// names land on the same key. +/// A chain path normalised as the MPQ hash compares it, so ADT `MWMO` and listfile names agree. fn key(name: &str) -> String { name.replace('/', "\\").to_ascii_lowercase() } -/// Every WMO root the world actually places, mapped to the maps it appears on: ADT `MODF` first, -/// then the WDT global-WMO that IS the world on a WMO-only map (decision 0688). +/// Every placed WMO root and the maps it is on: ADT `MODF`s, then each WMO-only map's WDT global. fn placed_roots(chain: &Chain) -> anyhow::Result>> { let mut placed: BTreeMap> = BTreeMap::new(); - // `World\Maps\\_XX_YY.adt` — the map name is the third path component. + // `World\Maps\\_XX_YY.adt`: the map name is the third path component. let map_of = |name: &str| key(name).split('\\').nth(2).unwrap_or("?").to_string(); let names: Vec = chain.list()?.into_iter().map(|e| e.name).collect(); @@ -137,13 +99,8 @@ fn main() -> anyhow::Result<()> { let placed = placed_roots(&chain)?; eprintln!("{} distinct WMO roots placed in the world", placed.len()); - // A group file's stem ends `_NNN`; a root's does not. The chain lists both. - // - // The listfile is NOT a complete index (`Chain::list`'s own doc: a file absent from every - // archive's listfile is readable by name but not enumerated), so the scanned set is the UNION of - // the listed roots and every root an ADT/WDT actually references. A placed-but-unlisted root is - // exactly the one this census must not miss, and the two counts printed below say whether the - // union ever added anything. + // Roots are the listed `.wmo` files whose stem lacks `_NNN`, plus every placed root the + // listfile misses: a file absent from every listfile is readable but not enumerated. let listed: BTreeSet = chain .list()? .into_iter() @@ -177,8 +134,7 @@ fn main() -> anyhow::Result<()> { let Ok(bytes) = chain.read(k) else { continue; }; - // `parse_wmo_root` is the group-count authority (`MOHD.nGroups`); a file that fails it is - // not a root and has no rooms to own. + // `parse_wmo_root` gives the group count (`MOHD.nGroups`); a file it rejects has no rooms. let Ok(root) = parse_wmo_root(&bytes) else { continue; }; @@ -243,9 +199,7 @@ fn main() -> anyhow::Result<()> { .count(); let liquid_groups: u32 = census.values().map(|r| r.liquid_groups).sum(); - // The two axes on their own. The cross-tab cannot say WHICH half of `has_portals || wmo_id != 0` - // carries a root, and "no root is instance-less" is a claim that has to be falsifiable from the - // same run rather than taken on the strength of one collapsed cell. + // Each axis alone: the cross-tab cannot say which half of `has_portals || wmo_id != 0` holds. let portal_less = census.values().filter(|r| !r.has_portals).count(); let unnamed = census.values().filter(|r| r.wmo_id == 0).count(); println!( @@ -258,9 +212,7 @@ fn main() -> anyhow::Result<()> { {liquid_groups} liquid groups in all" ); - // Every root on the rarer axis, named: `wmoID == 0` is what makes the instance depend on the - // portal graph at all, so the handful that have it are the whole falsifiable surface of the - // cross-tab's empty cell. + // Every `wmoID == 0` root by name: only these depend on their portal graph for an instance. println!("\nroots with wmoID 0:"); for (path, r) in census.iter().filter(|(_, r)| r.wmo_id == 0) { println!( diff --git a/crates/benilla-formats/src/anim_data.rs b/crates/benilla-formats/src/anim_data.rs index bfb54aac..1f0dcdde 100644 --- a/crates/benilla-formats/src/anim_data.rs +++ b/crates/benilla-formats/src/anim_data.rs @@ -1,32 +1,9 @@ -//! `AnimationData.dbc` — the per-animation policy row behind the sheath reconcile (decision 0080) -//! and, later, missing-clip fallback resolution. -//! -//! Layout — VERIFIED against build 5875 (header + full 208-row decode with name joins, -//! 2026-07-03): **208 × 7 × 28 B**: `ID(0), Name(str, 1), WeaponFlags(2), BodyFlags(3), _col4(4), -//! _col5(5), Fallback(6)`. -//! -//! - **WeaponFlags (col 2)** — the sheath-reconcile bits the client tests on every `PlayAnimation` -//! (`0x5fdf80`). The identification is the binary's: the reconcile indexes the -//! `WowClientDB` cache (`0xc0e070`, loader-asserted 7 columns × 28 B against -//! `DBFilesClient\AnimationData.dbc`) by the current sequence's AnimationData id, and all three -//! flag tests read row offset `+0x8` = column 2. -//! The bits: `4` force-stow (casts, Swim 42–45, Mount 91, -//! SitChair 102–104, Loot 50), `0x10` force-stow (every Emote, AttackUnarmed 16/117, -//! SitGround 96–98, Kneel 114–116, AttackBow/Rifle/Thrown), `0x20` force-draw-melee (armed -//! attacks 17–19/85–88, Ready1H/2H/2HL 26–28, specials, FishingCast/Loop 133–134). The column's -//! exact 5875 value set is `{0, 4, 16, 20, 32}` — precisely the tested bit triple. -//! - **Fallback (col 6)** — identified from the data (the chains are unambiguous: -//! Attack2H→Attack1H→AttackUnarmed, Sprint→Run, JumpLandRun→Run, Sleep→SleepDown, Drowned→Drown, -//! WalkBackwards→Walk, StealthWalk→Walk; col 5 read as a fallback yields nonsense — Attack2H→Stop, -//! Sleep→SpellCast). **Not the primary runtime missing-clip mechanism** (decision 0082, amending -//! this file's earlier reading): the real client's per-id fallback for ids `< 203` is a single -//! indexed read of the M2's own baked `PlayableAnimationLookup` (a per-model precomputed cache of -//! *this* column's walk against that model's actual sequence set — see -//! `benilla_formats::PlayableAnim` / `benilla_assets::ModelAnimations::resolve`). This column is -//! PATH 2 of that resolver (ids ≥ 203 only, `AnimationData.dbc`'s 208 rows) and the *source* the -//! baked tables were computed from. `0` = fall back to Stand. -//! - Cols 4/5 are unidentified here (4 reads priority-like — 500 on Death/Dead/Drown, 10 on -//! locomotion; 5 flag-like) and are not exposed. +//! `AnimationData.dbc`, the per-animation policy: 208 rows of `ID`, `Name`, `WeaponFlags`, +//! `BodyFlags`, two unidentified columns and `Fallback`. The reference's sheath reconcile +//! (`0x5fdf80`, on every `PlayAnimation`) reads `WeaponFlags` at row `+0x8` of the `0xc0e070` cache +//! for the requested id, not a model's substitute: `4` and `0x10` stow, `0x20` draws melee, and +//! 5875 sets no other bit. `Fallback` is walked only for ids past a model's baked +//! `PlayableAnimationLookup` (203 and up), which was computed from it. use std::collections::HashMap; @@ -39,7 +16,7 @@ use crate::Chain; /// One animation's policy row. #[derive(Clone, Copy)] pub struct AnimEntry { - /// The sheath-reconcile bits (see the module doc). `0` for most rows. + /// The sheath-reconcile bits (col 2). pub weapon_flags: u32, /// The substitute animation id when a model lacks this clip (`0` = Stand). pub fallback: u16, @@ -48,15 +25,13 @@ pub struct AnimEntry { /// `AnimationData.dbc` id → its policy row. pub struct AnimDataCatalog { rows: HashMap, - /// id → the row's Name string (col 1, `"Attack1H"`) — the human face for debug readouts - /// (the inspector's anim line). Beside [`AnimEntry`] so the policy row stays `Copy`. + /// The Name column (col 1, `"Attack1H"`) for debug readouts, kept apart so [`AnimEntry`] + /// stays `Copy`. names: HashMap, } impl AnimDataCatalog { - /// Build a catalog directly from rows — the synthetic-table entry point for tests exercising - /// missing-clip resolution's PATH 2 walk (decision 0082, `benilla_assets::ModelAnimations::resolve`) - /// without a real `AnimationData.dbc`. + /// A catalog of synthetic rows with no names, for tests without an `AnimationData.dbc`. pub fn from_rows(rows: impl IntoIterator) -> Self { Self { rows: rows.into_iter().collect(), @@ -64,13 +39,13 @@ impl AnimDataCatalog { } } - /// The sheath-reconcile WeaponFlags for an animation id (`0` for an unknown id — no policy). + /// The WeaponFlags for an animation id, `0` (no policy) for an unknown one. pub fn weapon_flags(&self, id: u16) -> u32 { self.rows.get(&id).map_or(0, |r| r.weapon_flags) } - /// The fallback animation for a model lacking clip `id` — `None` when the row has none - /// (fallback 0 = Stand, left to the caller's own id-list fallbacks) or the id is unknown. + /// The fallback for a model lacking clip `id`; `None` for Stand (`0`), which the caller + /// resolves itself. pub fn fallback(&self, id: u16) -> Option { self.rows .get(&id) @@ -78,8 +53,7 @@ impl AnimDataCatalog { .filter(|&f| f != 0 && f != id) } - /// The row's Name string (`"Run"`, `"Attack1H"`) — `None` for an unknown id or a - /// synthetic-table catalog ([`Self::from_rows`] keeps no names). + /// The row's Name (`"Run"`); a [`Self::from_rows`] catalog has none. pub fn name(&self, id: u16) -> Option<&str> { self.names.get(&id).map(String::as_str) } @@ -134,9 +108,7 @@ pub fn load_anim_data_catalog(chain: &mut Chain) -> Result { mod tests { use super::*; - /// The real 5875 table: the WeaponFlags value set is exactly the reconcile's tested bit - /// triple, the decision-0080 spot rows carry the recorded bits, and the fallback chains - /// resolve (Attack2H→Attack1H, Sprint→Run). Skips without client data. + /// The 5875 table: WeaponFlags within the reconcile's three bits, spot rows, fallback chains. #[test] fn real_animation_data_decodes() { let data = crate::wow_data_or_skip!(); @@ -144,7 +116,6 @@ mod tests { let cat = load_anim_data_catalog(&mut chain).expect("load AnimationData"); assert_eq!(cat.len(), 208); - // The whole column stays within the tested bit triple {0, 4, 0x10, 0x14, 0x20}. for (&id, row) in &cat.rows { assert!( matches!(row.weapon_flags, 0 | 4 | 0x10 | 0x14 | 0x20), @@ -153,7 +124,6 @@ mod tests { ); } - // Decision 0080's transcribed highlights. assert_eq!(cat.weapon_flags(42), 4, "Swim force-stows"); assert_eq!(cat.weapon_flags(91), 4, "Mount force-stows"); assert_eq!(cat.weapon_flags(102), 4, "SitChairLow force-stows"); @@ -169,18 +139,16 @@ mod tests { assert_eq!(cat.weapon_flags(133), 0x20, "FishingCast force-draws"); assert_eq!(cat.weapon_flags(0), 0, "Stand carries no policy"); - // The ranged-handling trio the reconcile exempts (0x69/0x6a/0x70). + // The ranged-handling trio the reconcile exempts. for id in [105, 106, 112] { assert_eq!(cat.weapon_flags(id), 0, "Load* anims carry no policy"); } - // The name column (the inspector's anim line). assert_eq!(cat.name(0), Some("Stand")); assert_eq!(cat.name(5), Some("Run")); assert_eq!(cat.name(17), Some("Attack1H")); assert_eq!(cat.name(255), None, "unknown id has no name"); - // The fallback chains. assert_eq!(cat.fallback(18), Some(17), "Attack2H → Attack1H"); assert_eq!(cat.fallback(17), Some(16), "Attack1H → AttackUnarmed"); assert_eq!(cat.fallback(143), Some(5), "Sprint → Run"); diff --git a/crates/benilla-formats/src/area_poi.rs b/crates/benilla-formats/src/area_poi.rs index d390895d..e5b4a6d1 100644 --- a/crates/benilla-formats/src/area_poi.rs +++ b/crates/benilla-formats/src/area_poi.rs @@ -1,27 +1,8 @@ -//! `AreaPOI.dbc` — the world-map's named points of interest: inns, flight masters, dungeon -//! entrances, battleground nodes — everything the minimap's nearest-3 POI blips (decision 0203 -//! phase 3) and the world map's POI icons draw from. -//! -//! **Client-only: vmangos carries no struct for this table** (never loaded server-side). Layout -//! pinned by inspection this session (header + full 339-row decode, cross-checked against a -//! community reference struct for the same build, 2026-07-07): **339 × 29 × 116 B**: -//! `ID(0), Importance(1), Icon(2), FactionID(3), Pos[3](4-6, world x/y/z), ContinentID(7), -//! Flags(8), AreaID(9), Name_lang(10, enUS string) + 7 other-locale slots(11-17) + NameFlags(18), -//! Description_lang(19, enUS string) + 7 other-locale slots(20-26) + DescriptionFlags(27), -//! WorldStateID(28)` — the field the task brief's 28-column enumeration left unaccounted for. -//! -//! Every field past `Pos`/`Name`/`Description` was proven by scanning all 339 rows, not assumed: -//! `Importance` ∈ `{0,2,3,4}` and `Icon` takes 43 distinct small values (both enum-shaped); -//! `FactionID` ∈ `{0,83,84}` (a `FactionTemplate` FK used for only the faction-tinted rows); -//! `ContinentID` ∈ `{0,1,30,37,529}` — Azeroth/Kalimdor plus three battleground map ids; -//! `AreaID` ranges `−1..=3427` including both `−1` (raw, stored as `u32::MAX` — the -//! `WMOAreaTable`-style "no group" sentinel) and a distinct `0`; every row's `Pos.x`/`Pos.y` falls -//! well inside `±17066` (the world-map half-extent); every row's enUS `Name_lang` is non-empty; -//! and **field 28 is `0` on 209 rows and a small nonzero id on the other 130**, each of those 130 -//! landing in one of a handful of tight numeric bands (`1301-1304`, `1325-1340`, …) — exactly the -//! shape of a `WorldState` id, confirmed on a named example: id 1613 "Stables" (an Arathi Basin -//! node, `ContinentID` 529) carries `WorldStateID` 1770 and `Description_lang` "In Conflict", the -//! live capture-state label a BG node's map icon shows. +//! `AreaPOI.dbc`, the named points of interest (inns, flight masters, dungeon entrances, +//! battleground nodes) behind the minimap's landmark blips and the world map's icons. Client-only: +//! vmangos never loads it. 339 rows of 29 columns (116 B): `ID`, `Importance`, `Icon`, +//! `FactionID`, `Pos[3]`, `ContinentID`, `Flags`, `AreaID`, then `Name` and `Description` as +//! 9-column loc-strings (enUS, 7 other locales, flags), then `WorldStateID`. use std::collections::HashMap; @@ -33,40 +14,34 @@ use crate::Chain; const AREA_POI: &str = "DBFilesClient\\AreaPOI.dbc"; -/// One `AreaPOI.dbc` row — a named world-map point of interest. +/// One `AreaPOI.dbc` row. #[derive(Clone, Debug)] pub struct AreaPoi { - /// Map-icon prominence tier (`{0,2,3,4}` in 5875 — higher shows at more zoomed-out levels). + /// Prominence tier, `{0, 2, 3, 4}` in 5875; the minimap ranks its landmark arrows by it. pub importance: u32, - /// The map-icon art index (43 distinct icons in 5875). + /// The map icon, a cell of `POIIcons.blp`. pub icon: u32, - /// `FactionTemplate.dbc` FK for a faction-tinted icon; `0` = untinted (the common case). + /// `FactionTemplate.dbc` id for a faction-tinted icon, `0` for none. pub faction_id: u32, /// World position `(x, y, z)`. pub pos: [f32; 3], - /// The map id this POI's `pos` is expressed in (Azeroth `0`, Kalimdor `1`, or a - /// battleground/instance map id). + /// The map `pos` is in: `0` Azeroth, `1` Kalimdor, or a battleground map. pub continent_id: u32, pub flags: u32, - /// `AreaTable.dbc` id, or `u32::MAX` (raw `−1`) for a continent-wide POI — distinct in this - /// data from a plain `0` (see the module doc). + /// `AreaTable.dbc` id, or `u32::MAX` (raw `-1`) for a continent-wide POI. pub area_id: u32, - /// enUS display name (locale slot 0) — non-empty on every 5875 row. + /// enUS display name (locale slot 0). pub name: String, - /// enUS description (locale slot 0) — empty on the ~2/3 of rows with no live status text. + /// enUS live status text (`"In Conflict"`), empty on most rows. pub description: String, - /// `WorldState` id driving this POI's live label/icon (e.g. a battleground node's capture - /// state); `0` = no live state (209 of 339 rows). + /// The `WorldState` id driving the live label and icon (a battleground node's capture + /// state), `0` for none. pub world_state_id: u32, } -/// `AreaPOI.dbc` — every row in **file order**, plus the id index. -/// -/// File order is not tidiness: the reference's world-map landmark builder `0x4a67a0` walks the -/// whole table by record (`[0xc0e054]`, count `[0xc0e058]`, stride `0x74`) and appends survivors -/// in that order, so the landmark *index* a Lua `GetMapLandmarkInfo(i)` reads is the DBC's own -/// order. A hash iteration would also make our landmark list a different permutation every frame, -/// which the world map's change-diff would read as a change and repaint on. +/// Every `AreaPOI.dbc` row in file order, plus an id index. The order is the reference's: its +/// landmark builder `0x4a67a0` walks the table by record (`[0xc0e054]`, count `[0xc0e058]`), so +/// `GetMapLandmarkInfo(i)` indexes in DBC order. pub struct AreaPoiCatalog { rows: Vec<(u32, AreaPoi)>, by_id: HashMap, @@ -77,9 +52,7 @@ impl AreaPoiCatalog { self.by_id.get(&id).map(|&i| &self.rows[i].1) } - /// Every row in **file order** — the reference builder's walk (see the type doc). The - /// nearest-3 minimap selection (decision 0203 phase 3) filters this by `continent_id` + - /// distance itself; the world map's landmark pass runs the `0x4a67a0` gate chain over it. + /// Every row in file order. pub fn rows(&self) -> impl Iterator { self.rows.iter().map(|(id, poi)| (*id, poi)) } @@ -93,9 +66,6 @@ impl AreaPoiCatalog { } } -/// 29 fields per the module doc: the two loc-string blocks are 9 dwords each (a string + 7 -/// other-locale string slots + a flags dword), matching the `TaxiNodes` schema pattern -/// (`crate::schema_for`). fn schema() -> Schema { let mut s = Schema::new("AreaPOI"); for name in ["ID", "Importance", "Icon", "FactionID"] { @@ -151,9 +121,7 @@ pub fn load_area_poi_catalog(chain: &mut Chain) -> Result { mod tests { use super::*; - /// The real 5875 table proves the layout: every row's enUS name is readable text, continent - /// ids and world positions land in the expected ranges, and the pinned Arathi Basin "Stables" - /// example resolves its `WorldStateID` + live status description. Skips without client data. + /// The 5875 layout: readable names, positions in range, and Arathi Basin's Stables node. #[test] fn real_area_poi_layout_sanity() { let data = crate::wow_data_or_skip!(); @@ -172,7 +140,6 @@ mod tests { "world x/y within the map half-extent: {:?}", poi.pos ); - // 0/1 are the continents; the rest are battleground/instance map ids — all small. assert!(poi.continent_id < 10_000, "continentId is a small map id"); if poi.world_state_id != 0 { worldstate_nonzero += 1; @@ -184,7 +151,6 @@ mod tests { "a meaningful share of rows carry a live WorldStateID: {worldstate_nonzero}" ); - // The pinned Arathi Basin "Stables" node. let stables = cat.get(1613).expect("id 1613 (Stables)"); assert_eq!(stables.name, "Stables"); assert_eq!(stables.continent_id, 529, "Arathi Basin's map id"); diff --git a/crates/benilla-formats/src/area_sound.rs b/crates/benilla-formats/src/area_sound.rs index 87400f2b..8aed2517 100644 --- a/crates/benilla-formats/src/area_sound.rs +++ b/crates/benilla-formats/src/area_sound.rs @@ -1,20 +1,7 @@ -//! The zone→audio data plane: **AreaTable** (the zone/subzone row the MCNK `areaId` points at) -//! joined to **ZoneMusic** / **SoundAmbience** / **ZoneIntroMusicTable** (decision 0070, slice 1). -//! -//! Layouts — VERIFIED against build 5875 (header + row decodes, 2026-07-02): -//! - `AreaTable.dbc` **1081 × 25 × 100 B**: `ID(0), ContinentID(1), ParentAreaID(2), AreaBit(3), -//! Flags(4), SoundProviderPref(5), SoundProviderPrefUnderwater(6), AmbienceID(7), ZoneMusic(8), -//! IntroSound(9), ExplorationLevel(10), AreaName_lang(11..19), FactionGroupMask(20), -//! LiquidTypeID[4](21..24)`. Spot-check row 12: "Elwynn Forest", Ambience 35, ZoneMusic 1. -//! NOTE the wowdev-wiki 5875 struct appends TBC+ fields (28 cols) — **wrong**; 25 verified -//! (decision 0070). -//! - `ZoneMusic.dbc` **99 × 8 × 32 B**: `ID, SetName(str), SilenceMin[2], SilenceMax[2], -//! Sounds[2]` — the `[2]` arrays are `[day, night]`, intervals in **ms**, `Sounds` → -//! SoundEntries kits (type 28). Row 1: "Zone-Forest", 180 000–300 000 ms, kit 2523/2523. -//! - `ZoneIntroMusicTable.dbc` **43 × 5 × 20 B**: `ID, Name(str), SoundID, Priority, -//! MinDelayMinutes`. Row 61: "Valley of Heroes", 2541, 1, 60. -//! - `SoundAmbience.dbc` **68 × 3 × 12 B**: `ID, AmbienceID[2] = [day, night]` → looping -//! SoundEntries kits (type 50). Row 22: day 4163 / night 4204. +//! The zone audio tables: `AreaTable` (the row an MCNK `areaId` names) joined to `ZoneMusic`, +//! `SoundAmbience` and `ZoneIntroMusicTable`. `AreaTable.dbc` has 25 columns in 5875, not the 28 +//! of later builds: the eleven `area_schema` names, `AreaName_lang` (11-19), `FactionGroupMask` +//! and `LiquidTypeID[4]`. use std::collections::HashMap; @@ -27,19 +14,18 @@ use crate::dbc::{parse, str_at, u32_at}; /// The audio-relevant slice of one `AreaTable` row. pub struct AreaEntry { pub id: u32, - /// Parent zone (subzone rows inherit unset audio fields from it; 0 = a top-level zone). + /// The parent zone, `0` for a top-level one; a subzone inherits its unset audio ids. pub parent: u32, - /// `SoundAmbience.dbc` FK (0 = inherit/none). + /// `SoundAmbience.dbc` id, `0` to inherit. pub ambience: u32, - /// `ZoneMusic.dbc` FK (0 = inherit/none). + /// `ZoneMusic.dbc` id, `0` to inherit. pub zone_music: u32, - /// `ZoneIntroMusicTable.dbc` FK (0 = inherit/none). + /// `ZoneIntroMusicTable.dbc` id, `0` to inherit. pub intro_sound: u32, - /// `SoundProviderPreferences.dbc` FK — the dry-land reverb preset (0 = inherit/none; only - /// 8 areas in 1.12, all dungeon floors — interiors carry theirs on `WMOAreaTable`). + /// `SoundProviderPreferences.dbc` id of the dry-land reverb preset, `0` to inherit; + /// interiors carry theirs on `WMOAreaTable`. pub sound_provider: u32, - /// `SoundProviderPreferences.dbc` FK while the listener is submerged (pref 11 "Underwater" - /// on 568 areas; 0 = inherit/none). + /// The preset while the listener is submerged (11 is "Underwater"), `0` to inherit. pub sound_provider_underwater: u32, /// The zone/subzone display name (enUS column). pub name: String, @@ -49,24 +35,24 @@ pub struct AreaEntry { pub struct ZoneMusicEntry { pub id: u32, pub set_name: String, - /// Silence between tracks, milliseconds, uniform in `[min, max]`. + /// Silence between tracks in ms, uniform in `[min, max]`. pub silence_min: [u32; 2], pub silence_max: [u32; 2], - /// SoundEntries kits (type 28) — the track pool per phase. + /// `SoundEntries` kits (type 28), the track pool per phase. pub sounds: [u32; 2], } -/// One `ZoneIntroMusicTable` row — the entry fanfare. +/// One `ZoneIntroMusicTable` row, the fanfare on entering an area. pub struct ZoneIntroEntry { pub id: u32, pub sound_id: u32, /// Higher wins when nested areas compete. pub priority: u32, - /// Replay throttle, minutes. + /// Replay throttle in minutes. pub min_delay_minutes: u32, } -/// One `SoundAmbience` row: the looping ambience kits, `[day, night]`. +/// One `SoundAmbience` row: the looping ambience kits (type 50), `[day, night]`. pub struct SoundAmbienceEntry { pub id: u32, pub kits: [u32; 2], @@ -80,7 +66,7 @@ pub struct AreaSoundCatalog { ambience: HashMap, } -/// An area's fully-resolved audio identity (after the parent walk). +/// An area's audio after the parent walk. pub struct AreaAudio<'a> { pub area_name: &'a str, pub music: Option<&'a ZoneMusicEntry>, @@ -99,20 +85,18 @@ impl AreaSoundCatalog { self.zone_music.get(&id) } - /// A `ZoneIntroMusicTable` row by id (the WMO interior override path resolves intros - /// directly — `WMOAreaTable.IntroSound` — instead of through an area row). + /// A `ZoneIntroMusicTable` row by id, for a `WMOAreaTable.IntroSound` interior override. pub fn intro(&self, id: u32) -> Option<&ZoneIntroEntry> { self.intros.get(&id) } - /// A `SoundAmbience` row by id (the WMO interior override path). + /// A `SoundAmbience` row by id, for a WMO interior override. pub fn ambience_row(&self, id: u32) -> Option<&SoundAmbienceEntry> { self.ambience.get(&id) } - /// Resolve an `areaId` to its audio rows, walking `ParentAreaID` for any FK that is 0 on the - /// subzone row (the standard vanilla inheritance: a subzone without its own music plays the - /// zone's). Cycle-guarded; `None` fields mean "no music/ambience anywhere up the chain". + /// An `areaId`'s audio rows, each id left `0` taken from the nearest ancestor up + /// `ParentAreaID`: a subzone without its own music plays its zone's. pub fn resolve(&self, area_id: u32) -> Option> { let first = self.areas.get(&area_id)?; let (mut music, mut intro, mut ambience) = (None, None, None); @@ -325,10 +309,7 @@ pub fn load_area_sound_catalog(chain: &mut Chain) -> Result { mod tests { use super::*; - /// End-to-end on the **real** 5875 tables: Elwynn Forest (area 12) resolves to the - /// "Zone-Forest" music set (3–5 min silence, kit 2523) and ambience 35; a subzone with its - /// own row zeroes (Crystal Lake 87, parent 12) inherits Elwynn's music through the parent - /// walk. Skips without client data. + /// The 5875 tables: Elwynn Forest (area 12) and a subzone inheriting its music. #[test] fn real_area_chain_resolves_elwynn() { let data = crate::wow_data_or_skip!(); @@ -355,7 +336,6 @@ mod tests { assert_eq!(music.sounds, [2523, 2523]); assert!(elwynn.ambience.is_some(), "Elwynn has ambience"); - // A subzone of Elwynn inherits through the parent walk when its own FKs are 0. let sub = cat .areas .values() diff --git a/crates/benilla-formats/src/area_table.rs b/crates/benilla-formats/src/area_table.rs index 1241b491..a07f69cb 100644 --- a/crates/benilla-formats/src/area_table.rs +++ b/crates/benilla-formats/src/area_table.rs @@ -1,20 +1,6 @@ -//! `AreaTable.dbc` — the area catalog: id → (map, parent zone, explore bit, name). The world map -//! (decision 0203 phase 2) resolves the player's MCNK `areaId` to its **top-level zone** through -//! the parent chain here (`SetMapToCurrentZone`), and labels zones with the localized `AreaName` -//! (the WorldMapArea string is the art *folder* — "Elwynn" — while the display name lives here — -//! "Elwynn Forest"). Phase 3's exploration overlays key off [`AreaTableRow::explore_flag`]. -//! -//! Layout — VERIFIED against build 5875 (2026-07-07, real-row decode: Northshire Valley id 9 → -//! zone 12 = Elwynn Forest; Booty Bay id 35 → zone 33 = Stranglethorn Vale; cross-checked against -//! mangoszero's `AreaTableEntry`): **25 × u32 cols**: `ID(0), MapID(1), ZoneID(2 — the parent -//! area, 0 = this row IS a top-level zone), ExploreFlag(3), Flags(4), … ExplorationLevel(10, the -//! binary's `+0x28`), AreaName(11)` + loc block, `FactionGroupMask(20)`. Sibling readers of the -//! same file: `quest_headers.rs` (id → name only), `area_sound.rs` (the audio columns). -//! -//! The PvP columns (zone-splash arc, decision 0287) — census of the real 5875 table: -//! `FactionGroupMask` is 2/4/0 (Alliance/Horde/neither) on zone rows and ~always 0 on subzone -//! rows; `Flags` bit `0x80` marks exactly the three FFA duel pits (Battle Ring 2177, The Rumble -//! Cage 2857, The Maul 3217 + its UNUSED twin) — *not* the enclosing "Gurubashi Arena" row. +//! `AreaTable.dbc`, the area catalog. The world map walks an MCNK `areaId` up the parent chain to +//! its top-level zone (`SetMapToCurrentZone`) and labels it with the localized `AreaName` +//! ("Elwynn Forest"; `WorldMapArea` holds the art folder, "Elwynn"). use std::collections::HashMap; @@ -26,28 +12,21 @@ use crate::dbc::{parse, str_at, u32_at}; const AREA_TABLE: &str = "DBFilesClient\\AreaTable.dbc"; -/// One `AreaTable.dbc` row (the columns the map arc reads). +/// One `AreaTable.dbc` row, the columns the map reads. #[derive(Clone, Debug)] pub struct AreaTableRow { /// `Map.dbc` id the area lives on. pub map_id: u32, - /// The parent area's id — `0` means this row is itself a top-level zone. + /// The parent area's id; `0` means this row is itself a top-level zone. pub zone_id: u32, - /// The exploration bit index (`PLAYER_EXPLORED_ZONES` bitset / `WorldMapOverlay` join, - /// phase 3). + /// The exploration bit index into `PLAYER_EXPLORED_ZONES`, also the `WorldMapOverlay` join. pub explore_flag: u32, - /// Area flags (col 4). Bit `0x80` = FFA duel pit ("PvP Area" / `isArena`, decision 0287); - /// bit `0x1` = snow; capitals carry `0x138`. + /// Bit `0x80` marks an FFA duel pit row (not its arena's), `0x1` snow; capitals carry `0x138`. pub flags: u32, - /// `FactionGroup.dbc` mask of the owning side (col 20): 2 = Alliance, 4 = Horde, 0 = neither - /// (contested — or a subzone row deferring to its zone). + /// `FactionGroup.dbc` mask of the owner: 2 Alliance, 4 Horde, 0 contested or a subzone row. pub faction_group_mask: u32, - /// `ExplorationLevel` (col 10, the binary's `AreaTable+0x28`) — **signed**, and read only as - /// `>= 0` vs `< 0`. It is the world-map landmark builder's exploration gate: a landmark whose - /// `AreaID` lands on a row with `>= 0` here stays hidden until the player has explored that - /// area ([`Self::explore_flag`]'s bit); `-1` exempts it (`0x4a6890`–`0x4a68f3`). The 5875 table - /// is `-1` on exactly one row and `>= 0` on the other 1080, so in practice the gate applies - /// wherever an `AreaID` resolves at all. + /// `ExplorationLevel` (`AreaTable+0x28`), read for its sign: a world-map landmark on a row + /// `>= 0` stays hidden until the area is explored, `-1` exempts it (`0x4a6890`-`0x4a68f3`). pub exploration_level: i32, /// The localized display name ("Elwynn Forest"). pub name: String, @@ -59,9 +38,7 @@ pub struct AreaTableCatalog { } impl AreaTableCatalog { - /// A catalog over rows given directly, rather than read off the chain — for a caller that - /// needs a table of exactly known shape (the map-arc gate tests build two-row ones, so that - /// what a gate does with `ExplorationLevel -1` is asserted rather than hoped for). + /// A catalog over rows given directly, for tests that need a table of known shape. pub fn from_rows(rows: Vec<(u32, AreaTableRow)>) -> Self { Self { by_id: rows.into_iter().collect(), @@ -72,14 +49,12 @@ impl AreaTableCatalog { self.by_id.get(&id) } - /// The display name for `id`, or `None`. + /// The localized display name for `id`. pub fn name(&self, id: u32) -> Option<&str> { self.by_id.get(&id).map(|r| r.name.as_str()) } - /// The top-level zone containing `area_id`: walk the `zone_id` parent chain until a row whose - /// parent is 0 (possibly `area_id` itself). `None` for an unknown id. Depth-guarded — the - /// 5875 chains are 1–2 deep, anything deeper is data corruption, not geography. + /// The top-level zone above `area_id`, or itself; a chain deeper than eight is corrupt data. pub fn top_zone(&self, area_id: u32) -> Option { let mut id = area_id; for _ in 0..8 { @@ -92,24 +67,9 @@ impl AreaTableCatalog { None } - /// Is `area_id` **cold** — does a unit standing here puff visible breath? - /// - /// The client's `0x67e9c0`, byte-exact: - /// - /// ```text - /// cold ⟺ ((leaf.Flags & 0x2) || no valid parent ? leaf.Flags : parent.Flags) & 0x1 - /// ``` - /// - /// **One hop, never a chain walk** ([`Self::top_zone`] is a different question with a - /// different answer). Bit `0x1` is authored on exactly four leaf rows in 5875 — Dun Morogh, - /// Winterspring, Razorfen Downs, Naxxramas — and the single-hop inheritance is what spreads it - /// to the 45 areas that are actually cold: every sub-area of Dun Morogh and Winterspring - /// carries `0x40` (bit `0x2` clear) and therefore reads its zone's flags instead. Bit `0x2` is - /// the opt-out that makes a sub-area answer for itself. - /// - /// This is **independent of the weather**: snowfall and breath vapour share no input, and - /// there is no indoor suppression — standing in the Thunderbrew Distillery is as cold as the - /// road outside, because indoor-ness only changes *which row* resolves, not this test. + /// Whether a unit standing in `area_id` puffs visible breath (`0x67e9c0`): bit `0x1` of the + /// leaf's flags when it sets bit `0x2` or has no parent row, else of its parent's. One hop, + /// never a chain walk, and no weather or indoor input. pub fn is_cold(&self, area_id: u32) -> bool { let Some(leaf) = self.by_id.get(&area_id) else { return false; @@ -122,13 +82,7 @@ impl AreaTableCatalog { row.flags & 0x1 != 0 } - /// The id of the zone **named** `name`, case-insensitively — the reverse of [`Self::name`], - /// for the one caller that has a name and needs the id: `/who`'s `z-"Elwynn Forest"` term, - /// which goes on the wire as a zone id (decision 0668). - /// - /// Names are not unique across the table (a subzone can share its zone's name, instance rows - /// repeat), so a **top-level** row (`zone_id == 0`) wins over any other match — that is what - /// "zone" means to the caller. Linear, which is fine at one call per query. + /// The id of the area named `name` (any case) for `/who`'s `z-` term; a top-level row wins. pub fn id_for_name(&self, name: &str) -> Option { let mut fallback = None; for (id, row) in &self.by_id { @@ -143,9 +97,7 @@ impl AreaTableCatalog { fallback } - /// Every row, unordered — for the rare query that is a whole-table **scan by flag** rather - /// than a lookup by id. The chat auto-join's city-word row (`Flags & 0x200`) is the first such - /// consumer, and it is a scan in the client too. + /// Every row, unordered, for a scan by flag (the chat auto-join's `Flags & 0x200` city row). pub fn rows(&self) -> impl Iterator { self.by_id.values() } @@ -159,9 +111,6 @@ impl AreaTableCatalog { } } -/// 25 u32-wide columns; only -/// `ID/MapID/ZoneID/ExploreFlag/Flags/ExplorationLevel/AreaName/FactionGroupMask` are read (see -/// module doc). fn schema() -> Schema { let mut s = Schema::new("AreaTable"); for i in 0..25 { @@ -192,8 +141,7 @@ pub fn load_area_table_catalog(chain: &mut Chain) -> Result { continue; }; let faction_group_mask = u32_at(r, 20).unwrap_or(0); - // Signed: `-1` is the "no exploration requirement" row. Absent reads as `-1` for the - // same reason — an unreadable column must not invent a gate. + // Signed; an absent column reads as `-1`, no exploration gate. let exploration_level = u32_at(r, 10).map_or(-1, |v| v as i32); by_id.insert( id, @@ -215,9 +163,6 @@ pub fn load_area_table_catalog(chain: &mut Chain) -> Result { mod tests { use super::*; - /// The real 5875 table: the verified parent chains (Northshire → Elwynn, Booty Bay → - /// Stranglethorn), a top-level zone resolving to itself, and the display-vs-art-folder name - /// split. Skips without client data. #[test] fn real_area_table_parent_chains_and_names() { let data = crate::wow_data_or_skip!(); @@ -234,29 +179,23 @@ mod tests { assert_eq!(northshire.name, "Northshire Valley"); assert_eq!(cat.top_zone(9), Some(12)); - // A leaf two hops down: Booty Bay → Stranglethorn Vale (top-level). + // Booty Bay (35) sits under Stranglethorn Vale (33), a top-level zone. assert_eq!(cat.top_zone(35), Some(33)); - // A top-level zone resolves to itself; its display name differs from the art folder. assert_eq!(cat.top_zone(12), Some(12)); assert_eq!(cat.name(12), Some("Elwynn Forest")); - // The phase-3 explore bit is populated (Elwynn's is 126 in 5875). assert_eq!(cat.get(12).expect("Elwynn").explore_flag, 126); - // The PvP columns (decision 0287): ownership masks on zone rows… assert_eq!(cat.get(12).expect("Elwynn").faction_group_mask, 2); assert_eq!(cat.get(14).expect("Durotar").faction_group_mask, 4); assert_eq!(cat.get(33).expect("Stranglethorn").faction_group_mask, 0); - // …and the FFA-pit bit on the leaf pit rows, NOT the enclosing arena row. + // The FFA-pit bit is on the pit row, not the enclosing arena row. assert_ne!(cat.get(2177).expect("Battle Ring").flags & 0x80, 0); assert_eq!(cat.get(1741).expect("Gurubashi Arena").flags & 0x80, 0); } - /// The cold-breath predicate on the shipped table (`0x67e9c0`): bit `0x1` is authored on - /// exactly four rows, and the ONE-HOP parent inheritance is what makes the sub-areas cold. - /// This is the test that would have caught reading the leaf alone — every place a player - /// actually stands in Dun Morogh is a sub-area whose own flags are `0x40`. + /// The sub-areas carry `0x40`, so only the one-hop inheritance makes them cold. #[test] fn cold_areas_inherit_one_hop_from_their_zone() { let data = crate::wow_data_or_skip!(); @@ -274,7 +213,6 @@ mod tests { assert!(cat.is_cold(id), "{label} is cold"); } - // The sub-areas: own flags `0x40` (bit 0x1 clear, bit 0x2 clear) → they read the zone's. for (id, label) in [ (131, "Kharanos"), (132, "Coldridge Valley"), @@ -286,8 +224,8 @@ mod tests { assert!(cat.is_cold(id), "{label} inherits its zone's cold"); } - // Negative controls, including the sharp one: Gnomeregan the Dun Morogh sub-area (133) is - // cold; Gnomeregan the dungeon row (721), which is its own top-level zone, is not. + // Gnomeregan the Dun Morogh sub-area (133) is cold; the dungeon row (721), its own + // top-level zone, is not. assert!(cat.is_cold(133), "Gnomeregan the sub-area"); for (id, label) in [ (721, "Gnomeregan (dungeon)"), diff --git a/crates/benilla-formats/src/area_trigger.rs b/crates/benilla-formats/src/area_trigger.rs index 5998ddb2..9b00944a 100644 --- a/crates/benilla-formats/src/area_trigger.rs +++ b/crates/benilla-formats/src/area_trigger.rs @@ -1,18 +1,6 @@ -//! `AreaTrigger.dbc` — the world's invisible trip-wires, and the containment law that fires them. -//! -//! A trigger is a volume the *client* watches: when the player walks into one, the client sends -//! `CMSG_AREATRIGGER` naming its id and the **server** decides what that means — a teleport (every -//! instance entrance, the Darnassus/Rut'theran portals), a quest's "explore here" objective, the -//! inn's rested-XP state, a battleground's entrance list. The client owns only the geometry; it -//! never knows what any trigger does. -//! -//! Layout — 10 × 4-byte columns (record stride `0x28`, which is what the reference's own scan -//! strides by): `ID(0), MapID(1), X(2), Y(3), Z(4), Radius(5), BoxLength(6), BoxWidth(7), -//! BoxHeight(8), BoxYaw(9)`. Positions are WoW world space (the space `bevy_to_wow` produces), -//! yaw in radians. -//! -//! The containment law is [`AreaTriggerRow::contains`] — read from the reference's `0x5e22d0`, -//! which is the only place this math lives. +//! `AreaTrigger.dbc`, the world's trigger volumes. The client owns only the geometry, sending +//! `CMSG_AREATRIGGER` on entering one; the server alone decides what it does. Rows stride `0x28`, +//! as the reference's scan does; positions are WoW world space (`bevy_to_wow`), yaw in radians. use std::collections::HashMap; @@ -24,8 +12,7 @@ use crate::dbc::{f32_at, parse, u32_at}; const AREA_TRIGGER: &str = "DBFilesClient\\AreaTrigger.dbc"; -/// One `AreaTrigger.dbc` row: a sphere (`radius != 0`) or an oriented box (`radius == 0`) sitting -/// on one map, in WoW world space. +/// One `AreaTrigger.dbc` row: a sphere (`radius != 0`) or an oriented box (`radius == 0`). #[derive(Clone, Copy, Debug)] pub struct AreaTriggerRow { /// The id that goes on the wire in `CMSG_AREATRIGGER`. @@ -34,33 +21,20 @@ pub struct AreaTriggerRow { pub map_id: u32, /// Centre, WoW world space. pub position: [f32; 3], - /// Sphere radius (yd). `0` ⇒ this row is a box; see [`Self::contains`]. + /// Sphere radius in yards; `0` makes the row a box. pub radius: f32, - /// **Full** box extents along the box's own local X/Y/Z (yd) — halved by the containment test, - /// which is how the reference reads them. + /// Full box extents along its local X/Y/Z in yards; the containment test halves them. pub box_size: [f32; 3], - /// The box's yaw about world Z (radians, CCW from +X), i.e. where its local +X points. + /// The box's yaw about world Z in radians, CCW from +X: where its local +X points. pub box_yaw: f32, } impl AreaTriggerRow { - /// Is `p` (WoW world space) inside this volume? - /// - /// The reference's containment predicate `0x5e22d0`, an orchestration over gx's matrix ops — - /// the comparison it owns is what this reproduces: - /// - /// - **`radius != 0` ⇒ sphere**, tested in **3D** and **inclusively**: the reference computes - /// `Σ(centre − p)²` and returns "inside" on `radius² >= dist²`. - /// - **`radius == 0` ⇒ oriented box**: the point is carried into the box's own frame (translate - /// to the centre, rotate by **−`box_yaw`** about Z) and compared against ±half of each - /// `box_size` component, **strictly** on all six faces (`-h < l < h`). The reference does the - /// transform through gx (`0x7bdc40` load / `0x7bdd60` rotate-about-Z / `0x7bd700` apply) and - /// owns only the comparison; vmangos's `IsPointInAreaTriggerZone` reaches the same law from - /// the other side (it rotates the player by `2π − box_orientation`, which is this rotation). - /// - /// The server re-runs the same test with **5 yd of slop** before acting - /// (`MiscHandler.cpp:641`), so a marginally tight answer here still opens the door; a - /// marginally *loose* one is simply ignored. + /// Whether `p` is inside, as the reference's `0x5e22d0` tests it. A sphere is a plain 3D + /// distance check, inclusive (`radius² >= Σ(centre − p)²`); a box carries `p` into its frame + /// through gx's matrix ops (`0x7bdc40` load, `0x7bdd60` rotate by `-box_yaw`, `0x7bd700` + /// apply), strict against half of `box_size` on all six faces. The server re-tests with 5 yd + /// of slop (`MiscHandler.cpp:641`). pub fn contains(&self, p: [f32; 3]) -> bool { if self.radius != 0.0 { let d = [ @@ -72,8 +46,7 @@ impl AreaTriggerRow { return self.radius * self.radius >= dist_sq; } - // World → box-local: the box's local +X points along `box_yaw`, so the inverse rotation is - // by −yaw about Z. Z is unrotated (the volume never pitches or rolls). + // World to box-local: rotate by -yaw about Z; the volume never pitches or rolls. let (sin, cos) = (-self.box_yaw).sin_cos(); let dx = p[0] - self.position[0]; let dy = p[1] - self.position[1]; @@ -89,31 +62,25 @@ impl AreaTriggerRow { } } -/// Every `AreaTrigger.dbc` row, bucketed by map — the per-map window the reference narrows to -/// before each check (`0x5e2080` walks the map-sorted table for a `[first, end)` index range; -/// bucketing answers the same question without depending on the file staying sorted). -/// -/// **File order is preserved inside each bucket**, because the check takes the *first* containing -/// row and volumes do overlap (an instance entrance sits inside its building's larger trigger). +/// Every row bucketed by map, the window the reference narrows to (`0x5e2080`). File order holds +/// in a bucket: the check takes the first containing row, and volumes overlap. pub struct AreaTriggerCatalog { by_map: HashMap>, len: usize, } impl AreaTriggerCatalog { - /// The triggers on `map_id`, in file order (empty for a map with none). + /// The triggers on `map_id`, in file order. pub fn on_map(&self, map_id: u32) -> &[AreaTriggerRow] { self.by_map.get(&map_id).map_or(&[], |v| v.as_slice()) } - /// The **first** trigger on `map_id` containing `p` — the reference's own pick - /// (`0x5e2110` breaks its scan on the first hit). + /// The first trigger on `map_id` containing `p`, where `0x5e2110` stops its scan. pub fn first_containing(&self, map_id: u32, p: [f32; 3]) -> Option<&AreaTriggerRow> { self.on_map(map_id).iter().find(|t| t.contains(p)) } - /// A row by id (linear over its map's bucket is not available here — ids are unique table-wide, - /// so this walks every bucket; used by tests and diagnostics, never per frame). + /// A row by id, walking every bucket: for tests and diagnostics, never per frame. pub fn get(&self, id: u32) -> Option<&AreaTriggerRow> { self.by_map.values().flatten().find(|t| t.id == id) } @@ -127,7 +94,7 @@ impl AreaTriggerCatalog { } } -/// 10 u32/f32-wide columns (see module doc). +/// The 10-column `AreaTrigger.dbc` schema. pub fn area_trigger_schema() -> Schema { let mut s = Schema::new("AreaTrigger"); s.add_field(SchemaField::new("ID", FieldType::UInt32)); @@ -185,7 +152,6 @@ mod tests { } } - /// The sphere leg: 3D, and inclusive exactly on the surface (the reference's `radius² >= d²`). #[test] fn sphere_is_3d_and_inclusive() { let t = sphere(5.0); @@ -195,12 +161,11 @@ mod tests { "exactly on the surface is in" ); assert!(!t.contains([15.1, 20.0, 30.0])); - // Z counts: straight up out of the sphere is out, which a 2D test would miss. + // Z counts: straight up out of the sphere is out. assert!(!t.contains([10.0, 20.0, 36.0])); assert!(t.contains([10.0, 20.0, 34.0])); } - /// The box leg: half-extents, strict faces, and the yaw actually rotating the volume. #[test] fn box_is_half_extents_strict_and_yawed() { let axis_aligned = AreaTriggerRow { @@ -214,12 +179,10 @@ mod tests { assert!(axis_aligned.contains([4.9, 1.9, 0.9])); // Half, not full: 6 yd along a 10-yd box is outside. assert!(!axis_aligned.contains([6.0, 0.0, 0.0])); - // Strict on the face itself. assert!(!axis_aligned.contains([5.0, 0.0, 0.0])); assert!(!axis_aligned.contains([0.0, 0.0, 1.0])); - // Rotate the same box a quarter turn: the long axis now runs along world +Y, so a point - // that was inside along X is outside, and its mirror along Y is inside. + // A quarter turn puts the long axis along world +Y. let turned = AreaTriggerRow { box_yaw: std::f32::consts::FRAC_PI_2, ..axis_aligned @@ -228,8 +191,6 @@ mod tests { assert!(turned.contains([0.0, 4.0, 0.0])); } - /// The real 5875 table: the Darnassus portal pair the reports name (B70), a box row and a - /// sphere row both present, and the per-map bucketing. Skips without client data. #[test] fn real_area_triggers_load_and_contain_their_own_centres() { let data = crate::wow_data_or_skip!(); @@ -237,21 +198,16 @@ mod tests { let cat = load_area_trigger_catalog(&mut chain).expect("load AreaTrigger"); assert_eq!(cat.len(), 432, "5875 ships 432 area triggers"); - // The two the Darnassus report rides on (B70), both on Kalimdor, both 10-yd spheres. They - // pin the coordinate columns from *outside* the client: vmangos's `areatrigger_teleport` - // sends 527 ("Darnassus - Exit") to (8785.79, 966.98, 30.20) — which is trigger **542**'s - // own position here, and vice versa. Two independent sources, agreeing to a couple of yards, - // on a pair of doorways that teleport to each other. + // The Darnassus portal pair, 10-yd spheres on Kalimdor: vmangos's `areatrigger_teleport` + // sends 527 to (8785.79, 966.98, 30.20), trigger 542's position here, and 542 to 527's. let exit = *cat.get(527).expect("trigger 527 (Darnassus - Exit)"); let entrance = *cat.get(542).expect("trigger 542 (Darnassus - Entrance)"); assert_eq!((exit.map_id, entrance.map_id), (1, 1)); assert!(exit.contains([9947.0, 2630.0, 1318.0]), "{exit:?}"); assert!(entrance.contains([8799.0, 970.0, 30.0]), "{entrance:?}"); - // …and 25 yd away is outside a 10-yd sphere. assert!(!entrance.contains([8824.0, 970.0, 30.0])); - // Every volume contains its own centre — the one invariant that catches a column shift - // (a size read as a coordinate, or a radius read from the wrong slot, breaks it). + // Every volume contains its own centre, which a column shift breaks. for t in cat.on_map(1) { assert!( t.contains(t.position), @@ -260,15 +216,12 @@ mod tests { ); } - // Both shapes are represented in the shipped table. - // Both shapes ship: 80 boxes, the rest spheres. let boxes = cat.on_map(0).iter().filter(|t| t.radius == 0.0).count(); assert!( boxes > 0 && boxes < cat.on_map(0).len(), "map 0 has both shapes" ); - // Buckets are per map, and the two continents carry most of the table. assert!(cat.on_map(1).iter().all(|t| t.map_id == 1)); assert_eq!((cat.on_map(0).len(), cat.on_map(1).len()), (133, 121)); } diff --git a/crates/benilla-formats/src/auction_house.rs b/crates/benilla-formats/src/auction_house.rs index 30fe8024..b670aeaa 100644 --- a/crates/benilla-formats/src/auction_house.rs +++ b/crates/benilla-formats/src/auction_house.rs @@ -1,29 +1,6 @@ -//! AuctionHouse.dbc — the **rates** each auction house charges: the deposit it takes to list an -//! item, and the cut it takes out of a sale. -//! -//! One consumer, and it is why the table has to be loaded rather than hardcoded: the sell pane's -//! `Deposit:` line has to show a number *before* the item is listed, so the client computes it -//! locally — `GetAuctionHouseDepositRate()` is a straight read of [`AuctionHouseCatalog::deposit_percent`] -//! at the house the session is talking to. Which house that is arrives on the wire: -//! `MSG_AUCTION_HELLO`'s reply carries a `houseId` alongside the auctioneer guid, and it keys this -//! table (decision 1511). Send the window the wrong id and the pane quietly shows Blackwater's 25% -//! where a city auctioneer charges 5%. -//! -//! Record layout (7 rows in the shipped 5875 file, verified by reading it): `ID@0`, `FactionID@1`, -//! `DepositPercent@2`, `CutPercent@3`, `Name_Lang@4..11`, `NameFlags@12`. Ids are 1..=7 and -//! contiguous, but this is keyed rather than indexed for the reason every catalog here is keyed: a -//! `houseId` off the wire is data, not an index we control. -//! -//! The shipped table is two tiers — the six faction houses charge **5% / 5%**, and Blackwater -//! (id 7, the neutral goblin house at faction 369) charges **25% / 15%**. That spread is the whole -//! economic point of the neutral auction house, so it is data worth reading rather than a constant -//! worth inlining. -//! -//! **The rate is only half of the deposit.** The server's charge also scales by stack count, by -//! duration, and by its own `Rate.Auction.Deposit` config multiplier, none of which live here; and -//! the real client's displayed number is computed with an intermediate truncation the server's is -//! not, so the two can disagree by a copper or two on cheap items. Decision 1511 records that -//! divergence and what benilla does about it — this module only supplies the percentage. +//! `AuctionHouse.dbc`, each auction house's deposit and cut rates. The server scales the deposit +//! further by stack count, duration and `Rate.Auction.Deposit`, and the reference's displayed +//! figure truncates midway where the server's does not, so they can differ by a copper or two. use std::collections::HashMap; @@ -38,40 +15,37 @@ const AUCTION_HOUSE: &str = "DBFilesClient\\AuctionHouse.dbc"; /// One auction house's row. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuctionHouseInfo { - /// The faction this house serves (`369` = Booty Bay, the neutral house). + /// The faction this house serves (`369`, Booty Bay, for the neutral house). pub faction: u32, - /// Percent of the item's vendor sell price taken to list it, before stack/duration scaling. + /// Percent of the item's vendor sell price taken to list it, before stack and duration scaling. pub deposit_percent: u32, /// Percent of the winning bid taken out of the seller's proceeds. pub cut_percent: u32, - /// The house's name ("Stormwind Auction House"), as shipped. + /// The house's name (`"Stormwind Auction House"`). pub name: String, } -/// AuctionHouse.dbc keyed by the `houseId` that `MSG_AUCTION_HELLO`'s reply carries. +/// `AuctionHouse.dbc` keyed by the `houseId` that `MSG_AUCTION_HELLO`'s reply carries. pub struct AuctionHouseCatalog { houses: HashMap, } impl AuctionHouseCatalog { - /// The whole row for `house_id`; `None` for an id with no row. + /// The row for `house_id`. pub fn get(&self, house_id: u32) -> Option<&AuctionHouseInfo> { self.houses.get(&house_id) } - /// The listing deposit rate, in percent — `GetAuctionHouseDepositRate()`'s answer. `None` for - /// an id with no row, which leaves the caller to decide (the sell pane shows no deposit rather - /// than inventing one). + /// The listing deposit rate in percent, `GetAuctionHouseDepositRate()`'s answer. pub fn deposit_percent(&self, house_id: u32) -> Option { self.houses.get(&house_id).map(|h| h.deposit_percent) } - /// The sale cut, in percent. + /// The sale cut in percent. pub fn cut_percent(&self, house_id: u32) -> Option { self.houses.get(&house_id).map(|h| h.cut_percent) } - /// Row count, for the load log. pub fn len(&self) -> usize { self.houses.len() } @@ -94,7 +68,7 @@ fn auction_house_schema() -> Schema { s } -/// Load AuctionHouse.dbc from the patch chain. +/// Load `AuctionHouse.dbc` from the patch chain. pub fn load_auction_houses(chain: &mut Chain) -> Result { let bytes = chain .read_file(AUCTION_HOUSE) @@ -124,9 +98,6 @@ pub fn load_auction_houses(chain: &mut Chain) -> Result { mod tests { use super::load_auction_houses; - /// The shipped 5875 table, read as data. The two tiers pinned here are the arithmetic the sell - /// pane rests on: every faction house is 5%/5%, and the neutral goblin house is 25%/15% — the - /// spread that makes cross-faction trading cost something. Skips without client data. #[test] fn the_shipped_houses_carry_the_two_rate_tiers() { let data = crate::wow_data_or_skip!(); @@ -135,7 +106,6 @@ mod tests { assert_eq!(cat.len(), 7, "the whole shipped table"); - // The six faction houses: a flat 5% to list, 5% of the sale. for id in 1..=6 { let h = cat.get(id).unwrap_or_else(|| panic!("house {id}")); assert_eq!(h.deposit_percent, 5, "house {id} deposit"); @@ -146,14 +116,12 @@ mod tests { Some("Stormwind Auction House") ); - // Blackwater — the neutral house, and the reason this is a table and not a constant. let neutral = cat.get(7).expect("house 7"); assert_eq!(neutral.faction, 369, "Booty Bay"); assert_eq!(neutral.deposit_percent, 25); assert_eq!(neutral.cut_percent, 15); assert_eq!(neutral.name, "Blackwater Auction House"); - // Past the table: no row. The sell pane shows no deposit rather than inventing one. assert_eq!(cat.deposit_percent(8), None); } } diff --git a/crates/benilla-formats/src/bank_bag_slot_prices.rs b/crates/benilla-formats/src/bank_bag_slot_prices.rs index ca006bf0..5e048f43 100644 --- a/crates/benilla-formats/src/bank_bag_slot_prices.rs +++ b/crates/benilla-formats/src/bank_bag_slot_prices.rs @@ -1,11 +1,6 @@ -//! `BankBagSlotPrices.dbc` — the bank's purchase-ladder price table (decision 0604). -//! -//! 12 rows × 2 `u32` columns (`ID`, price in copper). Verified from the real 5875 `dbc.MPQ` -//! (dumped this session): rows 1–6 = 1000, 10000, 100000, 250000, 500000, 1000000; rows 7–12 a -//! `999999999` sentinel — only the first 6 bank bag slots are actually purchasable -//! (`GetNumBankSlots()` reports `full` at 6; the sentinel rows exist but are unreachable). -//! `CMSG_BUY_BANK_SLOT` carries no slot index — the server buys slot `purchased_count + 1` itself -//! (`PLAYER_BYTES_2` byte 2) — so the client's only job is pricing that next slot. +//! `BankBagSlotPrices.dbc`: the copper price of each bank bag slot. Rows 7-12 hold an unreachable +//! `999999999` sentinel, as `GetNumBankSlots()` reports full at 6. `CMSG_BUY_BANK_SLOT` carries no +//! index: the server buys the slot after the count in `PLAYER_BYTES_2` byte 2. use anyhow::{Context, Result}; use benilla_dbc::{FieldType, Schema, SchemaField}; @@ -20,10 +15,7 @@ const BANK_BAG_SLOT_PRICES: &str = "DBFilesClient\\BankBagSlotPrices.dbc"; pub struct BankBagSlotPrices(HashMap); impl BankBagSlotPrices { - /// The cost of the *next* bank bag slot, given `purchased_count` already bought (slot - /// `purchased_count + 1`, 1-based to match the DBC's row ids). `None` past the table — no row - /// for that slot number at all, not merely the sentinel (the 6-purchasable-slot cap is a data - /// fact, not something this reader hardcodes). + /// The price of slot `purchased_count + 1`; a sentinel row still reads as its price. pub fn next_slot_price(&self, purchased_count: u8) -> Option { self.0.get(&(u32::from(purchased_count) + 1)).copied() } @@ -52,7 +44,6 @@ pub fn load_bank_bag_slot_prices(chain: &mut Chain) -> Result table_from(&bytes) } -/// The parse itself, split from the chain read so the golden test drives the identical path. fn table_from(bytes: &[u8]) -> Result { let rs = parse(bytes, schema(), "BankBagSlotPrices")?; let mut prices = HashMap::with_capacity(rs.records().len()); @@ -68,8 +59,7 @@ fn table_from(bytes: &[u8]) -> Result { mod tests { use super::*; - /// A hand-built WDBC with the real 5875 rows (verified against `dbc.MPQ` this session, - /// decision 0604): 6 real prices then the 6-row `999999999` sentinel. + /// A WDBC with the shipped rows: six prices, then six `999999999` sentinel rows. fn synthesize() -> Vec { let rows: &[[u32; 2]] = &[ [1, 1_000], diff --git a/crates/benilla-formats/src/bin/benilla-extract/chaincensus.rs b/crates/benilla-formats/src/bin/benilla-extract/chaincensus.rs index acd153b9..539cf43c 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/chaincensus.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/chaincensus.rs @@ -1,23 +1,13 @@ -//! `chaincensus`: the whole-table view of the **beam/chain** system (decision 0955) — every -//! `SpellVisualKit` that draws a `SpellChainEffects` beam, the row it names, and the spells that -//! reach it through which lifecycle stage. -//! -//! The scope instrument for B161 ("Chain Lightning has no chain effect"). It answers, from the -//! shipped table rather than from expectation, the two questions that turned that one report into -//! a system: *which* spells are affected (not just Chain Lightning — every drain, funnel and eye -//! beam in the game), and whether the small-int decode of `CharParamZero` is the real mechanism — -//! because if it is, **every** live slot must resolve to a real row of an 18-row table and name a -//! texture that matches the spell. A decode that were merely plausible would scatter. -//! -//! Read the `stage` column with the flag: `channel` kits ship flag 1, `cast` kits flag 0, and that -//! flag — not the type key's name — is the only thing the client distinguishes. +//! `chaincensus`: every `SpellVisualKit` that draws a `SpellChainEffects` beam, the row it names +//! and the spells that reach it by stage. Each live slot must name one of the table's 18 rows, or +//! the small-int decode of `CharParamZero` is wrong. The client tells the two kinds apart by the +//! flag alone, never the type key: `channel` kits ship flag 1, `cast` kits flag 0. use std::collections::{BTreeMap, BTreeSet}; use anyhow::Result; use benilla_formats::{char_proc_type, Chain, SpellVisualCatalog, VisualStages}; -/// A `SpellVisual` stage's column selector — one of the five lifecycle-kit fields. type StagePick = fn(&VisualStages) -> u32; const STAGES: [(&str, StagePick); 5] = [ @@ -35,7 +25,6 @@ struct Reach { stages: BTreeSet<&'static str>, } -/// Every kit → the spells that reach it, and by which stage. fn kit_reachability( chain: &mut Chain, visuals: &SpellVisualCatalog, diff --git a/crates/benilla-formats/src/bin/benilla-extract/charatlas.rs b/crates/benilla-formats/src/bin/benilla-extract/charatlas.rs index ba300fc8..8ff0b1e2 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/charatlas.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/charatlas.rs @@ -1,26 +1,8 @@ -//! `charatlas` — composite one character's body atlas off the chain and report **what painted -//! what**. -//! -//! The instrument the two "outfit texture" reports needed and nobody had. A dressed character's -//! body is one 256² atlas of ten fixed tiles (bbox table `0xb42450`), and every visible defect in -//! that class — a garment that stops early, a boot repainting a robe's hem, a bare band below the -//! knee — is one tile receiving the wrong contribution. Reading that off a screenshot means -//! guessing; reading it off the atlas means measuring. -//! -//! Three things it prints, all derived from the composite's own law -//! ([`benilla_formats::equip_blits`]), never a second transcription: -//! -//! 1. **The plan** — every equipment contribution in blit order, with the file each name actually -//! resolved to (or `MISSING`, which is the silent skip that reads as "the texture ends early"). -//! 2. **The per-tile diff** vs the same character composited naked, per atlas ROW. A tile whose -//! lower rows go unpainted is a garment that stops early; a row count that changes when one slot -//! is added is that slot's footprint. -//! 3. **The geosets** the same equipment selects, with the atlas rows each one samples — so "which -//! tile does the robe's skirt read?" is answered next to "what is in that tile". -//! -//! The tile↔geoset pairing is the whole diagnosis: geoset 1302 (the robe skirt) samples atlas rows -//! 112–223, which straddles the LegUpper **and LegLower** tiles — so a boot's LegLower contribution -//! lands on a robe's hem even though the boot's own geometry is disabled under a robe. +//! `charatlas`: composite one character's body atlas off the chain and report what painted what: +//! the equipment blits in [`benilla_formats::equip_blits`] order with the file each resolved to +//! (`MISSING` leaves base skin), the rows each tile repaints against the naked body, and the +//! geosets the equipment selects. Geoset 1302, the robe skirt, samples atlas rows 112-223 across +//! the LegUpper and LegLower tiles, so a boot's LegLower blit shows on a robe's hem. use anyhow::{Context, Result}; use benilla_formats::{ @@ -28,8 +10,7 @@ use benilla_formats::{ CharSections, CharacterGeosets, EmblemLayer, EquipGeosets, GuildEmblem, ItemDisplay, }; -/// The ten atlas tiles by group, for the per-tile report — the five head/left-column ones included, -/// so a head-section regression shows up in the same table as an equipment one. +/// The body's ten fixed tiles in its 256² atlas (the reference's bbox table `0xb42450`). const TILES: [(&str, u32, u32, u32, u32); 10] = [ ("g0 ArmUpper", 0, 0, 128, 64), ("g1 ArmLower", 0, 64, 128, 64), @@ -43,13 +24,12 @@ const TILES: [(&str, u32, u32, u32, u32); 10] = [ ("g7 Foot", 128, 224, 128, 32), ]; -/// The worn bodyslots the composite takes, in `equipment` order (bodyslot − 2). +/// The worn bodyslots the composite takes, in `equipment` order (bodyslot - 2). const SLOT_NAMES: [&str; 8] = [ "shirt", "chest", "belt", "pants", "boots", "wrist", "gloves", "tabard", ]; -/// One character appearance + what it wears — the whole input, so a report is reproducible from the -/// command line that made it. +/// One appearance and what it wears: the whole input, so its command line reproduces a report. pub struct Look { pub race: u8, pub sex: u8, @@ -58,17 +38,13 @@ pub struct Look { pub facial_hair: u8, pub hair_style: u8, pub hair_color: u8, - /// The eight worn display ids in `equipment` order; `0` = the slot is empty. + /// The eight worn display ids in `equipment` order; 0 is an empty slot. pub slots: [u32; 8], - /// The wearer's guild tabard, or `None` for "no guild" — the same input the world composite - /// takes (decision 1704). It only paints over a tabard whose display asks for it, so passing - /// one without a guild-emblem tabard in the tabard slot is a no-op, on purpose. + /// The guild tabard; it paints only over a tabard display that asks for an emblem. pub emblem: Option, } -/// A BLP's header as the composite cares about it: the dimensions it blits at and the alpha depth -/// that picks REPLACE (0) / 1-bit key (1) / blend (≥2) — the three that decide whether a -/// contribution *covers* the one under it or lets it through. +/// A BLP2's size and alpha depth, which picks the blit: replace (0), 1-bit key (1) or blend (2+). fn blp_shape(chain: &mut Chain, path: &str) -> Option<(u32, u32, u8)> { let b = chain.read_file(path).ok()?; if b.len() < 20 || &b[0..4] != b"BLP2" { @@ -112,9 +88,7 @@ pub fn charatlas(chain: &mut Chain, look: &Look, out: Option<&std::path::Path>) } } - // (1) The plan — the composite's own order, with what each name resolved to. Worn garments and - // the guild tabard's three layers come through the same list, because they land in the same - // rows and the question ("what repainted this cell?") is the same one. + // (1) The plan, in the composite's order; worn garments and the three emblem layers share it. println!("\nequipment blits (by ascending cell; later covers earlier within a tile):"); for step in equip_blits(&equipment, look.emblem, false) { let (_x, y, w, h) = equip_tile(step.layer).expect("layer < 8"); @@ -222,7 +196,6 @@ pub fn charatlas(chain: &mut Chain, look: &Look, out: Option<&std::path::Path>) }) .collect(); let rows = painted.iter().filter(|n| **n > 0).count(); - // Where a tile stops is the diagnosis, so name the run rather than dumping 64 numbers. let first = painted.iter().position(|n| *n > 0); let last = painted.iter().rposition(|n| *n > 0); let span = match (first, last) { @@ -235,18 +208,15 @@ pub fn charatlas(chain: &mut Chain, look: &Look, out: Option<&std::path::Path>) ); } - // (3) The geosets this same equipment selects. The atlas rows each samples are the model's - // (`benilla-extract m2batch ` prints the UV extents these come from), so the - // two halves of "which art lands on which geometry" sit in one report. + // (3) The geosets this equipment selects; `m2batch` on the race model prints their UV extents. let mut eq = EquipGeosets::default(); for (i, d) in equipment.iter().enumerate() { if let Some(d) = d { eq.bodyslots[i] = Some(d.geoset_groups); } } - // B3's gate is the ArmLower tile's occupancy — read off the same plan printed above (1864). + // The shirt-cuff geoset's gate is the ArmLower tile's occupancy, read off the same plan. eq.forearm_dressed = forearm_dressed(&equipment); - // Sorted + deduplicated at the source (`visible_geosets`). let ids = geosets.visible_geosets(look.race, look.sex, look.hair_style, look.facial_hair, &eq); println!("\nvisible geosets: {ids:?}"); diff --git a/crates/benilla-formats/src/bin/benilla-extract/charprocs.rs b/crates/benilla-formats/src/bin/benilla-extract/charprocs.rs index 4e71cfcb..310ec327 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/charprocs.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/charprocs.rs @@ -1,22 +1,14 @@ -//! `charprocs`: census the `SpellVisualKit` **CharProc** columns (fields 15–34) — the character-half -//! of a kit: what a kit does to the *body* (its alpha, its tint) rather than at an attach point. -//! -//! The scope instrument for the aura-state CharProc system (B114 — "Stealth shows nothing on the -//! character"). It answers, from the shipped table rather than from expectation: which proc **types** -//! exist, how many kits carry each, which **lifecycle stage** reaches them from a live spell, and — -//! for the state stage, whose kits live for an aura's whole life — exactly which spells reach which -//! proc with which parameter. A type that shows up only behind `cast`/`impact` is a discrete-play -//! concern; a type behind `state` is an aura-lifetime one. +//! `charprocs`: census the `SpellVisualKit` CharProc columns (fields 15-34), what a kit does to +//! the body rather than at an attach point: which proc types exist, which stage reaches each from +//! a live spell, and every state-stage proc, which lasts as long as its aura. use std::collections::{BTreeMap, BTreeSet}; use anyhow::Result; use benilla_formats::{char_proc_type, Chain, SpellVisualCatalog, TrailProc, VisualStages}; -/// A `SpellVisual` stage's column selector — one of the five lifecycle-kit fields. type StagePick = fn(&VisualStages) -> u32; -/// The five `SpellVisual` lifecycle stages, in field order — the label plus its column selector. const STAGES: [(&str, StagePick); 5] = [ ("precast", |s| s.precast), ("cast", |s| s.cast), @@ -25,7 +17,6 @@ const STAGES: [(&str, StagePick); 5] = [ ("channel", |s| s.channel), ]; -/// A human label for the proc types benilla models by name. fn type_name(ty: i32) -> &'static str { match ty { char_proc_type::TINT => "TINT (body RGB)", @@ -38,12 +29,12 @@ fn type_name(ty: i32) -> &'static str { } } -/// Census every kit's CharProc slots, then reachability per stage, then the state-stage detail. +/// Census the CharProc slots, their reach by stage, the state-stage procs and the weapon trails. pub fn run(chain: &mut Chain) -> Result<()> { let spells = benilla_formats::load_spell_catalog(chain)?; let visuals = benilla_formats::load_spell_visual_catalog(chain)?; - // 1. The raw table census: proc type → the kits carrying it. + // 1. Every proc type and the kits carrying it. let mut by_type: BTreeMap> = BTreeMap::new(); let mut kits_with_any = 0usize; for kit_id in visuals.kit_ids() { @@ -70,8 +61,7 @@ pub fn run(chain: &mut Chain) -> Result<()> { ); } - // 2. Reachability: which stage of which spell's visual actually reaches each proc type. A kit - // nobody's visual chain names is authored-but-dead as far as the client is concerned. + // 2. Each proc type's reach by stage from a live spell; a kit no visual names never plays. let mut by_stage: BTreeMap<(&str, i32), BTreeSet> = BTreeMap::new(); // The state stage's detail rows: (spell id, name, kit, proc type, params[0]). let mut state_rows: Vec<(u32, String, u32, i32, f32)> = Vec::new(); @@ -107,7 +97,7 @@ pub fn run(chain: &mut Chain) -> Result<()> { ); } - // 3. The state stage in full — these are the aura-lifetime procs, one line per (spell, proc). + // 3. Every state-stage proc, which lasts its aura's life, one line per spell and proc. println!( "\nSTATE-stage CharProcs — the aura-lifetime set ({} spell/proc pair(s)):", state_rows.len() @@ -120,10 +110,7 @@ pub fn run(chain: &mut Chain) -> Result<()> { ); } - // 4. The TRAIL set in full. Its shape is unlike the others': the proc carries no model and no - // emitter, so a kit whose slot list is EMPTY still has a visual — and 18 of these are exactly - // that, which is the single fact that decides whether the proc is worth building. The census - // prints the slot count beside every row so that number stays checkable rather than quoted. + // 4. The weapon-trail kits; a trail needs no effect slot, so it draws on a kit with none. let trail_kits: BTreeMap = visuals .kit_ids() .filter_map(|id| Some((id, visuals.kit(id)?.trail_proc()?))) @@ -160,9 +147,8 @@ pub fn run(chain: &mut Chain) -> Result<()> { let anim = kit.and_then(|k| k.anim_id); let [r, g, b] = trail.rgb(); let spells = reach.get(kit_id); - // `CharParamOne` is printed even though the type-8 arm never reads it (`0x60d80a`'s three - // `_ftol`s take Zero/Two/Three only). Not every shipped row carries `20.0` there, and a - // census that hides the column cannot catch that — Sinister Strike's kit 399 carries 15.0. + // The type-8 arm never reads `CharParamOne` (`0x60d80a`), but it is printed: not every row + // carries 20.0 there, and Sinister Strike's kit 399 carries 15.0. let unread = kit .and_then(|k| { k.char_procs() @@ -187,8 +173,7 @@ pub fn run(chain: &mut Chain) -> Result<()> { Ok(()) } -/// The kits a spell's visual chain reaches, printed as one line per stage — the per-spell view of -/// the census above (used by `spellvis`'s CharProc lines; kept here beside the type names). +/// Print one kit's CharProc slots, one line each. pub fn print_kit_procs(visuals: &SpellVisualCatalog, kit_id: u32, indent: &str) { let Some(kit) = visuals.kit(kit_id) else { return; diff --git a/crates/benilla-formats/src/bin/benilla-extract/glueextent.rs b/crates/benilla-formats/src/bin/benilla-extract/glueextent.rs index 15070f2a..92cd5147 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/glueextent.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/glueextent.rs @@ -1,14 +1,6 @@ -//! `glueextent` — how wide each shipped glue scene's art is, off the chain (decision 1619). -//! -//! The instrument behind the glue framing ceiling: for every `UI_*` diorama it prints the measured -//! [`benilla_formats::ArtExtent`] (the same call the client makes at scene spawn), the authored -//! 4:3 half-extents it is measured against, and the derived facts the framing law acts on — the -//! window aspect at which the art runs out of **width** under 1587's hor+ (past it the law zooms -//! in, holding the width) and the aspect at which it runs out of **height** for a narrow window. -//! -//! Beside the law's own reading (opaque batches, front faces) it prints two looser ones — opaque + -//! alpha-tested, and every batch — so a scene whose measured edge looks wrong against a capture can -//! be read for *which* batches sit at that edge without a second tool. +//! `glueextent`: every shipped glue scene's [`benilla_formats::ArtExtent`], measured off the chain +//! (the numbers `SHIPPED_GLUE_SCENES` transcribes), against its authored 4:3 half-extents, and the +//! window aspects at which its art runs out of width and of height. use anyhow::{Context, Result}; use benilla_formats::{ @@ -16,7 +8,7 @@ use benilla_formats::{ parse_m2_render_submeshes, Chain, Coverage, CoverageReader, ModelBlend, GLUE_AUTHORED_ASPECT, }; -/// The seven shipped scenes: the login gate + the six race stages (Gnome→Dwarf, Troll→Orc share). +/// The login gate and the six race stages (Gnome shares Dwarf's, Troll shares Orc's). const SCENES: [&str; 7] = [ "MainMenu", "Human", "Orc", "Dwarf", "NightElf", "Scourge", "Tauren", ]; @@ -39,10 +31,8 @@ pub fn glueextent(chain: &mut Chain, batches: bool) -> Result<()> { }; let t0 = authored_half_height(cam.fov); let h0 = t0 * GLUE_AUTHORED_ASPECT; - // The law's reading — every batch painting by its texels (the module's rule) — and the - // two readings on either side of it, so a scene whose measured edge looks wrong against - // a capture can be read for which rule moved it: opaque batches only, and every batch - // as if it painted fully. + // The texel rule the shipped table is measured by, bracketed by opaque batches only and + // by every batch painting fully. let mut reader = CoverageReader::new(chain); let mut paints: Vec> = Vec::with_capacity(subs.len()); for s in &subs { @@ -76,8 +66,6 @@ pub fn glueextent(chain: &mut Chain, batches: bool) -> Result<()> { opaque_only, with_all, ); - // Which batches sit at the measured edge: every opaque batch, with its blend, so a - // suspicious number can be traced to a card without a second command. let mut kinds = [0usize; 5]; for s in &subs { kinds[match s.blend { @@ -101,8 +89,7 @@ pub fn glueextent(chain: &mut Chain, batches: bool) -> Result<()> { cam.near_clip, ); if batches { - // Every batch's footprint in the frame, in units of t0 (so `±1.333` is the authored - // 4:3 box's side, `±1.0` its top/bottom): which card sets the edge. + // Each batch's footprint in units of t0: ±1.333 is the 4:3 box's side, ±1.0 its top. println!( " {:>3} {:<9} {:<3} {:<6} {:>5} {:>5} {:>4} {:>14} {:>14} tex", "idx", "blend", "2s", "paints", "front", "back", "clip", "x'/t0", "y'/t0" diff --git a/crates/benilla-formats/src/bin/benilla-extract/kitanim.rs b/crates/benilla-formats/src/bin/benilla-extract/kitanim.rs index a75c714e..a7852231 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/kitanim.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/kitanim.rs @@ -1,34 +1,15 @@ -//! `kitanim`: census the `SpellVisualKit` **animation** column (field 2) — the half of a kit that -//! plays a clip on the unit's *own body*, as opposed to the attach-point effect models -//! (`spellvis`), the CharProcs (`charprocs`) or the camera shake (`shakecensus`). -//! -//! The scope instrument for "the spell landed on me and my character did nothing". A kit's anim is -//! the only part of a visual that can be *invisible by omission*: an effect model that fails to -//! spawn leaves a trace line, while an anim that is never asked for, or is asked for and instantly -//! overwritten, leaves nothing at all. So the population has to be read off the shipped table: -//! -//! - **by stage**, because the stage decides the lifetime. A `cast`/`impact` anim is a one-shot on -//! the caster/victim; a **`state`** anim belongs to an aura's whole life, which is a different -//! consumer entirely (`creature_anim::spell_visual::arm_aura_state_fx`'s slot watcher, not -//! `play_impact`). -//! - **anim-only kits are called out**, because they are the class an "does this kit do anything?" -//! test drops. That test is the B114 shape: the aura-state watcher used to arm only on effect -//! models, so Stealth — whose whole visual is one CharProc — showed nothing. A state kit whose -//! whole visual is an *anim* fails the same way one level over. -//! - **wound-branch ids are marked**, because 8/9/10 (`StandWound`/`CombatWound`/`CombatCritical`) -//! do not play as themselves: the client's kit player hands them to the severity-0 flinch -//! (`0x60f3b8: push 0; call 0x60ea70`), so they are a different mechanism wearing the same -//! column. +//! `kitanim`: census the `SpellVisualKit` animation column (field 2), the clip a kit plays on the +//! unit's own body, by stage: a `cast` or `impact` anim is a one-shot on the caster or victim, a +//! `state` anim lasts the aura's life. Anim-only kits are marked, as a consumer keyed on effect +//! models never sees them. use std::collections::{BTreeMap, BTreeSet}; use anyhow::Result; use benilla_formats::{AnimDataCatalog, Chain, VisualStages}; -/// A `SpellVisual` stage's column selector — one of the five lifecycle-kit fields. type StagePick = fn(&VisualStages) -> u32; -/// The five `SpellVisual` lifecycle stages, in field order — the label plus its column selector. const STAGES: [(&str, StagePick); 5] = [ ("precast", |s| s.precast), ("cast", |s| s.cast), @@ -37,14 +18,13 @@ const STAGES: [(&str, StagePick); 5] = [ ("channel", |s| s.channel), ]; -/// The kit-player's wound branch: ids 8–10 are handed to the severity-0 flinch instead of being -/// played as themselves (`0x60f3b8`), so they are counted apart from the real body clips. +/// Ids 8-10 (`StandWound`, `CombatWound`, `CombatCritical`): the kit player hands them to the +/// severity-0 flinch instead of playing them (`0x60f3b8: push 0; call 0x60ea70`). fn is_wound_id(id: u16) -> bool { (8..=10).contains(&id) } -/// One state-stage kit's row: everything that decides whether a consumer keyed on effect models -/// alone would see it. +/// One state-stage kit: what decides whether a consumer keyed on effect models sees it. struct StateKit { anim: u16, slots: usize, @@ -55,19 +35,16 @@ struct StateKit { } impl StateKit { - /// Nothing but the anim — the class an effects/sound/proc test drops on the floor. fn anim_only(&self) -> bool { self.slots == 0 && !self.world && !self.sound && self.procs == 0 } } -/// Census the anim column: the raw table, then reachability per stage, then the two stages whose -/// consumers differ — `state` (aura lifetime) in full, `impact` (the victim's one-shot) ranked. +/// Census the anim column, its reach by stage, every `state` kit and the top `impact` kits. pub fn run(chain: &mut Chain) -> Result<()> { let spells = benilla_formats::load_spell_catalog(chain)?; let visuals = benilla_formats::load_spell_visual_catalog(chain)?; - // `AnimationData.dbc` is only the naming column here — a missing table degrades to bare ids - // rather than failing the census (the same optional shape every DBC consumer takes). + // `AnimationData.dbc` only names the ids, so a missing table prints `?` rather than failing. let anims = benilla_formats::load_anim_data_catalog(chain).ok(); let name = |id: u16| -> String { anims @@ -77,7 +54,7 @@ pub fn run(chain: &mut Chain) -> Result<()> { .to_string() }; - // 1. The raw table: which anim ids the shipped kits ask for at all. + // 1. Which anim ids the shipped kits ask for. let mut by_anim: BTreeMap> = BTreeMap::new(); for kit_id in visuals.kit_ids() { if let Some(anim) = visuals.kit(kit_id).and_then(|k| k.anim_id) { @@ -104,8 +81,7 @@ pub fn run(chain: &mut Chain) -> Result<()> { ); } - // 2. Reachability: a kit no spell's visual chain names is authored-but-dead, and the STAGE is - // what picks the consumer, so both are counted here. + // 2. The reach by stage; a kit no spell's visual names never plays. let mut stage_kits: BTreeMap<&str, BTreeSet> = BTreeMap::new(); let mut stage_spells: BTreeMap<&str, BTreeSet> = BTreeMap::new(); let mut state: BTreeMap = BTreeMap::new(); @@ -157,8 +133,7 @@ pub fn run(chain: &mut Chain) -> Result<()> { ); } - // 3. The STATE stage in full — the aura-lifetime set, which is the whole reason this census - // exists. `ANIM-ONLY` marks the kits an effects/sound/proc arm test never sees. + // 3. Every state kit; `ANIM-ONLY` marks those with no effect slot, world effect, sound or proc. let state_spells: usize = state .values() .flat_map(|k| k.spells.iter()) @@ -194,8 +169,7 @@ pub fn run(chain: &mut Chain) -> Result<()> { } } - // 4. The IMPACT stage, ranked — the VICTIM's body one-shot, the other consumer. Kept to a - // ranked list rather than spelled out: the population is the whole damage table. + // 4. The impact kits, the victim's one-shot, ranked by spell count. let impact_spells: usize = impact .values() .flat_map(|(_, s)| s.iter()) diff --git a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs index 00161d54..1ba48336 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs @@ -1,7 +1,4 @@ -//! Per-model M2 dump printers: `m2coll`, `m2seq`, `m2attach`, `m2anim`, `m2bones`, `m2batch` — -//! the single-model diagnostics that read one `.m2` and print everything a given concern -//! (collision hull, sequences, attachment points, animation channels, bone table, render -//! batches) actually carries. +//! The single-model M2 dumps: each reads one `.m2` and prints all it carries for one concern. use anyhow::{Context, Result}; use benilla_formats::{Chain, M2AnimSummary}; @@ -40,10 +37,8 @@ pub fn m2coll(chain: &mut Chain, internal_path: &str) -> Result<()> { Ok(()) } -/// Dump every sequence's EVENT keyframes: time (s from sequence start), 4CC ident, payload. -/// The event-order instrument (decision 0279): on attack clips, whether `$CPP` (the victim -/// defense dispatch) precedes `$AH0-3`/`$CAH` (the impact dispatch) decides which of the two -/// mutually-exclusive victim reactions the shared swing record feeds. +/// Dump every sequence's event keyframes. On an attack clip, whether `$CPP` (the victim's defense) +/// precedes `$AH0-3`/`$CAH` (the impact) picks which exclusive reaction the swing record feeds. pub fn m2events(chain: &mut Chain, internal_path: &str) -> Result<()> { let name = normalize(internal_path); let data = chain @@ -83,19 +78,13 @@ pub fn m2seq(chain: &mut Chain, internal_path: &str) -> Result<()> { .read_file(&name) .with_context(|| format!("reading '{name}' from chain"))?; let seqs = benilla_formats::parse_m2_animations(&data); - // `band` is the sequence's absolute window on the model's global keyframe timeline: every - // non-global-sequence track selects its keys from it, so it is what says whether a track is - // actually keyed HERE or is holding a clamped value from some other sequence's band. - // NB `idx` is this list's index, not the file's: zero-duration sequences are dropped. + // `band` is the window on the keyframe timeline every non-global-sequence track keys in. `idx` + // is not the file slot: zero-duration sequences are dropped. println!( "idx anim mode dur(s) mspd blend band(ms) freq replay bones keys" ); for (i, s) in seqs.iter().enumerate() { - // How much data the sequence's own time band actually holds: bones with any - // keyed track, and total keys across T/R/S (clamp constants included — a bone - // unkeyed in this band pins to its nearest authored key, see `read_bone_track`). - // Uneven coverage across same-id variations is what exposed the task-#14 tilt - // (HumanMale Stand idx 136 keys 13 fewer bones than the head). + // Bones with any keyed track in this band, and total T/R/S keys, clamp constants included. let bones = s .bones .iter() @@ -111,14 +100,11 @@ pub fn m2seq(chain: &mut Chain, internal_path: &str) -> Result<()> { s.anim_id, if s.looping { "loop " } else { "clamp" }, s.duration, - // `mspd` = the sequence's authored design movement speed (yd/s) — the DIVISOR of the - // locomotion playback rate (`speed / (mspd · |modelScale|)`, `0x5fe2f0` - // @0x5fe4be..0x5fe550). `0.00` ⇒ not a locomotion sequence, so it plays at rate 1×. + // `mspd`: the authored move speed (yd/s) dividing the locomotion rate, + // `speed / (mspd · |modelScale|)` (`0x5fe2f0` at 0x5fe4be..0x5fe550); 0 plays at 1×. s.move_speed, - // `blend` = the sequence's authored blend-IN time (s) — how long the client cross-fades - // from the outgoing pose into this one on a blended arm (op4 `blendFlag != 0`, the - // `+0x98 -> +0xc4` snapshot decayed over `1/blendTime`). `0.000` = an instant cut, and - // that is what a doll's turn looks like without it. + // `blend`: the blend-in time (s) the client cross-fades over on a blended arm (op4 + // `blendFlag != 0`, the `+0x98 -> +0xc4` snapshot decayed over `1/blendTime`); 0 cuts. s.blend_time, s.start_ms, s.end_ms, @@ -138,9 +124,7 @@ pub fn m2attach(chain: &mut Chain, internal_path: &str) -> Result<()> { .read_file(&name) .with_context(|| format!("reading '{name}' from chain"))?; let attachments = benilla_formats::parse_m2_attachments(&data)?; - // The position is raw WoW model space (X forward, Y left, Z up), as the M2 stores it — "where - // on the model does this rider actually sit?" needs it as much as the bone does: an item glow - // hangs on ids 0..4, spread along a weapon's length (decision 0805). + // Raw WoW model space (X forward, Y left, Z up); item glows hang on ids 0..4 along a weapon. println!("id bone position (WoW model space)"); for a in &attachments { let [x, y, z] = a.position; @@ -150,8 +134,6 @@ pub fn m2attach(chain: &mut Chain, internal_path: &str) -> Result<()> { Ok(()) } -/// One texture-transform track line for the `m2anim` dump: key count, interp/gseq tags, and the -/// first/last keys (enough to read a scroll direction + rate off a waterfall). fn print_txfm_track(name: &str, t: &benilla_m2::M2Track) { if t.keys.is_empty() { println!(" {name}: -"); @@ -172,7 +154,6 @@ fn print_txfm_track(name: &str, t: &benil ); } -/// The `m2anim` subcommand's dump — one section per channel family. fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { println!("sequences: {}", s.sequence_count); println!( @@ -204,10 +185,6 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { s.texture_transform_count ); println!("particle emitters: {}", s.particle_emitter_count); - // The full defs (pos/blend/texture/shape/rate-keys/ramps) alongside the summary's bone links — - // which emitter is the flame and which the glow is unreadable from bone+flags alone (the - // blood-spurt starburst diagnosis, decision 0141; a flame that "doesn't burn" is usually - // visible right here: an unresolved texture, or a burst rate track whose first key is 0). let defs = benilla_formats::parse_m2_particle_emitters(bytes).unwrap_or_default(); for (i, e) in s.emitter_bones.iter().enumerate() { println!( @@ -222,13 +199,9 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { } ); let Some(d) = defs.get(i) else { continue }; - // The emission MODEL first: a BURST emitter fires one ftol(rate) puff at its rate edge - // and never pours — reading its keys as a continuous rate is the exact misdiagnosis - // behind the Eviscerate 0.5s-vs-2s gap (`0x718ec8` → `0x7b5550`). + // A burst emitter fires one `ftol(rate)` puff at its rate edge and never pours + // (`0x718ec8` → `0x7b5550`). let burst = if d.burst() { "BURST " } else { "" }; - // PER-SEQUENCE timing (the runtime's actual sampling unit — the old print showed the two - // tracks rebased onto sequence 0's band, which read as authoritative and was exactly the - // B27 misparse). The quiet case stays quiet: one constant rate, no gate anywhere. let views = d.timing.slot_views(); let rate = match d.timing.constant_rate() { Some(r) => format!("{burst}rate {r:.1}/s"), @@ -247,10 +220,9 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { format!("{burst}rate/seq [{}]", per.join(" ")) } }; - // The enabled gate, per sequence slot (seconds from the slot's band start) — a one-shot - // effect's choreography, and a state GameObject's "which clips actually fire this". + // The enabled gate per sequence slot, in seconds from the slot's band start. let rate = if views.iter().all(|(_, _, e)| e.is_none()) { - rate // no gate authored anywhere (the overwhelmingly common shape) — no noise + rate // no gate authored anywhere, the common case } else { let per: Vec = views .iter() @@ -273,8 +245,7 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { .collect(); format!("{rate} enabled/seq [{}]", per.join(" ")) }; - // A tail's streak length is |velocity|·tail_time — without it "how long is this - // streak" needs a hand-parse of the raw record (the Eviscerate diagnosis gap). + // A tail's streak length is |velocity| · tail_time. let tail = if d.head_tail >= 1 { format!( " tail {:.2}s{}", @@ -288,10 +259,7 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { } else { String::new() }; - // The parameter channels sample per frame ([`benilla_formats::EmitParams`]); the compact - // line shows their opening values, and any channel that actually MOVES prints its full - // keyed ramp below — the view whose absence hid Frost Nova's 0.19 → 13.2 yd emission- - // radius ride behind a flat "radius [0.19..0.19]" (decision 0844). + // Each parameter channel's opening value; one that moves prints its keyed ramp below. let now = d.params.sample(None, 0.0, 0.0); println!( " {:?} {:?} {} {rate} life {:.2}s speed {:.2} grav {:.2} drag {:.1}{tail} twinkle [{:.2}..{:.2}] spd {:.1} pct {:.2} spin {:.2}", @@ -323,16 +291,15 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { } } } - // The kernel spread (`0x7b8d70`, `0x7b8890`): a sphere's ranges are latitude/longitude - // about +X (area = min/max shell radius); a plane's are the ±θ/±φ cone about +Z (area = - // the spawn rectangle). `(lat ±π, lon ±0)` reads directly as the edge-on ring family. + // The kernel spread (`0x7b8d70`, `0x7b8890`): a sphere's ranges are latitude and longitude + // about +X, its area the shell radii; a plane's a cone about +Z, its area the rectangle. let spread = match d.shape { benilla_formats::ParticleShape::Sphere => format!( "radius [{:.2}..{:.2}] lat ±{:.2} lon ±{:.2}", now.area_length, now.area_width, now.vertical_range, now.horizontal_range ), - // Spline repurposing (loader `0x70fa5e`–`0x70fae5`): area = tMin/tMax, - // vRange = tangent-spin ψ, hRange = scatter. + // A spline repurposes them (loader `0x70fa5e`-`0x70fae5`): area is tMin/tMax, vRange + // the tangent spin, hRange the scatter. benilla_formats::ParticleShape::Spline => match &d.spline { Some(s) => format!( "spline {} pts [{:.2} {:.2} {:.2} ..], t [{:.2}..{:.2}] spin ±{:.2} scatter {:.2}", @@ -357,16 +324,14 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { } else { String::new() }; - // The per-emitter model references: geometry (3-D model particles) and recursion - // (child emitters). if let Some(g) = &d.geometry_model { println!(" MODEL-PARTICLES: {g}"); } if let Some(r) = &d.recursion_model { println!(" CHILD-EMITTERS: {r}"); } - // The emitter-motion terms (`0x7b5230`): the follow-delta response - // line's authored (speed → fraction) samples, and the velocity-inherit scale. + // The emitter-motion terms (`0x7b5230`): the follow-delta response's (speed, fraction) + // samples and the velocity-inherit scale. let motion = match (d.follow_emitter(), d.inherits_emitter_motion()) { (false, false) => String::new(), (f, i) => { @@ -400,9 +365,7 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { ); } println!("ribbon emitters: {}", s.ribbon_emitter_count); - // A keyed look track prints its full `(ms, value)` ramp — the value[0]-only display once - // masked HolySmite's slash ribbons (height keyed 0 → 0.167 → 0 printed as `+0.00`, reading - // as "no ribbon" when the model authors a flare). + // A keyed ribbon track prints its whole `(ms, value)` ramp; its first value alone can read 0. let scalar = |t: &benilla_formats::ValueTrack| -> String { match t.keys.len() { 0 | 1 => format!("{:.2}", t.first()), @@ -466,8 +429,6 @@ pub fn m2anim(chain: &mut Chain, internal_path: &str) -> Result<()> { .with_context(|| format!("parsing M2 animation summary '{name}'"))?; print_m2anim_summary(&summary, &data); - // Texture-transform detail (0130 phase 3 grounding): the parsed TRS tracks plus the - // batch → lookup → transform wiring, straight from the full parser. let fmt = benilla_m2::parse_m2(&mut std::io::Cursor::new(&data[..])) .with_context(|| format!("parsing M2 '{name}'"))?; let m = fmt.model(); @@ -491,18 +452,13 @@ pub fn m2anim(chain: &mut Chain, internal_path: &str) -> Result<()> { } } } - // Color-alpha + texture-weight keys in full (they're tiny scalar tracks): the "how does this - // effect fade" instrument — the UI cooldown model's finish-flash ramp was pinned from exactly - // this dump (decision 0137 phase 4). if !m.color_alpha_tracks.is_empty() { println!("=== color alpha tracks (per M2Color) ==="); for (i, t) in m.color_alpha_tracks.iter().enumerate() { println!(" color {i}: interp {}, keys {:?}", t.interp, t.keys); } } - // The RGB half of the same M2Colors: the per-batch tint the client multiplies into the vertex - // colour. An effect that reads white where the reference reads coloured is usually visible - // right here (the Frost Nova purple-mist diagnosis). + // The M2Colors' RGB half: the per-batch tint the client multiplies into the vertex colour. if m.color_rgb_tracks.iter().any(|t| t.keys.len() > 1) { println!("=== color rgb tracks (per M2Color) ==="); for (i, t) in m.color_rgb_tracks.iter().enumerate() { @@ -524,9 +480,6 @@ pub fn m2anim(chain: &mut Chain, internal_path: &str) -> Result<()> { println!(" weight {i}: interp {}, keys {:?}", t.interp, t.keys); } } - // Bone SCALE keys per sequence — the "how does this element grow" instrument (the cooldown - // star's finish-flash pulse is a bone-scale curve, decision 0263's INTERIM). Scale-keyed - // bones only; effect/UI models keep this tiny. let seqs = benilla_formats::parse_m2_animations(&data); let any_scaled = seqs .iter() @@ -560,12 +513,8 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> { .with_context(|| format!("parsing M2 '{name}'"))?; let m = fmt.model(); let seqs = benilla_formats::parse_m2_animations(&data); - // `ign` is `flags & 0x7` spelled out — which of the parent's Translate/Scale/Rotate this bone - // REFUSES, taking the model root's instead (decision 0945). The raw flags hex could always be - // read for it and never was: three billboard rounds and a mount round each looked at - // `RidingHorse` bone 30's `0x00000006` and none read it as "the saddle discards the gallop", - // which decision 0932 then measured as a 21° rider swing and called faithful. A bone-table - // dump exists to answer "what does this bone actually inherit"; now it says so in words. + // `ign` spells out `flags & 0x7`: which of its parent's translation, scale and rotation the + // bone refuses, taking the model root's instead. println!( "idx keybone flags bb ign parent pivot \ keyed (seq[idx] T/R/S counts)" @@ -604,9 +553,7 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> { b.pivot.z, keyed.join(" "), ); - // Small tracks get their actual key values — two sequences can share a key COUNT while - // holding different values (the questgiver-marker seq 0 vs 190 lesson: counts alone - // mislabeled them "the same"). + // Small tracks print their key values: equal key counts can hide different values. for (si, s) in seqs.iter().enumerate() { let Some(bk) = s.bones.iter().find(|bk| bk.bone as usize == i) else { continue; @@ -619,17 +566,9 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> { .collect(); println!(" seq{si} (anim {}) T: {}", s.anim_id, keys.join(" ")); } - // Rotation keys as axis-angle (model-space WoW axes, Z up) — the "which way does - // this element actually turn" instrument: an emitter/billboard orientation bug - // needs the spin axis as ground truth, which a bare key COUNT never shows. Small - // tracks print every key; a long track (a swirl/rotor loop) prints first/last plus - // the axis of the first key-to-key increment — the spin axis itself. - // - // A long track also prints `swing` — the largest angle any key makes with the first, - // i.e. the track's actual AMPLITUDE. `step` is only the first increment, so a summary - // without this cannot answer "how far does this bone swing", which is the question a - // "does the rider inherit the mount's gallop pitch" investigation puts to it. Reading - // `step` as the amplitude under-reports a 20° swing as 1.5°. + // Rotation keys as axis-angle (WoW model axes, Z up). A long track prints its first and + // last keys, `step` (the first increment, whose axis is the spin axis) and `swing` (the + // largest angle any key makes with the first, the amplitude `step` is not). if !bk.rotation.is_empty() { let aa = |q: &[f32; 4]| { let w = q[3].clamp(-1.0, 1.0); @@ -653,7 +592,7 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> { let (t0, q0) = &bk.rotation[0]; let (_t1, q1) = &bk.rotation[1]; let (tn, qn) = bk.rotation.last().unwrap(); - // increment = q1 · q0⁻¹ — its axis is the track's spin axis. + // increment = q1 · q0⁻¹, whose axis is the spin axis. let inv0 = [-q0[0], -q0[1], -q0[2], q0[3]]; let inc = [ q1[3] * inv0[0] + q1[0] * inv0[3] + q1[1] * inv0[2] - q1[2] * inv0[1], @@ -661,9 +600,8 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> { q1[3] * inv0[2] + q1[0] * inv0[1] - q1[1] * inv0[0] + q1[2] * inv0[3], q1[3] * inv0[3] - q1[0] * inv0[0] - q1[1] * inv0[1] - q1[2] * inv0[2], ]; - // The amplitude: the largest angle any key makes with the first. `2·acos|⟨qi,q0⟩|` - // is the geodesic angle between two unit quats, sign-folded so a double-cover - // flip doesn't read as a 360° swing. + // `2·acos|⟨qi,q0⟩|` is the angle between two unit quats, sign-folded so a + // double-cover flip does not read as a 360° swing. let swing = bk .rotation .iter() @@ -682,12 +620,6 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> { ); } } - // Scale keys. A bone whose ONLY channel is scale — the pulsing card a spell - // effect hangs off a billboard, the freezing trap's ice shard — printed nothing - // here at all: the header row said `S2` and no detail line followed, so "how big - // does this thing actually get, and in which sequence" could not be answered from - // the dump. Non-uniform scale is exactly the interesting case (a square card - // stretched into a column), so all three components print. if !bk.scale.is_empty() && bk.scale.len() <= 8 { let keys: Vec = bk .scale @@ -702,14 +634,7 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> { Ok(()) } -/// Dump an M2's render batches as the renderer sees them, preceded by the model-level material -/// state the **static visibility cull** reads (`0x707680`: a batch is skipped -/// when `colorAlpha · transparencyWeight ≤ 0`). Batches this dump *lists* are ones that survived -/// that cull — when one of them turns out to be a stray primitive in game, these tables are where -/// the answer has to be, so they print together. -/// The facing readout for one batch (see the call site in [`m2batch`]): the first triangle's -/// winding normal, the mean authored vertex normal, and their dot. `None` for a batch with no -/// triangle to measure. +/// One batch's facing: its first triangle's winding normal, its mean vertex normal and their dot. fn winding(s: &benilla_formats::RenderSubmesh) -> Option { let tri = s.indices.get(..3)?; let p = |i: u32| s.positions.get(i as usize).copied(); @@ -724,8 +649,7 @@ fn winding(s: &benilla_formats::RenderSubmesh) -> Option { }; let norm = |v: [f32; 3]| { let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); - // A degenerate (zero-area) triangle has no direction to report — say so rather than - // printing NaNs that read like data. + // A zero-area triangle has no direction; say so rather than print NaNs that read as data. (l > 1e-9).then(|| [v[0] / l, v[1] / l, v[2] / l]) }; let facet = norm(cross(sub(b, a), sub(c, a))); @@ -741,10 +665,8 @@ fn winding(s: &benilla_formats::RenderSubmesh) -> Option { (Some(f), Some(n)) => format!("{:+.2}", f[0] * n[0] + f[1] * n[1] + f[2] * n[2]), _ => "-".to_string(), }; - // A billboard card authored back-to-front against the law's `+X`-at-the-viewer: the renderer - // turns its normals round so the card is lit off the face it presents (decision 0788). `vnorm` - // alone cannot answer this — it is the MEAN, so a batch whose normals cancel reads the same as - // one flat plane — hence the shape's own verdict here rather than an eyeball off the numbers. + // A billboard card wound away from its `+X` viewer: the renderer turns its normals round so it + // is lit off the face it presents. The mean `vnorm` cannot show this, so the shape decides. let lit_face = if s.billboard_card_faces_away() { " LIT-FACE-FLIP" } else { @@ -757,23 +679,13 @@ fn winding(s: &benilla_formats::RenderSubmesh) -> Option { )) } -/// Which BONES a batch's vertices actually ride, and — for a billboard batch — whether the batch is -/// a rigid card at all. -/// -/// The reference skins **per vertex**: every vertex is placed by its own (up to four) bone matrices -/// and weights, so a batch that straddles a billboard bone and a static one *deforms* — the static -/// end stays welded to the body while the billboard end swings to the camera. benilla instead splits -/// a batch into per-billboard-bone submeshes keyed on each **triangle's first vertex** and rotates -/// each group rigidly about that bone's pivot. The two agree only when a group's vertices are all -/// 100% on the one billboard bone; where they aren't, we tear geometry off the model that the -/// reference keeps attached. `MIXED` is exactly that condition, and `SPLIT-W` the softer form (a -/// vertex blended across bones, which a rigid group cannot express at all). +/// The bones a batch's vertices ride and, for a billboard batch, whether it is a rigid card. The +/// reference skins each vertex through its own bone weights, so only geometry wholly on the +/// billboard bone turns as one card: `MIXED` counts vertices off it, `SPLIT-W` blended vertices. fn skin_census(s: &benilla_formats::RenderSubmesh) -> Option { if s.joints.is_empty() { return None; } - // Vertices per PRIMARY bone (`bone_indices[0]` — the key our batch split groups on), in - // ascending bone order so two models' lines compare by eye. let mut per_bone: std::collections::BTreeMap = std::collections::BTreeMap::new(); for j in &s.joints { *per_bone.entry(j[0]).or_default() += 1; @@ -802,9 +714,7 @@ fn skin_census(s: &benilla_formats::RenderSubmesh) -> Option { if split > 0 { verdict.push_str(&format!(" SPLIT-W: {split} verts blended across bones")); } - // The distinct skin TUPLES with their vertex counts — a rigid group is one tuple at weight 1; - // anything else names the seam the split has to cut along. Capped so a 300-vert creature batch - // can't drown the dump (the question only ever has a handful of answers on a card-sized batch). + // Distinct skin tuples and their vertex counts, capped at 8; a rigid card is one at weight 1. let mut tuples: std::collections::BTreeMap = std::collections::BTreeMap::new(); for (j, w) in s.joints.iter().zip(&s.weights) { let key = (0..4) @@ -871,13 +781,10 @@ pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> { m.transparency_lookup, ); if let Ok(skin) = m.parse_embedded_skin(&data, 0) { - // One line per batch, because the batch → material/track mapping is the thing an alpha - // question turns on and it is NOT inferable from the render flags: `mat` indexes the - // materials list above, `color` the colour-alpha tracks (`ffff` = none), and `weight` - // indexes `transLookup` → the transparency track. The reference's combine is + // `mat` indexes the materials above, `color` the colour-alpha tracks (`ffff` for none) + // and `weight` `transLookup`. The reference skips a batch whose // `A = instanceAlpha × colors[color].alpha × transparency[transLookup[weight]].weight` - // (`0x707680`), so these three name every input to a batch's - // visibility. + // is 0 or less (`0x707680`). println!("skin batches ({}):", skin.batches().len()); for (i, b) in skin.batches().iter().enumerate() { let w = m @@ -919,9 +826,7 @@ pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> { if s.billboard.is_some() { flags.push("BILLBOARD"); } - // This batch's texcoords are GENERATED (`texture_unit_lookup > 2`), so the `uv` line below - // reports the authored UVs the runtime does *not* read — a degenerate span there is the - // asset saying "supply these", not a defect in the model. + // Texcoords generated at runtime (`texture_unit_lookup > 2`): the `uv` line is unused. if s.env_map { flags.push("ENV-MAP"); } @@ -931,16 +836,11 @@ pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> { if s.uv_anim.is_some() { flags.push("uv-anim"); } - // A character runtime slot (body atlas / hair / object / extra skin) has no embedded path — - // name the slot rather than a misleading bare NONE. let tex = match (&s.texture, s.char_slot) { (Some(t), _) => t.clone(), (None, Some(slot)) => format!(""), (None, None) => "NONE".into(), }; - // Model-space extent + centre: a batch that renders as a stray primitive is explained by - // where and how big it actually is (a degenerate zero-area batch is a different bug from a - // real card the reference hides some other way). let ext = |axis: usize| -> (f32, f32) { s.positions .iter() @@ -970,13 +870,8 @@ pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> { flags.join(" "), tex, ); - // The batch's UV extent, and the texel-per-yard density it implies. Two questions this - // answers that nothing else did: does the batch tile (range outside 0..1 — the sampler - // repeats, and a wrap discontinuity across a triangle blows up the derivative and drags - // the sampled mip to the coarsest level), and is the authored density so far above the - // screen's that even mip 0 is a minification? A cutout batch is where either shows up - // first, because a coarser mip does not merely soften it — it dissolves the silhouette - // the alpha key cuts (the Dun Morogh snow-fir report, B52). + // The UV extent and its density per yard. A range past 0..1 tiles, and a wrap across a + // triangle drags its mip to the coarsest level, which dissolves an alpha-keyed cutout. if !s.uvs.is_empty() && !s.positions.is_empty() { let uext = |axis: usize| { s.uvs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), t| { @@ -985,7 +880,6 @@ pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> { }; let (u, v) = (uext(0), uext(1)); let (du, dv) = (u.1 - u.0, v.1 - v.0); - // Model-space diagonal of the batch, as the yard scale the UV span stretches over. let diag = { let (x, y, z) = (ext(0), ext(1), ext(2)); ((x.1 - x.0).powi(2) + (y.1 - y.0).powi(2) + (z.1 - z.0).powi(2)).sqrt() @@ -1005,17 +899,11 @@ pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> { if diag > 1e-6 { du.max(dv) / diag } else { 0.0 }, ); } - // Which way the geometry FACES, in model space — the question a single-sided batch that - // renders when it shouldn't (or doesn't when it should) turns on. `facet` is the winding - // normal of the first triangle (`(p1−p0)×(p2−p0)`, WoW model axes); `vnorm` is the mean - // authored vertex normal. `dot` compares them: a batch whose winding disagrees with its own - // authored normals is wound back-to-front, and single-sided (`two_sided` absent above) it is - // culled from the side the author lit. + // A batch whose winding (`facet`) disagrees with its authored normals (`vnorm`) is wound + // back to front: single-sided, it is culled from the side the author lit. if let Some(w) = winding(s) { println!(" {w}"); } - // Which bones the batch's vertices ride — and whether a billboard group is a rigid card or - // a strip we tore off a static neighbour (see [`skin_census`]). if let Some(k) = skin_census(s) { println!(" {k}"); } @@ -1023,11 +911,9 @@ pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> { Ok(()) } -/// One track's value across sequence `seq_idx`'s band, under the reference's own key-search law -/// (`0x713d50`): the search window is `ranges[seq_idx]`, and a window that -/// collapses (`lo >= hi`) resolves to the single key `keys[lo]`. Returns `(lo, hi, held)` — the -/// value range the batch takes across the band, and whether the band keys nothing (so the value is -/// the bracket hold rather than authored motion). +/// A track's `(lo, hi, held)` over sequence `seq_idx`'s band, by the reference's key search +/// (`0x713d50`): the window is `ranges[seq_idx]`, and a collapsed window (`lo >= hi`) holds +/// `keys[lo]`, so `held` means the band keys nothing. fn band_span( track: &benilla_m2::M2ScalarTrack, seq_idx: usize, @@ -1055,15 +941,9 @@ fn band_span( (held, held, true) } -/// Dump an M2's **per-sequence material alpha**: every colour-alpha / transparency track's keys, -/// then the combined per-batch factor (`colour.alpha × transparency.weight`, the reference's -/// combine in `0x707680`) for **every sequence band**, not just the first. -/// -/// This is the "which batches does the reference hide, and when" instrument. A batch whose factor -/// is `0` in a band is one the real client **skips entirely** in that animation (`A ≤ 0` culls -/// before the blend mode is read), so a row of zeros under Stand and ones under Death is a batch -/// authored to appear only on death — exactly the voidwalker/banshee shape. `m2batch` gives the -/// batch → track wiring this reads; `m2seq` gives the bands. +/// Dump an M2's per-sequence material alpha: every colour-alpha and transparency track, then each +/// batch's `colour.alpha × transparency.weight` (the reference's combine, `0x707680`) per sequence +/// band. The reference skips a batch whose factor is 0 or less in that animation. pub fn m2alpha(chain: &mut Chain, internal_path: &str) -> Result<()> { let name = normalize(internal_path); let data = chain @@ -1076,10 +956,9 @@ pub fn m2alpha(chain: &mut Chain, internal_path: &str) -> Result<()> { println!("no embedded skin — no batches to combine"); return Ok(()); }; - // Sequences in **file order**, straight off the header array (count@0x1c/ofs@0x20, stride - // 0x44: anim id u16 @+0x00, band start/end u32 @+0x04/+0x08). Deliberately NOT - // `parse_m2_animations`, which drops zero-duration sequences and so renumbers the list — and - // the per-sequence `ranges` array below is indexed by the FILE slot. + // Sequences in file order off the header array (count at 0x1c, offset at 0x20, stride 0x44: + // u16 anim id at +0, u32 band start and end at +4 and +8), not `parse_m2_animations`, which + // drops zero-duration sequences: `ranges` is indexed by file slot. let seqs: Vec<(u16, (u32, u32))> = { let n = u32::from_le_bytes(data[0x1c..0x20].try_into().unwrap_or_default()) as usize; let o = u32::from_le_bytes(data[0x20..0x24].try_into().unwrap_or_default()) as usize; @@ -1119,8 +998,7 @@ pub fn m2alpha(chain: &mut Chain, internal_path: &str) -> Result<()> { .map(|&(ms, v)| format!("{ms}={v:.3}")) .collect::>() .join(" "), - // The per-sequence key windows the reference indexes by the playing sequence's file - // slot — printed so the `*` hold cells below can be checked against the file itself. + // The key windows the reference indexes by the playing sequence's file slot. if t.ranges.is_empty() { "none (whole-track search)".to_string() } else { @@ -1142,10 +1020,9 @@ pub fn m2alpha(chain: &mut Chain, internal_path: &str) -> Result<()> { println!(" #{i:<3} {}", keys(t)); } - // Each skin batch's two factor tracks, resolved exactly as the draw loop resolves them: - // colorIndex indexes `colors[]` DIRECTLY (out of range — incl. the 0xffff sentinel — means the - // factor doesn't apply); textureWeightComboIndex goes through `transLookup` (and applies only - // when the batch's textureCount is non-zero). + // As the draw loop resolves them: `colorIndex` indexes `colors[]` directly, and out of range + // (0xffff included) means no factor; the weight goes through `transLookup`, and only when + // `textureCount` is non-zero. let batches = skin.batches(); let color_of = |b: &benilla_m2::SkinBatch| m.color_alpha_tracks.get(b.color_index as usize); let weight_of = |b: &benilla_m2::SkinBatch| { @@ -1166,8 +1043,7 @@ pub fn m2alpha(chain: &mut Chain, internal_path: &str) -> Result<()> { for (i, &(anim_id, band)) in seqs.iter().enumerate() { print!("{i:>4} {anim_id:>5} {:>7}..{:<7}", band.0, band.1); for b in batches { - // A gseq-clocked track ignores the sequence band entirely (it runs on the global - // sequence's own clock), so report its full range rather than a band slice. + // A gseq track keeps its global sequence's clock, not the band: print its full range. let span = |t: Option<&benilla_m2::M2ScalarTrack>| match t { None => (1.0, 1.0, false), Some(t) if t.keys.is_empty() => (1.0, 1.0, false), @@ -1187,8 +1063,7 @@ pub fn m2alpha(chain: &mut Chain, internal_path: &str) -> Result<()> { } else { format!("{lo:.2}..{hi:.2}") }; - // `HIDE` marks a batch the reference never draws in this sequence: the combine can - // only reach 0 there, and `A ≤ 0` skips the batch outright. + // `HIDE`: the combine stays at or below 0 here, and `A ≤ 0` skips the batch. let cell = if hi <= 0.0 { format!("{cell} HIDE") } else { @@ -1203,10 +1078,8 @@ pub fn m2alpha(chain: &mut Chain, internal_path: &str) -> Result<()> { reference's collapsed key window holds (`0x713d50`)" ); - // The same question asked of OUR bake, per RENDER batch — which is not the same index space: - // a batch spanning several billboard bones splits into one submesh per bone, so `m2batch`'s - // render list runs longer than the skin list. This half is what the renderer will actually do, - // so a disagreement with the table above is a bug in the bake, not in the art. + // Our bake per render batch (a billboard batch may split per bone, lengthening the list); + // disagreeing with the table above is a bake bug, not the art's. let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or(""); let subs = benilla_formats::parse_m2_render_submeshes(&data, dir, &[]) .with_context(|| format!("parsing M2 render submeshes '{name}'"))?; @@ -1243,32 +1116,21 @@ pub fn m2alpha(chain: &mut Chain, internal_path: &str) -> Result<()> { Ok(()) } -/// Decode a particle emitter's file-flag word into the mechanism names the runtime keys off -/// (each is a [`benilla_formats::ParticleEmitterDef`] predicate), plus any bit the loader maps -/// to nothing — an unmapped bit on a model that looks wrong is a lead, not noise. +/// An emitter's file flags as the mechanisms the runtime keys off, plus any unmapped bits. fn part_flags(flags: u32) -> String { const NAMED: [(u32, &str); 14] = [ - // The reference has NO "lit" bit — 0x1 is the UNLIT flag, the inverse of the wiki lore - // (`0x70bb00`). Naming it the way the binary reads it is the - // point: an emitter WITHOUT this bit is the one that takes the scene's light, and that - // silent majority-of-one is what a dump has to make visible. + // 0x1 is unlit, not lit (`0x70bb00`): an emitter without it takes the scene's light. (0x0001, "unlit"), - // Runtime `rt+0x1ac` bit 0x10 (loader `0x70fd13` → `0x7b5d00`): the emitter's live - // particles are heap-sorted back-to-front on view depth before the quad writer runs - // (`0x7b3a10`), instead of drawn in pool order. NOT YET IMPLEMENTED — we draw pool - // order for every emitter; harmless where a cloud is one flat colour (alpha coverage is - // order-independent), visible where it is not. + // Runtime `rt+0x1ac` bit 0x10 (loader `0x70fd13` → `0x7b5d00`): the reference heap-sorts + // live particles back to front before the quad writer (`0x7b3a10`); we draw pool order. (0x0002, "depthSortParticles(TODO)"), - // NOT unmapped, and printing it as a lead sent one session hunting it as the ride switch - // (1578): file `0x8` feeds the SECOND runtime flag word, `rt+0x194` bit 1 = NOT(file 0x8) - // (loader `0x70fd01`), the vertex-format/blend word, orthogonal to simulation space. - // Bit 1's own reader is not identified; bit 0's (from file `0x1`) picks a 4- vs 8-word - // vertex stride. Half the corpus authors it. + // File 0x8 sets `rt+0x194` bit 1 = NOT(0x8) (loader `0x70fd01`), the vertex-format word, + // not the ride switch; bit 1's reader is untraced, and bit 0 (file 0x1) picks a 4- or + // 8-word vertex stride. Half the corpus authors 0x8. (0x0008, "vertexFormat(rt+0x194 b1, reader unread)"), - // **The ride-vs-trail switch** (spawn `0x7b8a9a`, draw `0x7b3e6f`; decision 1578): SET - // stores emitter-LOCAL and re-applies the live emitter matrix at draw (a rigid ride); - // CLEAR bakes the birth into WORLD and never re-applies it, so a moving host lays a trail - // `speed × lifetime` long. 30.4% of the corpus, 71% of `Item\ObjectComponents`. + // The ride-or-trail switch (spawn `0x7b8a9a`, draw `0x7b3e6f`): set keeps particles + // emitter-local, riding the live emitter; clear bakes birth into world space, so a moving + // host lays a trail `speed × lifetime` long. (0x0010, "modelSpace(ride)"), (0x0020, "sizeByInstanceScale"), (0x0040, "inheritEmitterMotion"), @@ -1305,7 +1167,7 @@ fn keys_str(keys: &[(f32, f32)]) -> String { .join(" ") } -/// Dump one M2's particle emitters in full — see the `M2part` command doc. +/// Dump one M2's particle emitters in full. pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { let name = normalize(internal_path); let data = chain @@ -1344,9 +1206,7 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { 1 => "tail", _ => "head+tail", }, - // The scene-light verdict the renderer will take (`ParticleEmitterDef::lit`) — the - // difference between "this sheet is shaded by the world" and "this sheet is a - // full-white cutout", which no other line here shows. + // Whether the renderer lights this emitter from the scene (`ParticleEmitterDef::lit`). if e.lit { "LIT" } else { "unlit" } ); println!( @@ -1364,13 +1224,8 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { println!(" recursion model (child emitters): {r}"); } if let Some(s) = &e.spline { - // EVERY control point, and at full precision. This used to print `first .. last`, - // which is actively misleading on a cubic Bézier: the path is `3K+1` points and the - // two INTERIOR handles of each segment are what bend it, so an emitter whose ends - // both sit at the origin can still fling its particles a long way — or not move them - // at all. Reading "(0,0,0) .. (0,0,0)" as "the particles never travel" is a guess - // the truncated line invites and cannot support (B228 nearly rested on it). - // Wrapped at 4 per line so a long path stays readable. + // Every control point at full precision: the path is `3K+1` points of cubic Bézier, + // bent by each segment's interior handles, so its ends alone say nothing of its reach. let pt = |p: &[f32; 3]| format!("({:.4},{:.4},{:.4})", p[0], p[1], p[2]); println!( " spline: {} control points ({} cubic segment(s)), model-local:", @@ -1383,7 +1238,6 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { } } - // Emission timing: the rate/gate pair, per file sequence slot. match e.timing.constant_rate() { Some(r) => println!(" rate {r:.2}/s (same in every sequence)"), None => { @@ -1420,9 +1274,7 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { } ); - // The nine emission parameter tracks: the flat ones on one line, the moving ones spelled - // out per slot (decision 0844 — a flattened track is exactly how a spread-out effect - // collapses to a point). + // The nine emission parameter tracks: flat ones on one line, moving ones per slot. let views = e.params.channel_views(); let mut flat: Vec = Vec::new(); let mut animated: Vec = Vec::new(); @@ -1481,10 +1333,7 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { c[0], c[1], c[2], c[3] ); } - // 4 decimals, not 3: UI models author their sizes in thousandths, where `{:.3}` turns the - // autocast shine's 0.0015 mid-key into a printed "0.002" — a 33% error that a transcriber - // reads as authored truth (B228). The colour ramp above stays at 3 because 0..1 channels - // do not have that problem. + // Four decimals: UI models author sizes in thousandths, as the autocast shine's 0.0015. println!( " size {:.4} -> {:.4} -> {:.4} yd (half-extent)", ol.scale[0], ol.scale[1], ol.scale[2] @@ -1503,12 +1352,9 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { ol.repeat[1] ); - // The derived read: what this record actually puts on screen. let rate = e.timing.peak_rate(); - // A burst emitter's count is NOT its peak rate: the burst fires on the rising edge of - // `enabled && rate > 0`, and an emitter whose gate closes on the same keyframe its rate - // opens fires nothing at all. Reading `peak_rate` here reported 50 phantom particles for - // `Strike_Impact_Chest`'s flare and sent a whole diagnosis 2.7x over on count. + // A burst's count is not its peak rate: it fires on the rising edge of + // `enabled && rate > 0`, and one whose gate closes as its rate opens fires nothing. let burst = e.timing.first_burst(Some(0)); let life = e.params.peak_lifespan(); let speed = views[0] @@ -1517,8 +1363,7 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { .flatten() .flat_map(|k| k.iter().map(|&(_, v)| v.abs())) .fold(0.0f32, f32::max); - // Drag caps total travel near `speed/drag` (the integrator's exponential decay); with no - // drag a particle simply coasts for its lifetime. + // Drag caps travel near `speed/drag`; without drag a particle coasts for its whole life. let reach = if e.drag > 1e-6 { (speed / e.drag).min(speed * life) } else { @@ -1548,17 +1393,9 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { Ok(()) } -/// Dump an M2's **camera table**, in raw file-index order — the index space a `` widget's -/// `Model:SetCamera(n)` walks (decision 2027; the selection is raw, `cameraLookup` is not -/// consulted there, so the table position IS the answer and the record's `type` is not). -/// -/// Prints, per record: its `type` word, the eye and look-at target at rest (`base + key 0`, raw -/// WoW model space), the diagonal fov in radians and degrees, near/far, the roll, and the key -/// count of each of the three tracks — the last column being the one that says whether the camera -/// is a still rig (every character/creature/interface camera in the chain) or an authored path -/// (the `Cameras\*.m2` fly-bys). The `cameraLookup` table is printed beside it, because the -/// portrait bake selects through it and the pane does not, and confusing the two is a one-line -/// mistake with a wrong picture at the end of it. +/// Dump an M2's camera table in raw file-index order, the index `Model:SetCamera(n)` selects by +/// without `cameraLookup`, which prints beside it because the portrait bake selects through it. +/// Key counts tell a still rig from an authored path (the `Cameras\*.m2` fly-bys). pub fn m2cam(chain: &mut Chain, internal_path: &str) -> Result<()> { let name = normalize(internal_path); let data = chain diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index b8775663..cf1fd72a 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -1,13 +1,6 @@ -//! `benilla-extract` — Phase 1 CLI over WoW 1.12.1 (build 5875) MPQ archives. -//! -//! Implemented: `list`, `extract` (raw bytes), operating on the **patch chain** -//! (later archives override earlier; real names come from patch.MPQ's `(listfile)`). -//! BLP->PNG and DBC->CSV come next. -//! -//! This file is the stable face: the clap [`Cli`]/[`Command`] definitions, the dispatch `match`, -//! and the tiny helpers shared across concerns. The heavier subcommands live in sibling modules by -//! concern: per-model M2 dump printers ([`m2dump`]), corpus-scan reports ([`scan`]), and the spell -//! visual chain ([`spellvis`]). +//! `benilla-extract`: a CLI over the 1.12.1 (build 5875) MPQ patch chain, where later archives +//! override earlier ones and names come from `patch.MPQ`'s `(listfile)`. This file holds the clap +//! commands, their dispatch and the shared helpers; the subcommands live in modules by concern. use std::path::PathBuf; @@ -58,28 +51,20 @@ enum Command { internal_path: String, /// Output `.png` file. output: PathBuf, - /// Also write every authored mip level (`.mip.png`) and print a per-level - /// texel census: how many texels the author left transparent vs not, the luma range of - /// each class, and how many sit below 128 — the set that DARKENS under a Mod2x lane, - /// which reads no alpha. The "what does the far sampler see" instrument (B358). + /// Also write every mip (`.mip.png`) and census each: transparent and opaque + /// texels, their luma, and how many sit below 128, which darken under alpha-blind Mod2x. #[arg(long)] mips: bool, }, - /// Composite ONE character's body atlas off the chain and report what painted what: the - /// equipment blits in blit order (with the file each region name resolved to, or `MISSING`), - /// the per-tile rows repainted vs the same character naked, and the geoset set the same - /// equipment selects. The "this garment stops early / that slot repainted this one" instrument - /// — a dressed body is ten fixed atlas tiles, and every defect in that class is one tile - /// receiving the wrong contribution. + /// Composite one character's body atlas and report what painted what. Charatlas { - /// `ChrRaces` id (1 human · 2 orc · 3 dwarf · 4 night elf · 5 undead · 6 tauren · 7 gnome · - /// 8 troll). + /// `ChrRaces`: 1 human, 2 orc, 3 dwarf, 4 night elf, 5 undead, 6 tauren, 7 gnome, 8 troll. #[arg(long)] race: u8, /// 0 male · 1 female. #[arg(long)] sex: u8, - /// `skinColor` — the CharSections variation the base skin + head sections key on. + /// `skinColor`, the CharSections variation the base skin and head sections key on. #[arg(long, default_value_t = 0)] skin: u8, /// `faceType`. @@ -94,29 +79,21 @@ enum Command { /// `hairColor`. #[arg(long, default_value_t = 0)] hair_color: u8, - /// The eight worn `ItemDisplayInfo` ids, comma-separated, in bodyslot order: - /// shirt,chest,belt,pants,boots,wrist,gloves,tabard. `0` leaves a slot empty; a short list - /// leaves the rest empty. + /// The eight worn `ItemDisplayInfo` ids, comma-separated in bodyslot order (shirt, chest, + /// belt, pants, boots, wrist, gloves, tabard); a 0 or a short list leaves slots empty. #[arg(long, value_delimiter = ',', default_value = "0")] slots: Vec, - /// The wearer's guild tabard, comma-separated in - /// `emblemStyle,emblemColor,borderStyle,borderColor,backgroundColor` order — the five - /// indices `SMSG_GUILD_QUERY_RESPONSE` carries (decision 1704). Omit for "no guild". Only - /// paints when the tabard slot holds a guild-emblem display (20621 is the shipped one). + /// The guild tabard, `emblemStyle,emblemColor,borderStyle,borderColor,backgroundColor` as + /// `SMSG_GUILD_QUERY_RESPONSE` carries them; only a guild-emblem tabard (20621) shows it. #[arg(long, value_delimiter = ',')] emblem: Option>, /// Write the composited atlas here as a PNG (256²). #[arg(long)] out: Option, }, - /// Measure every shipped glue scene's **art extent** (decision 1619): how far the opaque art - /// of each `UI_*` diorama covers around its authored camera 0, and the window aspects past - /// which the glue framing law stops widening and zooms instead. The instrument behind B330 - /// (the login backdrop's edges showing at 16:9) — the same measurement the client makes at - /// scene spawn, printed for all seven scenes with the looser readings beside it. + /// Measure each shipped glue scene's art extent and the window aspects where it runs out. Glueextent { - /// Also print every batch's footprint in the frame (front/back/clipped triangle counts, - /// projected extents) — which card sets the measured edge. + /// Also print every batch's footprint: front, back and clipped triangles and its extents. #[arg(long)] batches: bool, }, @@ -127,583 +104,297 @@ enum Command { /// Output `.csv` file. output: PathBuf, }, - /// Dump a spell's visual chain: spell → SpellVisual stages → each kit's anim/sound (decision - /// 0099's phase-2 instrument; columns per decision 0107). + /// Dump a spell's visual chain: its `SpellVisual` stages and each kit's contents. Spellvis { /// The `Spell.dbc` id (e.g. 133 = Fireball). spell_id: u32, }, - /// Census the **beam/chain** system (decision 0955): the 18-row `SpellChainEffects` table, - /// then every `SpellVisualKit` that draws a beam — the row it names, its beam count and - /// flag, and the spells that reach it through which lifecycle stage. The scope instrument - /// for B161 ("Chain Lightning has no chain effect"), and the check that the `CharParamZero` - /// small-int decode is the real mechanism: every live slot must land on a real row. + /// Census the beam system: `SpellChainEffects` and every `SpellVisualKit` that draws a beam. Chaincensus, - /// Census the **camera-shake** system (decision 1540): the 24 shipped `CameraShakes.dbc` - /// presets and every `CreatureModelData` row that names one, through the footstep column or - /// the death-thud column. The scope instrument for B298 ("walking past an Ancient Protector - /// shakes no screen"), and the check that fields 11/12 really are `CameraShakes` keys — every - /// live value must land on a real row. + /// Census the camera-shake system: the 24 `CameraShakes.dbc` presets and all that names one. Shakecensus, - /// Census the **death thud** — the body-fall sound a corpse makes on landing (`$DTH` → - /// `0x6236e0`, the sibling of the camera shake above). Two halves: the `DeathThudLookups.dbc` - /// matrix in full (`SizeClass × TerrainTypeSoundID` → the named land/water `SoundEntries` - /// kits), and the population sweep of which creature M2s key a `$DTH` at all, with the size - /// class their displays resolve to. The scope instrument for "a big corpse hits the ground - /// silently": it separates authored silence (an empty water column) from a real gap. + /// Census the death thud a corpse makes on landing (`$DTH`, `0x6236e0`): the + /// `DeathThudLookups.dbc` grid and which creature models key a `$DTH`. Thudcensus, - /// Census the `SpellVisualKit` **CharProc** columns (a kit's effect on the BODY — its alpha, - /// its tint): which proc types the shipped table carries, which lifecycle stage reaches each - /// from a live spell, and every state-stage (aura-lifetime) proc in full. The scope instrument - /// for the aura-state CharProc system. + /// Census the `SpellVisualKit` CharProc columns, a kit's effect on the body, by stage. Charprocs, - /// Census the `SpellVisualKit` **animation** column (field 2) — the half of a kit that plays a - /// clip on the unit's own BODY, as opposed to its attach-point effect models, its CharProcs or - /// its camera shake. Which anim ids the shipped table asks for, which lifecycle stage reaches - /// each (the stage picks the consumer: a `cast`/`impact` anim is a one-shot on the - /// caster/victim, a `state` anim belongs to an aura's whole life), then the state set in full - /// and the impact set ranked. The scope instrument for "the spell landed on me and my - /// character did nothing" — an anim that is never asked for leaves no trace to grep, unlike an - /// effect model that fails to spawn. `ANIM-ONLY` marks the state kits whose whole visual is the - /// anim, the class a "does this kit do anything?" test drops (the B114 shape one level over). + /// Census the `SpellVisualKit` animation column (field 2), a clip on the unit's body, by stage. Kitanim, - /// Dump an M2's collision hull as the mover collides with it: vertex/triangle counts, the - /// model-space AABB (WoW axes, Z up), and its extents — the "what does walking into this - /// actually hit" instrument (the step-up climb-vs-slide asset question, decision 0195; a - /// placement's own scale still multiplies these numbers). + /// Dump an M2's collision hull: counts and the model-space AABB (WoW axes, Z up), unscaled. M2coll { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Dump an M2's animation sequences in file order: `AnimationData.dbc` id, loop/clamp flag, - /// duration, authored design speed (the rate divisor), variation frequency, replay range — - /// the variation/replay instrument (decisions 0114/0117; sequences sharing an id are its - /// variation chain). + /// Dump an M2's sequences in file order: anim id, loop or clamp, duration, design speed (the + /// rate divisor), variation frequency and replay range; sequences sharing an id are variations. M2seq { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Dump an M2's **camera table** by raw file index — the index space a `` widget's - /// `Model:SetCamera(n)` walks (decision 2027): type, rest eye/target, diagonal fov, near/far, - /// roll, and each track's key count (which says still rig vs authored path). The - /// `cameraLookup` table is printed beside it — the portrait bake selects through that, the - /// pane does not. + /// Dump an M2's camera table by raw file index, the index `Model:SetCamera(n)` selects by; + /// the portrait bake selects through `cameraLookup` instead, printed beside it. M2cam { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Dump an M2's animation EVENT keyframes per sequence (`$CSS`/`$CAH`/`$AH0-3`/`$CPP`/`$HIT`…, - /// time + payload) — the event-order instrument (decision 0279: whether `$CPP` precedes the - /// impact tag decides defense-anim vs flinch on the shared swing record). + /// Dump an M2's animation event keyframes per sequence; whether `$CPP` precedes the impact + /// tag decides defense animation or flinch. M2events { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Dump an M2's attachment points (id + bone) — which sheath/held/effect anchors a model - /// actually has (decisions 0072/0122; a placement whose id is absent hangs nothing). + /// Dump an M2's attachment points (id and bone); a placement on an absent id hangs nothing. M2attach { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Dump an M2's animation-channel summary: sequence-0 bone motion, global-sequence bone - /// channels, transparency/color track key counts, texture transforms, and — per particle - /// emitter — its full def: shape, blend mode, head/tail, emission-rate keys (burst emitters - /// key `0 → peak → 0`), lifespan/speed/scale, position/area, resolved texture, and the - /// over-life color/alpha/size ramps. The one-command diagnosis for "this effect looks wrong / - /// doesn't show" (decisions 0130/0141); paired with `doodadscan`. + /// Dump an M2's animation channels, texture transforms and every particle emitter in full. M2anim { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Dump an M2's bone table: KeyBoneID, flags (billboard bits tagged), the `flags & 0x7` - /// parent-ignore bits spelled out (`ign` — which of the parent's Translate/Scale/Rotate the - /// bone refuses), parent, pivot, and which sequences key each bone (T/R/S key counts) — the - /// bone-attach / billboard-geometry instrument (decisions 0028/0122: which bone a rider - /// actually rides and whether it faces the camera; 0945: what it actually inherits from its - /// parent, which is how a saddle travels with the gallop without rotating with it). + /// Dump an M2's bone table, with the parent channels each bone ignores (`flags & 0x7`). M2bones { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Dump an M2's render batches as the renderer sees them: geoset, blend/flags - /// (emissive/additive/two-sided/billboard/depth), texture, vertex count, and whether an - /// alpha/UV animation baked — the "why doesn't this part of the model draw (or draw wrong)" - /// instrument (the Orgrimmar-bonfire missing-flame diagnosis). + /// Dump an M2's render batches as the renderer sees them. M2batch { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Dump an M2's **per-sequence material alpha**: every colour-alpha/transparency track's raw - /// keys, then the combined per-batch factor for EVERY sequence band — the "which batches does - /// the reference hide, and in which animation" instrument. A `HIDE` cell is a batch the real - /// client skips outright in that sequence (`A <= 0` culls before the blend mode is read, - /// `0x707b3a`–`0x707b5c`). Pair with `m2batch` (the batch -> track wiring) and `m2seq` (the - /// bands). + /// Dump an M2's per-sequence material alpha: each colour-alpha and transparency track, then + /// every batch's combined factor per sequence band. `HIDE` marks a batch the reference skips + /// in that sequence: `A <= 0` culls before the blend mode is read (`0x707b3a`-`0x707b5c`). M2alpha { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Dump ONE M2's particle emitters in full — every authored field of every record, as the - /// runtime parses it: bone/position/shape/blend/head-tail, texture + atlas, the decoded file - /// flags, the rate/gate timing and the nine emission tracks (flagged `ANIM` with their keys - /// when they actually move), drag/spin/tail/twinkle, spline and geometry/recursion models, - /// and the over-life colour/size/flipbook ramps. Closes with a derived read per emitter — - /// steady-state live count, ballistic reach, size range — so "this flame is a bright blob, - /// not a spread-out fire" is answered from the asset before anything is blamed on the - /// renderer. The per-model counterpart to the corpus sweeps (`partcensus`, `partscan`). + /// Dump one M2's particle emitters in full, with each one's derived count, reach and size. M2part { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Sweep every `.m2` and report where **addressing an attachment by id** through the model's - /// AttachLookup (the reference's `0x710310`) disagrees with scanning its record table — the - /// duplicate-id census behind decision 0805 (item glows hang on ids 0..4 of the item model). + /// Sweep every `.m2` for where addressing an attachment by id through AttachLookup + /// (`0x710310`) disagrees with scanning its records; item glows hang on the item's ids 0..4. Attachscan { /// Optional path prefix to restrict the sweep (e.g. `Item\ObjectComponents\Weapon`). prefix: Option, }, - /// Sweep every `.m2` in the chain and list the models carrying RIBBON emitters (header - /// `0x134`) — the population instrument for the ribbon subsystem (weapon trails, streamers): - /// which content actually authors one, before we build for it. + /// Sweep every `.m2` and list the models carrying ribbon emitters (header `0x134`). Ribbonscan, - /// Sweep every `.m2` and census its particle emitters' over-life **flipbook** fields: cell - /// ramps that play BACKWARDS (`begin > end` — legal, shipped, and fatal to a reader that - /// clamps into the pair), tail streaks whose ramp differs from the head's, indices past the - /// atlas (the reference wraps the column and lets the row run off), per-segment repeat counts, - /// and the two degenerate shapes the reference itself falls back on. Decision 0685. + /// Sweep every `.m2` and census particle flipbook fields: backward ramps (`begin > end`, legal + /// and shipped), tail ramps unlike the head's, indices past the atlas (the reference wraps the + /// column, the row runs off), repeat counts, and the reference's two degenerate fallbacks. Cellscan, - /// Sweep every `.m2` (optionally under a path prefix) and list the models whose MATERIAL - /// table authors the MULTIPLY blend modes 5 (Mod) / 6 (Mod2x) — the ARMORREFLECT sheen - /// family (decision 0528): the population instrument for the multiply-blend mechanism. + /// Sweep every `.m2` for materials with the multiply blends 5 (Mod) and 6 (Mod2x), the + /// armor-reflect sheen family. Blendscan { /// Internal-path prefix filter (e.g. `item\objectcomponents\weapon`), case-insensitive; /// all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and classify its BILLBOARD usage: - /// which arms it authors (spherical / lock-X / lock-Y / lock-Z), and whether geometry rides - /// them DIRECTLY (verts skinned to the billboard bone — the per-batch card path) or - /// INHERITED (verts on a descendant — the joint-palette path, decision 0205). The - /// population instrument for the billboard mechanism: which arms real content exercises, so - /// a class of spell visuals is closed by mechanism instead of tested spell-by-spell. + /// Sweep every `.m2` and classify its billboards: which arms it authors (spherical, lock-X, + /// -Y or -Z) and whether geometry is skinned to the billboard bone or rides a descendant. Bbscan { /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and classify each BILLBOARD batch by - /// which way its geometry FACES. A billboard bone puts the model's +X toward the viewer, so a - /// batch's winding normal decides whether it is ever seen: +X faces the camera, −X faces away - /// and — single-sided — is backface-culled by the reference from every angle. The population - /// instrument for "the card renders but shouldn't": the away+single-sided list is exactly the - /// authored placeholder geometry the reference hides and a forced-two-sided renderer reveals. + /// Sweep every `.m2` and classify each billboard batch by facing: a billboard bone turns +X to + /// the viewer, so a single-sided batch facing -X is culled by the reference from every angle. Bbfacescan { - /// Internal-path prefix filter (e.g. `world\generic`), case-insensitive; all models if omitted. + /// Internal-path prefix filter (e.g. `world\generic`), case-insensitive; all if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and report models that author flat - /// ground-plane render geometry: batches whose vertices all sit at model-space z≈0 (WoW axes, - /// Z up), which sloped terrain buries (Battle Shout's crescents are the canonical case — 6 - /// batches, each a 4-vert quad, every vertex exactly z=0, each quad skinned 100% to a single - /// bone). Flat batches sub-classify QUAD-1BONE (that exact crescent shape) vs OTHER-FLAT — the - /// population instrument for how general the ground-plane renderer mechanism must be. + /// Sweep every `.m2` for flat ground-plane batches (every vertex at model-space z≈0), which + /// sloped terrain buries; `QUAD-1BONE` is Battle Shout's crescent shape, a quad on one bone. Groundscan { /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and census the batches carrying - /// **degenerate authored vertex normals** — `(0,0,0)`, which the shipped corpus really does - /// author (the AQ40 Qiraji Brainwasher's sleeves, Uldaman Ironaya's skirt). The reference - /// consumes such a normal as the zero vector and its order-2 SH collapses to the flat DC term, - /// so the surface draws lit; a renderer that `normalize()`s it gets NaN, `clamp(NaN)` floors - /// the whole lighting factor to 0, and the batch renders PURE BLACK over its correct texture. - /// The population instrument for that class (decision 1268, bug B134) — `ALL` marks a batch - /// where every vertex is degenerate, and the tail names the worst-hit models. + /// Sweep every `.m2` for batches with zero authored vertex normals: the reference takes one as + /// is and its order-2 SH collapses to the flat DC term, so the surface draws lit, where + /// `normalize()` gives NaN and a black batch. `ALL` marks a batch degenerate at every vertex. Normalscan { /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and measure how far its authored header - /// bounding box — the model's **all-animation** vertex extent, and the box the reference - /// derives its doodad cull sphere from (`rec+0x5c`/`rec+0x68`) — reaches past its **bind-pose** - /// vertex extent. The population instrument for decision 1259: an animated placement's submesh - /// keeps its transform at the placement origin while the joint palette moves its vertices, so a - /// bind-pose bound culls an object whose geometry is still on screen. The ambient critters are - /// the extreme — `World\critter\birds\Bird01.m2` authors a 67 yd box around a 1.2 yd body, - /// because its root bone flies it 64 yd along a circuit. Also reports the reverse (`SHORT`: - /// bind-pose geometry OUTSIDE the authored box), which is why the fix unions the two boxes. + /// Sweep every `.m2` and measure how far its header bounds, the all-animation extent the + /// reference derives its doodad cull sphere from (`rec+0x5c`/`rec+0x68`), reach past its + /// bind-pose extent; `SHORT` marks bind-pose geometry outside the header box. Animboundscan { /// Internal-path prefix filter (e.g. `world\critter`), case-insensitive; all if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and census the geometry a - /// **non-character** spawn draws that the reference may not: MULTI-GEOSET models (more than - /// one `skinSectionId` — only the character compositor selects among them, so every other - /// spawn path draws all of them), UNTEX batches (no embedded texture and no character runtime - /// slot) and TINY batches (at most 2 faces). The population instrument behind the stray - /// untextured-primitive reports; `m2batch` then explains a single model in full. + /// Sweep every `.m2` for geometry a non-character spawn draws that the reference may not: + /// multi-geoset models (only the character compositor picks among geosets), untextured batches + /// and tiny ones (at most 2 faces). Geosetscan { /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every model named by **GameObjectDisplayInfo.dbc** and resolve what the reference's - /// GameObject animation arm plays in each reachable `GAMEOBJECT_STATE` × `GAMEOBJECT_ANIMPROGRESS` - /// substate (the substate table `0x5f3c30`, the `0x8607e4` LUT, the four-way missing-sequence - /// remap `0x5f3972`) — beside the generic loader seed the same model gets at build (`0x710153`, - /// animation id 0 through `playableAnimationLookup`). The population instrument for "which - /// GameObjects can the wire state actually be SEEN on": a model whose every substate lands on the - /// seed's own sequence is state-blind, so skipping the arm on its GO type costs nothing, while a - /// STATE-SENSITIVE model renders in the wrong pose the moment its type is left off the machine. - /// Also counts the models whose pose depends on the missing-sequence remap (benilla plays - /// nothing there today, i.e. bind pose) and the ones reaching a rate-0 freeze leg. + /// Sweep every `GameObjectDisplayInfo` model and resolve what the reference's GameObject + /// animation arm plays per reachable state and anim-progress substate (substate table + /// `0x5f3c30`, LUT `0x8607e4`, missing-sequence remap `0x5f3972`) against the loader's seed + /// (`0x710153`, animation 0 through `playableAnimationLookup`); a model whose every substate + /// lands on the seed's sequence is state-blind. Goanimscan, - /// Sweep every `.m2` and census the **event table's positional half**: the `bone` and - /// `position` every `M2Event` record carries beside its 4CC. The reference's event dispatchers - /// hand their arms the event's own world point (the authored `position` through its bone's live - /// matrix and the model's placement), while a consumer that plays at the model root uses the - /// placement alone — so this reports, per 4CC, how many records sit off the origin and how many - /// ride a bone any sequence keys. Where both are zero the two are the same point. + /// Sweep every `.m2` and census each `M2Event`'s `bone` and `position`: the reference's + /// dispatchers play at the event's own world point, so per 4CC it counts the records off the + /// origin and those on a bone some sequence keys. Eventmarkerscan { /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted. prefix: Option, }, - /// Census the **GameObject display sound slots** (`GameObjectDisplayInfo.Sound[0..9]`) against - /// the only thing that can reach them. Exactly one function in the reference reads those - /// columns (`0x5f4010`) and it is called only from the GO M2 anim-event dispatcher - /// (`0x5f3e20`): `$GO0..5` -> slots 0..5, `$GC0..3` -> slots 6..9. So a filled column is - /// audible only when the display's own model authors the matching event tag AND that tag sits - /// on a sequence the GameObject animation arm can actually play. Reports, per slot: columns - /// filled, of those how many are tagged, how many of those are on an armable sequence, and how - /// many name a LOOPING (0x200) kit — the flag that selects `0x5f4010`'s emitter-pool lane over - /// its one-shot lane. + /// Census `GameObjectDisplayInfo.Sound[0..9]`: only `0x5f4010` reads them, called only from + /// the GameObject anim-event dispatcher (`0x5f3e20`), `$GO0..5` to slots 0-5 and `$GC0..3` to + /// 6-9. Per slot: filled, tagged by the display's model, on an armable sequence, and naming a + /// looping kit (0x200, which picks `0x5f4010`'s emitter-pool lane over its one-shot lane). Goslotscan, - /// Sweep every `.m2` (optionally under a path prefix) and census the models whose batch - /// visibility is PER SEQUENCE — geometry the reference draws in one animation and skips in - /// another (the verified `A <= 0` alpha cull). The population instrument for "a single-sequence - /// material bake draws the wrong set"; `m2alpha` then explains one model in full. + /// Sweep every `.m2` for batches whose visibility changes per sequence, drawn in one + /// animation and culled by `A <= 0` in another. Alphascan { /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and census the **effect-model animation - /// lifecycle**: which models author the `Stand`(0) -> `Hold`(158) -> `Decay`(159) triple, and - /// what a consumer that arms ONE sequence and never advances renders for each. A spell-effect - /// model puts its grow-in in the clamping `Stand`, its sustained pulse in the looping `Hold` - /// and its fade-out in `Decay` — so arming only the first freezes on the birth's last frame for - /// the effect's whole life (the frozen Ice Barrier shield). The population instrument for that - /// class: FREEZE / hold-loops / decay-only counts, corpus-wide; `m2seq` explains one model. + /// Sweep every `.m2` for the effect lifecycle `Stand`(0), `Hold`(158), `Decay`(159): arming + /// only the clamping Stand freezes the effect on its last frame instead of pulsing in Hold. Fxlifescan { /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and measure how far our SKELETAL parse - /// sits from the reference's sampler, per bone track per sequence band: empty bands (our - /// nearest-key clamp vs the file's own `interpolation_ranges` window), held band edges (our - /// hold vs the reference's ongoing lerp toward the next key), and STEP tracks (`interp == 0`, - /// which the reference copies and we interpolate). The population instrument behind decision - /// 0133's named residual — and the safety check that a band's keys never fall outside the - /// window the reference searches. + /// Sweep every `.m2` and measure our skeletal parse against the reference's sampler, per bone + /// track and band: empty bands (our nearest-key clamp, its `interpolation_ranges` window), held + /// band edges (our hold, its lerp toward the next key) and step tracks (`interp == 0`, which it + /// copies and we interpolate). It also checks no band's keys fall outside the searched window. Bonescan { /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and census every particle-emitter - /// FEATURE the corpus authors — shapes, head/tail, blend modes (incl. the folded Mod/Mod2x), - /// every file-flag bit, spin signs, twinkle gates, atlas tiling, spline emitters, and the - /// record fields the renderer doesn't parse yet (geometry-model "model particles", - /// recursion-model child emitters, the rate track's interp word) — each with counts, example - /// models, and the spells whose visual chain plays them. The population instrument that turns - /// "this spell looks wrong" reports into a ranked mechanism worklist, closing feature classes - /// corpus-wide instead of spell-by-spell. + /// Sweep every `.m2` and census each particle-emitter feature the corpus authors. Partcensus { /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and list the particle emitters whose - /// **file slot 0 is dead while a later slot is alive** (`peak0 <= 0 < peakN`). The reference - /// samples the PLAYING sequence's rate window every frame, so an emitter whose burst is keyed - /// in a later variation is ordinary content — but a consumer that PINS slot 0 renders it as - /// nothing at all, for ever, on every placement, while the emitter still builds, pools and - /// ticks. The population instrument for that silent class (decision 0760; found on - /// `BlastedLandsLightningbolt01.m2`, the Blasted Lands strike that never fires). + /// Sweep every `.m2` for emitters whose file slot 0 is dead while a later slot lives + /// (`peak0 <= 0 < peakN`): the reference samples the playing sequence's rate window, so a + /// consumer pinned to slot 0 never emits them. Partslotscan { /// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and census the batches the bake routes - /// to a **per-placement material** — the ones whose UV loop or M2Color RGB tint loop is not the - /// same in every FILE sequence slot, so no material shared by every instance can be right for - /// them (decision 1408). The texture-transform twin of `partslotscan`. - /// - /// It asks the **bake**, not a copy of it: `RenderSubmesh::uv_seq` / `rgb_seq` are `Some` - /// exactly when `SeqLoops::uniform()` refused the shared lane, so the census and the runtime - /// cannot disagree by construction. It used to transcribe the sampler instead — it was written - /// to SIZE this fix, before the fix existed — and once the fix landed the twin drifted from it: - /// the transcription compared periods and value EXTENTS at 1e-3, which calls - /// `Spells\AdrenalineRush_Cast_Base` the same loop in both slots, where the bake's own tail key - /// differs by a full 1.0 (slot 0 ends blue, slot 1 returns to red). - /// - /// Per (batch, channel): **SHARED** — the set is `None`, the slots agree, nothing changes for - /// it (with the sub-count that actually animates on that lane) — and **PER-PLACEMENT**, the set - /// is `Some`. The latter splits by WHY the slots disagree, which is the question of whether - /// `uniform()`'s exact float equality is *earning* the routing it does: **DEAD-0** (slot 0 - /// bakes nothing while a later slot animates — the B98 shape, `BlackrockStatueLavaBubble.m2` - /// keying its whole UV flipbook inside file slot 1, a 50/50 variation of animation id 0, so the - /// slot-0 bake returns `None` and every placement is frozen for ever), **WRAP-ONLY** and - /// **KEYS-EPSILON** — the over-application buckets, where the slots hold the same authored loop - /// and are split only by the wrap flag or by sub-epsilon noise in a rebased key — and - /// **REAL-DIFFERS** (genuinely different loops, or slot 0 alive against a dead later slot). - /// Over the shipped corpus both over-application buckets are **empty**, so nothing is on the - /// per-placement lane that a coarser comparison would have left off it. - /// - /// Each bucket closes with its content family and a named worst-hit tail (batches, file - /// sequence slots). `World\` is the only family the per-placement lane reaches at all — a - /// placed doodad or WMO prop; `Creature\`/`Spells\` and the `World\Goober\` GameObject - /// displays resolve their own sequence through the entity lane — so a bucket living outside it - /// costs nothing whatever it says. + /// Sweep every `.m2` for batches the bake routes to a per-placement material, whose UV or + /// M2Color tint loop differs across file sequence slots, read from the bake itself (`uv_seq` + /// and `rgb_seq` are `Some` exactly when `SeqLoops::uniform()` refuses the shared lane) and + /// bucketed by why: `DEAD-0`, `WRAP-ONLY`, `KEYS-EPSILON` or `REAL-DIFFERS`. Only placed + /// doodads and WMO props reach the lane; creatures, spells and GameObjects use the entity lane. Uvslotscan { /// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and list the models whose animation is - /// authored **entirely outside the bone tracks**: sequences exist, per-sequence consumers exist - /// (particle emitters, ribbons, material alpha/colour, UV transforms), and not one sequence - /// keys a bone. A sequence clock that rides a bone-animation clip has nothing to ride on such a - /// model — no clip, no player, no "which sequence, how far in" — so every per-sequence consumer - /// degrades to file slot 0 at t = 0, for ever. The population instrument for that silent class - /// (decision 0941; found on the Molten Core rune + flame ring). `[GO]` marks a - /// `GameObjectDisplayInfo` model — the hosted-clock lane, where the freeze is total. + /// Sweep every `.m2` for models animated entirely outside the bone tracks: no sequence keys a + /// bone, so a clock riding a bone clip leaves every per-sequence consumer at slot 0, t = 0. + /// `[GO]` marks a `GameObjectDisplayInfo` model, where the freeze is total. Seqclockscan { /// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and list batches whose texture is authored - /// **CLAMP** (`M2Texture.flags` bit 0/1 clear) while the batch's UVs run **outside `0..1`** — the - /// population a repeat-sampling renderer draws wrong. That margin is deliberate: clamped it - /// samples the sheet's transparent border and the card fades to nothing; wrapped it folds into - /// the opposite edge and draws as solid geometry with a seam at the crossing (decision 0763, - /// bugs B52/B96 — the Dun Morogh snow-firs and the Plaguelands bush). + /// Sweep every `.m2` for batches whose texture is authored clamp on an axis (`M2Texture.flags` + /// bit 0 or 1 clear) while their UVs run outside 0..1 there on purpose: clamped they fade into + /// the sheet's transparent border, wrapped they draw solid with a seam where they cross. Uvwrapscan { /// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every model the **spell-visual chain** can reach — every `SpellVisualEffectName` - /// path a kit's ten effect slots, a `SpellVisual` row's missile model or its dest-anchored - /// model names — and census the batches whose **texture transform animates**, then classify - /// each by what a consumer that runs NONE of it renders. - /// - /// Decision 0271 deferred this channel on the claim that "no effect model in the current - /// corpus needs it"; this is that claim, made countable — it is what 2282 read to size the - /// missing scroll, and it is how the same question was re-asked of the unit / GameObject / - /// held-item corpus that 2295 then fixed (`entityuvscan`, its twin). It asks the **bake**, - /// not a transcription of it: a batch is in scope - /// exactly when `tex_anim` emitted a loop on any of the three channels, the same test the - /// lanes that DO run them use (`ui_models` 2019, `spell_fx` 2282). - /// - /// The classes are what a frozen batch draws, judged from the texture's own alpha through its - /// authored address mode — because a CLAMP-authored sheet's border is what a UV outside `0..1` - /// samples, and on this corpus that border is transparent: - /// - /// - **INVISIBLE** — frozen, every texel the batch reaches is transparent, while the scroll - /// reaches painted ones: the batch renders **nothing at all**. `Spells\SwipeCaster.m2` - /// (druid Swipe) is the class: two 51-vertex claw-trail strips whose UVs are authored at - /// `u[+0.945..+1.944]` over a 16×16 CLAMP sheet, so frozen they sample column 15 alone — - /// alpha 0 — and the whole of their visible existence is the `−0.97` U scroll. - /// - **FROZEN** — it draws, statically: the scroll is the motion it loses. - /// - **HELD** — keyed to a constant non-identity offset, so a lane that seeds none draws it - /// mis-registered rather than still. - /// - **NEVER** / **UNKNOWN** — flagged, never counted as INVISIBLE: nothing painted at any - /// point of the loop, or no alpha lane to judge from (`Mod`/`Mod2x`, an undecodable sheet). - /// - /// Each batch prints its per-axis reasoning (address mode, authored and frozen UV spans, the - /// texel indices those reach) so the call is checkable, and the report closes with the INVISIBLE - /// listing joined back through the chain: which spells reach it, and through which lifecycle - /// stage (`precast`/`cast`/`impact`/`state`/`channel`/`missile`/`area`). + /// Sweep every model the spell-visual chain reaches (kit effect slots, missile and + /// dest-anchored models) for batches whose texture transform animates, classed by what a + /// consumer running none of it draws, from the texture's alpha through its address mode: + /// `INVISIBLE` (frozen, it samples only transparent texels while the scroll reaches painted + /// ones), `FROZEN`, `HELD`, `NEVER` or `UNKNOWN`. It closes with the `INVISIBLE` batches joined + /// to the spells that reach them. Fxuvscan { /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all reachable effect /// models if omitted. prefix: Option, }, - /// Sweep every model the **ENTITY lane** can render — every `CreatureDisplayInfo` → - /// `CreatureModelData` body (NPCs, critters, mounts, and every PLAYER, which resolves through - /// the same chain, decision 0041), every `GameObjectDisplayInfo` model, every - /// `ItemDisplayInfo` left/right model joined to the `Item\ObjectComponents\` folder the - /// archives actually hold it in, and the corpse lane's `DeathSkeleton` bone piles — - /// and census the batches whose **texture transform animates**, then classify each by what a - /// consumer that runs NONE of it renders. - /// - /// The twin of `Fxuvscan`, asked of the corpus that record's fix did NOT reach. Decision 2282 - /// gave the spell-effect lane its texture transform and named this as its first deferral: - /// `model_render::batch::Materials::entity_variants` passed `play_uv = false`, and `build` - /// does `play_uv.then_some(sub.uv_anim.as_ref()).flatten()` — so every unit, player, - /// GameObject and held-item batch was handed no loop and drew its authored UVs, untransformed, - /// for ever. Nobody had measured how much content that is; this is that measurement, it is - /// what sized **decision 2295**, which closed it, and it shares `fxuvscan`'s whole per-batch - /// reader (`uv_batch`) so the two corpora cannot be judged by two different rules. It stays - /// because the census is how the claim "this lane needs no channel" is kept checkable — which - /// is the one thing 0271's deferral was missing. - /// - /// **Read the corpus from the tables, never from a path prefix.** "The path looks broken" and - /// "the path is reachable" are different questions and only the second one is worth a session - /// (2282's own closing note). A held item's directory is not even a column — the equipment lane - /// picks `Weapon`/`Shield`/`Shoulder`/`Head`/`Ammo`/`Quiver` from the slot the item is worn in - /// — so the sweep asks the archives which folder holds each display's basename, and expands a - /// helm stem into its sixteen per-race/sex files the way the attach does. - /// - /// The classes are `fxuvscan`'s, judged the same way — from the texture's own alpha through the - /// batch's authored address mode (INVISIBLE / NEVER / FROZEN / HELD / UNKNOWN; that command's - /// doc is where they are explained) — with one thing this corpus adds: a CREATURE batch's - /// `Monster1/2/3` sheet is **blank in the M2** and filled per display from - /// `CreatureDisplayInfo.textureVariation`, so one batch has as many sheets as the model has - /// skins and the verdict is asked once per skin. A character composite (body atlas, hair, - /// object skin) has no authored sheet at all and reads UNKNOWN, which is the honest answer - /// rather than a silent pass. - /// - /// **The load-bearing column is the CLOCK**, because it decides the SHAPE of the fix and not - /// merely its size: - /// - /// - **GSEQ** — every live loop rides a global sequence, a free-running per-scene clock the - /// reference anchors once per instance at attach. One shared material uniform is faithful - /// there; that is 0136 choice 1's lane, already built. - /// - **BAND** — every live loop rides its sequence band, i.e. the instance's own play head. Two - /// units playing different animations, or the same one at different phases, are at different - /// offsets, and no shared uniform can serve both: that is 2282's per-instance `UvLoop`. - /// - **MIXED** / **HOLD** — the batch's channels disagree, or it is keyed to a constant - /// non-identity offset that needs a seed and no clock at all. - /// - /// Each affected batch prints its three channels (translation / rotation / scaling — a sweep - /// that looked only at translation would have missed `GroundingTotem_Impact` entirely, which is - /// scale-only), its per-axis texel reasoning, both frozen verdicts, its clock, which file - /// sequence slots carry the keys and what those sequences are *called*, and — where the bake - /// refused the shared lane — `uvslotscan`'s own `uniform()` verdict for the same set. The - /// report closes with every affected model sorted by POPULATION: how many table rows can put it - /// on screen, which is the difference between one gnome terminal and every murloc in the game. - /// - /// **And then the same corpus again for the M2COLOR TINT**, because the UV channel is not the - /// only one this lane drops. `build` passes `sub.rgb_anim.as_ref()` *unconditionally* — there - /// is no `play_rgb` to flip — so a tint-animating entity batch is seeded at the loop's first - /// key and then never re-sampled, because `doodad_anim::register_tint`'s only call sites (like - /// `register_uv`'s) are in the world streamer's `assemble.rs`. The hole is the missing - /// REGISTRATION, not a missing argument, which is why flipping `play_uv` alone would only - /// trade one frozen frame for another. - /// - /// A tint is a **multiply**, so its failure mode is the wrong colour rather than missing - /// geometry, and the classes say how wrong: BLACK (the frozen tint kills the batch), STRONG, - /// SLIGHT, NEGLIGIBLE — bands over one printed number, the worst per-channel distance between - /// the frozen value and anything the loop reaches. Two qualifications the raw count would - /// overstate without: a batch whose slot 0 bakes nothing seeds **white**, so it is the right - /// colour until a later slot's animation plays and wrong only *during* it (the `slots` line - /// names which); and the **ALPHA** channel is counted beside them precisely because this lane - /// DOES serve it — `attach::dress::spawn_part` gives every part a `MatAnim` that - /// `sample_mat_anim` ticks per instance. One of the three material-animation channels runs. + /// `fxuvscan`'s census over the entity lane's models, as the tables reach them: every + /// `CreatureDisplayInfo` body (players included), `GameObjectDisplayInfo` model, + /// `ItemDisplayInfo` model in whichever `Item\ObjectComponents\` folder holds it (a helm as its + /// 16 race and sex files) and `DeathSkeleton`. A creature's `Monster1/2/3` sheet is + /// filled per display from `textureVariation`, so each skin is judged. The clock column is + /// `GSEQ` (a global sequence, which the reference anchors once per instance at attach), `BAND` + /// (the instance's own play head), `MIXED` or `HOLD`. It then repeats for the M2Color tint + /// (`BLACK`, `STRONG`, `SLIGHT`, `NEGLIGIBLE`), with the alpha channel counted beside it. Entityuvscan { /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all reachable entity /// models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and census the batches whose texture - /// coordinates are **GENERATED, not authored** — the sphere-map environment stages - /// (`texture_unit_lookup[texCoordSet] > 2`, the reference's gate at `0x70b8bd`). Such a batch - /// carries no usable UVs *by design* (the artist collapses the mesh onto one point because the - /// runtime supplies the coordinates), so a renderer that reads the vertex UV paints the whole - /// surface in **one texel** of a reflection sheet — silently, with no missing geometry to - /// notice. `DEGENERATE` marks exactly that population; the rest carry unused leftover UVs and - /// misdraw as a static smear instead. The instrument behind the Deeprun Tram glass tube. + /// Sweep every `.m2` for batches whose texture coordinates are generated, the sphere-map + /// stages (`texture_unit_lookup[texCoordSet] > 2`, the reference's gate at `0x70b8bd`). Their + /// UVs go unused by design; `DEGENERATE` marks those collapsed to one point, which a renderer + /// reading vertex UVs paints as one texel. Envmapscan { /// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` and report which sampler ADDRESS MODES the corpus asks of each texture - /// path, and how many paths are asked for more than one — the design check behind decision - /// 0763 (the mode lives on the sampler, which rides the `Image`, which is keyed by path). + /// Sweep every `.m2` and report the sampler address modes the corpus asks of each texture + /// path, and how many paths are asked for more than one. Texmodescan { /// Internal-path prefix filter, case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and count the two halves of the - /// **owner-last draw-order** law per model: the EFFECTS it authors (particle emitters + - /// ribbon trails) and the TRANSPARENT-pass batches of its own body those effects must draw - /// after (decisions 0719/0721), plus the model's reach and the draw-order rung that reach - /// produces. The population instrument for "how much does this fix, besides the one creature - /// it was found on": a model with effects AND transparent batches of its own is one whose - /// effects our distance sort could interleave with its own body; one without never had the - /// defect at all. + /// Sweep every `.m2` and count both halves of the owner-last draw order: each model's effects + /// (particle emitters and ribbons) and its own transparent-pass batches, with reach and rung. Fxordercensus { /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and census the **3-D model particles**: - /// emitters carrying a geometry-model reference, whose shard instances render the GEOMETRY - /// model's own submeshes stamped with the OWNER model's owner-last draw-order rung. Per - /// (owner, geometry) pair: the owner's rung and reach plus the geometry's material family - /// tuples (blend, two_sided, additive, no_depth_write, no_depth_test) — the exact keys the - /// shard materials are built from. The ground truth sizing the pipeline-warm menagerie's - /// shard rows: rung histogram, family-tuple set split by transparent-pass membership, and - /// the unresolvable-path bound. + /// Sweep every `.m2` for 3-D model particles, emitters whose shards draw a geometry model's + /// submeshes at the owner's draw-order rung: per (owner, geometry) pair, the rung, reach and + /// material family tuples the shard materials are built from. Shardcensus { /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and list the models where the - /// **loader-idle sequence is not file slot 0** while benilla's render content gate leaves it - /// unarmed — so every per-sequence bake (emission rate/gate, the nine parameter channels, - /// material alpha) degrades to slot 0, a sequence the instance is not playing. On the - /// `DuelingFlag`-shaped GameObjects (Spawn/Stand/Despawn) slot 0 is the **Spawn flourish**: - /// the population instrument for decision 0936, found on the Stormwind battlefield banner - /// pouring its spawn sparkle-tails for ever. + /// Sweep every `.m2` for models whose loader-idle sequence is not file slot 0 while benilla's + /// render content gate leaves them unarmed, so every per-sequence bake reads slot 0, a sequence + /// the instance is not playing; on `DuelingFlag`-shaped GameObjects that is the Spawn flourish. Idleslotscan { /// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` and census the **animation-driven sound emitters**: the models whose - /// sequences carry a `$DSL` (doodad sound loop) / `$DSE` (its release token) / `$DSO` (doodad - /// sound one-shot) / `$SND` (generic one-shot) marker, the `SoundEntries` kit each names with its 3D parameters, and — - /// the column this exists for — whether the carrying sequence is REST-posed, i.e. one the - /// render content gate (decision 0130) never builds a rig for. A placed lamp's hum is a single - /// `$DSL` on a sequence that keys no bone at all, so the whole class is unreachable through an - /// `AnimationPlayer`: the population instrument for "every world doodad is silent" (B345). + /// Sweep every `.m2` for animation sound markers (`$DSL` doodad loop, `$DSE` its release, + /// `$DSO` doodad one-shot, `$SND` one-shot) with each kit's 3-D parameters, and whether the + /// carrying sequence is rest-posed, one the render content gate builds no rig for. Soundeventscan { /// Internal-path prefix to limit the sweep (e.g. `world`); all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and list the models whose PARTICLE - /// emitters carry any of the given file-flag bits (`M2ParticleEmitter+0x04`) — the - /// population instrument for particle-flag mechanisms (which content actually authors - /// e.g. the `0x1000` plane-quad leg), so a class of effects is closed by mechanism - /// instead of tested spell-by-spell. + /// Sweep every `.m2` and list the models whose particle emitters carry any of the given + /// file-flag bits (`M2ParticleEmitter+0x04`). Partscan { /// The flag mask to match, hex or decimal (e.g. `0x1000`). mask: String, /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and report which models author M2 - /// dynamic LIGHT blocks — the population instrument for the mechanism (decision 0016, - /// `0x718960`). Per model: its point (`type==1`, the GL - /// hot-spot caster) vs directional (ambient-feed) light counts, then per point light its - /// bone/position/diffuse colour×intensity/attenuation/visibility-gate. The closing summary — - /// totals, a breakdown by top-level content family (Creature/Item.ObjectComponents/ - /// Character/World/Spells/other), and a diffuse-palette tally — is the real deliverable: - /// benilla only spawns these lights for ADT-placed doodads and WMO props today, so it answers - /// how much of the entity path (creatures, held items, GameObjects) is missing them. + /// Sweep every `.m2` for M2 dynamic lights (`0x718960`): per model its point (`type==1`) and + /// directional light counts, each point light's bone, position, colour, attenuation and + /// visibility gate, then totals by content family and a diffuse-palette tally. M2lightscan { /// Internal-path prefix filter (e.g. `item\objectcomponents`), case-insensitive; all /// models if omitted. prefix: Option, }, - /// Sweep every WMO ROOT and cross-tab the two halves of the skybox mechanism: the root's - /// **MOSB** skybox model against its groups' `0x40000` flag. `0x40000` is undocumented, so this - /// is what *identifies* it — across all 815 roots the bit never appears without a MOSB. Note - /// what that does and does NOT buy: it establishes `flag => MOSB`, never which group the - /// renderer tests (the reference's law is the flood-VISITED group, `0x6b42e0`; decision 0773 - /// correcting 0767). - /// Also the population instrument: which 1.12 buildings replace the `Light.dbc` gradient dome - /// with an authored sky (Stratholme's burning city is the only one reachable), and how much + /// Sweep every WMO root and cross-tab its MOSB skybox model against its groups' `0x40000` + /// flag, which never appears without a MOSB in all 815 roots; the reference tests it on the + /// flood-visited group (`0x6b42e0`). Also which buildings replace the `Light.dbc` dome with an + /// authored sky, and in how many groups: only Stratholme's is reachable in 1.12. Skyboxscan, - /// Dump all 18 `LightIntBand` rows of the `Light.dbc` entry covering a world position at a - /// time of day — the band-semantics instrument (which row holds which colour at which hour; - /// the celestial-diffuse band question, decision 0485). ⚠ This is ONE params record RAW — - /// near a sphere's falloff edge the LIVE light is mostly the continent global and looks - /// nothing like these rows; `lightblend` shows what actually wins at the position (0706). + /// Dump all 18 `LightIntBand` rows of the `Light.dbc` entry covering a position at a time of + /// day. One params record, raw: near a sphere's falloff edge the live light is mostly the + /// continent global, and `lightblend` shows what wins there. Lightbands { /// Map id (0 = Eastern Kingdoms, 1 = Kalimdor). map: u32, @@ -719,21 +410,18 @@ enum Command { /// Game minute-of-day (0..1440; 720 = noon). minute: u32, }, - /// Dump every `LightIntBand`/`LightFloatBand` row of an explicitly named `LightParams` id — the - /// instrument for rows **no position can reach**, because nothing in `Light.dbc` references them: - /// magma submersion reads the fixed global row 7 and slime row 6 (VERIFIED `0x6d2371`), not the - /// zone's underwater slot. + /// Dump every `LightIntBand` and `LightFloatBand` row of a named `LightParams` id, for rows no + /// position reaches: magma submersion reads the fixed global row 7 and slime row 6 + /// (`0x6d2371`), not the zone's underwater slot. Lightparam { /// `LightParams.dbc` id (1-based). id: u32, /// Game minute-of-day (0..1440; 720 = noon). minute: u32, }, - /// Dump the **per-weather-slot** light resolve at a world position: which `LightParams` id each - /// of the five `Light.dbc` slots names (clear / clear-underwater / storm / storm-underwater / - /// death), which of them are UNSET (and so fall back to clear), and the fog/ambient/diffuse the - /// blend resolves for each. `lightbands` only ever shows the clear slot, so an underwater or - /// ghost atmosphere that reads wrong could not be told from data that simply has no such record. + /// Dump the per-weather-slot light resolve at a position: the `LightParams` id each of the five + /// `Light.dbc` slots names (clear, clear underwater, storm, storm underwater, death), the unset + /// ones that fall back to clear, and each one's fog, ambient and diffuse. Lightslots { /// Map id (0 = Eastern Kingdoms, 1 = Kalimdor). map: u32, @@ -749,11 +437,8 @@ enum Command { /// Game minute-of-day (0..1440; 720 = noon). minute: u32, }, - /// Dump every light SPHERE covering a world position — distance, falloff, blend alpha, each - /// sphere's clear ambient/sun — then the faithful area-blend result vs the single `pick_light` - /// sample. The instrument for "which palette actually wins HERE": a position near a zone - /// sphere's falloff edge resolves mostly the continent global, and the raw per-params band dump - /// (`lightbands`) cannot show that. + /// Dump every light sphere covering a position (distance, falloff, blend alpha, clear ambient + /// and sun), then the area-blend result against the single `pick_light` sample. Lightblend { /// Map id (0 = Eastern Kingdoms, 1 = Kalimdor). map: u32, @@ -769,9 +454,8 @@ enum Command { /// Game minute-of-day (0..1440; 720 = noon). minute: u32, }, - /// Bulk-scan placed doodads (MDDF) and WMO doodad-set-0 props (MODF → MODS/MODD) across a - /// `(2·tile_radius+1)²` block of ADT tiles around a world position, and report how much of that - /// content animates (see `m2anim`) — the doodad-animation perf/scope instrument. + /// Scan placed doodads (MDDF) and WMO doodad-set-0 props (MODF, MODS, MODD) over a + /// `(2·tile_radius+1)²` block of ADT tiles around a position, and report how much animates. Doodadscan { /// Map directory name (e.g. `Azeroth`). map: String, @@ -784,36 +468,25 @@ enum Command { /// ADT tile radius around the center tile to scan (0 = just the containing tile). tile_radius: u32, }, - /// Dump a WMO root's placed-prop tables: every MODD doodad with its MODS set membership and - /// its OWNING group(s) read from the group files' MODR lists — the relation the reference - /// instantiates from (a *visible* group's own refs, `0x695aa0`), so a prop referenced by NO - /// group is one the real client never creates at all (the divergence decision 0689 names, - /// which benilla still spawns). The "which props exist in this building, who owns them, and - /// which would the reference even draw" instrument. + /// Dump a WMO root's placed props: each MODD doodad with its MODS sets and the groups whose + /// MODR names it. The reference creates props only from a group's MODR refs (`0x695aa0`), so + /// it never creates one no group names; benilla still spawns those. Wmodoodads { - /// Internal path to the WMO **root** (forward or back slashes accepted). + /// Internal path to the WMO root (forward or back slashes accepted). internal_path: String, /// Case-insensitive substring of the prop's model path (e.g. `lightray`); all if omitted. filter: Option, }, - /// Sweep every WMO **root** (optionally under a path prefix) and list the placed MODD props - /// the INTERIOR lighting lane commits as **literal black**. That lane's entire base light is - /// the MODD entry's own baked colour (ambient `cap96`, diffuse `floor112` — `0x694e90` → - /// `0x6a77e0`), and the floor leg is a hue-preserving scale by `112/max`, so a colour of - /// exactly `#000000` has nothing to raise and both words come out zero: the prop is lit by - /// nothing but its owning group's MOLR fixtures, and a group with none in range draws a pure - /// black silhouette. The `ALSO-EXT` column is the divergence half — a prop an EXTERIOR group's - /// MODR *also* names is one the reference reaches through that group too, while our - /// single-instance first-referrer-wins ownership pins it to the interior lane from every angle. + /// Sweep every WMO root for placed MODD props the interior lane lights literal black: its base + /// light is the MODD colour (ambient `cap96`, diffuse `floor112`, `0x694e90` → `0x6a77e0`), and + /// the `112/max` floor keeps `#000000` black. An exterior referrer sky-lights a prop instead + /// (`0x695aa0`); `RESCUED` counts those an interior group names first. Darkpropscan { /// Internal-path prefix to limit the sweep (e.g. `world\wmo\azeroth`); all if omitted. prefix: Option, }, - /// List individual doodad (MDDF) and WMO (MODF) placements around a world position whose - /// model path contains a substring — position, Euler rotation (deg), scale, uniqueId. The - /// "point at a thing in the world, tell me exactly how it's placed" instrument: an - /// orientation bug needs the placement rotation as ground truth, which `doodadscan`'s - /// aggregates never show. + /// List the doodad (MDDF) and WMO (MODF) placements around a position whose model path + /// contains a substring: position, Euler rotation (deg), scale and uniqueId. Placescan { /// Map directory name (e.g. `Azeroth`). map: String, @@ -828,12 +501,9 @@ enum Command { /// Case-insensitive substring of the model path (e.g. `instanceportal`). filter: String, }, - /// Print the terrain MCSH baked-shadow bit at a world position — the per-instance - /// terrain-shade selector an exterior M2 doodad samples once at its base (sun intensity - /// 2.5 lit / 0.5 shadowed, `0x69e4ad`; decisions 0063/0747) — plus an ASCII texel - /// neighborhood. A doodad base near a shadow edge sits one ~0.52 yd texel from the OTHER - /// intensity family, so two adjacent fence pieces can land on opposite sides — the - /// "why is ONE of these blown out" instrument. + /// Print the terrain MCSH baked-shadow bit at a position, which an exterior M2 doodad samples + /// once at its base (sun scale 1.0 lit, 0.5 shadowed, `0x698cb4`), and the texel + /// neighbourhood: one ~0.52 yd texel can split adjacent pieces across a shadow edge. Shadeat { /// Map directory name (e.g. `Azeroth`). map: String, @@ -851,10 +521,8 @@ fn normalize(path: &str) -> String { path.replace('/', "\\") } -/// The grouping/lookup key for a model path referenced from MDDF/MODD (`doodadscan`'s instance -/// tables): lowercase, `.mdx`/`.mdl` → `.m2`. Mirrors `benilla_formats`' own (crate-private) -/// `model_path` normalization so two placements of the same model authored under different -/// casing/extension collapse to one row instead of double-counting it. +/// A placed model path's grouping key, lowercase with `.mdx`/`.mdl` as `.m2`; it must match +/// `benilla_formats`' crate-private `model_path` normalization. fn model_key(raw: &str) -> String { let lower = raw.to_ascii_lowercase(); match lower @@ -874,7 +542,6 @@ fn parse_u32_maybe_hex(s: &str) -> Result { } } -/// `"yes"`/`"-"` for a compact table cell. fn yn(b: bool) -> &'static str { if b { "yes" @@ -953,7 +620,7 @@ fn main() -> Result<()> { output, } => { let name = normalize(&internal_path); - // Note which archive the file resolved from (handy for debugging overrides). + // The archive the file resolved from, which shows an override. let source = chain .find_file_archive(&name) .map(|p| p.display().to_string()); diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/geometry.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/geometry.rs index 1a383fb2..42c1c3c1 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/geometry.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/geometry.rs @@ -1,38 +1,22 @@ -//! Corpus scans over **what geometry a model draws** — the shape of its batches, independent of -//! how they are textured or shaded. -//! -//! Billboard cards and which way they face (`bbscan`, `bbfacescan`), geosets and the untextured / -//! single-triangle strays (`geosetscan`), flat ground-plane quads (`groundscan`), and degenerate -//! authored vertex normals (`normalscan`). What MATERIAL a batch carries is [`super::material`]'s -//! question. +//! Corpus scans over a model's geometry: billboards, geosets, ground quads, bounds, normals. use std::collections::{BTreeMap, HashMap}; use anyhow::Result; use benilla_formats::Chain; -/// Sweep every `.m2` (under `prefix`, if given) and classify every BILLBOARD batch by which way its -/// geometry faces — see the `Bbfacescan` command doc for why the sign decides visibility. -/// -/// A billboard bone puts the model's **+X** toward the viewer (the spherical arm, `0x71547c`), -/// so a batch whose winding normal is +X faces the camera and a −X one faces away. Single-sided -/// (`two_sided` false, i.e. no material `0x04`), the away-facing ones are backface-culled by the -/// reference from every angle — they are authored placeholders the author never saw. +/// Classify every billboard batch by facing: a billboard bone turns the model's +X toward the +/// viewer (the spherical arm, `0x71547c`), so a single-sided batch (no material `0x04`) whose +/// winding faces −X is culled by the reference from every angle. pub fn bbfacescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut cards) = (0u32, 0u32); - // The four card populations. `away_single` is the one the renderer's forced-two-sided override - // changes: those and only those become visible when a card is not allowed to be culled. + // The four card populations; `away_single` is what a forced two-sided draw would reveal. let (mut toward, mut away_single, mut away_two, mut edge_on) = (0u32, 0u32, 0u32, 0u32); - // …and the batches that are not cards at all. A facing only exists for a batch that IS one - // plane; a closed solid has faces every way round, backface culling never hides it, and - // sampling its first triangle answers a question it doesn't have (see `plane_normal`). + // Billboard batches that are not one plane: a solid has no single facing. let mut solid = 0u32; - // …and the other side of the same gate: batches the split REFUSED because their geometry is - // welded to a billboard bone (`RenderSubmesh::welded_billboard`, decisions 0839/0841). These - // carry no `billboard` at all, so the card loop below never sees them — they are counted from - // the flag the render lanes read, which is what makes this a cross-check of `bbscan`'s SEAM - // column (same models, counted from the two ends of the rule) rather than a re-derivation. + // Batches the card split refused as welded to a billboard bone: they carry no `billboard`, + // so they are counted from the render lanes' flag, a cross-check of `bbscan`'s SEAM column. let (mut welded, mut weld_models) = (0u32, 0u32); for name in names { let Ok(bytes) = chain.read_file(&name) else { @@ -50,12 +34,10 @@ pub fn bbfacescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { for (i, s) in subs.iter().enumerate() { let Some(bb) = &s.billboard else { continue }; cards += 1; - // Not one plane ⇒ not a card. Counted apart rather than classified by a triangle. if s.plane_normal().is_none() { solid += 1; continue; } - // The winding normal's X component: +1 faces the viewer, −1 faces away. let Some(fx) = facet_x(s) else { edge_on += 1; continue; @@ -96,8 +78,7 @@ pub fn bbfacescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// The X component of a batch's first-triangle winding normal, in WoW model space — `None` when the -/// triangle is degenerate or the normal lies in the YZ plane (nothing to decide a facing from). +/// The X of a batch's first-triangle unit winding normal, WoW model space; `None` if degenerate. fn facet_x(s: &benilla_formats::RenderSubmesh) -> Option { let tri = s.indices.get(..3)?; let p = |i: u32| s.positions.get(i as usize).copied(); @@ -115,11 +96,8 @@ fn facet_x(s: &benilla_formats::RenderSubmesh) -> Option { (len > 1e-9).then(|| n[0] / len) } -/// The widest max/min axis spread a scale track ever holds — `1.0` for a uniform track (the -/// overwhelming majority: a glow card that only pulses in size), `> 1` for one that stretches its -/// bone along an axis. A key with an axis at or below zero contributes `1.0`: a zero axis is the -/// reference's own "hide this" (the eyelid blink's retracted lid), not a stretch, and dividing by -/// it would report infinity. +/// The widest max/min axis ratio a scale track holds, 1 for uniform. A key with an axis at zero +/// counts as 1: a zero axis hides the bone (the eyelid blink), it does not stretch it. fn scale_spread(keys: &[(T, [f32; 3])]) -> f32 { keys.iter() .map(|(_, s)| { @@ -135,23 +113,12 @@ fn scale_spread(keys: &[(T, [f32; 3])]) -> f32 { .fold(1.0f32, f32::max) } -/// Sweep every `.m2` (under `prefix`, if given) and classify its billboard usage — see the -/// `Bbscan` command doc. Output per model: the authored arms and how many vertices ride each -/// DIRECTLY (primary bone is the billboard bone — the card path) vs INHERITED (primary bone -/// descends from one — the joint-palette path, decision 0205), then the same question for the -/// model's **particle emitters and ribbons** (`fx[…]`) — the population behind decision 0813: an -/// emitter on (or under) a billboard bone has a camera-dependent origin, because the reference -/// folds the record position through the *replaced* palette matrix (`0x7190a9`–`0x71910c`). -/// -/// The `NONUNIF[…]` column is the third population: billboard bones whose SCALE is animated -/// **non-uniformly** (in any sequence band, or on a global-sequence loop), listed as -/// `bone:arm×ratio` where the ratio is the widest max/min axis spread the bone ever holds. The -/// billboard law preserves the bone's scale under the substituted camera basis (`T·R_cam·S`), so -/// such a bone stretches its card along one model axis — the Lightwell's lock-Z shaft -/// (`World\Goober\G_HolyLightWell.m2` bone 0, `×4.26`) is a 0.12 yd card pulled into a 4.5 yd -/// column of light. Any consumer that reduces the scale to one scalar renders these squat and -/// blown-out instead (bug B169), and a `d` in the direct column is where that lands on a **card**, -/// whose transform is rebuilt from the joint rather than skinned from it. +/// Per model with billboard bones, the vertices riding each arm directly (the card path) or +/// through a descendant (the palette path), and three populations: `fx[…]`, emitters and ribbons +/// on a billboard chain, whose position the reference folds through the replaced palette matrix +/// (`0x7190a9`–`0x71910c`); `SEAM[…]`, bones the card split refuses; `NONUNIF[…]`, +/// `bone:arm×ratio` for non-uniform animated scale, which the reference keeps under the camera +/// basis (`T·R_cam·S`), so the card stretches along one axis (`G_HolyLightWell.m2` bone 0, ×4.26). pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let arm = |k: benilla_formats::BillboardKind| match k { @@ -164,14 +131,9 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { // Corpus totals: models exercising each arm, split by how the geometry rides it. let mut direct_models: HashMap<&'static str, u32> = HashMap::new(); let mut inherited_models: HashMap<&'static str, u32> = HashMap::new(); - // …and the effect riders (particles/ribbons on a billboard chain). let mut fx_models = 0u32; let mut fx_total: HashMap = HashMap::new(); - // …and the seam population (see the classification below). let (mut seam_models, mut seam_bones) = (0u32, 0u32); - // …and the non-uniform-scale population: billboard bones that stretch their card along one - // model axis, split by whether geometry rides them DIRECTLY (the card path, where a scalar - // scale loses the stretch outright) or only by inheritance (the palette path). let (mut nonunif_models, mut nonunif_bones, mut nonunif_direct) = (0u32, 0u32, 0u32); for name in names { let Ok(bytes) = chain.read_file(&name) else { @@ -191,9 +153,8 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { continue; } hits += 1; - // Nearest billboard ancestor (self included) per bone — the arm whose palette - // replacement a vertex on this bone inherits. Bounded walk (M2 parents precede - // children; the bound is just a malformed-file guard). + // The nearest billboard bone at or above `i`, whose palette replacement a vertex on `i` + // inherits; the bound guards a malformed parent chain. let ancestor_arm = |mut i: usize| -> Option<(bool, benilla_formats::BillboardKind)> { let mut hops = 0; loop { @@ -217,22 +178,15 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { None => {} } } - // SEAM bones: billboard bones whose geometry is welded to the rest of the model — by a - // partial vertex weight, or by a triangle it shares with a static neighbour. The reference - // skins per vertex, so such geometry BENDS (a flap's root stays on the body while its tip - // swings to the camera); a rigid card cannot express that at all, and moving the group - // rigidly tears the flap in two. These are the bones the split declines (decision 0839), - // read through the renderer's own predicate so this census cannot drift from it. + // SEAM: billboard bones welded to the model by a partial weight or a shared triangle. The + // reference skins per vertex, so the geometry bends; a rigid card would tear it. let seam = benilla_formats::non_separable_billboard_bones(&bytes); let fmt_counts = |m: &HashMap<&str, u32>| { let mut v: Vec = m.iter().map(|(k, n)| format!("{k}:{n}")).collect(); v.sort(); v.join(" ") }; - // The EFFECT riders: a particle emitter / ribbon whose bone chain reaches a billboard - // bone has a camera-dependent live frame (its record position rides the replaced palette - // matrix), so a consumer that places it at the rest pose puts it in the wrong place. - // `d` = the effect's own bone is the billboard bone, `i` = it descends from one. + // Effects on a billboard chain: `d` on the billboard bone itself, `i` below one. let mut fx: HashMap = HashMap::new(); let mut tally = |tag: &str, bone: u16| { if let Some((direct, k)) = ancestor_arm(bone as usize) { @@ -258,7 +212,6 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { v.join(" ") }; let bones: String = kinds.iter().flatten().map(|&k| arm(k)).collect(); - // NON-UNIFORM billboard scale (see the `NONUNIF` column in the doc above). let seqs = benilla_formats::parse_m2_animations(&bytes); let gseq = benilla_formats::parse_m2_global_sequence_bones(&bytes); let mut nonunif: BTreeMap = BTreeMap::new(); @@ -277,16 +230,14 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { *e = e.max(r); } } - // Only the BILLBOARD bones matter — an ordinary bone's non-uniform scale rides the joint - // palette like any other transform and nothing reduces it. + // Only billboard bones: an ordinary bone's scale rides the joint palette unreduced. nonunif.retain(|&b, _| kinds.get(b).copied().flatten().is_some()); let nonunif_col = if nonunif.is_empty() { String::new() } else { nonunif_models += 1; nonunif_bones += nonunif.len() as u32; - // Does geometry ride this bone DIRECTLY (primary bone == it, and separable)? That is - // the card path — where the stretch has to survive a transform rebuild. + // The card path: geometry rides the bone directly and the split accepts it. let direct_bone = |b: usize| { !seam.contains(&(b as u16)) && m.vertices.iter().any(|v| v.bone_indices[0] as usize == b) @@ -368,29 +319,15 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (under `prefix`, if given) and census the **geometry a non-character model -/// draws that the reference may not** — the population instrument behind the bug channel's -/// "stray untextured primitive" family. Three independent signals per model, all read through the -/// renderer's own batch resolution (`m2batch`'s), so the report can't drift from the mechanism: -/// -/// - **MULTI-GEOSET** — more than one distinct `skinSectionId`. The character compositor selects -/// among these; every other spawn path draws **all** of them, so this is exactly the population -/// an unfiltered creature/doodad/effect draw over-renders (`Creature\Banshee\Banshee.m2` is the -/// pinned case: `0`×17 + `402`×9). -/// - **UNTEX** — batches with no texture *and* no runtime slot that fills one: neither a character -/// composite slot ([`benilla_formats::RenderSubmesh::char_slot`]) nor a creature skin variation -/// ([`benilla_formats::RenderSubmesh::skin_slot`], filled at spawn from `CreatureDisplayInfo`). -/// Both fills are ordinary, so counting them would drown the signal — 324 of 420 `Creature\` -/// models carry a skin slot. What is left is geometry nothing can texture. -/// - **TINY** — batches of at most 2 faces: the literal single-triangle/quad primitives. -/// -/// A model is listed when it trips any of the three. `m2batch` then explains one model in full. +/// List the models whose batches trip any of three signals, read through the renderer's own +/// batch resolution: `MULTI-GEOSET`, more than one `skinSectionId` (the reference builds every +/// geoset visible, `0x70ebd0`, and only the character compositor hides any); `UNTEX`, no texture +/// and no character or creature-skin slot to fill one; `TINY`, at most two faces. pub fn geosetscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut hits) = (0u32, 0u32); let (mut multi_models, mut untex_models, mut tiny_models) = (0u32, 0u32, 0u32); - // Top-level directory → multi-geoset model count, so the report says *where* the population - // lives (Creature/, Spells/, World/…) rather than only how big it is. + // Top-level directory → multi-geoset model count. let mut by_dir: BTreeMap = BTreeMap::new(); for name in names { let Ok(bytes) = chain.read_file(&name) else { @@ -462,26 +399,17 @@ pub fn geosetscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (under `prefix`, if given) and report models that author flat ground-plane -/// render geometry — the population instrument for the class of spell effects that lie in the -/// model-space XY plane at z≈0 (WoW axes, Z up) and get buried by sloped terrain (Battle Shout's -/// crescents are the canonical case: 6 batches, each a 4-vert quad, every vertex exactly z=0, each -/// quad skinned 100% to a single bone). Per batch (same batch/geoset resolution `m2batch` uses): -/// FLAT if every vertex has `|z| <= 0.01` in model space; flat batches sub-classify QUAD-1BONE -/// (the crescent shape — [`benilla_formats::RenderSubmesh::ground_quad`], the ground-fx decal -/// lane's own detector) vs OTHER-FLAT (flat but not that shape, staying on the ordinary render -/// path) — which decides how general the renderer mechanism has to be. +/// List the models with flat batches, every vertex at model-space `|z| <= 0.01`: effects that lie +/// on the ground, which sloped terrain buries (Battle Shout's six single-bone quads). A flat batch +/// is `QUAD-1BONE` when [`benilla_formats::RenderSubmesh::ground_quad`], the decal lane's +/// detector, takes it, else `OTHER-FLAT`. pub fn groundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut hits, mut all_flat, mut mixed) = (0u32, 0u32, 0u32, 0u32); let (mut quad_total, mut other_total) = (0u32, 0u32); - // The hover census: every ground-quad-SHAPED batch whose uniform plane sits ABOVE z = 0 - // (any height — the shape test is the renderer's own, the ceiling is lifted), the population - // that decides where the decal lane's hover ceiling (`GROUND_HOVER_MAX`) can sit. + // Quad-shaped batches whose plane sits above z = 0, uncapped: where `GROUND_HOVER_MAX` can sit. let mut hovers: Vec<(f32, String)> = Vec::new(); - // The TINT census: ground quads whose whole colour is a CONSTANT M2Color - // (`GroundQuad::tint`) — the population a decal consumer draws white unless it carries the - // vertex-colour bake across (the Flare's two washes on the neutral `GENERICGLOW*` radials). + // Ground quads with a constant M2Color tint (`GroundQuad::tint`), which a decal must carry. let (mut tinted_total, mut tinted_models) = (0u32, 0u32); for name in names { let Ok(bytes) = chain.read_file(&name) else { @@ -515,9 +443,6 @@ pub fn groundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { if !blend_modes.contains(&bm) { blend_modes.push(bm); } - // The RENDERER's own detector, so this report is exactly what the ground-fx - // decal lane will do with each batch — the instrument can't drift from the - // mechanism it measures. if let Some(q) = s.ground_quad() { quad_count += 1; if q.tint != [1.0; 3] && !tints.contains(&q.tint) { @@ -589,24 +514,10 @@ pub fn groundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (under `prefix`, if given) and measure how far its authored header bounding -/// box — the model's **all-animation** vertex extent, and the box the reference derives its doodad -/// cull sphere from — reaches past its **bind-pose** vertex extent. -/// -/// The population instrument for decision 1259. A placed model's submesh entity keeps its transform -/// at the placement origin while the joint palette moves its vertices, so a bind-pose mesh bound -/// stops describing what is drawn the moment the model animates: cull with it and the object blinks -/// out while its geometry is still on screen. `SLACK` is how many yards the authored box reaches -/// past the bind-pose box on its worst face — for the ambient critters (birds, bats, butterflies, -/// wasps) that is tens of yards against a sub-yard body, which is the whole bug. -/// -/// `SHORT` is the same measure with the signs reversed: yards of bind-pose geometry sticking out of -/// the *authored* box. It is not zero in the shipped corpus, which is why the fix unions the two -/// boxes instead of swapping one for the other — the reference never trips over it because it tests -/// the box's circumsphere, which swallows the overhang. -/// -/// `ANIM` marks the models the widened bound actually applies to: any bone track with more than one -/// key, or any global-sequence bone channel. A static model keeps its tighter per-batch bound. +/// Rank the animated models by how far the header box, the all-animation extent the reference +/// builds its doodad cull sphere from, reaches past the bind-pose vertex extent (`SLACK`, yards on +/// the worst face): a bind-pose bound culls such a model while its geometry is on screen. `SHORT` +/// is bind-pose geometry outside the header box, which the reference's circumsphere swallows. pub fn animboundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; // (slack, short, animates, bind-pose half-diagonal, name) @@ -650,8 +561,7 @@ pub fn animboundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let half_diag = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2)).sqrt() * 0.5; - // "Animates" the way `doodad_anim::classify` means it: geometry that MOVES relative to the - // placement transform — a keyed bone track, or a free-running global-sequence channel. + // Animates as `doodad_anim::classify` means it: a keyed bone track or a global sequence. let keyed = benilla_formats::parse_m2_animations(&bytes) .iter() .any(|a| { @@ -681,9 +591,7 @@ pub fn animboundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { "{:>9} {:>8} {:>9} {:>4} MODEL", "SLACK", "SHORT", "BIND-R", "ANIM" ); - // The class the bug is about: the authored box dwarfs the body, so the animation carries the - // model clean out of any bind-pose bound. Ranked by that ratio, not by raw slack — a 200 yd - // waterfall with 200 yd of slack is never culled either way. + // Ranked by slack over body size: a large model with large slack is never culled either way. let mut ranked: Vec<&(f32, f32, bool, f32, String)> = rows.iter().filter(|r| r.2 && r.0 > 1.0).collect(); ranked.sort_by(|a, b| (b.0 / b.3.max(0.05)).total_cmp(&(a.0 / a.3.max(0.05)))); @@ -696,18 +604,10 @@ pub fn animboundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// `normalscan` — census the batches carrying **degenerate authored vertex normals** (`(0,0,0)`). -/// -/// The shipped corpus authors them, and the reference draws those surfaces lit: its `Model2.bls` -/// vertex program consumes the normal as the zero vector, so the order-2 SH quadratic form -/// collapses to its DC term. A renderer that -/// `normalize()`s the same datum gets NaN, `clamp(NaN, 0, 1)` floors the lighting factor to 0, and -/// the batch renders **pure black over its correct texture** — bug B134's Qiraji Brainwasher -/// sleeves and Ironaya skirt, and the reason the shader's normalize is guarded (decision 1268). -/// -/// This is the population instrument for that class: how many models are on it, how much of each -/// batch is degenerate, and which models are worst hit. `ALL` marks a batch with no usable normal -/// at all — the one that renders as a solid black shape rather than a shaded gradient. +/// Census the batches with degenerate `(0,0,0)` authored normals. The reference lights them: its +/// `Model2.bls` vertex program takes the zero normal as is, collapsing the order-2 SH form to its +/// DC term, where a `normalize()` gives NaN and draws the batch black. `ALL` marks a batch with no +/// usable normal. pub fn normalscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; // (degenerate verts, total verts, batches touched, all-degenerate batches, model) diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs index 17a21534..46268083 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs @@ -1,9 +1,4 @@ -//! Corpus scans over **what lights a model** — the population instruments for the lighting -//! lanes, on both sides of the WMO/M2 boundary. -//! -//! `darkpropscan` asks which placed WMO props the interior lane commits as literal black -//! (decision 0969's census), `m2lightscan` which M2s author dynamic light blocks at all, and -//! `shadeat` reads the terrain MCSH shadow bit that decides a doodad's sun gain. +//! Corpus scans over what lights a model: dark WMO props, M2 light blocks, terrain shadow. use std::collections::BTreeMap; @@ -12,23 +7,11 @@ use benilla_formats::{Chain, M2Light}; use crate::model_key; -/// Sweep every WMO **root** (under `prefix`, if given) and list the placed MODD props whose -/// INTERIOR lighting lane commits **literal black**. -/// -/// The interior lane's whole base light is the MODD entry's own baked colour field: ambient = -/// `cap96(colour)`, diffuse = `floor112(colour)` (`0x694e90` create → `0x6a77e0`). The floor leg -/// *raises* a dim colour to max 112 — but -/// it is a hue-preserving scale by `112/max`, so a colour of exactly `#000000` has nothing to raise -/// and both words come out zero. Such a prop is lit by nothing but its owning group's MOLR fixture -/// lights, and a group carrying none (or none within its authored attenuation disk) leaves it a -/// pure black silhouette. -/// -/// The colour field is zero on ~3% of shipped MODDs — the baker leaves it unbaked for props it -/// treats as exterior — so what decides the symptom is the **class of the groups that reference the -/// prop**, and EXTERIOR WINS (decision 0969: the reference's def is per (MODD, placement) and -/// `0x695aa0` makes the exterior bit absorbing). A prop any exterior group's MODR names is therefore -/// sky-lit and never listed here; the `RESCUED` tally counts them, because taking the *first* -/// referrer instead is exactly what drew Booty Bay's entrance arch as a black silhouette. +/// List the placed MODD props that the interior lane lights literal black. Its base light is the +/// MODD colour, ambient `cap96`, diffuse `floor112` (`0x694e90` → `0x6a77e0`), and the floor is a +/// hue-preserving scale by `112/max`, so `#000000` stays black and only the owning group's MOLR +/// lights reach the prop. Exterior wins: any exterior referrer makes the prop sky-lit +/// (`0x695aa0`); `RESCUED` counts those an interior group names first. pub fn darkpropscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let roots = super::wmo_roots(chain, prefix)?; @@ -44,7 +27,6 @@ pub fn darkpropscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { }; roots_scanned += 1; modds_total += root.doodads().len() as u32; - // Nothing to classify without a zero-colour MODD — skip the group reads entirely. if !root.doodads().iter().any(|d| d.color[..3] == [0, 0, 0]) { continue; } @@ -78,11 +60,10 @@ pub fn darkpropscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } zero_colour += 1; let Some(referrers) = refs.get(&(i as u16)) else { - continue; // ORPHAN: no group names it — the exterior default + continue; // no group names it: lit as exterior }; let owner = referrers[0]; - // EXTERIOR WINS over every interior referrer (decision 0969) — the MODD-colour lane is - // for props referenced by interior groups ONLY. + // Exterior wins over every interior referrer. if !referrers .iter() .all(|g| infos.get(*g as usize).is_some_and(|gi| gi.interior)) @@ -92,9 +73,8 @@ pub fn darkpropscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } continue; } - // The owning group's MOLR omni lights, gated by their own attenuation disk measured - // from the prop's origin (the spawn fold uses the loaded M2's bounds reference point; - // the origin is within a model radius of it, so this is the census approximation). + // The owning group's MOLR omni lights in range of the prop's origin; the spawn measures + // from the M2's bounds reference point, within a model radius of it. let in_range = light_refs .get(&owner) .map(|ls| { @@ -156,11 +136,10 @@ pub fn darkpropscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// How many rows of the closing colour tally print (the rest are counted, never silently dropped). +/// Rows of the closing colour tally that print; the rest are counted. const TALLY_ROWS: usize = 20; -/// Cheap warm/cool/neutral hue classification of a `diffuse_color`, used only to eyeball the -/// colour-tally section of `m2lightscan`'s summary — the warm-torch family vs anything unusual. +/// A rough warm, cool or neutral tag for a light colour in `m2lightscan`'s tally. fn hue_tag(r: f32, g: f32, b: f32) -> &'static str { if r >= g && r > b * 1.15 { "warm" @@ -171,9 +150,7 @@ fn hue_tag(r: f32, g: f32, b: f32) -> &'static str { } } -/// Per-family tally for `m2lightscan`'s summary: how many models in this content family carry -/// lights, how many `type==1` point lights they author in total, how many of those are dark -/// (`visibility_off`), and a handful of example paths. +/// One content family's row in `m2lightscan`'s summary; `dark` counts `visibility_off` lights. #[derive(Default)] struct FamilyStats { models: u32, @@ -182,23 +159,14 @@ struct FamilyStats { examples: Vec, } -/// Sweep every `.m2` (optionally under a path prefix) and report which models author M2 dynamic -/// LIGHT blocks — the population instrument for the mechanism (decision 0016, `0x718960`). Per -/// model (only models with ≥1 light, printed -/// sorted by path): its `type==1` point-light count vs directional (`type==0`, ambient-feed, not -/// a discrete GL light) count, then per POINT light: bone, model-space position, `diffuse_color × -/// diffuse_intensity` (raw colour, intensity, and the product), authored attenuation start/end, -/// and an `OFF` tag when [`M2Light::visibility_off`] — the one shape (a static `0` visibility -/// key) that keeps a light dark (`0x71646d`). The closing summary is the real deliverable: totals, -/// a breakdown by top-level content family ([`super::family_of`]) — benilla only spawns these -/// lights for ADT-placed doodads and WMO props today, so this answers how much of the entity path -/// (creatures, held items, GameObjects) is actually missing them — and a cheap diffuse -/// colour×intensity tally ([`hue_tag`]). +/// List the models that author M2 light blocks (`0x718960`): per point light (`type` 1) its bone, +/// position, diffuse colour times intensity and attenuation, `OFF` when a static 0 visibility key +/// keeps it dark (`0x71646d`); directional lights (`type` 0) feed ambient and are only counted. +/// The summary totals them by content family and by colour. pub fn m2lightscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; - // Rounded `(r, g, b) × 100` (int-keyed to stay orderable) — a cheap grouping key for the - // authored diffuse×intensity palette across point lights. + // Diffuse times intensity in hundredths, as integers so the key orders. type ColorKey = (i32, i32, i32); let (mut scanned, mut hits, mut total_point, mut total_dark) = (0u32, 0u32, 0u32, 0u32); @@ -305,7 +273,6 @@ pub fn m2lightscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let tag = hue_tag(r, g, b); println!("{count:>4}x ({r:.2}, {g:.2}, {b:.2}) {tag:<5} e.g. {example}"); } - // Never let the top-20 read as "that's all of them". if let Some(rest) = ranked.len().checked_sub(TALLY_ROWS).filter(|n| *n > 0) { println!(" … and {rest} rarer colours (top {TALLY_ROWS} shown)"); } @@ -313,10 +280,9 @@ pub fn m2lightscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// The terrain MCSH shadow bit at a world position + an ASCII texel neighborhood (`#` shadowed, -/// `.` lit, `?` off-tile/no-chunk). One MCSH texel is `TILE_SIZE/1024` ≈ 0.52 yd; the grid spans -/// ±8 texels so a doodad base sitting one texel from a shadow edge — the 2.5-vs-0.5 intensity -/// cliff — is visible at a glance. +/// Print the terrain MCSH shadow bit, which sets a doodad's sun gain (2.5 lit, 0.5 shadowed), at +/// a world position and the ±8 texels around it (`#` shadowed, `.` lit, `?` no chunk); a texel is +/// `TILE_SIZE / 1024`, about 0.52 yd. pub fn shadeat(chain: &mut Chain, map: &str, x: f32, y: f32) -> Result<()> { let tiles = benilla_formats::load_tiles_around(chain, map, x, y, 0) .with_context(|| format!("loading the tile under ({x}, {y}) on {map}"))?; diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/material.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/material.rs index 38d4a30d..af913e50 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/material.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/material.rs @@ -1,30 +1,13 @@ -//! Corpus scans over **how a batch is textured and blended** — the material side of a model's -//! render submeshes. -//! -//! Blend modes (`blendscan`), per-sequence batch visibility through the alpha combine -//! (`alphascan`), sampler address modes and the UVs that need them (`uvwrapscan`, `texmodescan`), -//! generated-texcoord environment stages (`envmapscan`), and the batches whose animated -//! texture-transform / tint loop is not the same in every sequence slot, which the bake routes to -//! a per-placement material (`uvslotscan`). -//! -//! `fxuvscan` is the intersection of the first two, asked of one corpus: the spell-effect models, -//! whose animated texture transform decides — through their CLAMP-authored UVs — whether a -//! consumer that runs none of it loses the batch's motion or its whole existence. It sized -//! decision 2282, which built the consumer; it stays the way that question is asked at all — and -//! `entityuvscan` is that same question asked of the unit / GameObject / held-item corpus, which -//! sized decision 2295. Both lanes run their transform today; what the two scans are for is -//! keeping "this lane needs no channel" a countable claim rather than an assumed one. They share -//! their whole per-batch reader. +//! Corpus scans over how a batch is textured and blended, and what the effect and entity corpora's +//! animated texture transforms and tints render for a consumer that runs none of them. use std::collections::BTreeMap; use anyhow::{Context, Result}; use benilla_formats::{Chain, KeyAnim, SeqLoops}; -/// Sweep every `.m2` (under `prefix`, if given) and list the models whose MATERIAL table authors -/// blend mode 5 (Mod) / 6 (Mod2x) — the multiply-blend census (decision 0528). One line per -/// matching model: its per-material `(flags, blend)` pairs and path. The raw header read (materials -/// count/ofs at `0x84`, 4-byte `{u16 flags, u16 blend}` records) matches `benilla-m2`'s parse. +/// List the models whose material table authors blend mode 5 (Mod) or 6 (Mod2x): materials at +/// `0x84`/`0x88`, 4-byte `{u16 flags, u16 blend}` records. pub fn blendscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut hits) = (0u32, 0u32); @@ -58,17 +41,8 @@ pub fn blendscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (under `prefix`, if given) and census the models whose batch visibility is -/// **per sequence** — geometry the reference draws in one animation and skips in another, via the -/// alpha combine (`A = colourAlpha × weight`, `A ≤ 0` culls; `0x707680`). -/// -/// This is the population instrument for the class of bug where a client bakes the material tracks -/// once and draws the result forever: every model listed here has at least one batch whose authored -/// visibility CHANGES between sequences, so a single-sequence bake is guaranteed to be wrong for it -/// in some animation. Per model it reports how many batches are **hidden in the model's first -/// sequence** (what a doodad-shaped bake would show) versus hidden in *some* sequence, so the two -/// failure directions — drawing geometry that should be hidden, and hiding geometry that should -/// draw — are separated. `m2alpha` then explains one model in full. +/// List the models whose batch visibility changes between sequences through the alpha combine +/// (`A = colourAlpha × weight`, `A ≤ 0` culls; `0x707680`), which a single-sequence bake misses. pub fn alphascan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut hits) = (0u32, 0u32); @@ -87,8 +61,7 @@ pub fn alphascan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { if seq_count < 2 { continue; // a one-sequence model can't disagree with itself } - // A batch is "hidden in slot s" when its combined factor is 0 across that whole band. The - // sampling grid is coarse on purpose — a batch that so much as flickers non-zero is drawn. + // Hidden in slot s: the combined factor is 0 at every sample; any flicker above 0 draws. let hidden_in = |sub: &benilla_formats::RenderSubmesh, slot: usize| -> bool { sub.alpha_anim.as_ref().is_some_and(|a| { (0..=16u16).all(|k| a.sample(Some(slot), f32::from(k) * 0.25, 0.0) <= 0.0) @@ -122,7 +95,6 @@ pub fn alphascan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { *by_dir.entry(top.to_ascii_lowercase()).or_default() += 1; rows.push((name, varies, first, any)); } - // Loudest first: the models where the most geometry changes hands between sequences. rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); println!( "model varies hid@seq0 hid@any" @@ -141,19 +113,9 @@ pub fn alphascan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (optionally under a path prefix) and list the batches whose texture is -/// authored **CLAMP** (`M2Texture.flags` bit 0/1 clear) while the batch's own UVs run **outside -/// `0..1`** — the exact population a repeat-sampling renderer draws wrong (decision 0763, B52/B96). -/// -/// The margin outside `0..1` is deliberate authoring: clamped, it samples the texture's transparent -/// border and the card fades out to nothing. Sampled with repeat it wraps into the opposite edge — -/// on a cutout sheet, the opaque middle — so the margin draws as solid geometry with a hard seam -/// where u or v crosses the wrap. That is why a snow-fir grows pale plates with a crease down each -/// bough, and why the artefact never looked like an extra primitive: it is the *same* card, -/// sampling the wrong texels. -/// -/// `over` is how far past the edge the batch reaches, in UV units — the width of the wrongly-drawn -/// margin as a fraction of the sheet. +/// List the clamp-authored batches (`M2Texture.flags` bit 0 or 1 clear) whose UVs leave `0..1`: +/// clamped, the margin samples the transparent border, where repeat draws the opposite edge with a +/// seam. `over` is how far past the edge the batch reaches, in UV units. pub fn uvwrapscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut batches, mut hits, mut models) = (0u32, 0u32, 0u32, 0u32); @@ -179,9 +141,7 @@ pub fn uvwrapscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { }) }; let (u, v) = (ext(0), ext(1)); - // Only an axis authored CLAMP can be drawn wrong by repeat; a wrapping axis is meant - // to tile. A hair of float slop past the edge is not a margin — require 1/512 of a - // sheet, well under the thinnest authored border and well over rounding. + // Only a clamp axis draws wrong under repeat; under 1/512 of a sheet is float slop. const SLOP: f32 = 1.0 / 512.0; let bad_u = !s.wrap_x && (u.0 < -SLOP || u.1 > 1.0 + SLOP); let bad_v = !s.wrap_y && (v.0 < -SLOP || v.1 > 1.0 + SLOP); @@ -234,13 +194,8 @@ pub fn uvwrapscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` and report, per texture path, which sampler ADDRESS MODES the corpus asks of -/// it — and how many paths are asked for **more than one** (decision 0763). -/// -/// The design question behind it: the address mode lives on the GPU sampler, which in our asset -/// layer is a property of the loaded `Image`, which is keyed by path. If a `.blp` is only ever -/// asked for one mode, path-keying stays correct and the mode can simply ride the load. Every path -/// asked for two needs two uploads, or one of its users renders wrong. +/// List the texture paths the corpus asks for in more than one address mode: the mode rides the +/// loaded `Image`, keyed by path, so such a path needs one upload per mode. pub fn texmodescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; // texture path -> set of (wrap_x, wrap_y) asked for, as a 4-bit mask @@ -285,36 +240,19 @@ pub fn texmodescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (optionally under a path prefix) and census the batches whose texture -/// coordinates are **GENERATED, not authored** — the sphere-map environment stages -/// (`texture_unit_lookup[texCoordSet] > 2`, the reference's gate at `0x70b8bd`). -/// -/// The population instrument for a silent class: such a batch carries no usable UVs *by design* — -/// the artist collapses the whole mesh onto one point because the runtime is meant to supply the -/// coordinates — so a renderer that reads the vertex UV draws the entire surface in **one texel** -/// of a reflection sheet. Nothing about that failure is loud: no missing geometry, no error, just a -/// flat wash of whatever colour happens to sit at that corner (`GnomeSubwayGlass.m2` → the Deeprun -/// Tram tube's yellow, `AKGNOMEREFLECT.BLP` texel 0,0 = 225,221,142, doubled by its Mod2x blend). -/// -/// **DEGENERATE** marks the batches where the authored UVs collapse to a single point — the ones -/// that render as a flat colour field. The rest carry leftover UVs that merely go unused, so they -/// misdraw as a static smear of the sheet instead: wrong, but not obviously so. Both are fixed by -/// the same mechanism; the split says which reports a renderer's env support explains. +/// List the batches whose texture coordinates are generated, not authored: the sphere-map +/// environment stages (`texture_unit_lookup[texCoordSet] > 2`, gated at `0x70b8bd`). A +/// `DEGENERATE` batch's authored UVs collapse to one point, so reading them draws one flat texel +/// (`GnomeSubwayGlass.m2`: `AKGNOMEREFLECT.BLP` texel 0,0, doubled by Mod2x). pub fn envmapscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut batches, mut hits, mut models, mut degenerate) = (0u32, 0u32, 0u32, 0u32, 0u32); - // Which blend modes and which sheets the mechanism actually serves — a Mod2x env layer tints - // what is behind it (glass), an Add one lays a highlight over the surface (the metal sheen). + // The blend modes and sheets env stages use: Mod2x tints what is behind (glass), Add a sheen. let mut by_blend: BTreeMap = BTreeMap::new(); let mut by_sheet: BTreeMap = BTreeMap::new(); - // **The fallback census.** `stage_is_env_mapped` reads an OUT-OF-RANGE `texture_unit_lookup` - // index as env — the reference's own unguarded read, and the only safe direction. But that - // branch is the one way the gate can *invent* env-mapping on a model whose art never asked for - // it, so it is counted separately: a hit is trustworthy exactly when it came from a real - // `>= 3` entry. `empty_table` is the degenerate shape of the same thing (no table at all ⇒ - // every batch falls through), broken out because it would tar a whole model rather than a - // stage. + // `stage_is_env_mapped` reads an out-of-range `texture_unit_lookup` index as env, as the + // reference's unguarded read does; those hits, and models with no table at all, count apart. let (mut from_oob, mut empty_table) = (0u32, 0u32); for name in names { let Ok(bytes) = chain.read_file(&name) else { @@ -349,8 +287,6 @@ pub fn envmapscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { *by_blend.entry(format!("{:?}", s.blend)).or_default() += 1; let sheet = s.texture.as_deref().unwrap_or("NONE").to_string(); *by_sheet.entry(sheet.clone()).or_default() += 1; - // Does the authored UV set collapse to a point? Then the vertex data cannot even - // approximate the sheet and the batch renders as one flat colour. let span = |axis: usize| { s.uvs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), t| { (lo.min(t[axis]), hi.max(t[axis])) @@ -407,15 +343,13 @@ pub fn envmapscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// The two per-batch channels the bake resolves **per file sequence slot** — the pair -/// `models::m2_batches` hands the runtime as `uv_seq` / `rgb_seq`. Both are `C3Vector` tracks, so -/// one classifier serves both; only how many components survive the bake differs. +/// The two per-batch channels the bake resolves per file sequence slot (`uv_seq`, `rgb_seq`), +/// both `C3Vector` tracks, so one classifier serves both. #[derive(Clone, Copy, PartialEq, Eq)] enum Channel { - /// The texture transform's **translation** track (`tex_anim::bake_uv_seqs`), read off - /// `RenderSubmesh::uv_seq` and its shared-lane twin `uv_anim`. + /// The texture transform's translation (`tex_anim::bake_uv_seqs`): `uv_seq`, or `uv_anim`. Uv, - /// The M2Color **RGB** tint track (`mat_anim::bake_rgb_seqs`), read off `rgb_seq` / `rgb_anim`. + /// The M2Color RGB tint (`mat_anim::bake_rgb_seqs`): `rgb_seq`, or `rgb_anim`. Rgb, } @@ -434,7 +368,6 @@ impl Channel { } } - /// The field name the report quotes, so a reader can grep the struct the numbers came from. fn field(self) -> &'static str { match self { Self::Uv => "uv_seq", @@ -442,9 +375,6 @@ impl Channel { } } - /// Axis names for the value-range cells — one per component the bake keeps. The UV pair stays - /// `x`/`y`: the bake carries the track's raw components and how the reference maps them onto - /// U/V is still under RE (`tex_anim`'s module doc). fn axes(self) -> &'static [&'static str] { match self { Self::Uv => &["x", "y"], @@ -453,36 +383,22 @@ impl Channel { } } -/// How far apart two baked loops may be and still be the **same authored function**. Tight on -/// purpose: this is the noise floor of a `u32` millisecond key rebased through an `f32` divide, not -/// an authoring tolerance — a disagreement anyone could see is orders of magnitude larger. +/// How far apart two baked loops may be and still be one authored loop: the noise of a `u32` ms +/// key rebased through an `f32` divide. const KEY_EPS: f32 = 1e-4; -/// Why one batch's slots disagree — the sub-classification of every batch the bake routed to the -/// per-placement lane, i.e. of every set whose [`SeqLoops::uniform`] came back `None`. -/// -/// The question these four answer is whether that verdict is **earned**. `uniform()` compares -/// `KeyAnim` by `PartialEq` — exact float equality on the clock flags, the period and every key — -/// so two slots holding the same authored loop can still be split by a single flag or by -/// sub-epsilon noise in a rebased timestamp. `Dead0` and `RealDiffers` are real divergence, where -/// one material per placement is the only right answer; `WrapOnly` and `KeysEpsilon` are batches a -/// coarser comparison would have left on the shared lane, and their count IS the over-application. +/// Why a batch's slots disagree, for every set [`SeqLoops::uniform`] refused. `uniform()` is exact +/// float equality, so `WrapOnly` and `KeysEpsilon` are sets a coarser test would share; `Dead0` +/// and `RealDiffers` need a material per placement. #[derive(Clone, Copy, PartialEq, Eq)] enum Why { - /// Slot 0 bakes nothing while a later slot bakes a loop — the B98 shape, the founding case, and - /// the only class a slot-0-pinned bake could not render at all. + /// Slot 0 bakes nothing while a later slot bakes a loop. Dead0, - /// Every slot bakes the same loop except [`KeyAnim::wrap`]: the sequences carrying it disagree - /// on their own loop flag, so one band clamps at its tail where another wraps. Visible only at - /// the very end of a one-shot band — and never at all on a placed doodad, which loops. + /// The slots differ only in [`KeyAnim::wrap`]: one band clamps at its tail where another wraps. WrapOnly, - /// Every slot agrees to within [`KEY_EPS`] on every number and on every flag — the slots differ - /// only by float noise in the rebased key times, and exact `PartialEq` is the whole reason they - /// are here. + /// The slots agree within [`KEY_EPS`] on every number and flag: float noise only. KeysEpsilon, - /// Genuinely different loops: a different key count, a different interpolation or clock law, - /// numbers apart by more than noise — or slot 0 alive against a later slot that holds still, - /// DEAD-0's mirror. + /// Different loops (key count, clock law or values), or slot 0 alive beside a dead later slot. RealDiffers, } @@ -503,7 +419,6 @@ impl Why { } } - /// The one-line reading of the bucket, printed beside its counts. fn blurb(self) -> &'static str { match self { Self::Dead0 => { @@ -529,16 +444,13 @@ impl Why { /// One batch-channel's read of the bake. struct Verdict { why: Why, - /// The largest numeric disagreement between slot 0 and any other slot. `0.0` means the slots - /// are bit-identical — which, for a set the bake kept, means only a *flag* differs. + /// The largest disagreement between slot 0 and another slot; 0 means only a flag differs. delta: f32, - /// The report line's detail cell. detail: String, } -/// The largest absolute disagreement between two baked loops over everything a consumer reads: the -/// period, and every key's time and value. `None` when they are not the same **shape** at all — a -/// different key count or a different interpolation/clock law is a different function, not noise. +/// The largest difference between two baked loops in period, key times and values; `None` when +/// their key count or clock law differs. fn loop_delta>(a: &KeyAnim, b: &KeyAnim) -> Option { if a.step != b.step || a.gseq != b.gseq || a.keys.len() != b.keys.len() { return None; @@ -553,9 +465,7 @@ fn loop_delta>(a: &KeyAnim, b: &KeyAnim) -> Option { Some(d) } -/// `[.L]` — one character per **file** sequence slot: `L` where the slot bakes a loop, `.` where -/// its key window moves nothing. Slot 0 is leftmost, so a leading `.` reads as the B98 shape at a -/// glance. Long tables (a creature's fifty-odd sequences) are truncated to a readable head. +/// `[.L]`, one character per file sequence slot from slot 0: `L` bakes a loop, `.` moves nothing. fn slot_cell(slots: &[Option>]) -> String { const HEAD: usize = 28; let bits: String = slots @@ -570,8 +480,7 @@ fn slot_cell(slots: &[Option>]) -> String { } } -/// `[Wc.]` — the per-slot clock law, the other half of what `PartialEq` compares: `W` wrap, `c` -/// clamp, `.` dead. The whole content of a WRAP-ONLY row. +/// `[Wc.]`, each slot's clock law: `W` wrap, `c` clamp, `.` dead. fn wrap_cell(slots: &[Option>]) -> String { slots .iter() @@ -583,8 +492,7 @@ fn wrap_cell(slots: &[Option>]) -> String { .collect::() } -/// `1.500s wrap 28k x[+0.000..+0.938] y[…]` — one baked loop as its consumer sees it: the clock, -/// the size, and the range it sweeps on every component the bake keeps. +/// One baked loop as `1.500s wrap 28k x[+0.000..+0.938] y[…]`: period, clock, keys, ranges. fn loop_cell>(l: &KeyAnim, ch: Channel) -> String { let range = ch .axes() @@ -610,13 +518,11 @@ fn loop_cell>(l: &KeyAnim, ch: Channel) -> String { ) } -/// Read one batch-channel's per-slot set exactly as the runtime does — [`SeqLoops::slots`], the -/// same values [`SeqLoops::uniform`] compared — and say why they disagree. +/// Why one batch-channel's slots disagree, read through [`SeqLoops::slots`] as the runtime does. fn classify + PartialEq>(set: &SeqLoops, ch: Channel) -> Verdict { let slots = set.slots(); let cell = slot_cell(slots); - // A set exists only when SOME slot bakes, so a dead slot 0 is by construction a dead slot 0 - // beside a live later one — no second test needed. + // A set exists only when some slot bakes, so a dead slot 0 has a live later one. let Some(base) = slots.first().and_then(Option::as_ref) else { let (s, live) = slots .iter() @@ -638,9 +544,7 @@ fn classify + PartialEq>(set: &SeqLoops, ch: Channel) -> Verd detail: format!("{cell} slot 0 {} vs slot {s} dead", loop_cell(base, ch),), }; }; - // Name WHAT diverges, not merely that something did: a value-range cell hides a timing - // difference (same extremes, different key times) behind identical-looking brackets, and - // that is exactly the ambiguity this scan exists to remove. + // Name what diverges: a value range hides a timing difference behind identical brackets. let reason = match loop_delta(base, l) { Some(d) if d <= KEY_EPS => { delta = delta.max(d); @@ -676,50 +580,36 @@ fn classify + PartialEq>(set: &SeqLoops, ch: Channel) -> Verd } } -/// Per-channel corpus counters. Model counts are per bucket and count a model once if **any** of -/// its batches lands there, so they overlap rather than partition. +/// Per-channel corpus counters; a model counts once in each bucket any of its batches lands in. #[derive(Default)] struct Tally { - /// The set is `None`: every slot bakes the same loop, so the batch keeps the shared lane. + /// Batches whose slots all bake the same loop, which keep the shared lane. shared_b: u32, - /// …and of those, the ones that actually animate there (`uv_anim`/`rgb_anim` with `period > 0`). + /// Of those, the ones that animate there (`period > 0`). shared_live_b: u32, shared_live_m: u32, - /// The set is `Some` — batches and models routed to the per-placement lane, by [`Why`]. + /// Batches and models on the per-placement lane, by [`Why`]. per_b: [u32; 4], per_m: [u32; 4], - /// Models with at least one per-placement batch on this channel, whatever the reason. per_m_any: u32, - /// WRAP-ONLY batches whose slots are **bit-identical** apart from the flag; the rest carry - /// sub-epsilon key noise on top of it. + /// WRAP-ONLY batches bit-identical apart from the flag. wrap_exact: u32, } -/// One model's per-placement batches in one (channel, bucket) — the row the family census and the -/// worst-hit tail are both built from. +/// One model's per-placement batches in one (channel, bucket). struct Hit { name: String, ch: Channel, why: Why, batches: u32, - /// The model's **file** sequence slot count, straight off the set the bake built. slots: usize, } -/// How many rows of the worst-hit tail print (the rest are counted, never silently dropped). const TAIL_ROWS: usize = 25; -/// Sweep every `.m2` (optionally under a path prefix) and census the batches the bake routes to a -/// **per-placement material** because their UV / tint loop is not the same in every file sequence -/// slot — see the `Uvslotscan` command doc for the why. -/// -/// It reads the bake's own verdict (`RenderSubmesh::uv_seq` / `rgb_seq`, `Some` exactly when -/// [`SeqLoops::uniform`] refused the shared lane) rather than re-deriving it: this scan used to -/// carry a transcribed sampler because the fix it was sizing did not exist yet, and once it did the -/// twin drifted from it — the very failure `idleslotscan`'s rule names ("beside the parse, so the -/// census asks the renderer's own question instead of a hand-copied twin that can drift from it"). -/// Every number below is therefore what the runtime sees, and the four PER-PLACEMENT buckets say -/// how much of it `uniform()`'s exact float equality is *earning*. +/// Census the batches the bake gives a per-placement material because their UV or tint loop +/// differs between file sequence slots, read off the bake's own verdict (`uv_seq`/`rgb_seq`, +/// `Some` when [`SeqLoops::uniform`] refused the shared lane), with the `Why` of each. pub fn uvslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut batches) = (0u32, 0u64); @@ -737,9 +627,7 @@ pub fn uvslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { scanned += 1; batches += subs.len() as u64; let mut lines: Vec = Vec::new(); - // This model's own contribution, folded into the corpus tally once per channel below: - // (shared, shared-and-live) batch counts, per-bucket batch counts, and the file sequence - // slot count — which only a batch carrying a set can report, because the set IS the table. + // This model's counts per channel: (shared, shared and live), per bucket, its slot count. let mut shared = [(0u32, 0u32); 2]; let mut per = [[0u32; 4]; 2]; let mut slots = [0usize; 2]; @@ -855,9 +743,6 @@ pub fn uvslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { t.wrap_exact ); } - // Where each bucket's population LIVES, and which models carry most of it. `World\` is a - // placed doodad or WMO prop — the only lane the per-placement material reaches — so a - // bucket that is mostly `Creature\`/`Spells\` costs nothing today whatever it says. for w in Why::ALL { let rows: Vec<&Hit> = hits .iter() @@ -894,50 +779,31 @@ pub fn uvslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } // --------------------------------------------------------------------------- -// fxuvscan — what the SPELL-EFFECT corpus's animated texture transforms render -// for a consumer that runs none of them. -// -// Everything down to `uv_batch` is shared with `entityuvscan` below, which asks -// the identical question of the corpus 2282's fix did NOT reach. The sheet -// decode, the two frozen states, the per-axis texel reasoning and the five -// classes live here once on purpose: two sweeps that classified the same batch -// by two slightly different rules would be worse than one sweep. +// fxuvscan, and the per-batch reader down to `uv_batch` that `entityuvscan` shares // --------------------------------------------------------------------------- -/// How many spells print per INVISIBLE model before the rest are counted. const FX_SPELL_ROWS: usize = 12; -/// How many unreadable model paths print before the rest are counted. const FX_UNREAD_ROWS: usize = 20; -/// How many time samples the animated sweep takes across each slot's loop — enough to bound a -/// scroll that only crosses the sheet for part of its period (the reported box is the union, so -/// it only ever over-states what the animation reaches, never under-states it). +/// Time samples per slot's loop in the animated sweep; the reported box is their union. const FX_ANIM_SAMPLES: usize = 64; -/// One texture's mip-0 channels, as the visibility question needs them. -/// -/// Alpha decides it for the modes that read alpha — `Blend`/`AlphaTest`, and M2 mode 4 `Add` -/// (`SRC_ALPHA/ONE`), which is most of the effect corpus. An `Opaque` batch paints whatever it -/// covers and a `Mod`/`Mod2x` batch's equation reads no alpha at all (decision 0528), so neither -/// is called from the sheet. The one mode that needs [`Self::rgb`] is **3, `NoAlphaAdd`** -/// (`ONE/ONE`): it adds the texel's colour with the alpha channel untouched, so an alpha-0 border -/// with non-zero RGB still puts light on the screen. `BloodSpurtSmall01`'s border column is -/// exactly that shape — alpha 0, RGB up to 107 — so the distinction is not hypothetical. +/// One texture's mip-0 alpha, plus [`Self::rgb`] for blend 3 `NoAlphaAdd` (`ONE/ONE`), which adds +/// colour whatever the alpha (`BloodSpurtSmall01`'s border: alpha 0, RGB up to 107); `Opaque`, +/// `Mod` and `Mod2x` batches are not judged from the sheet. struct Sheet { w: u32, h: u32, /// Row-major, one byte per texel. alpha: Vec, - /// Row-major `max(r, g, b)` — what an `ONE/ONE` add would put on the screen. + /// Row-major `max(r, g, b)`. rgb: Vec, } impl Sheet { - /// The inclusive texel index ranges one axis's UV span `[lo, hi]` reaches under the batch's own - /// address mode — clamped to the edge texel (`wrap == false`, the border-sampling case this - /// whole scan turns on) or folded back into `0..1`. A wrapping span of a full sheet or more - /// reaches every texel. + /// The inclusive texel ranges a UV span `[lo, hi]` reaches: clamped to the edge texel, or + /// folded into `0..1` when `wrap`. fn axis(lo: f32, hi: f32, n: u32, wrap: bool) -> Vec<(u32, u32)> { let last = n.saturating_sub(1); let texel = |t: f32| ((t * n as f32) as i64).clamp(0, i64::from(last)) as u32; @@ -956,13 +822,8 @@ impl Sheet { } } - /// The most this sheet can PAINT anywhere in the UV box `u × v` — the greatest alpha, or, when - /// `with_rgb` (a batch that may be `NoAlphaAdd`), the greater of alpha and `max(r, g, b)`. - /// `0` exactly when every texel the box reaches contributes nothing. - /// - /// The box is the batch's UV **bounding box**, which is a superset of the texels its triangles - /// actually cover, so a `0` here is conservative in the one direction that matters: it cannot - /// call a batch invisible that in fact paints. + /// The most the sheet paints in the UV box `u × v`: its greatest alpha, and with `with_rgb` + /// its greatest `max(r, g, b)`. The box bounds the triangles, so a 0 is never a false one. fn paint( &self, u: (f32, f32), @@ -993,8 +854,8 @@ impl Sheet { } } -/// Decode one texture's mip-0 alpha channel, memoised by lowercased path. `None` for a path the -/// chain has no BLP for, or one that will not decode — reported as `UNKNOWN`, never a silent pass. +/// One texture's [`Sheet`], memoised by lowercased path; `None`, reported `UNKNOWN`, if it will +/// not load. fn fx_sheet<'a>( cache: &'a mut BTreeMap>, chain: &mut Chain, @@ -1020,8 +881,7 @@ fn fx_sheet<'a>( .as_ref() } -/// One effect model's place in the spell-visual chain: the `SpellVisualEffectName` rows that name -/// it, and every spell that reaches it with the lifecycle stages it arrives through. +/// An effect model's `SpellVisualEffectName` rows and the spells that reach it, by stage. #[derive(Default)] struct FxReach { /// The table's own spelling of the path (`.mdx`), for display. @@ -1031,9 +891,8 @@ struct FxReach { spells: BTreeMap)>, } -/// Fold one `SpellVisualEffectName` id's model into the corpus, and — when `spell` is given — note -/// that that spell reaches it through that lifecycle stage. An id with no row or an empty path -/// (the table's absent-model convention) folds to nothing, which is the client's own no-op. +/// Fold one `SpellVisualEffectName` id's model into the corpus, noting `spell`'s stage when given; +/// an id with no row or path adds nothing. fn fx_record( out: &mut BTreeMap, visuals: &benilla_formats::SpellVisualCatalog, @@ -1043,11 +902,8 @@ fn fx_record( let Some(path) = visuals.effect_path(effect) else { return; }; - // One shipped row spells its path with a LEADING separator - // (`\\spells\\HellFire_FirePuff_Caster_Base.mdx`, `SpellVisualEffectName` 1180). The chain has - // the model under the same name without it, so the key drops leading separators while the - // DISPLAY path keeps the table's own spelling — the quirk stays visible instead of being - // silently smoothed away. + // `SpellVisualEffectName` 1180 spells its path with a leading separator; the chain holds it + // without one, so the key drops it and the display path keeps it. let e = out .entry(crate::model_key(path.trim_start_matches(['\\', '/']))) .or_default(); @@ -1064,15 +920,9 @@ fn fx_record( } } -/// Every model the spell-visual chain can reach, keyed by [`crate::model_key`] — walked from the -/// **tables**, not from the spells, so a kit no shipped spell names is still swept (it is still -/// content the lane can be asked to play); the spell join is the second pass over the same map. -/// -/// The reachable set is every kit's ten effect slots (`VisualKit::effects` — the nine bone-attach -/// slots plus the world/state plant), every `SpellVisual` row's missile model (field 7) and its -/// dest-anchored model (field 12, the DynamicObject's own `.mdx`). The stage labels are the five -/// lifecycle kit columns plus `missile` and `area`, the latter covering both dest-anchored columns -/// (field 12's model and field 13's area kit) — one lane, one name. +/// Every model the spell-visual tables reach, keyed by [`crate::model_key`], then joined to the +/// spells: every kit's ten effect slots, and each `SpellVisual` row's missile model (field 7) and +/// dest-anchored model (field 12). Stage `area` covers field 12 and the area kit (field 13). fn fx_corpus( visuals: &benilla_formats::SpellVisualCatalog, spells: &benilla_formats::SpellCatalog, @@ -1125,24 +975,18 @@ fn fx_corpus( out } -/// What a consumer that runs NO texture-transform animation renders for one batch whose transform -/// the bake DID key. +/// What a consumer that runs no texture-transform animation renders for a keyed batch. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum FxClass { - /// The transform moves, and frozen the batch samples no painted texel while the animation does: - /// the batch renders **nothing at all**. The `SwipeCaster` class. + /// Frozen, the batch samples no painted texel while the animation does: it draws nothing. Invisible, - /// The transform moves, and the batch paints nothing at any point of the loop — so the missing - /// UV animation is not what is wrong with it. Flagged, never counted as INVISIBLE. + /// It paints nothing at any point of the loop, so the frozen transform is not its fault. Never, - /// The transform moves and the batch draws — statically, where the scroll is the motion. + /// It draws, but statically. Frozen, - /// The transform is keyed but never moves within its band: a constant NON-identity offset the - /// bake carries as a `period == 0` hold. A lane that seeds no offset draws the batch at the - /// identity instead — a static mis-registration, not a missing scroll. + /// A constant non-identity offset (`period == 0`), which a lane seeding none draws misplaced. Held, - /// No call from the sheet: a `Mod`/`Mod2x` batch (its blend equation reads no alpha) or a - /// texture the chain will not hand over. Flagged. + /// No call from the sheet: a `Mod`/`Mod2x` batch, or a texture that will not load. Unknown, } @@ -1187,9 +1031,8 @@ impl FxClass { } } -/// The batch's texture-transform state at `(slot, t)` — the triple [`benilla_formats::uv_transform`] -/// composes, read the way the one lane that RUNS it reads it (`ui_models::UvPart::write_rows`): the -/// per-slot set when the bake built one, else the single shared loop. +/// The texture-transform triple [`benilla_formats::uv_transform`] composes at `(slot, t)`: the +/// per-slot set when the bake built one, else the shared loop. fn fx_state( sub: &benilla_formats::RenderSubmesh, slot: usize, @@ -1213,8 +1056,7 @@ fn fx_state( (trans, rot, scale) } -/// How many file sequence slots this batch's transform was baked across (1 when only the shared -/// single-slot loop exists), and the longest loop period in slot `slot`. +/// How many file sequence slots the batch's transform was baked across, at least 1. fn fx_slots(sub: &benilla_formats::RenderSubmesh) -> usize { let n = |v: Option| v.unwrap_or(0); n(sub.uv_seq.as_ref().map(|s| s.slots().len())) @@ -1223,8 +1065,7 @@ fn fx_slots(sub: &benilla_formats::RenderSubmesh) -> usize { .max(1) } -/// The longest period any of the batch's three transform channels loops on in slot `slot`; -/// `0.0` when every one of them is a constant hold there. +/// The longest transform period in slot `slot`; 0 when every channel holds. fn fx_period(sub: &benilla_formats::RenderSubmesh, slot: usize) -> f32 { let t = match (&sub.uv_seq, &sub.uv_anim) { (Some(s), _) => s.seq(Some(slot)).map_or(0.0, |l| l.period), @@ -1244,8 +1085,7 @@ fn fx_period(sub: &benilla_formats::RenderSubmesh, slot: usize) -> f32 { t.max(r).max(s) } -/// The batch's UV bounding box carried through [`benilla_formats::uv_transform`] — all four -/// corners, so a rotation or scale is bounded rather than assumed away. +/// The UV bounding box through [`benilla_formats::uv_transform`], all four corners. fn fx_box( u: (f32, f32), v: (f32, f32), @@ -1264,9 +1104,7 @@ fn fx_box( out } -/// Does this batch's texture transform animate? The UI lane's own test, verbatim: any of the three -/// channels baked to something ([`benilla_formats::RenderSubmesh::uv_anim`] and the three -/// per-sequence sets). Shared, so the two sweeps cannot drift into asking it differently. +/// Whether the batch's texture transform is keyed: the shared loop or any per-sequence set. fn uv_keyed(sub: &benilla_formats::RenderSubmesh) -> bool { sub.uv_anim.is_some() || sub.uv_seq.is_some() @@ -1274,14 +1112,8 @@ fn uv_keyed(sub: &benilla_formats::RenderSubmesh) -> bool { || sub.uv_scale_seq.is_some() } -/// Does this model author M2 blend mode **3** (`NoAlphaAdd`, `ONE/ONE`) anywhere? -/// -/// The bake folds modes 3 and 4 into one `Blend` + `additive` pair, and no `RenderSubmesh` carries -/// the source material back (the billboard split means batches are not even 1:1 with skin batches), -/// so the question is asked of the model's MATERIAL TABLE — the same raw read `blendscan` does -/// (count/ofs at `0x84`, 4-byte `{u16 flags, u16 blend}`). A model with no mode-3 material has -/// alpha-gated additives and the alpha test is exact; one that has any gets the stricter -/// alpha-or-RGB test on its additive batches, which can only make INVISIBLE harder to claim. +/// Whether the material table (read as `blendscan` does) authors blend 3 `NoAlphaAdd`: the bake +/// folds modes 3 and 4 into `additive`, so such a model's additive batches are judged on RGB too. fn authors_no_alpha_add(bytes: &[u8]) -> bool { let at = |o: usize| -> Option { Some(u32::from_le_bytes(bytes.get(o..o + 4)?.try_into().ok()?)) @@ -1297,27 +1129,20 @@ fn authors_no_alpha_add(bytes: &[u8]) -> bool { } } -/// One animating batch's whole texture-transform read — the per-batch body [`fxuvscan`] and -/// [`entityuvscan`] share, so the two sweeps cannot answer the same question differently. -/// -/// The caller supplies the sheet path because the two corpora do not resolve it the same way: an -/// effect model's batch names its own `.blp`, while a CREATURE batch's `Monster1/2/3` slot is blank -/// in the M2 and filled per display from `CreatureDisplayInfo.textureVariation` — so the same batch -/// has as many sheets as the model has skins, and the visibility question has to be asked of each. +/// One keyed batch's texture-transform read, shared by [`fxuvscan`] and [`entityuvscan`]. The +/// caller supplies the sheet: a creature batch's `Monster1/2/3` slot is filled per display from +/// `CreatureDisplayInfo`, so it has one sheet per skin. struct UvBatch { - /// The verdict under the seed a `play_uv = false` lane renders at (the identity). + /// The verdict at the identity, the offset a material built with `play_uv = false` seeds. class: FxClass, - /// The verdict under the track's first key — what the same batch becomes on a lane that hands - /// `model_material` the loop but never ticks it. + /// The verdict at the first key, where a lane that seeds the loop and never ticks it sits. key_class: FxClass, - /// `uv_anim.sample(0.0)` — the translation seed, `[0, 0]` when the shared loop is absent. + /// `uv_anim.sample(0.0)`, `[0, 0]` without a shared loop. seed: [f32; 2], - /// The report block, in print order. lines: Vec, } -/// Read one batch's texture transform — see [`UvBatch`]. The caller has already established that -/// the transform is keyed ([`uv_keyed`]) and that the batch has UVs to transform. +/// Read one batch's texture transform; the caller has checked [`uv_keyed`] and that it has UVs. fn uv_batch( sheets: &mut BTreeMap>, chain: &mut Chain, @@ -1335,27 +1160,15 @@ fn uv_batch( let slots = fx_slots(sub); let moves = (0..slots).any(|s| fx_period(sub, s) > 0.0); - // THE TWO FROZEN STATES, because our codebase has two seeding conventions and they - // are not the same picture: - // - // - **identity** — the offset an effect part actually renders at. Its material is - // built by `model_render::batch::Materials::entity_variants`, whose `play_uv` is - // `false`, so `model_material` is handed no loop and seeds `sun_scale.zw = (0, 0)`: - // the batch draws its AUTHORED UVs, untransformed. This is the primary verdict. - // - **first key** — `uv_anim.sample(0.0)`, what the same builder seeds when a lane - // DOES hand it the loop (the doodad and off-world lanes). Reported beside it, - // because a fix that wires the loop in without ticking it lands the batch here. - // - // Rotation and scale are at the identity in both: the translation seed is the only - // transform channel a shared material carries at all (the affine pair is the UI - // lane's per-pane table row, decision 2019). + // Two frozen states: the identity, which a material built with `play_uv = false` seeds + // (`sun_scale.zw = (0, 0)`, the authored UVs), and the first key, which `model_material` + // seeds when handed the loop. Rotation and scale stay at the identity in both: a shared + // material carries only the translation seed. let seed = sub.uv_anim.as_ref().map_or([0.0, 0.0], |a| a.sample(0.0)); let (fu, fv) = fx_box(au, av, [0.0, 0.0], [0.0, 0.0, 0.0, 1.0], [1.0, 1.0]); let (ku, kv) = fx_box(au, av, seed, [0.0, 0.0, 0.0, 1.0], [1.0, 1.0]); - // ANIMATED REACH. The union over every slot and a uniform sweep of its loop — an - // over-approximation by construction, so "nothing painted here either" is a claim the - // geometry cannot contradict. + // The animated reach: the union over every slot's sampled loop, an over-approximation. let mut ru = (f32::MAX, f32::MIN); let mut rv = (f32::MAX, f32::MIN); for slot in 0..slots { @@ -1385,7 +1198,6 @@ fn uv_batch( ), None => (0, 0, 0, "—".to_string()), }; - // One rule, run twice — once per frozen state, so the two verdicts cannot drift apart. let verdict = |painted: u8| { if !moves { FxClass::Held @@ -1413,11 +1225,8 @@ fn uv_batch( n(sub.uv_scale_seq.is_some(), "S"), ) }; - // The per-axis reasoning, read off the TEXELS rather than off the span: a CLAMP axis - // whose frozen span runs past an edge samples that edge's texel for every bit of it - // past the edge, so a span like `[+0.951..+1.950]` over a 16-texel sheet is not - // "mostly outside" — it is column 15, alone, for its whole length. That collapse is the - // whole mechanism, so the line names it. + // Per axis, in texels: a clamp span past an edge samples the edge texel, so `[+0.951..+1.950]` + // over 16 texels is column 15 alone. let axis = |wrap: bool, a: (f32, f32), f: (f32, f32), n: u32| { let (texels, verdict) = match n { 0 => ("—".to_string(), "no sheet"), @@ -1503,21 +1312,18 @@ fn uv_batch( } } -/// One classified batch — the row the class listings and the spell join are both built from. struct FxHit { - /// The corpus key (lowercased `.m2`), so the reach map joins straight onto it. + /// The corpus key (lowercased `.m2`) the reach map joins on. key: String, batch: usize, - /// The verdict under the seed the effect lane actually renders at (the identity). + /// The verdict at the identity seed. class: FxClass, - /// The verdict under the track's first key — what the same batch becomes on a lane that hands - /// `model_material` the loop but never ticks it. + /// The verdict at the first-key seed. key_class: FxClass, } -/// Sweep every model the SPELL-VISUAL CHAIN can reach and census the batches whose **texture -/// transform animates** — then classify each by what a consumer that runs none of it renders. See -/// the `Fxuvscan` command doc for the why. +/// Classify every keyed texture-transform batch the spell-visual chain reaches by what a consumer +/// that runs none of it renders. pub fn fxuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let visuals = benilla_formats::load_spell_visual_catalog(chain)?; let spells = benilla_formats::load_spell_catalog(chain)?; @@ -1530,14 +1336,9 @@ pub fn fxuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let mut per_class: BTreeMap = BTreeMap::new(); let mut per_key_class: BTreeMap = BTreeMap::new(); let mut hits: Vec = Vec::new(); - // Every model the chain names that the archives do not carry, or will not parse — listed, - // never a silent hole in the sweep. let mut unread: Vec = Vec::new(); - // How many batches the two frozen states disagree about (see the classification below): the - // number that says whether "what does a no-UV-animation consumer render" has one answer here - // or two. let mut seed_disagrees = 0u32; - // Of the batches that DO draw frozen, how many draw at UVs the loop never opens at. + // Batches that draw frozen at UVs the loop never opens at. let mut misregistered = 0u32; for (key, reach) in &corpus { @@ -1577,8 +1378,6 @@ pub fn fxuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { if read.class != read.key_class { seed_disagrees += 1; } - // A batch that draws today but at a picture the loop never opens at: the identity is - // not the seed, so what is on screen is neither the animation nor its first frame. if read.class == FxClass::Frozen && (read.seed[0] != 0.0 || read.seed[1] != 0.0) { misregistered += 1; } @@ -1647,9 +1446,8 @@ pub fn fxuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { ); if !unread.is_empty() { println!(); - // Every one of these on the shipped 1.12 chain is a DEAD TABLE ROW, not a hole in the - // sweep: `SpellVisualEffectName` still carries the Warcraft-III-era `Particles\*.mdl` - // names the 1.12 archives never shipped. The extension split says so at a glance. + // On 1.12 each is a dead `SpellVisualEffectName` row, mostly `Particles\*.mdl` names the + // archives never shipped. let mdl = unread .iter() .filter(|u| u.to_ascii_lowercase().contains(".mdl ")) @@ -1720,9 +1518,7 @@ pub fn fxuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { c, ); } - // The batches the first-key seed alone condemns: they draw today, and would stop drawing on a - // lane that seeds the loop's opening value without running it. Named, because that is exactly - // the shape of a half-finished fix. + // Batches invisible only at the first-key seed, where a lane that never ticks the loop sits. let key_only: Vec<&FxHit> = hits .iter() .filter(|h| h.key_class == FxClass::Invisible && h.class != FxClass::Invisible) @@ -1750,9 +1546,7 @@ pub fn fxuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } } - // The deliverable: the distinct spells that reach an INVISIBLE batch at all, named in full — - // the set a player can cast and see nothing for. `+` marks a spell that is only reached under - // the first-key seed, never under the one the lane renders at today. + // The spells that reach an invisible batch; `+` marks one reached only at the first-key seed. let mut reached: BTreeMap, bool)> = BTreeMap::new(); for h in hits @@ -1789,59 +1583,46 @@ pub fn fxuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } // --------------------------------------------------------------------------- -// entityuvscan — what the UNIT / GAMEOBJECT / HELD-ITEM corpus's animated -// texture transforms render on the lane that runs none of them. +// entityuvscan: the unit, GameObject and held-item corpus // --------------------------------------------------------------------------- -/// How many display ids print per model before the rest are counted. const ENTITY_ID_ROWS: usize = 12; -/// How many unreadable model paths print before the rest are counted. const ENTITY_UNREAD_ROWS: usize = 20; -/// The `Item\ObjectComponents\\` folders an `ItemDisplayInfo` **left** model column can land -/// in, and the kind label each one carries. The directory is NOT a column of the table: it is -/// chosen by the slot the item is worn in (`entities::equipment::ensure_item_model`), which is a -/// property of the *item*, not of the display — so a census asks the archives which of them -/// actually holds the file. +/// The `Item\ObjectComponents\\` folders an `ItemDisplayInfo` left model can live in. The +/// table has no folder column (the worn slot picks it), so the census asks the archives. const ITEM_DIRS_L: [&str; 4] = ["Weapon", "Shield", "Shoulder", "Quiver"]; -/// …and of a **right** model column: the right pauldron, or an arrow/bullet. +/// The folders a right model can live in: the right pauldron, or an arrow or bullet. const ITEM_DIRS_R: [&str; 2] = ["Shoulder", "Ammo"]; -/// Helm files are per race and sex — `_.m2`, prefix by race id, `M`/`F` by sex -/// (`ensure_item_model`'s own table, pinned against the MPQ listing in decision 0074). +/// Helm files are per race and sex, `_.m2`: this prefix by race id, then `M` or `F`, +/// as `ensure_item_model` spells them. const HELM_RACE_PREFIX: [&str; 8] = ["Hu", "Or", "Dw", "Ni", "Sc", "Ta", "Gn", "Tr"]; -/// One entity-lane model's place in the DBCs: every table row that can put it on screen, so a -/// broken batch joins back to something a director can go and look at. +/// Every table row that can put an entity-lane model on screen. #[derive(Default)] struct EntityReach { /// The table's own spelling of the path (`.mdx`), for display. path: String, /// The `CreatureModelData` row ids naming it (normally one). creature_models: std::collections::BTreeSet, - /// The `CreatureDisplayInfo` rows that reach it — the POPULATION number: one model reached by - /// 300 display rows is a different fix from one reached by 1. + /// The `CreatureDisplayInfo` rows that reach it, the population measure. creature_displays: Vec, - /// The playable `(race, sex)` bodies among those displays (`ChrRaces` cols 4/5 → the same - /// CreatureDisplayInfo chain a streamed unit takes, decision 0041), spelled as the glue does. + /// The playable `(race, sex)` bodies among those displays (`ChrRaces` columns 4 and 5). player_bodies: Vec, - /// `GameObjectDisplayInfo` rows naming it. go_displays: Vec, /// `ItemDisplayInfo` rows naming it, and through which `Item\ObjectComponents\` join. item_displays: BTreeMap>, - /// The bone-pile skeletons (`DeathSkeleton`) — the corpse lane's own models, which - /// no display table names. + /// The corpse lane's bone piles (`DeathSkeleton`), which no display table names. bones: Vec, - /// Also named by the SPELL-VISUAL chain, i.e. already swept by `fxuvscan` and already running - /// its transform on the effect lane since 2282. Marked, never dropped: a model can be held in - /// a hand (this lane, frozen) and thrown as a missile (that lane, animated) on the same tick. + /// Also reached by the spell-visual chain, so also swept by `fxuvscan`. also_fx: bool, } impl EntityReach { - /// How many table rows can put this model on screen — the rough instance-population proxy. + /// Table rows that can put this model on screen, a rough population measure. fn rows(&self) -> usize { self.creature_displays.len() + self.go_displays.len() @@ -1908,26 +1689,21 @@ impl EntityReach { #[derive(Default)] struct EntityCorpus { models: BTreeMap, - /// `CreatureDisplayInfo` rows walked, and the `CreatureModelData` rows no display reaches - /// (unreachable content — counted, never swept). + /// `CreatureDisplayInfo` rows walked, and `CreatureModelData` rows no display reaches. creature_rows: usize, creature_orphan_models: usize, /// `GameObjectDisplayInfo` rows walked, and those naming a `.wmo` (no M2 batches to animate). go_rows: usize, go_wmo: usize, - /// `ItemDisplayInfo` model columns walked, and the two ways one can fail to reach a file: the - /// basename ships under an `Item\ObjectComponents\` folder this column is never joined to - /// (`item_other_dir` — every one of these is a thrown weapon named in BOTH columns, so the - /// model is already in the corpus through its left column and only the extra display-id join - /// is dropped), or nothing ships it at all (`item_absent` — a dead row, the shape `fxuvscan` - /// found in `SpellVisualEffectName`). + /// `ItemDisplayInfo` model columns walked; `item_other_dir` names a file under a folder its + /// column never joins (thrown weapons, swept through the left column), `item_absent` a file + /// nothing ships. item_rows: usize, item_other_dir: usize, item_absent: usize, } -/// Fold one model path into the corpus under [`crate::model_key`], keeping the table's own -/// spelling for display. +/// Fold one model path into the corpus under [`crate::model_key`], keeping the table's spelling. fn entity_record<'a>( out: &'a mut BTreeMap, path: &str, @@ -1941,15 +1717,9 @@ fn entity_record<'a>( e } -/// Every model the ENTITY lane can render, walked from the tables that name one — never from a -/// path prefix, because reachability is the whole question ("the path looks broken" and "the path -/// is reachable" are different questions, 2282's own closing note). -/// -/// Four sources, one per `build_parts` caller that is not the spell-effect lane: -/// `CreatureDisplayInfo` → `CreatureModelData` (every NPC, critter, mount — and every PLAYER body, -/// which resolves through the same chain, decision 0041), `GameObjectDisplayInfo`, -/// `ItemDisplayInfo`'s two model columns joined to the `Item\ObjectComponents\` folder the archives -/// actually hold them in, and the corpse lane's `DeathSkeleton` bone piles. +/// Every model the entity lane can render, walked from the tables: `CreatureDisplayInfo` → +/// `CreatureModelData` (player bodies included), `GameObjectDisplayInfo`, `ItemDisplayInfo`'s two +/// model columns joined to the folders that hold them, and the corpse lane's bone piles. fn entity_corpus(chain: &mut Chain) -> Result { let mut out = EntityCorpus::default(); @@ -2016,15 +1786,12 @@ fn entity_corpus(chain: &mut Chain) -> Result { } // --- held / worn item geometry ---------------------------------------------------------- - // The archives decide which folder a display's basename lives in, because the table does not - // carry one: the equipment lane picks the directory from the slot the item is worn in. let item_files: Vec = super::m2_names(chain, Some("item\\objectcomponents"))? .into_iter() .map(|n| n.to_ascii_lowercase()) .collect(); let files: std::collections::HashSet<&str> = item_files.iter().map(String::as_str).collect(); - // …and the same listing keyed by BASENAME, so an unjoinable column can say which of the two - // failures it is rather than being counted as one lump. + // The same listing by basename, to tell `item_other_dir` from `item_absent`. let by_base: std::collections::HashSet<&str> = item_files .iter() .filter_map(|f| f.rsplit_once('\\').map(|(_, b)| b)) @@ -2053,8 +1820,7 @@ fn entity_corpus(chain: &mut Chain) -> Result { placed = true; } } - // A HELM ships as sixteen per-race/sex files under one stem, and the display row names - // the stem alone — so the census expands it the way the attach does. + // A helm row names only the stem; the files are per race and sex. if col == 0 { let stem = name.strip_suffix(".m2").unwrap_or(name); for prefix in HELM_RACE_PREFIX { @@ -2078,9 +1844,7 @@ fn entity_corpus(chain: &mut Chain) -> Result { } } - // Every id list above is built by walking a HashMap, so it comes out in a different order - // every run. Sorted here, once, because an instrument whose report reshuffles cannot be - // diffed against its own previous output. + // The id lists come from HashMap walks: sort them so the report diffs run to run. for e in out.models.values_mut() { e.creature_displays.sort_unstable(); e.go_displays.sort_unstable(); @@ -2088,7 +1852,7 @@ fn entity_corpus(chain: &mut Chain) -> Result { e.bones.sort(); } - // --- the mark: which of these the spell-effect lane already runs ------------------------- + // --- the models the spell-visual chain also reaches ------------------------------------- if let (Ok(visuals), Ok(spells)) = ( benilla_formats::load_spell_visual_catalog(chain), benilla_formats::load_spell_catalog(chain), @@ -2102,22 +1866,18 @@ fn entity_corpus(chain: &mut Chain) -> Result { Ok(out) } -/// Which clock one batch's keyed transform runs on — the question that decides the SHAPE of the -/// fix, not just its size (0136 choice 1 versus 2282's per-instance lane). +/// Which clock a batch's keyed transform runs on. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum UvClock { - /// Every live loop is clocked on a **global sequence**: a free-running per-scene ms clock the - /// reference anchors once per instance at attach (`0x70eae1`, `CM2Model+0x68`). One - /// shared uniform on a free-running clock is faithful here — 0136's lane, unchanged. + /// Every live loop runs on a global sequence, a free-running clock the reference anchors once + /// per instance at attach (`0x70eae1`, `CM2Model+0x68`), so one shared uniform serves it. Gseq, - /// Every live loop is clocked on its **sequence band**: the host's own playing-clip time, so - /// two instances playing different animations — or the same one at different phases — are at - /// different offsets. A shared uniform cannot be right for both. + /// Every live loop runs on its sequence band, the host's play head, which no shared uniform + /// serves. Band, /// The batch's three channels disagree: some global, some band. Mixed, - /// Keyed but never moving — a constant non-identity hold (`period == 0`), which needs a seed - /// and no clock at all. + /// A constant hold (`period == 0`): a seed, no clock. Hold, } @@ -2154,20 +1914,20 @@ impl UvClock { /// One transform channel's read of the clock question. #[derive(Default)] struct ChannelClock { - /// `[L.L]` per file sequence slot — `None` when the channel carries no per-slot set. + /// `[L.L]` per file slot; `None` without a per-slot set. cell: Option, - /// File slots whose loop is LIVE (`period > 0`). + /// File slots whose loop is live (`period > 0`). live: Vec, /// `true` for each live loop clocked on a global sequence. gseq: Vec, /// The leading live loop as the report prints it: `(period, wrap, gseq)`. lead: Option<(f32, bool, bool)>, - /// The channel's one loop is the SHARED slot-0 bake — every sequence plays the same one. + /// The channel's one loop is the shared slot-0 bake. uniform: bool, } impl ChannelClock { - /// `gseq 2.000s wrap` / `band 1.500s clamp` / `—`. + /// `gseq 2.000s wrap`, `band 1.500s clamp`, `held`, or a dash. fn cell_text(&self) -> String { match self.lead { None if self.cell.is_none() => "—".to_string(), @@ -2181,8 +1941,7 @@ impl ChannelClock { } } -/// Read one channel's per-slot set (or its shared slot-0 twin, for the translation channel which is -/// the only one that has one) the way the runtime reads it. +/// Read one channel's per-slot set, or the translation channel's shared slot-0 loop. fn channel_clock + PartialEq>( set: Option<&SeqLoops>, shared: Option<&KeyAnim>, @@ -2213,7 +1972,6 @@ fn channel_clock + PartialEq>( out } -/// One classified entity batch — the row every listing and the population tail are built from. struct EntityHit { key: String, batch: usize, @@ -2222,22 +1980,18 @@ struct EntityHit { clock: UvClock, /// `T`/`R`/`S`, in that order, for the channels the bake keyed. chans: [bool; 3], - /// The batch's translation lane: `true` when it carries a per-sequence set (`uv_seq`), which is - /// `uvslotscan`'s PER-PLACEMENT population; `false` when it keeps the shared slot-0 loop. + /// The translation has a per-sequence set (`uv_seq`), `uvslotscan`'s per-placement population. per_seq_trans: bool, - /// …and whether that shared loop is LIVE — `uvslotscan`'s "carry a LIVE loop there" count, the - /// number this sweep's corpus must be a subset of. + /// The shared translation loop is live, `uvslotscan`'s live shared count. live_shared_trans: bool, } -/// Sweep every model the UNIT / GAMEOBJECT / HELD-ITEM tables reach and census the batches whose -/// texture transform animates — see the `Entityuvscan` command doc for the why. +/// Classify the keyed texture-transform and tint batches the unit, GameObject and item tables +/// reach. pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let corpus = entity_corpus(chain)?; let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); let anim_names = benilla_formats::load_anim_data_catalog(chain).ok(); - // Loaded once, beside the corpus, because the per-batch sheet question asks it per MODEL: a - // creature batch's `Monster1/2/3` slot is filled from the display's own texture variations. let creatures = benilla_formats::load_creature_catalog(chain).unwrap_or_default(); let mut sheets: BTreeMap> = BTreeMap::new(); @@ -2254,20 +2008,17 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let mut misregistered = 0u32; // The `uvslotscan` cross-check: this corpus's share of the two whole-corpus translation counts. let (mut live_shared, mut per_seq) = (0u32, 0u32); - // Batches whose sheet is filled at RUNTIME (a creature skin variation, a character composite), - // and those where two of a model's skins disagree about what a frozen batch paints. + // Batches whose sheet is filled at runtime (a creature skin, a character composite), and + // those where two of a model's skins disagree about what a frozen batch paints. let (mut skin_filled, mut skin_disagrees) = (0u32, 0u32); - // --- the TINT half of the same sweep (the second dead channel on this lane) --- + // --- the tint half of the same sweep --- let (mut tint_models, mut tint_batches) = (0u32, 0u32); let mut tint_class: BTreeMap = BTreeMap::new(); let mut tint_clock: BTreeMap = BTreeMap::new(); let mut tint_hits: Vec = Vec::new(); let mut tint_blocks: Vec<(String, Vec)> = Vec::new(); let (mut tint_live_shared, mut tint_per_seq) = (0u32, 0u32); - // …and the ALPHA channel beside them, which this lane DOES serve — counted so the report can - // say which of the three material-animation channels is a hole and which is not, rather than - // leaving the reader to assume all three are the same. let (mut alpha_models, mut alpha_batches, mut alpha_live) = (0u32, 0u32, 0u32); let keys: Vec = corpus.models.keys().cloned().collect(); @@ -2294,9 +2045,7 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { continue; }; batches += subs.len() as u64; - // Any of the three channels keeps the model: a batch can animate its tint and not its UVs - // (four do), and gating on `uv_keyed` alone is exactly the shape of predicate this sweep - // exists to catch. + // Any of the three channels keeps the model: a batch can animate its tint and not its UVs. if !subs .iter() .any(|sub| uv_keyed(sub) || tint_keyed(sub) || sub.alpha_anim.is_some()) @@ -2304,16 +2053,13 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { continue; } let no_alpha_add = authors_no_alpha_add(&bytes); - // File sequence slot -> its `AnimationData.dbc` id, so a BAND loop can be named by the - // animations that carry it rather than by an opaque slot number. A slot with no entry is a - // zero-duration sequence (`parse_m2_animations` drops those; the bake does not). + // File slot -> `AnimationData.dbc` id; a missing slot is a zero-duration sequence, which + // the parse drops and the bake keeps. let slot_anim: BTreeMap = benilla_formats::parse_m2_animations(&bytes) .iter() .map(|a| (a.seq_index, a.anim_id)) .collect(); - // The creature skins this model is rendered with — `Monster1/2/3` is blank in the M2 and - // filled per display from `CreatureDisplayInfo.textureVariation`, so a batch on one of - // those slots has as many sheets as the model has skins. + // The creature skins per `Monster1/2/3` slot, from each display's `textureVariation`. let mut skins: [std::collections::BTreeSet; 3] = Default::default(); for d in &corpus.models[&key].creature_displays { let Some(m) = creatures.model(*d) else { @@ -2326,8 +2072,7 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } } - // WHICH BANDS carry the keys — the load-bearing half for a band loop, because it says - // whether the loop is one animation's or every animation's. Shared by both channels. + // Which bands carry the live loops, named by animation. let slot_line = |c: &ChannelClock| -> String { match (&c.cell, c.uniform) { (Some(cell), _) => { @@ -2350,7 +2095,7 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } }; - // --- the TINT + ALPHA pass over the same batches ----------------------------------- + // --- the tint and alpha pass over the same batches --------------------------------- let mut tlines: Vec = Vec::new(); let mut model_has_alpha = false; for (bi, sub) in subs.iter().enumerate() { @@ -2428,9 +2173,7 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } anim_batches += 1; - // WHICH SHEET. An authored path where the batch carries one; otherwise the display's - // own skin variations, each asked separately — the frozen verdict is a property of the - // texture, and two skins of one model can answer differently. + // The sheet: the authored path, else each of the display's skins, which can differ. let candidates: Vec = match (&sub.texture, sub.skin_slot, sub.char_slot) { (Some(t), _, _) => vec![t.clone()], (None, Some(slot), _) => { @@ -2441,8 +2184,7 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { .unwrap_or_default() } (None, None, Some(_)) => { - // A runtime character composite (body atlas, object skin, hair): there is no - // authored sheet to judge, and UNKNOWN is what that has to read as. + // A runtime character composite has no sheet to judge: UNKNOWN. skin_filled += 1; Vec::new() } @@ -2463,7 +2205,6 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } let read = &reads[0].1; - // THE CLOCK, per channel, exactly as the runtime resolves it. let t = channel_clock(sub.uv_seq.as_ref(), sub.uv_anim.as_ref()); let r = channel_clock::<[f32; 4]>(sub.uv_rot_seq.as_ref(), None); let s = channel_clock::<[f32; 2]>(sub.uv_scale_seq.as_ref(), None); @@ -2537,8 +2278,7 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { if s.cell.is_some() { lines.push(format!(" slots S {}", slot_line(&s))); } - // …and, when the translation set exists, WHY the bake refused the shared lane — the - // `uniform()` question `uvslotscan` asks, asked here of the same set. + // Why the bake refused the shared lane, as `uvslotscan` reports it. if let Some(set) = sub.uv_seq.as_ref() { let v = classify(set, Channel::Uv); lines.push(format!(" uv_seq {:<12} {}", v.why.label(), v.detail)); @@ -2621,9 +2361,7 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { counts for the same two lanes (its `of those … carry a LIVE loop there` and \ `PER-PLACEMENT` lines) — this corpus is a subset of that one by construction." ); - // The two translation lanes NAMED, not just counted: `uvslotscan` prints the same two - // populations over the whole corpus, so listing the models here makes the subset claim - // checkable by eye instead of on trust. + // The two translation lanes by model, to check against `uvslotscan`'s listings. let mut shared_models: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); let mut per_seq_models: BTreeMap<&str, u32> = BTreeMap::new(); for h in &hits { @@ -2669,8 +2407,7 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } } - // Every model carrying a batch of class `c`, with the rows that reach it — the join back from - // a verdict to something a director can go and stand in front of. + // Every model with a batch of class `c`, and the table rows that reach it. let listing = |title: &str, note: &str, pick: fn(&EntityHit) -> FxClass, c: FxClass| { let rows: Vec<&EntityHit> = hits.iter().filter(|h| pick(h) == c).collect(); if rows.is_empty() { @@ -2710,9 +2447,7 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { c, ); } - // The batches the first-key seed alone condemns: they draw today, and would stop drawing on a - // lane that seeds the loop's opening value without running it. Named, because that is exactly - // the shape of a half-finished fix. + // Batches invisible only at the first-key seed, where a lane that never ticks the loop sits. let key_only: Vec<&EntityHit> = hits .iter() .filter(|h| h.key_class == FxClass::Invisible && h.class != FxClass::Invisible) @@ -2740,8 +2475,6 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } } - // The deliverable: every affected model, what it renders today, and how much of the world - // reaches it — sorted by POPULATION, because that is what sizes the fix. let mut models: BTreeMap<&str, (Vec<&EntityHit>, usize)> = BTreeMap::new(); for h in &hits { let e = models.entry(h.key.as_str()).or_default(); @@ -2783,8 +2516,7 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } // ===================================================================================== - // The M2COLOR TINT half. Same corpus, same walk, a different dead channel — and a - // different failure mode, because a tint is a multiply and a UV offset is a lookup. + // The M2Color tint half: a tint multiplies, where a UV offset looks up. // ===================================================================================== println!(); println!( @@ -2889,7 +2621,6 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { already." ); - // The join-back for the tint, by class — same shape as the UV listings. let tlisting = |title: &str, c: TintClass| { let rows: Vec<&TintHit> = tint_hits.iter().filter(|h| h.class == c).collect(); if rows.is_empty() { @@ -2930,7 +2661,6 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { tlisting(c.label(), c); } - // …and the population tail, sorted the same way the UV one is. let mut tmodels: BTreeMap<&str, (Vec<&TintHit>, usize)> = BTreeMap::new(); for h in &tint_hits { let e = tmodels.entry(h.key.as_str()).or_default(); @@ -2963,20 +2693,16 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } // --------------------------------------------------------------------------- -// The M2COLOR TINT half of the same census — the second dead channel on the -// same lane, found by asking where `register_tint` is called from. +// The M2Color tint half of the entity census // --------------------------------------------------------------------------- -/// Does this batch's M2Color tint animate? The runtime's own test: the shared slot-0 bake, or the -/// per-sequence set the bake keeps when the slots disagree. The twin of [`uv_keyed`], and written -/// as a union for the same reason — a predicate spelled `rgb_anim.is_some()` silently skips a batch -/// whose slot 0 is a dead hold beside a live later slot (the tint corpus has four of those). +/// Whether the batch's tint is keyed: the shared slot-0 loop, or the per-sequence set, which +/// alone carries a batch whose slot 0 is dead. fn tint_keyed(sub: &benilla_formats::RenderSubmesh) -> bool { sub.rgb_anim.is_some() || sub.rgb_seq.is_some() } -/// How many file sequence slots this batch's tint was baked across (1 when only the shared -/// single-slot loop exists) — [`fx_slots`]'s tint twin. +/// How many file sequence slots the tint was baked across, at least 1. fn tint_slots(sub: &benilla_formats::RenderSubmesh) -> usize { sub.rgb_seq.as_ref().map_or(1, |s| s.slots().len()).max(1) } @@ -2990,9 +2716,7 @@ fn tint_period(sub: &benilla_formats::RenderSubmesh, slot: usize) -> f32 { } } -/// The tint at `(slot, t)`, read the way a consumer that RAN this channel would read it — the -/// per-slot set when the bake built one, else the single shared loop ([`fx_state`]'s shape). -/// White where neither exists, which is the tint identity. +/// The tint at `(slot, t)`: the per-slot set, else the shared loop, else white. fn tint_state(sub: &benilla_formats::RenderSubmesh, slot: usize, t: f32) -> [f32; 3] { match (&sub.rgb_seq, &sub.rgb_anim) { (Some(s), _) => s.seq(Some(slot)).map_or([1.0; 3], |l| l.sample(t)), @@ -3001,33 +2725,22 @@ fn tint_state(sub: &benilla_formats::RenderSubmesh, slot: usize, t: f32) -> [f32 } } -/// Rec.709 relative luminance — a reading aid for "how far apart are these two tints", not a -/// perceptual model. The per-channel deltas beside it are the evidence; this is the single number -/// that sorts a hue swap from a brightness swing. +/// Rec.709 relative luminance, a reading aid beside the per-channel deltas. fn luma(c: [f32; 3]) -> f32 { 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2] } -/// What a consumer that seeds the tint and never re-samples it renders. -/// -/// A tint is a **multiply**, so the failure mode is never "invisible geometry" the way a UV freeze -/// can be — it is the wrong colour, and how wrong is the only question worth asking. The bands are -/// a reading aid over one measured number (the worst per-channel distance between the frozen value -/// and anything the loop reaches, in 0..1 tint units); every batch prints that number, so the call -/// is checkable and the thresholds are not load-bearing. +/// What a consumer that seeds the tint and never re-samples it renders, banded by the worst +/// per-channel distance between the seed and anything the loop reaches (0..1 tint units). #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum TintClass { - /// The frozen tint is (near) BLACK while the loop reaches lit values: the multiply kills the - /// batch's colour outright, so it draws black — or, on an additive batch, not at all. The one - /// band where "wrong colour" becomes "wrong existence". + /// The seed is near black while the loop lights up: it draws black, or nothing if additive. Black, - /// The frozen value is a quarter of the tint range or more away from what the loop reaches — - /// a colour difference nobody has to look for. + /// 0.25 or more away. Strong, /// Between 0.05 and 0.25 away: visible side by side, easy to miss alone. Slight, - /// Under 0.05 away — the loop barely moves, or it moves and comes back. Frozen is - /// unobjectionable, and counting it as a bug would overstate the hole. + /// Under 0.05 away: the loop barely moves. Negligible, } @@ -3060,38 +2773,28 @@ impl TintClass { } } -/// One batch's whole tint read. struct TintBatch { class: TintClass, - /// What `model_material` actually bakes into `tint.xyz`: `rgb_anim.sample(0.0)` — the SHARED - /// slot-0 loop's first key — or identity white when there is no slot-0 bake at all. + /// What `model_material` seeds into `tint.xyz`: the shared loop's first key, else white. seed: [f32; 3], - /// …and which of those two it was. A white seed is the tint twin of the UV lane's DEAD-0: the - /// batch's live tint lives in a later sequence slot the shared material cannot see. + /// The seed is white: there is no shared loop, and the tint lives in a later slot. seed_is_white: bool, /// The worst per-channel distance between the seed and anything the loop reaches. delta: f32, - /// This batch carries a per-sequence set (`rgb_seq`) — `uvslotscan`'s PER-PLACEMENT population. per_seq: bool, - /// …and the shared slot-0 loop is live (`period > 0`) — its SHARED-live population. live_shared: bool, lines: Vec, } -/// Read one batch's animated tint — see [`TintBatch`]. The caller has established [`tint_keyed`]. +/// Read one batch's keyed tint; the caller has checked [`tint_keyed`]. fn tint_batch(sub: &benilla_formats::RenderSubmesh, bi: usize) -> TintBatch { let slots = tint_slots(sub); - // THE SEED, read off the builder rather than assumed: `model_render::model_material` does - // `rgb_anim.map_or([1,1,1], |a| a.sample(0.0))` and writes it to `tint.xyz`, and - // `batch::Materials::build` hands it `sub.rgb_anim.as_ref()` UNCONDITIONALLY — there is no - // `play_rgb` gate to flip. So unlike the UV channel, which freezes at the identity, the tint - // freezes at the loop's OPENING COLOUR… except where slot 0 baked nothing, and then it freezes - // at white while every live slot tints. + // `model_material` seeds `tint.xyz` with `rgb_anim`'s first key, or white without one, and + // the builder always hands it the loop, so an unticked tint holds its opening colour. let seed = sub.rgb_anim.as_ref().map_or([1.0; 3], |a| a.sample(0.0)); let seed_is_white = sub.rgb_anim.is_none(); - // Everything the loop reaches, over every slot and a uniform sweep of each — the same - // over-approximating union `uv_batch` takes, for the same reason. + // Everything the loop reaches: the same sampled union `uv_batch` takes. let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]); let mut delta = 0.0f32; for slot in 0..slots { @@ -3156,20 +2859,15 @@ fn tint_batch(sub: &benilla_formats::RenderSubmesh, bi: usize) -> TintBatch { } } -/// One classified entity tint batch — the row the listings and the population tail are built from. struct TintHit { key: String, batch: usize, class: TintClass, clock: UvClock, - /// The colour the material is frozen at — printed in the listings, because "which wrong - /// colour" is the whole content of a tint bug. + /// The colour the material is seeded with. seed: [f32; 3], - /// **Is the freeze visible at REST?** True when file sequence slot 0 — the band a resting - /// instance plays — carries a live loop. False means the seed is the right colour in every - /// state but the ones a later slot keys, so the batch is wrong only *while that animation - /// plays*. Four of this corpus's five are that second shape, and reporting them as flatly - /// broken would overstate the hole. + /// Slot 0, the band a resting instance plays, carries a live loop; otherwise the seed holds + /// until a later slot's animation plays. rest_wrong: bool, seed_is_white: bool, delta: f32, diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs index 9cf28ccb..a102c5b9 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs @@ -1,24 +1,5 @@ -//! Corpus-scan reports: the **population instruments** that sweep many models or placements to -//! answer "how big is this class, and where does it live", rather than dumping one asset (that is -//! [`crate::m2dump`]'s job). Every one of them is reached as `scan::` from the dispatch in -//! [`crate::main`]; the submodules group them by the question they answer, not by the file format -//! they read: -//! -//! - [`world`] — placed content: a WMO root's own tables, an ADT block's placements. -//! - [`lighting`] — what lights a model: the WMO prop lanes, M2 light blocks, terrain shadow. -//! - [`geometry`] — what geometry a model draws: billboards, geosets, flat ground quads. -//! - [`material`] — how a batch is textured and blended: blend modes, UV wrap, env stages, -//! the batches whose UV/tint loop differs between sequence slots, and what the spell-effect -//! (`fxuvscan`) and unit/GameObject/held-item (`entityuvscan`) corpora's animated texture -//! transforms render for a consumer that runs none of them. -//! - [`particles`] — particle and ribbon emitters, and the features the corpus authors. -//! - [`skeleton`] — the bone tree and the attachment table that addresses it. -//! - [`sequence`] — which sequence an arm plays, what breaks when it is the wrong one, and -//! which of its event markers an arm can reach at all. -//! -//! Split out of a single 3.9k-line `scan.rs` once it had grown ~30 independent sweeps: the file -//! was one *kind* of thing, but not one concern, and nothing in it was shared across families -//! except the corpus listing below. +//! Corpus-scan reports: sweeps over many models or placements that measure how big a class is +//! and where it lives, grouped by the question they answer rather than the format they read. use anyhow::{Context, Result}; use benilla_formats::Chain; @@ -45,13 +26,8 @@ pub use sequence::{ pub use skeleton::{attachscan, bonescan, eventmarkerscan}; pub use world::{doodadscan, placescan, skyboxscan, wmodoodads}; -/// Every `.m2` in the chain, in listfile order, narrowed to a path `prefix` when one is given. -/// -/// The opening line of nearly every sweep here, and — before the split — twenty-two copy-pasted -/// copies of it. Names come back in their listfile casing (that is what a report prints and what -/// [`Chain::read_file`] is handed); the `.m2` test and the prefix match are both done on a -/// lowercased copy, and the prefix is normalized to the chain's own `\` separators, so a caller -/// may pass either slash in either case. +/// Every `.m2` in the chain in listfile order and casing, narrowed to a path `prefix` matched +/// case-insensitively with either slash. pub(crate) fn m2_names(chain: &mut Chain, prefix: Option<&str>) -> Result> { let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); Ok(chain @@ -66,16 +42,8 @@ pub(crate) fn m2_names(chain: &mut Chain, prefix: Option<&str>) -> Result_NNN.wmo`. Only the root carries -/// the tables a sweep wants (MOHD/MODS/MODD/MOGI/MOLT/MOSB); a group file handed to -/// [`benilla_formats::parse_wmo_root`] is just a parse failure to skip. Underscore then exactly -/// three digits is the whole test — and it is here, once, because the two sweeps that used to -/// spell it inline had drifted into spelling it *differently* (one guarded a stem shorter than -/// four characters, the other did not, so a hypothetical `123.wmo` was a root to one and a group -/// to the other). +/// Every WMO root in the chain, narrowed like [`m2_names`]. Only a root carries the +/// MOHD/MODS/MODD/MOGI/MOLT/MOSB tables; a group file is `_NNN.wmo`, exactly three digits. pub(crate) fn wmo_roots(chain: &mut Chain, prefix: Option<&str>) -> Result> { let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); Ok(chain @@ -96,9 +64,7 @@ pub(crate) fn wmo_roots(chain: &mut Chain, prefix: Option<&str>) -> Result` path component for consistent family-key display -/// regardless of how a given asset's listfile entry happened to be cased (`WEAPON`/`weapon`/ -/// `Weapon` all collapse to `Weapon`). +/// Title-case an `Item\ObjectComponents\` component so its listfile casings share one key. fn title_case(s: &str) -> String { let mut chars = s.chars(); match chars.next() { @@ -109,17 +75,8 @@ fn title_case(s: &str) -> String { } } -/// The top-level content-family bucket for an internal M2 path — the summary dimension for "how -/// much content is affected, and of what KIND", shared by [`lighting::m2lightscan`] and -/// [`material::uvslotscan`]. Derived from the path's first one or two components, -/// case-insensitively. -/// -/// The split that earns its keep is `World\` versus everything else: a `World\` model is a placed -/// doodad or WMO prop, which reaches the screen through the shared-material lanes (the M2-light -/// spawn, the `UvAnimMaterials`/`MatAnimTable` registries), while a `Creature\`/`Spells\` one -/// reaches an entirely different consumer. `World\Goober\` (GameObject displays) splits off again -/// because it is the hosted lane, not the ADT/WMO one; everything not otherwise named folds into -/// `other`. +/// The content family of an M2 path, a sweep's summary dimension: `World\` is placed doodads and +/// WMO props, `World\Goober\` GameObject displays. pub(super) fn family_of(name: &str) -> String { let comps: Vec<&str> = name.split('\\').collect(); let low = |s: &str| s.to_ascii_lowercase(); diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/particles.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/particles.rs index f50fae13..9d0709aa 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/particles.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/particles.rs @@ -1,9 +1,5 @@ -//! Corpus scans over **particle and ribbon emitters** — which of the emitter system's features -//! the shipped data actually authors, and which shapes a consumer can get wrong. -//! -//! The feature census (`partcensus`), the flipbook ramps (`cellscan`), the per-slot emission -//! windows (`partslotscan`), the 3-D geometry-model shards (`shardcensus`), and the draw-order -//! population effects share with their owner's own transparent batches (`fxordercensus`). +//! Corpus scans over particle and ribbon emitters: the features the data authors, and the shapes +//! a consumer can get wrong. use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; @@ -12,14 +8,9 @@ use benilla_formats::Chain; use crate::model_key; -/// Sweep every `.m2` in the chain and list the models carrying RIBBON emitters, with each -/// model's `+0xc0` **enable-gate** census beside the count — the population instrument for -/// "which trails are state-dependent, and where does the gate actually key". -/// -/// A `GATED` line names the sequences a trail is dark in; `MID-SEQ` marks the models whose gate -/// flips *inside* a band rather than only at its start, which a band-start-only reader cannot -/// express (decision 1011 — `G_FrostTrap`'s streamers light 200 ms into the trigger and nowhere -/// else, so a band-start read says "never" and an ungated consumer says "always"). +/// List the models with ribbon emitters and each ribbon's `+0xc0` enable gate per sequence; +/// `MID-SEQ` marks a gate that flips inside a band, not only at its start (`G_FrostTrap`'s +/// streamers light 200 ms into the trigger and nowhere else). pub fn ribbonscan(chain: &mut Chain) -> Result<()> { let names = super::m2_names(chain, None)?; let (mut scanned, mut hits, mut gated, mut mid_seq) = (0u32, 0u32, 0u32, 0u32); @@ -35,7 +26,6 @@ pub fn ribbonscan(chain: &mut Chain) -> Result<()> { } hits += 1; let defs = benilla_formats::parse_m2_ribbon_emitters(&bytes).unwrap_or_default(); - // Per ribbon: the sequences it can be dark in, and whether the gate keys mid-band. let mut notes: Vec = Vec::new(); let (mut any_gate, mut any_mid) = (false, false); for (i, d) in defs.iter().enumerate() { @@ -55,12 +45,8 @@ pub fn ribbonscan(chain: &mut Chain) -> Result<()> { per.sort(); notes.push(format!(" r{i} {}", per.join(" "))); } - // The BLEND census beside the gate one. A ribbon takes its blend from a real M2 material - // (`materialIndices[0]` → render-flags `blend`), so unlike a particle emitter it can in - // principle author any mode — and the two the parse folds (`Opaque`, covering file modes - // 0/1/5/6) render with no alpha test at all, which is exactly what painted the particle - // side's AlphaKey debris as solid squares. Tallied here so the fold stays a MEASURED - // approximation instead of an assumed-empty one. + // A ribbon's blend is its material's (`materialIndices[0]` → render flags), so unlike a + // particle's it can be any mode; the tally shows what the parse folds each raw mode to. for d in &defs { let raw = d.blend_mode.map_or("?".into(), |m| m.to_string()); *blends @@ -89,9 +75,7 @@ pub fn ribbonscan(chain: &mut Chain) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (under `prefix`, if given) and list the models whose particle emitters -/// carry any bit of `mask` in their file flags — see the `Partscan` command doc. One line per -/// matching emitter: its index, full flag word, shape/type, and the model path. +/// List the particle emitters whose file flags carry any bit of `mask`. pub fn partscan(chain: &mut Chain, mask: u32, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut hits, mut emitters) = (0u32, 0u32, 0u32); @@ -130,11 +114,9 @@ pub fn partscan(chain: &mut Chain, mask: u32, prefix: Option<&str>) -> Result<() Ok(()) } -/// The census's raw-extras view of one on-disk emitter record: the fields the shipped -/// [`benilla_formats::ParticleEmitterDef`] deliberately does not carry (yet). Read straight off -/// the record bytes (stride/header per the parser's module doc): the two model-filename M2Arrays -/// at `+0x18` (geometry model — 3-D "model particles") and `+0x20` (recursion model — per-particle -/// child emitters), and the emission-rate track's interpolation word (`+0xdc`, 0 = step). +/// Raw fields of one emitter record (stride `0x1f8`): the geometry-model (`+0x18`) and +/// recursion-model (`+0x20`) path `M2Array`s, and the emission-rate track's interpolation word +/// (`+0xdc`, 0 = step) and key count. struct RecordExtras { geometry_model: Option, recursion_model: Option, @@ -142,7 +124,6 @@ struct RecordExtras { rate_keys: u32, } -/// Read the raw extras for every emitter record in an M2 (empty if not an MD20 or no emitters). fn record_extras(bytes: &[u8]) -> Vec { const STRIDE: usize = 0x1f8; let u16_at = |o: usize| u16::from_le_bytes([bytes[o], bytes[o + 1]]); @@ -215,8 +196,7 @@ fn spell_attribution(chain: &mut Chain) -> HashMap> { map } -/// One census dimension's tally. `model_count` counts every distinct model exactly; -/// `models` keeps the first 64 (sorted) for the example listings. +/// One census dimension: `model_count` counts every model, `models` keeps the first 64, sorted. #[derive(Default)] struct Tally { emitters: u32, @@ -239,8 +219,7 @@ impl Tally { } } -/// Sweep every `.m2` (under `prefix`, if given) and census which particle-emitter FEATURES the -/// corpus actually authors — see the `Partcensus` command doc. +/// Census which particle-emitter features the corpus authors, with the spells behind rare ones. pub fn partcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let attribution = spell_attribution(chain); let names = super::m2_names(chain, prefix)?; @@ -270,12 +249,8 @@ pub fn partcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { benilla_formats::ParticleShape::Sphere => hit("shape:sphere"), benilla_formats::ParticleShape::Spline => hit("shape:SPLINE"), } - // ANISOTROPIC plane rectangles — the population on which the areaLength↔areaWidth axis - // pairing is observable at all (a SQUARE area renders identically either way). 0563 and - // 0566 both pre-named "a 90°-wrong ANISOTROPIC effect" as this lane's suspect, but no - // instrument could LIST that population, so the swapped pairing outlived both audits - // until Gressil's 0.1 × 1.1 blade smoke drew its curtain across the blade. Bucketed by - // aspect so a thin curtain (load-bearing) separates from a near-square (invisible). + // Anisotropic plane areas, the only ones where the areaLength/areaWidth axis pairing + // shows (a square renders the same either way), bucketed by aspect. let now = d.params.sample(None, 0.0, 0.0); if d.shape == benilla_formats::ParticleShape::Plane { let lo = now.area_length.abs().min(now.area_width.abs()); @@ -289,10 +264,8 @@ pub fn partcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } } } - // ANIMATED parameter channels (decision 0844): the population the value[0] flatten - // silently mis-rendered — Frost Nova's emission radius riding its ring out, Arcane - // Explosion's riding its dome. Tallied per channel so the census names WHICH knob - // actually moves in the corpus. + // Parameter channels that animate, per channel (Frost Nova's emission radius rides + // its ring out). const PARAM_KEYS: [&str; 9] = [ "param-anim:speed", "param-anim:speedVar", @@ -394,8 +367,8 @@ pub fn partcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } if x.rate_keys > 1 && x.rate_interp != 0 { hit("rate:LERP-RAMP(interp!=0)"); - // A BURST emitter with a lerp rate track would arm a near-zero count at its - // rising edge — if the corpus authored one, the burst edge law needs a re-look. + // A burst emitter with a lerped rate would arm a near-zero count at its + // rising edge. if d.burst() { hit("rate:BURST+LERP(suspect)"); } @@ -418,8 +391,6 @@ pub fn partcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { ); } - // The full model list — with spell attribution — for the dimensions that decide mechanism - // scope (the UPPERCASE keys: unimplemented or folded legs, plus the odd corners). let detail: &[&str] = &[ "MODEL-PARTICLES(geometry)", "CHILD-EMITTERS(recursion)", @@ -471,23 +442,11 @@ pub fn partcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` and census its particle emitters' over-life **flipbook** fields — the -/// population instrument behind decision 0685 (the reverse-playing cell ramp). -/// -/// One line per emitter that is interesting on any axis, then the totals. The axes are exactly the -/// ways a flipbook reader can be wrong, each of which shipped data does exercise: -/// -/// - `INVERTED` — a `begin > end` pair. Legal, and it means *play the sheet backwards*; a reader -/// that clamps into `[begin, end]` mangles it (and in Rust panics outright). -/// - `TAIL-RAMP` — the tail streak's own ramp differs from the head's, on an emitter that draws a -/// tail. The two are independently authored (file +0x168.. vs +0x174..); handing the head's cell -/// to the streak animates it through a sheet the author pinned to one cell. -/// - `PAST-ATLAS` — a cell index beyond `rows·cols`. The reference masks the COLUMN and leaves the -/// ROW unbounded, so the index wraps to row 0 rather than holding the last cell. -/// - `REPEAT` — a per-segment repeat count ≠ 1, i.e. the sheet cycles more than once per segment. -/// - `NON-POW2` / `MID` — the two shapes the reference itself degrades on (a 1×1 fallback, and a -/// `mid` of 0/1 that walks its own sampler into a NaN). Both are empty in 1.12.1 and are here so -/// that stays checkable. +/// Census the emitters' over-life flipbook fields: `INVERTED` (`begin > end`) plays the sheet +/// backwards, which a `clamp` into it panics on; `TAIL-RAMP`, a tail ramp (`+0x174`) unlike the +/// head's (`+0x168`); `PAST-ATLAS`, a cell past `rows·cols`, which the reference wraps to row 0 +/// (it masks the column only); `REPEAT`, a segment repeat other than 1; `NON-POW2` and `MID`, +/// shapes the reference degrades on (a 1×1 fallback, a NaN sampler), none in 1.12.1. pub fn cellscan(chain: &mut Chain) -> Result<()> { let names = super::m2_names(chain, None)?; @@ -524,7 +483,7 @@ pub fn cellscan(chain: &mut Chain) -> Result<()> { pair(&ol.head_cells[1]) ); } - // Only a tail-drawing emitter (particleType 1/2) can show a tail-ramp difference. + // Only a tail-drawing emitter (particleType 1 or 2) shows a tail ramp. if e.head_tail >= 1 && ol.tail_cells != ol.head_cells { tail_ramp += 1; println!( @@ -542,8 +501,7 @@ pub fn cellscan(chain: &mut Chain) -> Result<()> { let atlas = e.tile_rows * e.tile_cols; if ramps.iter().any(|r| r.begin >= atlas || r.end >= atlas) { past_atlas += 1; - // On a 1×1 sheet every index resolves to the same texture, so only a real atlas - // can show the wrap. + // On a 1×1 sheet every index is the same texture: only a real atlas shows the wrap. if atlas > 1 { past_atlas_real += 1; println!( @@ -576,19 +534,10 @@ pub fn cellscan(chain: &mut Chain) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (optionally under a path prefix) and list the emitters whose **file slot 0 is -/// dead while another slot is alive** — the shape that makes a pinned-slot-0 consumer silently -/// render nothing (decision 0760, found on `BlastedLandsLightningbolt01.m2`, B63). -/// -/// The reference samples the **playing** sequence's rate window every frame (`0x71850d`); a -/// consumer that pins slot 0 instead is only correct while -/// slot 0 carries the emitter's whole story. When an author keys the burst in a *later* variation — -/// a lightning strike that fires on 5 % of arms, an ambient prop with a rare flourish — slot 0 is a -/// flat zero and the pinned consumer emits nothing, for ever, on every placement. That is invisible -/// from the outside: the emitter is built, pooled, and ticking; it just never births a particle. -/// -/// `peak0` is slot 0's own peak rate, `peakN` the best any other slot reaches. A listed emitter has -/// `peak0 <= 0 < peakN`. `slots` counts FILE sequence slots (the axis `EmitTiming` bakes on). +/// List the emitters whose file slot 0 never emits while another slot does (`peak0 <= 0 < +/// peakN`, the peak rates). The reference samples the playing sequence's rate window every frame +/// (`0x71850d`), so a consumer pinned to slot 0 never births a particle from them. `slots` counts +/// file sequence slots, the axis `EmitTiming` bakes on. pub fn partslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut with_emitters, mut emitters, mut dead0) = (0u32, 0u32, 0u32, 0u32); @@ -654,25 +603,15 @@ pub fn partslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (optionally under a path prefix) and census the corpus's **3-D model -/// particles**: the emitters whose record carries a geometry-model reference (`+0x18` — a shard -/// instance renders that GEOMETRY model's own submeshes, not a billboard quad). Per OWNER model: -/// the owner-last draw-order rung its shards are stamped with at runtime -/// (`owner_last_rung(m2_owner_reach(owner submeshes))`); per referenced GEOMETRY model: the -/// material family tuples its render submeshes author — (blend, two_sided, additive, -/// no_depth_write, no_depth_test), the exact key the shard's material is built from. -/// -/// This is the **ground truth for the pipeline-warm menagerie's shard rows**: warming a pipeline -/// per (rung × family) cell is only worth its table if the corpus's cells are enumerable, and this -/// census is what enumerates them — the rung histogram sizes the rung axis, the family-tuple set -/// (split by transparent-pass membership, the renderer's own test) sizes the material axis, and -/// the unresolved-path count bounds what the table can never cover. +/// Census the 3-D model particles, emitters with a geometry model (`+0x18`) whose shards draw that +/// model's submeshes: per owner the draw-order rung its shards are stamped with +/// (`owner_last_rung(m2_owner_reach(..))`), per geometry model the material families its shards +/// are built from. The rung and family sets size the pipeline-warm table's shard rows. pub fn shardcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; - // A submesh's material family: the tuple the shard material is keyed by, rendered compactly - // (`Blend+2s+add`), plus its transparent-pass membership — the renderer's own test, where - // `additive` forces the pass whatever the authored blend says (`model_material`). + // A submesh's material family label and whether it draws in the transparent pass, which + // `additive` forces whatever the blend (`model_material`). let fam = |s: &benilla_formats::RenderSubmesh| { let transparent = s.additive || matches!( @@ -692,8 +631,7 @@ pub fn shardcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { (label, transparent) }; - // Each distinct geometry model is read, parsed, and tallied exactly once; the cache keeps its - // (family, transparent) list per submesh for the per-pair lines. `None` = unresolvable. + // Each geometry model is read and tallied once; `None` marks an unresolvable path. let mut geo_cache: BTreeMap>> = BTreeMap::new(); let mut fam_transparent: BTreeMap = BTreeMap::new(); let mut fam_opaque: BTreeMap = BTreeMap::new(); @@ -709,9 +647,8 @@ pub fn shardcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { if benilla_formats::parse_m2_particle_emitters(&bytes).is_err() { continue; } - // Distinct geometry references, normalized the way the chain resolves model paths - // (lowercase, `\` separators, `.mdx`/`.mdl` → `.m2` — `model_key` mirrors the crate's own - // `model_path`). + // Distinct geometry paths, normalized as the chain resolves them (`model_key` mirrors + // the crate's `model_path`). let geos: BTreeSet = record_extras(&bytes) .iter() .filter_map(|e| e.geometry_model.as_deref()) @@ -721,8 +658,7 @@ pub fn shardcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { continue; } owners += 1; - // The rung the owner's shards are stamped with: the renderer's own bound and rung, at - // placement scale 1 (a scaled placement multiplies the reach). + // The owner's rung at placement scale 1; a scaled placement multiplies the reach. let dir = name.rsplit_once('\\').map_or("", |(d, _)| d); let owner_subs = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]).unwrap_or_default(); @@ -810,19 +746,12 @@ pub fn shardcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (optionally under a path prefix) and count, per model, the two halves of the -/// **owner-last draw-order** law: the EFFECTS a model authors (particle emitters + ribbon trails) -/// and the TRANSPARENT-pass batches of its own body those effects must draw after (decisions -/// 0719/0721). A model with both is one the rung actually changes; a model with effects and no -/// transparent batch of its own never had the defect and is listed only in the totals. -/// -/// This is the population instrument the two decisions were argued from. "Does this fix anything -/// besides the voidwalker's eyes?" is not a question to answer by naming plausible creatures — -/// it is a count, and the count is what says whether the mechanism closes a class or a case. +/// Count per model the two halves of the owner-last draw order: the effects it authors (particle +/// emitters and ribbons) and its own transparent-pass batches they must draw after. Only a model +/// with both is changed by the rung, and only those are listed. pub fn fxordercensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; - // Per top-level content family (Creature / Item / Spells / World / …), so the totals say - // WHERE the class lives rather than only how big it is. + // Top-level directory → (models with effects, of those at risk). let mut family: BTreeMap = BTreeMap::new(); let (mut scanned, mut with_fx, mut at_risk) = (0u32, 0u32, 0u32); let mut rungs: BTreeMap = BTreeMap::new(); @@ -839,9 +768,8 @@ pub fn fxordercensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { continue; } with_fx += 1; - // The occluders: batches the renderer puts in the one distance-sorted transparent list. - // `additive` forces that pass whatever the authored blend says (`model_material`), so the - // test mirrors the renderer's rather than reading the blend word alone. + // Batches in the distance-sorted transparent pass, which `additive` forces whatever the + // blend (`model_material`). let dir = name.rsplit_once('\\').map_or("", |(d, _)| d); let subs = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]).unwrap_or_default(); let occluders: Vec<&benilla_formats::RenderSubmesh> = subs @@ -857,7 +785,6 @@ pub fn fxordercensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { }) .collect(); let transparent = occluders.len(); - // The renderer's own bound and the renderer's own rung — not a re-derivation of them. let reach = benilla_formats::m2_owner_reach(&subs); let fam = name.split('\\').next().unwrap_or("?").to_string(); family.entry(fam).or_default().0 += 1; @@ -869,7 +796,7 @@ pub fn fxordercensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { .entry(name.split('\\').next().unwrap_or("?").to_string()) .or_default() .1 += 1; - // At placement scale 1 — the survey number; a scaled placement multiplies the reach. + // At placement scale 1; a scaled placement multiplies the reach. let rung = benilla_formats::owner_last_rung(reach); *rungs.entry(rung as u32).or_default() += 1; println!( diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/sequence.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/sequence.rs index b7e87fe8..727c227b 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/sequence.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/sequence.rs @@ -1,13 +1,5 @@ -//! Corpus scans over **which sequence is playing** — the animation-arm side, where being on the -//! wrong sequence (or on none at all) silently mis-renders everything keyed to it. -//! -//! The GameObject substate arm (`goanimscan`), the effect-model Stand/Hold/Decay lifecycle -//! (`fxlifescan`), the loader-idle slot the per-sequence bakes degrade away from (`idleslotscan`), -//! and the models with no keyed bone at all, so no clock ever advances (`seqclockscan`). -//! -//! Plus the arm's *reach*: which models author a sound-event marker the rig gate never arms -//! (`soundeventscan`) — the same failure one level down, where the sequence is right and nothing -//! runs its clock. +//! Corpus scans over which sequence is playing: GameObject substates, the effect-model lifecycle, +//! the idle slot, clockless models, and the markers a sequence carries. use std::collections::{BTreeMap, HashSet}; @@ -16,10 +8,9 @@ use benilla_formats::Chain; use crate::model_key; -/// The GameObject animation arm's LUT (`.data 0x8607e4`): -/// internal **substate** → the `AnimationData.dbc` id the object layer arms. +/// The GameObject arm's LUT (`.data 0x8607e4`): internal substate → the `AnimationData.dbc` id. const SUBSTATE_ANIM: [u16; 13] = [ - 145, // 0 Spawn — NO client path produces this substate + 145, // 0 Spawn (one-shot on create, `0x5f382f` → `0x5f8c50`) 147, // 1 Closed (rest) 148, // 2 Open (motion) 149, // 3 Opened (rest) @@ -27,18 +18,16 @@ const SUBSTATE_ANIM: [u16; 13] = [ 150, // 5 Destroy (motion) 151, // 6 Destroyed (rest) 152, // 7 Rebuild (motion) - 153, 154, 155, 156, // 8..11 Custom0-3 — reachable only via SMSG_GAMEOBJECT_CUSTOM_ANIM + 153, 154, 155, 156, // 8..11 Custom0-3 (one-shots through `0x5f8c50`) 157, // 12 Despawn ]; -/// The **transient** (transition-motion) substates among the reachable ones — the rows slot 14 -/// `0x5f4120` advances off at the arm's window end (2 Open → 3 Opened, 4 Close → 1 Closed, -/// 5 Destroy → 6 Destroyed). Their duration is the object layer's, never the clip's loop bit. +/// The transition substates, which slot 14 (`0x5f4120`) advances at the arm's window end +/// (2 Open → 3 Opened, 4 Close → 1 Closed, 5 Destroy → 6 Destroyed), whatever the clip's loop bit. const MOTION_SUBSTATES: [usize; 3] = [2, 4, 5]; -/// The six substates a `GAMEOBJECT_STATE` × `GAMEOBJECT_ANIMPROGRESS` pair can actually produce -/// (`0x5f3c30`). Substate 0 (Spawn) has no producer at all, and 8..12 come from other opcodes -/// entirely. +/// The six substates a `GAMEOBJECT_STATE` × `GAMEOBJECT_ANIMPROGRESS` pair produces (`0x5f3c30`); +/// Spawn (0), Custom0-3 (8..11) and Despawn (12) come from the one-shot channel `0x5f8c50`. const REACHABLE: [(usize, &str); 6] = [ (1, "READY settled"), (4, "READY mid "), @@ -48,9 +37,8 @@ const REACHABLE: [(usize, &str); 6] = [ (5, "ALT mid "), ]; -/// The four-way remap (`0x5f3972`): what the arm actually requests when the model doesn't author -/// the substate's LUT id. Returns `(id, rate0)` — `rate0` marks the two legs that freeze a *motion* -/// clip at frame 0 to stand in for a missing *rest* pose. +/// The four-way remap (`0x5f3972`) for a model that lacks the substate's LUT id, as `(id, rate0)`: +/// `rate0` marks the two legs that freeze a motion clip at frame 0 in place of a missing rest pose. fn go_remap(m: &benilla_m2::M2Model, id: u16) -> (u16, bool) { if m.owns_animation(id) { return (id, false); @@ -77,14 +65,13 @@ fn go_remap(m: &benilla_m2::M2Model, id: u16) -> (u16, bool) { 149 if m.owns_animation(148) => (149, false), 149 if m.owns_animation(146) => (146, true), 149 => (151, false), - // Outside the door group there is no remap — the id goes to op4 as-is. + // Outside the door group the id goes to op4 as is. other => (other, false), } } -/// op4's own id → played sequence resolve (`0x7121a0` via `0x711bf0`): the model's -/// `playableAnimationLookup` row (when the id is in range), then `animationLookup` to a file slot. -/// `None` when nothing playable comes out — the reference arms nothing and the pose simply stands. +/// op4's id resolve (`0x7121a0` via `0x711bf0`): `playableAnimationLookup` when the id is in +/// range, then `animationLookup` to a file slot. With no slot the reference arms nothing. fn go_resolve_slot(m: &benilla_m2::M2Model, id: u16) -> Option<(u16, u16)> { let played = m .playable_animation_lookup @@ -94,9 +81,8 @@ fn go_resolve_slot(m: &benilla_m2::M2Model, id: u16) -> Option<(u16, u16)> { (slot != 0xffff).then_some((played, slot)) } -/// The generic loader seed (`0x71019b`): resolve id 0, and arm **id 0** when the model owns what -/// that resolves to — only the degenerate leg (owning nothing reachable) falls back to the raw -/// `animations[0]` dword. +/// The loader seed (`0x71019b`): id 0 when the model owns what id 0 resolves to, else the +/// file-order-first sequence's own id (`animations[0]`). fn go_loader_seed( m: &benilla_m2::M2Model, seqs: &[benilla_formats::ModelAnimation], @@ -108,18 +94,16 @@ fn go_loader_seed( if m.owns_animation(resolved) { go_resolve_slot(m, 0) } else { - // The degenerate leg: `animations[0]`'s low16 — the file-order-first sequence's own id. go_resolve_slot(m, seqs.first()?.anim_id) } } -/// Sweep every model named by **GameObjectDisplayInfo.dbc** and resolve, per model, what the -/// reference's GameObject animation arm plays in each reachable `GAMEOBJECT_STATE` × -/// `GAMEOBJECT_ANIMPROGRESS` substate — see the `Goanimscan` command doc. +/// Resolve, for every `GameObjectDisplayInfo` model, what the reference's GameObject arm plays in +/// each state substate, printing the models where that differs from the loader seed. pub fn goanimscan(chain: &mut Chain) -> Result<()> { let catalog = benilla_formats::load_gameobject_catalog(chain).context("GameObjectDisplayInfo.dbc")?; - // displayId → path, deduped to one entry per model (many displays share a model). + // Model path → its display ids; many displays share a model. let mut models: BTreeMap> = BTreeMap::new(); for (id, path) in catalog.iter() { let key = model_key(path); @@ -132,17 +116,12 @@ pub fn goanimscan(chain: &mut Chain) -> Result<()> { } let (mut parsed, mut no_seq, mut blind, mut sensitive, mut needs_remap, mut rate0) = (0u32, 0u32, 0u32, 0u32, 0u32, 0u32); - // The transition half (decision 1151): a MOTION substate whose resolved sequence is bit-0-clear - // is one the kernel wraps for ever — so it is bounded only by the object layer's completion - // advance (`0x5f4120`), and a consumer that arms it by the loop bit instead flaps. And `replay` - // decides whether that window is one band or several. + // A motion substate on a looping sequence (flags bit 0 clear) wraps until the completion + // advance (`0x5f4120`) ends it; the replay range sets how many bands that window spans. let (mut looping_motion, mut looping_motion_sensitive, mut multi_replay) = (0u32, 0u32, 0u32); - // The VARIATION half (`0x5f3aee: push -1`): the GameObject arm rolls a `_rand`-weighted - // variation, while the loader seed underneath it (`0x71019b`) takes an explicit variation 0. So - // a model that authors a CHAIN on a reachable substate's id plays something the seed never - // reaches — and a consumer resolving the id to its head variation renders the whole rest of the - // chain unreachable. Onyxia's lava traps are the case (`ONYZIASLAIRLAVATRAP`: Stand ×2, and - // only the 10 %-weighted second one spurts lava). + // The arm rolls a weighted variation (`0x5f3aee` pushes -1) where the loader seed takes + // variation 0 (`0x71019b`), so a chain on a substate's id reaches sequences the seed cannot + // (`ONYZIASLAIRLAVATRAP`: two Stands, only the 10%-weighted second spurts lava). let mut chained = 0u32; let mut chained_paths: Vec = Vec::new(); for (path, displays) in &models { @@ -171,16 +150,12 @@ pub fn goanimscan(chain: &mut Chain) -> Result<()> { remapped |= req != lut; froze |= r0; differs |= armed.map(|(_, s)| s) != seed.map(|(_, s)| s); - // The played sequence's own kernel law. `slot` is the M2's FILE slot, which is not this - // list's index (zero-duration sequences are dropped), so match on `seq_index`. + // `slot` is a file slot, not an index here (zero-duration sequences are dropped). let played = armed.and_then(|(_, slot)| seqs.iter().find(|s| s.seq_index == slot as usize)); - // A transient substate (a transition motion) armed on a band the kernel wraps: what - // ends it is the completion advance (`0x5f4120`), never the clip. let motion = MOTION_SUBSTATES.contains(&sub); flaps |= motion && !r0 && played.is_some_and(|s| s.looping); replays |= played.is_some_and(|s| (s.min_replay, s.max_replay) != (0, 0)); - // A variation chain on THIS substate's armed id: >1 sequence sharing it. let variations = armed.map_or(0, |(id, _)| seqs.iter().filter(|s| s.anim_id == id).count()); has_chain |= variations > 1; @@ -230,8 +205,7 @@ pub fn goanimscan(chain: &mut Chain) -> Result<()> { } remapped.then(|| needs_remap += 1); froze.then(|| rate0 += 1); - // Only the state-SENSITIVE models are worth printing: on every other one the arm lands on - // the same sequence the loader seed already holds, so `GAMEOBJECT_STATE` is unobservable. + // Print only state-sensitive models; on the rest every substate plays the seed's sequence. if differs { println!("{path} ({} sequences, displays {displays:?})", seqs.len()); println!( @@ -280,33 +254,16 @@ pub fn goanimscan(chain: &mut Chain) -> Result<()> { Ok(()) } -/// `AnimationData.dbc` ids of the effect-model lifecycle triple (names read from the real DBC). +/// `AnimationData.dbc` ids of the effect-model lifecycle: Stand, Hold, Decay. const ANIM_STAND: u16 = 0; const ANIM_HOLD: u16 = 158; const ANIM_DECAY: u16 = 159; -/// Sweep every `.m2` (optionally under a path prefix) and census the **effect-model animation -/// lifecycle**: which models author the `Stand`(0) → `Hold`(158) → `Decay`(159) triple, and what a -/// consumer that arms ONE sequence and never advances would render for each. -/// -/// The reference arms an effect instance's default track — `animationLookup[0]`, i.e. `Stand` — and -/// a model that also owns `Hold` authors `Stand` as a **birth** (grow-in, clamp flag set) with the -/// sustained pulse living in the separate looping `Hold` sequence, and the fade-out in `Decay`. -/// A one-sequence consumer therefore FREEZES on the last frame of the birth for the effect's whole -/// life — no pulse, no fade — which is exactly what "the Ice Barrier shield is frozen" looks like. -/// -/// The classification is that failure, made countable: -/// - **FREEZE** — owns `Hold`, and the armed `Stand` clamps: the reference pulses, we hold a pose. -/// - **hold-loops** — owns `Hold` but `Stand` itself loops: still wrong (the wrong clip), but moving. -/// - **decay-only** — owns `Decay` and no `Hold`: only the reap leg is unrendered. -/// -/// It also counts the **file-order divergence**: the reference arms `animationLookup[0]`, so a -/// consumer that arms the file's *first slot* instead is additionally wrong on any model whose slot -/// 0 is not its `Stand` (the `DuelingFlag.m2` Spawn/Stand/Despawn shape, decision 0637). The two -/// bugs are independent and the closing line separates them. -/// -/// The population instrument for the mechanism, so it is closed corpus-wide rather than spell by -/// spell; `m2seq` then explains one model in full. +/// Census the effect models with a `Hold` (158) or `Decay` (159) leg. The reference arms +/// `animationLookup[0]`, `Stand`, which beside a `Hold` is a clamped birth before the looping +/// pulse, so a consumer that never advances freezes on its last frame (`FREEZE`); `hold-loops` +/// has a looping `Stand`, `decay-only` no `Hold`. It also lists the models whose file slot 0 is +/// not `Stand` (`DuelingFlag.m2`: Spawn, Stand, Despawn), where arming slot 0 is wrong outright. pub fn fxlifescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut freeze, mut hold_loops, mut decay_only) = (0u32, 0u32, 0u32, 0u32); @@ -322,13 +279,12 @@ pub fn fxlifescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { if seqs.is_empty() { continue; } - // What the reference arms: `animationLookup[0]` — Stand — never the file-order-first slot - // (they diverge on any model whose sequence 0 is not its Stand). + // The reference arms `animationLookup[0]`, Stand, not file slot 0. let armed = seqs.iter().find(|s| s.anim_id == ANIM_STAND); let hold = seqs.iter().find(|s| s.anim_id == ANIM_HOLD); let decay = seqs.iter().find(|s| s.anim_id == ANIM_DECAY); if hold.is_none() && decay.is_none() { - continue; // neither leg authored — no lifecycle to miss + continue; // no lifecycle leg authored } let class = match (hold.is_some(), armed) { (true, Some(a)) if !a.looping => { @@ -344,8 +300,6 @@ pub fn fxlifescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { "decay-only" } }; - // The file-order divergence, independent of the lifecycle bug: what a slot-0 consumer arms - // versus the reference's `animationLookup[0]`. let arm = match (armed, seqs[0].anim_id) { (None, first) => { no_stand += 1; @@ -389,8 +343,6 @@ pub fn fxlifescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { "of those, the file-order divergence (a slot-0 consumer arms the wrong sequence outright): \ {slot0_not_stand} with slot 0 != Stand, {no_stand} with no Stand at all" ); - // Named in full, never left to the row cap: this set is small and each member is a distinct - // "arms the wrong sequence from frame one" case. for (name, _, arm, ..) in rows.iter().filter(|r| r.2 != "slot0") { println!(" {name:<58} {arm}"); } @@ -401,20 +353,10 @@ pub fn fxlifescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (optionally under a path prefix) and list the models where the **loader-idle -/// sequence is not file slot 0** while benilla's render content gate declines to arm it — so every -/// per-sequence bake (`EmitTiming`/`EmitParams`/`AlphaAnim`) degrades to slot 0, a sequence the -/// instance is not playing (decision 0936, found on the Stormwind battlefield banner). -/// -/// The reference arms the loader-idle sequence on **every** M2 instance at load (`0x70ebd0`'s -/// tail), so "which sequence is this playing" always has an answer. -/// benilla skips the arm when looping the idle would render identically to the static mesh — sound -/// for the *mesh*, and silently wrong for anything keyed on the sequence *identity*. The two only -/// disagree visibly when the idle is not slot 0, which is the `DuelingFlag` shape: -/// `Spawn(145) / Stand(0) / Despawn(157)`, where slot 0 is the **Spawn flourish**. -/// -/// Reports, per model: the emitters whose enabled/rate gate at t = 0 differs between slot 0 and the -/// idle slot (`FX`), and the batches whose combined material-alpha factor differs (`ALPHA`). +/// List the models whose loader-idle sequence is not file slot 0 and is a rest pose the content +/// gate leaves unarmed, with the emitters (`FX`) and batches (`ALPHA`) that differ at t = 0 +/// between the two slots. The reference arms the idle on every instance at load (`0x70ebd0`'s +/// tail), so a per-sequence bake follows it, never slot 0 (`DuelingFlag.m2`'s slot 0 is Spawn). pub fn idleslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut unarmed, mut window) = (0u32, 0u32, 0u32); @@ -432,8 +374,7 @@ pub fn idleslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { .unwrap_or_default() .first() .map_or(0, |p| p.resolved_id); - // benilla's render content gate (`benilla_assets`' `idle_pose_differs`): a bind-pose idle - // is not armed at all, so nothing ever overrides the per-sequence bakes' opening slot. + // The content gate (`benilla_assets`' `idle_pose_differs`) arms no rig for a rest pose. if anims .iter() .any(|a| a.anim_id == idle_id && !a.is_rest_pose()) @@ -446,7 +387,7 @@ pub fn idleslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { .find(|a| a.anim_id == idle_id) .map_or(0, |a| a.seq_index); if idle == 0 { - continue; // degrading to slot 0 happens to BE the idle slot — no divergence + continue; // the idle is slot 0: nothing differs } window += 1; let mut lines = Vec::new(); @@ -497,30 +438,12 @@ pub fn idleslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (optionally under a path prefix) and list the models whose animation is -/// authored **entirely outside the bone tracks** — sequences exist, per-sequence consumers exist -/// (particle emitters, ribbons, material alpha/colour, UV transforms), and **not one sequence -/// keys a bone**. -/// -/// That combination is the blind spot of a renderer whose sequence clock rides a *bone* animation -/// clip: with no keyed bone there is no clip, so there is no player, so nothing ever says which -/// sequence is playing or how far into it — and every per-sequence consumer degrades to "file slot -/// 0, at t = 0", permanently. The emitters still build, pool and tick; they just read the wrong -/// column of a table that never advances. Found on the Molten Core rune + flame ring (decision -/// 0941): the rune's slot 0 is its *Closed* band, where every emission rate key is 0 (no flames at -/// all), and the ring's spline spawn-window opens `0 → 1` over its first second, so frozen at t=0 -/// all 180 particles are born at one point of a 2.8-yd circle — one bright blob. -/// -/// The `[GO]` mark is the cross-tab that matters: a `GameObjectDisplayInfo` model reaches the -/// world through the **hosted** clock (`EmitClock::Host`), which is the lane that freezes at -/// `t = 0`. A placed doodad of the same shape still runs its spawn-age clock (only its *slot* is -/// pinned — decision 0760's axis), so it is wrong in a different, milder way. -/// -/// PARTIAL models — some sequences key bones, some don't — are tallied, not listed: there the -/// clock exists, but the unkeyed sequences are unreachable (no clip to arm), so a GameObject -/// substate or a creature animation id that resolves to one plays nothing. +/// List the models whose sequences drive per-sequence consumers (emitters, ribbons, material +/// alpha and colour, UV transforms) while no sequence keys a bone, so their sequence clock cannot +/// hang on bone curves. `[GO]` marks a `GameObjectDisplayInfo` model, whose emitters run on the +/// host's clock (`EmitClock::Host`); models where only some sequences key a bone are `PARTIAL`. pub fn seqclockscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - // Which models a GameObject can display — the hosted-clock lane. + // The models a GameObject can display, whose emitters run on the host's clock. let go_models: HashSet = benilla_formats::load_gameobject_catalog(chain) .map(|c| c.iter().map(|(_, p)| model_key(p)).collect()) .unwrap_or_default(); @@ -538,8 +461,7 @@ pub fn seqclockscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { continue; } with_seqs += 1; - // Exactly the runtime's own test (`build_animation_clip`): a sequence becomes a clip iff - // some bone track produced a key inside its band. + // Whether a sequence poses a bone, as `build_animation_clip`'s flag decides it. let keyed = seqs .iter() .filter(|a| { @@ -548,17 +470,15 @@ pub fn seqclockscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { }) }) .count(); - // Counted over EVERY model with sequences, consumers or not: it measures clip - // REACHABILITY (can an arm find this sequence at all), which is a property of the model. + // Counted over every model with sequences, consumers or not. if keyed > 0 && keyed < seqs.len() { partial += 1; } let Ok(s) = benilla_formats::parse_m2_animation_summary(&bytes) else { continue; }; - // The per-sequence consumers: everything that samples a track on the playing sequence's - // clock. A constant (≤1 key) colour/alpha track is excluded — it reads the same in every - // slot, so a frozen clock costs it nothing. + // Everything that samples a track on the playing sequence's clock; a constant (≤1 key) + // colour or alpha track reads the same in every slot and is left out. let consumers = s.particle_emitter_count + s.ribbon_emitter_count + s.color_alpha_tracks.1 @@ -566,9 +486,7 @@ pub fn seqclockscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + s.transparency_tracks.1 + s.texture_transform_count; if keyed == 0 && consumers == 0 { - // The same shape with nothing to sample: a clock costs it nothing and buys it - // nothing. Counted because it IS the cost side of giving every sequenced model a - // clock — these are the models that gain an inert one. + // No keyed bone and nothing to sample: a clock here is inert. inert += 1; } if consumers > 0 && keyed == 0 { @@ -603,25 +521,11 @@ pub fn seqclockscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` and census the **animation-driven sound emitters**: the models whose -/// sequences carry a `$DSL` (doodad sound loop) / `$DSE` (its release token) / `$DSO` (doodad -/// sound one-shot) / `$SND` (generic one-shot) event marker, the `SoundEntries` kit each names, -/// and — the column this exists for — whether the carrying sequence is **rest-posed**. -/// -/// `$DSE` is in the set because it is half of `$DSL`'s lifecycle and it is **lane-specific**: the -/// placed-doodad handler `0x6951e0` has an arm for it, the GameObject dispatcher `0x5f3e20` has -/// none at all, so which models author it decides whether -/// a consumer may route the token uniformly. -/// -/// Why that column decides everything: `benilla_assets`' render content gate -/// (`idle_pose_differs` → [`benilla_formats::ModelAnimation::is_rest_pose`], decision 0130) skips -/// building a rig for a sequence that would render as the static mesh, and the whole point of -/// that gate is that it is a question **about pixels only**. A placed lamp's Stand band keys no -/// bone at all — it is pure rest pose — yet it carries the one `$DSL` marker that is its hum. So -/// every model in the `REST` column is one whose sound the arm can never reach through a rig: its -/// events need a clock that does not depend on there being anything to animate. -/// -/// Reports the per-model rows, then a tag/kit histogram and the `REST`-gated share. +/// Census the `$DSL` (loop), `$DSE` (its release), `$DSO` and `$SND` (one-shots) markers per model, +/// the `SoundEntries` kit each names, and whether the carrying sequence is a rest pose (`REST`), +/// which gets no rig ([`benilla_formats::ModelAnimation::is_rest_pose`]), so only a rig-free clock +/// reaches its marker (a lamp's Stand keys no bone yet carries its `$DSL` hum). `$DSE` has an arm +/// in the placed-doodad handler `0x6951e0` and none in the GameObject dispatcher `0x5f3e20`. pub fn soundeventscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { const SOUND_TAGS: [&[u8; 4]; 4] = [b"$DSL", b"$DSE", b"$DSO", b"$SND"]; let kits = benilla_formats::load_sound_kit_catalog(chain).ok(); @@ -639,14 +543,9 @@ pub fn soundeventscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { continue; } scanned += 1; - // The spawn tier `benilla_world::doodad_anim::classify` would give this model, from the - // same three inputs it reads: a boneless model is `Static` outright (the 0035 guard); - // otherwise the 0130 content gate picks `FirstSeq` when the loader-idle clip's pose - // differs from bind, else `GlobalSeqOnly` when free-running channels exist, else - // `Static`. `is_rest_pose` is the gate's own predicate (decision 0936 pushed it down - // beside the parse so this census cannot drift from it); the tier matters here because - // only the two animated tiers get an anim-root ENTITY at all — a `Static` carrier has - // nothing but loose submeshes to hang an emitter on. + // The spawn tier `benilla_world::doodad_anim::classify` gives this model: `Static` when + // boneless, else `FirstSeq` when the idle is not a rest pose, else `GlobalSeqOnly` with + // global-sequence channels, else `Static`. let bones = benilla_m2::parse_m2(&mut std::io::Cursor::new(&bytes)) .map_or(0, |f| f.model().bones.len()); let idle_id = benilla_formats::parse_m2_playable_animation_lookup(&bytes) @@ -677,8 +576,7 @@ pub fn soundeventscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { if marks.is_empty() { continue; } - // The gate is asked of the sequence that CARRIES the marker: that is the one whose - // clock has to run for the marker to be reached at all. + // Asked of the sequence that carries the marker: its clock must run to reach it. let rest = a.is_rest_pose(); model_rest_gated |= rest; for e in &marks { @@ -752,15 +650,14 @@ pub fn soundeventscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// The ten `GameObjectDisplayInfo.Sound[n]` slots, in column order — the names the reference's -/// own substate family gives them. +/// The sound-slot names of the ten `GameObjectDisplayInfo.Sound[n]` columns, in column order. const GO_SLOT_NAMES: [&str; 10] = [ "Stand", "Open", "Loop", "Close", "Destroy", "Opened", "Custom0", "Custom1", "Custom2", "Custom3", ]; -/// The event tag that reaches each display slot (the dispatcher `0x5f3e20`'s ten call sites): -/// `$GO0..5` → slots 0..5, `$GC0..3` → slots 6..9. +/// The event tag that plays each display slot through `0x5f3e20`: `$GO0..5` → slots 0..5, +/// `$GC0..3` → slots 6..9. fn go_slot_tag(slot: usize) -> [u8; 4] { if slot < 6 { [b'$', b'G', b'O', b'0' + slot as u8] @@ -769,14 +666,11 @@ fn go_slot_tag(slot: usize) -> [u8; 4] { } } -/// The model sequence **file slots** the GameObject arm can ever put on screen: the loader seed -/// (`0x71019b`) plus every substate any client path produces — the six `GAMEOBJECT_STATE` × -/// `GAMEOBJECT_ANIMPROGRESS` ones ([`REACHABLE`]), the four Custom ones (opcode `0xb3`) and -/// Despawn (`0x215`) — each through the four-way remap (`0x5f3972`) and op4's -/// `playableAnimationLookup` resolve. -/// -/// This is the reachability half of [`goslotscan`]: an authored `$GOn` on a sequence outside this -/// set is a marker no arm can ever cross, so its display slot is dead however the DBC is filled. +/// The file slots the GameObject arm can put on screen: the loader seed (`0x71019b`), the six +/// state substates through the four-way remap (`0x5f3972`), and the Custom0-3 (opcode `0xb3`) and +/// Despawn (`0x215`) one-shots, resolved through `playableAnimationLookup`. Spawn, which the +/// create path arms (`0x5f382f`), is left out, so a marker only its sequence carries reads as +/// unarmable. fn go_armable_slots( m: &benilla_m2::M2Model, seqs: &[benilla_formats::ModelAnimation], @@ -791,15 +685,15 @@ fn go_armable_slots( out } -/// Census the **GameObject display sound slots** against the model events that are the only thing -/// that can reach them — see the `Goslotscan` command doc for what the join proves. +/// Census the GameObject display sound slots against the `$GOn`/`$GCn` model events, the only +/// path that plays them (`0x5f4010`, called from `0x5f3e20` alone). pub fn goslotscan(chain: &mut Chain) -> Result<()> { let catalog = benilla_formats::load_gameobject_catalog(chain).context("GameObjectDisplayInfo.dbc")?; let sounds = benilla_formats::load_gameobject_sounds(chain).context("GameObjectDisplayInfo.dbc")?; let kits = benilla_formats::load_sound_kit_catalog(chain).ok(); - // displayId → model path, so the same model parsed once serves every display that names it. + // Model path → the displays that name it, so each model is parsed once. let mut per_model: BTreeMap> = BTreeMap::new(); for (id, path) in catalog.iter() { if sounds.slots(id).is_some() { @@ -809,18 +703,16 @@ pub fn goslotscan(chain: &mut Chain) -> Result<()> { for ids in per_model.values_mut() { ids.sort_unstable(); } - // Per slot: displays carrying a kit · of those, models authoring the tag · of those, on an - // armable sequence · and how many of the live ones name a LOOPING (0x200) kit — the lane - // select `0x458830` hands `0x5f4010`, which is the emitter-pool arm rather than a one-shot. + // Per slot: displays with a kit, of those with the tag authored, of those on an armable + // sequence, and the live ones whose kit loops (flag 0x200), which `0x5f4010` plays through + // the emitter pool (`0x461d80`) rather than as a one-shot. let mut filled = [0u32; 10]; let mut tagged = [0u32; 10]; let mut reachable = [0u32; 10]; let mut looping = [0u32; 10]; - // Filled columns whose model authors no reaching tag at all — dead data in the reference, and - // exactly what a state-transition-driven consumer would play that the reference never does. + // Filled columns whose model authors no reaching tag: dead data the reference never plays. let mut dead = [0u32; 10]; - // Markers authored where the display's column is 0 — the tag fires and `0x458830` fails a null - // id, so the reference is silent. Counted per tag so "authored but silent" is visible. + // Tags authored where the column is 0: the tag fires and `0x458830` fails on the null id. let mut silent_tag = [0u32; 10]; let mut unarmable_tag = [0u32; 10]; let (mut models_seen, mut wmo_or_missing) = (0u32, 0u32); diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/skeleton.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/skeleton.rs index 04ac5196..2cf4e4ca 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/skeleton.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/skeleton.rs @@ -1,8 +1,4 @@ -//! Corpus scans over the **bone tree and what addresses it** — the skeleton's own tracks, and -//! the attachment table things hang off. -//! -//! `bonescan` measures our skeletal parse against the reference's sampler band by band; -//! `attachscan` censuses where a table scan disagrees with the reference's `attachLookup`. +//! Corpus scans over the bone tree and what addresses it: bone tracks, attachments, event markers. use std::collections::{BTreeMap, BTreeSet}; @@ -11,15 +7,9 @@ use benilla_formats::Chain; use crate::model_key; -/// Sweep every `.m2` and census its **attachment addressing**: how many records it authors, how -/// many attach ids its AttachLookup resolves, and — the point of the report — where the two -/// disagree, i.e. where "scan the table for a record with this id" answers differently from the -/// reference's `lookup[id]` (`0x710310`). -/// -/// Built for decision 0805 (item glows hang on the item model's ids 0..4) to answer the question -/// that decides whether the lookup can be adopted globally: which models change hands? One line -/// per divergent model — `+id` = an id only the lookup reaches, `-id` = an id only a table scan -/// reaches (a record the reference cannot address at all), `id:a→b` = same id, different record. +/// List the models where a table scan for an attachment id and the reference's `lookup[id]` +/// (`0x710310`) disagree: `+id` only the lookup reaches, `-id` only the scan reaches (the +/// reference cannot address it), `id:a→b` a different record. pub fn attachscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut with_points, mut divergent) = (0u32, 0u32, 0u32); @@ -37,8 +27,6 @@ pub fn attachscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { continue; } with_points += 1; - // What a table scan would answer (first record per id — the pre-0805 rule) against what - // the lookup answers, compared by the record each id lands on. let mut scan: BTreeMap = BTreeMap::new(); for (i, a) in model.attachments.iter().enumerate() { scan.entry(a.id).or_insert(i); @@ -85,12 +73,8 @@ pub fn attachscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// One bone `M2Track` read straight off the file bytes (v256 stride `0x1c`: interp@0, gseq@2, -/// interpolation_ranges `M2Array`@0x04/0x08, timestamps@0x0c/0x10, values@0x14/0x18). -/// -/// Deliberately raw rather than via `parse_m2_animations`: this instrument's whole job is to -/// compare what the **file** says against what our parser currently emits, so it must not go -/// through the parser under test. +/// One bone `M2Track` read raw, so the check never goes through the parser it tests: v256 stride +/// `0x1c`, interp@0, gseq@2, then `M2Array`s ranges@0x04, timestamps@0x0c, values@0x14. struct RawBoneTrack { interp: u16, gseq: u16, @@ -151,12 +135,11 @@ impl RawBoneTrack { }) } - /// The reference's sample at absolute time `t_ms` for sequence file slot `slot` — `0x713d50` - /// verbatim, then the sampler's own lerp/step leg (`0x713ea0`, `0x71af20`). + /// The reference's sample at absolute `t_ms` for file slot `slot`: `0x713d50`'s key search, + /// then the lerp or step of `0x713ea0`/`0x71af20`. fn reference(&self, slot: usize, t_ms: u32) -> Option<[f32; 4]> { let last = self.ts.len().checked_sub(1)?; - // `0x713d59`: the window is `ranges[slot]` when the array is present, else the whole - // key list. + // `0x713d59`: the window is `ranges[slot]` when present, else the whole key list. let (lo, hi) = match self.ranges.get(slot) { Some(&(lo, hi)) => (lo as usize, hi as usize), None => (0, last), @@ -174,7 +157,7 @@ impl RawBoneTrack { break; } } - // `0x713e45`: `k1 = k0+1`, bounded by the TOTAL key count — never by the window's `hi`. + // `0x713e45`: `k1 = k0 + 1`, bounded by the total key count, not the window's `hi`. if self.interp == 0 || k0 + 1 > last { return Some(self.vals[k0]); } @@ -191,9 +174,8 @@ impl RawBoneTrack { Some(out) } - /// What `models::anim::read_bone_track` emits for this band today: the in-band keys, or — when - /// the band holds none — the single **nearest** out-of-band key (benilla decision 0133). - /// Returns `(value_at_band_start, value_at_band_end, band_was_empty)`. + /// A plain hold, the answer without the `ranges` window: the band's first and last own keys, + /// or the nearest out-of-band key when it has none, as `(start, end, band_was_empty)`. fn benilla_today(&self, start: u32, end: u32) -> Option<([f32; 4], [f32; 4], bool)> { let inb: Vec = (0..self.ts.len()) .filter(|&k| self.ts[k] >= start && self.ts[k] <= end) @@ -213,9 +195,8 @@ impl RawBoneTrack { Some((self.vals[k], self.vals[k], true)) } - /// Distance between two sampled values in the channel's own units: **degrees** of rotation for - /// a quaternion (numerically stable near identity — `acos(dot)` has a ~0.04° floor in f32), - /// **model units** for a translation/scale vector. + /// Distance in the channel's units: degrees for a quaternion, by the chord form since + /// `acos(dot)` floors near 0.04° in f32; model units otherwise. fn delta(&self, a: [f32; 4], b: [f32; 4]) -> f32 { if self.comps == 4 { let norm = |q: [f32; 4]| { @@ -246,30 +227,14 @@ impl RawBoneTrack { } } -/// Sweep every `.m2` (optionally under a path prefix) and measure, per bone track and per sequence -/// band, how far **our** skeletal parse sits from the **reference's** sampler — the population -/// instrument behind benilla decision 0133's named residual ("an empty band clamps to the nearest -/// authored key … a named approximation of the mid-gap lerp"). -/// -/// Three separately-reported disagreements, each a distinct mechanism: -/// -/// - **EMPTY bands** — a band with no keys of its own. We hold the nearest authored key; the -/// reference holds `keys[ranges[slot].lo]`, or lerps across the bracket when the window spans two -/// keys. The 0133 residual proper. -/// - **HELD edges** — a keyed band whose first key is late / last key is early. Bevy holds the edge -/// key; the reference keeps interpolating toward the neighbouring **out-of-band** key, because -/// `0x713d50`'s `k1 = k0+1` is bounded by the total key count and not by the window. -/// - **STEP tracks** — `interpolation_type == 0`. The reference's samplers branch on it and copy -/// `keys[k0]` with no interpolation; our bone parse emits keys and lets Bevy interpolate, so a -/// snap becomes a glide. -/// -/// Plus the safety check the whole idea rests on: whether any band's own keys fall **outside** the -/// window `ranges[slot]` would search (they never do — if they did, adopting the window would -/// reintroduce the garbage pose 0133 records). +/// Measure, per bone track and sequence band, how far a plain key hold sits from the reference's +/// sampler (`0x713d50`): an empty band samples the `ranges[slot]` window, past a band's last key +/// the reference keeps lerping toward `k0 + 1` (bounded by the total key count, not the window), +/// and a step track (`interp == 0`) copies `keys[k0]` where the emitted clips glide. It also +/// checks that no band's keys fall outside its own window, which makes reading the window safe. pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; - // Above the f32 noise floor of the stable angle formula (~1e-4°) by two orders, and far below - // anything an eye could catch — a slot over this is a real authored difference. + // Two orders above the angle formula's f32 noise (about 1e-4°), far below what shows. const ROT_EPS: f32 = 0.01; // degrees const VEC_EPS: f32 = 1e-4; // model units let (mut scanned, mut models_step, mut models_empty_differ, mut models_edge_differ) = @@ -283,7 +248,7 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let mut gseq_orphan_models: BTreeSet = BTreeSet::new(); let mut gseq_restrict_models: BTreeSet = BTreeSet::new(); let mut emitted_extra = 0u64; - // (empty-band peak, keyed-edge peak) per channel kind — the bound, not a threshold count. + // (empty-band peak, keyed-edge peak) per channel kind. let (mut peak_rot, mut peak_vec) = ((0.0f32, 0.0f32), (0.0f32, 0.0f32)); let mut emitted_models: BTreeSet = BTreeSet::new(); // Worst offender per class: (delta, model, bone, channel, slot). @@ -304,7 +269,7 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { .map(|s| u32::from_le_bytes(s.try_into().unwrap()) as usize) .unwrap_or(0) }; - // Sequences in FILE order (count@0x1c/ofs@0x20, stride 0x44) — the order `ranges` indexes. + // Sequences in file order (count@0x1c, ofs@0x20, stride 0x44), the order `ranges` indexes. let (sn, so) = (u32_at(0x1c), u32_at(0x20)); let seqs: Vec<(u32, u32)> = (0..sn) .map_while(|i| { @@ -312,7 +277,7 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { (e + 0x44 <= b.len()).then(|| (u32_at(e + 4) as u32, u32_at(e + 8) as u32)) }) .collect(); - // globalSequences @0x14/0x18 — a duration per entry; 0 means the loop has no period. + // globalSequences @0x14/0x18: a duration per entry, 0 for no period. let (gn, go) = (u32_at(0x14), u32_at(0x18)); let gseq_period = |g: u16| -> u32 { let i = g as usize; @@ -340,8 +305,7 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { if tr.interp == 0 { step_tracks += 1; m_step += 1; - // The step deviation only becomes visible when a band holds TWO keys: that is - // where the reference snaps and we glide. Measure the size of the snap. + // A step shows only where a band holds two keys: the reference snaps there. for (slot, &(start, end)) in seqs.iter().enumerate() { if end <= start { continue; @@ -362,14 +326,10 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } } } - // A global-sequence track runs on its own clock. `read_bone_track` keeps the - // SINGLE-key case (a constant channel — the stowed-weapon rest quats) and leaves - // the multi-key case to `parse_m2_global_sequence_bones` → the `GlobalSeqDrive` - // lane, which needs a non-zero `globalSequences[gseq]` period. A multi-key channel - // on a ZERO-period global sequence falls between the two and is sampled by - // neither — census that gap, and the shape of the `ranges` window the reference - // would still apply here (`0x713d50` selects the window BEFORE it resolves the gseq - // clock, so a restrictive window would clip the loop). + // A global-sequence track runs on its own clock: one key is a constant in every + // clip, more go to `parse_m2_global_sequence_bones`, which needs a non-zero + // period, so a multi-key track on a zero period plays nowhere. `0x713d50` picks + // the `ranges` window before the gseq clock, so a narrow window clips the loop. if tr.gseq != 0xffff { gseq_tracks += 1; if tr.ts.len() > 1 { @@ -427,8 +387,7 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } else { peak.1 = peak.1.max(d); slots_keyed += 1; - // Does this band's own key set sit inside the window the reference - // searches? If not, adopting the window would drop playable keys. + // Reading the window is safe only if the band's own keys sit inside it. if let Some(&(lo, hi)) = tr.ranges.get(slot) { let inb: Vec = (0..tr.ts.len()) .filter(|&k| tr.ts[k] >= start && tr.ts[k] <= end) @@ -448,9 +407,8 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } } } - // The other half of the check: what our parser ACTUALLY emits. A band-slot whose emitted - // key count exceeds its own in-band key count is one where the head/tail sample differed - // from the edge key and had to be carried — the only slots this parse changes. + // What the parse emits: a band-slot with more keys than its own in-band count carries a + // head or tail sample from the window. for a in benilla_formats::parse_m2_animations(&b) { for bk in &a.bones { for (off, comps, emitted) in [ @@ -487,7 +445,6 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { if m_edge > 0 { models_edge_differ += 1; } - // Keep the worst-offender lists bounded without losing the tail. for w in [&mut worst_empty, &mut worst_edge] { if w.len() > 4096 { w.sort_by(|a, b| b.0.total_cmp(&a.0)); @@ -583,34 +540,19 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` and census the **event table's positional half** — the `bone` and `position` -/// fields every `M2Event` record carries beside its 4CC and timestamps. -/// -/// The question it exists to answer: the reference's animation-event dispatchers hand their arms -/// the event's **own** world point (`0x5f3e20`'s `[ebp+0x10]`, `&M2SceneCallback.position` — the -/// authored `position` transformed by its bone's live matrix and the model's placement), while a -/// consumer that plays every anim-event sound at the model root is using the placement alone. That -/// difference is worth code only where the records are actually *off* the origin, or on a bone -/// that *moves*; where every record of a tag sits at the origin on a static bone the two are the -/// same point and the simpler consumer is not wrong. -/// -/// So, per 4CC: how many records, how many sit more than [`OFF_ORIGIN`] from the model origin (the -/// `position` field is already model-space — it is the marker's REST point, which is why -/// `build_markers` subtracts the bone pivot to get a bone-local offset), how many ride a bone -/// **whose chain any sequence keys** (the bone's own track being empty says nothing: it inherits -/// its parent's matrix), and the largest offset seen with the model that authors it. +/// Census each 4CC's `M2Event` records by `bone` and `position`. The reference hands an event's +/// arm its own world point, the model-space rest `position` moved by its bone's live matrix and the +/// placement (`0x5f3e20`'s `[ebp+0x10]`), which differs from the model root only for a record off +/// the origin or on a bone whose chain any sequence keys. pub fn eventmarkerscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - /// Yards from the model origin past which a marker is "off the origin". Well under any - /// audible 3D difference, so it is a *presence* threshold, not a significance one. + /// Yards from the model origin past which a marker counts as off it: a presence threshold. const OFF_ORIGIN: f32 = 0.01; let names = super::m2_names(chain, prefix)?; // 4CC → (records, off-origin, on a keyed bone, max offset, the model + bone at that max) let mut per_tag: BTreeMap = BTreeMap::new(); let (mut scanned, mut carriers) = (0u32, 0u32); - // Models authoring the SAME 4CC more than once, and those whose duplicates disagree about - // where they are: the reference's kernel fires a specific RECORD and snapshots that record's - // point, so a consumer that resolves the tag by a first-match table scan answers the wrong - // point exactly here. + // Models that author a 4CC more than once, and those whose copies sit apart: the reference + // fires a specific record and takes its point, so a first-match lookup by tag is wrong there. let (mut dup_models, mut dup_split) = (0u32, 0u32); let mut dup_examples: Vec = Vec::new(); for name in names { @@ -626,10 +568,7 @@ pub fn eventmarkerscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { continue; } carriers += 1; - // Which bones any sequence keys at all. A marker's own bone being unkeyed is NOT enough - // to make it static — the bone inherits its parent's matrix — so the test walks the - // ancestor chain, which is the difference between "sits still" and "sits still relative to - // something that moves". + // The bones any sequence keys; a marker moves if any bone up its parent chain is keyed. let keyed: BTreeSet = benilla_formats::parse_m2_animations(&bytes) .iter() .flat_map(|a| a.bones.iter().map(|b| b.bone)) diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/world.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/world.rs index 94e8a2c5..b6c1191e 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/world.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/world.rs @@ -1,8 +1,4 @@ -//! Corpus scans over **placed world content** — what a WMO root or an ADT tile block puts in -//! the world, and how much of it the reference would even draw. -//! -//! One `.wmo` root's own tables (`wmodoodads`, `skyboxscan`) and one map region's placements -//! (`placescan`, `doodadscan`). How a placed prop is LIT is a separate question — [`super::lighting`]. +//! Corpus scans over placed world content: a WMO root's own tables and an ADT region's placements. use std::collections::{BTreeMap, HashMap, HashSet}; @@ -11,12 +7,9 @@ use benilla_formats::{Chain, M2AnimSummary}; use crate::{model_key, yn}; -/// Dump a WMO root's placed-prop tables: every MODD doodad with its MODS set membership and its -/// OWNING group(s) read from the group files' MODR lists — the relation the reference instantiates -/// from (`0x695aa0` loops a *visible* group's own refs). A prop referenced by NO group is never -/// created by the real client at all — the -/// divergence decision 0689 names and benilla still spawns. This answers "which props exist here, -/// who owns them, and which would the reference even draw" in one read (the B30/B32 question). +/// Dump a WMO root's MODD props with their MODS sets and owning groups, read from the group files' +/// MODR lists. The reference creates a prop only from a visible group's refs (`0x695aa0`), so it +/// never draws a prop no group names (`ORPHAN`); benilla spawns one with no room gate. pub fn wmodoodads(chain: &mut Chain, raw_path: &str, filter: Option<&str>) -> Result<()> { let root_path = raw_path.replace('/', "\\").to_ascii_lowercase(); let bytes = chain @@ -26,7 +19,7 @@ pub fn wmodoodads(chain: &mut Chain, raw_path: &str, filter: Option<&str>) -> Re .with_context(|| format!("parsing WMO root '{root_path}'"))?; let set_names = mods_set_names(&bytes); - // MODD index -> the groups whose MODR reference it (the ownership relation). + // MODD index -> the groups whose MODR names it. let stem = root_path.strip_suffix(".wmo").unwrap_or(&root_path); let mut owners: BTreeMap> = BTreeMap::new(); let mut groups_read = 0u32; @@ -121,9 +114,8 @@ pub fn wmodoodads(chain: &mut Chain, raw_path: &str, filter: Option<&str>) -> Re Ok(()) } -/// The MODS set names (`char name[20]` per 32-byte record) — `WmoDoodadSet` keeps only the ranges, -/// so read the names off the raw root bytes here (top-level chunks are `[magic][size][data]`, magic -/// on disk reversed: MODS → `SDOM`). +/// The MODS set names, `char name[20]` at the head of each 32-byte record, read off the raw root +/// (chunk magics are reversed on disk: `SDOM`) since `WmoDoodadSet` keeps only the ranges. fn mods_set_names(bytes: &[u8]) -> Vec { let mut off = 0usize; while off + 8 <= bytes.len() { @@ -156,31 +148,14 @@ fn mods_set_names(bytes: &[u8]) -> Vec { Vec::new() } -/// Sweep every WMO **root** in the chain and report the two halves of the skybox mechanism: which -/// roots author a **MOSB** skybox model, and which carry groups flagged `0x40000` -/// ([`benilla_formats::WmoGroupInfo::show_skybox`]). -/// -/// This is the instrument that *identifies* the flag — and **exactly how far that identification -/// reaches is the point**. `0x40000` is undocumented, so the cross-tab is what establishes it means -/// anything at all: the bit never appears on a group whose root names no skybox, across all 815 -/// roots. That is a one-way implication, `flag ⇒ MOSB`, and the summary prints it so the claim is -/// re-checkable in one command rather than trusted from a decision record. -/// -/// **It does not, and cannot, say which group the RENDERER tests** — and reading it as if it did is -/// the mistake decision 0767 made (superseded by 0773). The law is that `0x40000` is tested -/// inside the portal flood (`0x6b42e0` in `0x6b41c0`) on the group being *visited*, so the predicate -/// is "any flood-reached group carries the bit". A census over static asset bytes has no way to see -/// that distinction; only the binary did. -/// -/// It is also the population instrument for the mechanism: which buildings in 1.12 replace the -/// `Light.dbc` gradient dome with an authored sky, and how much of each one does it. Stratholme's -/// city shell sets the bit on 61 of its 83 groups; the only other roots that set it at all are the -/// four Caverns of Time shells, which ship in the 5875 data with no 1.12 instance to enter. +/// Cross-tab every WMO root's MOSB skybox model against its groups flagged `0x40000` +/// ([`benilla_formats::WmoGroupInfo::show_skybox`]): across the 815 roots the flag never appears +/// without a MOSB. Which group counts is not in the assets: the reference tests the bit on each +/// group its portal flood visits (`0x6b42e0` in `0x6b41c0`). Only Stratholme's shell (61 of 83 +/// groups) and the four unused Caverns of Time shells set it. pub fn skyboxscan(chain: &mut Chain) -> Result<()> { - // Roots only, because only the root carries MOSB/MOGI. let names = super::wmo_roots(chain, None)?; - // The cross-tab that identifies the flag: roots with/without a MOSB × groups with/without 0x40000. let (mut both, mut mosb_only, mut flag_only, mut neither) = (0u32, 0u32, 0u32, 0u32); let mut scanned = 0u32; let mut hits: Vec<(String, String, usize, usize)> = Vec::new(); @@ -251,10 +226,7 @@ pub fn skyboxscan(chain: &mut Chain) -> Result<()> { Ok(()) } -/// List individual doodad (MDDF) and WMO (MODF) placements around a world position whose model -/// path contains `filter` (case-insensitive) — the per-placement position / Euler rotation / -/// scale / uniqueId ground truth an orientation investigation needs (`doodadscan` only -/// aggregates). +/// List the MDDF and MODF placements near a world position whose model path contains `filter`. pub fn placescan( chain: &mut Chain, map: &str, @@ -300,9 +272,8 @@ pub fn placescan( Ok(()) } -/// Bulk-scan placed doodads (MDDF) and WMO doodad-set-0 props (MODF → MODS/MODD) across a -/// `(2·tile_radius+1)²` block of ADT tiles around a world position, and report how much of that -/// content animates. +/// Count the placed doodads (MDDF) and WMO set-0 props (MODF → MODS/MODD) in the +/// `(2·tile_radius+1)²` tiles around a world position, and how much of that content animates. pub fn doodadscan( chain: &mut Chain, map: &str, @@ -314,12 +285,10 @@ pub fn doodadscan( .with_context(|| format!("loading tiles around ({center_x}, {center_y}) on {map}"))?; eprintln!("{} tile(s) loaded", tiles.len()); - // Direct M2 placements (MDDF) — deduped by uniqueId, which a tile-straddling doodad - // repeats identically across every tile it touches (decision-0021 terrain streamer's own - // dedup key; see `benilla_formats::Doodad::unique_id`). + // MDDF placements, deduped by uniqueId: a doodad straddling tiles repeats in each one. let mut seen_doodad_ids: HashSet = HashSet::new(); let mut m2_instances: HashMap = HashMap::new(); - // WMO placements (MODF) — same dedup, by their own uniqueId. + // MODF placements, deduped the same way. let mut seen_wmo_ids: HashSet = HashSet::new(); let mut wmo_instances: HashMap = HashMap::new(); for (_, tile) in &tiles { @@ -345,10 +314,7 @@ pub fn doodadscan( wmo_instances.len() ); - // Fold each unique WMO's doodad-set-**0** M2 props into the same instance table (set 0 is - // the WMO's always-on global set, per `WmoDoodadSet` doc), each multiplied by that WMO's - // own (deduped) placement count — one building placement = one instance of every set-0 prop - // it carries. + // Each WMO placement adds one instance of every set-0 prop, the always-on global set. let mut wmo_root_failures = 0u32; for (wmo_path, &count) in &wmo_instances { let root_path = wmo_path.to_ascii_lowercase(); // matches `load_wmo`'s own normalization @@ -374,7 +340,6 @@ pub fn doodadscan( eprintln!(" ({wmo_root_failures} WMO root(s) failed to read/parse — skipped)"); } - // Per-unique-model animation summary — one parse per model regardless of instance count. let mut summaries: HashMap = HashMap::new(); let mut parse_failures: Vec<(String, String)> = Vec::new(); for model in m2_instances.keys() { @@ -453,9 +418,7 @@ pub fn doodadscan( } } - // The rare material channels by NAME (each is <1% of instances, so the top-30 table - // almost never surfaces them): the exact models the phase-2/3 material-animation work - // verifies against. + // The rare material channels by name: each is under 1% of instances, below the top 30. println!(); println!("=== material-channel models (animated transparency / color / UV) ==="); let mut rare: Vec<(&String, &u32)> = m2_instances @@ -480,8 +443,7 @@ pub fn doodadscan( ); } - // Moving-seq0 models with a variation chain, by NAME (the random-variation arm `0x695100`): - // the exact placed models where variationIdx −1 vs 0 is visible at all. + // Moving seq-0 models with a variation chain, where the random pick (`0x695100`) shows. println!(); println!("=== moving-seq0 multi-variation models ==="); let mut varied: Vec<(&String, &u32)> = m2_instances @@ -501,9 +463,7 @@ pub fn doodadscan( ); } - // Emitters hosted on a moving bone chain, by NAME (0130 phase 4 grounding): the exact - // placed models where emitter bone-follow is visible at all — an emitter on a static - // chain sits at its rest pose whether or not we attach it. + // Emitters on a moving bone chain: the placed models where emitter bone-follow shows. println!(); println!("=== emitter-on-moving-bone models ==="); let mut movers: Vec<(&String, &u32)> = m2_instances diff --git a/crates/benilla-formats/src/bin/benilla-extract/shakecensus.rs b/crates/benilla-formats/src/bin/benilla-extract/shakecensus.rs index a4eee7c3..bee26f19 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/shakecensus.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/shakecensus.rs @@ -1,30 +1,7 @@ -//! `shakecensus`: the whole-table view of the **camera-shake** system (decisions 1540/1849) — the -//! 24 shipped `CameraShakes.dbc` presets, and everything that names one: the creature models -//! (footstep + death thud), and the `SpellEffectCameraShakes.dbc` groups the spell side reaches -//! them through. -//! -//! The scope instrument for B298 ("walking past an Ancient Protector shakes no screen"). It -//! answers, from the shipped data rather than from expectation, the two questions that turn that -//! one report into a system: *which* creatures are affected (not just the Ancients — every kodo, -//! giant, titan and dragon in the game), and whether `CreatureModelData` fields 11/12 really are -//! `CameraShakes` keys — because if they are, every live value must land on a real row of a -//! 24-row table, and the amplitudes must rank by mass. A column map that were merely plausible -//! would scatter across the id space and hit holes. -//! -//! Read the output beside the module doc of [`benilla_formats::CameraShakeCatalog`]: the creature -//! rows and the spell rows are visibly different shapes (phase, duration, the direction triples), -//! and that split is itself the check. -//! -//! The **group** half (decision 1849) is the same instrument one indirection out: 58 of the 1772 -//! shipped `SpellVisualKit` rows name a group in field 14, every value must land on the 9-row -//! table, and the census names the kits so a spell-side shake can be traced from a preset back to -//! the kit that fires it. Then the `$SHK` sweep, whose **host** column is the one that matters — -//! only a GameObject or DynamicObject decodes the tag, so a marker on a creature or spell-effect -//! model, or on one nothing hosts at all, is authored-but-inert. -//! -//! The roll-up at the end is deliberately **two tiers**, because they answer different questions: a -//! preset can be *named by a table* and still have nothing that fires it. Keeping them apart is -//! what caught this census's own field-12 mistake (see the `$SHK` block). +//! `shakecensus`: the 24 shipped `CameraShakes.dbc` presets and all that names one: creature +//! models (`CreatureModelData` fields 11 and 12, footstep and death thud), the 9 +//! `SpellEffectCameraShakes.dbc` groups that `SpellVisualKit` field 14 names, and `$SHK` animation +//! events. Every named id must land on a real row, or the column map is wrong. use std::collections::{BTreeMap, BTreeSet}; @@ -38,8 +15,7 @@ struct Users { death_thud: Vec, } -/// A model path reduced to a comparison key: lowercase, back-slashed, extension dropped. The DBCs -/// name models `.mdx`; the archives hold them as `.m2`, and the casing is arbitrary in both. +/// A model path's comparison key: DBCs name models `.mdx`, the archives hold `.m2`, in any case. fn mdx_key(path: &str) -> String { let p = path.to_ascii_lowercase().replace('/', "\\"); match p.rsplit_once('.') { @@ -48,7 +24,6 @@ fn mdx_key(path: &str) -> String { } } -/// The model's own file name — the census reads better without the `Creature\\Foo\\` prefix. fn short(path: &str) -> String { path.rsplit('\\').next().unwrap_or(path).to_string() } @@ -78,7 +53,7 @@ pub fn shakecensus(chain: &mut Chain) -> Result<()> { } } - // Which groups name each preset, and which kits name each group — the spell side's two hops. + // The spell side's two hops: the groups naming each preset, the kits naming each group. let mut group_users: BTreeMap> = BTreeMap::new(); for g in shakes.groups() { for id in g.shakes() { @@ -145,8 +120,7 @@ pub fn shakecensus(chain: &mut Chain) -> Result<()> { ); } - // Every value the creature columns actually carry must resolve — a miss would say the column - // map is wrong, which is the whole point of running this. + // Every value the creature columns carry must resolve; a miss means the column map is wrong. let mut dangling = Vec::new(); for (model_id, path, footstep, thud) in &models { for (col, id) in [ @@ -210,24 +184,13 @@ pub fn shakecensus(chain: &mut Chain) -> Result<()> { } } - // The third producer: the `$SHK` animation event, whose payload is a GROUP id (never a preset - // id). Only the GameObject and DynamicObject dispatchers decode it — `CGUnit_C::HandleAnimEvent` - // does not — so a `$SHK` on a creature M2 is inert, and this sweep is what says whether the - // shipped content authors any at all. + // The third producer, the `$SHK` animation event, whose payload is a group id, never a preset. println!("\n$SHK animation events — the third producer (GameObject / DynamicObject only)\n"); - // The HOST column is what decides whether a marker is live at all: only the GameObject - // (typemask 0x20, `0x5f3e20`) and DynamicObject (0x40, `0x5d58c0`) dispatchers decode `$SHK`. - // A marker on a creature M2 reaches `CGUnit_C::HandleAnimEvent`, which does not decode the tag - // (decision 1540); one on a model that is only ever a bone-attached `CEffect` reaches the fixed - // `$SND`/`$HIT` router `0x61f6f0`. Both are authored-but-inert. - // - // The DynamicObject side is easy to get wrong and this census got it wrong once: the anchor's - // area model is `SpellVisual` **field 12**, gated by field 11 ([`VisualStages::area_effect`] / - // `area_gate` — `0x5d57c0`, benilla's `entities::dest_fx`). Judging - // the `Spells\*` carriers by "no display table names it" reported six presets dead that are - // not: Warlock **Inferno** (SpellVisual 4859 → effect 2362 `Infernal_Impact_Base`) and - // Ragnaros's **Meteor** (7479 → 3007 `Meteor_Impact_Base`) both plant one, and both models - // carry `$SHK` group 2. So the host set is three, not two. + // Only the GameObject (typemask 0x20, `0x5f3e20`) and DynamicObject (0x40, `0x5d58c0`) + // dispatchers decode `$SHK`; on a creature it reaches `CGUnit_C::HandleAnimEvent` and on a + // bone-attached `CEffect` the fixed `$SND`/`$HIT` router `0x61f6f0`, and neither decodes it. + // A DynamicObject's area model is `SpellVisual` field 12, gated by field 11 (`0x5d57c0`): + // Inferno's visual 4859 and Meteor's 7479 plant one (effects 2362, 3007) with `$SHK` group 2. let gos = benilla_formats::load_gameobject_catalog(chain)?; let go_models: BTreeSet = gos.iter().map(|(_, path)| mdx_key(path)).collect(); let creature_models: BTreeSet = creatures.model_paths().map(mdx_key).collect(); @@ -316,10 +279,8 @@ pub fn shakecensus(chain: &mut Chain) -> Result<()> { tally(&shk_live_groups) ); - // The roll-up, in two tiers, because they answer different questions. A preset can be NAMED by - // a table and still have nothing that fires it — presets 7/8/9 sit only in group 2, and a group - // no kit and no `$SHK` marker names is a dead row. A shipped-but-unreachable preset is a - // finding, not a bug; it is printed rather than inferred so nobody has to guess. + // Two tiers: a preset a table names can still have nothing that fires it, as a group no kit + // and no live `$SHK` marker names is dead. let named: BTreeSet = users .keys() .copied() diff --git a/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs b/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs index 92a8d614..af26b7ca 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs @@ -1,6 +1,4 @@ -//! `spellvis`: dump a spell's visual chain — spell → SpellVisual stages → each kit's anim/sound/ -//! camera shake/attach effects, plus the missile block — decision 0099's phase-2 instrument -//! (columns per decisions 0107 and 1849). +//! `spellvis`: dump a spell's visual chain, every stage's kit and the missile. use anyhow::Result; use benilla_formats::Chain; @@ -38,9 +36,7 @@ pub fn run(chain: &mut Chain, spell_id: u32) -> Result<()> { kit.anim_id.map_or("—".into(), |a| a.to_string()), kit.sound.map_or("—".into(), |s| s.to_string()), ); - // The kit's CAMERA SHAKE (field 14, decision 1849): a - // `SpellEffectCameraShakes` GROUP id, expanded to the presets it fires. - // `benilla-extract … shakecensus` is the whole-table view. + // Field 14: a `SpellEffectCameraShakes` group id, expanded to its presets. if let Some(group) = kit.shake { match shakes.group(group) { Some(g) => println!( @@ -55,11 +51,9 @@ pub fn run(chain: &mut Chain, spell_id: u32) -> Result<()> { ), } } - // The kit's CharProcs (fields 15-34): what it does to the BODY. + // The kit's CharProcs (fields 15-34), what it does to the body. crate::charprocs::print_kit_procs(&visuals, kit_id, " "); - // The kit's BEAM, if it draws one (decision 0955): the chain CharProc's - // decoded `SpellChainEffects` row. `benilla-extract … chaincensus` is the - // whole-table view. + // The beam, if any: the chain CharProc's decoded `SpellChainEffects` row. if let Some(c) = kit.chain_proc() { match visuals.chain_effect(c.effect_id) { Some(e) => println!( @@ -81,7 +75,6 @@ pub fn run(chain: &mut Chain, spell_id: u32) -> Result<()> { ), } } - // The kit's attach-point emitter slots (phase 3): tag + effect model. for (tag, effect) in kit.effects() { println!( " attach {tag:#04x} effect {effect:<5} -> {}", @@ -93,9 +86,8 @@ pub fn run(chain: &mut Chain, spell_id: u32) -> Result<()> { } } } - // The missile block (phase 4): the projectile exists whenever Speed > 0; its model is - // field 7's SpellVisualEffectName (else the ammo/ErrorCube fallbacks) and it homes to - // field 9's dest-attach ordinal on a live target. + // A missile exists whenever `Speed > 0`: its model is field 7's `SpellVisualEffectName`, else + // the ammo or ErrorCube fallback, and it homes on a live target's field-9 dest-attach ordinal. if spell.speed > 0.0 { let tag = benilla_formats::MISSILE_ATTACH_TABLE .get(stages.missile_attach as usize) diff --git a/crates/benilla-formats/src/bin/benilla-extract/thudcensus.rs b/crates/benilla-formats/src/bin/benilla-extract/thudcensus.rs index 5f24fa1a..7235cd07 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/thudcensus.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/thudcensus.rs @@ -1,29 +1,14 @@ -//! `thudcensus`: the whole-table view of the **death thud** — the body-fall sound a corpse makes -//! as it lands (`$DTH` → `0x6236e0`), the sibling of the same event's camera shake. -//! -//! Two halves, because the report it exists for ("a big corpse hits the ground silently") has two -//! possible causes and they look identical in game: -//! -//! 1. **The table** — the `DeathThudLookups.dbc` matrix in full, `SizeClass × TerrainTypeSoundID` -//! → the land and water `SoundEntries` kits, named. An empty cell here is *authored* silence -//! (Sand and Soggy have no water column at all), and telling that apart from a bug is the -//! whole point of printing the grid rather than the rows. -//! 2. **The population** — which creature M2s actually key a `$DTH` at all, on which sequences, -//! and what size class each resolves to. A model that keys none is silent in the reference -//! too; a model that keys one and still sounds wrong is a runtime question, not a data one. -//! -//! The cross-check that validates the column map: every live `DeathThudLookups` sound id must -//! land on a real `SoundEntries` row, every terrain axis value on a real `TerrainTypeSounds` row, -//! and every model's `SizeClass` inside `0..=4` — a shifted field would scatter across the id -//! space and hit holes, exactly as `shakecensus` checks fields 11/12. +//! `thudcensus`: the death thud a corpse makes as it lands (`$DTH`, `0x6236e0`): the full +//! `DeathThudLookups.dbc` grid, `SizeClass × TerrainTypeSoundID` to land and water `SoundEntries` +//! kits, and the creature models that key a `$DTH`. An empty cell is authored silence (Sand and +//! Soggy have no water column), as is a model with no `$DTH`. use std::collections::{BTreeMap, BTreeSet}; use anyhow::Result; use benilla_formats::Chain; -/// A model path reduced to a comparison key: lowercase, back-slashed, extension dropped. The DBCs -/// name models `.mdx`; the archives hold them as `.m2`. +/// A model path's comparison key: DBCs name models `.mdx`, the archives hold `.m2`. fn mdx_key(path: &str) -> String { let p = path.to_ascii_lowercase().replace('/', "\\"); match p.rsplit_once('.') { @@ -32,8 +17,7 @@ fn mdx_key(path: &str) -> String { } } -/// The `SizeClass` names the shipped kits give themselves (`DeathThudSmallDirt` … -/// `DeathThudColossalWood`). +/// The `SizeClass` names the kits carry, `DeathThudSmallDirt` to `DeathThudColossalWood`. const SIZE_NAMES: [&str; 5] = ["Small", "Medium", "Large", "Giant", "Colossal"]; fn size_name(class: i32) -> String { @@ -50,8 +34,7 @@ pub fn thudcensus(chain: &mut Chain) -> Result<()> { let creatures = benilla_formats::load_creature_catalog(chain)?; // ── 1 · the table ──────────────────────────────────────────────────────────────────────── - // The terrain axis, with the TerrainType rows that reach each id — a terrain sound nothing - // names is unreachable however well-populated its column is. + // The terrain axis with the TerrainType rows naming each id; an unnamed sound is unreachable. let mut terrain_names: BTreeMap> = BTreeMap::new(); for terrain in 0..64 { if let Some(sound) = steps.sound_class_of(terrain) { @@ -104,9 +87,7 @@ pub fn thudcensus(chain: &mut Chain) -> Result<()> { } // ── 2 · the population ─────────────────────────────────────────────────────────────────── - // Which creature models key a `$DTH`, and the size class(es) the displays that reach each one - // resolve to. A model whose displays disagree is normal — 2 971 displays override their - // model's column — and the census prints the whole set so a mis-sized thud is visible. + // 2971 displays override their model's size class, so each model prints the whole set. let mut resolved: BTreeMap> = BTreeMap::new(); for (display, model) in creatures.display_models() { if let Some(class) = creatures.size_class(display) { @@ -204,16 +185,10 @@ pub fn thudcensus(chain: &mut Chain) -> Result<()> { off_axis.len() ); - // ── 3 · where a corpse is SILENT ───────────────────────────────────────────────────────── - // The asymmetry the table half implies and only this sweep sizes. Both lookups are keyed on - // `TerrainType.SoundID`, and `TerrainType 10 "None"` — the unauthored default a WMO surface - // takes when its `MOMT+0x20` says nothing — has `SoundID = 0`. `FootstepTerrainLookup` has a - // **row at terrain sound 0** for 17 footstep classes; `DeathThudLookups` has **none at all**. - // So the same floor that creaks underfoot swallows the body that lands on it, and that is - // authored data in both directions, not a gap on either side. - // - // What this section measures is how much of the shipped building stock that covers, because - // "a corpse landed silently indoors" is a *correct* observation that looks exactly like a bug. + // ── 3 · where a corpse is silent ───────────────────────────────────────────────────────── + // Both lookups key on `TerrainType.SoundID`, and TerrainType 10 "None", the default of a WMO + // surface whose `MOMT+0x20` is unset, has `SoundID` 0. `FootstepTerrainLookup` has rows there + // for 17 footstep classes and `DeathThudLookups` none, so a corpse on such a floor is silent. let ftl_at_zero: Vec = (0..256) .filter(|c| steps.resolve_terrain(*c, 10).is_some()) .collect(); @@ -244,9 +219,7 @@ pub fn thudcensus(chain: &mut Chain) -> Result<()> { for ground_type in root.material_ground_types() { materials += 1; *by_terrain.entry(ground_type).or_default() += 1; - // A material thuds iff its terrain id reaches a nonzero TerrainTypeSounds class that - // the thud table has a row for. Size class is irrelevant to *whether* — every class - // 0..=4 is populated on the same terrain axis. + // Size class 0 stands for all: every class 0..=4 has rows on the same terrain axis. if steps .sound_class_of(ground_type) .is_some_and(|ts| thuds.resolve(0, ts).is_some()) @@ -284,8 +257,7 @@ pub fn thudcensus(chain: &mut Chain) -> Result<()> { "\n{silent_roots} of {wmo_roots} WMO roots have NO surface that can thud; {} do", thudding.len() ); - // All of them, not a head: the question this list answers is "does THIS building thud", and a - // truncated list cannot answer it. 121 lines is a census, not a flood. + // Untruncated: the list answers whether a given building thuds. for p in &thudding { println!(" thuds somewhere: {p}"); } diff --git a/crates/benilla-formats/src/bin/mpqx.rs b/crates/benilla-formats/src/bin/mpqx.rs index 3e7389a5..fc8a3cb5 100644 --- a/crates/benilla-formats/src/bin/mpqx.rs +++ b/crates/benilla-formats/src/bin/mpqx.rs @@ -1,9 +1,6 @@ -//! One-off extraction helper (session tool, not shipped): `mpqx `. -//! Reads through the SAME patch chain as the runtime ([`benilla_formats::Chain`], the full vanilla -//! mount law) — it used to hand-roll a partial archive list in `benilla-mpq` and silently -//! miss whole archives (`wmo.MPQ`: "not found" for every building/ship). A path the top archive -//! **delete-marks** is reported as DELETED (exit 2) — the client doesn't load it; look for a -//! `Blizzard_*` addon replacement (decision 0246). +//! Extraction helper, not shipped: `mpqx `, read through the +//! runtime's patch chain ([`benilla_formats::Chain`]). It exits 2 on a path the top archive +//! delete-marks, which the client does not load. use std::path::Path; @@ -32,7 +29,7 @@ fn main() { from ); } - // Chain::read distinguishes the tombstone in its message; surface it with the old exit code. + // `Chain::read` names a delete-marked path in its error message. Err(e) if e.to_string().contains("deleted from patch chain") => { eprintln!("{e} — the client does not load this path (see decision 0246)"); std::process::exit(2); diff --git a/crates/benilla-formats/src/camera_shakes.rs b/crates/benilla-formats/src/camera_shakes.rs index 24e890ad..de7bb993 100644 --- a/crates/benilla-formats/src/camera_shakes.rs +++ b/crates/benilla-formats/src/camera_shakes.rs @@ -1,69 +1,12 @@ -//! `CameraShakes.dbc` — the shipped camera-shake presets. +//! `CameraShakes.dbc`, the 24 shipped camera-shake presets, and `SpellEffectCameraShakes.dbc`, +//! the 9 groups of up to three presets fired at one point. //! -//! A shake is **not** authored per model or per animation: the model names a preset id, and this -//! 24-row table holds the shape. Two id spaces reach it (decisions 1540, 1849): -//! -//! - **The big-creature footstep/death thud** names a preset **directly** — -//! `CreatureModelData.FootstepShakeSize` (field 11) and `.DeathThudShakeSize` (field 12), read -//! here through [`CreatureCatalog::footstep_shake`](crate::CreatureCatalog::footstep_shake). -//! - **Everything else names a GROUP** — `SpellEffectCameraShakes.dbc`, the 9-row indirection read -//! here too ([`SpellShakeGroup`]): up to three `CameraShakes` ids fired at one point. Both -//! `SpellVisualKit` field 14 and the `$SHK` animation event speak this id space and never the -//! preset one (`[0xc0d814]`, bound `0xc0d818`). -//! -//! **Layout — VERIFIED** against build 5875 (header + row decode, 2026-08-22): `24 × 8 × 32 B`, -//! string block empty. The **column names** are the conventional map (wowdev.wiki + vmangos -//! `DBCStructure.h`), and the shipped values corroborate them structurally rather than by -//! assertion: rows 4/5/6, 7/8/9, 13/14/15, 16/17/18, 36/37/38 and 76/77/78 are **direction -//! triples** — identical `Duration` within a triple, `Direction` running 0·1·2 — which is what a -//! per-axis column looks like and nothing else does. -//! -//! **The two families are visibly distinct**, which is the check that the creature columns really -//! do index this table: -//! -//! | family | rows | `Type`/`Direction` | `Duration` | `Phase` | `Coefficient` | -//! |---|---|---|---|---|---| -//! | creature (footstep + thud) | 1, 2, 10, 11, 12 | `1` / `2` | 0.40–0.65 s | **nonzero** | 1.0–2.0 | -//! | spell (the direction triples) | 4–9, 13–18, 36–38, 76–78 | 0 or 1 / 0·1·2 | 0.6–20 s | **0.0** | 0.4–3.0 | -//! -//! and within the creature family the amplitude ranks by mass exactly as it should: the Ancient -//! Protector and the kodo take row 1 (`amplitude 2.0`), the Ancient of Lore/War, the giants and -//! the dragons take row 2 (`amplitude 7.0`), and the death thuds (rows 11/12) are longer and -//! stronger than the footsteps. -//! -//! **The semantics** — what `ShakeType` and `Direction` select, and the distance attenuation the -//! evaluator applies — were settled against the reference in decision 1540 and live beside the -//! evaluator, in `benilla-app`'s `camera_shake`. This module stays the shipped data and nothing -//! more. -//! -//! ## The group table — `SpellEffectCameraShakes.dbc` (9 rows × 4 fields, recsize 16) -//! -//! Every **spell-side** producer names a row of *this* table, never a `CameraShakes` id: the -//! reference's shake spawner is reached through a group that fires **up to three presets at one -//! point**. Layout verified against build 5875 the same way ([`SpellShakeGroup`]): `ID` plus three -//! `CameraShakes` ids at `+0x4/+0x8/+0xc`, zeros skipped, string block empty. The shipped rows: -//! -//! | group | slots | group | slots | -//! |---|---|---|---| -//! | 1 | 4 · 5 · 6 | 6 | 4 · 5 · 6 | -//! | 2 | 7 · 8 · 9 | 7 | 18 · 17 · 18 | -//! | 3 | 1 | 26 | 36 · 37 · 38 | -//! | 4 | 15 · 14 · 15 | 66 | 76 · 77 · 78 | -//! | 5 | 11 | | | -//! -//! **The three slots are slots, not axes.** Group 4 lists `15` twice and group 7 lists `18` twice — -//! a per-axis reading cannot survive that. The *intent* is clearly a per-axis triple (the ids named -//! are the `direction` 0·1·2 members of a `CameraShakes` family), but the duplicate simply loses the -//! evaluator's strict-`>` tie-break and contributes nothing. -//! -//! **Reachability.** The creature columns name `CameraShakes` {1, 2, 10} (footstep) and -//! {10, 11, 12, 38} (thud); the nine groups name {1, 4, 5, 6, 7, 8, 9, 11, 14, 15, 17, 18, 36, 37, -//! 38, 76, 77, 78}. Their union is 21 of the 24 shipped rows — **13, 16 and 56 are named by neither -//! table**. All three are `direction 0` rows; 13 and 16 are the missing member of a family whose -//! other two *are* named (14/15 by group 4, 17/18 by group 7), and 56 stands alone. See -//! `benilla-extract shakecensus`, which prints the whole map — and keeps "named by a table" apart -//! from "has a live producer behind it", which are the same 21 rows here only because every group -//! turns out to have a producer. +//! Two id spaces, never conflated: the creature footstep and death-thud columns +//! (`CreatureModelData` fields 11 and 12, read through +//! [`CreatureCatalog::footstep_shake`](crate::CreatureCatalog::footstep_shake)) name a preset +//! directly, while `SpellVisualKit` field 14 and the `$SHK` animation event name a group, never a +//! preset (`[0xc0d814]`, bound `0xc0d818`). Column names follow vmangos `DBCStructure.h`; what +//! `ShakeType` and `Direction` select is the evaluator's, `benilla-app`'s `camera_shake`. use std::collections::HashMap; @@ -76,17 +19,13 @@ use crate::Chain; const CAMERA_SHAKES: &str = "DBFilesClient\\CameraShakes.dbc"; const SPELL_EFFECT_CAMERA_SHAKES: &str = "DBFilesClient\\SpellEffectCameraShakes.dbc"; -/// One `CameraShakes.dbc` row — the authored shape of a shake, in the table's own units. -/// -/// Every field below `id` is data as shipped; see the module doc for what is verified (the layout -/// and the family split) and what still awaits the reference (the evaluator's reading of them). +/// One `CameraShakes.dbc` row: a shake's authored shape, in the table's own units. #[derive(Debug, Clone, Copy, PartialEq)] pub struct CameraShake { pub id: u32, - /// `ShakeType` — 0 or 1 across the shipped table; every creature row is `1`. + /// `ShakeType`: 0 or 1 as shipped; every creature row is 1. pub shake_type: u32, - /// `Direction` — 0·1·2, running consistently across the spell rows' triples; every creature - /// row is `2`. + /// `Direction`: 0, 1 or 2, one per member of a spell-side triple; every creature row is 2. pub direction: u32, pub amplitude: f32, pub frequency: f32, @@ -96,32 +35,25 @@ pub struct CameraShake { pub coefficient: f32, } -/// One `SpellEffectCameraShakes.dbc` row — **up to three [`CameraShake`] ids fired at one point**. -/// -/// This is the id space every *spell-side* producer speaks: `SpellVisualKit` field 14 and the -/// `$SHK` animation event both name a **group**, never a preset. Zero is the empty slot; the -/// reference walks the three in order and skips them (`edi = 3`, `0x6ecb40`). +/// One `SpellEffectCameraShakes.dbc` row: up to three [`CameraShake`] ids fired at one point. The +/// reference walks the three slots in order and skips a zero (`0x6ecb40`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SpellShakeGroup { pub id: u32, - /// The three slots as shipped, zeros included — iterate with [`Self::shakes`]. + /// As shipped, zeros included; [`Self::shakes`] skips them. pub slots: [u32; 3], } impl SpellShakeGroup { - /// The populated slots, in the reference's walk order. A group can name the same preset twice - /// (group 4 is `15 · 14 · 15`); the duplicate is kept here and loses the evaluator's tie-break. + /// The populated slots in walk order. A duplicate (group 4 is `15, 14, 15`) is kept: the slots + /// are slots, not axes, and it loses the evaluator's strict `>` tie-break. pub fn shakes(&self) -> impl Iterator + '_ { self.slots.iter().copied().filter(|&id| id != 0) } } -/// The camera-shake tables: `CameraShakes.dbc` keyed by row id, and the -/// `SpellEffectCameraShakes.dbc` groups that index it — **two id spaces, never conflated** -/// (the same per-table split [`crate::SpellVisualCatalog`] keeps). -/// -/// `Default` is the **empty** catalog — the same thing "the DBC failed to load" already means to -/// every consumer (every lookup misses and the caller takes its documented fallback). +/// Both camera-shake tables, presets and groups each keyed by their own id. `Default` is the empty +/// catalog, which is what a failed load means to every consumer. #[derive(Default)] pub struct CameraShakeCatalog { rows: HashMap, @@ -129,8 +61,7 @@ pub struct CameraShakeCatalog { } impl CameraShakeCatalog { - /// One preset by id. `None` for id 0 (the "no shake" value both creature columns use) and for - /// any id the shipped table does not carry. + /// One preset by id; 0, the creature columns' "no shake", is never a row. pub fn get(&self, id: u32) -> Option<&CameraShake> { self.rows.get(&id) } @@ -148,8 +79,7 @@ impl CameraShakeCatalog { self.rows.is_empty() } - /// One `SpellEffectCameraShakes.dbc` group by id — the spell side's whole lookup. `None` for - /// id 0 (the "no shake" value the kit column uses) and for any id the 9-row table lacks. + /// One group by id; 0, the kit column's "no shake", is never a row. pub fn group(&self, id: u32) -> Option<&SpellShakeGroup> { self.groups.get(&id) } @@ -163,16 +93,14 @@ impl CameraShakeCatalog { self.groups.len() } - /// Seed one preset — for fixtures exercising the evaluator without a client install (the same - /// builder shape [`crate::SpellVisualCatalog::with_chain_effect`] uses). The live path is - /// [`load_camera_shakes`]. + /// Seed one preset, for fixtures that run the evaluator without an install. #[must_use] pub fn with_row(mut self, row: CameraShake) -> Self { self.rows.insert(row.id, row); self } - /// Seed one group. See [`Self::with_row`]. + /// Seed one group, for fixtures. #[must_use] pub fn with_group(mut self, group: SpellShakeGroup) -> Self { self.groups.insert(group.id, group); @@ -205,12 +133,8 @@ fn spell_effect_camera_shakes_schema() -> Schema { s } -/// Read both camera-shake tables off the patch chain. -/// -/// `SpellEffectCameraShakes.dbc` is loaded beside `CameraShakes.dbc` rather than on demand: it is -/// nine rows, it ships in the same archive, and every spell-side producer needs both to resolve a -/// single shake. A missing or malformed table is a hard error like every other DBC (1300) — the -/// caller's fallback is to run with no shake system at all, which is what an empty catalog means. +/// Read both camera-shake tables off the patch chain. A missing or malformed table is an error; +/// the caller's fallback is the empty catalog, no shakes at all. pub fn load_camera_shakes(chain: &mut Chain) -> Result { let bytes = chain .read_file(CAMERA_SHAKES) @@ -259,11 +183,8 @@ pub fn load_camera_shakes(chain: &mut Chain) -> Result { mod tests { use std::collections::BTreeSet; - /// The nine shipped `SpellEffectCameraShakes.dbc` groups, verbatim — the id space every - /// spell-side producer speaks. Sparse ids (`…7, 26, 66`) and duplicate slots (group 4 lists - /// `15` twice, group 7 lists `18` twice) are both load-bearing: the sparseness is what makes a - /// wrong column map impossible to miss, and the duplicate is what proves the three slots are - /// **slots, not axes**. + /// The nine shipped groups, verbatim. The sparse ids expose a wrong column map; the duplicate + /// slots in groups 4 and 7 show the three are slots, not axes. #[test] fn the_spell_shake_groups_decode_as_shipped() { let data = crate::wow_data_or_skip!(); @@ -299,8 +220,7 @@ mod tests { ); } - /// **The property that licenses the column map**: every id the group table names must land on a - /// real row of the 24-row preset table. A merely plausible map would dangle. + /// The check on the column map: a merely plausible one would leave group slots dangling. #[test] fn every_group_slot_resolves_to_a_preset() { let data = crate::wow_data_or_skip!(); @@ -318,8 +238,7 @@ mod tests { "the 18 presets the spell side reaches" ); - // The census the module doc states: with the creature columns' {1,2,10} and - // {10,11,12,38}, three shipped rows are named by NEITHER table. + // The footstep column names {1, 2, 10} and the thud column {10, 11, 12, 38}. let creature: BTreeSet = [1, 2, 10, 11, 12, 38].into_iter().collect(); let all: BTreeSet = cat.iter().map(|r| r.id).collect(); let unreached: Vec = all.difference(&(&named | &creature)).copied().collect(); diff --git a/crates/benilla-formats/src/chain.rs b/crates/benilla-formats/src/chain.rs index 2ef67c90..980900b1 100644 --- a/crates/benilla-formats/src/chain.rs +++ b/crates/benilla-formats/src/chain.rs @@ -1,16 +1,6 @@ -//! The vanilla patch chain — a priority-ordered set of MPQ archives, read through `benilla-mpq`. -//! -//! Replaces `wow-mpq`'s `PatchChain` *and* the old `ChainReader` (decision 0021). Those were two types -//! because `wow-mpq`'s `Archive::open` re-parsed the hash/block (and the useless `(attributes)`) tables -//! on every open, so `ChainReader` bolted a `Mutex>` handle-cache on top to avoid -//! re-paying that per read. `benilla_mpq::Archive` now caches its parsed tables in an `Arc` and reads -//! `&self` (a fresh OS handle per read, no seek-state sharing), so the cache is gone and one `Chain` -//! serves both the `&self` concurrent Bevy `AssetReader` path and the `&mut` streaming-loader path. -//! -//! Later archives override earlier ones for files sharing an internal path (so a patch archive -//! wins); a read resolves a name to the highest-priority archive that holds it. Base content -//! archives carry no `(listfile)`, so resolution is by name **hash**, which works without one. -//! Which archives mount, and in what order, is [`mount_order`]'s law (decision 1300). +//! The vanilla patch chain: a priority-ordered set of MPQ archives, read through `benilla-mpq`. A +//! read resolves a name to the highest-priority archive holding it, so a patch wins; base archives +//! carry no `(listfile)`, so resolution is by name hash. use std::collections::HashSet; use std::path::Path; @@ -28,13 +18,12 @@ pub struct ChainEntry { /// A priority-ordered patch chain of MPQ archives (`Send + Sync`; reads are `&self` and lock-free). pub struct Chain { - /// Ascending priority — later archives win. `resolve` scans back-to-front. + /// Ascending priority: later archives win. archives: Vec, } -/// `patch-?.MPQ` with the reference's FindFirstFileW semantics: `?` matches **exactly one** -/// character, case-insensitively — `patch-3.MPQ` mounts, `patch-10.MPQ` does not (the glob -/// template `0x82edbc` and its wrapper `0x42ad10`). +/// `patch-?.MPQ` as the reference's `FindFirstFileW` glob matches it (template `0x82edbc`, wrapper +/// `0x42ad10`): `?` is exactly one character, any case, so `patch-10.MPQ` never mounts. fn is_patch_glob_match(name: &str) -> bool { let lower = name.to_ascii_lowercase(); let Some(mid) = lower @@ -46,14 +35,10 @@ fn is_patch_glob_match(name: &str) -> bool { mid.chars().count() == 1 } -/// The vanilla mount law over a `Data` directory listing, **ascending priority** (decision 1300; -/// the mounter `0x403740`): the ten -/// [`VANILLA_BASE_ORDER`] archives at their fixed priorities, then `patch.MPQ`, then every -/// `patch-?.MPQ` sorted ascending by case-folded name — the binary sorts its glob matches -/// *descending* (`strnicmp`) and walks the array backwards, so the order is deterministic, never -/// filesystem enumeration; `patch-3` overrides `patch-2` — then `speech2.MPQ` above every patch. -/// Names are matched case-insensitively (the reference runs on a case-insensitive filesystem) and -/// returned as found on disk; absent archives are simply not in the result. +/// The reference's mount order over a `Data` listing, ascending priority (`0x403740`): the ten +/// [`VANILLA_BASE_ORDER`] archives, `patch.MPQ`, every `patch-?.MPQ` by case-folded name (the +/// reference sorts descending with `strnicmp` and walks backwards), then `speech2.MPQ`. Names +/// match case-insensitively and come back as found on disk. fn mount_order(dir_names: &[String]) -> Vec { let find = |want: &str| { dir_names @@ -75,13 +60,11 @@ fn mount_order(dir_names: &[String]) -> Vec { } impl Chain { - /// Open a chain from a vanilla `Data` directory (every archive [`mount_order`] finds in it, - /// lowest priority first) or a single `.MPQ` file (just that archive). + /// Open a `Data` directory's archives in [`mount_order`], or a single `.MPQ` file. /// - /// An archive that exists but fails to open is a hard error, deliberately: the reference logs - /// `"Failed to open archive"` and continues, but a silent skip turns a corrupt `dbc.MPQ` — or a - /// modder's malformed `patch-3.MPQ` — into cryptic missing-file failures far downstream. Same - /// composite on a healthy install; a clear error instead of a quirk on a broken one (1300). + /// Deviation: an archive that fails to open is an error, where the reference logs + /// `"Failed to open archive"` and goes on, because a skipped corrupt archive surfaces only as + /// missing files far downstream. pub fn open(path: &Path) -> Result { let mut archives = Vec::new(); if path.is_dir() { @@ -113,34 +96,29 @@ impl Chain { Ok(Self { archives }) } - /// The highest-priority archive with an *entry* for `name` (readable file **or** delete-marker), - /// if any. Stops at the winning archive — including a tombstone, which correctly shadows any - /// lower-priority copy (decision 0246). Callers that want "readable" must check - /// [`Archive::is_delete_marker`]. + /// The highest-priority archive with an entry for `name`, a delete marker included, as a + /// tombstone shadows every lower copy: check [`Archive::is_delete_marker`] for a readable file. fn resolve(&self, name: &str) -> Option<&Archive> { self.archives.iter().rev().find(|a| a.contains(name)) } - /// Whether the chain holds `name` as a **readable** file (accepts `/` or `\`; case-insensitive). - /// A path whose winning entry is a delete-marker is *not* present — the client deleted it (0246). + /// Whether `name` (`/` or `\`, any case) is a readable file, not a delete marker. pub fn contains(&self, name: &str) -> bool { self.resolve(name) .is_some_and(|a| !a.is_delete_marker(name)) } - /// The path of the archive `name` resolves to (the winning override) — for debugging / extract. + /// The path of the archive `name` resolves to, for debugging and extraction. pub fn find_file_archive(&self, name: &str) -> Option<&Path> { self.resolve(name).map(|a| a.path()) } - /// Read a file by internal path (accepts `/` or `\`), from its winning archive. `&self`: safe to - /// call concurrently (the Bevy `AssetReader` does). + /// Read a file by internal path (`/` or `\`) from its winning archive. pub fn read(&self, name: &str) -> Result> { let archive = self .resolve(name) .ok_or_else(|| anyhow!("file not in patch chain: {name}"))?; - // A tombstone shadows every lower copy: the path is deleted from the composite, so this is a - // clean "not found", not a fall-through to a stale base version (decision 0246). + // A tombstone deletes the path from the composite: not found, never a stale lower copy. if archive.is_delete_marker(name) { bail!( "file deleted from patch chain: {name} (tombstoned by {})", @@ -152,19 +130,14 @@ impl Chain { .with_context(|| format!("reading {name} from {}", archive.path().display())) } - /// `&mut` alias of [`Chain::read`] — kept so the streaming-loader call sites that thread a - /// `&mut Chain` read exactly as they did against `wow-mpq`'s `PatchChain`. + /// `&mut` alias of [`Chain::read`] for call sites that thread a `&mut Chain`. pub fn read_file(&mut self, name: &str) -> Result> { self.read(name) } - /// List the chain's named files with sizes. Dev/extract use only — files absent from every - /// listfile (most of `texture.MPQ`) are reachable by name but not enumerated. - /// - /// Unions the `(listfile)` of **every** archive that carries one: each archive's listfile names - /// only the files *it* holds, so resolving `(listfile)` like an ordinary overridden file (as this - /// used to) returns just the top patch archive's sliver — 92 names from `patch-2.MPQ` instead of - /// the chain's ~86k. Sizes still resolve per-name to the winning archive. + /// The chain's named files with sizes, for development and extraction; a file in no listfile + /// (most of `texture.MPQ`) is readable by name but not listed. Unions every archive's + /// `(listfile)`, as each names only its own files; sizes come from the winning archive. pub fn list(&self) -> Result> { let mut seen = HashSet::new(); let mut out = Vec::new(); @@ -174,12 +147,12 @@ impl Chain { }; for raw in String::from_utf8_lossy(&listfile).split([';', '\r', '\n']) { let name = raw.trim(); - // Dedupe across archives the way MPQ hashing compares names: case-insensitive, `/`≡`\`. + // Dedupe the way MPQ hashing compares names: any case, `/` and `\` alike. if name.is_empty() || !seen.insert(name.replace('/', "\\").to_ascii_lowercase()) { continue; } if let Some(a) = self.resolve(name) { - // A tombstoned path isn't a file in the composite — don't list it (0246). + // A tombstoned path is not a file in the composite. if a.is_delete_marker(name) { continue; } @@ -207,11 +180,9 @@ mod tests { assert!(is_patch_glob_match("patch-2.MPQ")); assert!(is_patch_glob_match("patch-3.MPQ")); assert!(is_patch_glob_match("PATCH-A.mpq")); - // Zero or two-plus characters: FindFirstFileW's `?` is exactly one. assert!(!is_patch_glob_match("patch-.MPQ")); assert!(!is_patch_glob_match("patch-10.MPQ")); assert!(!is_patch_glob_match("patch-33.MPQ")); - // Not the glob's shape at all. assert!(!is_patch_glob_match("patch.MPQ")); assert!(!is_patch_glob_match("patch-2.MPQ.bak")); assert!(!is_patch_glob_match("mypatch-2.MPQ")); @@ -219,8 +190,7 @@ mod tests { #[test] fn mount_order_is_the_carved_law() { - // A shuffled install with a custom patch, plus files the mounter must ignore: - // base.MPQ (telemetry-only in the reference), backup.MPQ, loose non-archives. + // base.MPQ is telemetry-only in the reference and never mounts. let dir = owned(&[ "patch-2.MPQ", "backup.MPQ", @@ -249,8 +219,6 @@ mod tests { #[test] fn patch_sort_is_ascending_and_case_folded() { - // Later wins in `Chain`, so ascending case-folded order makes patch-3 override patch-2 - // and `patch-B` override `patch-a` ('a' < 'b' after the strnicmp-style fold). let dir = owned(&["patch-B.MPQ", "patch-3.MPQ", "patch-a.MPQ", "patch-2.MPQ"]); assert_eq!( mount_order(&dir), diff --git a/crates/benilla-formats/src/characters/customization.rs b/crates/benilla-formats/src/characters/customization.rs index 93870c0d..f420ef5b 100644 --- a/crates/benilla-formats/src/characters/customization.rs +++ b/crates/benilla-formats/src/characters/customization.rs @@ -1,24 +1,9 @@ -//! Character-creation source data (decision 0423): the per-race body displayIds (ChrRaces), the -//! race/class combos (CharBaseInfo), and the five appearance-dial *ranges* per (race, sex) derived -//! from CharSections + CharHairGeosets + CharacterFacialHairStyles. -//! -//! **The ranges are data-derived, never hardcoded** (the client's CharSections grid, built at -//! `0x476020`). Each dial cycles index `0..count-1`; the counts come from: -//! - **skin color** = distinct `ColorIndex` of CharSections `SECTION_SKIN` (variation 0) for (race, sex); -//! - **face** = distinct `VariationIndex` of `SECTION_FACE`; -//! - **hair style** = distinct `VariationID` of **CharHairGeosets** for (race, sex), `max(1, …)` (the -//! client's `0x478540` count fetcher — bald is style 0); -//! - **hair color** = distinct `ColorIndex` of `SECTION_HAIR`; -//! - **facial hair** = distinct `VariationID` of **CharacterFacialHairStyles** for (race, sex). -//! -//! By construction every `(skin, face, hairStyle, hairColor, facialHair)` tuple in the product of a -//! (race, sex)'s ranges passes vmangos's `Player::ValidateAppearance` — the server checks, in order, -//! that CharSections rows exist for `SKIN(0, skin)`, `FACE(face, skin)` (face is keyed by *skinColor*), -//! `HAIR(hairStyle, hairColor)`, and — unless the (race, sex) is facial-hair-excluded (Tauren, or a -//! female that isn't Night Elf / Undead) — `FACIAL_HAIR(facialHair, hairColor)`, plus a -//! CharacterFacialHairStyles row for `facialHair`. The real 5875 grids are *complete* over each -//! coupled pair, so the independent per-dial counts never produce an invalid tuple — asserted -//! exhaustively against the transcribed predicate in the real-data test. +//! Character-creation source data from ChrRaces, CharBaseInfo and CharStartOutfit, and the five +//! appearance-dial ranges per (race, sex), counted from the DBCs: skin colour, face and hair colour +//! off the CharSections rows the client groups into its grid (`0x476020`), hair and facial-hair +//! styles off CharHairGeosets and CharacterFacialHairStyles. Every tuple in a (race, sex)'s ranges +//! passes vmangos's `Player::ValidateAppearance`, since the shipped grids are complete over each +//! coupled pair. use std::collections::{HashMap, HashSet}; @@ -35,11 +20,7 @@ const CHAR_HAIR_GEOSETS: &str = "DBFilesClient\\CharHairGeosets.dbc"; const CHAR_FACIAL_HAIR_STYLES: &str = "DBFilesClient\\CharacterFacialHairStyles.dbc"; const CHAR_SECTIONS: &str = "DBFilesClient\\CharSections.dbc"; -// CharSections `SectionType` values the range derivation reads (the hair apply `0x4784c0` pushes -// 3; the same constants `sections.rs` uses — kept local so the two concerns don't couple). -// `SECTION_FACIAL_HAIR` (2) isn't here: the facial-hair *count* comes from -// CharacterFacialHairStyles, not CharSections (the test's ValidateAppearance transcription defines -// it there, where it's used). +// CharSections `SectionType` values the dial ranges read (hair is 3 at `0x4784c0`). const SECTION_SKIN: u8 = 0; const SECTION_FACE: u8 = 1; const SECTION_HAIR: u8 = 3; @@ -47,12 +28,10 @@ const SECTION_HAIR: u8 = 3; /// A CharSections availability key: `(race, sex, sectionType, variation, color)`. type SectionKey = (u8, u8, u8, u8, u8); -/// The 8 playable races (ChrRaces ids 1–8); higher rows (Goblin = 9) aren't character-creatable. +/// The playable races, ChrRaces ids 1–8; Goblin (9) and above are not creatable. const PLAYABLE_RACES: [u8; 8] = [1, 2, 3, 4, 5, 6, 7, 8]; -/// The 8 playable races' ChrRaces client fileStrings (col 15), a **frozen fact** of the build — the -/// load-time misparse guard for the string-column positions in [`load_races`] (if col 15 drifted, -/// these wouldn't match), and the GlueStrings key stems (`RACE_INFO_SCOURGE`, not `…_UNDEAD`). +/// The shipped ChrRaces fileStrings (col 15), guarding [`load_races`]' string columns. const KNOWN_FILES: [(u8, &str); 8] = [ (1, "Human"), (2, "Orc"), @@ -64,15 +43,12 @@ const KNOWN_FILES: [(u8, &str); 8] = [ (8, "Troll"), ]; -/// The **raw** 1.12 `CharBaseInfo` content, race → classes (a frozen fact of the file; class ids: -/// Warrior 1, Paladin 2, Hunter 3, Rogue 4, Priest 5, Shaman 7, Mage 8, Warlock 9, Druid 11). Used -/// only as the load-time misparse guard (proves the 2-byte parse landed) — note it is NOT the -/// playable set: the file carries one dead pair, Dwarf-Mage, which the real client never offers -/// (see [`UNUSED_COMBOS`]). +/// The shipped `CharBaseInfo` rows by race, the load-time guard on the 2-byte parse; not the +/// playable set, as it holds the dead Dwarf-Mage row ([`UNUSED_COMBOS`]). const KNOWN_COMBOS: [(u8, &[u8]); 8] = [ (1, &[1, 2, 4, 5, 8, 9]), // Human (2, &[1, 3, 4, 7, 9]), // Orc - (3, &[1, 2, 3, 4, 5, 8]), // Dwarf — 8 (Mage) is the dead row, stripped after the guard + (3, &[1, 2, 3, 4, 5, 8]), // Dwarf; 8 (Mage) is the dead row (4, &[1, 3, 4, 5, 11]), // Night Elf (5, &[1, 4, 5, 8, 9]), // Undead (6, &[1, 3, 7, 11]), // Tauren @@ -80,19 +56,11 @@ const KNOWN_COMBOS: [(u8, &[u8]); 8] = [ (8, &[1, 3, 4, 5, 7, 8]), // Troll ]; -/// The `CharBaseInfo` pairs the real client never offers on the create screen — leftovers in the -/// data, not creatable content. Dwarf-Mage (3, 8) sits fully populated in both CharBaseInfo *and* -/// CharStartOutfit, yet the real client hardcodes the skip: its class-list builder (`0x4706b0`) -/// iterates CharBaseInfo and drops rows matching the literal `race == 3 && class == 8` — two x86 -/// immediates, the only such pair binary-wide (decisions 0549/0550). Stripped here right after the -/// raw-parse guard — the same mechanism, one layer down — so `allows`/`classes_for_race` answer -/// what the real client offers. +/// `CharBaseInfo` pairs the client never offers: its class-list builder `0x4706b0` skips the +/// literal `race == 3 && class == 8` (Dwarf-Mage), though the row and its outfit are populated. const UNUSED_COMBOS: [(u8, u8); 1] = [(3, 8)]; -/// One renderable item of a class's level-1 starting outfit (CharStartOutfit.dbc, decision 0527): -/// the item's **ItemDisplayInfo** id — the same kind of id the char-enum equipment carries, so it -/// feeds the equipment pipeline directly with no Item.dbc/template hop — and its InventoryType (the -/// worn slot). Non-worn entries (bags, food, hearthstone — InventoryType 0) are dropped at load. +/// One worn item of a CharStartOutfit row: its ItemDisplayInfo id and its InventoryType. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct StartOutfitItem { pub display_id: u32, @@ -109,66 +77,53 @@ pub struct DialRanges { pub facial_hair: u8, } -/// Character-creation source data: race body displayIds, race/class combos, and per-(race, sex) -/// appearance-dial ranges. Loaded once from the DBCs ([`Self::load`]). +/// Character-creation source data, loaded once from the DBCs ([`Self::load`]). pub struct CharCreateCatalog { /// race → (male displayId, female displayId), from ChrRaces cols 4/5. displays: HashMap, - /// race → the ChrRaces client fileString (col 15: `"Human"`, `"Scourge"`, …) — the stem the glue - /// screens key GlueStrings entries on (`RACE_INFO_`, `ABILITY_INFO_`). + /// race → ChrRaces fileString (col 15), the GlueStrings key stem (`RACE_INFO_`). files: HashMap, - /// race → (facial-hair customization token per sex [male, female] — cols 26/27 — and the hair - /// customization token — col 28). The glue tokens behind the per-race dial labels - /// (`HAIR__STYLE`, `FACIAL_HAIR_`); a facial token of `"NONE"` hides that dial. + /// race → ([male, female] facial-hair token, cols 26/27; hair token, col 28). custom_tokens: HashMap, - /// The (race, class) combos CharBaseInfo permits. combos: HashSet<(u8, u8)>, - /// (race, sex) → the five dial counts. ranges: HashMap<(u8, u8), DialRanges>, - /// (race, class, sex) → the level-1 starting-outfit items the create preview dresses in - /// (CharStartOutfit, decision 0527) — worn slots only, DisplayId + InventoryType. + /// (race, class, sex) → the worn starting-outfit items. start_outfits: HashMap<(u8, u8, u8), Vec>, } impl CharCreateCatalog { - /// The body model displayId for a (race, sex) — resolved through the ordinary CreatureDisplayInfo - /// chain, the same as any streamed unit (decision 0041). `None` for a non-playable race. + /// The body displayId for a (race, sex), resolved through CreatureDisplayInfo like any unit. pub fn body_display(&self, race: u8, sex: u8) -> Option { self.displays .get(&race) .map(|&(m, f)| if sex == 0 { m } else { f }) } - /// Whether a race may be created as a class (CharBaseInfo). + /// Whether a race may be created as a class (CharBaseInfo less `UNUSED_COMBOS`). pub fn allows(&self, race: u8, class: u8) -> bool { self.combos.contains(&(race, class)) } - /// The ChrRaces client fileString for a race (`"Human"`, `"Scourge"`, …) — the GlueStrings key - /// stem. `None` for a non-playable race. + /// The ChrRaces fileString for a race (`"Human"`, `"Scourge"`), the GlueStrings key stem. pub fn race_file(&self, race: u8) -> Option<&str> { self.files.get(&race).map(String::as_str) } - /// The hair-customization glue token for a race (`"NORMAL"`, `"HORNS"`) — the dial-label key - /// (`HAIR__STYLE` / `HAIR__COLOR`). + /// A race's hair glue token (`"NORMAL"`, `"HORNS"`), keying `HAIR__STYLE`/`_COLOR`. pub fn hair_customization(&self, race: u8) -> Option<&str> { self.custom_tokens.get(&race).map(|(_, h)| h.as_str()) } - /// The facial-hair-customization glue token for a (race, sex) (`"NORMAL"`, `"MARKINGS"`, …) — - /// the dial-label key (`FACIAL_HAIR_`); `"NONE"` means the dial is hidden (the ref's - /// `GetFacialHairCustomization() == "NONE"` branch). + /// A (race, sex)'s facial-hair glue token, keying `FACIAL_HAIR_`; the glue hides the dial + /// when `GetFacialHairCustomization()` is `"NONE"`. pub fn facial_hair_customization(&self, race: u8, sex: u8) -> Option<&str> { self.custom_tokens .get(&race) .map(|(f, _)| f[(sex as usize).min(1)].as_str()) } - /// The classes a race may be created as, ascending by class id. The real client's grid order is - /// CharBaseInfo *row* order (its builder `0x4706b0` walks the table, decision 0550) — which in - /// the 5875 file is ascending class id for all 8 races (verified byte fact), so the sort here is - /// order-equivalent on the real data. + /// The classes a race may be created as, ascending by id: the client lists them in CharBaseInfo + /// row order (`0x4706b0`), which is ascending class id in the shipped file. pub fn classes_for_race(&self, race: u8) -> Vec { let mut cs: Vec = self .combos @@ -180,24 +135,20 @@ impl CharCreateCatalog { cs } - /// The five appearance-dial counts for a (race, sex). `None` for a non-playable race. + /// The five appearance-dial counts for a (race, sex). pub fn ranges(&self, race: u8, sex: u8) -> Option { self.ranges.get(&(race, sex)).copied() } - /// The renderable starting-outfit items for a (race, class, sex) — the level-1 gear the create - /// screen dresses the preview in (decision 0527). Empty for a combo with no CharStartOutfit row. + /// The worn level-1 gear the create preview dresses a (race, class, sex) in. pub fn start_outfit(&self, race: u8, class: u8, sex: u8) -> &[StartOutfitItem] { self.start_outfits .get(&(race, class, sex)) .map_or(&[], Vec::as_slice) } - /// Load the character-creation catalog from the patch chain (ChrRaces + CharBaseInfo + - /// CharSections + CharHairGeosets + CharacterFacialHairStyles). Two load-time self-checks fail - /// loudly on a misparse: the parsed combos must equal the frozen 1.12 table (guards the 2-byte - /// CharBaseInfo layout), and every playable (race, sex) must have a body display + all five - /// ranges nonzero. + /// Load the catalog from the patch chain. The misparse guards compare the raw combos, so they + /// run before `UNUSED_COMBOS` are stripped. pub fn load(chain: &mut Chain) -> Result { let (displays, files, custom_tokens) = load_races(chain)?; let combos = load_combos(chain)?; @@ -231,7 +182,6 @@ impl CharCreateCatalog { Ok(catalog) } - /// Load-time misparse guards (see [`Self::load`]). fn self_check(&self) -> Result<()> { for (race, classes) in KNOWN_COMBOS { let got = self.classes_for_race(race); @@ -251,12 +201,8 @@ impl CharCreateCatalog { ); } } - // CharStartOutfit byte-layout guard (like the CharBaseInfo one): Human(1) Warrior(1) male(0) - // wears the recruit set — shirt disp 9891 (inv 4/BODY), pants 9892 (7/LEGS), boots 10141 - // (8/FEET), plus the worn shortsword 1542 (21/MAINHAND) and small shield 18730 (14/SHIELD). - // If the packed race/class/gender head or the DisplayId/InventoryType array offsets drifted, - // these (display, inv) pairs wouldn't line up. Bags/food (InventoryType 0) are dropped, so - // the count is exactly these five worn items. + // CharStartOutfit layout guard: the shipped Human Warrior male set, (display, + // InventoryType) for the shirt, pants, boots, shortsword and shield. let hw = self.start_outfit(1, 1, 0); for &(disp, inv) in &[ (9891u32, 4u8), @@ -302,12 +248,8 @@ impl CharCreateCatalog { } } -/// ChrRaces → per playable race: the (male, female) displayIds, the client fileString, and the glue -/// customization tokens. 29 fields in build 5875 (record size 116); we read cols 0 (RaceID), -/// 4 (MaleDisplayId), 5 (FemaleDisplayId), 15 (ClientFileString), 26/27 (FacialHairCustomization -/// male/female) and 28 (HairCustomization) — the rest (name strings + spell/sound ids) are declared -/// `UInt32` (a string column is a 4-byte offset, read and ignored). The string-column positions are -/// guarded by [`CharCreateCatalog::self_check`]'s frozen fileString table. +/// ChrRaces (29 fields, 116-byte records) → per playable race: displayIds (cols 4/5), fileString +/// (15), facial-hair (26/27) and hair (28) tokens; other string columns are read as ignored `u32`s. #[allow(clippy::type_complexity)] // one pass over one DBC → the catalog's three race-keyed maps fn load_races( chain: &mut Chain, @@ -352,10 +294,7 @@ fn load_races( Ok((displays, files, tokens)) } -/// CharBaseInfo → the (race, class) combos. A **special** DBC: 2 columns of **one byte each** -/// (record size 2, not 8), so the generic 4-byte-field reader can't touch it — parsed by hand off the -/// raw record block. Byte 0 = RaceID, byte 1 = ClassID (verified against the 5875 file + the known -/// combo table). +/// CharBaseInfo → (race, class) pairs: two one-byte fields, parsed by hand off the record block. fn load_combos(chain: &mut Chain) -> Result> { let bytes = chain .read_file(CHAR_BASE_INFO) @@ -381,15 +320,9 @@ fn load_combos(chain: &mut Chain) -> Result> { Ok(combos) } -/// CharStartOutfit → (race, class, sex) → the worn starting items. A **special** DBC (like -/// CharBaseInfo): 41 columns / 152 bytes with a byte-packed race/class/gender/unused head, so the -/// generic 4-byte-field reader can't touch it — parsed by hand off the raw record block. Layout, -/// byte-verified against the 5875 file: `ID` u32 @0; `Race`/`Class`/`Gender`/unused u8 @4/5/6/7; -/// `ItemId[12]` i32 @8 (dropped — we render by display); **`DisplayId[12]` i32 @56**; -/// **`InventoryType[12]` i32 @104**. Each row keeps the (DisplayId, InventoryType) pair of every -/// **worn** slot (both ≥ 1 — this drops the empty `-1` tail and the InventoryType-0 bag/food/hearth -/// entries, which never render). DisplayId is already an ItemDisplayInfo id (the char-enum -/// equipment convention), so it feeds the equipment pipeline with no template hop. +/// CharStartOutfit → (race, class, sex) → worn items, parsed by hand (41 fields, 152 bytes): `ID` +/// u32 @0, race/class/gender u8 @4/5/6, `ItemId[12]` @8, `DisplayId[12]` @56, `InventoryType[12]` +/// @104. A slot is kept when both are ≥ 1, dropping the `-1` tail and the InventoryType-0 bags. fn load_start_outfits(chain: &mut Chain) -> Result>> { let bytes = chain .read_file(CHAR_START_OUTFIT) @@ -432,10 +365,8 @@ fn load_start_outfits(chain: &mut Chain) -> Result Result> { let bytes = chain .read_file(CHAR_SECTIONS) @@ -460,8 +391,7 @@ fn load_available_sections(chain: &mut Chain) -> Result> { Ok(avail) } -/// CharHairGeosets → (race, sex) → the set of hair-style `VariationID`s. 6 fields (ID, Race, Sex, -/// Variation, GeosetID, unused); we key on cols 1/2/3, as the client's `0x478540` does. +/// CharHairGeosets → (race, sex) → hair-style variations, keyed on cols 1/2/3 as `0x478540` does. fn load_hair_geosets(chain: &mut Chain) -> Result>> { let bytes = chain .read_file(CHAR_HAIR_GEOSETS) @@ -482,9 +412,7 @@ fn load_hair_geosets(chain: &mut Chain) -> Result> Ok(map) } -/// CharacterFacialHairStyles → (race, sex) → the set of facial-hair `VariationID`s. 9 fields; the -/// keys are cols 0/1/2 (Race, Sex, Variation, as `0x478740` keys them — note col 0 is Race here, -/// not an ID). +/// CharacterFacialHairStyles → (race, sex) → variations, keyed on cols 0/1/2 as `0x478740` does. fn load_facial_hair_styles(chain: &mut Chain) -> Result>> { let bytes = chain .read_file(CHAR_FACIAL_HAIR_STYLES) @@ -521,7 +449,6 @@ fn section_axis_count( .len() as u8 } -/// Derive the five dial counts for a (race, sex) from the section domain + the two geoset tables. fn derive_ranges( race: u8, sex: u8, @@ -532,9 +459,8 @@ fn derive_ranges( DialRanges { skin: section_axis_count(avail, race, sex, SECTION_SKIN, false), face: section_axis_count(avail, race, sex, SECTION_FACE, true), - // The hair-style count is the client's CharHairGeosets fetcher (`max(1, …)` — a race/sex with - // no rows still has the bald style), NOT the CharSections HAIR variation count (Orc female has - // one extra HAIR variation with no geoset; offering it would be a dead style). + // Hair styles are CharHairGeosets variations, at least 1 (bald), not CharSections HAIR + // variations: Orc female has one HAIR variation with no geoset. hair_style: hair_geo .get(&(race, sex)) .map_or(1, |v| v.len().max(1) as u8), @@ -543,9 +469,7 @@ fn derive_ranges( } } -/// A schema of `n` `UInt32` fields named `f0..fn` — for the DBCs we read purely by index (the field -/// *names* are irrelevant; string columns are 4-byte offsets read as ints and ignored). The parser -/// requires the count to match the file header, which is the alignment guard. +/// `n` `UInt32` fields for a DBC read by index; the parser's field-count check guards the layout. fn all_u32_schema(name: &str, n: usize) -> Schema { let mut s = Schema::new(name); for i in 0..n { @@ -558,13 +482,11 @@ fn all_u32_schema(name: &str, n: usize) -> Schema { mod tests { use super::*; - /// CharSections `SECTION_TYPE_FACIAL_HAIR` — used only by the predicate transcription (the runtime - /// facial-hair count comes from CharacterFacialHairStyles, so the lib never references it). + /// CharSections `SECTION_TYPE_FACIAL_HAIR`, needed only by the transcribed check. const SECTION_FACIAL_HAIR: u8 = 2; - /// vmangos's `Player::ValidateAppearance`, transcribed verbatim (`Player.cpp:326`): the exact - /// predicate the server runs on `CMSG_CHAR_CREATE`. `GetCharSectionEntry` is an available-row - /// lookup (`avail` already excludes the `0x1`-flagged rows), and face is keyed by *skinColor*. + /// vmangos's `Player::ValidateAppearance` (`Player.cpp:326`), run on `CMSG_CHAR_CREATE`; + /// `avail` already excludes the `0x1`-flagged rows, and face is keyed by skin colour. fn validate_appearance( avail: &HashSet, facial: &HashMap<(u8, u8), HashSet>, @@ -586,7 +508,7 @@ mod tests { if !has(SECTION_HAIR, hair, hair_color) { return false; } - // Tauren, and any female that isn't Night Elf (4) or Undead (5), have no FACIAL_HAIR sections. + // Tauren and all females but Night Elf (4) and Undead (5) have no FACIAL_HAIR sections. let excluded = race == 6 || (sex == 1 && race != 4 && race != 5); if !excluded && !has(SECTION_FACIAL_HAIR, facial_hair, hair_color) { return false; @@ -596,10 +518,6 @@ mod tests { .is_some_and(|vs| vs.contains(&facial_hair)) } - /// The whole Phase-2 contract on the **real** 5875 DBCs: the 16 body displayIds resolve to a - /// character body model; the parsed combos equal the frozen 1.12 table; the derived ranges are - /// dense `0..n-1` on every dial; and — the load-bearing guarantee — every tuple in the product of - /// a (race, sex)'s ranges passes the transcribed `ValidateAppearance`. Skips without client data. #[test] fn char_create_catalog_matches_the_5875_dbcs() { let data = crate::wow_data_or_skip!(); @@ -607,7 +525,6 @@ mod tests { let cat = CharCreateCatalog::load(&mut chain).expect("load char-create catalog"); let creatures = crate::load_creature_catalog(&mut chain).expect("load creatures"); - // 1 · The 16 body displayIds resolve through the CreatureDisplayInfo chain to a body model. for race in PLAYABLE_RACES { for sex in [0u8, 1] { let display = cat.body_display(race, sex).expect("has display"); @@ -625,8 +542,7 @@ mod tests { } } - // 2 · The catalog answers the *playable* sets: the frozen raw table (guarded at load) - // minus the dead pairs the real client never offers (UNUSED_COMBOS — Dwarf-Mage). + // The playable sets are the raw table less the dead pairs. for (race, classes) in KNOWN_COMBOS { let playable: Vec = classes .iter() @@ -646,11 +562,8 @@ mod tests { CharBaseInfo row must not reach the create screen)" ); - // 2b · The glue customization tokens land in the right columns (spot-checks pin both the - // column positions and the male/female order of cols 26/27 against the dumped 5875 rows: - // Tauren cycle horns, males normal facial hair, Human females piercings, Night Elf - // females markings, trolls tusks both sexes). Note: no 5875 row is "NONE" — every - // (race, sex) has a facial dial in this build. + // The glue tokens against the shipped rows, pinning cols 26/27's male/female order; no + // shipped row is "NONE". assert_eq!(cat.hair_customization(6), Some("HORNS"), "Tauren hair tok"); assert_eq!(cat.hair_customization(1), Some("NORMAL"), "Human hair tok"); assert_eq!( @@ -674,11 +587,7 @@ mod tests { "Troll male facial tok" ); - // 2c · CharStartOutfit (decision 0527): every creatable (race, class, sex) combo dresses - // in ≥1 worn item, and the InventoryType semantics land — the Human Warrior recruit set - // (guarded at load too) and the Human Mage's robe (InventoryType 20, chest column). - // (Creatable = the playable sets; the dead Dwarf-Mage pair also has a populated outfit - // row, but nothing can ever wear it.) + // Every creatable combo wears at least one item; the Human Mage wears a robe (inv 20). for (race, classes) in KNOWN_COMBOS { for &class in classes .iter() @@ -704,7 +613,7 @@ mod tests { "Human Mage male should wear a robe (inv 20, disp 12647): {mage:?}" ); - // 3 + 4 · Ranges dense; every producible tuple passes ValidateAppearance. + // Every tuple in the dial ranges passes ValidateAppearance. let avail = load_available_sections(&mut chain).expect("sections"); let facial = load_facial_hair_styles(&mut chain).expect("facial hair"); for race in PLAYABLE_RACES { diff --git a/crates/benilla-formats/src/characters/geosets.rs b/crates/benilla-formats/src/characters/geosets.rs index 2e487e2f..78c2f19f 100644 --- a/crates/benilla-formats/src/characters/geosets.rs +++ b/crates/benilla-formats/src/characters/geosets.rs @@ -10,10 +10,9 @@ const CHAR_HAIR_GEOSETS: &str = "DBFilesClient\\CharHairGeosets.dbc"; const CHAR_FACIAL_HAIR: &str = "DBFilesClient\\CharacterFacialHairStyles.dbc"; const HELMET_GEOSET_VIS: &str = "DBFilesClient\\HelmetGeosetVisData.dbc"; -/// The default 16 region-base geosets (`cc+0x144`, set by the ctors `0x476810`/`0x476960`) — -/// increments of 100 (group 7's base is the outlier 702). Entries 0–3 (hair + the 3 facial-hair -/// groups) are overwritten by the customization; 4–15 (the equipment-group bases: -/// glove/boot/sleeve/…/cloak) stay as these bare-skin defaults. +/// The 16 default region bases of `cc+0x144` (ctors `0x476810`/`0x476960`); group 7's is 702. +/// Entries 0–3, hair and facial hair, are replaced by the customization; 4–15 are the equipment +/// groups' bare defaults. const REGION_BASES: [u16; 16] = [ 1, 101, 201, 301, 401, 501, 601, 702, 801, 901, 1001, 1101, 1201, 1301, 1401, 1501, ]; @@ -22,48 +21,33 @@ const REGION_BASES: [u16; 16] = [ pub struct CharacterGeosets { /// (race, sex, hairStyle) → hair `GeosetID` (group 0). hair: HashMap<(u8, u8, u8), u32>, - /// (race, sex, facialHair) → the 3 facial-hair geoset variations (DBC fields gA/gB/gC, in file - /// order — gA→group 1 (+100), gB→group 3 (+300), gC→group 2 (+200), per `0x478660`). + /// (race, sex, facialHair) → three geoset variations in file order, for groups 1, 3, 2 + /// (`0x478660`). facial: HashMap<(u8, u8, u8), [u32; 3]>, - /// HelmetGeosetVisData row id → its 5 **race** bitmasks (consumer `0x4799a0`): a set bit - /// `1 << race` in column *c* forces one region-base slot back to its group base — hiding the - /// styled hair/facial geoset (and the ears) under a worn helm. + /// HelmetGeosetVisData row id → five race bitmasks (`0x4799a0`): bit `1 << race` forces a hair, + /// facial-hair or ear slot back to its group base under a helm. helmet_vis: HashMap, } -/// The worn geoset selectors the equipment branches of `0x477520` read (decisions 0074, 1864): per -/// bodyslot 2–9 (shirt, chest, belt, pants, boots, wrist, gloves, tabard) the item's -/// `geosetGroup[0..2]` (`None` = the slot is empty), plus the cloak's `geosetGroup[0]`, plus the -/// worn helm's `HelmetGeosetVisData` row pair (`[male, female]` — ItemDisplayInfo cols 12/13). -/// Default = naked. -/// -/// **Two of the eight branches are not fed by an ItemDisplayInfo column at all** and take their own -/// field here (decision 1864): B3 reads the ArmLower *composite grid*, and B6 reads a client flag. +/// What the equipment branches of `0x477520` read: each bodyslot 2–9's three `geosetGroup`s +/// (shirt, chest, belt, pants, boots, wrist, gloves, tabard), the cloak's first, and the helm's +/// `HelmetGeosetVisData` rows (`[male, female]`, ItemDisplayInfo cols 12/13). Default is naked. #[derive(Default, Clone, Copy)] pub struct EquipGeosets { pub bodyslots: [Option<[u32; 3]>; 8], pub cloak: Option, pub helm_vis: Option<[u32; 2]>, - /// B3's gate: does anything **other than the shirt** dress the forearm? — the six dwords - /// `cc+0x26c..cc+0x280` (ArmLower cells 1..6). Fill it from - /// [`forearm_dressed`](crate::forearm_dressed), which reads the one composite plan. + /// Branch B3's gate: something other than the shirt dresses the forearm (`cc+0x26c..cc+0x280`, + /// ArmLower cells 1–6); fill it from [`forearm_dressed`](crate::forearm_dressed). pub forearm_dressed: bool, - /// B6's gate: `[cc+0xc]`, "the guild registrar's tabard designer is open on this character" - /// (its setter `0x5e07fb`). Set while that window is up so the body wears a previewable tabard - /// with an **empty** tabard slot; nothing else in the client ever sets it. + /// Branch B6's gate `[cc+0xc]`: the tabard designer is open (setter `0x5e07fb`), so the body + /// wears a previewable tabard over an empty tabard slot. pub tabard_preview: bool, } impl CharacterGeosets { - /// The geoset IDs a character of this appearance renders — the opening block (the naked set) - /// plus **all eight** equipment branches B1–B8 of `0x477520` (decisions 0074, 1864 — - /// implemented from its *arithmetic*; the branch comments below name each one at its address). - /// A body submesh whose `skinSectionId` is in this set is drawn; all others hidden. Most - /// branches gate on an ItemDisplayInfo `geosetGroup` field being non-zero ("section present"); - /// B3 and B6 do not, and read [`EquipGeosets::forearm_dressed`] / [`EquipGeosets::tabard_preview`]. - /// - /// Returned **sorted and deduplicated**: the client toggles a per-submesh flag, where several - /// branches re-enable a base the opening loop already set, so a list needs normalising once. + /// The geosets (`skinSectionId`s) this appearance draws, sorted and deduplicated: the naked + /// set, then the eight equipment branches B1–B8 of `0x477520`. pub fn visible_geosets( &self, race: u8, @@ -72,26 +56,21 @@ impl CharacterGeosets { facial_hair: u8, equip: &EquipGeosets, ) -> Vec { + // The naked set: the region bases and geoset 0, the body. let mut set = REGION_BASES.to_vec(); - set.push(0); // geoset 0 (the body), enabled unconditionally - // Entry 0 (group 0 = hair): the chosen hairstyle's geoset, clamped ≥1 (`0x478540` - // returns `max(1, geosetId)`, so a bald style still resolves to geoset 1, the scalp). + set.push(0); + // Hair: `0x478540` returns `max(1, geosetId)`, so a bald style shows geoset 1, the scalp. if let Some(&g) = self.hair.get(&(race, sex, hair_style)) { set[0] = g.max(1) as u16; } - // Entries 1–3 (groups 1/2/3 = facial hair): DBC variation + the group base (`0x478660` adds - // 100/300/200 to fields gA/gB/gC respectively). Absent row ⇒ the default bases 101/201/301 stand. + // Facial hair: `0x478660` adds 100/300/200 to the row's fields; no row keeps the bases. if let Some(&[a, b, c]) = self.facial.get(&(race, sex, facial_hair)) { - set[1] = (a + 100) as u16; // gA → group 1 - set[3] = (b + 300) as u16; // gB → group 3 - set[2] = (c + 200) as u16; // gC → group 2 + set[1] = (a + 100) as u16; + set[3] = (b + 300) as u16; + set[2] = (c + 200) as u16; } - // A worn helm's vis row (`0x4799a0`): each of the 5 columns is a - // **race** bitmask; a set `1 << race` bit forces one region-base slot back to its group - // base, dropping the styled geoset the customization just selected — hair to the bare scalp - // (1), the facial groups to their bases, and the ears to **701** (over the 702 default no - // customization touches). Runs after the customization overwrites, exactly the client's - // order; the slots {0,1,2,3,7} are disjoint from every equipment branch below. + // A helm's vis row (`0x4799a0`) forces slots back to their bases, the ears to 701, after + // the customization; its slots {0,1,2,3,7} are disjoint from every equipment branch. if let Some(rows) = equip.helm_vis { let row = rows[usize::from(sex == 1)]; if let Some(masks) = self.helmet_vis.get(&row) { @@ -104,40 +83,32 @@ impl CharacterGeosets { } } } - // The equipment branches. `g(slot, sub)` = the slot's geosetGroup[sub], gated non-zero. - // EquipGeosets bodyslot indices: 0 shirt · 1 chest · 2 belt · 3 pants · 4 boots · 5 wrist · - // 6 gloves · 7 tabard. + // The equipment branches; `g(slot, sub)` is a non-zero geosetGroup, slots 0 shirt, 1 chest, + // 2 belt, 3 pants, 4 boots, 5 wrist, 6 gloves, 7 tabard. let g = |slot: usize, sub: usize| { equip.bodyslots[slot] .map(|groups| groups[sub]) .filter(|v| *v != 0) }; let disable = |set: &mut Vec, lo: u16, hi: u16| set.retain(|id| *id < lo || *id > hi); - // The robe bit: chest or pants geosetGroup[2] (B4's guard) — it also suppresses B5's tabard - // flap and B6's preview. B7 is NOT gated on it: that branch reads the chest's bit alone. + // The robe bit (chest or pants geosetGroup[2]) gates B4, B5 and B6; B7 reads the chest's. let robe = g(1, 2).or_else(|| g(3, 2)); - // B1/B2 (`0x477564`): gloves replace the glove group (401+v, own range disabled); else the - // chest's sleeves (the `else` arm, B2). + // B1/B2 (`0x477564`): gloves replace the glove group, else the chest's sleeves show. if let Some(v) = g(6, 0) { disable(&mut set, 401, 499); set.push(401 + v as u16); } else if let Some(v) = g(1, 0) { set.push(801 + v as u16); } - // B3 (`0x4775bd`): the shirt's sleeve cuff — only when nothing ELSE dresses the forearm. - // The reference's gate is the ArmLower equip row's cells 1..6, not "is a chest equipped": - // most chest pieces leave that tile empty and the cuff survives them, while a bracer or a - // glove takes it and the cuff goes (decision 1864; [`EquipGeosets::forearm_dressed`]). + // B3 (`0x4775bd`): the shirt's cuff, unless something else dresses the forearm. if !equip.forearm_dressed { if let Some(v) = g(0, 0) { set.push(801 + v as u16); } } - // B4 (`0x4775fb`): a robe hides boots/kneepads/pant-legs/trousers and shows its skirt - // (1301+v); else the boots replace the boot group; else the pants' kneepads. The bare `901` - // enables are the reference's own (`0x477648`, `0x477685`) and are no-ops here — the 16-base - // loop already carries 901 and nothing on this path disables group 9 — kept so the sequence - // stays the reference's rather than resting on that invariant. + // B4 (`0x4775fb`): a robe hides the leg groups and shows its skirt, else boots replace the + // boot group, else the pants' kneepads. The bare 901 enables are the reference's own + // (`0x477648`, `0x477685`), redundant with the bases. if let Some(v) = robe { disable(&mut set, 501, 599); disable(&mut set, 902, 999); @@ -145,10 +116,8 @@ impl CharacterGeosets { disable(&mut set, 1300, 1399); set.push(1301 + v as u16); } else if let Some(v) = g(4, 0) { - // `0x477639` disables the WHOLE boot group before enabling the boot's own geoset — the - // naked foot comes off with the boot on. 501 is a real submesh on 11 of the 18 character - // models (64 vertices on Human male), so leaving it on — as benilla did until decision - // 1864, on a misread of this same note — put a bare ankle inside every boot. + // `0x477639` disables the whole boot group first: the bare foot, 501, is a real submesh + // on 11 of the 18 character models. disable(&mut set, 501, 599); set.push(901); set.push(501 + v as u16); @@ -157,30 +126,22 @@ impl CharacterGeosets { } else { set.push(901); } - // B5 (`0x477706`): the tabard flap (robes hide it). + // B5 (`0x477706`): the tabard flap, hidden by a robe. if robe.is_none() { if let Some(v) = g(7, 0) { set.push(1201 + v as u16); } } - // B6 (`0x477752`): the tabard-designer preview. **No ItemDisplayInfo column feeds this - // branch** — its gate is the flag `[cc+0xc]`, set only while the guild registrar's tabard - // designer is open (setter `0x5e07fb`; the branch's old "helm-skirt" label was a mislabel, - // and no shipped head display carries a geoset group at all). It forces the tabard group on - // with an EMPTY tabard slot: 1201, plus 1202 when neither the chest nor the legs carries the - // robe bit. On shipped character models 1201 has no submesh (it is the group's "no tabard" - // base) and 1202 is the flap — so the observable effect is exactly "wear the tabard so the - // design can be previewed" (decision 1864). + // B6 (`0x477752`): the designer preview wears 1201, the group's empty base, and without a + // robe 1202, the flap. if equip.tabard_preview { - set.push(1201); // already on from the opening loop; the reference enables it anyway + set.push(1201); // redundant with the bases; the reference enables it too if robe.is_none() { set.push(1202); } } - // B7 (`0x477799`): the shirt's doublet + the pants' leg geoset (the recorded 1102 base — not - // 1101). Skipped ENTIRELY when the CHEST is a robe (`0x4777a4` — the legs' own robe bit does - // NOT gate here, unlike B4/B5/B6) or when a TABARD is worn (`0x4777b8`, bodyslot 9 sub 0; - // the guard is the tabard, not the glove). Decision 1864. + // B7 (`0x477799`): the shirt's doublet and the pants' legs (base 1102, not 1101); skipped + // under a chest robe (`0x4777a4`, not a legs robe) or a tabard (`0x4777b8`). if g(1, 2).is_none() && g(7, 0).is_none() { if let Some(v) = g(0, 1) { set.push(1001 + v as u16); @@ -189,36 +150,20 @@ impl CharacterGeosets { set.push(1102 + v as u16); } } - // B8 (`0x47780a`): a cloak replaces the cloak group (1501+v, range disabled). + // B8 (`0x47780a`): a cloak replaces the cloak group. if let Some(v) = equip.cloak.filter(|v| *v != 0) { disable(&mut set, 1500, 1599); set.push(1501 + v as u16); } - // The client's `0x7110d0` writes a flag per submesh, so enabling an already-enabled geoset - // is idempotent there; ours is a list, and several branches re-enable a base the opening - // loop already added. Normalise once at the end so the result is a canonical set. + // The client sets a flag per submesh (`0x7110d0`), so a repeated enable is a no-op there. set.sort_unstable(); set.dedup(); set } - /// Load both customization DBCs from the patch chain. - /// - /// **A duplicated `(race, sex, variation)` key resolves to the FIRST row** (decision 0682). Both - /// consumers are *linear scans* of the loaded table — `0x478540` for the hair geoset, `0x478740` - /// for the facial-hair record (`0x478540` is a loaded-DBC linear scan, not a pool) — so the - /// earliest matching record is the one the reference returns, where a last-write - /// `HashMap::insert` would take the latest. - /// - /// It matters exactly once in the shipped data, and it was B11: `CharHairGeosets` carries **four** - /// rows for **(race 9 goblin, sex 0, variation 0)** — ids 241–244, geosets 1 / 2 / 1 / 2, the only - /// duplicated key in the file (243/244 read as the goblin-*female* pair with a zeroed sex column, - /// which is why goblin females, having no row at all, were always right). Geoset 1 is the bare - /// scalp; geoset 2 is that scalp **plus** a 38-vertex topknot whose texture slot is `Monster1` — - /// a slot no character-model goblin display fills, so it drew flat white (decision 0157). All 314 - /// character-model goblin displays select variation 0, so last-write put a white tuft on every one - /// of them. `CharacterFacialHairStyles` has no duplicate keys today; it takes the same rule - /// because the same scan law governs it. + /// Load the customization DBCs from the patch chain. A repeated `(race, sex, variation)` key + /// takes its first row, as the client's linear scans (`0x478540`, `0x478740`) do: the shipped + /// CharHairGeosets lists goblin male variation 0 four times (geosets 1, 2, 1, 2). pub fn load(chain: &mut Chain) -> Result { let hair = { let bytes = chain @@ -231,7 +176,7 @@ impl CharacterGeosets { if let (Some(race), Some(sex), Some(var), Some(geoset)) = (u32_at(r, 1), u32_at(r, 2), u32_at(r, 3), u32_at(r, 4)) { - // FIRST row wins — see the duplicate-key note on [`CharacterGeosets::load`]. + // The first row wins. m.entry((race as u8, sex as u8, var as u8)) .or_insert(geoset); } @@ -249,7 +194,7 @@ impl CharacterGeosets { )?; let mut m = HashMap::with_capacity(rs.records().len()); for r in rs.records() { - // fields: RaceID, SexID, VariationID(facialHair), 3×unused, gA, gB, gC (no ID column). + // fields: RaceID, SexID, VariationID(facialHair), 3×unused, Geoset100/300/200. if let (Some(race), Some(sex), Some(var)) = (u32_at(r, 0), u32_at(r, 1), u32_at(r, 2)) { @@ -258,15 +203,13 @@ impl CharacterGeosets { u32_at(r, 7).unwrap_or(0), u32_at(r, 8).unwrap_or(0), ]; - // FIRST row wins — see the duplicate-key note on [`CharacterGeosets::load`]. + // The first row wins. m.entry((race as u8, sex as u8, var as u8)).or_insert(g); } } m }; - // HelmetGeosetVisData — the helm hide-masks (read by `0x4799a0`). Soft-optional: a - // missing/undecodable table just means helms never hide hair (the pre-helm-vis behavior), - // like the other soft-fails. + // HelmetGeosetVisData, the helm hide-masks: without the file, helms hide nothing. let helmet_vis = match chain.read_file(HELMET_GEOSET_VIS) { Ok(bytes) => { let rs = parse(&bytes, helmet_vis_schema(), "HelmetGeosetVisData")?; @@ -288,7 +231,7 @@ impl CharacterGeosets { } } -/// CharHairGeosets.dbc — 6 fields in build 5875 (verified against the file header). +/// CharHairGeosets.dbc: 6 fields. pub(crate) fn char_hair_geosets_schema() -> Schema { let mut s = Schema::new("CharHairGeosets"); for (name, ty) in [ @@ -304,9 +247,8 @@ pub(crate) fn char_hair_geosets_schema() -> Schema { s } -/// HelmetGeosetVisData.dbc — 6 fields in build 5875 (verified: the loader `0x546f00` asserts -/// fieldCount 6 / recordSize 0x18; the 5 mask columns are **race** bitmasks, as `0x4799a0` tests -/// them). +/// HelmetGeosetVisData.dbc: 6 fields, 0x18-byte records (loader `0x546f00`); the five masks are +/// race bitmasks (`0x4799a0`). pub(crate) fn helmet_vis_schema() -> Schema { let mut s = Schema::new("HelmetGeosetVisData"); for (name, ty) in [ @@ -322,7 +264,7 @@ pub(crate) fn helmet_vis_schema() -> Schema { s } -/// CharacterFacialHairStyles.dbc — 9 fields in build 5875 (verified); no ID column (keyed race/sex/var). +/// CharacterFacialHairStyles.dbc: 9 fields, no ID column. pub(crate) fn char_facial_hair_schema() -> Schema { let mut s = Schema::new("CharacterFacialHairStyles"); for (name, ty) in [ @@ -332,9 +274,9 @@ pub(crate) fn char_facial_hair_schema() -> Schema { ("Unused3", FieldType::UInt32), ("Unused4", FieldType::UInt32), ("Unused5", FieldType::UInt32), - ("Geoset100", FieldType::UInt32), // gA → group 1 (+100) - ("Geoset300", FieldType::UInt32), // gB → group 3 (+300) - ("Geoset200", FieldType::UInt32), // gC → group 2 (+200) + ("Geoset100", FieldType::UInt32), + ("Geoset300", FieldType::UInt32), + ("Geoset200", FieldType::UInt32), ] { s.add_field(SchemaField::new(name, ty)); } @@ -345,8 +287,7 @@ pub(crate) fn char_facial_hair_schema() -> Schema { mod tests { use super::*; - /// The equipment geoset branches (decisions 0074/1864, from the arithmetic of `0x477520`): - /// gloves/boots/robe/cloak **replace** their groups, and a robe suppresses the tabard branch. + /// Gloves, boots, a robe and a cloak replace their groups; a robe hides the tabard flap. #[test] fn equipment_geoset_branches() { let cg = CharacterGeosets { @@ -357,21 +298,18 @@ mod tests { let naked = cg.visible_geosets(1, 0, 0, 0, &EquipGeosets::default()); assert!(naked.contains(&401) && naked.contains(&1101) && naked.contains(&1501)); - // Gloves v=1: the glove group is replaced (401 out, 402 in). let mut eq = EquipGeosets::default(); eq.bodyslots[6] = Some([1, 0, 0]); let set = cg.visible_geosets(1, 0, 0, 0, &eq); assert!(set.contains(&402) && !set.contains(&401)); - // Boots v=2: the boot group is replaced — 503 in, the naked foot 501 OUT (`0x477639` - // disables [501,599] first). benilla stacked them until decision 1864. + // Boots take the bare foot, 501, off (`0x477639`). let mut eq = EquipGeosets::default(); eq.bodyslots[4] = Some([2, 0, 0]); let set = cg.visible_geosets(1, 0, 0, 0, &eq); assert!(set.contains(&503) && !set.contains(&501)); - // A robe (chest group[2]=1) shows its skirt (1302) and hides boots/pant-legs/trousers — - // including a worn tabard's flap and the pants' own branches. + // A robe shows its skirt and hides the leg groups and the tabard flap. let mut eq = EquipGeosets::default(); eq.bodyslots[1] = Some([1, 0, 1]); eq.bodyslots[3] = Some([2, 1, 0]); @@ -387,7 +325,6 @@ mod tests { assert!(!set.contains(&1202), "tabard suppressed under a robe"); assert!(set.contains(&802), "the chest's sleeves still show"); - // Cloak v=4: the cloak group is replaced (1501 out, 1505 in). let eq = EquipGeosets { cloak: Some(4), ..Default::default() @@ -396,10 +333,7 @@ mod tests { assert!(set.contains(&1505) && !set.contains(&1501)); } - /// **B3's real gate** (`0x4775bd`, decision 1864): the shirt's sleeve cuff survives or dies by - /// what dresses the *forearm tile*, not by whether a chest is equipped. Both directions matter — - /// 533 of the 637 shipped chest displays leave `ArmLowerTexture` empty, and 566 of 570 wrist - /// displays fill it — and benilla had both backwards until this record. + /// Branch B3 (`0x4775bd`) keys the shirt's cuff on the forearm tile, not the chest slot. #[test] fn b3_shirt_sleeve_follows_the_forearm_tile_not_the_chest_slot() { let cg = CharacterGeosets { @@ -420,9 +354,7 @@ mod tests { !cg.visible_geosets(1, 0, 0, 0, &shirt(true)).contains(&802), "something else paints the forearm: the cuff goes" ); - // And a chest in the slot is NOT the gate: a chest whose ArmLower art is empty leaves the - // tile free, and the cuff stays. (The chest's own sleeve geoset is B1's `else`, group 8 too, - // so give this one none.) + // A chest with no sleeve geoset (B2 would add one to group 8 too) leaves the cuff. let mut eq = shirt(false); eq.bodyslots[1] = Some([0, 0, 0]); assert!( @@ -431,10 +363,7 @@ mod tests { ); } - /// **B6 — the tabard-designer preview** (`0x477752`; decision 1864). The one - /// branch of the eight with no ItemDisplayInfo column behind it: its gate is `[cc+0xc]`, "the - /// guild registrar's tabard designer is open", and it wears the tabard flap (1202) with an - /// **empty** tabard slot — unless a robe is on, which suppresses it exactly as it suppresses B5. + /// Branch B6 (`0x477752`): the open designer wears the flap over an empty slot, unless robed. #[test] fn b6_tabard_preview_wears_the_flap_with_an_empty_slot() { let cg = CharacterGeosets { @@ -442,10 +371,8 @@ mod tests { facial: HashMap::new(), helmet_vis: HashMap::new(), }; - // Off: an empty tabard slot shows no flap. let set = cg.visible_geosets(1, 0, 0, 0, &EquipGeosets::default()); assert!(!set.contains(&1202), "no tabard, no designer: no flap"); - // On: the flap appears with nothing in the slot. let preview = EquipGeosets { tabard_preview: true, ..Default::default() @@ -453,7 +380,6 @@ mod tests { let set = cg.visible_geosets(1, 0, 0, 0, &preview); assert!(set.contains(&1202), "the designer forces the flap on"); assert!(set.contains(&1201), "and the group base with it"); - // A robe suppresses it, from either the chest's or the legs' robe bit. for slot in [1usize, 3] { let mut eq = preview; eq.bodyslots[slot] = Some([0, 0, 1]); @@ -464,9 +390,7 @@ mod tests { } } - /// **B7's two guards** (`0x4777a4` / `0x4777b8`, decision 1864): the shirt-doublet + pant-leg arm - /// is skipped whole when the CHEST is a robe or when a TABARD is worn — and, unlike B4/B5/B6, - /// the *legs'* own robe bit does not gate it. + /// Branch B7 is off under a chest robe or a tabard (`0x4777a4`, `0x4777b8`), not a legs robe. #[test] fn b7_is_gated_by_the_chest_robe_and_the_tabard() { let cg = CharacterGeosets { @@ -494,7 +418,6 @@ mod tests { "ungated: both on" ); - // A worn tabard skips the whole branch (`0x4777b8` guards on the tabard, not the glove). let mut eq = base; eq.bodyslots[7] = Some([1, 0, 0]); let set = cg.visible_geosets(1, 0, 0, 0, &eq); @@ -504,7 +427,6 @@ mod tests { "a tabard skips B7 whole" ); - // A chest robe skips it too — and takes the doublet with it, which the pre-1864 code left on. let mut eq = base; eq.bodyslots[1] = Some([0, 0, 1]); let set = cg.visible_geosets(1, 0, 0, 0, &eq); @@ -513,8 +435,7 @@ mod tests { "a chest robe skips B7 whole" ); - // The LEGS' robe bit does not: B4-long still hides the pant-leg group, but the doublet — - // enabled after that disable — stays on. This is the asymmetry the old `robe` shortcut lost. + // A legs robe: B4 hides the pant-leg group, then B7 enables its geosets anyway. let mut eq = base; eq.bodyslots[3] = Some([1, 0, 1]); let set = cg.visible_geosets(1, 0, 0, 0, &eq); @@ -525,10 +446,7 @@ mod tests { ); } - /// The helm-vis force (`0x4799a0`): each vis column is a **race** bitmask; a set - /// `1 << race` bit forces the slot back to its group base — hair to the bare scalp (1), facial - /// groups to 101/201/301, ears to 701 — dropping the styled geosets. The row is sex-selected - /// (ItemDisplayInfo col 12 male / 13 female); a mask without our race bit leaves the style. + /// The helm vis row (`0x4799a0`) is picked by sex, and a race bit forces the group bases. #[test] fn helm_vis_forces_group_bases_by_race() { let mut hair = HashMap::new(); @@ -536,8 +454,8 @@ mod tests { let mut facial = HashMap::new(); facial.insert((1, 0, 1), [2u32, 3, 4]); // styled facial → 102/303/204 let mut helmet_vis = HashMap::new(); - helmet_vis.insert(368, [u32::MAX; 5]); // hide-everything row (real id, dumped) - helmet_vis.insert(245, [0u32; 5]); // hide-nothing row (real id, dumped) + helmet_vis.insert(368, [u32::MAX; 5]); // a shipped row id, hiding everything + helmet_vis.insert(245, [0u32; 5]); // a shipped row id, hiding nothing let cg = CharacterGeosets { hair, facial, @@ -547,14 +465,14 @@ mod tests { helm_vis: Some([368, 245]), ..Default::default() }; - // Male (sex 0) picks row 368: everything forced to base. + // Male picks row 368. let set = cg.visible_geosets(1, 0, 1, 1, &helm); assert!(set.contains(&1) && !set.contains(&5), "hair → bare scalp"); assert!(set.contains(&101) && !set.contains(&102)); assert!(set.contains(&201) && !set.contains(&204)); assert!(set.contains(&301) && !set.contains(&303)); assert!(set.contains(&701) && !set.contains(&702), "ears → 701"); - // The female column of this pair is the hide-nothing row: styles survive. + // Female picks row 245. let mut hair = HashMap::new(); hair.insert((1, 1, 1), 5u32); let cg2 = CharacterGeosets { @@ -583,9 +501,7 @@ mod tests { assert!(set.contains(&5), "race bit miss → style kept"); } - /// The naked selection for a known Human-male appearance (rows dumped from the build-5875 files): - /// hairStyle 1 → GeosetID 2; facialHair 1 → gA/gB/gC = 1/2/1. So the set carries the body (0), the - /// hair geoset (2), the three facial geosets (101 / 201 / 302), and the default group bases. + /// A Human male's naked set, shipped rows: hairStyle 1 → geoset 2, facialHair 1 → 1/2/1. #[test] fn naked_human_male_selection() { let mut hair = HashMap::new(); @@ -604,13 +520,12 @@ mod tests { assert!(set.contains(&101), "facial group 1: gA 1 + 100"); assert!(set.contains(&302), "facial group 3: gB 2 + 300"); assert!(set.contains(&201), "facial group 2: gC 1 + 200"); - // Default equipment-group bases (entries 4–15) stand for a naked body. assert!(set.contains(&401) && set.contains(&702) && set.contains(&1501)); - // A non-selected hair variant is hidden (geoset 5 is hairStyle 4, not chosen). + // Geoset 5 is hairStyle 4, not chosen. assert!(!set.contains(&5), "unselected hair variant hidden"); } - /// A bald hairstyle (GeosetID 0) still resolves to geoset 1 (the scalp), never 0-the-body-collision. + /// A bald style (GeosetID 0) resolves to geoset 1, the scalp, not 0, the body. #[test] fn bald_resolves_to_scalp_geoset_one() { let mut hair = HashMap::new(); @@ -624,20 +539,14 @@ mod tests { assert!(set.contains(&1), "max(1, 0) = scalp geoset 1"); } - /// **B11, against the shipped DBC** (decision 0682): `CharHairGeosets` carries four rows for the - /// goblin male's only hairstyle, and the reference's linear scan takes the first — geoset **1**, - /// the bare scalp. Last-write took id 244's geoset **2**, which adds an untextured topknot. - /// - /// Three things are pinned here because each on its own could rot: that the shipped file really - /// does duplicate that key (the defect only exists if it does), that we resolve it to 1, and that - /// **no other key in the table is duplicated** — which is what bounds this change to goblins. + /// The shipped CharHairGeosets repeats only the goblin male's one style, and the first row, + /// geoset 1, the bare scalp, wins; geoset 2 adds an untextured topknot. #[test] fn goblin_male_hair_takes_the_first_of_four_duplicate_rows() { let data = crate::wow_data_or_skip!(); let mut chain = crate::open_chain(&data).expect("open chain"); - // The file's own rows, read independently of the loader, so the test states the premise it - // rests on rather than assuming it. + // The file's rows, read without the loader. let bytes = chain.read_file(CHAR_HAIR_GEOSETS).expect("CharHairGeosets"); let rs = parse(&bytes, char_hair_geosets_schema(), "CharHairGeosets").expect("parse"); let mut keys: HashMap<(u32, u32, u32), Vec> = HashMap::new(); @@ -660,7 +569,6 @@ mod tests { "the goblin-male block is the duplicate, in file order" ); - // And the loader resolves it the reference's way. let cg = CharacterGeosets::load(&mut chain).expect("load customization tables"); let set = cg.visible_geosets(9, 0, 0, 0, &EquipGeosets::default()); assert!( @@ -671,9 +579,7 @@ mod tests { !set.contains(&2), "goblin male does NOT show geoset 2 — the white topknot of B11" ); - // The goblin female has no row at all (race 9 ships only sex-0 rows), so she falls to the - // region base, which is the same scalp. She was never affected; assert it so a future - // "fix" to the sex column has to notice her. + // Race 9 ships only sex-0 rows, so the goblin female keeps the region base, the same scalp. let f = cg.visible_geosets(9, 1, 0, 0, &EquipGeosets::default()); assert!(f.contains(&1) && !f.contains(&2), "goblin female unchanged"); } diff --git a/crates/benilla-formats/src/characters/mod.rs b/crates/benilla-formats/src/characters/mod.rs index ca017ff3..4f5ee29c 100644 --- a/crates/benilla-formats/src/characters/mod.rs +++ b/crates/benilla-formats/src/characters/mod.rs @@ -1,24 +1,10 @@ -//! Character customization render data (decision 0041, Milestone B) — what a player's appearance -//! selects: which **geosets** the body shows ([`CharacterGeosets`]) and which **skin textures** the -//! body wears ([`CharSections`]). +//! Character appearance render data: the geosets an appearance shows ([`CharacterGeosets`], the +//! client's geoset dispatch `0x477520`) and the body skin it wears: body-skin batches +//! (`M2TextureType::Other(1)`) have no texture of their own, and [`CharSections::composite_body`] +//! builds one on the 256² partition `0x475c50` and the section→cell map `0x4782e0`. //! -//! **Geosets.** A character model contains *every* hairstyle, facial-hair piece, and body-option geoset; -//! the real client renders only the selected ones. The selection is the compositor's geoset dispatch -//! `0x477520`. For a character with **no equipment** the 8 per-item -//! branches all no-op (their `ItemDisplayInfo` records are null), leaving only the unconditional opening -//! block: disable every geoset, enable geoset 0, then enable the 16 region-base entries of `cc+0x144` — -//! entries 0–3 overwritten by the customization DBCs (the chosen hair + 3 facial-hair geosets), entries -//! 4–15 the default group bases. We render an M2 submesh iff its `skinSectionId` is in that set. -//! -//! **Skin textures.** A character body's `M2TextureType::Other(1)` (body skin) batches have no embedded -//! texture — the client supplies a runtime composite keyed on the appearance ([`CharSections::composite_body`], -//! decision 0044). The **base skin** (`sectionType 0`) is a single full 256² body-layout BLP per (race, -//! sex, skinColor); the face / facial-hair / hair / underwear overlays (`sectionTypes 1–4`) are region -//! BLPs blended on top at fixed atlas tiles (the static partition `0x475c50` writes + the -//! section→cell map of `0x4782e0`). -//! -//! DBC field maps from the client's record readers (CharSections `0x575540`, the geoset tables -//! `0x5753b0`/`0x575a80`); field counts + row semantics cross-checked against the build-5875 files. +//! DBC layouts follow the client's record readers: CharSections `0x575540`, the geoset tables +//! `0x5753b0`/`0x575a80`. mod customization; mod geosets; @@ -31,7 +17,6 @@ pub use sections::{ BlitSource, CharSections, EmblemLayer, EquipBlit, GuildEmblem, }; -// The appearance tables' schemas, for the `benilla-extract dbc` CSV dump (`crate::schema_for`) — -// the same constructors the loaders above use, never a second transcription of the same layout. +// The loaders' own schemas, for the `benilla-extract dbc` CSV dump (`crate::schema_for`). pub(crate) use geosets::{char_facial_hair_schema, char_hair_geosets_schema, helmet_vis_schema}; pub(crate) use sections::char_sections_schema; diff --git a/crates/benilla-formats/src/characters/sections.rs b/crates/benilla-formats/src/characters/sections.rs index 445beb1c..83fee743 100644 --- a/crates/benilla-formats/src/characters/sections.rs +++ b/crates/benilla-formats/src/characters/sections.rs @@ -9,64 +9,37 @@ use crate::{read_texture_mip_chain, BlpMipChain, Chain}; const CHAR_SECTIONS: &str = "DBFilesClient\\CharSections.dbc"; -/// CharSections `sectionType` values (facial hair is 2 at `0x478660`, hair 3 at `0x4784c0`): the -/// layer a row supplies. `skin` is the full base atlas; the rest are region overlays the body -/// composite blends on top (decision 0044). +/// CharSections `sectionType` values (facial hair 2 at `0x478660`, hair 3 at `0x4784c0`). const SECTION_SKIN: u8 = 0; const SECTION_FACE: u8 = 1; const SECTION_FACIAL_HAIR: u8 = 2; const SECTION_HAIR: u8 = 3; const SECTION_UNDERWEAR: u8 = 4; -/// The hair variation the client's type-6 binder substitutes when the selected style resolves no -/// texture — a **literal 1** in the binary at two of its three call sites (`0x478445`, `0x4786f2`), -/// not a search. See [`CharSections::hair_mesh_texture`]; decision 0536. +/// The hair variation the type-6 binder falls back to: a literal 1 at `0x478445` and `0x4786f2`. const HAIR_SUBSTITUTE_VARIATION: u8 = 1; -/// An atlas rect `(x, y, w, h)` in pixels — a composite destination tile. +/// An atlas rect `(x, y, w, h)` in pixels. type Tile = (u32, u32, u32, u32); -/// The body-atlas tiles the head + underwear overlays composite into — four of the 10 rects of the -/// static 256² partition `0x475c50` writes. g8/g9 are the head strip (left column, Y 160–256: g8 -/// the 32-tall upper band, g9 the 64-tall lower band); g3 (torso upper) and g5 (pelvis) are the -/// right column's Y 0–64 and Y 96–160 — the two tiles the underwear section dresses, one per -/// texture column (the group→cell map: the group-3 handler `0x4772f0` takes underwear cell -/// `cc+0x20c`, the group-5 handler `0x4773a0` takes `cc+0x208`, and `0x478790` builds those two -/// from the sectionType-4 row's `TextureName[1]`/`[0]`). +/// The head strip of the 256² partition `0x475c50` writes: g8 the upper band, g9 the lower. const TILE_G8: Tile = (0, 160, 128, 32); const TILE_G9: Tile = (0, 192, 128, 64); -// The underwear's two tiles ARE two of the equipment tiles — the composite reaches them through -// `EQUIP_TILES` so there is one transcription of the rects, and these names exist for the tests, -// which read far better naming the group than indexing the array. +// The underwear's torso and pelvis tiles are equipment tiles g3 and g5, named for the tests. #[cfg(test)] const TILE_G3: Tile = EQUIP_TILES[3]; #[cfg(test)] const TILE_G5: Tile = EQUIP_TILES[5]; -/// The underwear section's two blits: `(textureColumn, equipLayer, columnsTested)` — which -/// `TextureName` column dresses which equipment tile, and **how far into that tile's equipment -/// columns the client looks before it draws at all** (read from the two handlers below). -/// -/// The underwear is not an under-layer — it is the tile's **fallback**. `0x4772f0` (TorsoUpper) -/// tests `cc+0x2e8/0x2ec/0x2f0` = columns 0–2 and `0x4773a0` (LegUpper) tests `cc+0x368/0x36c` = -/// columns 0–1, each `jne` straight past the underwear blit; only with every tested cell null does -/// the handler blit `cc+0x20c`/`cc+0x208`. So a shirt, a chest/robe or a guild tabard's background -/// hides the bra, and legs or a robe hide the panties — but the tested set is a strict **prefix**, -/// and the columns past it are not consulted: **a belt never hides the panties, and a plain tabard -/// never hides the bra**, even though both repaint their tile afterwards. -/// -/// The base-skin blit is unconditional in both branches, so a suppressed tile shows *skin*, not a -/// hole — and the underwear itself is a REPLACE (alphaDepth 0, an opaque full-tile paste), never a -/// blend. +/// The underwear's blits, `(TextureName column, layer, cells tested)`: the tile's fallback, drawn +/// by `0x4772f0` (TorsoUpper, `cc+0x20c`) only with cells 0–2 empty and by `0x4773a0` (LegUpper, +/// `cc+0x208`) only with cells 0–1 empty; `0x478790` fills both from the sectionType-4 row. const UNDERWEAR_TILES: [(usize, usize, i8); 2] = [ - (0, 5, 2), // TextureName[0] → LegUpper: {Legs, Chest/Robe} tested; the belt (col 2) is not - (1, 3, 3), // TextureName[1] → TorsoUpper: {Shirt, Chest/Robe, guild-tabard Background} tested + (0, 5, 2), // pelvis into LegUpper: pants and chest tested, not the belt + (1, 3, 3), // torso into TorsoUpper: shirt, chest and a guild tabard's background tested ]; -/// The eight equipment tiles g0–g7 (written by `0x475c50`), in **layer order** — the load-bearing -/// identity of decision 0074: ItemDisplayInfo texture column *i* = compositor layer *i* = tile -/// g*i*. Note g3 == [`TILE_G3`] and g5 == [`TILE_G5`] — equipment TorsoUpper/LegUpper share the -/// underwear's two tiles; underwear blits first, so it sits under. +/// The equipment tiles g0–g7 of `0x475c50`: ItemDisplayInfo texture column i is layer i, tile gi. const EQUIP_TILES: [Tile; 8] = [ (0, 0, 128, 64), // g0 ArmUpper (0, 64, 128, 64), // g1 ArmLower @@ -78,8 +51,7 @@ const EQUIP_TILES: [Tile; 8] = [ (128, 224, 128, 32), // g7 Foot ]; -/// The `Item\TextureComponents\` region directory per layer/column (decision 0074; empirically -/// suffix-matched — the on-disk dirs are exactly these eight). +/// The `Item\TextureComponents\` directory per layer, the eight the data ships. const EQUIP_TEX_DIRS: [&str; 8] = [ "ArmUpperTexture", "ArmLowerTexture", @@ -91,17 +63,9 @@ const EQUIP_TEX_DIRS: [&str; 8] = [ "FootTexture", ]; -/// The `[0x803bf8]` bodyslot×layer table, rows = bodyslots 2–9 (shirt, chest, belt, -/// pants, boots, wrist, gloves, tabard), columns = layers 0–7: the **cell** within the layer's row -/// that this slot's contribution occupies, `-1` = this slot never touches the layer. -/// -/// It is a **default, not a fixed priority** — that was decision 0074's mistake and B326/B327's -/// cause. `0x478ad0` tests the cell for `-1` and for a non-empty texture name, then hands the slot to -/// the layer's own **chooser** `[0xb42424 + layer*4]`, and two of the eight choosers overrule the -/// table from the item's `geosetGroup` (see [`equip_column`]). Layers 0/2/3/4/5/7 take it verbatim. -/// -/// The row is 16 cells and holds **one record per cell**; the composite handler fans out over it by -/// ascending cell index, so a higher cell is blitted later and covers a lower one. +/// The `[0x803bf8]` table: per bodyslot 2–9 and layer 0–7, the default cell of the slot's art, +/// `-1` for never; two layer choosers (`[0xb42424 + layer*4]`) overrule it ([`equip_column`]). +/// A row holds one record per cell, blitted by ascending cell. const EQUIP_LAYER_COLUMN: [[i8; 8]; 8] = [ [0, 0, -1, 0, 0, -1, -1, -1], // shirt [1, 1, -1, 1, 1, 1, 1, -1], // chest (robes reach the legs) @@ -110,101 +74,55 @@ const EQUIP_LAYER_COLUMN: [[i8; 8]; 8] = [ [-1, -1, -1, -1, -1, -1, 2, 0], // boots [-1, 2, -1, -1, -1, -1, -1, -1], // wrist [-1, 3, 0, -1, -1, -1, -1, -1], // gloves - [-1, -1, -1, 4, 4, -1, -1, -1], // tabard (TorsoUpper is cell 4, not 3 — 0074 mis-read it) + [-1, -1, -1, 4, 4, -1, -1, -1], // tabard ]; -/// The worn-slot indices the two overruling choosers name, in `equipment` order (bodyslot − 2). +/// Worn-slot indices in `equipment` order, bodyslot − 2. const SLOT_CHEST: usize = 1; const SLOT_PANTS: usize = 3; const SLOT_BOOTS: usize = 4; const SLOT_GLOVES: usize = 6; -/// The tabard's worn-slot index (bodyslot 9 − 2) — the slot whose display decides whether the -/// guild-emblem layers install at all ([`ItemDisplay::takes_guild_emblem`]). +/// The tabard's worn slot, whose display decides whether the guild emblem installs. const SLOT_TABARD: usize = 7; /// Where the guild tabard's art lives. const GUILD_EMBLEM_DIR: &str = "Textures\\GuildEmblems"; -/// The two compositor layers the guild tabard paints, and the filename half each names: TorsoUpper -/// takes the `_TU_` files, TorsoLower the `_TL_` ones (`0x47a610` stores the `_TU_` -/// trio into group 3's cells and the `_TL_` twins into group 4's). No other layer is touched — the -/// emblem is a torso garment, and the arms/legs keep whatever the rest of the outfit painted. +/// The guild tabard's layers and name halves (`0x47a610`): TorsoUpper `_TU_`, TorsoLower `_TL_`. const EMBLEM_LAYERS: [(usize, &str); 2] = [(3, "TU"), (4, "TL")]; -/// The filename half layer `layer` reads its guild-emblem art from, or `None` for a layer the -/// tabard never reaches. fn emblem_half(layer: usize) -> Option<&'static str> { EMBLEM_LAYERS .iter() .find_map(|&(l, half)| (l == layer).then_some(half)) } -/// CharSections texture lookup: (race, sex, sectionType, variation, colorIndex) → that row's up-to-3 -/// `TextureName` columns (empty strings for absent columns). Feeds both the base body skin -/// (`sectionType 0`) and the head/pelvis region overlays the body composite blends on top (decision 0044). +/// CharSections: (race, sex, sectionType, variation, colorIndex) → the row's `TextureName`s. pub struct CharSections { sections: HashMap<(u8, u8, u8, u8, u8), [String; 3]>, } impl CharSections { - /// The base body-skin BLP for an appearance — CharSections `sectionType 0`, the single full 256² - /// body-layout texture keyed by skinColor. `None` if the row is absent. + /// The 256² base body skin for a skin colour (`sectionType 0`). pub fn skin_texture(&self, race: u8, sex: u8, skin_color: u8) -> Option<&str> { self.tex(race, sex, SECTION_SKIN, 0, skin_color, 0) } - /// The standalone **extra-skin** BLP for an appearance — CharSections `sectionType 0`, - /// `TextureName[1]`, keyed by skinColor (`…Skin00_NN_Extra.blp`). The texture the client binds to a - /// character body's M2 texture type 8 batches, loaded plain — never composited (the client's extra - /// loader is a bare TextureCreate). Only fur races author the column: tauren bind their head/leg fur - /// to it; every other race's rows leave it empty (and their models carry no type-8 batch). + /// The extra skin (`sectionType 0`, `TextureName[1]`), bound uncomposited to M2 texture type 8; + /// only the fur races author it (tauren). pub fn skin_extra_texture(&self, race: u8, sex: u8, skin_color: u8) -> Option<&str> { self.tex(race, sex, SECTION_SKIN, 0, skin_color, 1) } - /// The hair-**mesh** texture for an appearance — CharSections `sectionType 3`, `TextureName[0]`, - /// keyed by hairStyle (variation) + hairColor. The single BLP the client binds to the hair geometry's - /// M2 texture type 6 (decision 0045); the colour is baked into the chosen file, not a runtime tint. - /// `None` when the row/column is empty (e.g. a bald style, variation 0 — its columns are blank). - /// (`TextureName[1]/[2]` of the same row are the scalp-on-skin overlays the body composite blends into - /// the head atlas — those go through [`Self::composite_body`], not here.) + /// The hair row's `TextureName[0]`, colour baked in; meshes use [`Self::hair_mesh_texture`]. pub fn hair_texture(&self, race: u8, sex: u8, hair_style: u8, hair_color: u8) -> Option<&str> { self.tex(race, sex, SECTION_HAIR, hair_style, hair_color, 0) } - /// The texture to bind to a character body's M2 **type 6** batches — the hair sheet, which on - /// several races dresses the *facial* hair geometry (beard/mustache geosets) as well as the - /// scalp hair. Prefer this over [`Self::hair_texture`] anywhere a mesh is being textured; - /// `hair_texture` is the raw row accessor, and a bald row is genuinely blank. - /// - /// **The mechanism** (decision 0536). The client has no fallback *lookup*. - /// `0x478220(cc, variationIdx)` is the sole type-6 binder, it always reads `TextureName[0]` - /// (never column-indexed), and an **empty name is a no-op that leaves the slot untouched** - /// (`0x47827d cmp BYTE PTR [ecx],0x0` → `je 0x4782d8`) — not a null bind. Three sites call it, - /// in build order skin → hairStyle → facialHair: - /// - /// 1. `0x478445` — variation **literal 1**, gated `sex==0 && hairStyle==0 && ChrRaces.Flags & 8` - /// 2. `0x478450` — variation `hairStyle`, unconditional - /// 3. `0x4786f2` — variation **literal 1**, gated on the selected beard having geoset geometry - /// and the hairStyle row missing a detail column; no sex/race/hairStyle test - /// - /// Because no site ever clears the slot, the result is simply the **last site that resolved a - /// non-empty name** — which reduces to: take the hairStyle row, and when it is blank take - /// **variation 1** at the same colour. That is the fixpoint of the client's incremental apply - /// pipeline, and it is what this implements; we resolve a whole look at once rather than - /// re-applying per dial, so emulating the three sites separately would add machinery without - /// changing an output. - /// - /// The literal 1 is load-bearing where a race authors several sheets per colour: Human male - /// resolves `Hair03_.blp` specifically, not an arbitrary non-blank row. - /// - /// **Where this can diverge** (verified inert on 1.12.1 data, kept honest rather than dropped): - /// site 3's trigger is broader than "the hairStyle row is blank" — it also fires for Dwarf M - /// {0,2,4,9}, Scourge M {1,3,4,8} and Orc F {7}, i.e. 14 of 145 variation groups rather than the - /// 5 blank ones. In every one of those the variation-1 sheet is byte-identical to what the real - /// hairStyle already bound, so the reduction above is exact on the shipped build. It would only - /// part company with the client on data where a site-3 group's variation-1 sheet differs from - /// its own — which build 5875 does not contain. + /// The sheet for M2 texture type 6, the hair and on several races the beard: the style's row, + /// else variation 1 at the same colour. The client's binder `0x478220` ignores an empty name + /// (`0x47827d`) and runs for variation 1 (`0x478445`, `0x4786f2`) and the style (`0x478450`); + /// on the shipped data its last non-empty bind is always this. pub fn hair_mesh_texture( &self, race: u8, @@ -216,7 +134,7 @@ impl CharSections { .or_else(|| self.hair_texture(race, sex, HAIR_SUBSTITUTE_VARIATION, hair_color)) } - /// One `TextureName` column of a section row, or `None` if the row/column is absent or empty. + /// One `TextureName` column of a row; an empty name is `None`. fn tex(&self, race: u8, sex: u8, ty: u8, var: u8, color: u8, col: usize) -> Option<&str> { self.sections .get(&(race, sex, ty, var, color)) @@ -224,27 +142,12 @@ impl CharSections { .filter(|s| !s.is_empty()) } - /// Composite a character's full body-skin atlas: the base 256² skin with the face / facial-hair / - /// hair / underwear region overlays blended in at their fixed atlas tiles, returned as one mip - /// pyramid ready to upload (decision 0044). `Ok(None)` if the base skin row is absent; a missing or - /// undecodable overlay is skipped (best-effort, like the rest of the asset pipeline). + /// The body-skin atlas as one mip pyramid: the 256² base skin, the head overlays, the + /// underwear, the equipment by bodyslot − 2 and the guild emblem ([`equip_blits`]), each at its + /// tile per authored mip level (`0x475c50`, `0x4770f0`); `Ok(None)` without a base skin row. /// - /// Each overlay is read from its own origin and source-over blitted onto the base at the verified - /// tile (`0x475c50`'s bbox table + `0x4770f0`'s src rect + the head-section map). The overlay - /// BLP's own alpha is the blend control, so an opaque overlay (`alphaDepth 0` → the real client's - /// REPLACE: face, pelvis) overwrites the base and an alpha one (facial hair) blends — at full 8-bit - /// precision, not the client's 16-bit RGB565 / 2-bit-coverage memory format (the modern-client - /// choice; the decision has the why). Composited per **authored** mip level — no gamma-byte CPU - /// downsample, the same C2 rule [`read_texture_mip_chain`]/the world-art upload follow. - /// - /// `equipment` is the dressed extension (decision 0074): the worn ItemDisplayInfo rows by - /// **bodyslot − 2** (shirt, chest, belt, pants, boots, wrist, gloves, tabard; `None` = the slot - /// is empty). Their region textures blit into the eight equipment tiles after the skin sections, - /// stacked per layer by the client's priority table — and they also **gate** the underwear, which - /// is the tile's fallback rather than an under-layer ([`UNDERWEAR_TILES`]). - /// - /// `emblem` is the wearer's guild tabard (decision 1704), which paints the torso layers' cells - /// 2/3/4 over the garment's own — see [`equip_blits`] for when it installs and when it does not. + /// Deviation: blends in 8-bit RGBA, not the client's RGB565 with 2-bit coverage, because that + /// format only saves texture memory and loses precision. pub fn composite_body( &self, chain: &mut Chain, @@ -264,13 +167,8 @@ impl CharSections { }; let mut atlas = read_texture_mip_chain(chain, base_path) .with_context(|| format!("reading base skin '{base_path}'"))?; - // The head overlays: (sectionType, variation, color, texColumn, destTile) — the verified fan-out - // (the section → cell core `0x4782e0` + the head map). Within a tile, order matters (later - // overwrites/blends over earlier): base skin (already the canvas) → face → facial hair → hair. - // Note the columns differ by section: face/facial-hair use TextureName[0]/[1] (lower/upper), - // hair uses [1]/[2]. Hair is blank for e.g. Human male (its texid columns are empty), so those - // reads no-op there; included so the path is correct for races whose hairline does composite - // into the head. + // The head overlays `(sectionType, variation, color, column, tile)` of `0x4782e0`, in order + // face, facial hair, hair; face and facial hair use columns 0/1, hair 1/2. let overlays: [(u8, u8, u8, usize, Tile); 6] = [ (SECTION_FACE, face, skin, 0, TILE_G9), (SECTION_FACE, face, skin, 1, TILE_G8), @@ -287,13 +185,10 @@ impl CharSections { blit_over(&mut atlas, &overlay, tile); } } - // The equipment plan, built once: the blits below consume it, and the underwear reads it as its - // gate. One plan, so "what dresses this tile?" has a single answer (decision 0074's `equip_blits`). + // One plan gates the underwear and drives the equipment blits. let plan = equip_blits(&equipment, emblem, tabard_preview); - // The underwear ([`UNDERWEAR_TILES`]) — the tile's fallback, not an under-layer: a contribution - // in any of the group's TESTED columns and the client draws no underwear there at all, leaving - // the base skin it already blitted. Drawn before the equipment because the untested columns (the - // belt, the tabard) still stack on top of it. + // The underwear, skipped when a tested cell is taken (`UNDERWEAR_TILES`); drawn first, so + // the untested cells (belt, tabard) stack on top. for (col, layer, tested) in UNDERWEAR_TILES { if plan.iter().any(|s| s.layer == layer && s.column < tested) { continue; @@ -305,11 +200,7 @@ impl CharSections { blit_over(&mut atlas, &overlay, EQUIP_TILES[layer]); } } - // The equipment layers (decision 0074) and the guild tabard's three (decision 1704), in the - // one order [`equip_blits`] decides, each taking the first of its own - // [`EquipBlit::candidates`] that decodes. A name that resolves to nothing is skipped — - // best-effort, like the skin overlays; on the model it reads as a garment that stops early, - // which is what the instrument's `MISSING` line is for. + // The equipment and emblem layers in plan order, each from its first decodable candidate. for step in &plan { if let Some(overlay) = step .candidates(sex) @@ -344,12 +235,8 @@ impl CharSections { str_at(&rs, r, 8).unwrap_or_default(), ]; let key = (race as u8, sex as u8, ty as u8, var as u8, color as u8); - // The standard player sections are `flags == 0`. Some (race,sex,type,color) keys ALSO have - // an `EXTRA`-flagged row (`flags & 0x1`) sharing the same colorIndex — e.g. human-male - // skinColor 0/1 carry both `HumanMaleSkin00_00` (flags 0) and the late `…_100` (flags 1). - // Those are not the chargen-selectable skins; a player with skinColor 0/1 must get `…_00`. - // So a standard row always wins; an extra row only fills a key no standard row provides - // (order-independent — robust to the extras appearing before or after the standards). + // A row flagged `0x1` can share its key with a standard row (Human male skin 0 has + // `…Skin00_00` and `…_100`); the standard row wins in either order. let extra = u32_at(r, 9).unwrap_or(0) & 0x1 != 0; if extra { sections.entry(key).or_insert(texs); @@ -362,37 +249,25 @@ impl CharSections { } } -/// One equipment contribution the body composite blits, carrying the worn bodyslot it came from, -/// the layer/tile it lands in, and the `ItemDisplayInfo` region-texture name it draws. -/// -/// This is the *plan* [`CharSections::composite_body`] executes, exposed so the instrument -/// (`benilla-extract charatlas`) reads the composite off the **same law the composite runs** rather -/// than a second transcription that can drift. "Which worn slot repainted this tile?" is the -/// question both reported outfit bugs turned on, and it was unanswerable without this. +/// One step of the equipment plan [`CharSections::composite_body`] runs, public so +/// `benilla-extract charatlas` reports the same plan. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct EquipBlit<'a> { - /// The compositor layer — `ItemDisplayInfo` texture column *i* = layer *i* = atlas tile g*i*. + /// The compositor layer: ItemDisplayInfo texture column i, atlas tile gi. pub layer: usize, - /// The cell this contribution occupies in the layer's row — [`equip_column`]'s answer for a worn - /// garment, or the guild tabard's fixed 2/3/4. Ascending = blitted later. + /// The cell in the layer's row ([`equip_column`], or the emblem's 2/3/4); higher blits later. pub column: i8, /// Where the art comes from. pub source: BlitSource<'a>, } -/// What one [`EquipBlit`] paints with — a worn garment's own region texture, or one of the three -/// guild-tabard layers. Two different filename laws, which is why this is an enum and not a path: -/// a garment resolves `Item\TextureComponents\\_.blp` (`_U` first, the -/// wearer's gender letter as the fallback), the tabard `Textures\GuildEmblems\<…>__U.blp` -/// with no gender variant at all. +/// What an [`EquipBlit`] paints: a garment's `Item\TextureComponents\\_.blp`, +/// or a guild-tabard layer's `Textures\GuildEmblems\<…>__U.blp`, which is never gendered. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum BlitSource<'a> { - /// A worn garment's `ItemDisplayInfo` region texture: the bodyslot it came from, indexed as - /// [`CharSections::composite_body`]'s `equipment` is (bodyslot − 2: 0 shirt · 1 chest · 2 belt · - /// 3 pants · 4 boots · 5 wrist · 6 gloves · 7 tabard), and the row's name for this layer (no - /// directory, no gender suffix). + /// A worn garment: its slot (bodyslot − 2) and its bare region-texture name for this layer. Worn { slot: usize, texture: &'a str }, - /// One of the wearer's guild-tabard layers — see [`EmblemLayer`]. + /// One of the wearer's guild-tabard layers. Emblem { part: EmblemLayer, emblem: GuildEmblem, @@ -400,13 +275,8 @@ pub enum BlitSource<'a> { } impl EquipBlit<'_> { - /// The chain paths this contribution is looked up under, **in the order the composite tries - /// them**, first hit wins. Two for a worn garment (`_U`, then the wearer's gender letter — the - /// order `0x476e20` builds them in); one for a guild-tabard layer, which ships a single - /// ungendered name. - /// - /// The composite runs this and the instrument reports it, so "which file did that cell take?" - /// has one answer and cannot drift into two transcriptions. + /// The chain paths to try, first hit wins: `_U` then the gender letter for a garment + /// (`0x476e20`), the one ungendered name for an emblem layer. pub fn candidates(&self, sex: u8) -> Vec { match self.source { BlitSource::Worn { texture, .. } => { @@ -419,50 +289,28 @@ impl EquipBlit<'_> { } } -/// A guild's tabard — the five indices `SMSG_GUILD_QUERY_RESPONSE` carries, and the whole input to -/// the guild-emblem composite. They are file indices, not colours: each names a shipped BLP under -/// `Textures\GuildEmblems\`, and an index with no file simply paints nothing (the client's -/// `**** Unable to Load Texture %s` leaves the cell null), so nothing here is range-checked. -/// -/// **Signed on purpose.** A guild that has never bought a tabard carries `-1` in all five (vmangos -/// `Guild/Guild.cpp:86`; `int32` on the wire), and `{:02}` renders that as `-1` — the same -/// `Emblem_-1_-1_TU_U` the client's own `%02d` builds, which resolves to no file and so paints -/// nothing. Clamping, or casting through unsigned, would invent a crest for a guild that has none. +/// A guild's tabard: the five `SMSG_GUILD_QUERY_RESPONSE` indices, each naming a BLP under +/// `Textures\GuildEmblems\` (the designer's counts `[0x808220]` = {170, 17, 6, 17, 51}); an index +/// with no file paints nothing. Signed, never clamped: a guild with no tabard sends `-1` in all +/// five (vmangos `Guild/Guild.cpp:86`, `int32`). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub struct GuildEmblem { - /// The emblem symbol — `Emblem_