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

Part of #41: the comments of `benilla-formats` (the library, its extract tools, examples and tests) are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 19,654 comment lines become 7,264.

Fixes #105: the WMO collision comments now name walking's box-query leaf `0x6bca50` and the camera and line-of-sight segment-query leaf `0x6bc700`, in `models/collision.rs`, `examples/wmo_bsp.rs` and `tests/chain.rs`.

- **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal.
- **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them:
  - A placed doodad's material alpha reads file slot 0 on its spawn clock, while the reference reads bone 0's armed sequence, re-rolled every play window. The old text said doodads are armed once at load.
  - The fog end is stored ×36 and scaled once at load (`0x6d6090`). It is not raw.
  - The MCSH bit picks a map doodad's sun scale, 1.0 lit or 0.5 shadowed. 2.5 belongs to an entity's light node.
  - `SpellRec` is the row as stored, column × 4, not a translated copy.
  - A global sequence's clock is anchored at the instance's attach time (`0x714352`).
  - Particle twinkle is a gated size multiplier (`0x7b2adc`).
  - The blob shadow sizes from the Stand box.
  - Data claims are checked against the install: the weapon-trail kits' parameters and alphas, the beam rows' noise, and the maps' `field_16`.
  - Dozens more turned up on the way: renamed or deleted items, docs sitting on the wrong item, stale counts and links.
- **Deviation labels.** "Deviation:" now marks only a choice with a reason. An unbuilt behaviour says so plainly.
- **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Four second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the reference, the code and the shipped data.

String literals are untouched; they are #92.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-24 19:38:53 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 87a92eb295
commit 8da634afa9
243 changed files with 6607 additions and 19001 deletions
@@ -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> [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 -- <id|map:N>...`
//!
//! 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",
@@ -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);
+5 -12
View File
@@ -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 -- <m2 path>`
fn main() -> anyhow::Result<()> {
let virt = std::env::args()
+7 -37
View File
@@ -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<String> = 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<u8> = 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];
+10 -28
View File
@@ -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] {
@@ -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<String> = 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<f32> {
if sub.positions.is_empty() || sub.uvs.len() != sub.positions.len() {
return None;
+1 -2
View File
@@ -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)?;
@@ -1,6 +1,5 @@
//! Session probe: print AnimationData.dbc rows (id, name, weapon flags, fallback) —
//! `cargo run -p benilla-formats --example dump_anim_names <Data-dir> [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 <Data-dir> [id ...]`
fn main() {
let mut args = std::env::args().skip(1);
@@ -1,7 +1,6 @@
//! Session probe: which bones carry keys in a given sequence — `cargo run -p benilla-formats
//! --example dump_bone_keys <file.m2> <anim_id> [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 <file.m2> <anim_id> [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})",
@@ -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()
+3 -4
View File
@@ -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;
@@ -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 -- <m2 path | substring>`
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<String> = if exact {
vec![arg]
+4 -14
View File
@@ -1,15 +1,6 @@
//! `emdump <internal\path.m2>` — 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 <internal\path.m2>`
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}");
@@ -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 -- <m2 path> [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<Mat4> {
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<Mat4> {
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<usize> = 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
@@ -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 -- <m2>`
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<f32> {
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);
@@ -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",
+3 -6
View File
@@ -1,9 +1,6 @@
//! `gseqdump <internal\path.m2>` — 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 <internal\path.m2>`
//! Output is Blizzard data: never commit it.
fn main() -> anyhow::Result<()> {
let virt = std::env::args()
+9 -25
View File
@@ -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 -- <map> <x> <y> [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,
@@ -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<String, Kind> = 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();
@@ -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 <substring> [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()));
@@ -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());
@@ -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<f32> = 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<f32> = 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<x>_<y>.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<Vec<u8>>> = 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<usize, usize> = 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;
@@ -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::<usize, usize>::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<usize> {
let (cols, rows) = (m.grid[0] as usize, m.grid[1] as usize);
if cols < 2 || rows < 2 {
@@ -1,10 +1,7 @@
//! Throwaway: the sort/admission inputs the trainer & craft windows key on. Two modes:
//!
//! * `line <skill-line-name-substring>...` — 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 <n>` — 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 <name-substring>...` 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 <n>` 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)?;
+3 -3
View File
@@ -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 -- <png> [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 -- <png> [cx cy r]`
fn main() -> anyhow::Result<()> {
let a: Vec<String> = std::env::args().skip(1).collect();
let img = image::open(&a[0])?.to_rgb8();
@@ -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())
+10 -32
View File
@@ -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 -- <WoW/Data> <internal\path.m2>`
//! 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 -- <WoW/Data> <internal\path.m2>`
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::<f32>() / 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)
@@ -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)?;
+2 -4
View File
@@ -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)?;
+4 -5
View File
@@ -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 -- <race> <class>`.
//! `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 -- <race> <class>`
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;
+11 -27
View File
@@ -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 -- <map> <x> <y> [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<benilla_formats::TileMesh>> = 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:?}");
+17 -58
View File
@@ -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 -- <map> <x> <y> [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<f32>,
/// 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<usize>> = 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::<Vec<_>>() {
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 };
@@ -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 -- <map> <x> <y> [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();
+3 -13
View File
@@ -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() {
@@ -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<GroupRow>,
}
/// 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<String> = 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) ----",
+19 -43
View File
@@ -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 -- <wmo-path-or-substring>`
//! 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<u16>, usize) {
let n = nodes.len() / 0x10;
let (mut seen, mut reach, mut bad) = (vec![false; n], HashSet::new(), 0usize);
@@ -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 -- <root.wmo> # 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 -- <root.wmo> | --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()
@@ -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 -- <wmo-path-or-substring>...`
//! 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,
+10 -25
View File
@@ -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 -- <wmo-path-or-substring> [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<f32> {
let mut best: Option<f32> = 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 <wmo-path-or-substring> [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)");
@@ -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 [-- <substring>]`
//!
//! 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(|()| {
+15 -37
View File
@@ -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 -- <wmo-path-or-substring>`
//! 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<Tri>> = 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];
@@ -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 `<root stem>_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<String, (u32, u32)> = 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)
@@ -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<LiquidMesh>,
/// 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<Group> {
})
}
/// 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<String> = 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<u32, (usize, usize)> = 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<u32, (usize, usize)> = BTreeMap::new();
let mut odd: Vec<String> = Vec::new();
for name in chain.list()?.into_iter().map(|e| e.name) {
+12 -32
View File
@@ -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 -- <wmo-path-or-substring> [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) ---",
@@ -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<String>,
}
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<BTreeMap<String, BTreeSet<String>>> {
let mut placed: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
// `World\Maps\<map>\<map>_XX_YY.adt` — the map name is the third path component.
// `World\Maps\<map>\<map>_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<String> = 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<String> = 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!(
+16 -48
View File
@@ -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<AnimationData>` 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<u16, AnimEntry>,
/// 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<u16, String>,
}
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<Item = (u16, AnimEntry)>) -> 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<u16> {
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<AnimDataCatalog> {
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");
+20 -54
View File
@@ -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<u32, usize>,
@@ -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<Item = (u32, &AreaPoi)> {
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<AreaPoiCatalog> {
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");
+22 -42
View File
@@ -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<u32, SoundAmbienceEntry>,
}
/// 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<AreaAudio<'_>> {
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<AreaSoundCatalog> {
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()
+24 -86
View File
@@ -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<u32> {
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<u32> {
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<Item = &AreaTableRow> {
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<AreaTableCatalog> {
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<AreaTableCatalog> {
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)"),
+24 -71
View File
@@ -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<u32, Vec<AreaTriggerRow>>,
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));
}
+11 -43
View File
@@ -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<u32, AuctionHouseInfo>,
}
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<u32> {
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<u32> {
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<AuctionHouseCatalog> {
let bytes = chain
.read_file(AUCTION_HOUSE)
@@ -124,9 +98,6 @@ pub fn load_auction_houses(chain: &mut Chain) -> Result<AuctionHouseCatalog> {
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);
}
}
@@ -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<u32, u32>);
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<u32> {
self.0.get(&(u32::from(purchased_count) + 1)).copied()
}
@@ -52,7 +44,6 @@ pub fn load_bank_bag_slot_prices(chain: &mut Chain) -> Result<BankBagSlotPrices>
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<BankBagSlotPrices> {
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<BankBagSlotPrices> {
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<u8> {
let rows: &[[u32; 2]] = &[
[1, 1_000],
@@ -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,
@@ -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<GuildEmblem>,
}
/// 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 <the race model>` 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:?}");
@@ -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<i32, BTreeSet<u32>> = 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<u32>> = 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<u32, TrailProc> = 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;
@@ -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<Option<Coverage>> = 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"
@@ -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<u16, BTreeSet<u32>> = 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<u32>> = BTreeMap::new();
let mut stage_spells: BTreeMap<&str, BTreeSet<u32>> = BTreeMap::new();
let mut state: BTreeMap<u32, StateKit> = 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())
@@ -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<V: std::fmt::Debug + Copy + PartialEq>(name: &str, t: &benilla_m2::M2Track<V>) {
if t.keys.is_empty() {
println!(" {name}: -");
@@ -172,7 +154,6 @@ fn print_txfm_track<V: std::fmt::Debug + Copy + PartialEq>(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<String> = 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<String> = 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<String> {
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<String> {
};
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<String> {
(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<String> {
))
}
/// 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<String> {
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<u16, usize> = 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<String> {
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<String, usize> = 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!("<char:{slot:?}>"),
(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::<Vec<_>>()
.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<String> = Vec::new();
let mut animated: Vec<String> = 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 `<Model>` 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
@@ -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 (`<stem>.mip<N>.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 (`<stem>.mip<N>.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<u32>,
/// 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<Vec<i32>>,
/// Write the composited atlas here as a PNG (256²).
#[arg(long)]
out: Option<PathBuf>,
},
/// 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 `<Model>` 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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 `<Race><Sex>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 `<Race><Sex>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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<u32> {
}
}
/// `"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());
@@ -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<f32> {
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<f32> {
(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<T>(keys: &[(T, [f32; 3])]) -> f32 {
keys.iter()
.map(|(_, s)| {
@@ -135,23 +113,12 @@ fn scale_spread<T>(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<String, u32> = 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<String> = 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<String, u32> = 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<usize, f32> = 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<String, u32> = 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)
@@ -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<String>,
}
/// 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}"))?;
File diff suppressed because it is too large Load Diff
@@ -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::<name>` 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<Vec<String>> {
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<Vec<St
.collect())
}
/// Every WMO **root** in the chain, narrowed to a path `prefix` when one is given — same casing
/// and matching rules as [`m2_names`].
///
/// A WMO ships as one root plus one file per group, named `<stem>_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 `<stem>_NNN.wmo`, exactly three digits.
pub(crate) fn wmo_roots(chain: &mut Chain, prefix: Option<&str>) -> Result<Vec<String>> {
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<Vec<S
.collect())
}
/// Capitalize an `Item\ObjectComponents\<sub>` 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\<sub>` 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();
@@ -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<String> = 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<String>,
recursion_model: Option<String>,
@@ -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<RecordExtras> {
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<String, Vec<(u32, String)>> {
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<String, Option<Vec<(String, bool)>>> = BTreeMap::new();
let mut fam_transparent: BTreeMap<String, u32> = BTreeMap::new();
let mut fam_opaque: BTreeMap<String, u32> = 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<String> = 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<String, (u32, u32)> = BTreeMap::new();
let (mut scanned, mut with_fx, mut at_risk) = (0u32, 0u32, 0u32);
let mut rungs: BTreeMap<u32, u32> = 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!(
@@ -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<String, Vec<u32>> = 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<String> = 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<String> = 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<String, Vec<u32>> = 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);
@@ -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<u16, usize> = 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<usize> = (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<String> = BTreeSet::new();
let mut gseq_restrict_models: BTreeSet<String> = 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<String> = 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<usize> = (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<String, (u32, u32, u32, f32, String)> = 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<String> = 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<u16> = benilla_formats::parse_m2_animations(&bytes)
.iter()
.flat_map(|a| a.bones.iter().map(|b| b.bone))
@@ -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<u16, Vec<u32>> = 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<String> {
let mut off = 0usize;
while off + 8 <= bytes.len() {
@@ -156,31 +148,14 @@ fn mods_set_names(bytes: &[u8]) -> Vec<String> {
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<u32> = HashSet::new();
let mut m2_instances: HashMap<String, u32> = HashMap::new();
// WMO placements (MODF) — same dedup, by their own uniqueId.
// MODF placements, deduped the same way.
let mut seen_wmo_ids: HashSet<u32> = HashSet::new();
let mut wmo_instances: HashMap<String, u32> = 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<String, M2AnimSummary> = 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
@@ -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<String>,
}
/// 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<u32, Vec<u32>> = 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<String> = gos.iter().map(|(_, path)| mdx_key(path)).collect();
let creature_models: BTreeSet<String> = 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<u32> = users
.keys()
.copied()
@@ -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)
@@ -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<u32, Vec<u32>> = 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<u32, BTreeSet<u32>> = 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<u32> = (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}");
}
+4 -7
View File
@@ -1,9 +1,6 @@
//! One-off extraction helper (session tool, not shipped): `mpqx <data-dir> <virtual-path> <out>`.
//! 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 <data-dir> <virtual-path> <out>`, 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);
+28 -109
View File
@@ -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<Item = u32> + '_ {
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<u32, CameraShake>,
@@ -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<CameraShakeCatalog> {
let bytes = chain
.read_file(CAMERA_SHAKES)
@@ -259,11 +183,8 @@ pub fn load_camera_shakes(chain: &mut Chain) -> Result<CameraShakeCatalog> {
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<u32> = [1, 2, 10, 11, 12, 38].into_iter().collect();
let all: BTreeSet<u32> = cat.iter().map(|r| r.id).collect();
let unreached: Vec<u32> = all.difference(&(&named | &creature)).copied().collect();
+27 -59
View File
@@ -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<HashMap<…, Archive>>` 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<Archive>,
}
/// `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<String> {
let find = |want: &str| {
dir_names
@@ -75,13 +60,11 @@ fn mount_order(dir_names: &[String]) -> Vec<String> {
}
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<Self> {
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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<ChainEntry>> {
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),
@@ -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<u8, (u32, u32)>,
/// race → the ChrRaces client fileString (col 15: `"Human"`, `"Scourge"`, …) — the stem the glue
/// screens key GlueStrings entries on (`RACE_INFO_<FILE>`, `ABILITY_INFO_<FILE><n>`).
/// race → ChrRaces fileString (col 15), the GlueStrings key stem (`RACE_INFO_<FILE>`).
files: HashMap<u8, String>,
/// 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_<tok>_STYLE`, `FACIAL_HAIR_<tok>`); a facial token of `"NONE"` hides that dial.
/// race → ([male, female] facial-hair token, cols 26/27; hair token, col 28).
custom_tokens: HashMap<u8, ([String; 2], String)>,
/// 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<StartOutfitItem>>,
}
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<u32> {
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_<tok>_STYLE` / `HAIR_<tok>_COLOR`).
/// A race's hair glue token (`"NORMAL"`, `"HORNS"`), keying `HAIR_<tok>_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_<tok>`); `"NONE"` means the dial is hidden (the ref's
/// `GetFacialHairCustomization() == "NONE"` branch).
/// A (race, sex)'s facial-hair glue token, keying `FACIAL_HAIR_<tok>`; 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<u8> {
let mut cs: Vec<u8> = 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<DialRanges> {
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<Self> {
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<HashSet<(u8, u8)>> {
let bytes = chain
.read_file(CHAR_BASE_INFO)
@@ -381,15 +320,9 @@ fn load_combos(chain: &mut Chain) -> Result<HashSet<(u8, u8)>> {
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<HashMap<(u8, u8, u8), Vec<StartOutfitItem>>> {
let bytes = chain
.read_file(CHAR_START_OUTFIT)
@@ -432,10 +365,8 @@ fn load_start_outfits(chain: &mut Chain) -> Result<HashMap<(u8, u8, u8), Vec<Sta
Ok(map)
}
/// CharSections → the set of *available* `(race, sex, sectionType, variation, color)` keys — the rows
/// a player may select (`Flags & 0x1 == 0`; the `0x1`-flagged rows are `SECTION_FLAG_UNAVAILABLE`,
/// the late `…_100` extras the compositor already skips). This is exactly the domain vmangos's
/// `GetCharSectionEntry` searches, so range derivation + validation read the same predicate.
/// CharSections → the selectable keys, rows without flag `0x1` (`SECTION_FLAG_UNAVAILABLE`): the
/// domain vmangos's `GetCharSectionEntry` searches.
fn load_available_sections(chain: &mut Chain) -> Result<HashSet<SectionKey>> {
let bytes = chain
.read_file(CHAR_SECTIONS)
@@ -460,8 +391,7 @@ fn load_available_sections(chain: &mut Chain) -> Result<HashSet<SectionKey>> {
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<HashMap<(u8, u8), HashSet<u8>>> {
let bytes = chain
.read_file(CHAR_HAIR_GEOSETS)
@@ -482,9 +412,7 @@ fn load_hair_geosets(chain: &mut Chain) -> Result<HashMap<(u8, u8), HashSet<u8>>
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<HashMap<(u8, u8), HashSet<u8>>> {
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<SectionKey>,
facial: &HashMap<(u8, u8), HashSet<u8>>,
@@ -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<u8> = 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 {
+77 -171
View File
@@ -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<u32, [u32; 5]>,
}
/// 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<u32>,
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<u16> {
// 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<u16>, 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<Self> {
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<u32>> = 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");
}
+7 -22
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+30 -85
View File
@@ -1,30 +1,6 @@
//! `ChatChannels.dbc` — the six built-in chat channels and, in their flags, **which ones the
//! client joins by itself**.
//!
//! 1.12 has no server-side zone-channel join: vmangos's `Player::UpdateLocalChannels`
//! (`src/game/Objects/Player.cpp:5121`) is an empty function whose whole body is the comment
//! `// Updated client-side`. The client walks this table at world entry **and on every zone
//! change** and sends `CMSG_JOIN_CHANNEL` for each auto-join row with the zone's name spliced into
//! the row's pattern — one function, `ZoneChannelRefresh 0x49a210` (decision 0288 phase 6; the
//! walk itself is this crate's consumer, `benilla_app::ui_chat::channels`).
//!
//! Layout — the real 5875 file, decoded here (662 bytes, 6 records × **21 × u32** cols, 138-byte
//! string block): `ChannelID(0), Flags(1), FactionGroup(2), Name[8](3..10), NameMask(11),
//! Shortcut[8](12..19), ShortcutMask(20)`. The whole shipped table, verbatim:
//!
//! | ID | flags | name pattern (enUS) | shortcut |
//! |---|---|---|---|
//! | 1 | `0x00003` | `General - %s` | `General` |
//! | 2 | `0x0003B` | `Trade - %s` | `Trade` |
//! | 22 | `0x10003` | `LocalDefense - %s` | `LocalDefense` |
//! | 23 | `0x10004` | `WorldDefense` | `WorldDefense` |
//! | 24 | `0x00000` | `LookingForGroup` | `LookingForGroup` |
//! | 25 | `0x20032` | `GuildRecruitment - %s` | `GuildRecruitment` |
//!
//! The flag names are vmangos's ([`flags`], `src/game/Chat/Channel.h`). **Exactly rows 1, 2 and 22
//! carry `INITIAL`** — and that is independently corroborated: the reference client's own
//! `WTF/…/chat-cache.txt` persists `ZONECHANNELS 2097155` = `0x200003` = bits 0, 1 and 21, i.e.
//! `1 << (ChannelID - 1)` for exactly those three. Two unrelated artifacts, one set.
//! `ChatChannels.dbc`, the six built-in chat channels. The zone-channel join is the client's
//! (vmangos's `Player::UpdateLocalChannels`, `Objects/Player.cpp:5121`, is empty): at world entry
//! and every zone change `0x49a210` sends `CMSG_JOIN_CHANNEL` for each auto-join row.
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
@@ -34,40 +10,39 @@ use crate::dbc::{parse, str_at, u32_at};
const CHAT_CHANNELS: &str = "DBFilesClient\\ChatChannels.dbc";
/// `ChatChannels.dbc` flag bits, named per vmangos `Channel::ChannelDBCFlags`
/// (`src/game/Chat/Channel.h:107-116`) and checked against the shipped rows in the module table.
/// `ChatChannels.dbc` flag bits, named as vmangos's `ChannelDBCFlags` (`Chat/Channel.h:107-116`).
pub mod flags {
/// The client joins this channel on its own. Rows 1 (General), 2 (Trade), 22 (LocalDefense).
/// The client joins it on its own: General (1), Trade (2) and LocalDefense (22).
pub const INITIAL: u32 = 0x00001;
/// The name carries a `%s` filled per zone — the channel is re-joined when the zone changes.
/// The name carries a `%s` filled per zone, so the channel is re-joined on a zone change.
pub const ZONE_DEP: u32 = 0x00002;
/// One channel for the whole realm (WorldDefense).
pub const GLOBAL: u32 = 0x00004;
/// The trade channel proper.
pub const TRADE: u32 = 0x00008;
/// Only joined while inside a capital. Trade and GuildRecruitment carry both city bits.
/// Joined only inside a capital; Trade and GuildRecruitment carry both city bits.
pub const CITY_ONLY: u32 = 0x00010;
/// Its twin — vmangos reads this one for its own `CHANNEL_FLAG_CITY`.
/// The second city bit; vmangos reads this one for its `CHANNEL_FLAG_CITY`.
pub const CITY_ONLY2: u32 = 0x00020;
/// LocalDefense / WorldDefense.
/// LocalDefense and WorldDefense.
pub const DEFENSE: u32 = 0x10000;
/// GuildRecruitment — needs a guild.
/// GuildRecruitment, which needs a guild.
pub const GUILD_REQ: u32 = 0x20000;
/// LookingForGroup. Zero on the 1.12 row, so nothing carries it in this build.
/// LookingForGroup; the 1.12 row's flags are 0, so no row carries it.
pub const LFG: u32 = 0x40000;
}
/// One `ChatChannels.dbc` row.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChatChannelRow {
/// `ChannelID` — the number the client hands Lua as a chat event's **arg7** and as
/// `JoinChannelByName`'s first return (`ChatFrame.lua:786`, `:1379`).
/// `ChannelID`: a chat event's `arg7` and `JoinChannelByName`'s first return
/// (`ChatFrame.lua:786`, `:1379`).
pub id: u32,
/// See [`flags`].
/// Bits from [`flags`].
pub flags: u32,
/// The enUS name pattern, `%s` intact ("General - %s").
pub pattern: String,
/// The enUS shortcut ("General") — the number-less, zone-less name.
/// The enUS shortcut ("General"), the name without number or zone.
pub shortcut: String,
}
@@ -82,45 +57,27 @@ impl ChatChannelRow {
self.flags & flags::ZONE_DEP != 0
}
/// Is this row **only joined inside a capital**? Gated on `CITY_ONLY` (`0x10`) alone — the
/// client tests the zone's own capital flag under this bit and no other
/// (`0x49a3b8`/`0x49a512`).
/// Whether the row is joined only inside a capital; the client tests the zone's capital flag
/// under `CITY_ONLY` alone (`0x49a3b8`/`0x49a512`).
pub fn is_city_only(&self) -> bool {
self.flags & flags::CITY_ONLY != 0
}
/// Is this the row a **guild** decides about — `GUILD_REQ` (`0x20000`), `GuildRecruitment`
/// alone in the 1.12 table? The client keys three things on it: the cascade's composer
/// `0x49f140` finds the row by this bit, and a manual `JoinChannelByName`/`LeaveChannelByName`
/// on it forces the auto-join option off (`0x49ed3d`/`0x49ef8f`).
/// Whether this is the guild-gated row (`GUILD_REQ`), which the client finds by that bit
/// (`0x49f140`); a manual join or leave on it turns its auto-join off (`0x49ed3d`/`0x49ef8f`).
pub fn is_guild_recruitment(&self) -> bool {
self.flags & flags::GUILD_REQ != 0
}
/// Does this row's `%s` take the shared **city** word instead of the zone's name?
///
/// A different bit from [`Self::is_city_only`] — `CITY_ONLY2` (`0x20`) — and a different test
/// in the client (`0x49a308`/`0x49a4ea` pick the substitution on it). Rows 2 and 25 carry
/// both bits, so the two questions have the same answer on the 1.12 table; they are kept apart
/// because the client keeps them apart.
/// Whether the `%s` takes the city word, not the zone's name: `CITY_ONLY2`, tested apart from
/// [`Self::is_city_only`] (`0x49a308`/`0x49a4ea`) though 1.12 rows set both bits or neither.
pub fn takes_city_name(&self) -> bool {
self.flags & flags::CITY_ONLY2 != 0
}
/// The joinable name for a player standing in `zone_name`, with `city_name` the shared capital
/// word.
///
/// The `%s` is the **zone**'s display name for an ordinary zone-dependent row
/// ("General - Elwynn Forest") and `city_name` for a city-named one ("Trade - City") — one
/// trade channel shared by every capital rather than one per city.
///
/// **"City" is DBC data, not a string literal**: the client keeps the word at `0xb4e4f0`, whose
/// single writer `0x4985fd` is a load-time scan of `AreaTable.dbc` for the row with
/// `Flags & 0x200`. In the shipped 5875 table that is **exactly one row — id 3459,
/// `AreaName[enUS] = "City"`** (asserted in [`tests`] against the real file), which is
/// precisely why searching `WoW.exe` for `City\0` finds nothing at all. The caller passes the
/// word in rather than this crate re-reading `AreaTable.dbc`, because the auto-join walk
/// already holds that catalog — and passing it keeps the localized name localized.
/// The joinable name in `zone_name`, or with `city_name` on a city-named row ("Trade - City").
/// The city word is data: `0x4985fd` stores at `0xb4e4f0` the name of the one `AreaTable.dbc`
/// row with `Flags & 0x200` (id 3459, "City").
pub fn joinable_name(&self, zone_name: &str, city_name: &str) -> String {
if !self.is_zone_dependent() {
return self.pattern.clone();
@@ -141,8 +98,7 @@ pub struct ChatChannelsCatalog {
}
impl ChatChannelsCatalog {
/// Build a catalog from rows directly — for tests that must run without an install (the real
/// six rows are asserted against the shipped DBC by [`tests`]).
/// A catalog from rows given directly, for tests that run without an install.
pub fn from_rows(rows: Vec<ChatChannelRow>) -> Self {
ChatChannelsCatalog { rows }
}
@@ -155,18 +111,13 @@ impl ChatChannelsCatalog {
self.rows.is_empty()
}
/// The rows the client joins by itself, in table order ([`ChatChannelRow::is_auto_join`]).
/// The rows the client joins by itself, in table order.
pub fn auto_join_rows(&self) -> impl Iterator<Item = &ChatChannelRow> {
self.rows.iter().filter(|r| r.is_auto_join())
}
/// The row a composed channel name belongs to, or `None` for a custom channel.
///
/// Matched the way the server matches it — vmangos's `GetChannelEntryFor(std::string const&)`
/// (`src/game/Database/DBCStores.cpp:531`) strips the `%s` from each pattern and asks whether
/// the name *contains* what is left, so `"General - Elwynn Forest"` resolves to ChannelID 1.
/// Matching the server's own rule is the point: it is what decides whether the channel we
/// joined is the built-in one both clients land in.
/// The row a composed name belongs to, matched as the server does: the name contains the
/// pattern minus its `%s` (`GetChannelEntryFor`, `Database/DBCStores.cpp:531`).
pub fn row_for_name(&self, name: &str) -> Option<&ChatChannelRow> {
let lower = name.to_ascii_lowercase();
self.rows.iter().find(|r| {
@@ -175,8 +126,7 @@ impl ChatChannelsCatalog {
})
}
/// The `ChatChannels.dbc` ChannelID behind a composed name — a chat event's **arg7**.
/// 0 for a custom channel, which is what the reference passes there too.
/// The ChannelID behind a composed name, a chat event's `arg7`; 0 for a custom channel.
pub fn zone_channel_id(&self, name: &str) -> u32 {
self.row_for_name(name).map_or(0, |r| r.id)
}
@@ -221,9 +171,6 @@ pub fn load_chat_channels_catalog(chain: &mut Chain) -> Result<ChatChannelsCatal
mod tests {
use super::*;
/// The real shipped table, row for row — the module doc's table asserted rather than trusted,
/// because every channel name benilla sends on the wire is composed from these six rows.
/// Skips without client data.
#[test]
fn the_real_table_is_the_six_documented_rows() {
let data = crate::wow_data_or_skip!();
@@ -247,13 +194,11 @@ mod tests {
]
);
// The auto-join set is exactly the `chat-cache.txt` ZONECHANNELS mask 0x200003 — two
// artifacts, one answer (module doc).
// The auto-join set is the reference's `chat-cache.txt` `ZONECHANNELS` mask.
let mask: u32 = cat.auto_join_rows().map(|r| 1 << (r.id - 1)).sum();
assert_eq!(mask, 0x0020_0003, "General + Trade + LocalDefense");
}
/// Name composition, both arms, and the reverse lookup the server also does.
#[test]
fn names_compose_per_zone_and_resolve_back_to_their_row() {
let data = crate::wow_data_or_skip!();
+38 -115
View File
@@ -1,78 +1,19 @@
//! `ChrClasses.dbc` — the per-class client table, and the three columns anything here reads off
//! it.
//! `ChrClasses.dbc`, the per-class table, narrowed to the three columns the reference reads off
//! its class-indexed record table (`0xc0def4`, max id `0xc0def8`, loader `0x542360`).
//!
//! One file, one parse. Two of the three are answers the *engine* gives Lua about a class and the
//! third is the talent spell-modifier gate's right-hand side; all three are read by address off
//! the same class-indexed record table at `ds:0xc0def4`, bounded by the max id at `ds:0xc0def8`
//! (store `0xc0deec`, loader `0x542360`, filename `0x85838c`).
//! Field 4, the pet name token: the second return of `HasPetSpells` (`0x4b4410`), `"PET"` on every
//! row but the Warlock's `"DEMON"`. FrameXML resolves it with `getglobal("PET_TYPE_"..token)`
//! (`SpellBookFrame.lua:173`), so the token is a key, never display text, and is not localized.
//!
//! ## Field 4 — the pet name token
//! Field 15, the class spell family: a talent modifier applies to a spell only when its
//! `SpellFamilyName` (`Spell.dbc` column 160) equals the local player's (`GetSpellModifiers`,
//! `0x6e6b30`), cached in `[0xcecaac]` by its one non-zeroing writer, `0x6e6ca0` (called from
//! `0x5debcc`). The shipped values are vmangos `SpellFamilyNames`.
//!
//! `HasPetSpells()`'s second return, and the only thing that decides whether the spellbook's
//! second tab reads "Pet" or "Demon" (decision 1032). `HasPetSpells 0x4b4410` pushes it verbatim:
//!
//! ```text
//! 0x4b445d eax = [player + 0x110] ; the descriptor fields
//! 0x4b4463 eax = byte [fields + 0x79] ; UNIT_FIELD_BYTES_0 byte 1 = the CLASS
//! 0x4b446b if (class < 0 || class > [0xc0def8]) -> the out-of-range arm
//! 0x4b4473 ecx = [0xc0def4] ; the class record table
//! 0x4b4479 eax = ecx[class] ; 1-BASED — index 0 is never a class
//! 0x4b447c edx = [rec + 0x10] ; <- the token string
//! 0x4b44a6 (player did not resolve) edx = "PET" ; the literal 0x846a40
//! ```
//!
//! **`rec + 0x10` is field 4, and the file says which field that is**: dumped from the real 5875
//! `ChrClasses.dbc` (9 rows, 17 columns, 0x44-byte records), field 4 is `"PET"` in every row except
//! Warlock (id 9), which is `"DEMON"`. Field 5 is the class name (`Warrior`/`Paladin`/…), so a
//! one-column slip would put "Warlock" on the tab — plausible enough to ship, which is why
//! [`tests`] anchors both columns.
//!
//! FrameXML then does `getglobal("PET_TYPE_"..token)` (`SpellBookFrame.lua:173`) against
//! `PET_TYPE_PET = "Pet"` / `PET_TYPE_DEMON = "Demon"` (`GlobalStrings.lua:3057-3058`) — so the
//! token is a **key**, never display text, and it is right that it is not localized here.
//!
//! ## Field 16 — the relic-slot flag
//!
//! `UnitHasRelicSlot 0x519e50` reads it and nothing else. Same walk — typemask bit 4 (PLAYER),
//! `UNIT_FIELD_BYTES_0` byte 1 for the class, bound against `0xc0def8`, index `0xc0def4` — then:
//!
//! ```text
//! 0x519ebb mov ecx,[row + 0x40] ; <- field 16, the relic flag
//! ```
//!
//! Non-zero pushes the **number 1.0**; zero pushes **nil** (never `false`). There is no `cmp`
//! against a class id anywhere in the function: 1.12 is entirely data-driven here, and this table
//! is the data. In the shipped file the column is 1 for exactly **2 Paladin, 7 Shaman, 11 Druid**
//! — Libram, Totem, Idol.
//!
//! **Why this was believed impossible, and the trap worth keeping.** The base `dbc.MPQ` copy of
//! this file is **16 fields / 64-byte records and has no field 16 at all**; `patch.MPQ` supersedes
//! it with the 17-field / 68-byte version, and the 5875 loader asserts exactly those two numbers
//! (`0x54240e`/`0x542446`), so the patch copy is the only one the client can read. Read the base
//! archive alone — or an early-vanilla 16-column struct — and the flag simply is not there, which
//! is how "the relic slot post-dates 1.12" became a settled belief in this codebase and stayed one
//! across two decision records. It is false; see decision 1796. [`CHR_CLASSES_FIELDS`] is what
//! keeps us on the patch copy, and it is load-bearing, not defensive.
//!
//! The flag is engine-enforced, not a UI conceit: `IsValidForSlot 0x5da1d0`'s `slot == 0x11` leg
//! requires `(InventoryType == 28 RELIC) == hasRelicSlot`, so **INVSLOT 17 takes a relic for those
//! three classes and a ranged weapon for everyone else** — one slot, two meanings. 1.12 has no
//! *separate* relic slot, which is the one true fragment inside the old belief.
//!
//! ## Field 15 — the class's spell family
//!
//! `GetSpellModifiers 0x6e6b30`'s second gate: a talent modifier applies to a spell only when that
//! spell's own `SpellFamilyName` (`Spell.dbc` column 160) equals the LOCAL PLAYER's class family,
//! which the client caches in the global `[0xcecaac]`. That global has exactly one
//! non-zeroing writer, `0x6e6ca0` (sole caller `0x5debcc`, the local-player create-finalise): it
//! takes `UNIT_FIELD_BYTES_0` byte 1 — the class, the same walk both sections above make — and
//! stores `ChrClasses[class] + 0x3c`, i.e. **field 15**.
//!
//! Anchored on the shipped 5875 file, which is also a could-have-failed control on the column: the
//! nine rows read Warrior 4, Paladin 10, Hunter 9, Rogue 8, Priest 6, Shaman 11, Mage 3, Warlock 5,
//! Druid 7 — the vmangos `SpellFamilyNames` values exactly, in an order no neighbouring column
//! could reproduce (field 14 is a name-flags word reading 8323199 on every row, field 16 is the
//! relic flag above).
//! Field 16, the relic-slot flag, read by `UnitHasRelicSlot` (`0x519e50`) and nothing else: no
//! class id is compared, the table is the data. It is set for Paladin, Shaman and Druid, and
//! `IsValidForSlot` (`0x5da1d0`) enforces it: INVSLOT 17 takes a relic (`InventoryType` 28) for
//! those three and a ranged weapon for everyone else.
use std::collections::HashMap;
@@ -84,27 +25,24 @@ use crate::dbc::{parse, str_at, u32_at};
const CHR_CLASSES: &str = "DBFilesClient\\ChrClasses.dbc";
/// `ChrClasses.dbc`'s column count in 5875 (the header's `field_count`; `benilla-dbc` enforces it).
///
/// This is the **patch** copy's shape. The base archive's 16-field copy fails the check rather
/// than silently reading one column short of the relic flag — see the module header.
/// The patch copy's column count, which `benilla-dbc` enforces as the reference loader does
/// (`0x54240e`, `0x542446`): the base archive's 16-column copy lacks the relic flag and is refused,
/// never read one column short.
const CHR_CLASSES_FIELDS: usize = 17;
/// The pet-name-token column — byte `0x10` in the client's own record read, i.e. field 4.
/// Field 4, read at `rec + 0x10` by `HasPetSpells` (`0x4b447c`).
const PET_NAME_TOKEN_FIELD: usize = 0x10 / 4;
/// The relic-slot column — byte `0x40` in `UnitHasRelicSlot`'s read, i.e. field 16.
/// Field 16, read at `row + 0x40` by `UnitHasRelicSlot` (`0x519ebb`).
const RELIC_SLOT_FIELD: usize = 0x40 / 4;
/// The class spell-family column — byte `0x3c` in `0x6e6ca0`'s read, i.e. field 15. See the module
/// header for the walk and the shipped-file anchor.
/// Field 15, read at `+0x3c` by `0x6e6ca0`.
const SPELL_FAMILY_FIELD: usize = 0x3c / 4;
/// The literal the client pushes when the player object does not resolve (`0x846a40`). Also the
/// value nine of the ten class rows carry, which is why an unloaded table degrades invisibly.
/// The literal the reference pushes when the player does not resolve (`0x846a40`). Every class but
/// the Warlock carries it too, so an unloaded table degrades invisibly.
pub const PET_NAME_TOKEN_FALLBACK: &str = "PET";
/// One class row, narrowed to the columns anything reads.
#[derive(Debug, Clone)]
struct ChrClass {
pet_name_token: Option<String>,
@@ -112,14 +50,13 @@ struct ChrClass {
spell_family: u32,
}
/// `ChrClasses.dbc` → class id ⇒ the columns we read.
/// `ChrClasses.dbc`'s read columns by class id.
#[derive(Debug, Default, Clone)]
pub struct ChrClasses(HashMap<u32, ChrClass>);
impl ChrClasses {
/// The pet name token for a class id, or the client's own [`PET_NAME_TOKEN_FALLBACK`] for a
/// class with no row. Mirrors `0x4b44a6`: the reference never answers nil here, only ever a
/// string.
/// `HasPetSpells`' second return: always a string, never nil, and the literal
/// [`PET_NAME_TOKEN_FALLBACK`] for a class with no row (`0x4b44a6`).
pub fn pet_name_token(&self, class: u32) -> &str {
self.0
.get(&class)
@@ -127,22 +64,16 @@ impl ChrClasses {
.unwrap_or(PET_NAME_TOKEN_FALLBACK)
}
/// Whether a class id's INVSLOT 17 is a relic slot — `UnitHasRelicSlot`'s whole body.
///
/// A class with no row answers `false`, which is the reference's own shape: its bound check
/// against `0xc0def8` falls to the nil leg, and an unloaded table reads every class as an
/// ordinary ranged wielder rather than inventing a relic slot for one.
/// Whether the class's INVSLOT 17 is a relic slot, `UnitHasRelicSlot` whole. A class with no
/// row answers false, as the reference's bound check against `0xc0def8` falls to its nil leg.
pub fn has_relic_slot(&self, class: u32) -> bool {
self.0.get(&class).is_some_and(|c| c.has_relic_slot)
}
/// The class's spell family — the value the reference caches in `[0xcecaac]` and compares
/// every spell's `SpellFamilyName` against before any talent modifier may apply.
///
/// A class with no row answers `0`, which is the reference's own degraded state: `0x6e7150`
/// zeroes the global at world-enter (`6e7317`, `esi = 0`) and it stays 0 until the local
/// player resolves — and `GetSpellModifiers`' first conjunct (`SpellFamilyName != 0`) exists
/// precisely so nothing matches during that window.
/// The class spell family, which the reference caches in `[0xcecaac]` and matches against a
/// spell's `SpellFamilyName` before a talent modifier applies. A class with no row answers 0,
/// the global's value from world-enter (`0x6e7150`) until the player resolves, which the
/// gate's `SpellFamilyName != 0` conjunct makes match nothing.
pub fn spell_family(&self, class: u32) -> u32 {
self.0.get(&class).map_or(0, |c| c.spell_family)
}
@@ -155,8 +86,8 @@ impl ChrClasses {
fn schema() -> Schema {
let mut s = Schema::new("ChrClasses");
for i in 0..CHR_CLASSES_FIELDS {
// Only the two read columns are typed; everything else stays an opaque dword. The class
// NAME block (field 5 up) is deliberately not decoded — nothing here displays it.
// Only the token is a string; the rest, the class name block from field 5 included, stay
// opaque dwords.
let ty = if i == PET_NAME_TOKEN_FIELD {
FieldType::String
} else {
@@ -197,9 +128,7 @@ mod tests {
Some(crate::open_chain(&data).expect("open chain"))
}
/// The real 5875 table, byte-anchored on the one column that matters. The neighbouring field
/// holds the class NAME, so a one-column slip reads "Warlock"/"Hunter" — which would still
/// resolve a `PET_TYPE_*` global lookup to nil and blank the tab, i.e. fail silently.
/// Field 5 holds the class name, so a one-column slip would blank the spellbook tab silently.
#[test]
fn warlocks_pet_is_a_demon_and_everyone_elses_is_a_pet() {
let Some(mut chain) = chain() else { return };
@@ -209,15 +138,11 @@ mod tests {
for (class, who) in [(1, "Warrior"), (3, "Hunter"), (11, "Druid")] {
assert_eq!(t.pet_name_token(class), "PET", "{who}");
}
// 6 and 10 have no row in 1.12 (Death Knight / Monk arrive later); the client's own
// out-of-range arm answers the literal, and so does this.
// 6 and 10 have no row in 1.12; the reference's out-of-range arm answers the literal.
assert_eq!(t.pet_name_token(6), PET_NAME_TOKEN_FALLBACK);
assert_eq!(t.pet_name_token(0), PET_NAME_TOKEN_FALLBACK);
}
/// Field 16, anchored against the shipped file the same way — and against the belief it
/// replaces. Three classes, and exactly three: this asserts the whole column, because a
/// half-right answer here (say, Paladin alone) is the shape the old claim would decay into.
#[test]
fn libram_totem_and_idol_are_the_three_relic_classes() {
let Some(mut chain) = chain() else { return };
@@ -235,15 +160,13 @@ mod tests {
] {
assert!(!t.has_relic_slot(class), "{who} wields a ranged weapon");
}
// No row, and no invented slot — the reference's nil leg.
// No row: the reference's nil leg.
assert!(!t.has_relic_slot(6));
assert!(!t.has_relic_slot(0));
}
/// Field 15, the spell-family gate's right-hand side. The whole column is asserted because
/// that is what makes it a column pin rather than a coincidence: nine distinct values in nine
/// rows, each matching the vmangos `SpellFamilyNames` enum, is a shape no neighbour has
/// (field 14 is 8323199 on every row, field 16 is the three-row relic flag).
/// The whole column pins field 15: nine distinct values matching vmangos `SpellFamilyNames`
/// are a shape no neighbour has (field 14 is the class's file name, field 16 the relic flag).
#[test]
fn every_class_row_carries_its_vmangos_spell_family() {
let Some(mut chain) = chain() else { return };
@@ -261,7 +184,7 @@ mod tests {
] {
assert_eq!(t.spell_family(class), family, "{who}");
}
// No row: the reference's zeroed global, which conjunct 1 of the gate refuses.
// No row: the reference's zeroed global, which the gate's first conjunct refuses.
assert_eq!(t.spell_family(6), 0);
assert_eq!(t.spell_family(0), 0);
}
+56 -202
View File
@@ -1,77 +1,18 @@
//! `CinematicSequences.dbc` + `CinematicCamera.dbc` — the in-engine cinematic fly-bys, and the
//! `CinematicSequences.dbc` and `CinematicCamera.dbc`: the in-engine cinematic fly-bys and the
//! world-space camera path each one resolves to.
//!
//! A cinematic in 1.12 is **not** a movie file. `SMSG_TRIGGER_CINEMATIC` carries a
//! `CinematicSequences.dbc` id; the row names up to eight `CinematicCamera.dbc` cameras; each of
//! those names a `Cameras\*.m2` whose single [`M2Camera`](benilla_m2::M2Camera) record *is* the
//! shot — an eye path, a look-at path and a roll, keyed over a timeline in the model's own local
//! frame — plus the world origin and facing to plant that frame at. The client flies its view
//! along it and the world renders normally underneath. (The pre-rendered `.avi` under
//! `Data\<locale>\Interface\Cinematics` is the *other* thing, the opening movie; it is not this.)
//! A cinematic is not a movie. `SMSG_TRIGGER_CINEMATIC` carries a sequence id, the sequence names
//! up to eight camera rows, and each row names a `Cameras\*.m2` whose one
//! [`M2Camera`](benilla_m2::M2Camera) record is the shot: eye, look-at and roll tracks in the
//! model's local frame, planted at the row's world origin and facing. The world renders normally
//! underneath. A shot ranges far from its origin (the Tauren intro starts 1741 yd out), and the
//! server re-anchors object visibility to the flying camera while one runs (vmangos
//! `Player::UpdateCinematic`), so the world streams from the camera, not the avatar.
//!
//! **The shipped tables, dumped 2026-08-29 (VERIFIED — the bytes, not a wiki):**
//! `CinematicSequences.dbc` is `10 × 10 fields × 40 B`: `ID · soundId · camera[8]`. Every shipped
//! row uses exactly **one** camera and authors `soundId = 0`. `CinematicCamera.dbc` is
//! `10 × 7 × 28 B`: `ID · model (string) · soundId · originX · originY · originZ · originFacing`.
//! The model column ships `.mdx`; the archive file is `.m2` ([`camera_model_path`]). The ten rows
//! are the eight race intros plus `PalantirOfAzora` and `Scry_cam`. Facing is **radians** — the
//! shipped values include `3.14159` and `4.71239` (π and 3π/2) to five decimals, which no degree
//! table would.
//!
//! **The world transform** — `world = origin + Rz(+facing)·local`, `z` straight through — is the
//! one non-obvious step, and it is now settled twice over.
//!
//! It was first checked against an independent oracle rather than assumed: vmangos walks its own
//! server-side copy of these paths (`Player::UpdateCinematic`) off a hand-built
//! `cinematic_waypoints` table of sampled world positions, and our evaluated path lands on those
//! samples (tens of yards over ~600-yard offsets, on a table whose z column is ground-level, not
//! camera-level). The three sign/axis alternatives miss by 700–1800 yards. See
//! [`CinematicPath::sample`].
//!
//! The binary agrees, and an emulated run of its own bytes confirms it. The client applies affines
//! as a **row vector on the left** (`out = in·M`, `0x7bca80`), and the stored 3×3 (`0x50c870`) is
//! `[[cos,sin,0],[−sin,cos,0],[0,0,1]]`. Read as a diagram acting on a *column* vector that is
//! `Rz(−facing)` — which is what a matrix picture invites. The client never applies it that way.
//! The bytes and the oracle never actually disagreed.
//!
//! **The shipped corpus, read out of the ten `Cameras\*.m2` files** (`fov` radians, `d₀` = how far
//! the shot's first eye position sits from its own origin, horizontally):
//!
//! | cam | model | fov | length | pos keys | roll | d₀ |
//! |---|---|---|---|---|---|---|
//! | 1 | PalantirOfAzora | 0.7854 | 14.9 s | 10 | const 0 | 205 yd |
//! | 2 | FlybyUndead | **1.5708** | 102.0 s | 22 | 18 keys | 523 yd |
//! | 122 | FlybyNightElf | 0.7854 | 102.0 s | 32 | 19 keys | 813 yd |
//! | 142 | FlyByHuman | 0.7854 | 87.5 s | 20 | const 2π | 663 yd |
//! | 162 | FlyByGnome | 0.7854 | 76.7 s | 27 | const 0 | 705 yd |
//! | 182 | FlyByTroll | 0.7854 | 57.8 s | 21 | 12 keys | 39 yd |
//! | 202 | FlyByTauren | 0.7854 | 70.3 s | 19 | 7 keys | 1741 yd |
//! | 224 | Scry_cam | 0.7854 | 3.3 s | 1 | const 3π | 0 yd |
//! | 234 | FlyByDwarf | 0.7854 | 59.6 s | 28 | const 2π | 652 yd |
//! | 235 | FlybyOrc | 0.7854 | 70.2 s | 22 | 14 keys | 1100 yd |
//!
//! Three things there are load-bearing and none of them are guessable. **The Undead intro's FOV is
//! 90°, not 45°** — a client that hard-coded the other nine's value would render that one shot at
//! half the intended width. **Roll is authored around multiples of 2π rather than around 0**
//! (`FlyByDwarf` holds a constant `6.2832`; `Scry_cam` holds `3π`), so it is an *angle to apply*,
//! and a "roll is zero, skip it" shortcut happens to be right only by the identity `2π ≡ 0`; five
//! of the ten genuinely animate it. And the shots **start far from their own origin** — a Tauren
//! 1741 yards out — which is why the server re-anchors object visibility to the flying camera
//! while one runs (decision 0196), and why the client has to stream the world from the *camera*
//! and not the avatar for the duration.
//!
//! **The optics in this table are data, not the shot's framing** (decision 1711). A 24-site census
//! settles it: the M2 camera
//! record's `fov`, `nearClip` and `farClip` are written at model load and read only by `0x7ac640`,
//! which is reachable solely from the portrait and `<Model>` frame paths. **On the cinematic path
//! nothing reads any of the three.** A fly-by is rendered through the *world camera's own* optics,
//! re-stamped every frame.
//!
//! That dissolves the puzzle this note used to record: the authored clips are `0.22222` / `27.7778`
//! (8/36 and 1000/36, identical on every shipped shot), and a 27.8-yard far plane would render the
//! dwarf intro's mountains as empty sky. They are not world clips because they are not clips at
//! all here — which is what two floats nobody consumes look like. They stay parsed because they
//! are genuinely in the record and the portrait path does read them.
//! The plant is `world = origin + Rz(+facing)·local`, z unchanged. The reference applies affines
//! to a row vector on the left (`out = in·M`, `0x7bca80`), so its stored 3×3
//! `[[cos,sin,0],[−sin,cos,0],[0,0,1]]` (`0x50c870`) is `Rz(+facing)`, though it reads as
//! `Rz(−facing)` on a column vector.
use std::collections::HashMap;
@@ -88,37 +29,32 @@ const CAMERAS: &str = "DBFilesClient\\CinematicCamera.dbc";
/// How many camera slots a `CinematicSequences.dbc` row carries (fields 2..=9).
pub const SEQUENCE_CAMERAS: usize = 8;
/// One `CinematicSequences.dbc` row — what a `SMSG_TRIGGER_CINEMATIC` id resolves to.
/// One `CinematicSequences.dbc` row, what a `SMSG_TRIGGER_CINEMATIC` id resolves to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CinematicSequence {
pub id: u32,
/// `soundId` (field 1). **Zero on every shipped row** — the sound a fly-by plays comes from
/// its *camera* row instead ([`CinematicCameraRow::sound_id`]).
/// `soundId` (field 1): 0 on every shipped row; a fly-by's sound is its camera row's.
pub sound_id: u32,
/// The camera ids to play, in order, trailing zeros dropped. Every shipped row holds one.
pub cameras: Vec<u32>,
}
/// One `CinematicCamera.dbc` row — a shot: which path model, where to plant it, and the sound.
/// One `CinematicCamera.dbc` row, a shot: its path model, where to plant it, and its sound.
#[derive(Debug, Clone, PartialEq)]
pub struct CinematicCameraRow {
pub id: u32,
/// The path model as the table ships it (`Cameras\FlyByDwarf.mdx`). Use
/// [`camera_model_path`] for the archive path.
/// The path model as the table ships it (`.mdx`); [`camera_model_path`] gives the archive path.
pub model: String,
/// `SoundEntries.dbc` id for the shot's audio, `0` for none (`PalantirOfAzora` and `Scry_cam`
/// are the two silent rows).
/// `SoundEntries.dbc` id for the shot's audio, 0 for none.
pub sound_id: u32,
/// Where the path's local frame is planted, raw WoW world coordinates.
pub origin: [f32; 3],
/// The local frame's yaw about `+Z`, **radians**.
/// The local frame's yaw about `+Z`, in radians.
pub origin_facing: f32,
}
/// Both cinematic tables, keyed by row id.
///
/// `Default` is the **empty** catalog — what "the DBCs failed to load" already means to a caller
/// (every lookup misses, and a trigger it cannot resolve is a trigger it skips).
/// Both cinematic tables, keyed by row id. `Default` is the empty catalog: every lookup misses,
/// so a trigger it cannot resolve is skipped.
#[derive(Default)]
pub struct CinematicCatalog {
sequences: HashMap<u32, CinematicSequence>,
@@ -154,8 +90,7 @@ impl CinematicCatalog {
}
}
/// The archive path for a camera row's model: the table's `.mdx` reference mapped to the `.m2` the
/// MPQ actually holds (the same normalisation every other model reference in the client takes).
/// The archive path for a camera row's model: the table ships `.mdx`, the MPQ holds `.m2`.
pub fn camera_model_path(model: &str) -> String {
let stem = model.rsplit_once('.').map_or(model, |(stem, ext)| {
match ext.to_ascii_lowercase().as_str() {
@@ -201,8 +136,7 @@ pub fn load_cinematics(chain: &mut Chain) -> Result<CinematicCatalog> {
let mut sequences = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
// Trailing zeros are "no camera", not camera 0 — the shipped rows are one camera then
// seven zeros. Stop at the first, so a hypothetical gap can't smuggle a zero in.
// A zero slot is "no camera", not camera 0; the list stops at the first one.
let cameras = (0..SEQUENCE_CAMERAS)
.map_while(|i| u32_at(r, 2 + i).filter(|&c| c != 0))
.collect();
@@ -242,13 +176,13 @@ pub fn load_cinematics(chain: &mut Chain) -> Result<CinematicCatalog> {
Ok(CinematicCatalog { sequences, cameras })
}
/// One instant of a cinematic: where the view is, what it looks at, and how it is banked — all in
/// **raw WoW world coordinates** (X north, Y west, Z up), the convention this crate keeps.
/// One instant of a cinematic: eye, look-at target and roll, in raw WoW world coordinates.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CinematicView {
pub eye: [f32; 3],
pub target: [f32; 3],
/// Roll about the view axis, radians. The authored sign, unconverted.
/// Roll about the view axis in radians, the authored sign unconverted: an angle to apply, never
/// tested for 0, as shots hold values such as 2π (`FlyByDwarf`) and 3π (`Scry_cam`).
pub roll: f32,
}
@@ -256,35 +190,19 @@ pub struct CinematicView {
pub struct CinematicPath {
/// The camera row this was built from.
pub camera_id: u32,
/// The row's `SoundEntries.dbc` narration id, `0` for a silent shot — carried here so a
/// consumer that has the shot has everything the shot plays.
/// The row's `SoundEntries.dbc` narration id, 0 for a silent shot.
pub sound_id: u32,
/// The authored field of view, radians — `0.7854` (45°) on fifteen of the sixteen shipped
/// shots and `1.5708` (90°) on the Undead intro.
///
/// **The reference reads this from nothing on the cinematic path** (a 24-site census: the M2
/// camera's fov and the two clips are written at model load and read only by `0x7ac640`, which
/// is reachable
/// solely from the portrait and `<Model>` frame paths). A fly-by is rendered through the
/// **world camera's own** optics, re-stamped every frame. So this is a real field of the
/// record, and it is not what a fly-by is framed with — see decision 1711 for what benilla did
/// with it before that was known.
/// The authored field of view in radians: 45° on every shipped shot but the Undead intro's 90°.
/// Not the fly-by's framing: the reference reads the record's optics only in `0x7ac640`, on the
/// portrait and `<Model>` paths, and flies through the world camera's own, set every frame.
pub fov: f32,
/// The authored near clip, radians-free yards — read by nothing on this path, like [`Self::fov`].
///
/// It is carried because it is *there*, and because its value is the tell: every shipped shot
/// authors `8/36` or `1000/36` — `0.2222` and `27.7778` yards. As world clips those are
/// nonsense (a 27.8-yard far plane renders the dwarf intro's mountains as empty sky), which is
/// exactly what you would expect of two floats nothing consumes.
/// The authored near clip (8/36 on every shipped shot), unread on this path like [`Self::fov`].
pub near_clip: f32,
/// The authored far clip — see [`Self::near_clip`].
/// The authored far clip (1000/36 on every shipped shot), unread on this path.
pub far_clip: f32,
/// How long the shot runs, milliseconds — the *width* of the sequence band the tracks are
/// keyed inside (`end − start`), not its end. See [`Self::sample`] for why the two differ.
/// How long the shot runs in ms: the width of its sequence band (`end − start`), not its end.
pub duration_ms: u32,
/// Where that band begins on the model's global timeline; added to the sample time so a shot
/// whose first key is not at zero (`FlybyNightElf`, `Scry_cam`) starts on its first key
/// instead of holding it.
/// The band's start on the model's global timeline, added to every sample time.
band_start: u32,
origin: [f32; 3],
facing_sin_cos: (f32, f32),
@@ -292,8 +210,7 @@ pub struct CinematicPath {
}
impl CinematicPath {
/// Build a shot from its camera row: read the `.m2` off the chain, take its camera record, and
/// plant it at the row's origin/facing.
/// Build a shot from its camera row: the `.m2`'s camera record, planted at the row's origin.
pub fn load(chain: &mut Chain, row: &CinematicCameraRow) -> Result<Self> {
let path = camera_model_path(&row.model);
let bytes = chain
@@ -304,22 +221,14 @@ impl CinematicPath {
/// The same, from bytes already in hand (the test/tooling seam).
pub fn from_m2_bytes(bytes: &[u8], row: &CinematicCameraRow) -> Result<Self> {
// The camera array alone, not a whole model parse: a `Cameras\*.m2` carries no geometry to
// parse and the shot is entirely in this one record.
// The camera array alone: a `Cameras\*.m2` has no geometry, and the shot is this record.
let camera = benilla_m2::parse_cameras(bytes)
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("model carries no camera record"))?;
// The shot's length is the model's own sequence band (stride 0x44 records at header
// 0x1c/0x20, band `[start, end]` at +0x04/+0x08 — the same walk the emitter-timing bake
// uses). The reference arms this as an ordinary M2 animation on sequence 0, so the shot
// runs the band: it *plays* for `end − start` and its tracks are keyed at absolute
// global-timeline stamps inside `[start, end]`. Eight of the ten shipped cameras band at
// `[0, end]` and the distinction is invisible on them — but `FlybyNightElf` bands at
// `[333, 102333]` and `Scry_cam` at `[33, 3333]`, and reading `end` as the duration
// played those two 333 ms / 33 ms too long with the opening frames frozen on the first
// key (which is what sampling `t = 0` against a track that starts at 333 returns).
// The fallback, for a file that authors no sequence at all, is the last key.
// The reference plays the shot as an ordinary M2 animation on sequence 0: it runs for
// `end − start`, its keys stamped on the global timeline inside `[start, end]`. A file
// with no sequence falls back to its last key.
let (band_start, band_end) = sequence_band(bytes)
.or_else(|| {
[
@@ -347,28 +256,8 @@ impl CinematicPath {
})
}
/// Sample the shot at `ms` from its start, **end-clamped** at both ends.
///
/// `ms` is measured from the *shot's* start; the tracks are keyed on the model's global
/// timeline, so it is offset by [`Self::band_start`] before it reaches them. On eight of the
/// ten shipped cameras that offset is zero and the two are the same number; on
/// `FlybyNightElf` (band `[333, 102333]`) and `Scry_cam` (`[33, 3333]`) it is the difference
/// between opening on the authored first key and holding it for a third of a second first.
///
/// Two steps, in this order. The reference's publish pass composes each track against its base
/// (`eye = position_base + positions(t)`, `target = target_position_base + target(t)`); then
/// the local frame is planted in the world by the camera row's origin and facing:
///
/// ```text
/// world.x = origin.x + local.x·cos(facing) − local.y·sin(facing)
/// world.y = origin.y + local.x·sin(facing) + local.y·cos(facing)
/// world.z = origin.z + local.z
/// ```
///
/// i.e. a plain yaw about `+Z` by the row's facing, then a translation — no scale, no
/// handedness flip. Checked against vmangos's independently sampled `cinematic_waypoints` for
/// the dwarf (41) and human (81) intros; the sign-flipped and axis-swapped alternatives are
/// off by an order of magnitude (module doc).
/// Sample the shot `ms` after its start, clamped at both ends: each track composed against its
/// base, then planted in the world by the row's origin and facing, with no scale or flip.
pub fn sample(&self, ms: u32) -> CinematicView {
let ms = self.band_start.saturating_add(ms);
let eye = self.to_world(sample_against(
@@ -398,22 +287,15 @@ impl CinematicPath {
}
}
/// A camera track sampled and composed against its base — the reference's publish form.
/// A camera track sampled and composed against its base, the reference's publish form.
fn sample_against(track: &M2Track<M2SplineKey<[f32; 3]>>, base: [f32; 3], ms: u32) -> [f32; 3] {
let d = track.sample_ms(ms).unwrap_or([0.0; 3]);
std::array::from_fn(|i| base[i] + d[i])
}
/// The model's **first** sequence band, `(start, end)` in global-timeline milliseconds (header
/// `0x1c` count / `0x20` offset, entry stride `0x44`, band at `+0x04`/`+0x08`). `None` for a model
/// with no sequences.
///
/// **`start` is not always zero, and the shipped corpus is its own proof of these offsets.** Eight
/// of the ten cameras band at `[0, end]`, but `FlybyNightElf` bands at `[333, 102333]` and
/// `Scry_cam` at `[33, 3333]` — and in both files the band's `start` is *exactly* the first
/// timestamp on the position and target tracks, while `end` is exactly the last. Two fields that
/// land on the first and last key of every file in the corpus are the first and last key, which
/// is why this is read rather than assumed to be a duration.
/// The model's first sequence band, `(start, end)` in global-timeline ms: count at header `0x1c`,
/// offset at `0x20`, entries `0x44` apart, band at `+0x04`/`+0x08`. `start` is not always 0
/// (`FlybyNightElf` bands at `[333, 102333]`).
fn sequence_band(b: &[u8]) -> Option<(u32, u32)> {
let le_u32 = |o: usize| -> Option<u32> {
b.get(o..o + 4)
@@ -430,9 +312,8 @@ fn sequence_band(b: &[u8]) -> Option<(u32, u32)> {
mod tests {
use super::*;
/// The eight race intros, by `ChrRaces.dbc` `CinematicSequence` (field 16) — the ids a first
/// login actually sends (VERIFIED in the shipped `ChrRaces.dbc`, and matching vmangos's
/// `SendCinematicStart(rEntry->CinematicSequence)`).
/// The eight race intros, `ChrRaces.dbc` `CinematicSequence` (field 16), which vmangos sends
/// on a first login (`CharacterHandler.cpp:581`).
const RACE_INTROS: [u32; 8] = [2, 21, 41, 61, 81, 101, 121, 141];
#[test]
@@ -443,8 +324,7 @@ mod tests {
);
assert_eq!(camera_model_path("Cameras\\X.MDX"), "Cameras\\X.m2");
assert_eq!(camera_model_path("Cameras\\X.m2"), "Cameras\\X.m2");
// A path with no extension at all still names an .m2, and a dot inside a directory name
// is not an extension.
// A path with no extension still names an `.m2`.
assert_eq!(camera_model_path("Cameras\\X"), "Cameras\\X.m2");
}
@@ -456,14 +336,13 @@ mod tests {
assert_eq!(cat.sequence_count(), 10);
assert_eq!(cat.camera_count(), 10);
// Every shipped sequence names exactly one camera and carries no sound of its own.
for id in RACE_INTROS {
let seq = cat.sequence(id).unwrap_or_else(|| panic!("sequence {id}"));
assert_eq!(seq.cameras.len(), 1, "sequence {id} camera count");
assert_eq!(seq.sound_id, 0, "sequence {id} sound");
}
// The dwarf intro, end to end — the row decision 0196 captured live.
// The dwarf intro's shipped row, end to end.
let dwarf = cat.shots(41);
assert_eq!(dwarf.len(), 1);
let cam = dwarf[0];
@@ -492,18 +371,10 @@ mod tests {
path.camera.positions.keys.len() >= 10,
"sequence {id} is richly keyed"
);
// Cubic Bézier on both vector tracks — the case the four-way interp dispatch exists
// for, and the one a step/linear-only sampler would silently mangle.
// Interp 2 is cubic Bézier, on both vector tracks.
assert_eq!(path.camera.positions.interp, 2, "sequence {id} position");
assert_eq!(path.camera.target.interp, 2, "sequence {id} target");
// **The end condition, and the proof of the band offsets.** The authored sequence
// band brackets both vector tracks exactly: its `start` is the first key's timestamp
// and its `end` is the last one's, on every shipped fly-by. Two header fields that
// land on the first and last key of all eight files are the first and last key — and
// that is what licenses reading the playback length as `end − start` rather than as
// `end`, which is the bug this assertion now pins (`FlybyNightElf` bands at
// `[333, 102333]`, so the two readings differ by a third of a second and by whether
// the shot opens on its first key or holds it).
// The band brackets both tracks exactly: `start` is the first key, `end` the last.
for (what, track) in [
("position", &path.camera.positions),
("target", &path.camera.target),
@@ -519,12 +390,7 @@ mod tests {
"sequence {id} {what} track ends on the band"
);
}
// The clips are uniform across the corpus; the FOV is NOT — the Undead intro is 90°
// where the rest are 45°. Kept as a **data** assertion, and no longer as a reason to
// read it per shot: decision 1711 established that the reference's cinematic path
// reads none of these three, so the split is a fact about the files and not about how
// any shot is framed. It stays because a parser that silently started returning zeros
// for all three would otherwise pass every other test in this file.
// Data only: uniform clips, and a 90° fov on the Undead intro against 45° elsewhere.
assert!((path.near_clip - 8.0 / 36.0).abs() < 1e-6);
assert!((path.far_clip - 1000.0 / 36.0).abs() < 1e-4);
let want_fov = if id == 2 {
@@ -540,14 +406,8 @@ mod tests {
}
}
/// The dwarf intro's world path, pinned at three instants.
///
/// These numbers are the **transform's** golden: they were derived independently (a
/// hand-written evaluator over the raw bytes) and then cross-checked, time-independently,
/// against vmangos's `cinematic_waypoints` samples for this cinematic — our arc passes within
/// 59.6 yd horizontally of every one of the server's six samples (mean 35.5). The three
/// alternative conventions do far worse on the same measure: `Rz(−facing)` 436.8 yd,
/// axis-swapped 334.0, no rotation at all 859.9. So a sign flip here fails this test loudly.
/// The golden points come from a separate evaluator over the raw bytes; the arc passes within
/// 59.6 yd of all six of vmangos's `cinematic_waypoints` samples for this intro.
#[test]
fn real_dwarf_intro_flies_the_authored_arc() {
let data = crate::wow_data_or_skip!();
@@ -575,11 +435,9 @@ mod tests {
near(mid.target, [-5715.481, -427.434, 450.394], "mid target");
let end = path.sample(path.duration_ms);
near(end.eye, [-6246.921, 333.773, 384.187], "end eye");
// Past the end the path holds its last key — it does not wrap to the start.
// Past the end the path holds its last key; it does not wrap.
assert_eq!(path.sample(u32::MAX).eye, end.eye);
// Roll here is a single key holding **2π**, not 0 — the authored angles sit around whole
// turns (`Scry_cam` holds 3π), so roll is applied as an angle and only *happens* to be an
// identity on this shot.
// One roll key, holding 2π rather than 0.
assert!(
(start.roll - std::f32::consts::TAU).abs() < 1e-4,
"{}",
@@ -588,9 +446,6 @@ mod tests {
assert_eq!(mid.roll, start.roll, "a single-key roll track is constant");
}
/// The shots range far from their own origin — the reason the server re-anchors object
/// visibility to the flying camera while a cinematic runs (decision 0196), and the reason
/// benilla has to stream the world from the camera rather than the avatar for the duration.
#[test]
fn real_flyby_shots_range_far_from_their_origin() {
let data = crate::wow_data_or_skip!();
@@ -599,8 +454,7 @@ mod tests {
for id in RACE_INTROS {
let row = cat.shots(id)[0].clone();
let path = CinematicPath::load(&mut chain, &row).expect("path");
// The furthest the eye gets from the body's neighbourhood over the whole shot. The
// troll intro *starts* only 39 yd out, so this is the reach, not the first frame.
// The reach over the whole shot, not the first frame: the troll intro starts 39 yd out.
let reach = (0..=path.duration_ms)
.step_by(500)
.map(|ms| {
+47 -191
View File
@@ -1,68 +1,12 @@
//! `CreatureFamily.dbc` + `ItemPetFood.dbc` — the pet's **family word** and the **diet** it implies
//! (decision 1062): `UnitCreatureFamily`'s "Imp"/"Wolf"/"Cat", and `GetPetFoodTypes`'s
//! "Meat, Fish".
//! `CreatureFamily.dbc` and `ItemPetFood.dbc`: a pet's family word (`UnitCreatureFamily`,
//! `0x51a310`) and the diet its food mask names (`GetPetFoodTypes`, `0x4bea10`).
//!
//! Both layouts below were **dumped from this install's own shipped files** rather than taken from
//! memory or a wiki — the WDBC header first, then every column of every row, then a census of what
//! each column actually holds. What that dump says:
//! `CreatureFamily.dbc` is 18 fields in 0x48-byte records, 23 rows with ids 1..28 (10, 13, 14, 18
//! and 22 absent). `ItemPetFood.dbc` is 10 fields, 8 rows. Mask bit `b` names food row `b + 1`,
//! low bit first, which is the client's record order (vmangos `Objects/Pet.cpp:1503-1504`).
//!
//! **`CreatureFamily.dbc` — 18 fields, `record_size` 0x48, 23 rows, string block 933 bytes.**
//!
//! | field | byte | holds |
//! |---|---|---|
//! | 0 | 0x00 | `ID` — 1..28 with gaps (10, 13, 14, 18, 22 are absent) |
//! | 1 | 0x04 | `minScale`, a **float** (0.4 … 1.0) |
//! | 2 | 0x08 | `minScaleLevel` — **1 on all 22 real families**, warlock minions included; 0 only on row 28 |
//! | 3 | 0x0c | `maxScale`, a **float** (0.6 … 1.1) |
//! | 4 | 0x10 | `maxScaleLevel` — **60 on all 22**; 0 only on row 28 |
//! | 5 | 0x14 | `skillLine[0]` — a `SkillLine.dbc` id (188…758), distinct on every row |
//! | 6 | 0x18 | `skillLine[1]` — 270 on every tameable family, 0 on the rest |
//! | 7 | 0x1c | **`petFoodMask`** — the bitfield this module's other half indexes |
//! | 8..15 | 0x20 | the localized `Name` block; **enUS is field 8**, the other seven are all zero |
//! | 16 | 0x40 | the name block's locale-present flags (0x7EFFFE or 0x3F007E) — **not** zero |
//! | 17 | 0x44 | `iconFile` — `Interface\Icons\Ability_Hunter_Pet_*` (a string, not a dword) |
//!
//! (The 2026-08-06 dump's note that the level columns are "0 for the warlock ones" was wrong, and
//! is corrected above off the shipped file: Imp/Voidwalker/Succubus/Felhunter/Doomguard all read
//! 1 → 60 like every hunter family, and simply carry `minScale == maxScale` so their ramp is flat.
//! Only "Remote Control" (row 28) is 0/0/0/0. It matters because those four columns are the
//! character-select pet's SIZE — decision 1538.)
//!
//! That confirms the `0x48` record and the 23 rows [`crate::pet_stats`]'s header already recorded,
//! and **corrects its "every dword from `0x28` up is zero"**: fields 10..15 (`0x28`–`0x3c`) are
//! indeed all zero — which is what that argument actually rested on, since the reads it was ruling
//! out were at `+0x28`/`+0x30`/`+0x3c`/`+0x48` — but `0x40` and `0x44` are not. The conclusion
//! stands (a `rec+0x48` read still runs off the end of a 0x48-byte record); the sentence was one
//! column too wide.
//!
//! **`ItemPetFood.dbc` — 10 fields, `record_size` 0x28, 8 rows, string block 55 bytes**: `ID`@0,
//! the localized `Name` block@1..8 (**enUS is field 1**, the rest zero), name flags@9. The eight
//! rows in id order are exactly `Meat / Fish / Cheese / Bread / Fungus / Fruit / Raw Meat /
//! Raw Fish`.
//!
//! **The mask's bit → row mapping is `bit b` → `ItemPetFood` row `b + 1`** (see
//! [`PetFoodNames::for_mask`]) — DATA-derived twice over. The highest bit any shipped row sets is
//! **7** (Turtle, mask 178) and the table has exactly 8 rows, so a 0-based mapping would leave row
//! 8 permanently unreachable and Wolf (mask 1) with an empty diet. And vmangos, reading the same
//! file, tests exactly `1 << (item->FoodType - 1)` against this column (`Objects/Pet.cpp:1503-1504`,
//! `Database/DBCStructure.h:251` — its `CreatureFamilyfmt` `"nfifiiiissssssssxx"` is this table's
//! layout above, field for field). Every resulting diet matches vanilla's documented ones: Wolf =
//! Meat, Cat = Meat+Fish, Bear/Boar = all six, Gorilla = Fungus+Fruit, Wind Serpent = Fish+Cheese+
//! Bread.
//!
//! **All of the above holds in the binary too** (2026-08-06 — `GetPetFoodTypes 0x4bea10`
//! and `UnitCreatureFamily 0x51a310`), and the two dumps agree line for line with nothing left
//! over: the mask is column 7, the bit map is `1 << (recordID - 1)`, the family name is column
//! `8 + locale`, the food name is column `1 + locale`, the id space really does have null rows at
//! 10/13/14/18/22, and the binary's own positive controls (Wolf → Meat; Bear and Boar → all six;
//! Turtle → Fish/Fungus/Fruit/Raw Fish) are the rows asserted below. The one claim that had been
//! flagged INFERRED here — that the walk is **low bit first** — is settled the same way: the client
//! pushes in **record order**, which is what ascending bits produce.
//!
//! What this module deliberately does **not** model is `GetPetFoodTypes`' own `0x6116e0` gate
//! (owner-is-me + the local player is a Hunter). That is a live-state question, not a table one, so
//! it lives at the feed (`benilla_app::ui_pet_stats`) — and it is why a charmed boar under a priest
//! answers an empty diet despite a mask of 63.
//! `GetPetFoodTypes`' gate (the pet is ours and we are a Hunter, `0x6116e0`) is live state, applied
//! at the feed (`benilla_app::ui_pet_stats`): a charmed boar under a priest has no diet.
use std::collections::HashMap;
@@ -75,57 +19,35 @@ use crate::dbc::{f32_at, parse, str_at, u32_at};
const CREATURE_FAMILY: &str = "DBFilesClient\\CreatureFamily.dbc";
const ITEM_PET_FOOD: &str = "DBFilesClient\\ItemPetFood.dbc";
/// `CreatureFamily.dbc`'s column count (the DBC header's `field_count`; `benilla-dbc` enforces it,
/// so a wrong number fails loudly instead of misaligning every column).
/// `CreatureFamily.dbc`'s field count, which `benilla-dbc` checks against the header.
const FAMILY_FIELDS: usize = 18;
/// `ItemPetFood.dbc`'s column count.
const FOOD_FIELDS: usize = 10;
/// `CreatureFamily.dbc`'s level → size ramp — bytes `0x04`/`0x08`/`0x0c`/`0x10`, i.e. fields 1–4.
/// (Written as indices, not the `byte / 4` the columns below use: `0x04 / 4` is `4 / 4`, which
/// clippy's `eq_op` reads as a mistake — and it is not worth an allow to keep one idiom.)
const FAMILY_MIN_SCALE_FIELD: usize = 1;
const FAMILY_MIN_SCALE_LEVEL_FIELD: usize = 2;
const FAMILY_MAX_SCALE_FIELD: usize = 3;
const FAMILY_MAX_SCALE_LEVEL_FIELD: usize = 4;
/// `CreatureFamily.dbc`'s pet-food mask — byte `0x1c` = field 7.
const FAMILY_FOOD_MASK_FIELD: usize = 0x1c / 4;
/// `CreatureFamily.dbc`'s enUS `Name` — byte `0x20` = field 8.
/// The enUS name, the localized block's first slot.
const FAMILY_NAME_FIELD: usize = 0x20 / 4;
/// `CreatureFamily.dbc`'s `iconFile` — byte `0x44` = field 17. The last column, past the name
/// block's locale flags; not read by anything yet, typed as a string so the schema is honest.
/// `iconFile`, after the name block's locale flags.
const FAMILY_ICON_FIELD: usize = 0x44 / 4;
/// `ItemPetFood.dbc`'s enUS `Name` — field 1, the same shape every other localized table here uses.
/// `ItemPetFood.dbc`'s enUS name.
const FOOD_NAME_FIELD: usize = 1;
/// The number of `ItemPetFood` rows a mask can name. Not a guess at the file's size: it is the
/// width the *mask* can address, and the shipped table happens to fill it exactly (bit 7 → row 8,
/// Turtle's "Raw Fish"). Bits above this are ignored rather than fabricating rows.
/// The reference tests `1 << (row - 1)` for each of the 8 food rows, so higher bits name nothing.
const MAX_FOOD_BITS: u32 = 8;
/// One `CreatureFamily.dbc` row, reduced to its live columns: the family word, the diet mask, and
/// the **level → size ramp**.
/// One `CreatureFamily.dbc` row: the family word, icon, diet mask and level-to-size ramp.
#[derive(Debug, Clone, PartialEq)]
pub struct CreatureFamily {
/// The localized family word — "Wolf", "Imp", "Wind Serpent". What `UnitCreatureFamily` pushes.
/// The localized family word ("Wolf", "Imp"), what `UnitCreatureFamily` returns.
pub name: String,
/// The family's icon path — `Interface\\Icons\\Ability_Hunter_Pet_<Family>` for every one of
/// the 22 real families (verified by dumping the shipped table this session; the column is
/// field 17, `0x44`). This is a pet's icon everywhere one is shown without an item behind it:
/// the stable window's slot buttons and `GetPetIcon` (decision 1676). The schema already
/// declared the column so the parser aligned past it; only the read into this struct is new.
///
/// Empty for a row that ships no icon — the same "render the empty-slot art" case as a pet
/// whose creature query has not landed, which is why consumers treat it as absent rather than
/// passing an empty path to a texture setter (decision 1046: a path resolving to nothing draws
/// WHITE).
/// `Interface\\Icons\\Ability_Hunter_Pet_<Family>`; empty when the row ships none.
pub icon: String,
/// The pet-food bitfield (field 7). **`0` for every warlock minion** — Imp, Voidwalker,
/// Succubus, Felhunter, Doomguard all ship a zero mask, and so does row 28 ("Remote Control") —
/// which is why a diet list can be legitimately empty without anything having gone wrong.
/// The diet bitfield; 0 on the five warlock minions and row 28, so an empty diet is real.
pub pet_food_mask: u32,
/// The size ramp's ends (fields 1–4): the model scale at `min_scale_level` and at
/// `max_scale_level`. See [`CreatureFamily::scale_at`].
/// The size ramp: the model scale at `min_scale_level` and at `max_scale_level`.
pub min_scale: f32,
pub min_scale_level: u32,
pub max_scale: f32,
@@ -133,28 +55,16 @@ pub struct CreatureFamily {
}
impl CreatureFamily {
/// **A pet's render scale at a level** — the character-select pet's whole size law (decision
/// 1538):
/// A pet's render scale at `level`, the reference's size law for the character-select pet:
///
/// ```text
/// S = minScale + (maxScale − minScale) · clamp(level − minScaleLevel, 0, range) / range
/// range = maxScaleLevel − minScaleLevel
/// ```
///
/// This is what the reference actually sizes the select pet with. It *also* computes the
/// familiar `CreatureModelData.modelScale × CreatureDisplayInfo.scale` product one instruction
/// earlier and then **overwrites it** with this (`0x472e87 fstp`) — the product survives only
/// when the family lookup misses, which for a real pet it never does.
///
/// Every one of the 22 real families ramps 1 → 60, so a freshly tamed wolf really is visibly
/// smaller than a level-60 one (Wolf 0.7 → 1.0, Crab 0.7 → 1.4, Spider 0.4 → 0.6); the warlock
/// minions ship `min == max`, so theirs is flat at every level (Imp 0.5, Voidwalker 0.8,
/// Succubus and Doomguard 1.0, Felhunter 0.7).
///
/// **`None` for a degenerate ramp** — `range <= 0`, which only row 28 ("Remote Control", all
/// four columns 0) ships. The reference has no guard there and divides by zero into a NaN
/// scale; that is a defect of the original, not a mechanism, so we answer "no ramp" and let the
/// caller take the product fallback the reference itself takes on a lookup miss.
/// The reference overwrites the `modelScale × scale` product with it (`0x472e87`), so the
/// product survives only a family lookup miss. Deviation: a zero range (row 28 only) is `None`,
/// the lookup-miss fallback, because the reference divides by zero there into a NaN scale.
pub fn scale_at(&self, level: u32) -> Option<f32> {
let range = self.max_scale_level.checked_sub(self.min_scale_level)?;
if range == 0 {
@@ -171,18 +81,13 @@ impl CreatureFamily {
pub struct CreatureFamilies(HashMap<u32, CreatureFamily>);
impl CreatureFamilies {
/// The row for a family id, or `None`.
///
/// **Family `0` is `None` and that is the common case, not an error**: the file's ids start at
/// 1, and every creature that is neither a tameable beast nor a warlock minion carries `0` in
/// the wire's `pet_family` slot. `UnitCreatureFamily`'s nil is this `None`.
/// The row for a family id; the wire's 0 (no family) is `None`, `UnitCreatureFamily`'s nil.
pub fn get(&self, id: u32) -> Option<&CreatureFamily> {
self.0.get(&id)
}
/// The localized family word for an id — [`Self::get`]'s name half.
/// The family's icon path, or `None` for an unknown family or a row that ships none —
/// the stable window's slot art and `GetPetIcon`'s answer (decision 1676).
/// The icon for the stable's slots and `GetPetIcon`; `None` for an unknown family or an empty
/// path, which would draw white.
pub fn icon(&self, id: u32) -> Option<&str> {
self.0
.get(&id)
@@ -194,9 +99,8 @@ impl CreatureFamilies {
self.get(id).map(|f| f.name.as_str())
}
/// A pet's render scale from its family and level — [`CreatureFamily::scale_at`] over
/// [`Self::get`]. `None` for an unknown family (including the wire's `0`) or a degenerate ramp;
/// the caller then takes the display's own scale product, as the reference does.
/// [`CreatureFamily::scale_at`] by family id; on `None` the caller takes the display's scale
/// product, as the reference does.
pub fn pet_scale(&self, family: u32, level: u32) -> Option<f32> {
self.get(family)?.scale_at(level)
}
@@ -219,18 +123,8 @@ impl PetFoodNames {
self.0.get(&id).map(String::as_str)
}
/// Expand a [`CreatureFamily::pet_food_mask`] into the localized names it selects — **bit `b`
/// selects row `b + 1`**, ascending, which is the client's own **record order**
/// (`0x4bea10`'s `1 << (recordID - 1)`; the module header carries the derivation).
///
/// An empty result is a real answer, not a failure: a warlock minion's family ships mask `0`.
/// `GetPetFoodTypes` then returns nothing at all, which is exactly what the reference's own
/// `BuildListString()` turns into nil — and the diet icon that would show it is hidden for a
/// minion anyway (`HasPetUI`'s second return, decision 1005).
///
/// **This is the table half only.** The binding's live gate — owner-is-me and the local player
/// is a Hunter (`0x6116e0`) — is the feed's to apply; a charmed boar has a mask of 63 and still
/// answers nothing under a non-hunter.
/// The food names a [`CreatureFamily::pet_food_mask`] selects, bit `b` naming row `b + 1` in
/// the reference's record order (`0x4bea10`). The `0x6116e0` gate is the caller's.
pub fn for_mask(&self, mask: u32) -> Vec<&str> {
(0..MAX_FOOD_BITS)
.filter(|b| mask & (1 << b) != 0)
@@ -250,11 +144,10 @@ fn family_schema() -> Schema {
let mut s = Schema::new("CreatureFamily");
for i in 0..FAMILY_FIELDS {
let ty = match i {
// minScale / maxScale — the only two floats in the record.
// minScale and maxScale, the record's only floats.
1 | 3 => FieldType::Float32,
FAMILY_NAME_FIELD | FAMILY_ICON_FIELD => FieldType::String,
// The rest of the locale block reads as dwords: every one is 0 in the shipped file, and
// typing them as strings would make the parser chase seven zero offsets per row.
// The other locale slots, all 0 in the shipped file, read as dwords.
_ => FieldType::UInt32,
};
s.add_field(SchemaField::new(format!("F{i}"), ty));
@@ -284,8 +177,7 @@ pub fn load_creature_families(chain: &mut Chain) -> Result<CreatureFamilies> {
let mut by_id = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
// A row with no enUS name is dropped rather than stored empty: the binding's contract is
// "a word or nil", and an empty string would render as a trailing space on the level line.
// A nameless row is dropped: `UnitCreatureFamily` answers a word or nil, never "".
let Some(name) = str_at(&rs, r, FAMILY_NAME_FIELD).filter(|n| !n.is_empty()) else {
continue;
};
@@ -330,17 +222,7 @@ mod tests {
Some(crate::open_chain(&data).expect("open chain"))
}
/// **The level → size ramp, off the shipped file** (decision 1538) — the character-select pet's
/// whole size law, and four columns that fail silently if they slide: misread one and every pet
/// is simply the wrong size, which no other assertion here would catch.
///
/// The load-bearing shape is that **all 22 real families ramp 1 → 60**, warlock minions
/// included — they are flat only because `min == max`, not because their level columns are
/// zero. Row 28 ("Remote Control") is the sole 0/0/0/0 row, and the sole `None`.
/// The icon column, off the shipped file (decision 1676). It is the stable window's slot art
/// and `GetPetIcon`'s answer, and it sits at field 17 behind a locale block the schema types as
/// dwords — so a misread there returns an empty string, and an empty texture path draws WHITE
/// (decision 1046). Asserting real paths is what catches that.
/// Field 17 sits behind locale slots typed as dwords, so a column slip reads an empty path.
#[test]
fn every_shipped_family_carries_a_pet_icon() {
let Some(mut chain) = chain() else { return };
@@ -350,12 +232,11 @@ mod tests {
fams.icon(1),
Some("Interface\\Icons\\Ability_Hunter_Pet_Wolf")
);
// A warlock minion has one too — the stable never shows them, but `GetPetIcon` does.
// A warlock minion has one too, for `GetPetIcon`.
assert!(fams
.icon(15)
.is_some_and(|i| i.starts_with("Interface\\Icons\\")));
// No real family is missing one, and none is a bare empty string masquerading as a path.
for id in 1..=27 {
let Some(f) = fams.get(id) else { continue };
assert!(
@@ -364,7 +245,6 @@ mod tests {
f.name
);
}
// An unknown family is absent, not empty.
assert_eq!(fams.icon(9999), None);
}
@@ -373,7 +253,6 @@ mod tests {
let Some(mut chain) = chain() else { return };
let fams = load_creature_families(&mut chain).expect("families");
// Every real family ramps across the same 1 → 60 band.
for (id, f) in (1..=27).filter_map(|id| fams.get(id).map(|f| (id, f))) {
assert_eq!(
(f.min_scale_level, f.max_scale_level),
@@ -383,7 +262,7 @@ mod tests {
);
}
// A hunter family really does grow: Wolf 0.7 at level 1 → 1.0 at 60, linear between.
// Wolf: 0.7 at level 1 to 1.0 at 60, linear between.
let wolf = fams.get(1).expect("Wolf");
assert_eq!(wolf.name, "Wolf");
assert!((wolf.scale_at(1).unwrap() - 0.7).abs() < 1e-6);
@@ -393,11 +272,11 @@ mod tests {
(mid - (0.7 + 0.3 * 29.0 / 59.0)).abs() < 1e-6,
"midpoint {mid} is not on the line"
);
// …and the ramp CLAMPS at both ends rather than extrapolating.
// The ramp clamps at both ends.
assert_eq!(wolf.scale_at(0), wolf.scale_at(1));
assert_eq!(wolf.scale_at(255), wolf.scale_at(60));
// The warlock minions are flat — same band, `min == max`.
// The warlock minions are flat: `min == max`.
for (id, want) in [(23, 0.5_f32), (16, 0.8), (15, 0.7), (17, 1.0), (19, 1.0)] {
let f = fams.get(id).expect("warlock family");
for level in [1, 30, 60] {
@@ -410,51 +289,40 @@ mod tests {
}
}
// The one degenerate row: no ramp, and NOT a NaN.
// Row 28's zero range: `None`, not NaN.
let remote = fams.get(28).expect("Remote Control");
assert_eq!(remote.scale_at(60), None);
// Unknown families (the wire's `0` for every non-pet) answer None, not a fabricated size.
// The wire's 0 and an id gap are unknown families.
assert_eq!(fams.pet_scale(0, 60), None);
assert_eq!(fams.pet_scale(10, 60), None); // a real id-space gap
assert_eq!(fams.pet_scale(10, 60), None);
assert!((fams.pet_scale(1, 60).unwrap() - 1.0).abs() < 1e-6);
}
/// The real 5875 `CreatureFamily.dbc`, byte-anchored: 23 rows, the ids with their gaps, and
/// the two columns we read.
///
/// **The name column is the trap.** Field 8 sits immediately after `petFoodMask` (7) and is
/// followed by seven zero locale slots — read one column early and every name is a *number*;
/// read one late and every name is empty. Both fail silently at runtime (a blank level line),
/// so the names are asserted verbatim, not merely for presence.
/// Names are asserted verbatim: a column early reads the food mask, a column late a zero slot.
#[test]
fn the_real_creature_families_name_every_pet() {
let Some(mut chain) = chain() else { return };
let f = load_creature_families(&mut chain).expect("load CreatureFamily.dbc");
assert_eq!(f.len(), 23, "5875 ships 23 creature families");
// The hunter families, at both ends of the id range.
assert_eq!(f.name(1), Some("Wolf"));
assert_eq!(f.name(2), Some("Cat"));
assert_eq!(f.name(7), Some("Carrion Bird"));
assert_eq!(f.name(27), Some("Wind Serpent"));
// …the warlock minions, which is what the pet page's level line reads most often.
assert_eq!(f.name(15), Some("Felhunter"));
assert_eq!(f.name(16), Some("Voidwalker"));
assert_eq!(f.name(17), Some("Succubus"));
assert_eq!(f.name(19), Some("Doomguard"));
assert_eq!(f.name(23), Some("Imp"));
// The id column really has gaps — 10/13/14/18/22 are absent from the shipped file, so a
// dense 1..=23 assumption would silently shift half the table.
// The id gaps: a dense 1..=23 read would shift half the table.
for missing in [10, 13, 14, 18, 22] {
assert_eq!(f.name(missing), None, "id {missing} is not in the file");
}
// Family 0 is the wire's "no family", and it must be nil rather than the first row.
assert_eq!(f.name(0), None);
}
/// The eight food names, verbatim and in id order — the row set the mask indexes.
#[test]
fn the_real_pet_food_names_are_the_eight_diets() {
let Some(mut chain) = chain() else { return };
@@ -476,13 +344,8 @@ mod tests {
assert_eq!(n.name(9), None);
}
/// **The join, on the real data** — the half neither file can check alone, and the one that
/// would fail silently as "a bear eats fish and cheese".
///
/// Wolf, Bear, Boar and Turtle are **the binary's own positive controls** (`0x4bea10`,
/// 2026-08-06); the rest are vanilla's documented diets, which is the
/// independent corroboration that `bit b → row b+1` is right (vmangos's
/// `1 << (FoodType - 1)` is the third).
/// Wolf, Bear, Boar and Turtle are the reference's own cases (`0x4bea10`); the rest are
/// vanilla's documented diets.
#[test]
fn the_shipped_masks_expand_to_vanillas_own_diets() {
let Some(mut chain) = chain() else { return };
@@ -498,20 +361,15 @@ mod tests {
assert_eq!(diet(9), ["Fungus", "Fruit"], "Gorilla");
assert_eq!(diet(12), ["Cheese", "Fungus", "Fruit"], "Tallstrider");
assert_eq!(diet(27), ["Fish", "Cheese", "Bread"], "Wind Serpent");
// Turtle (mask 178) is the control that PINS the bit width: it is the only family to set
// bit 7, and bit 7 → row 8 is what makes "Raw Fish" reachable at all.
// Turtle (mask 178) is the only family to set bit 7, row 8's "Raw Fish".
assert_eq!(
diet(21),
["Fish", "Fungus", "Fruit", "Raw Fish"],
"Turtle — control"
);
// **Exactly which families have an EMPTY diet**, because the pet page's diet tooltip
// depends on it: `BuildListString()` returns nil for an empty list and `format`'s "%s"
// errors on nil, so the icon is safe only while no HUNTER-tameable family has a zero mask.
// The shipped file's zero-mask set is the five warlock minions plus row 28 ("Remote
// Control") — no tameable beast among them. Asserted as an exact set, not a spot check: a
// future row with a zero mask is exactly the thing that would break that tooltip.
// The exact empty-diet set: the diet tooltip formats `BuildListString()`'s nil for an empty
// list and errors, so no tameable beast may join the five minions and row 28.
let empty: Vec<u32> = {
let mut v: Vec<u32> = fam
.0
@@ -529,8 +387,6 @@ mod tests {
);
}
/// The mask expansion's own edges, away from the shipped data: an unknown bit names no row
/// rather than panicking or inventing one, and bits past the table are ignored.
#[test]
fn mask_bits_past_the_table_are_ignored() {
let mut names = HashMap::new();
@@ -540,7 +396,7 @@ mod tests {
assert_eq!(n.for_mask(0), Vec::<&str>::new());
assert_eq!(n.for_mask(0b101), ["Meat", "Cheese"]);
// Bit 1 names row 2, which this cut-down table doesn't have: skipped, not empty-stringed.
// Bit 1 names row 2, absent here: skipped.
assert_eq!(n.for_mask(0b111), ["Meat", "Cheese"]);
// Bit 31 is past `MAX_FOOD_BITS` entirely.
assert_eq!(n.for_mask(0x8000_0001), ["Meat"]);
+26 -78
View File
@@ -1,27 +1,7 @@
//! Creature voice: **CreatureDisplayInfo.SoundID → CreatureSoundData.dbc** (decision 0070
//! slice 3) — the per-display voice kit set (exertion/wound/death/aggro/fidgets…).
//!
//! Layouts — VERIFIED against build 5875 (header + row decode, 2026-07-02):
//! `CreatureSoundData` **406 × 30 × 120 B**: `ID, ExertionID, ExertionCriticalID, InjuryID,
//! InjuryCriticalID, InjuryCrushingBlowID, DeathID, StunID, StandID, FootstepID (a
//! FootstepTerrainLookup **class**, NOT a kit), AggroID, WingFlapID, WingGlideID, AlertID,
//! Fidget[4], CustomAttack[4], NPCSoundID, LoopSoundID, CreatureImpactType (0 flesh · 1 stone ·
//! 2 wood · 3 ethereal), JumpStartID, JumpEndID, PetAttackID, PetOrderID, PetDismissID`.
//! Columns **27/28/29** are the pet's own voice (decision 2039): 27 and 28 are what the bark
//! dispatcher `0x623a40` reaches at states 2 and 1 — `SMSG_PET_ACTION_SOUND`'s two selectors —
//! and 29 is `SMSG_PET_DISMISS_SOUND`'s, resolved fresh by model id at `0x604140` rather than off
//! the cached row, which is why a census over the cache's consumers missed it. The **names** here
//! are the community's and stay INFERRED — a DBC carries no column names — but the shipped kits
//! corroborate them: the four rows that populate 27/28 name `A_<demon>_KILL` and `A_<demon>_ORDER`.
//! The column nothing reads is **22**, which is also empty on all 406 rows.
//! Spot-check row 26: exertion 312/313, injury 315/316/0, death 314, stun 690, stand 317,
//! footstep class 8, aggro 694, alert 1107 — all coherent kit-id ranges.
//! The chain is `UNIT_FIELD_DISPLAYID` → `CreatureDisplayInfo.SoundID`, **falling back to
//! `CreatureModelData.SoundID`** (col 13 of the 430 × 16 × 64 B table) when the display's own FK
//! is 0 — the client's generic resolution (the earlier "no model fallback in 1.12" note here was
//! wrong). The fallback is load-bearing: 10 261 of 10 534
//! displays carry SoundID 0, and with the model link 10 533/10 534 resolve a row (byte-census
//! 2026-07-03) — character displays reach footstep class 7 this way, as data, not client logic.
//! Creature voice: `CreatureDisplayInfo.SoundID` to `CreatureSoundData.dbc`, 30 `u32` columns of
//! per-display voice kits whose names are the community's, since a DBC carries none. Column 22
//! (`NPCSoundID`) is empty on every row and unread. A display whose own `SoundID` is 0, nearly
//! every one, resolves through `CreatureModelData.SoundID` (col 13), as the client does.
use std::collections::HashMap;
@@ -31,8 +11,7 @@ use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, u32_at};
/// One `CreatureSoundData` row — every field is a SoundEntries kit id (0 = none) except where
/// noted.
/// One `CreatureSoundData` row; every field is a `SoundEntries` kit id (0 for none) unless noted.
pub struct CreatureVoice {
/// Attack grunt `[normal, critical]`.
pub exertion: [u32; 2],
@@ -42,8 +21,7 @@ pub struct CreatureVoice {
pub stun: u32,
/// Fired by the `$FDX` anim event.
pub stand: u32,
/// `FootstepTerrainLookup.CreatureFootstepID` — the creature's footstep **class**, joined
/// against the terrain type (NOT a kit id).
/// The footstep class (`FootstepTerrainLookup.CreatureFootstepID`), not a kit id.
pub footstep_class: u32,
pub aggro: u32,
/// Fired by `$WNG` / `$WGG`.
@@ -56,29 +34,23 @@ pub struct CreatureVoice {
pub custom_attack: [u32; 4],
/// A looping body sound (SoundEntries type 27).
pub loop_sound: u32,
/// Melee impact material: 0 flesh · 1 stone · 2 wood · 3 ethereal (WeaponImpactSounds slot
/// class — consumed with combat impacts).
/// Melee impact material (`WeaponImpactSounds`): 0 flesh, 1 stone, 2 wood, 3 ethereal.
pub impact_type: u32,
pub jump_start: u32,
pub jump_end: u32,
/// **Column 27** — the bark a pet gives when it takes an attack order
/// (`SMSG_PET_ACTION_SOUND` selector `PET_TALK_ATTACK`, the reference's bark state **2**,
/// read at `0x623ad3` as `[row+0x6c]`).
/// Column 27, the bark on an attack order: `SMSG_PET_ACTION_SOUND`'s `PET_TALK_ATTACK`, bark
/// state 2 of `0x623a40` (`0x623ad3` reads `[row+0x6c]`).
pub pet_attack: u32,
/// **Column 28** — the bark a pet gives when it acknowledges an ordered spell
/// (`SMSG_PET_ACTION_SOUND` selector `PET_TALK_SPECIAL_SPELL`, the reference's bark state
/// **1**, read at `0x623ac8` as `[row+0x70]`).
/// Column 28, the bark acknowledging an ordered spell: `PET_TALK_SPECIAL_SPELL`, bark state 1
/// (`0x623ac8` reads `[row+0x70]`).
pub pet_order: u32,
/// **Column 29** — what a dismissed pet says as it goes (`SMSG_PET_DISMISS_SOUND`'s handler
/// `0x604140`, read at `0x6041c4`/`0x6041dc` as `[row+0x74]`).
///
/// Not a bark: it is off `0x623a40`'s table entirely, played at a bare world position with no
/// unit, no latch and no attach point — because by the time it sounds the pet is gone.
/// Column 29, `SMSG_PET_DISMISS_SOUND`'s parting line: `0x604140` reads `[row+0x74]`
/// (`0x6041c4`, `0x6041dc`) and plays it at a bare world position, since the pet is gone.
pub pet_dismiss: u32,
}
/// display id → voice rows, joined through `CreatureDisplayInfo.SoundID`, plus the raw
/// `CreatureModelData.SoundID` join the dismiss sound needs on its own.
/// Voice rows by display id through `CreatureDisplayInfo.SoundID`, and by model id through
/// `CreatureModelData.SoundID` for the dismiss sound.
pub struct CreatureVoiceCatalog {
display_to_sound: HashMap<u32, u32>,
model_to_sound: HashMap<u32, u32>,
@@ -86,18 +58,14 @@ pub struct CreatureVoiceCatalog {
}
impl CreatureVoiceCatalog {
/// The voice set for a creature **display id** (the `NetEntity.display_id` the wire gives us).
/// The voice set for a creature display id (`UNIT_FIELD_DISPLAYID`).
pub fn for_display(&self, display_id: u32) -> Option<&CreatureVoice> {
self.rows.get(self.display_to_sound.get(&display_id)?)
}
/// The voice set for a **`CreatureModelData` id**, through that table's own `SoundID`
/// (col 13) with no display in between.
///
/// The odd one out, and deliberately so: every other caller starts from
/// `UNIT_FIELD_DISPLAYID`, but `SMSG_PET_DISMISS_SOUND` names a model directly and the
/// reference resolves it exactly this way (`0x604140`: `[[0xc0de68] + id*4] + 0x34` →
/// `[[0xc0de54] + sound*4]`) — no display step, no fallback.
/// The voice set for a `CreatureModelData` id through its own `SoundID` (col 13), with no
/// display step and no fallback: `SMSG_PET_DISMISS_SOUND` names a model, and `0x604140`
/// resolves it so (`[[0xc0de68] + id*4] + 0x34`, then `[[0xc0de54] + sound*4]`).
pub fn for_model(&self, model_id: u32) -> Option<&CreatureVoice> {
self.rows.get(self.model_to_sound.get(&model_id)?)
}
@@ -135,7 +103,7 @@ fn cmd_schema() -> Schema {
s
}
/// Read both tables off the patch chain into the joined catalog.
/// Read the three tables off the patch chain into the joined catalog.
pub fn load_creature_voice_catalog(chain: &mut Chain) -> Result<CreatureVoiceCatalog> {
let bytes = chain
.read_file("DBFilesClient\\CreatureSoundData.dbc")
@@ -194,7 +162,7 @@ pub fn load_creature_voice_catalog(chain: &mut Chain) -> Result<CreatureVoiceCat
else {
continue;
};
// The display's own FK wins; 0 falls back to the model's (module docs).
// The display's own `SoundID` wins; 0 falls back to the model's.
let sound = if sound != 0 {
Some(sound)
} else {
@@ -215,8 +183,6 @@ pub fn load_creature_voice_catalog(chain: &mut Chain) -> Result<CreatureVoiceCat
mod tests {
use super::*;
/// The join works on the real 5875 tables: display 26 resolves the byte-decoded row (death
/// kit 314, footstep class 8), and a majority of sound-linked displays resolve to a real row.
#[test]
fn real_creature_voice_resolves() {
let data = crate::wow_data_or_skip!();
@@ -230,9 +196,7 @@ mod tests {
assert_eq!(v.footstep_class, 8);
assert_eq!(v.aggro, 694);
// The CreatureModelData fallback: the Elwynn wolf display (903) has
// CreatureDisplayInfo.SoundID 0 and resolves through its model's SoundID (43);
// the human-male character display (49) reaches footstep class 7 the same way.
// The model fallback: the Elwynn wolf (903) and a human male (49) have display `SoundID` 0.
let wolf = cat.for_display(903).expect("wolf resolves via the model");
assert_eq!(wolf.footstep_class, 8);
let human = cat
@@ -240,19 +204,8 @@ mod tests {
.expect("human male resolves via the model");
assert_eq!(human.footstep_class, 7);
}
/// **The pet voice columns are a warlock-demon surface, and the shipped data names them.**
///
/// Exactly **four** of the 406 rows carry columns 27/28/29 — the Imp, Succubus, Doomguard and
/// Voidwalker voice rows — and the kits they name are `A_<demon>_KILL`, `A_<demon>_ORDER` and
/// `A_<demon>_DISMISS`. That naming is an *independent* confirmation of which column is
/// which: the reference sends `PET_TALK_ATTACK` to bark state 2 = column 27,
/// `PET_TALK_SPECIAL_SPELL` to state 1 = column 28, and `SMSG_PET_DISMISS_SOUND` to column 29
/// — and the data calls those three `_KILL`, `_ORDER` and `_DISMISS`, in that order, three
/// times over (decision 2039; `0x623a40` maps the states to the columns).
///
/// **A hunter pet is therefore silent on all three, faithfully** — its row's columns are 0,
/// so the reference reads a zero kit and plays nothing. The Felhunter has no kit in the file
/// at all. Skips without client data.
/// Only four rows carry columns 27-29, and their kits are named `_KILL`, `_ORDER` and
/// `_DISMISS` in column order; a hunter pet's are 0, so it is silent, as in the reference.
#[test]
fn only_the_four_demon_voices_carry_pet_barks() {
let data = crate::wow_data_or_skip!();
@@ -275,8 +228,7 @@ mod tests {
})
.collect();
carrying.sort_unstable();
// Casing is the file's own — `A_Imp_Dismiss` and `A_DOOMGUARD_DISMISS01` are how those two
// rows are written, and the lookup is case-insensitive anyway.
// Casing is the file's own; the kit lookup is case-insensitive.
assert_eq!(
carrying,
vec![
@@ -307,8 +259,6 @@ mod tests {
],
);
// Reachable from real displays, not orphan rows: the Imp's voice is the one 25 of them
// share, and the wolf a hunter tames carries none of the three.
let imp = cat.for_display(904).expect("an imp display resolves");
assert_eq!(
(imp.pet_attack, imp.pet_order, imp.pet_dismiss),
@@ -320,9 +270,7 @@ mod tests {
(0, 0, 0)
);
// The dismiss sound's own join — `CreatureModelData.SoundID` with **no display step**
// (`0x604140`). The imp's model must reach the same row its displays do, or the packet
// would be silent for the one pet it exists for.
// The dismiss sound's model join (`0x604140`) reaches the imp's row too.
let by_model = cat
.for_model(
*cat.model_to_sound
+6 -21
View File
@@ -1,14 +1,6 @@
//! CreatureType.dbc — the 13-row creature-class table (Beast, Humanoid, Critter, Totem, …).
//!
//! The one consumer today: the TAB-target scan's critter rejection (the scorer `0x494200` looks
//! the candidate's creature type up in the cached table `[0xc0de2c]` and rejects when
//! `row[+0x28] & 1` — **flag bit 0**).
//! In the shipped 1.12 data only **Critter (8)** carries the bit (the "critter/totem/non-combat
//! pet" gloss is later-era; see the real-chain test). A unit's creature type itself comes off
//! the wire (`SMSG_CREATURE_QUERY_RESPONSE`), cached with its name.
//!
//! Record layout (1.12, 11 × u32-slot fields): id@0, name(8 locales + flags = 9 slots)@1..9,
//! flags@10.
//! `CreatureType.dbc`: each creature type's flags word (column 10). The TAB-target scorer
//! (`0x494200`) looks a unit's type up in its cached table (`[0xc0de2c]`) and rejects it when
//! `row[+0x28] & 1`. A unit's type comes from `SMSG_CREATURE_QUERY_RESPONSE`.
use std::collections::HashMap;
@@ -20,16 +12,13 @@ use crate::Chain;
const CREATURE_TYPE: &str = "DBFilesClient\\CreatureType.dbc";
/// creature-type id → the row's flags dword. Query through [`CreatureTypeFlags::no_tab_target`].
/// Creature type id → the row's flags word.
#[derive(Debug, Default, Clone)]
pub struct CreatureTypeFlags(HashMap<u32, u32>);
impl CreatureTypeFlags {
/// Whether this creature type is excluded from TAB/nearest-enemy targeting — the client's
/// `flags & 1` test. In the shipped **1.12** data exactly ONE row carries the bit:
/// **Critter (8)** (real-chain verified below — Totem 11 does NOT; the "can't tab totems"
/// lore is a later-era flag change, not 5875 data). An unknown or missing type is targetable
/// (the client's out-of-range index skips the check).
/// Whether TAB targeting skips this type (`flags & 1`): in 1.12 data only Critter (8), not
/// Totem (11). An unknown type is targetable, as the client skips an out-of-range index.
pub fn no_tab_target(&self, creature_type: u32) -> bool {
self.0.get(&creature_type).is_some_and(|f| f & 1 != 0)
}
@@ -72,10 +61,6 @@ pub fn load_creature_type_flags(chain: &mut Chain) -> Result<CreatureTypeFlags>
mod tests {
use super::*;
/// The shipped 1.12 data, read through the real chain when the install is present: **11**
/// rows (ids 1–11), and exactly one carries flag bit 0 — **Critter (8)**. Totem (11) does
/// NOT (verified against the real file; the critter/totem gloss from later expansions doesn't
/// hold for 5875 data). Beast (1) and Humanoid (7) are targetable.
#[test]
fn shipped_flags_mark_only_critter() {
let data = crate::wow_data_or_skip!();
+103 -369
View File
@@ -1,24 +1,6 @@
//! Creature display resolution: `displayId` → M2 model + skin textures + scale.
//!
//! An NPC's `UNIT_FIELD_DISPLAYID` indexes **CreatureDisplayInfo.dbc**, which gives a `ModelID`
//! (into **CreatureModelData.dbc**, the `.mdx` path), a per-display scale, and up to three skin
//! texture names. Creature M2s leave their `Monster1/2/3` texture slots blank and pull the skin
//! from these names — the texture lives **in the same directory as the model** (wowdev.wiki). The
//! effective render scale is `CreatureModelData.modelScale * CreatureDisplayInfo.creatureModelScale`.
//!
//! **Character-model NPCs.** A humanoid NPC (guard, questgiver, townsfolk) uses a `Character\…` body
//! M2 — the same model a player wears — but its appearance is *not* on the wire (as a player's is);
//! it lives in **CreatureDisplayInfoExtra.dbc**, reached via `CreatureDisplayInfo.ExtendedDisplayInfoID`
//! (0 for a plain beast). That row supplies race/sex + the customization selectors and, in field 18, a
//! **bake name** — a pre-composited body atlas the client ships under `Textures\BakedNpcTextures\` and
//! loads directly (rather than compositing live like the local player). We surface it as
//! [`NpcAppearance`]; the beast skin path ([`CreatureModel::textures`]) is untouched.
//!
//! Layouts verified against build 5875 (field counts from the file header, cross-checked with
//! wowdev.wiki + vmangos `DBCStructure.h`): CreatureModelData = 16 fields (ID@0, ModelName@2,
//! ModelScale@4, CollisionHeight@15); CreatureDisplayInfo = 12 fields (ID@0, ModelID@1, ExtendedDisplayInfoID@3, Scale@4,
//! TextureVariation@6/7/8); CreatureDisplayInfoExtra = 19 fields (ID@0, Race@1, Sex@2, Skin@3, Face@4,
//! HairStyle@5, HairColor@6, FacialHair@7, Equipment@8..17, BakeName@18).
//! Creature display resolution: a `displayId` to its M2, skin textures and scale through
//! `CreatureDisplayInfo` and `CreatureModelData`. A humanoid NPC's look is not on the wire: it
//! comes from `CreatureDisplayInfoExtra` and a baked atlas under `Textures\BakedNpcTextures\`.
use std::collections::HashMap;
@@ -32,46 +14,30 @@ const CREATURE_MODEL_DATA: &str = "DBFilesClient\\CreatureModelData.dbc";
const CREATURE_DISPLAY_INFO: &str = "DBFilesClient\\CreatureDisplayInfo.dbc";
const CREATURE_DISPLAY_INFO_EXTRA: &str = "DBFilesClient\\CreatureDisplayInfoExtra.dbc";
/// A resolved creature: model path + effective scale + its (up to three) skin texture names, plus —
/// for a character-model NPC — the [`NpcAppearance`] that skins its body.
/// A resolved creature display: model, scale, skins and, for a humanoid NPC, its appearance.
#[derive(Debug, Clone)]
pub struct CreatureModel {
/// `.mdx` path (the M2 loader normalizes to `.m2`), e.g. `Creature\Basilisk\Basilisk.mdx`.
/// `.mdx` path (the M2 loader maps it to `.m2`), e.g. `Creature\Basilisk\Basilisk.mdx`.
pub model_path: String,
/// `CreatureModelData.modelScale * CreatureDisplayInfo.creatureModelScale`.
pub scale: f32,
/// `textureVariation[0..2]` — bare names (no dir/extension); `None` where empty. The renderer
/// resolves a used one to `<dir-of-model_path>\<name>.blp` for the model's `Monster1/2/3` slots.
/// For a character-model NPC the body skin comes from [`Self::npc_appearance`]'s baked atlas
/// regardless; these slots are empty on ~98% of such rows and unused for the body on the rest
/// (the body build `0x5fb200` never reads them — a `Monster`-slot binding on a character M2 is
/// a separate, untraced mechanism).
/// `textureVariation[0..2]`, bare names found beside the model, for its `Monster1/2/3` slots;
/// a humanoid NPC's body build (`0x5fb200`) never reads them.
pub textures: [Option<String>; 3],
/// A character-model NPC's body appearance (from CreatureDisplayInfoExtra, via the display's
/// `ExtendedDisplayInfoID`). `None` for a plain beast/monster (ExtendedDisplayInfoID 0) — those
/// skin from [`Self::textures`], not here.
/// A character-model NPC's appearance; `None` for a plain creature (`ExtendedDisplayInfoID` 0).
pub npc_appearance: Option<NpcAppearance>,
/// `CreatureDisplayInfo.BloodLevel` (+0x28) — **tier 1** of the reference's UnitBloodLevels
/// row resolve. Not a resolved key: the three tiers need the table to know which candidate
/// lands, so they live in [`crate::BloodCatalog::level_key`], which is what a consumer calls.
/// `CreatureDisplayInfo.BloodLevel` (+0x28), tier 1 of the reference's `UnitBloodLevels` row
/// resolve, which [`crate::BloodCatalog::level_key`] performs.
pub blood_display: i32,
/// `CreatureModelData.BloodID` (+0x14) — **tier 2** of the same resolve. `−1` in 122 of the
/// 430 shipped models; that is *not* "bloodless", it is a tier-2 miss that falls through to
/// tier 3 (see [`crate::BloodCatalog::level_key`]).
/// `CreatureModelData.BloodID` (+0x14), tier 2 of the same resolve: `−1` is a miss that falls
/// through to tier 3, not bloodlessness.
pub blood_model: i32,
/// `CreatureModelData.collisionHeight` — the unit's collision box height in **raw model units**
/// (multiply by the unit's render scale for world yards; see
/// [`CreatureCatalog::collision_height`], which is the accessor every consumer should use).
/// `CreatureModelData.collisionHeight`, in raw model units.
pub collision_height: f32,
}
/// A character-model NPC's appearance, from **CreatureDisplayInfoExtra.dbc**. A humanoid NPC wears a
/// `Character\…` body M2 whose skin is not on the wire; this carries what the client needs to render
/// it: race/sex + the customization selectors (for the hair mesh + the geoset selection), the ten worn
/// **equipment** display ids (the armor geosets + the helm/shoulder attach models), and a `bake_name`
/// — the pre-composited body atlas the client ships under `Textures\BakedNpcTextures\` and loads
/// directly. `skin`/`face` are already baked into that atlas; they're kept for completeness and for the
/// live-composite fallback when a row carries no bake name.
/// A character-model NPC's appearance, from `CreatureDisplayInfoExtra.dbc`. `skin` and `face` are
/// already in the baked atlas; they serve the live composite when a row has no bake name.
#[derive(Debug, Clone)]
pub struct NpcAppearance {
pub race: u8,
@@ -81,15 +47,10 @@ pub struct NpcAppearance {
pub hair_style: u8,
pub hair_color: u8,
pub facial_hair: u8,
/// The ten worn-equipment `ItemDisplayInfo` display ids (fields 8..17), **bodyslot-indexed**:
/// `0` head · `1` shoulder · `2` shirt · `3` chest · `4` belt · `5` pants · `6` boots · `7` wrist ·
/// `8` gloves · `9` tabard (no cloak column — the row stops at bodyslot 9). `0` = the slot is
/// empty. Direct display ids (not item entries — no template round-trip): the head/shoulder ids
/// drive the helm/pauldron attach sub-models and the shirt..tabard ids drive the equipment geosets,
/// through the same `ItemDisplayInfo` catalog + geoset machinery the player wire path uses.
/// Worn `ItemDisplayInfo` ids (fields 8..17), 0 = empty, by body slot: head, shoulder, shirt,
/// chest, belt, pants, boots, wrist, gloves, tabard (no cloak column).
pub equipment: [u32; 10],
/// The pre-baked body-atlas file name (bare, no dir) under `Textures\BakedNpcTextures\`; `None`
/// when field 18 is empty (then the body composites live from the fields above, like a player).
/// The baked body atlas under `Textures\BakedNpcTextures\`; `None` composites the body live.
pub bake_name: Option<String>,
}
@@ -97,24 +58,16 @@ pub struct NpcAppearance {
#[derive(Debug, Clone)]
struct DisplayRow {
model_id: u32,
/// `ExtendedDisplayInfoID` — the CreatureDisplayInfoExtra key for a character-model NPC; 0 = none.
/// `ExtendedDisplayInfoID`: the `CreatureDisplayInfoExtra` key; 0 = none.
extended_id: u32,
scale: f32,
textures: [Option<String>; 3],
/// `BloodLevel` (field 10) — a per-display UnitBloodLevels override. `0` in 10498 of the
/// 10534 shipped displays, and `0` is not a row of that table, so it falls through to the
/// model's `BloodID` (see [`CreatureModel::blood_display`]).
/// `BloodLevel` (field 10), a per-display `UnitBloodLevels` override; 0 names no row.
blood_level: u32,
/// `SizeClass` (field 9, @+0x24) — the display's **size-class override**, read signed:
/// `−1` (1 791 of the 10 534 shipped rows) defers to the model's own
/// [`ModelRow::size_class`]. See [`CreatureCatalog::size_class`].
/// `SizeClass` (field 9, +0x24), the display's override, read signed: `−1` defers to the model.
size_class: i32,
/// `CreatureModelAlpha` (field 5, @+0x14) — the display's **base render opacity**, 0..=255.
/// This is the `baseAlpha` of the reference's per-unit alpha product (`0x60d2d0`, the CGUnit
/// vtbl+0x6c getter: `CreatureDisplayInfo+0x14 × (1/255)`) — an authored translucency 445 of
/// the 10534 shipped displays carry (wisps, spirits,
/// ghosts; the modal non-opaque value is 128). Players are not on this chain (the getter
/// returns a flat 1.0 for them).
/// `CreatureModelAlpha` (field 5, +0x14), 0..=255: the `baseAlpha` of the reference's unit
/// alpha product (`0x60d2d0`, × 1/255), which is a flat 1.0 for players.
model_alpha: u32,
}
@@ -123,41 +76,28 @@ struct DisplayRow {
struct ModelRow {
path: String,
scale: f32,
/// `Flags` (field 1) — see [`CreatureCatalog::breathes`] for the one bit we read.
/// `Flags` (field 1); bit 0x2 is the no-breath flag.
flags: u32,
/// `SizeClass` (field 3, @+0x0c) — the model's own audible size, `0 Small · 1 Medium ·
/// 2 Large · 3 Giant · 4 Colossal`, and the fallback arm of [`CreatureCatalog::size_class`].
/// Reads signed for symmetry with the display override; every one of the 430 shipped rows is
/// in `0..=4`, so the fallback always answers.
/// `SizeClass` (field 3, +0x0c), 0 Small to 4 Colossal, read signed like the display's.
size_class: i32,
/// `BloodID` — see [`CreatureModel::blood_model`]. Reads signed: `−1` in 122 of the 430
/// shipped rows, which the reference treats as a tier-2 miss, not as bloodlessness.
/// `BloodID`, read signed: `−1` is a tier-2 miss, not bloodlessness.
blood: i32,
/// `FootprintTextureID` (field 6) — the `FootprintTextures.dbc` key. Reads signed: `−1`
/// (133 of 430 shipped rows) marks a model that leaves no prints.
/// `FootprintTextureID` (field 6), read signed: `−1` leaves no prints.
footprint_texture: i32,
/// `FootprintTextureLength`/`Width` (fields 7/8), authored in **inches** — the client caches
/// them ×(1/36) into yards (`0x607a00`).
/// `FootprintTextureLength`/`Width` (fields 7/8), in inches (× 1/36 to yards, `0x607a00`).
footprint_length: f32,
footprint_width: f32,
/// `collisionHeight` (field 15), raw model units — see [`CreatureCatalog::collision_height`].
/// `collisionHeight` (field 15), raw model units.
collision_height: f32,
/// `FoleyMaterialID` (field 10) — a `Material.dbc` id, and the whole of a *creature's* armor
/// foley (see [`CreatureCatalog::foley_material`]). `[unit+0xb3c]` is this row, and the
/// reference reads it at `+0x28`, which is field 10 on the 16-field 5875 record.
/// `FoleyMaterialID` (field 10): the reference reads `[unit+0xb3c]+0x28`, field 10 of 16.
foley_material: u32,
/// `FootstepShakeSize` (field 11) and `DeathThudShakeSize` (field 12) — **`CameraShakes.dbc`
/// row ids**, 0 on a model that shakes nothing. Only 25 of the 430 shipped rows carry a
/// footstep shake, and the set is exactly the thumping-giant list (Ancients, kodos, sea and
/// mountain giants, titans, dragons, Anubisath, stone keeper, fel beast, Nian, Lord Kezzak,
/// bear) — see [`crate::CameraShakeCatalog`] and decision 1540.
/// `FootstepShakeSize`/`DeathThudShakeSize` (fields 11/12), `CameraShakes.dbc` ids; 0 = none.
footstep_shake: u32,
death_thud_shake: u32,
}
/// A display's footprint-decal parameters (see [`CreatureCatalog::footprint`]): the
/// `FootprintTextures.dbc` key + the print rectangle in **yards** (length along the facing,
/// width across), pre-scale.
/// A footprint decal: the `FootprintTextures.dbc` key and the print size in yards (length along
/// the facing, width across), before the unit's scale.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FootprintParams {
pub texture_id: u32,
@@ -165,61 +105,39 @@ pub struct FootprintParams {
pub width: f32,
}
/// Display/model tables loaded from the DBCs, resolving `displayId` → [`CreatureModel`].
///
/// `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).
/// The creature display tables; `Default` is empty, which consumers treat as a failed load.
#[derive(Default)]
pub struct CreatureCatalog {
/// CreatureDisplayInfo: displayId → row.
display: HashMap<u32, DisplayRow>,
/// CreatureModelData: modelId → row.
models: HashMap<u32, ModelRow>,
/// CreatureDisplayInfoExtra: extendedDisplayInfoId → character-model NPC appearance.
extra: HashMap<u32, NpcAppearance>,
}
impl CreatureCatalog {
/// A display's own `creatureModelScale` column alone — the MOUNT scale law (`0x613ef0`: a
/// rendered mount = `OBJECT_FIELD_SCALE_X ×
/// CreatureDisplayInfo.creatureModelScale`; `CreatureModelData.modelScale` does NOT multiply
/// in, unlike [`CreatureModel::scale`]'s spawned-creature product). `None` when the display
/// id misses.
/// A display's own `creatureModelScale`, the mount scale law: a mount renders at
/// `OBJECT_FIELD_SCALE_X × creatureModelScale`, without `modelScale` (`0x613ef0`).
pub fn display_scale(&self, display_id: u32) -> Option<f32> {
self.display.get(&display_id).map(|r| r.scale)
}
/// A display's **footstep camera-shake preset** — `CreatureModelData.FootstepShakeSize`, a
/// `CameraShakes.dbc` row id fired on each footfall of a heavy enough creature. `None` when
/// the display id misses or the model shakes nothing (405 of the 430 shipped models).
///
/// The trigger, the evaluator and the distance falloff are decision 1540's; this is the
/// authored id and nothing more.
/// A display's footstep camera shake, a `CameraShakes.dbc` id (`FootstepShakeSize`).
pub fn footstep_shake(&self, display_id: u32) -> Option<u32> {
let row = self.display.get(&display_id)?;
let id = self.models.get(&row.model_id)?.footstep_shake;
(id != 0).then_some(id)
}
/// A display's **death-thud camera-shake preset** — `CreatureModelData.DeathThudShakeSize`,
/// the one-off shake as the body lands. Same conventions as [`Self::footstep_shake`].
/// A display's camera shake as the body lands (`DeathThudShakeSize`).
pub fn death_thud_shake(&self, display_id: u32) -> Option<u32> {
let row = self.display.get(&display_id)?;
let id = self.models.get(&row.model_id)?.death_thud_shake;
(id != 0).then_some(id)
}
/// A display's **audible size class** — `0 Small · 1 Medium · 2 Large · 3 Giant ·
/// 4 Colossal`, the axis [`crate::DeathThudCatalog`] picks the body-fall sample on.
///
/// The reference's own two-step (`0x625500`): the **display's** `SizeClass` column wins, and
/// only its `−1` sentinel defers to the **model's**. That order matters — 8 743 of the 10 534
/// shipped displays override their model, so reading the model alone would mis-size most of
/// the world (every Small display sharing a Medium model, for one).
///
/// `None` when either lookup misses, or when **both** rows read `−1`. No shipped row does, so
/// that arm exists to keep a broken/absent table silent rather than Small — the same thing
/// the reference's own unsigned `sizeClass >= 5` gate (`0x623744`) does with a `−1`.
/// A display's audible size class, 0 Small to 4 Colossal, the axis of
/// [`crate::DeathThudCatalog`]. The display's `SizeClass` wins and only its `−1` defers to the
/// model's (`0x625500`); `None` when both are `−1`, which the reference's unsigned `>= 5` gate
/// (`0x623744`) also silences.
pub fn size_class(&self, display_id: u32) -> Option<u32> {
let row = self.display.get(&display_id)?;
let class = match row.size_class {
@@ -229,9 +147,7 @@ impl CreatureCatalog {
u32::try_from(class).ok()
}
/// Every `CreatureModelData` row that names a camera-shake preset: `(model id, path,
/// footstep id, death-thud id)`. The **census** view — the runtime reads
/// [`Self::footstep_shake`] by display id instead, because that is what a unit carries.
/// Every model row naming a shake: `(model id, path, footstep id, death-thud id)`.
pub fn shaking_models(&self) -> impl Iterator<Item = (u32, &str, u32, u32)> + '_ {
self.models
.iter()
@@ -239,103 +155,59 @@ impl CreatureCatalog {
.map(|(id, m)| (*id, m.path.as_str(), m.footstep_shake, m.death_thud_shake))
}
/// Every `CreatureModelData` model path, unordered — the census surface for "is this M2 a
/// creature model?", which is what decides whether an animation event on it reaches
/// `CGUnit_C::HandleAnimEvent` at all.
/// Every model path: being a creature model decides whether an M2's animation events reach
/// `CGUnit_C::HandleAnimEvent`.
pub fn model_paths(&self) -> impl Iterator<Item = &str> + '_ {
self.models.values().map(|m| m.path.as_str())
}
/// Every `CreatureModelData` row as `(model id, path, its own SizeClass)` — the **census**
/// view of the death-thud size axis. The runtime reads [`Self::size_class`] by *display* id
/// instead, because most displays override this column.
/// Every model row as `(model id, path, SizeClass)`; most displays override the column.
pub fn sized_models(&self) -> impl Iterator<Item = (u32, &str, i32)> + '_ {
self.models
.iter()
.map(|(id, m)| (*id, m.path.as_str(), m.size_class))
}
/// Every display as `(display id, its model id)` — the census hop that lets an instrument roll
/// a per-display answer (a resolved size class, say) up onto the model that carries the M2.
/// Every display as `(display id, model id)`.
pub fn display_models(&self) -> impl Iterator<Item = (u32, u32)> + '_ {
self.display.iter().map(|(id, r)| (*id, r.model_id))
}
/// A display's **spawned-creature render scale** — the product
/// `CreatureModelData.modelScale × CreatureDisplayInfo.creatureModelScale`, the same number
/// [`CreatureModel::scale`] carries, without the row's string clones.
///
/// Almost nothing in the world reads this: the server folds it into `OBJECT_FIELD_SCALE_X` and
/// the client renders a unit at that field alone, so multiplying it again would square it
/// (`crate::entities::attach`'s note, `0x613ef0`). The **glue
/// screens are the exception** — the character-select pet has no wire object and therefore no
/// server scale, and the reference sizes it with exactly this product (`0x472dc6`
/// `fld [x+0x10]; fmul [y+0x10]` → a uniform `diag(S,S,S,1)`). `None` when either DBC lookup
/// misses.
/// `modelScale × creatureModelScale`. The server folds it into `OBJECT_FIELD_SCALE_X`, which
/// alone scales a unit (`0x613ef0`), so only the glue screens read this: the reference sizes
/// the serverless character-select pet by it (`0x472dc6`).
pub fn model_scale(&self, display_id: u32) -> Option<f32> {
let row = self.display.get(&display_id)?;
let model = self.models.get(&row.model_id)?;
Some(model.scale * row.scale)
}
/// A display's **base render alpha** in `0.0..=1.0` — `CreatureDisplayInfo.CreatureModelAlpha`
/// / 255 ([`DisplayRow::model_alpha`]). The first factor of the unit alpha product the aura
/// CharProc nodes multiply into (`crate::aura_visual`). `None` for an unknown display; a known
/// display with no authored translucency reads `1.0`.
/// `CreatureModelAlpha / 255`, the first factor of the unit alpha product.
pub fn display_base_alpha(&self, display_id: u32) -> Option<f32> {
self.display
.get(&display_id)
.map(|r| f32::from(r.model_alpha.min(255) as u8) / 255.0)
}
/// A display's **collision height** in raw model units — `CreatureModelData.collisionHeight`,
/// the per-unit `h` every depth line in the client is a fraction of (swim at `0.75·h`, splash at
/// `0.4·h`, the foam gate at `2·h`; the reference reads each against `CMovement+0xb4`). World
/// yards = this × the unit's render scale (`OBJECT_FIELD_SCALE_X`, which the server has already
/// folded the DBC scales into) — the caller multiplies, because only it knows the live scale.
///
/// The column is **exactly the model's own MD20 collision-box Z extent**, verified against the
/// shipped client for all thirteen character models (`tests::collision_height_is_the_m2_box`) —
/// which is what settles the space: it is authored pre-scale, like the geometry it bounds.
/// `None` when either DBC lookup misses; callers fall back to the client's own ctor default
/// (`2.0277777`, `0x616fd8`).
/// `CreatureModelData.collisionHeight`, the `h` of every depth line (swim `0.75·h`, splash
/// `0.4·h`, foam gate `2·h`, read against `CMovement+0xb4`). It is the M2's collision-box Z
/// extent, pre-scale: the prism is this × `max(OBJECT_FIELD_SCALE_X, creatureModelScale)`
/// (`0x60b312` → `0x617501`). A miss takes the constructor default, `2.0277777` (`0x616fd8`).
pub fn collision_height(&self, display_id: u32) -> Option<f32> {
let row = self.display.get(&display_id)?;
Some(self.models.get(&row.model_id)?.collision_height)
}
/// The display's **foley material** — a `Material.dbc` id, the creature half of the footfall
/// rustle (`0x623610`: `[[unit+0xb3c]+0x28]` handed straight to `0x4584e0`). A *player* does
/// not come through here at all: its own override reads the equipped chest instead
/// (`0x62fa30`), so this is the answer for creatures — and, for a player wearing a
/// non-character display, the body it is actually wearing.
///
/// `None` when either DBC lookup misses. `Some(0)` is the real "no material" the data
/// carries, and resolves to silence at [`crate::MaterialCatalog::foley_kit`].
///
/// **In shipped 5875 data this column is 0 in every one of the 430 rows**, in both the base
/// archive's 333-row copy and the patched 430 (`tests::no_shipped_model_carries_a_foley`).
/// The creature branch of the foley is therefore inert against the real client's own files:
/// the armor rustle you hear is the *player* override's, and no NPC has one. Kept because it
/// is the reference's own path and one map lookup, and because a server shipping patched
/// DBCs would light it up — not because it does anything today. A future reader finding this
/// silent has found the data, not a bug.
/// A display's foley material (`Material.dbc`), the creature half of the footfall rustle:
/// `0x623610` hands `[[unit+0xb3c]+0x28]` to `0x4584e0`, while a player reads its chest
/// (`0x62fa30`). Every shipped row is 0, so an NPC's silent armor is the data, not a bug.
pub fn foley_material(&self, display_id: u32) -> Option<u32> {
let row = self.display.get(&display_id)?;
Some(self.models.get(&row.model_id)?.foley_material)
}
/// Does this display's model **breathe** — i.e. may it wear the `$BTH` hardcoded effects
/// (cold vapour, underwater bubbles, inebriated bubbles)?
///
/// `CreatureModelData.Flags & 0x2` suppresses the whole family (`0x600003`, the row at
/// `[unit+0xb3c]`): 99 of the 430 shipped
/// rows carry it — skeletons, ghosts, ghouls, zombies, banshees, every elemental, golems,
/// slimes, infernals, voidwalkers, succubi, spiders, frogs, crocodiles, turtles, totems. The
/// things that have no breath to see. Every player row is `0x4`, so players pass.
///
/// An unknown display breathes — this catalog's degrade shape is "fall back to the common
/// case", and the common case is 331 of the 430 rows.
/// Whether the model may wear the `$BTH` breath effects: `CreatureModelData.Flags & 0x2`
/// suppresses them (`0x600003`, the row at `[unit+0xb3c]`); an unknown display breathes.
pub fn breathes(&self, display_id: u32) -> bool {
self.display
.get(&display_id)
@@ -343,12 +215,8 @@ impl CreatureCatalog {
.is_none_or(|m| m.flags & 0x2 == 0)
}
/// A display's **footprint decal** parameters — `CreatureModelData` fields 6..=8 through the
/// display→model chain, sizes converted to **yards** (the client's own ×(1/36) inches→yards
/// cache, byte-verified at `0x607a00` for the mounted getter `0x607920`). `None` when either
/// lookup misses, the model authors `FootprintTextureID = −1` (no prints — 133 of 430 shipped
/// rows), or the print rectangle is degenerate (40 rows carry an id over a 0×0 size). World
/// yards = these × the unit's render scale — the caller multiplies, like `collision_height`.
/// A display's footprint decal, fields 6..=8, in yards (inches × 1/36, the client's cache at
/// `0x607a00` for the getter `0x607920`); `None` for a `−1` texture or a 0×0 print.
pub fn footprint(&self, display_id: u32) -> Option<FootprintParams> {
let row = self.display.get(&display_id)?;
let model = self.models.get(&row.model_id)?;
@@ -361,11 +229,11 @@ impl CreatureCatalog {
})
}
/// Resolve an NPC display id to its model, or `None` if either DBC lookup misses.
/// Resolve an NPC display id to its model.
pub fn model(&self, display_id: u32) -> Option<CreatureModel> {
let row = self.display.get(&display_id)?;
let model = self.models.get(&row.model_id)?;
// A non-zero ExtendedDisplayInfoID that resolves to an extra row ⇒ a character-model NPC.
// A non-zero `ExtendedDisplayInfoID` with an extra row marks a character-model NPC.
let npc_appearance = (row.extended_id != 0)
.then(|| self.extra.get(&row.extended_id).cloned())
.flatten();
@@ -390,14 +258,13 @@ impl CreatureCatalog {
self.display.is_empty()
}
/// Number of character-model NPC appearance rows loaded (for logging/diagnostics). Zero here means
/// CreatureDisplayInfoExtra failed to load ⇒ humanoid NPCs stay untextured.
/// Number of appearance rows; 0 means `CreatureDisplayInfoExtra` failed to load.
pub fn extra_len(&self) -> usize {
self.extra.len()
}
}
/// CreatureModelData.dbc — 16 fields in build 5875 (no `mountHeight`). We read ID, ModelName, scale.
/// CreatureModelData.dbc: 16 fields in build 5875 (no `mountHeight`).
pub(crate) fn creature_model_data_schema() -> Schema {
let mut s = Schema::new("CreatureModelData");
for (name, ty) in [
@@ -423,7 +290,7 @@ pub(crate) fn creature_model_data_schema() -> Schema {
s
}
/// CreatureDisplayInfo.dbc — 12 fields in build 5875. We read ID, ModelID, scale, 3 skin textures.
/// CreatureDisplayInfo.dbc: 12 fields in build 5875.
pub(crate) fn creature_display_info_schema() -> Schema {
let mut s = Schema::new("CreatureDisplayInfo");
for (name, ty) in [
@@ -436,14 +303,11 @@ pub(crate) fn creature_display_info_schema() -> Schema {
("TextureVariation0", FieldType::String),
("TextureVariation1", FieldType::String),
("TextureVariation2", FieldType::String),
// **NOT `PortraitTextureName`** — 5875 has no such column, and reading field 9 as a
// string would resolve a size class into the string block. The client reads it as a
// signed int at `+0x24` and compares it to `-1` (`0x625509`); the shipped column takes
// exactly `{-1, 0, 1, 2, 3, 4}` across all 10 534 rows, `-1` on 1 791 of them.
// Not `PortraitTextureName` (absent in 5875): a signed size class, tested for -1 at
// `0x625509`.
("SizeClass", FieldType::UInt32),
("BloodLevel", FieldType::UInt32),
// Labeled BloodID in some third-party maps, but the 5875 values (33..188, dense) are the
// NPC sound-kit range, not blood ids — the blood override is BloodLevel above.
// Some maps label this BloodID, but its 5875 values (33..188) are NPC sound ids.
("NPCSoundID", FieldType::UInt32),
] {
s.add_field(SchemaField::new(name, ty));
@@ -451,10 +315,7 @@ pub(crate) fn creature_display_info_schema() -> Schema {
s
}
/// CreatureDisplayInfoExtra.dbc — 19 fields in build 5875 (`19 × 4 == 76`-byte records; field map
/// cross-checked with vmangos `DBCStructure.h`). We read the appearance selectors, the 10 equipment
/// columns (8..17 — `ItemDisplayInfo` display ids for the worn armor geosets + helm/shoulder attach),
/// and the bake name.
/// CreatureDisplayInfoExtra.dbc: 19 fields, 76-byte records in 5875 (vmangos `DBCStructure.h`).
pub(crate) fn creature_display_info_extra_schema() -> Schema {
let mut s = Schema::new("CreatureDisplayInfoExtra");
for (name, ty) in [
@@ -493,11 +354,7 @@ pub fn load_creature_catalog(chain: &mut Chain) -> Result<CreatureCatalog> {
scale: f32_at(r, 4).unwrap_or(1.0),
flags: u32_at(r, 1).unwrap_or(0),
size_class: u32_at(r, 3).map_or(-1, |v| v as i32),
// BloodID (field 5) reads signed: −1 in 122 of the 430 shipped rows.
// That is a tier-2 MISS, not bloodlessness — the resolve falls through
// to the records base (1850). Kept signed so the miss is visible.
blood: u32_at(r, 5).map_or(0, |v| v as i32),
// FootprintTextureID reads signed too: −1 = no prints (see ModelRow docs).
footprint_texture: u32_at(r, 6).map_or(-1, |v| v as i32),
footprint_length: f32_at(r, 7).unwrap_or(0.0),
footprint_width: f32_at(r, 8).unwrap_or(0.0),
@@ -532,7 +389,6 @@ pub fn load_creature_catalog(chain: &mut Chain) -> Result<CreatureCatalog> {
scale: f32_at(r, 4).unwrap_or(1.0),
textures: [str_at(&rs, r, 6), str_at(&rs, r, 7), str_at(&rs, r, 8)],
blood_level: u32_at(r, 10).unwrap_or(0),
// Signed: −1 is "no override", not a size class (see the field docs).
size_class: u32_at(r, 9).map_or(-1, |v| v as i32),
model_alpha: u32_at(r, 5).unwrap_or(255),
},
@@ -542,10 +398,7 @@ pub fn load_creature_catalog(chain: &mut Chain) -> Result<CreatureCatalog> {
d
};
// CreatureDisplayInfoExtra — the character-model NPC appearance table. Best-effort: a plain beast
// catalog is still useful without it (only humanoid NPCs need it), so a load failure degrades to an
// empty map (humanoid NPCs stay untextured) rather than sinking the whole catalog. The caller logs
// `extra_len()` so a `0` (unexpected — it ships in patch.MPQ) is visible.
// Best-effort: without it only humanoid NPCs lose their skins; the caller logs `extra_len()`.
let extra = load_creature_display_info_extra(chain).unwrap_or_default();
Ok(CreatureCatalog {
@@ -555,7 +408,7 @@ pub fn load_creature_catalog(chain: &mut Chain) -> Result<CreatureCatalog> {
})
}
/// Load CreatureDisplayInfoExtra.dbc → `extendedDisplayInfoId` → [`NpcAppearance`].
/// Load CreatureDisplayInfoExtra.dbc: `extendedDisplayInfoId` → [`NpcAppearance`].
fn load_creature_display_info_extra(chain: &mut Chain) -> Result<HashMap<u32, NpcAppearance>> {
let bytes = chain
.read_file(CREATURE_DISPLAY_INFO_EXTRA)
@@ -578,10 +431,7 @@ fn load_creature_display_info_extra(chain: &mut Chain) -> Result<HashMap<u32, Np
hair_style: u32_at(r, 5).unwrap_or(0) as u8,
hair_color: u32_at(r, 6).unwrap_or(0) as u8,
facial_hair: u32_at(r, 7).unwrap_or(0) as u8,
// fields 8..17 — the ten worn-equipment ItemDisplayInfo display ids, bodyslot-indexed
// (0 head · 1 shoulder · 2 shirt … 9 tabard); `0` = the slot is empty.
equipment: std::array::from_fn(|i| u32_at(r, 8 + i).unwrap_or(0)),
// field 18 — the baked body-atlas name.
bake_name: str_at(&rs, r, 18),
},
);
@@ -594,10 +444,7 @@ fn load_creature_display_info_extra(chain: &mut Chain) -> Result<HashMap<u32, Np
mod tests {
use super::*;
/// **The blood-row tier populations** over the shipped tables — the measurement behind 1850.
/// `CreatureModelData.BloodID = −1` (122 of 430 models) is a tier-2 *miss*, not a bloodless
/// marker, so those displays fall through to the reference's tier-3 records base and bleed
/// RED. benilla read `−1` as bloodless and dropped the spurt on all 595 of them.
/// `BloodID = −1` is a tier-2 miss, so 595 displays reach tier 3 and bleed red.
#[test]
fn blood_row_tiers_over_the_shipped_displays() {
let data = crate::wow_data_or_skip!();
@@ -605,8 +452,7 @@ mod tests {
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
let blood = crate::load_blood_catalog(&mut chain).expect("blood tables");
// A tier resolves iff its id names a real UnitBloodLevels row — which is exactly what
// `level_key` reports when the *other* tier is forced to miss.
// A tier resolves iff `level_key` finds its id with the other tier forced to miss.
let resolves = |v: i32| {
u32::try_from(v)
.ok()
@@ -633,18 +479,7 @@ mod tests {
assert_eq!(tiers.iter().sum::<usize>(), cat.display.len());
}
/// **Tier 3 is ordinary fauna, not an exotic tail** — the content proof behind 1859, and the
/// reason the records-base fallback cannot mean "no blood".
///
/// Read the tier-3 population by its oddest members — elementals, skeletons, mecha-striders —
/// and the natural conclusion is that `BloodID = −1` marks a bloodless creature and the
/// fallback is a misread of the disassembly. The population says otherwise: it is *headed* by
/// Quilboar (42 displays), Mountain Giants (30), Crocolisks (27), Gnolls (25), Nagas (24) and
/// Trolls (17). A fallback that resolved to "no blood" would leave Razorfen, every gnoll camp
/// and every Stranglethorn troll bloodless — which is not the game anyone played. That is a
/// proof from shipped content, independent of any instruction decode, so the creatures are
/// named here: a future round that "simplifies" `level_key` back to two tiers fails saying
/// *which creature stopped bleeding*, not merely that a count moved.
/// Tier 3 is ordinary fauna, so its records base cannot mean no blood.
#[test]
fn the_tier_three_fallback_bleeds_red_on_ordinary_creatures() {
let data = crate::wow_data_or_skip!();
@@ -652,9 +487,7 @@ mod tests {
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
let blood = crate::load_blood_catalog(&mut chain).expect("blood tables");
// Each authors `BloodID = −1` on a single, uniquely-pathed model row, and none of their
// displays carries a `BloodLevel` override — so every one reaches the resolve by tier 3
// alone, with no other tier able to account for the result.
// Each has `BloodID = −1` and no display override: tier 3 alone resolves it.
for path in [
r"Creature\Quillboar\QuillBoar.mdx",
r"Creature\Crocodile\Crocodile.mdx",
@@ -686,7 +519,7 @@ mod tests {
assert!(seen > 0, "{path} is missing from the shipped display table");
}
// …and the row it lands on really draws — red, both facings, both sizes.
// The records-base row draws, both facings and both sizes.
for (front, large) in [(true, false), (true, true), (false, false), (false, true)] {
assert!(
blood.effect_id(1, 2, front, large).is_some(),
@@ -695,15 +528,8 @@ mod tests {
}
}
/// **`BloodID = −1` is unfilled data, not a "bloodless" marker** — the evidence that closes
/// the question 1850 left open (1859).
///
/// Nine shipped models appear under **two** `CreatureModelData` rows for the same art and the
/// twins *disagree* about blood; in eight of the nine, one side of the disagreement is `−1`
/// (the ninth, FelBat, splits 1 vs 2). A Baby Murloc is a Baby
/// Murloc. If `−1` meant "this creature does not bleed", the same creature would bleed or not
/// depending on which of its two rows a display happened to name — so `−1` is an unspecified
/// value, and the reference's tier-3 records base is precisely the handler for one.
/// Nine models have two `CreatureModelData` rows that disagree about blood, eight with one side
/// `−1`: the value is unfilled, which tier 3 handles.
#[test]
fn the_minus_one_blood_id_is_unfilled_data() {
let data = crate::wow_data_or_skip!();
@@ -733,9 +559,7 @@ mod tests {
"the Baby Murloc pair is the clearest case and must be among them: {split:?}"
);
// The same unfilled field, one level up: within a single creature family one model says
// −1 and its siblings name a colour. A "bloodless" reading would put bleeding quilboar
// warriors next to bloodless quilboar in the same Razorfen room.
// Within one creature family, one model says −1 and its siblings name a colour.
let blood_of = |needle: &str| {
cat.models
.values()
@@ -764,11 +588,7 @@ mod tests {
}
}
/// The footprint accessor on the **real** build-5875 DBCs: a display wearing the HumanMale
/// body resolves the Base boot print (`FootprintTextures` id 1) at the authored 12×10 inches
/// → 1/3 × 5/18 yards (the client's ×1/36 cache conversion, byte-verified at `0x607a00`);
/// a display whose model authors `FootprintTextureID = −1` resolves `None`. Guards the
/// schema columns (a shifted field would misread every print) and the inches→yards space.
/// HumanMale's Base boot print (id 1) is 12×10 inches, 1/3 × 5/18 yd; a `−1` model has none.
#[test]
fn footprints_resolve_on_real_data() {
let data = crate::wow_data_or_skip!();
@@ -789,7 +609,6 @@ mod tests {
assert_eq!(p.texture_id, 1, "the Base boot print");
assert!((p.length - 12.0 / 36.0).abs() < 1e-6, "length {}", p.length);
assert!((p.width - 10.0 / 36.0).abs() < 1e-6, "width {}", p.width);
// A −1 model (133 shipped rows) yields no print params through any display over it.
let printless = cat
.display
.iter()
@@ -803,11 +622,6 @@ mod tests {
assert_eq!(cat.footprint(printless), None);
}
/// End-to-end on the **real** build-5875 DBCs: the `ExtendedDisplayInfoID` chain resolves
/// character-model NPCs (guards/townsfolk) to a CreatureDisplayInfoExtra appearance whose body is a
/// `Character\` M2 skinned by a pre-baked atlas that actually ships under `Textures\BakedNpcTextures\`.
/// Guards the extra schema (a shifted column would misread the bake name), the display→extra join, and
/// the baked-texture-path convention. Skips when the client data isn't present.
#[test]
fn character_model_npcs_resolve_a_shipped_baked_atlas() {
let data = crate::wow_data_or_skip!();
@@ -819,8 +633,6 @@ mod tests {
cat.extra_len()
);
// Every display that resolves both an appearance and a bake name is definitively a character
// model — confirm the body path + that its baked atlas is a real, readable file.
let mut verified = 0;
for (&disp, row) in &cat.display {
if row.extended_id == 0 {
@@ -851,25 +663,7 @@ mod tests {
);
}
/// The worn-equipment columns (fields 8..17) decode in bodyslot order, anchored on live
/// server-truth: the **Stormwind City Guard** (display 3167) carries a plate helm (slot 0), a
/// pauldron pair (slot 1), and boot/glove/tabard geosets (slots 6/8/9), with empty chest/wrist
/// (slots 3/7). These ids are the real `CreatureDisplayInfoExtra` values read off the build-5875
/// DBC; a shifted column or a wrong field offset would misread them. Skips without the client data.
/// **The size-class column, pinned against the shipped client.** The death-thud sound
/// (`benilla-app`'s `sound::death_thud`) picks its sample on this axis alone, so a shifted
/// field would re-size the whole world's body-falls silently.
///
/// The three arms of `0x625500`, each on a row that exercises it:
/// - the **display override** wins — display 792 is a Gorilla (model 136, class `0` Small)
/// overridden to `3` Giant, which is the whole reason the override exists;
/// - `−1` **defers to the model** — display 170 is a Sea Giant with no override and the
/// model's `4` Colossal;
/// - and an ordinary agreeing row (the Ancient Protector, `3` Giant on both).
///
/// The control is the human male at `1` Medium: a schema shift onto a neighbouring column
/// would have to keep *all four* of these, and the string-block column that used to be
/// misread here (field 9 was labelled `PortraitTextureName`) cannot.
/// The three arms of `0x625500`: an override, a `−1` deferral and an agreeing row.
#[test]
fn the_size_class_column_resolves_its_three_arms() {
let data = crate::wow_data_or_skip!();
@@ -894,9 +688,7 @@ mod tests {
assert_eq!(cat.size_class(49), Some(1), "HumanMale is Medium");
assert_eq!(cat.size_class(0), None, "no such display");
// Every shipped display resolves, and inside the reference's own `0..=4` bound — so its
// unsigned `>= 5` gate never fires on this data, and no body-fall is silent for want of
// a size.
// Every display lands in `0..=4`, so the reference's unsigned `>= 5` gate never fires here.
let mut n = 0;
for &id in cat.display.keys() {
let class = cat
@@ -911,15 +703,7 @@ mod tests {
assert_eq!(n, 10_534, "the whole shipped display table");
}
/// **The shake columns, pinned against the shipped client** (B298, decision 1540). Fields 11
/// and 12 are `CameraShakes.dbc` row ids, and the evidence that the map is right is not that
/// the names look plausible — it is that the census is *semantic*: 25 of 430 rows carry a
/// footstep shake and every one of them is a creature heavy enough to shake a camera, the
/// amplitude ranks by mass, and nothing dangles.
///
/// The Ancient Protector is the reported row (Dolanaar's tree guardians, B298); the human male
/// is the control that must stay zero, since a schema shift would smear a neighbouring column
/// into these and give *everything* a shake.
/// Fields 11 and 12 are `CameraShakes.dbc` ids; 25 models, all heavy, carry a footstep shake.
#[test]
fn the_footstep_shake_columns_are_the_thumping_giants() {
let data = crate::wow_data_or_skip!();
@@ -937,20 +721,18 @@ mod tests {
Some(11),
"Ancient Protector thud"
);
// Display 1460 is Onu's Ancient of Lore (model 187) — the heavier row 2.
// Display 1460 is Onu's Ancient of Lore (model 187), the heavier row 2.
assert_eq!(
cat.footstep_shake(1460),
Some(2),
"Ancient of Lore footstep"
);
// The control: a player body shakes nothing, at either column. If a schema shift walked
// these indices onto a neighbour, this is the assert that catches it.
// The control: a player body shakes nothing in either column.
assert_eq!(cat.footstep_shake(49), None, "HumanMale leaves no thump");
assert_eq!(cat.death_thud_shake(49), None, "nor a thud");
// The census, and the property that licenses the whole column map: every id a creature
// names must land on a real row of the 24-row table.
// Every id a creature names lands on a row of the 24-row table.
let shakes = crate::load_camera_shakes(&mut chain).expect("load CameraShakes.dbc");
let mut footstep = 0;
for (_, path, foot, thud) in cat.shaking_models() {
@@ -976,7 +758,7 @@ mod tests {
let npc = guard
.npc_appearance
.expect("display 3167 is a character-model NPC with an appearance row");
// 0 head · 1 shoulder · 2 shirt · 3 chest · 4 belt · 5 pants · 6 boots · 7 wrist · 8 gloves · 9 tabard.
// Body-slot order: head, shoulder, shirt, chest, belt, pants, boots, wrist, gloves, tabard.
assert_eq!(
npc.equipment,
[14964, 7541, 7223, 0, 7224, 7225, 7255, 0, 7698, 6255],
@@ -984,21 +766,6 @@ mod tests {
);
}
/// **The column's space, pinned.** `CreatureModelData.collisionHeight` is not a derived or
/// hand-authored number: it is a verbatim copy of the model's own MD20 **collision-box** Z
/// extent, in raw model units. Asserted for all thirteen player-race body models against the
/// shipped client — which is what licenses [`CreatureCatalog::collision_height`]'s contract
/// that world yards = column × render scale (it is pre-scale, exactly like the geometry it
/// bounds), and pins field index 15 so a schema shift can't silently return garbage.
///
/// Tauren Female is the load-bearing row: `modelScale` 1.25 with a 2.111 column. If the column
/// were authored post-`modelScale` the box would read 2.111/1.25 = 1.689, so this row alone
/// refutes the "divide the model scale out" reading (which is what vmangos's server-side
/// `Unit::UpdateModelData` does).
/// `CreatureModelData.Flags & 0x2` — the `$BTH` suppression (B233, decision 1149). The census
/// on the shipped table is 99 of 430 rows, and the split is semantic, not arbitrary: the
/// things with no breath to see. **Every player row passes** (they carry `0x4`), which is what
/// makes the flag safe to gate the reported case on.
#[test]
fn the_breathless_models_are_the_ones_with_no_breath() {
let data = crate::wow_data_or_skip!();
@@ -1011,7 +778,6 @@ mod tests {
"the shipped census: 99 of {} models suppress breath",
cat.models.len()
);
// Every playable race's own display breathes — the reported case.
for (display_id, label) in [
(49, "HumanMale"),
(50, "HumanFemale"),
@@ -1021,7 +787,6 @@ mod tests {
] {
assert!(cat.breathes(display_id), "{label} breathes");
}
// …and the breathless: a skeleton, a water elemental, an infernal.
for (display_id, label) in [
(158, "Skeleton"),
(110, "WaterElemental"),
@@ -1035,16 +800,7 @@ mod tests {
);
}
/// **The creature foley is dead data in 5875.** Every `CreatureModelData` row ships
/// `FoleyMaterialID = 0`, so `0x623610`'s branch resolves to silence for every NPC in the
/// game and the armor rustle is the player override's alone. Pinned as a test rather than a
/// comment because it is a *negative* that a future reader will otherwise re-derive by
/// wondering why NPCs are quiet — and because a shipped file that ever grows a nonzero here
/// should make this fail loudly rather than change the soundscape silently.
///
/// The row alignment this rests on is checked in the same pass: field 11 (footstep shake)
/// is nonzero on exactly the 25 thumping-giant rows [`ModelRow`] documents, which would not
/// hold if the schema had slipped a column. Skips without client data.
/// Every shipped model has `FoleyMaterialID` 0; a data file that grows one fails here.
#[test]
fn no_shipped_model_carries_a_foley() {
let data = crate::wow_data_or_skip!();
@@ -1063,8 +819,7 @@ mod tests {
"shipped data grew a creature foley material: models {foleyed:?}"
);
// The alignment guard: the neighbouring column is NOT uniformly zero, so a zero at
// field 10 is the data's own answer and not a schema that slid.
// Field 11 is not all zero, so the zero at field 10 is the data, not a slid schema.
let shakers = cat
.models
.values()
@@ -1079,8 +834,7 @@ mod tests {
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
// (display id, label, the column's expected value). Display ids are the ChrRaces
// Male/FemaleDisplayId columns; Goblin shares one display across both sexes.
// (display id, label, expected column); ids from ChrRaces Male/FemaleDisplayId.
let races: &[(u32, &str, f32)] = &[
(49, "HumanMale", 2.031),
(50, "HumanFemale", 1.913),
@@ -1123,7 +877,7 @@ mod tests {
);
}
// Not vacuous: the races must actually differ, or "one constant for everyone" would pass.
// Not vacuous: the races differ.
let gnome = cat.collision_height(1564).unwrap();
let nelf = cat.collision_height(55).unwrap();
assert!(
@@ -1132,15 +886,8 @@ mod tests {
);
}
/// **Why the collision-prism FLOOR is invisible on shipped data** — and therefore why its
/// absence hid until a server override went looking for it (B311's triage, decision 1568).
///
/// The real client's prism height is `CollisionHeight × max(SCALE_X, CreatureDisplayInfo.scale)`
/// (`0x60b312` → `0x617501`). vmangos folds `modelScale × displayScale` into `SCALE_X`, so the
/// floor can only bite where `modelScale < 1` would drag the product under the display column —
/// and **no shipped row scales below 1.0**. So on stock data `max` always picks `SCALE_X`, our
/// old `× SCALE_X` was bit-identical, and only a `creature_template.display_scale` override or
/// a shrink aura can separate the two.
/// vmangos folds both scales into `SCALE_X`, so the prism's `max(SCALE_X, display scale)` floor
/// bites only where `modelScale < 1`, and no shipped row is below 1.
#[test]
fn no_shipped_model_scales_below_one_so_the_prism_floor_is_inert_at_rest() {
let data = crate::wow_data_or_skip!();
@@ -1157,23 +904,16 @@ mod tests {
under.is_empty(),
"a sub-1 modelScale would make the floor bite at rest: {under:?}"
);
// Not vacuous: the column is really read, and really varies.
// Not vacuous: the column is read, and varies.
assert!(
cat.models.values().any(|m| m.scale > 1.0),
"some row scales above 1.0, or this is asserting on a zeroed column"
);
}
/// **The shapeshift divergence, pinned in numbers** (decision 1574). The reference derives the
/// collision prism from `UNIT_FIELD_NATIVEDISPLAYID`, so a druid in a form keeps the druid's
/// depth lines. This asserts the two readings really differ on shipped data, and by how much —
/// a doc claiming "up to 0.72 yd" is worth nothing if the DBC rows drift under it.
///
/// `h = collisionHeight × max(SCALE_X, CreatureDisplayInfo.scale)` on the row named. Player
/// `SCALE_X` starts at `modelScale × CDI.scale` and a shapeshift multiplies it by the form's
/// own factor (vmangos `GetShapeshiftDisplayInfo`: 1.0 for bear/moonkin/tree, 0.80 for
/// cat/travel/aquatic) — both live-confirmed by decision 0695's own probe (tauren bear
/// `h = 2.083 × 1.35`, tauren cat `SCALE_X 1.35 → 1.08`).
/// The prism comes from `UNIT_FIELD_NATIVEDISPLAYID`, so a druid in form keeps the druid's
/// depth lines, where the form's row would move the swim line up to 0.72 yd. A form scales
/// `SCALE_X` by 1.0 or 0.80 (vmangos `GetShapeshiftDisplayInfo`).
#[test]
fn a_shapeshift_moves_the_collision_prism_and_the_native_row_is_what_stops_it() {
let data = crate::wow_data_or_skip!();
@@ -1209,28 +949,22 @@ mod tests {
"worst swim-line divergence is {worst} yd, the doc says 0.72"
);
// The direction flips with the form, which is why this can't be waved off as a constant
// offset: a night elf cat swims too EARLY, a tauren moonkin far too LATE.
// The sign flips with the form, so this is no constant offset.
assert!(h(892, 0.80) < h(55, 0.80), "NElf cat: form row is shorter");
assert!(
h(15375, 1.35) > h(59, 1.35),
"Tauren moonkin: form row is taller"
);
// 0695's own live probe, reproduced from the DBCs: tauren bear h = 2.083 × 1.35.
// The tauren bear value seen live: h = 2.083 × 1.35.
assert!(
(h(2289, 1.35) - 2.083 * 1.35).abs() < 5e-3,
"tauren bear form-derived h should reproduce 0695's observed 2.812"
);
}
/// **The Shore Strider, pinned** (B311, decision 1568). The reported giant's own chain and
/// numbers, recorded so nobody re-suspects the height: display 4945 → `CreatureModelData` 35,
/// `Creature\SeaGiant\SeaGiant.mdx`, column 2.083 over a display scale of 1.75 and a
/// `modelScale` of 1.0. Its prism is `2.083 × 1.75 = 3.645` yd under **both** the old
/// `× SCALE_X` reading and the corrected `× max(SCALE_X, displayScale)` — identical to the
/// float — so the height was never why it glided. The cause was the missing
/// `UNIT_FIELD_FLAGS` enter gate; this row is the control that says so.
/// The Shore Strider (display 4945, model 35): column 2.083 × display scale 1.75 = 3.645 yd
/// with or without the floor, since its `modelScale` is 1.0.
#[test]
fn the_shore_strider_prism_is_the_same_under_both_readings() {
let data = crate::wow_data_or_skip!();
+10 -25
View File
@@ -1,8 +1,5 @@
//! Small shared helpers for our typed DBC catalog loaders (creatures, game objects).
//!
//! DBC files carry no column types, so each loader supplies a [`Schema`] (field count must match the
//! file header) and reads fields by index. Layouts are verified clean-room against build 5875 (the
//! file header's `fieldCount × 4 == recordSize`, cross-checked with wowdev.wiki).
//! Shared helpers for the typed DBC loaders. A DBC carries no column types, so each loader supplies
//! a [`Schema`] whose field count must match the file header, and reads fields by index.
use std::collections::HashMap;
use std::io::Cursor;
@@ -12,7 +9,7 @@ use benilla_dbc::{DbcParser, FieldType, Record, RecordSet, Schema, SchemaField,
use crate::Chain;
/// Parse a DBC's bytes with `schema` into an owned record set (`what` names it for error messages).
/// Parse a DBC with `schema`; `what` names the file in errors.
pub(crate) fn parse(bytes: &[u8], schema: Schema, what: &str) -> Result<RecordSet> {
let parser = DbcParser::parse(&mut Cursor::new(bytes))
.map_err(|e| anyhow!("parsing {what} header: {e}"))?;
@@ -24,7 +21,7 @@ pub(crate) fn parse(bytes: &[u8], schema: Schema, what: &str) -> Result<RecordSe
.map_err(|e| anyhow!("parsing {what} records: {e}"))
}
/// An unsigned field (accepts the int variants).
/// An unsigned field, from either int variant: the schema tag only picks the decoding.
pub(crate) fn u32_at(r: &Record, i: usize) -> Option<u32> {
match r.get_value(i)? {
Value::UInt32(v) => Some(*v),
@@ -33,7 +30,6 @@ pub(crate) fn u32_at(r: &Record, i: usize) -> Option<u32> {
}
}
/// A float field.
pub(crate) fn f32_at(r: &Record, i: usize) -> Option<f32> {
match r.get_value(i)? {
Value::Float32(v) => Some(*v),
@@ -41,9 +37,7 @@ pub(crate) fn f32_at(r: &Record, i: usize) -> Option<f32> {
}
}
/// A signed field (accepts either int variant, mirroring [`u32_at`]'s leniency: the schema tag
/// just picks how the parser decodes the same 4 raw bytes, so a column read as `UInt32` still
/// round-trips through here bit-for-bit).
/// A signed field; either int variant reads, bit for bit.
pub(crate) fn i32_at(r: &Record, i: usize) -> Option<i32> {
match r.get_value(i)? {
Value::Int32(v) => Some(*v),
@@ -52,7 +46,7 @@ pub(crate) fn i32_at(r: &Record, i: usize) -> Option<i32> {
}
}
/// A non-empty string field, resolved through the record set's string block.
/// A string field from the string block; an empty string reads as `None`.
pub(crate) fn str_at(rs: &RecordSet, r: &Record, i: usize) -> Option<String> {
match r.get_value(i)? {
Value::StringRef(StringRef(off)) => {
@@ -63,10 +57,7 @@ pub(crate) fn str_at(rs: &RecordSet, r: &Record, i: usize) -> Option<String> {
}
}
/// `SpellIcon.dbc`: id → texture path (`Interface\Icons\…`, extensionless). 1033 records × 2
/// fields (`ID(0)`, `TextureFilename(1, str)`) — verified against build 5875 (`spells.rs`'s own
/// module doc). Shared by [`crate::spells`] (the action-bar catalog) and [`crate::skill_lines`]
/// (a skill line's own tab icon) — one load, not two, since both just want id → path.
/// `SpellIcon.dbc`: id → extensionless texture path (`Interface\Icons\…`).
pub(crate) fn load_spell_icon_map(chain: &mut Chain) -> Result<HashMap<u32, String>> {
let bytes = chain
.read_file("DBFilesClient\\SpellIcon.dbc")
@@ -84,10 +75,8 @@ pub(crate) fn load_spell_icon_map(chain: &mut Chain) -> Result<HashMap<u32, Stri
Ok(icons)
}
/// A minimal `ID(0) … Name(name_col) …` schema of `cols` u32-wide columns — the shape every
/// "id → localized name" DBC has, since a string column is itself one u32 offset into the string
/// block. Enough for the id→name reads; the loc block's other 7 slots and its flag mask stay
/// anonymous `cN` columns.
/// An id → name schema of `cols` four-byte columns (a string column is one offset); the other
/// locale slots and the locale flag mask stay anonymous `cN` columns.
pub(crate) fn id_name_schema(what: &str, name_col: usize, cols: usize) -> Schema {
let mut s = Schema::new(what);
for i in 0..cols {
@@ -100,9 +89,7 @@ pub(crate) fn id_name_schema(what: &str, name_col: usize, cols: usize) -> Schema
s
}
/// Read an `ID → name` DBC through the patch chain into a map, skipping rows whose name is empty.
/// Shared by every such table ([`crate::quest_headers`]'s AreaTable/QuestSort,
/// [`crate::quest_info`]'s QuestInfo) — the layout differences are the two column numbers.
/// Read an id → name DBC into a map, skipping rows whose name is empty (a hole, not a name).
pub(crate) fn load_id_name_table(
chain: &mut Chain,
file: &str,
@@ -116,8 +103,6 @@ pub(crate) fn load_id_name_table(
let rs = parse(&bytes, id_name_schema(what, name_col, cols), what)?;
let mut out = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
// `str_at` already drops an empty string — a row whose name slot points at the string
// block's leading NUL is a hole, not a name.
if let (Some(id), Some(name)) = (u32_at(r, 0), str_at(&rs, r, name_col)) {
out.insert(id, name);
}
+22 -61
View File
@@ -1,36 +1,9 @@
//! The **death thud** — the body-fall impact a corpse makes as it lands, fired on the `$DTH`
//! animation event (`0x6236e0`, the sibling of the same event's camera shake `0x625c30`).
//!
//! It rides the **same terrain chain the footsteps do** ([`crate::FootstepCatalog`]) — the
//! surface under the unit → `TerrainType` → `TerrainType.SoundID` — and joins it against the
//! creature's **size class** rather than its footstep class:
//!
//! ```text
//! (CreatureDisplayInfo.SizeClass ?? CreatureModelData.SizeClass) × TerrainType.SoundID
//! → DeathThudLookups → SoundEntries (land | water)
//! ```
//!
//! Layouts — VERIFIED against build 5875 (headers + row decodes, 2026-09-02):
//! - `DeathThudLookups` **45 × 5 × 20 B**: `ID, SizeClass, TerrainTypeSoundID, SoundEntryID,
//! SoundEntryIDWater`.
//! - `TerrainTypeSounds` **9 × 1 × 4 B**: a bare id enum, `1..=9`, and nothing else — the axis
//! `TerrainType.SoundID` and `FootstepTerrainLookup.TerrainSoundID` are both keyed on. It is
//! parsed for its **domain**: the reference bakes this table into a per-size-class array
//! dimensioned by it and bounds-checks `terrainSoundId >= count` before the lookup
//! (`0x623771`), and the census wants the empty columns as much as the full ones.
//!
//! **The five size classes are the five audible sizes**, and their kits name themselves:
//! `0 Small · 1 Medium · 2 Large · 3 Giant · 4 Colossal` (`DeathThudSmallDirt` … through
//! `DeathThudColossalWood`, all in `Sound\Effects\DeathImpacts`, one file each). So this is
//! **not** a big-creature-only effect the way the camera shake is — every creature that carries a
//! `$DTH` key thuds, and only the *sample* scales with the body. The water column is coarser: four
//! kits (`DeathThudWaterSmall/Medium/Giant/Colossal`, `…\DeathImpacts\InWater`) shared across the
//! whole terrain axis, since a splash does not care what is under the water.
//!
//! Only five of the nine terrain sounds carry the full class sweep (Dirt 1, Stone 3, Snow 4,
//! Wood 5, Grass 6); the rest (Metallic 2, Leaves 7, Sand 8, Soggy 9) were filled in later against
//! the *same* 25 kits — Metallic borrows Stone, Leaves borrows Dirt — and those rows carry a
//! **water column of 0**, which is silence and not a fallback to land (see [`DeathThudCatalog::kit`]).
//! The death thud a corpse makes as it lands, fired on the `$DTH` animation event (`0x6236e0`,
//! beside the same event's camera shake `0x625c30`). `DeathThudLookups.dbc` joins the creature's
//! size class (0 Small to 4 Colossal; the display's, else the model's) with the footsteps' terrain
//! sound to a land and a water `SoundEntries` kit, so every creature with a `$DTH` key thuds.
//! `TerrainTypeSounds.dbc` is read for its id domain, `1..=9`, which the reference sizes its baked
//! array by and bounds-checks against (`0x623771`).
use std::collections::{BTreeSet, HashMap};
@@ -44,37 +17,32 @@ use crate::dbc::{parse, u32_at};
pub struct DeathThudCatalog {
/// `(SizeClass, TerrainTypeSoundID)` → `(land kit, water kit)`.
lookup: HashMap<(u32, u32), (u32, u32)>,
/// Every `TerrainTypeSounds.dbc` id, ascending — the lookup's terrain axis in full, including
/// the ids no `DeathThudLookups` row names.
/// Every `TerrainTypeSounds.dbc` id, including those no `DeathThudLookups` row names.
terrain_sounds: BTreeSet<u32>,
}
impl DeathThudCatalog {
/// The `(land, water)` `SoundEntries` kits for a size class landing on a terrain-sound class.
/// `None` when the pair has no row — the reference's own answer for an out-of-domain index,
/// and silence.
/// The `(land, water)` `SoundEntries` kits for a size class landing on a terrain sound; a
/// pair with no row is silent, as in the reference.
pub fn resolve(&self, size_class: u32, terrain_sound: u32) -> Option<(u32, u32)> {
self.lookup.get(&(size_class, terrain_sound)).copied()
}
/// The one kit that actually plays: the water column when the corpse is in liquid, the land
/// column otherwise. **A zero is silence, never a fallback to the other column** — the
/// reference plays whatever dword it read, and `SoundEntries` has no row 0, so the 18 rows
/// whose water column is 0 are simply mute in water.
/// The kit that plays, water in liquid and land otherwise. A `0` is silence, never the other
/// column: the reference plays the dword it read, and `SoundEntries` has no row 0.
pub fn kit(&self, size_class: u32, terrain_sound: u32, in_water: bool) -> Option<u32> {
let (land, water) = self.resolve(size_class, terrain_sound)?;
let kit = if in_water { water } else { land };
(kit != 0).then_some(kit)
}
/// Every `TerrainTypeSounds` id, ascending — the census's column headings.
/// Every `TerrainTypeSounds` id, ascending.
pub fn terrain_sounds(&self) -> impl Iterator<Item = u32> + '_ {
self.terrain_sounds.iter().copied()
}
/// Every size class a `DeathThudLookups` row names, ascending — the census's rows. The
/// shipped data is exactly `0..=4`, which is also why the reference's `sizeClass >= 5` gate
/// (`0x623744`) never fires on it.
/// Every size class a row names, ascending: `0..=4` in 5875, so the reference's
/// `sizeClass >= 5` gate (`0x623744`) never fires.
pub fn size_classes(&self) -> BTreeSet<u32> {
self.lookup.keys().map(|(sc, _)| *sc).collect()
}
@@ -137,9 +105,7 @@ pub fn load_death_thud_catalog(chain: &mut Chain) -> Result<DeathThudCatalog> {
mod tests {
use super::*;
/// The shipped 5875 tables, decoded: the full 45-row join, the 9-id terrain domain, the five
/// size classes, and the two spot-checks that pin the column order (a swapped SizeClass /
/// TerrainTypeSoundID pair would still load, and would still be 45 rows).
/// The 5875 tables; the spot rows pin the column order, as a swapped pair still counts 45.
#[test]
fn real_death_thud_chain_resolves() {
let data = crate::wow_data_or_skip!();
@@ -154,16 +120,13 @@ mod tests {
);
assert_eq!(cat.size_classes(), (0..=4).collect(), "Small..Colossal");
// Size class 0 (Small) on terrain sound 1 (Dirt) → `DeathThudSmallDirt` 907 /
// `DeathThudWaterSmall` 1266. Size class 4 (Colossal) on 6 (Grass) →
// `DeathThudColossalGrass` 928 / `DeathThudWaterColossal` 1269.
// Small on Dirt (`DeathThudSmallDirt`), Colossal on Grass (`DeathThudColossalGrass`).
assert_eq!(cat.resolve(0, 1), Some((907, 1266)));
assert_eq!(cat.resolve(4, 6), Some((928, 1269)));
assert_eq!(cat.kit(4, 6, false), Some(928));
assert_eq!(cat.kit(4, 6, true), Some(1269));
// The later-filled rows: Metallic (2) borrows the Stone kits, and its water column is 0 —
// silence in water, NOT a fall back to the land kit.
// Metallic (2) borrows the Stone kits and has no water kit: silent in water.
assert_eq!(
cat.resolve(0, 2),
Some((910, 0)),
@@ -172,15 +135,13 @@ mod tests {
assert_eq!(cat.kit(0, 2, true), None, "and nothing in water");
assert_eq!(cat.kit(0, 2, false), Some(910));
// Out of domain both ways: terrain sound 0 is `TerrainType "None"`'s SoundID and is not a
// TerrainTypeSounds row at all; size class 5 is past the table.
// Terrain sound 0 (`TerrainType` "None") and size class 5 have no rows.
assert_eq!(cat.resolve(0, 0), None);
assert_eq!(cat.resolve(5, 1), None);
}
/// Every terrain-sound id a `TerrainType` row names is a real `TerrainTypeSounds` row (or the
/// `0` of `"None"`), and every `DeathThudLookups` terrain axis value is one too — the join the
/// reference does by array index, checked as data.
/// The reference joins these tables by array index, so every terrain sound a `TerrainType` or
/// `DeathThudLookups` row names must be a `TerrainTypeSounds` row, or `0`.
#[test]
fn the_terrain_axis_agrees_across_the_three_tables() {
let data = crate::wow_data_or_skip!();
@@ -195,8 +156,8 @@ mod tests {
"DeathThudLookups names terrain sound {terrain_sound}, which TerrainTypeSounds lacks"
);
}
// `TerrainType 10 "None"` is the unauthored default and its SoundID is 0 — not a row, and
// the reason a building's floor with no material makes no thud.
// `TerrainType` 10, "None", is the unauthored default: SoundID 0, so a floor with no
// material makes no thud.
for terrain in 0..=10 {
let sound = steps
.sound_class_of(terrain)
+13 -41
View File
@@ -1,25 +1,8 @@
//! `DurabilityCosts.dbc` + `DurabilityQualities.dbc` — the client-side repair-cost tables.
//!
//! The 5875 client computes the *displayed* repair price itself (`0x5da330`, wrapped by
//! `0x4faf30`):
//!
//! ```text
//! perItem = round_nearest_even( max(1, round_half_away(
//! (maxDurability − durability)
//! · DurabilityQualities[2·Quality + 1].mult (float)
//! · DurabilityCosts[ItemLevel].column (int) ))
//! · (1.0 − vendorReputationDiscount) )
//! ```
//!
//! The `DurabilityCosts` column is picked by item class/subclass: WEAPON (class 2) reads the 21
//! weapon columns (fields 1–21, byte `0x04 + subclass·4`), ARMOR (class 4) the 8 armor columns
//! (fields 22–29, byte `0x58 + subclass·4`). Anything else costs nothing (never has durability).
//!
//! ⚠ The client's `2·Quality + 1` quality-row key differs from vmangos's `(Quality+1)·2` — the
//! server may *charge* a different amount than the client *displays* (inferred at the
//! client/server boundary, unconfirmed; a live A/B at repair bring-up settles whether the DBC rows
//! make them equivalent). We implement the client's verified key — benilla displays what 5875
//! displays.
//! `DurabilityCosts.dbc` and `DurabilityQuality.dbc`, behind the repair price the reference
//! computes itself (`0x5da330`, wrapped by `0x4faf30`): points lost × the quality row
//! `2·Quality + 1` × the item level's weapon or armor column by subclass, rounded half away from
//! zero to at least 1, then × `(1 - discount)` rounded half to even. vmangos keys the quality row
//! `(Quality + 1)·2` (`Player.cpp:4996`), so its charge may differ from the price shown.
use std::collections::HashMap;
@@ -34,16 +17,14 @@ const DURABILITY_QUALITIES: &str = "DBFilesClient\\DurabilityQuality.dbc";
/// The two repair tables, id-keyed.
pub struct DurabilityTables {
/// Row id (= item level) → the 29 per-point cost columns (21 weapon, then 8 armor).
/// Item level → the 29 per-point cost columns (21 weapon, then 8 armor).
costs: HashMap<u32, Vec<u32>>,
/// Row id → the quality multiplier (the client keys this with `2·Quality + 1`).
qualities: HashMap<u32, f32>,
}
impl DurabilityTables {
/// The per-point integer cost column for an item's class/subclass at `item_level`, or `None`
/// when the item can't be repaired (not a weapon/armor, subclass out of the column range, or
/// the level row is missing).
/// The per-point cost for a class and subclass at `item_level`, `None` when unrepairable.
fn column(&self, item_level: u32, class: u32, subclass: u32) -> Option<u32> {
let row = self.costs.get(&item_level)?;
let idx = match class {
@@ -54,10 +35,7 @@ impl DurabilityTables {
row.get(idx).copied()
}
/// The displayed repair cost for one item, in copper — the client's exact arithmetic (see the
/// module doc). `points_lost` = max − current durability; 0 lost (or an unrepairable item)
/// costs 0. `discount` is the vendor reputation discount (0.0 until benilla models
/// reputation), applied with the client's final round-to-nearest-even.
/// The displayed repair cost of one item in copper, after the vendor's reputation `discount`.
pub fn repair_cost(
&self,
points_lost: u32,
@@ -77,14 +55,12 @@ impl DurabilityTables {
return 0;
};
let base = f64::from(points_lost) * f64::from(mult) * f64::from(col);
// round_half_away for a positive value = floor(x + 0.5) (the client's ±0.5 + _ftol),
// then the max(1, …) floor.
// Half away from zero, as the reference's ±0.5 then `_ftol`, then at least 1.
let per = ((base + 0.5).floor() as i64).max(1);
// The reputation-discount multiply ends in round-to-nearest-even.
// The discount multiply rounds half to even.
let discounted = per as f64 * f64::from(1.0 - discount);
let f = discounted.floor();
let frac = discounted - f;
// Exact .5 goes to the even neighbour; anything else to the nearer.
let up = frac > 0.5 || (frac == 0.5 && (f as i64) % 2 != 0);
let rounded = if up { f + 1.0 } else { f };
rounded.max(0.0) as u32
@@ -155,16 +131,13 @@ mod tests {
#[test]
fn per_item_cost_is_the_clients_arithmetic() {
let t = tables();
// 10 points lost on a common (q1) sword at level 23: 10 · 0.6 · 4 = 24.
assert_eq!(t.repair_cost(10, 23, 1, 2, 7, 0.0), 24);
// Mail gloves, 7 points: 7 · 0.6 · 2 = 8.4 → round-half-away → 8.
// Mail gloves, 7 points: 7 · 0.6 · 2 = 8.4, rounded to 8.
assert_eq!(t.repair_cost(7, 23, 1, 4, 3, 0.0), 8);
// The max(1, …) floor: 1 point · 0.6 · … → 2.4 rounds to 2; a fractional sub-1 base
// still charges 1 (1 point on the 2/point mail with a tiny mult).
// A base under 1 still charges 1: 1 · 0.1 · 2 = 0.2.
let mut t2 = tables();
t2.qualities.insert(3, 0.1);
assert_eq!(t2.repair_cost(1, 23, 1, 4, 3, 0.0), 1, "min 1 copper");
// Undamaged or unrepairable: free.
assert_eq!(t.repair_cost(0, 23, 1, 2, 7, 0.0), 0);
assert_eq!(t.repair_cost(10, 23, 1, 15, 0, 0.0), 0, "not weapon/armor");
assert_eq!(t.repair_cost(10, 99, 1, 2, 7, 0.0), 0, "no level row");
@@ -177,8 +150,7 @@ mod tests {
let mut row = vec![0u32; 29];
row[7] = 1; // 1 copper/point so points == the pre-discount price
t.costs.insert(23, row);
// A 50% discount (exactly representable): 41 → 20.5, the tie rounds to EVEN 20;
// 43 → 21.5 → 22; 42 → 21.0, no tie.
// Half off: 20.5 rounds to 20 and 21.5 to 22; 21.0 is no tie.
assert_eq!(t.repair_cost(41, 23, 1, 2, 7, 0.5), 20);
assert_eq!(t.repair_cost(43, 23, 1, 2, 7, 0.5), 22);
assert_eq!(t.repair_cost(42, 23, 1, 2, 7, 0.5), 21);
+30 -59
View File
@@ -1,22 +1,11 @@
//! `TransportAnimation.dbc` — the type-11 TRANSPORT (elevator/lift/tram) keyframe paths, and the
//! client's own cycle evaluator. Pure math, no Bevy, **WoW coordinates throughout**.
//!
//! A type-11 GameObject never streams a position: the server sends one anchor (the create
//! movement block's `UPDATE_FLAG_TRANSPORT` u32, its `time-since-GO-create % period` clock) and
//! the client animates the car itself — `target = (anchor + local_elapsed) % period`, bracket the
//! two keyframes around `target`, lerp their **local offsets**, rotate the offset by the spawn's
//! `GAMEOBJECT_ROTATION` quaternion, and add the stationary spawn position. The mechanism is
//! the client's path evaluator `0x5f6280` + the type-11 tick `0x5f5f10`; vmangos's
//! `ElevatorTransport::Update` (`Transport.cpp:396-437`) is the same math on the server side,
//! which is what keeps the two in sync. This module implements the *mechanism* idiomatically
//! (f32, binary-search bracket), not the reference's x87 spill pattern.
//!
//! **7 fields:** `ID(0), TransportID(1), TimeIndex(2), PosX(3), PosY(4), PosZ(5), SequenceID(6)`
//! — `TransportID` is the **gameobject_template entry** (not a display or path id), `TimeIndex`
//! is cumulative ms, and the last frame's `TimeIndex` IS the cycle period (`0x5f6280` reads
//! `segments[count-1].time` as its modulus; vmangos `TotalTime` likewise). `SequenceID` names the
//! car M2's animation per span (162 moving / 164 stationary on the live rows) — not consumed
//! here (deferred polish; the car still renders its idle).
//! `TransportAnimation.dbc`, the keyframe paths of type-11 transports (elevators), and the
//! reference's cycle evaluator over them, in WoW coordinates. The server sends one anchor, the
//! create block's `UPDATE_FLAG_TRANSPORT` clock (time since creation, modulo the period), and the
//! client animates the car: bracket the keyframes around `(anchor + elapsed) % period`, lerp their
//! offsets, rotate by the spawn's `GAMEOBJECT_ROTATION` and add the spawn position (`0x5f6280`,
//! ticked by `0x5f5f10`), as vmangos's `ElevatorTransport::Update` does (`Transport.cpp:396-435`).
//! `TransportID` is the `gameobject_template` entry; `SequenceID`, the car's animation per span, is
//! not read.
use std::collections::HashMap;
@@ -28,29 +17,27 @@ use crate::Chain;
const TRANSPORT_ANIMATION: &str = "DBFilesClient\\TransportAnimation.dbc";
/// One `TransportAnimation.dbc` row — a keyframe on a type-11 transport's authored local path.
/// One `TransportAnimation.dbc` row, a keyframe on a type-11 transport's local path.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ElevatorKeyframe {
/// Cumulative cycle time, ms. The path's last frame's `time_ms` is the cycle period.
/// Cumulative cycle time in ms; the last frame's is the period.
pub time_ms: u32,
/// Local offset from the spawn point (WoW axes), **before** the spawn-rotation is applied.
/// Offset from the spawn point in WoW axes, before the spawn rotation.
pub pos: [f32; 3],
}
/// `TransportAnimation.dbc` grouped by `TransportID` (= `gameobject_template` entry), each
/// path's frames sorted by `TimeIndex`.
/// `TransportAnimation.dbc` grouped by `TransportID`, each path's frames sorted by `TimeIndex`.
pub struct ElevatorPaths {
by_entry: HashMap<u32, Vec<ElevatorKeyframe>>,
}
impl ElevatorPaths {
/// A template entry's keyframes in time order, or `None` for a GO with no authored path.
/// A returned slice is never empty and always starts at `time_ms == 0` (load guarantees).
/// A template entry's keyframes in time order: at least two, the first at `time_ms == 0`.
pub fn entry(&self, template_entry: u32) -> Option<&[ElevatorKeyframe]> {
self.by_entry.get(&template_entry).map(Vec::as_slice)
}
/// Number of distinct transport entries carrying a path.
/// The number of transport entries with a path.
pub fn len(&self) -> usize {
self.by_entry.len()
}
@@ -60,7 +47,6 @@ impl ElevatorPaths {
}
}
/// 7 fields per the module doc.
fn schema() -> Schema {
let mut s = Schema::new("TransportAnimation");
for name in ["ID", "TransportID", "TimeIndex"] {
@@ -73,10 +59,9 @@ fn schema() -> Schema {
s
}
/// Read `TransportAnimation.dbc` off the patch chain into an [`ElevatorPaths`]. Paths that could
/// not drive a cycle (fewer than 2 frames, a zero period, or a first frame not at `t=0`) are
/// dropped with the same silence the client affords them — a GO whose entry isn't here simply
/// renders frozen at its spawn point.
/// Read `TransportAnimation.dbc` off the patch chain, silently dropping, as the reference does, a
/// path that cannot drive a cycle (under 2 frames, a zero period, a first frame past 0); its GO
/// stays at its spawn point.
pub fn load_elevator_paths(chain: &mut Chain) -> Result<ElevatorPaths> {
let bytes = chain
.read_file(TRANSPORT_ANIMATION)
@@ -103,21 +88,16 @@ pub fn load_elevator_paths(chain: &mut Chain) -> Result<ElevatorPaths> {
Ok(ElevatorPaths { by_entry })
}
/// The path's cycle period, ms — the last keyframe's cumulative time (`0x5f6280`'s modulus).
/// The cycle period in ms, the last keyframe's time (`0x5f6280`'s modulus).
pub fn elevator_period_ms(frames: &[ElevatorKeyframe]) -> u32 {
frames.last().map_or(1, |f| f.time_ms).max(1)
}
/// The client's type-11 cycle evaluator (`0x5f6280`, mechanism form):
/// world position of the car at `cycle_ms ∈ [0, period)`, given the spawn's stationary position
/// and its `GAMEOBJECT_ROTATION` quaternion `(x, y, z, w)`. Also reports whether the bracketing
/// span is in motion (the dock/depart edge for consumers' instruments).
///
/// The rotation is a plain quaternion rotation of the local offset — the binary builds the
/// standard 3×3 of `q` row-major and combines transposed, which IS `R(q)·d`. vmangos reproduces
/// it as `(d * q)` + a y sign flip
/// (`Transport.cpp:426-428` "magical sign flip but it works"); the two agree on every live 1.12
/// row with `x = y = 0` offsets and on pure-yaw spawn quats — the tests pin one worked case.
/// The reference's type-11 evaluator (`0x5f6280`): the car's world position at `cycle_ms`, from
/// the spawn position and its `GAMEOBJECT_ROTATION` quaternion `(x, y, z, w)`, and whether the
/// bracketing span moves. The offset turns by `R(q)`, as the reference's transposed 3×3 does;
/// vmangos's `d * q` with a y sign flip (`Transport.cpp:425-426`) agrees on the 1.12 data's
/// vertical paths and pure-yaw spawns.
pub fn elevator_sample(
frames: &[ElevatorKeyframe],
spawn_pos: [f32; 3],
@@ -128,8 +108,7 @@ pub fn elevator_sample(
let period = elevator_period_ms(frames);
let target = cycle_ms % period;
// Bracket: the last frame with `time_ms <= target` and its successor. `partition_point` on
// the sorted times replaces the binary's ring-cursor scan (same bracket, stateless).
// The reference's bracket, found by search rather than its ring cursor.
let hi = frames.partition_point(|f| f.time_ms <= target);
let (prev, next) = (frames[hi - 1], frames[hi.min(frames.len() - 1)]);
@@ -155,7 +134,7 @@ pub fn elevator_sample(
)
}
/// `R(q)·v` — rotate a vector by a unit quaternion `(x, y, z, w)`.
/// `R(q)·v` for a unit quaternion `(x, y, z, w)`.
fn rotate_by_quat(v: [f32; 3], q: [f32; 4]) -> [f32; 3] {
let [x, y, z, w] = q;
// t = 2·(q.xyz × v); v' = v + w·t + q.xyz × t
@@ -202,26 +181,22 @@ mod tests {
fn dwell_then_lerp_then_wrap() {
let f = frames();
let base = [100.0, 200.0, 50.0];
// Dwell window: both bracketing frames at the same offset -> parked, not moving.
let (p, moving) = elevator_sample(&f, base, IDENT, 2500);
assert_eq!(p, base);
assert!(!moving);
// Mid-descent: t=7500 is halfway through the 5000->10000 span -> z offset -5.
// 7500 is halfway through the 5000 to 10000 span: z offset -5.
let (p, moving) = elevator_sample(&f, base, IDENT, 7500);
assert_eq!(p, [100.0, 200.0, 45.0]);
assert!(moving);
// The cycle wraps at period (30000): cycle 30000 IS cycle 0.
let (p0, _) = elevator_sample(&f, base, IDENT, 0);
let (pw, _) = elevator_sample(&f, base, IDENT, 30000);
assert_eq!(p0, pw);
// Just before wrap: nearly back at the spawn offset.
let (p, moving) = elevator_sample(&f, base, IDENT, 29999);
assert!(moving);
assert!((p[2] - 50.0).abs() < 0.01, "z = {}", p[2]);
}
/// One worked rotation case: a pure-yaw spawn quat (the only kind on live 1.12 spawn rows)
/// turning a lateral offset. q = 90° about +Z: (x, y) -> (-y, x).
/// A 90° yaw (1.12 transport spawns only yaw) turns `(x, y)` into `(-y, x)`.
#[test]
fn spawn_yaw_rotates_the_local_offset() {
let half = std::f32::consts::FRAC_PI_4; // 90°/2
@@ -243,22 +218,19 @@ mod tests {
);
}
/// The real 5875 table: the Mesa Elevator pair + the Undercity elevator prove layout and the
/// load guarantees (sorted, t0 = 0, period = last frame). Skips without client data.
/// The 5875 table: layout and load guarantees on three real paths.
#[test]
fn real_transport_animation_layout_sanity() {
let data = crate::wow_data_or_skip!();
let mut chain = Chain::open(&data).expect("open patch chain");
let paths = load_elevator_paths(&mut chain).expect("load TransportAnimation.dbc");
// The two Thunder Bluff Mesa Elevator cars (gameobject_template 4170/4171) and the
// Undercity elevator (152614) — verified against the extracted table this session.
// Thunder Bluff's Mesa Elevator cars are templates 4170 and 4171, Undercity's 152614.
let top = paths.entry(4170).expect("Mesa Elevator 4170");
assert_eq!(elevator_period_ms(top), 30033);
assert_eq!(top[0].time_ms, 0);
assert_eq!(top[0].pos, [0.0, 0.0, 0.0]);
assert!(top.windows(2).all(|w| w[0].time_ms <= w[1].time_ms));
// The car travels on z only — x/y are authoring noise at ~2e-6 yd (the rotation
// question is moot for pure-vertical paths). Full descent: 61.24 yd.
// The car moves on z only (x and y are ~2e-6 yd of noise), 61.24 yd down.
assert!(top
.iter()
.all(|f| f.pos[0].abs() < 1e-3 && f.pos[1].abs() < 1e-3));
@@ -266,7 +238,6 @@ mod tests {
let bottom = paths.entry(4171).expect("Mesa Elevator 4171");
assert_eq!(elevator_period_ms(bottom), 30000);
assert!(paths.entry(152614).is_some(), "Undercity elevator");
// A no-path entry answers None (999999 is no template).
assert!(paths.entry(999_999).is_none());
}
}
+56 -132
View File
@@ -1,50 +1,36 @@
//! Per-sequence **particle emission timing** — the key-window bake (`0x713d50`) of the emitter's
//! per-frame-sampled M2Tracks: spawn rate `+0xdc` + enabled gate `+0x1dc` ([`EmitTiming`]) and the
//! other nine emission parameters ([`EmitParams`]), one loop per FILE sequence slot. Split from
//! [`crate::particles`] (the raw record parse) because it is the *runtime sampling* face:
//! decision 0641's material-alpha structure, one channel over.
//! Particle emitter tracks sampled per sequence: the spawn rate (`+0xdc`) and enabled gate
//! (`+0x1dc`) in [`EmitTiming`], the other nine parameters in [`EmitParams`], each baked one loop
//! per file sequence slot by the reference's key-window kernel (`0x713d50`).
use benilla_m2::M2ScalarTrack;
use crate::models::{bake_track, ScalarAnim, SeqSlot};
/// Per-sequence **emission timing**: the emitter's two per-frame-sampled M2Tracks — spawn rate
/// (`+0xdc`) and the enabled gate (`+0x1dc`) — baked one loop per FILE sequence slot through the
/// key-window kernel `0x713d50` ([`crate::models::bake_track`]), exactly the material-alpha
/// structure of decision 0641 one channel over. The emitter phase of the reference's animate
/// kernel `0x714260` samples both through the **playing** sequence's key window every frame and
/// forces the spawn rate to 0 while the gate is off (`0x717d90`/`0x718f32`).
///
/// The clock law rides the **baked loop** ([`crate::models::KeyAnim::wrap`]), decided from the
/// slot at bake time: a **looping** sequence wraps its band (`t mod period` — a windowed gate
/// re-fires every pass), a **clamped** one parks at the band end and holds the tail value (never
/// aliasing back to the band start), and a gseq-tagged track always wraps on its own free clock
/// whatever the playing sequence does. Consumers resolve `seq` from whatever clock they run: a
/// placed doodad passes `None` (slot 0 — its one-time arm), an effect its armed slot, a
/// unit/GameObject its live playing sequence.
/// The spawn rate and enabled gate per file sequence slot. The reference's animate kernel
/// (`0x714260`) samples both through the playing sequence's key window every frame, and the rate
/// is 0 while the gate is off (`0x717d90`, `0x718f32`). A looping slot wraps its band, a clamped
/// one holds its tail, a gseq-tagged track runs on its own clock; `None` or an unknown `seq` is
/// slot 0.
#[derive(Debug, Clone, Default)]
pub struct EmitTiming {
/// Baked rate loop per file slot; `None` = the track keys nothing there (spawn rate 0).
/// Per file slot; `None` where the track keys nothing (rate 0).
rate: Vec<Option<ScalarAnim>>,
/// Baked gate per file slot (step, 0/1); `None` = no gate authored — the loader default is
/// ON (`0x710092`: `block+0x14c = 1` for every emitter).
/// Per file slot, stepped 0 or 1; `None` is on, the loader default (`0x710092` sets
/// `block+0x14c = 1`).
enabled: Vec<Option<ScalarAnim>>,
/// Per file slot: sequence flags bit 0 CLEAR = the band loops. Carried for [`Self::idx`]'s
/// slot count and the dump instruments' view — the *sampling* clock is the baked loop's own.
/// Per file slot: the band loops when sequence flags bit 0 is clear.
looping: Vec<bool>,
}
impl EmitTiming {
/// Bake both tracks against every file sequence slot; `gseq` is the global-sequence duration
/// table. Each slot carries its own loop flag, which becomes the baked loop's clock.
/// Bake both tracks per file sequence slot; `gseq` is the global-sequence duration table.
pub(crate) fn bake(
rate: &M2ScalarTrack,
enabled: &M2ScalarTrack,
slots: &[SeqSlot],
gseq: &[u32],
) -> Self {
// Keep every baked shape, constants included — this channel has no static fallback path,
// so a held value must survive the bake (`|_| false` on both predicates).
// Keep every shape, constants included (`|_| false` twice): there is no static fallback.
let per_slot = |t: &M2ScalarTrack| -> Vec<Option<ScalarAnim>> {
slots
.iter()
@@ -58,8 +44,6 @@ impl EmitTiming {
}
}
/// Resolve a consumer's sequence to a file slot: out-of-range / unknown degrades to slot 0
/// (the doodad lane's one-time arm, and the old single-band behaviour).
fn idx(&self, seq: Option<usize>) -> usize {
match seq {
Some(i) if i < self.looping.len() => i,
@@ -67,9 +51,8 @@ impl EmitTiming {
}
}
/// Is the gate ON, `elapsed` seconds into sequence slot `seq`? A slot with no baked gate is
/// ON (the loader default). `shared_now` is the world's shared elapsed seconds — a
/// gseq-tagged gate routes to it ([`crate::models::KeyAnim::clock`]).
/// Whether the gate is on `elapsed` seconds into slot `seq`; `shared_now`, the world clock in
/// seconds, drives a gseq-tagged gate.
pub fn emitting(&self, seq: Option<usize>, elapsed: f32, shared_now: f64) -> bool {
self.enabled
.get(self.idx(seq))
@@ -77,9 +60,8 @@ impl EmitTiming {
.is_none_or(|a| a.sample_or(a.clock(elapsed, shared_now), 1.0) > 0.5)
}
/// The spawn rate (particles/sec), `elapsed` seconds into sequence slot `seq`. A slot with no
/// baked rate spawns nothing. Floored at 0 (a track tail may legitimately go negative).
/// `shared_now` routes a gseq-tagged rate loop to the shared world clock.
/// Particles per second `elapsed` seconds into slot `seq`, floored at 0 as a track tail can go
/// negative; `shared_now` drives a gseq-tagged rate.
pub fn rate(&self, seq: Option<usize>, elapsed: f32, shared_now: f64) -> f32 {
self.rate
.get(self.idx(seq))
@@ -88,8 +70,7 @@ impl EmitTiming {
.max(0.0)
}
/// The rate track's peak over every slot — the "can this emitter ever contribute" spawn gate
/// (a burst emitter keys `0 → peak → 0`, so its first key is 0 but it absolutely emits).
/// The rate's peak over every slot, for the spawn cull: a burst emitter's first key is 0.
pub fn peak_rate(&self) -> f32 {
self.rate
.iter()
@@ -98,24 +79,10 @@ impl EmitTiming {
.fold(0.0, f32::max)
}
/// The **burst this emitter actually fires** on sequence slot `seq`, as
/// `(seconds into the slot, particle count)` — or `None` when it never fires one.
///
/// The reference's burst gate is `enabled != 0 && sampledRate > 0`, both sampled from the
/// same clock in the same frame, and it triggers on that predicate's **rising edge**, emitting
/// `ftol(rate)` particles (`0x718ed2`–`0x718ef6`;
/// benilla's `particles::accumulate_emission` runs the same rule). Both tracks are STEP, so
/// this walks a 60 Hz grid over the slot's keyed span — the same resolution a running frame
/// gives it — and reports the first instant the predicate holds.
///
/// **`None` is a real shipped shape, not a parse failure.** An emitter whose enabled track
/// falls to 0 on the very keyframe its rate track rises off 0 never emits anything: the two
/// conditions are never true together. `Spells\\Strike_Impact_Chest.m2`'s gold flare emitter
/// is exactly that, and reading its [`Self::peak_rate`] as a particle count overstates the
/// effect by 50 particles that neither client draws.
///
/// A gseq-tagged track is sampled against `shared_now = 0` — this is a static dump/inspection
/// face, not a running clock.
/// The first burst on slot `seq` as `(seconds in, particle count)`, on a 60 Hz grid: the
/// reference fires `ftol(rate)` particles on the rising edge of `enabled != 0 && rate > 0`
/// (`0x718ed2`-`0x718ef6`). `None` is a shipped shape: `Spells\\Strike_Impact_Chest.m2`'s gold
/// flare turns its gate off on the key its rate rises.
pub fn first_burst(&self, seq: Option<usize>) -> Option<(f32, f32)> {
let i = self.idx(seq);
let span = |o: &Option<ScalarAnim>| -> f32 {
@@ -139,8 +106,7 @@ impl EmitTiming {
}
}
/// `Some(rate)` when every slot bakes the same single-key rate — the overwhelmingly common
/// shape, and the dump instruments' quiet case.
/// `Some(rate)` when every slot bakes the same single-key rate, the common shape.
pub fn constant_rate(&self) -> Option<f32> {
let mut it = self.rate.iter();
let first = it.next()?.as_ref()?;
@@ -149,9 +115,7 @@ impl EmitTiming {
.then_some(v)
}
/// Per-slot read view for the dump instruments: `(looping, rate keys, enabled keys)`, one per
/// file sequence slot (`None` = the track keys nothing in that slot). Times are seconds from
/// the slot's band start — the values the runtime actually samples, not the raw file keys.
/// `(looping, rate keys, enabled keys)` per file slot, in seconds from the band start.
#[allow(clippy::type_complexity)] // a read-only tuple view for the dumps
pub fn slot_views(&self) -> Vec<(bool, Option<&[(f32, f32)]>, Option<&[(f32, f32)]>)> {
fn keys(list: &[Option<ScalarAnim>], i: usize) -> Option<&[(f32, f32)]> {
@@ -180,15 +144,10 @@ impl EmitTiming {
}
}
/// One frame's sampled emitter **parameters** — the nine per-frame-sampled scalar M2Tracks of the
/// emitter record (bases `+0x34..+0x130`, every one except the rate/enabled pair). The reference's
/// animate kernel `0x714260` samples ALL ten scalar tracks into the per-emitter animation block
/// (`[model+0x3d0]`, stride 0x16c — the rate channel, sampled at `0x71850d`, is the template) and
/// pushes them onto the live emitter through its setters each frame. **These are NOT constants**:
/// `Frost_Nova_area` ramps its emission-sphere radius
/// 0.19 → 13.2 yd with the expanding ring, `ArcaneExplosion_Base` 0 → 7.2 yd with the growing
/// dome — flatten either to `value[0]` and every birth lands at the centre (the "born way too
/// close" bug this type exists to fix).
/// One frame's sample of the emitter's nine parameter tracks (`+0x34..+0x130`, all but rate and
/// gate). The reference samples them every frame into the emitter's animation block
/// (`[model+0x3d0]`, stride 0x16c) as it does the rate (`0x71850d`), and they animate:
/// `Frost_Nova_area`'s emission sphere grows from 0.19 to 13.2 yd.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ParamsNow {
/// Initial particle speed (yards/sec).
@@ -199,17 +158,16 @@ pub struct ParamsNow {
pub vertical_range: f32,
/// Azimuthal spread, radians (sphere: longitude range; spline: scatter jitter).
pub horizontal_range: f32,
/// Downward acceleration (yards/sec²) — a live per-frame emitter field (the integrator reads
/// it every frame, `0x7b2680`), so it samples here rather than baking per particle.
/// Downward acceleration (yards/sec²), read live every frame by the integrator (`0x7b2680`).
pub gravity: f32,
/// Particle lifetime (seconds). The reference passes the CURRENT value into each spawn
/// (`life_param`, the kernels' `ebp+0xc`), so a birth captures it for life.
/// Particle lifetime (seconds); a birth keeps the value current at its spawn (the spawn
/// kernels' `ebp+0xc`).
pub lifespan: f32,
/// Plane: full x-extent (±½ rect); sphere: MIN radius; spline: tMin.
/// Plane: full x-extent (±½ rect); sphere: minimum radius; spline: tMin.
pub area_length: f32,
/// Plane: full y-extent; sphere: MAX radius; spline: tMax.
/// Plane: full y-extent; sphere: maximum radius; spline: tMax.
pub area_width: f32,
/// zSource — velocity pivot at `(0, 0, z)` (0 = unused).
/// zSource: velocity pivot at `(0, 0, z)`, 0 for none.
pub z_source: f32,
}
@@ -230,14 +188,11 @@ impl Default for ParamsNow {
}
}
/// The nine emitter parameter tracks, baked one loop per FILE sequence slot — the exact
/// bake/clock/sampling law of [`EmitTiming`]'s rate channel (byte-verified there), applied to its
/// nine sibling tracks. Sample once per sim frame on the emitter's clock and feed births/kill/
/// integration the result ([`ParamsNow`]).
/// The nine parameter tracks, baked per file sequence slot under [`EmitTiming`]'s law; sampled
/// once per frame on the emitter's clock.
#[derive(Debug, Clone, Default)]
pub struct EmitParams {
/// Per-channel, per-slot baked loops, in [`ParamsNow`] field order; `None` = keyless there
/// (the channel default stands).
/// Per channel in [`ParamsNow`] field order, per slot; `None` holds the channel default.
channels: [Vec<Option<ScalarAnim>>; 9],
}
@@ -254,9 +209,7 @@ impl EmitParams {
}
}
/// Sample every channel `elapsed` seconds into sequence slot `seq` (same slot resolution as
/// [`EmitTiming`]: out-of-range degrades to slot 0). `shared_now` routes gseq-tagged
/// channels to the shared world clock.
/// Every channel `elapsed` seconds into slot `seq`, resolved as [`EmitTiming`] resolves it.
pub fn sample(&self, seq: Option<usize>, elapsed: f32, shared_now: f64) -> ParamsNow {
let d = ParamsNow::default();
let at = |i: usize, default: f32| -> f32 {
@@ -282,10 +235,8 @@ impl EmitParams {
}
}
/// The lifespan channel's peak over every slot — the spawn-cull gate (an animated lifespan
/// may open at 0; an emitter is dead only if it NEVER exceeds 0). A keyless channel reads
/// the loader default; a keyed one folds its own keys ONLY (folding from the default would
/// mask an authored sub-1.0 peak).
/// The lifespan's peak over every slot, for the spawn cull. A keyless channel is the loader
/// default; a keyed one folds only its keys, so an authored peak under 1.0 is not masked.
pub fn peak_lifespan(&self) -> f32 {
let mut keys = self.channels[5]
.iter()
@@ -298,8 +249,7 @@ impl EmitParams {
keys.fold(0.0, f32::max)
}
/// Constant channels from one [`ParamsNow`] — the test/tool constructor, and the shape every
/// pre-track consumer had.
/// Constant channels from one [`ParamsNow`], for tests and tools.
pub fn constant(now: ParamsNow) -> Self {
let ch = |v: f32| {
vec![Some(ScalarAnim {
@@ -325,9 +275,7 @@ impl EmitParams {
}
}
/// Per-channel dump view: `(name, per-slot keys)` — `None` = keyless in that slot. The dump
/// instruments print any channel whose keys actually move (the view that would have shown
/// Frost Nova's 0.19 → 13.2 yd radius ramp instead of hiding it behind `value[0]`).
/// `(name, per-slot keys)` per channel.
#[allow(clippy::type_complexity)] // a read-only tuple view for the dumps, like `slot_views`
pub fn channel_views(&self) -> [(&'static str, Vec<Option<&[(f32, f32)]>>); 9] {
const NAMES: [&str; 9] = [
@@ -366,8 +314,6 @@ mod tests {
}
}
/// File sequence slots as `(band, loops)` — the loop flag is the slot's CLOCK, so these tests
/// spell it per slot rather than carry a parallel array beside the bands.
fn slots(spec: &[((u32, u32), bool)]) -> Vec<SeqSlot> {
spec.iter()
.enumerate()
@@ -379,9 +325,6 @@ mod tests {
.collect()
}
/// The STEP rate law: a `{0:0, 67:30}` track is silent before its 67 ms key — the burst
/// fires AT the key, full-count — and holds 30 after (moved here from the runtime's
/// `accumulate_emission` tests when the sampling moved into the bake).
#[test]
fn step_rate_is_silent_before_its_key_and_holds_after() {
let t = EmitTiming::bake(
@@ -400,8 +343,7 @@ mod tests {
assert!(t.emitting(None, 0.5, 0.0), "no gate track = always on");
}
/// The LINEAR ramp law (the BloodSpurt shape `0 → 100 → 0`, interp 1): mid-ramp pours the
/// interpolated rate, and the self-closing tail really falls back to 0.
/// BloodSpurt's linear `0, 100, 0` rate (interp 1).
#[test]
fn lerp_ramp_interpolates_and_self_closes() {
let t = EmitTiming::bake(
@@ -421,10 +363,6 @@ mod tests {
assert_eq!(t.rate(None, 0.500, 0.0), 0.0, "the ramp self-closes");
}
/// The per-sequence window law — the B27 shape, synthesized: an enabled gate authored ON
/// inside a clamped one-shot clip (slot 0) whose window in the idle loop (slot 1) resolves
/// to OFF. The old seq-0-only rebase parked the clamped clock at its end value; the bake
/// must read OFF at idle, run slot 0's window, and HOLD (not wrap) slot 0's tail.
#[test]
fn idle_window_is_off_and_a_clamped_clip_holds_its_tail() {
// Absolute timeline: clip A (one-shot) band 1000..2000, idle band 2333..2667.
@@ -444,13 +382,11 @@ mod tests {
]),
&[],
);
// Idle (slot 1): the collapsed window (1,1) resolves to keys[1] = OFF — at every time,
// wrap included.
// Idle (slot 1): the collapsed window (1,1) is keys[1], off at every time.
assert!(!t.emitting(Some(1), 0.0, 0.0));
assert!(!t.emitting(Some(1), 0.25, 0.0));
assert!(!t.emitting(Some(1), 400.0, 0.0));
// The one-shot clip (slot 0): ON at its start, OFF from 333 ms — and the clamped clock
// HOLDS that tail at/after the band end instead of aliasing back to the ON start.
// The clip (slot 0): on, off from 333 ms, and held off past the band end.
assert!(t.emitting(Some(0), 0.1, 0.0));
assert!(!t.emitting(Some(0), 0.5, 0.0));
assert!(
@@ -461,9 +397,8 @@ mod tests {
!t.emitting(Some(0), 5.0, 0.0),
"parked long past the end: still off"
);
// The later clip (slot 2): its degenerate window is the ON key.
// The later clip (slot 2): its degenerate window is the on key.
assert!(t.emitting(Some(2), 0.05, 0.0));
// Unknown/out-of-range degrades to slot 0 (the doodad law).
assert!(t.emitting(None, 0.1, 0.0));
assert!(!t.emitting(Some(9), 0.5, 0.0));
// The rate is a whole-track constant: same in every slot.
@@ -471,10 +406,7 @@ mod tests {
assert_eq!(t.peak_rate(), 20.0);
}
/// The ANIMATED-parameter law — Frost Nova's authored shape: the emission-sphere radius
/// (areaLength = areaWidth) lerps 0.19 → 13.2 yd over 667 ms and holds, riding the ring
/// outward; lifespan ramps beside it. `value[0]` flattening (the bug this bakes away) reads
/// 0.19 for ever and births the whole mist at the caster's feet.
/// Frost Nova's authored shape: the sphere radius lerps 0.19 to 13.2 yd over 667 ms and holds.
#[test]
fn animated_area_ramp_samples_mid_flight() {
let area = track(1, &[(0, 0.1944), (667, 13.1967), (867, 13.1967)], &[]);
@@ -498,12 +430,11 @@ mod tests {
assert!((at(0.2).lifespan - 0.6127).abs() < 5e-3, "lifespan rides");
assert_eq!(at(0.5).emission_speed, 0.0, "keyless channel: default");
assert!((p.peak_lifespan() - 0.8008).abs() < 1e-4);
// The clamped one-shot parks at its tail, never aliasing back to the tight opening.
// The clamped clip holds its tail.
assert!((at(5.0).area_length - 13.1967).abs() < 1e-3);
}
/// A LOOPING slot wraps its band — a windowed gate re-fires every pass (the precast hold's
/// pulsing hand flash), where a clamped slot would have parked.
/// A windowed gate re-fires every pass, as the precast hold's pulsing hand flash does.
#[test]
fn a_looping_band_wraps_its_gate_window() {
let gate = track(0, &[(0, 1.0), (200, 0.0)], &[]);
@@ -521,16 +452,9 @@ mod tests {
);
}
/// **The dead-slot-0 shape** — `BlastedLandsLightningbolt01.m2`'s emitter 2, synthesized
/// (decision 0760, bug B63). Two sequences, both anim id 0: a variation chain. Slot 0 keys a
/// single 0 — a flat silence for its whole band — while the strike itself, `0 → 30 → 0`, is
/// keyed only in slot 1, which the arm's frequency-weighted roll reaches ~5 % of the time.
///
/// A consumer that PINS slot 0 therefore emits **nothing at all, for ever**, on every
/// placement: the emitter builds, pools and ticks, and never births a particle. That is what
/// the placed-doodad lane did until it started passing the slot its own arm actually rolled.
/// `peak_rate()` folding across all slots is why such an emitter survives the spawn cull — it
/// looks alive to the build and is dead to the clock. `partslotscan` counts 947 of these.
/// `BlastedLandsLightningbolt01.m2`'s emitter 2: two variations of anim 0, the strike keyed
/// only in slot 1, which the arm's weighted roll picks about 5% of the time. A consumer pinned
/// to slot 0 never emits, yet `peak_rate` keeps the emitter past the spawn cull.
#[test]
fn a_burst_keyed_only_in_a_later_variation_is_silent_in_slot_0() {
// Absolute timeline: slot 0's band 0..1333, slot 1's band 1367..2667 (the real model's).
@@ -551,7 +475,7 @@ mod tests {
&slots(&[((0, 1333), true), ((1367, 2667), true)]),
&[],
);
// Slot 0 — what the old pinned consumer sampled. Silent across its whole band.
// Slot 0 is silent across its whole band.
for s in [0.0, 0.3, 0.6, 0.9, 1.2] {
assert_eq!(
t.rate(Some(0), s, 0.0),
@@ -564,7 +488,7 @@ mod tests {
0.0,
"`None` degrades to slot 0 — also silent"
);
// Slot 1 — what the arm actually rolled. The strike is here, and only here.
// Slot 1 carries the strike.
assert_eq!(t.rate(Some(1), 0.0, 0.0), 0.0, "slot 1 opens closed");
assert_eq!(
t.rate(Some(1), 0.316, 0.0),
@@ -572,7 +496,7 @@ mod tests {
"the burst fires mid-band"
);
assert_eq!(t.rate(Some(1), 0.5, 0.0), 0.0, "and self-closes");
// The trap that hid it: the build-time cull sees a live emitter either way.
// The build-time cull still sees a live emitter.
assert_eq!(
t.peak_rate(),
30.0,
+48 -123
View File
@@ -1,40 +1,10 @@
//! `EmotesText.dbc` × `EmotesTextData.dbc` — the **sentence** an `SMSG_TEXT_EMOTE` becomes
//! ("Bob waves at you." / "You wave." / "Bob waves at Jane."), decision 1274.
//! `EmotesText.dbc` joined with `EmotesTextData.dbc`: the sentence an `SMSG_TEXT_EMOTE` becomes
//! ("Bob waves at you."). [`crate::emotes`] owns the same table's command, animation and voice
//! columns; this module owns its 16 `EmoteText[]` columns and the strings they point into.
//!
//! The sibling module [`crate::emotes`] owns the same `EmotesText.dbc`'s *other* half — the
//! `/command` name, the anim id, the voice kits. This one owns its 16 **`EmoteText[]`** columns and
//! the localized-string table they point into, because the two are consumed by different arcs
//! (audio+animation vs the chat log) and only one of them needs `EmotesTextData.dbc` at all.
//!
//! The law below is the real client's sole sentence composer, **`0x49b200`** — read at the bytes
//! for this implementation (`objdump 0x49b200..0x49b47c`), which is where the byte addresses in
//! the comments come from.
//!
//! # The composer, in three parts
//!
//! **1 · A 4-bit column selector.** `index = gender | context`, where `context` is built from three
//! facts and the *order of the tests matters* (`0x49b2c3`-`0x49b316`):
//!
//! | bit | test | meaning |
//! |---|---|---|
//! | `2` (`0x49b2c8`) | performer guid == the active player's | **you are the performer** — and this *skips* the bit-0 test |
//! | `1` (`0x49b2f3`) | the wire's target name == your own name | **you are the target** (only ever tested when you are not the performer) |
//! | `4` (`0x49b311`) | the wire's target name is empty | **untargeted** |
//! | `8` (`0x49b31c`) | the performer's `NameCache` record `+0x13c == 1` (Female) | performer **gender** |
//!
//! Reachable columns are `{0,1,2,4,6} | gender` — 10 of the 16; 3/5/7/11/13/15 cannot be selected
//! (bit 0 and bit 2 are mutually exclusive, and bit 1 skips bit 0's test). WAVE (id 101) in the
//! shipped table: col0 "%s waves at %s.", col1 "%s waves at you.", col2 "You wave at %s.",
//! col4 "%s waves.", col6 "You wave.", every other column 0.
//!
//! **2 · A four-rung fallback ladder** when the picked column has no non-empty string for the
//! locale (`0x49b332`, `0x49b35e`, `0x49b38a`, `0x49b3c5`): `gender|ctx` → `ctx` → `gender|ctx'` →
//! `ctx'`, where `ctx' = (ctx & !1) | 4` ("drop target-self, force untargeted"). All four empty ⇒
//! **no chat line at all** (`0x49b4bd`), and the emote is anim + voice only.
//!
//! **3 · Four `SStrPrintf` sites**, picked by (performer-is-other) × (target-is-other), always
//! performer-first (`0x49b3fa`-`0x49b479`). See [`EmoteLine`] for the self-target edge that falls
//! out of the test order.
//! The composer is the reference's `0x49b200`: a 4-bit column selector, a four-rung fallback when
//! the column has no string for the locale, then one of four `SStrPrintf` sites, performer first.
//! All four rungs empty means no chat line at all; the emote is then only an animation and a voice.
use std::collections::HashMap;
@@ -47,63 +17,48 @@ use crate::Chain;
const EMOTES_TEXT: &str = "DBFilesClient\\EmotesText.dbc";
const EMOTES_TEXT_DATA: &str = "DBFilesClient\\EmotesTextData.dbc";
/// `EmotesText.dbc`'s `EmoteText[]` width — the selector is 4 bits wide because this is 16.
/// `EmoteText[]`'s width, which the 4-bit selector indexes.
const FORMS: usize = 16;
/// How many locale columns an `EmotesTextData.dbc` row carries in 5875 (`id + 8 strings + flags`
/// = the 10 fields the loader's `cmp eax,0xa` at `0x544579` insists on). Same shape as
/// [`crate::languages`]'s `Name_lang` block, and read the same way: the loader keeps every column,
/// the app asks for the slot it wants (`[0xc0e080]`, 0 = enUS, the only one this install fills).
/// Locale columns per `EmotesTextData.dbc` row: id, 8 strings and flags make the 10 fields the
/// loader checks (`0x544579`); the client reads the slot named in `[0xc0e080]`.
const LOCALES: usize = 8;
/// The joined text-emote sentence tables.
#[derive(Debug, Default, Clone)]
pub struct EmoteTextCatalog {
/// `EmotesText.dbc` id → its 16 `EmoteText[]` columns (each an `EmotesTextData` id, 0 = none).
/// `EmotesText.dbc` id to its 16 `EmoteText[]` columns, each an `EmotesTextData` id or 0.
forms: HashMap<u32, [u32; FORMS]>,
/// `EmotesTextData.dbc` id → its localized format strings, one per locale column.
data: HashMap<u32, [Option<String>; LOCALES]>,
}
/// The facts `0x49b200` branches on, in the composer's own terms.
///
/// **`target_is_you` is only tested when you are not the performer** — the guid compare at
/// `0x49b2c8` jumps past it — so [`EmoteTextCatalog::compose`] derives both facts here rather than
/// trusting a caller to pre-clear one.
///
/// That branch order once looked like it meant "a self-emote renders *You wave at ⟨YourName⟩.*",
/// and decision 1274 said so. **It does not** (corrected by 1282): the sending client zeroes a
/// self-target before the packet is built (`DoEmote 0x5ef611`), so `target` arrives EMPTY for that
/// action and the untargeted column wins — "You wave.". Vanilla has no reflexive form *and* no
/// self-named one; the case simply never reaches this struct.
/// The facts `0x49b200` branches on. A self-emote never names you as its target: the sending
/// client clears a self-target before building the packet (`DoEmote`, `0x5ef611`), so it arrives
/// untargeted and reads "You wave.".
#[derive(Debug, Clone, Copy)]
pub struct EmoteLine<'a> {
/// The performer's resolved name — the `%s` the reference fills from the `NameCache` record
/// pointer it passes as-is (`record+0` *is* the name).
/// The performer's resolved name, the `%s` the reference fills from its `NameCache` record.
pub performer: &'a str,
/// The performer guid == the active player's (`0x49b2c8`).
/// The performer guid is the active player's (`0x49b2c8`).
pub performer_is_you: bool,
/// The performer's sex is Female (`record+0x13c == 1`) — the `+8` on the column index.
/// The performer is female, `NameCache` record `+0x13c == 1`: `+8` on the column (`0x49b31c`).
pub performer_female: bool,
/// The target's display name **as the server sent it** (the wire string, never round-tripped
/// through a name cache); empty = untargeted.
/// The target name as the wire carries it, never round-tripped through a name cache; empty is
/// untargeted (`0x49b311`).
pub target: &'a str,
/// Your own name (`GetOwnName 0x609210`), compared case-insensitively against `target`
/// (`0x64a480`).
/// Your own name (`GetOwnName`, `0x609210`), compared case-insensitively with `target` at
/// `0x49b2f3` (`0x64a480`).
pub your_name: &'a str,
}
impl EmoteTextCatalog {
/// The composed chat line for a text emote, or `None` when the ladder runs dry (the
/// reference's "no line at all" tail — the emote is still an animation and a voice).
///
/// `locale` is the client's locale column; 0 (enUS) is the only one a 5875 enUS install fills.
/// The chat line for a text emote; `None` when the ladder runs dry (`0x49b4bd`). `locale` is
/// the client's locale column, 0 for enUS.
pub fn compose(&self, text_id: u32, line: &EmoteLine, locale: usize) -> Option<String> {
let forms = self.forms.get(&text_id)?;
// ── the selector (`0x49b2c3`-`0x49b316`) ────────────────────────────────────────────────
// Both flags start "the other guy" and are cleared by the branch that fires — the order is
// load-bearing: matching the performer skips the target compare entirely.
// Order matters: matching the performer skips the target compare entirely.
let (mut performer_is_other, mut target_is_other) = (true, true);
let mut ctx = 0usize;
if line.performer_is_you {
@@ -119,8 +74,7 @@ impl EmoteTextCatalog {
let gender = usize::from(line.performer_female) * 8;
// ── the ladder (`0x49b332` / `0x49b35e` / `0x49b38a` / `0x49b3c5`) ──────────────────────
// `ctx` is mutated in place by rung 3, exactly as the reference mutates `esi`, so rung 4
// reads the *corrected* context and not the original one.
// Rung 3 rewrites `ctx` in place, as the reference does `esi`, so rung 4 reads the new one.
let template = self
.template(forms, gender | ctx, locale)
.or_else(|| self.template(forms, ctx, locale))
@@ -135,15 +89,12 @@ impl EmoteTextCatalog {
(true, true) => fill(template, &[line.performer, line.target]),
(true, false) => fill(template, &[line.performer]),
(false, true) => fill(template, &[line.target]),
// Provably unreachable in the reference (clearing `target_is_other` requires the
// performer branch not to have fired); kept as the format's own no-arg case.
// Unreachable: clearing `target_is_other` needs the performer branch not to fire.
(false, false) => fill(template, &[]),
})
}
/// One `EmoteText[index]` column resolved to a **non-empty** localized string, or `None` — the
/// reference's four rejections rolled into one (`index` past the array, the column's id absent
/// from `EmotesTextData`, the row's string blank for this locale).
/// One `EmoteText[index]` column's non-empty string for the locale, the ladder's rung test.
fn template<'a>(
&'a self,
forms: &[u32; FORMS],
@@ -154,7 +105,7 @@ impl EmoteTextCatalog {
self.data.get(&data_id)?.get(locale)?.as_deref()
}
/// How many `EmotesText.dbc` rows carry a form table (169 in 5875).
/// `EmotesText.dbc` rows, 169 in 5875.
pub fn len(&self) -> usize {
self.forms.len()
}
@@ -164,9 +115,8 @@ impl EmoteTextCatalog {
}
}
/// `SStrPrintf` (`0x64a7f0`) reduced to what these templates actually use: replace each `%s` with
/// the next argument, left to right. A template with more `%s` than arguments keeps the surplus
/// verbatim — the reference would print stack garbage there, and no shipped row does it.
/// `SStrPrintf` (`0x64a7f0`) reduced to the `%s` these templates use. Deviation: a surplus `%s`
/// stays verbatim, where the reference would print stack garbage; no shipped row has one.
fn fill(template: &str, args: &[&str]) -> String {
let mut out = String::with_capacity(template.len() + 16);
let mut rest = template;
@@ -181,8 +131,7 @@ fn fill(template: &str, args: &[&str]) -> String {
out
}
/// `EmotesText.dbc` — `ID`, `Name`(str), `EmoteID`, `EmoteText[16]`; 19 fields × 4 = the 76-byte
/// record the client's loader checks for.
/// `EmotesText.dbc`: 19 fields, the 76-byte record the reference loader checks for.
fn emotes_text_schema() -> Schema {
let mut s = Schema::new("EmotesText");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
@@ -194,8 +143,8 @@ fn emotes_text_schema() -> Schema {
s
}
/// `EmotesTextData.dbc` — `ID`, eight `Text_lang` strings, `Flags`; the classic 1.12
/// `LocalizedString` block (10 fields × 4 = 40 bytes, the reader's own `cmp eax,0x28`).
/// `EmotesTextData.dbc`: a 1.12 `LocalizedString` block, the 40-byte record the reader checks
/// (`cmp eax,0x28`).
fn emotes_text_data_schema() -> Schema {
let mut s = Schema::new("EmotesTextData");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
@@ -231,8 +180,8 @@ pub fn load_emote_text_catalog(chain: &mut Chain) -> Result<EmoteTextCatalog> {
let Some(id) = u32_at(r, 0) else { continue };
let mut texts: [Option<String>; LOCALES] = Default::default();
for (locale, slot) in texts.iter_mut().enumerate() {
// `str_at` already answers `None` for an empty string, which is the reference's
// "blank for this locale ⇒ take the next rung" test (`cmpb $0,(%edx)`).
// `str_at` answers `None` for an empty string: the reference's blank-means-next-rung
// test (`cmpb $0,(%edx)`).
*slot = str_at(&rs, r, 1 + locale);
}
data.insert(id, texts);
@@ -245,9 +194,7 @@ pub fn load_emote_text_catalog(chain: &mut Chain) -> Result<EmoteTextCatalog> {
mod tests {
use super::*;
/// WAVE's five reachable enUS forms — checked against the shipped DBC, so a schema slip (a
/// column-index off-by-one, the wrong locale slot) shows up as the wrong sentence rather than
/// as a silent empty.
/// WAVE: a column or locale slip shows as the wrong sentence, never a silent empty.
const WAVE: u32 = 101;
fn line<'a>(performer: &'a str, target: &'a str, you: &'a str) -> EmoteLine<'a> {
@@ -268,13 +215,10 @@ mod tests {
);
assert_eq!(fill("%s waves at you.", &["Bob"]), "Bob waves at you.");
assert_eq!(fill("You wave.", &["Bob"]), "You wave.");
// Surplus verbs stay literal rather than eating the rest of the template.
assert_eq!(fill("%s waves at %s.", &["Bob"]), "Bob waves at %s.");
assert_eq!(fill("", &["Bob"]), "");
}
/// The real chain: this module is a join between two shipped tables plus a bit-selector, and a
/// synthetic fixture would only test the plumbing.
#[test]
fn the_shipped_tables_compose_waves_five_forms() {
let data = crate::wow_data_or_skip!();
@@ -282,22 +226,22 @@ mod tests {
let cat = load_emote_text_catalog(&mut chain).expect("load");
assert_eq!(cat.len(), 169, "one form table per EmotesText row");
// ctx 0 — someone else waves at someone else.
// ctx 0: someone else waves at someone else.
assert_eq!(
cat.compose(WAVE, &line("Bob", "Jane", "Me"), 0).as_deref(),
Some("Bob waves at Jane.")
);
// ctx 1 — someone else waves at YOU (the target name matches your own).
// ctx 1: someone else waves at you (the target name matches your own).
assert_eq!(
cat.compose(WAVE, &line("Bob", "Me", "Me"), 0).as_deref(),
Some("Bob waves at you.")
);
// ctx 4 — someone else waves at nobody.
// ctx 4: someone else waves at nobody.
assert_eq!(
cat.compose(WAVE, &line("Bob", "", "Me"), 0).as_deref(),
Some("Bob waves.")
);
// ctx 2 — YOU wave at someone.
// ctx 2: you wave at someone.
let mine = EmoteLine {
performer_is_you: true,
..line("Me", "Jane", "Me")
@@ -306,7 +250,7 @@ mod tests {
cat.compose(WAVE, &mine, 0).as_deref(),
Some("You wave at Jane.")
);
// ctx 6 — YOU wave at nobody.
// ctx 6: you wave at nobody.
let mine = EmoteLine {
performer_is_you: true,
..line("Me", "", "Me")
@@ -314,16 +258,8 @@ mod tests {
assert_eq!(cat.compose(WAVE, &mine, 0).as_deref(), Some("You wave."));
}
/// **The composer has no self-target case, because it can never be handed one** — and this
/// test exists to record that, since the shape of the code invites the opposite conclusion
/// (decision 1282, correcting 1274).
///
/// The branch order is real: `0x49b2c8` matching the performer jumps past the target compare,
/// so if a self-emote *did* arrive with your own name in the target slot, this is what it
/// would render. It never arrives. The **send** side zeroes a self-target before the packet
/// exists (`DoEmote 0x5ef611`), so the server echoes an empty name and the untargeted column
/// wins — "You wave.", asserted just above. Reading this arm as "what a self-emote does" is
/// exactly the mistake 1274 made.
/// The branch order would render your own name for a self-target, but the send side clears a
/// self-target (`DoEmote`, `0x5ef611`), so the server echoes an empty name: "You wave.".
#[test]
fn the_self_named_target_form_is_unreachable_from_the_wire() {
let data = crate::wow_data_or_skip!();
@@ -337,8 +273,7 @@ mod tests {
cat.compose(WAVE, &me, 0).as_deref(),
Some("You wave at Me.")
);
// What the wire actually delivers for that same action — an EMPTY target — and therefore
// what a player really reads.
// What the wire delivers for that action: an empty target.
let me = EmoteLine {
performer_is_you: true,
..line("Me", "", "Me")
@@ -346,8 +281,7 @@ mod tests {
assert_eq!(cat.compose(WAVE, &me, 0).as_deref(), Some("You wave."));
}
/// The gender rung: WAVE has no female column, so `8|ctx` misses and rung 2 (`ctx` alone)
/// answers — a female performer must read exactly the same sentence, not nothing.
/// WAVE has no female column, so `8|ctx` misses and rung 2, `ctx` alone, answers.
#[test]
fn a_missing_female_column_falls_back_to_the_ungendered_one() {
let data = crate::wow_data_or_skip!();
@@ -363,28 +297,19 @@ mod tests {
);
}
/// A locale column this install does not ship is empty everywhere, so the ladder runs dry and
/// the composer emits no line — the reference's `0x49b4bd` tail, not a panic and not a blank.
/// An unshipped locale is empty everywhere, so the ladder runs dry: no line (`0x49b4bd`).
#[test]
fn an_unshipped_locale_composes_nothing() {
let data = crate::wow_data_or_skip!();
let mut chain = Chain::open(&data).expect("open patch chain");
let cat = load_emote_text_catalog(&mut chain).expect("load");
assert_eq!(cat.compose(WAVE, &line("Bob", "Jane", "Me"), 5), None);
// …and so does an id with no row at all.
assert_eq!(cat.compose(9999, &line("Bob", "Jane", "Me"), 0), None);
}
/// The whole table, not one row. This is the check that would have caught B156's real shape —
/// "the sentence table is never consulted" reads identically to "this one emote has no text"
/// until you sweep all 169.
///
/// **Exactly three rows are legitimately silent**, and naming them is the point: `SIT`(86)
/// points columns 4/6 at `EmotesTextData` rows 446/447, which exist but ship **blank in every
/// locale**, while `STAND`(141) and `TRAIN`(264) have all sixteen columns zero. The ladder
/// therefore runs dry for them and the reference prints nothing — which is what a vanilla
/// `/sit` does. Every other row must compose a `%s`-free sentence in all five reachable
/// contexts.
/// Three rows are silent as shipped: `SIT` (86) points columns 4 and 6 at `EmotesTextData`
/// rows 446 and 447, blank in every locale, and `STAND` (141) and `TRAIN` (264) have all
/// sixteen columns zero. Every other row composes a `%s`-free sentence in all five contexts.
#[test]
fn every_shipped_emote_composes_except_the_three_silent_rows() {
let data = crate::wow_data_or_skip!();
+35 -94
View File
@@ -1,29 +1,7 @@
//! Emote audio data (decision 0070 slice 4): **EmotesText** (the `/wave` command table) joined
//! to **EmotesTextSound** (the race/sex voice kits) and **Emotes** (anim emotes + their
//! `EventSoundID`).
//!
//! Layouts — VERIFIED against build 5875 (headers + row decodes, 2026-07-02):
//! - `EmotesText` **169 × 19 × 76 B**: `ID, Name(str, e.g. "WAVE"), EmoteID (→Emotes),
//! EmoteText[16]`. Spot-check: WAVE = id 101, emote 3.
//! - `EmotesTextSound` **418 × 5 × 20 B**: `ID, EmotesTextID, RaceID, SexID (0 male / 1 female),
//! SoundID (→SoundEntries)`.
//! - `Emotes` **78 × 7 × 28 B**: `ID, SlashCommand(str), AnimID, EmoteFlags, EmoteSpecProc,
//! EmoteSpecProcParam, EventSoundID (→SoundEntries)`. Spot-check row 2: ONESHOT_BOW, anim 66
//! (VERIFIED against vmangos `SharedDefines.h`'s `Emote` enum: `EMOTE_ONESHOT_BOW = 2`).
//!
//! `AnimID` (column 2) feeds both the one-shot anim-emote path (`SMSG_EMOTE`'s `Emotes.dbc` id) and
//! the looping state-emote idle (`UNIT_NPC_EMOTESTATE`, the same id space) — [`EmoteSoundCatalog::anim`].
//!
//! `EmoteSpecProc`/`EmoteSpecProcParam` (columns 4/5) carry the **posture** half of the client's
//! `DoEmote`: proc `1` means "this emote sets a stand state", and the param IS that state
//! ([`EmoteSoundCatalog::posture_state`]) — the mechanism behind `/sit` (proc `2` is the looping
//! state emote whose `EventSoundID` the `$ESD` event rings).
//!
//! `EmoteFlags` (column 3) feeds the **send-side posture-eligibility gate** — the only `EmoteFlags`
//! consumer, byte-verified at the real client's `CheckEmoteEligible` (`0x47db40`, called from
//! `DoEmote` `0x5ef560`): before `CMSG_TEXT_EMOTE` goes out, bit `0x0001` combined with a non-zero
//! stand-state aborts the *entire* emote (no packet, no anim — a seated `/bow` does nothing).
//! [`EmoteSoundCatalog::emote_flags`] promotes the raw bits for `crate::chat`'s gate.
//! Emote data: `EmotesText` (`ID, Name, EmoteID, EmoteText[16]`, the `/wave` command table)
//! joined to `EmotesTextSound` (`ID, EmotesTextID, RaceID, SexID, SoundID`, the voice kits)
//! and `Emotes`, the anim emotes:
//! `ID, SlashCommand, AnimID, EmoteFlags, EmoteSpecProc, EmoteSpecProcParam, EventSoundID`.
use std::collections::HashMap;
@@ -33,39 +11,28 @@ use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, str_at, u32_at};
/// The joined emote-audio tables.
/// The joined emote tables.
pub struct EmoteSoundCatalog {
/// Uppercased `EmotesText.Name` ("WAVE") → text-emote id — the `/command` send key.
/// Uppercased `EmotesText.Name` ("WAVE") to text-emote id.
by_name: HashMap<String, u32>,
/// text-emote id → `Emotes.dbc` id (the anim the emote plays; 0 = chat-only).
/// Text-emote id to the `Emotes.dbc` id it plays, 0 for chat-only.
text_emote: HashMap<u32, u32>,
/// `(text-emote id, race, sex)` → voice kit.
/// `(text-emote id, race, sex)` to voice kit.
voice: HashMap<(u32, u32, u32), u32>,
/// `Emotes.dbc` id → its `EventSoundID` kit (0 = none).
event_sound: HashMap<u32, u32>,
/// `Emotes.dbc` id → its `AnimID` (`AnimationData.dbc` id; 0 = none).
anim: HashMap<u32, u32>,
/// `Emotes.dbc` id → its `EmoteFlags` (the send-side posture-eligibility gate bits).
emote_flags: HashMap<u32, u32>,
/// `Emotes.dbc` id → its `EmoteSpecProc` (column 4). `2` marks a looping STATE emote whose
/// `EventSoundID` the `$ESD` anim event rings (the client's `row[+0x10] == 2` gate at the
/// `$ESD` handler `0x6239f0`).
/// `Emotes.dbc` id to its `EmoteSpecProc`; `2` is a looping state emote whose `EventSoundID`
/// the `$ESD` anim event rings (`0x6239f0` tests `row[+0x10] == 2`).
spec_proc: HashMap<u32, u32>,
/// `Emotes.dbc` id → its `EmoteSpecProcParam` (column 5). For `EmoteSpecProc == 1` this is the
/// **stand state** the emote sets — see [`EmoteSoundCatalog::posture_state`].
/// `Emotes.dbc` id to its `EmoteSpecProcParam`, the stand state when `EmoteSpecProc` is 1.
spec_proc_param: HashMap<u32, u32>,
/// The five **chat/interact gesture slots**, in [`GESTURE_FLAG_BITS`] order — see
/// [`EmoteSoundCatalog::gesture`].
/// The five gesture slots, in [`GESTURE_FLAG_BITS`] order.
gesture: [Option<u32>; GESTURE_FLAG_BITS.len()],
}
/// The five `EmoteFlags` bits the client hard-codes to build its gesture table (`0x603a60`), in
/// slot order — **talk, question, exclamation,
/// shout, laugh**. The *bits* are the fidelity fact: the client never names an `Emotes.dbc` id or an
/// `AnimationData.dbc` id here, it scans the table for whichever row carries each bit. In the
/// shipped 1.12.1 data each bit is carried by exactly one row (ids 1 / 6 / 5 / 22 / 11 → anims
/// 60 / 65 / 64 / 81 / 70), but transcribing those numbers instead of the scan would bake content
/// into code.
/// The `EmoteFlags` bits the client scans `Emotes.dbc` for to build its gesture table
/// (`0x603a60`), in slot order: talk, question, exclamation, shout, laugh. It names no row id.
pub const GESTURE_FLAG_BITS: [u32; 5] = [0x08, 0x10, 0x20, 0x40, 0x100];
impl EmoteSoundCatalog {
@@ -79,7 +46,7 @@ impl EmoteSoundCatalog {
self.voice.get(&(text_id, race, sex)).copied()
}
/// The anim emote a text emote plays (0/none for chat-only emotes).
/// The anim emote a text emote plays.
pub fn text_emote(&self, text_id: u32) -> Option<u32> {
self.text_emote.get(&text_id).copied().filter(|&e| e != 0)
}
@@ -89,43 +56,33 @@ impl EmoteSoundCatalog {
self.event_sound.get(&emote_id).copied().filter(|&k| k != 0)
}
/// An `Emotes.dbc` id's `AnimID` (the `AnimationData.dbc` id it plays; `0`/absent = none).
/// Shared by the one-shot anim-emote path (`SMSG_EMOTE`) and the looping state-emote idle
/// (`UNIT_NPC_EMOTESTATE`) — both carry an `Emotes.dbc` id in the same id space.
/// The `AnimationData.dbc` id an `Emotes.dbc` id plays, for both `SMSG_EMOTE` one-shots and
/// the looping `UNIT_NPC_EMOTESTATE` idle, which share the id space.
pub fn anim(&self, emote_id: u32) -> Option<u32> {
self.anim.get(&emote_id).copied().filter(|&a| a != 0)
}
/// The `Emotes.dbc` id in gesture slot `slot` — an index into [`GESTURE_FLAG_BITS`], which is
/// exactly the `code` the client's gesture dispatcher `0x60bb30` takes. `None` when no shipped
/// row carries that bit (the client's own "empty slot ⇒ no gesture" leg).
/// The `Emotes.dbc` id in gesture `slot`, the `code` the client's gesture dispatcher
/// `0x60bb30` takes; a slot no shipped row fills plays no gesture, as in the client.
pub fn gesture(&self, slot: usize) -> Option<u32> {
self.gesture.get(slot).copied().flatten()
}
/// An `Emotes.dbc` id's raw `EmoteFlags` bits (`None` when the id isn't in the catalog; `0` is a
/// real, meaningful value — "no gate bits set" — so unlike [`Self::anim`]/[`Self::event_sound`]
/// it is *not* filtered out). Feeds `benilla::chat`'s send-side posture-eligibility gate — see
/// the module doc.
/// An `Emotes.dbc` id's raw `EmoteFlags`, `0` included. Bit `0x0001` with a non-zero stand
/// state aborts the whole emote before `CMSG_TEXT_EMOTE` is sent, so a seated `/bow` does
/// nothing (`0x47db40`, called from `DoEmote` `0x5ef560`).
pub fn emote_flags(&self, emote_id: u32) -> Option<u32> {
self.emote_flags.get(&emote_id).copied()
}
/// An `Emotes.dbc` id's `EmoteSpecProc` (`None` when the id isn't in the catalog; `0` is a real
/// value, so not filtered). `2` = a looping state emote — the `$ESD` event-sound gate (see the
/// field doc).
/// An `Emotes.dbc` id's `EmoteSpecProc`, `0` included; `2` is a looping state emote.
pub fn spec_proc(&self, emote_id: u32) -> Option<u32> {
self.spec_proc.get(&emote_id).copied()
}
/// The **stand state** a POSTURE emote sets: `EmoteSpecProcParam` gated on `EmoteSpecProc == 1`
/// (`None` for every other emote). This is the client's `DoEmote` (`0x5ef560`) state branch:
/// `if (rec.EmoteSpecProc == 1 && …) SetStandState(rec.SpecProcParam)`, the same `0x5ed430`
/// setter the sit key drives. The five
/// reachable rows: STATE_SIT(13)→1, STATE_SLEEP(12)→3, STATE_KNEEL(68)→8, STATE_STAND(26)→0,
/// STATE_AT_EASE(313)→2 — which is why `/sit` sits at all, since the *server* deliberately does
/// nothing for a STATE text emote (vmangos `ChatHandler.cpp` `HandleTextEmoteOpcode`: SIT /
/// SLEEP / KNEEL / NONE break out before `HandleEmote`).
/// The stand state a posture emote sets, `EmoteSpecProcParam` when `EmoteSpecProc` is 1, as
/// `DoEmote` (`0x5ef560`) feeds the sit key's setter `0x5ed430`. `/sit` rests on this alone:
/// the server does nothing for a state text emote (`ChatHandler.cpp` `HandleTextEmoteOpcode`).
pub fn posture_state(&self, emote_id: u32) -> Option<u32> {
(self.spec_proc.get(&emote_id).copied() == Some(1))
.then(|| self.spec_proc_param.get(&emote_id).copied())
@@ -193,11 +150,9 @@ pub fn load_emote_sound_catalog(chain: &mut Chain) -> Result<EmoteSoundCatalog>
let mut emote_flags = HashMap::with_capacity(rs.records().len());
let mut spec_proc = HashMap::with_capacity(rs.records().len());
let mut spec_proc_param = HashMap::with_capacity(rs.records().len());
// The gesture slots. The client walks the record array from the LAST index DOWN and its bit
// tests are an else-if chain, so a row carrying two of the bits lands only in the first
// matching slot and a duplicated bit resolves to the LOWEST array index. Iterating forward and
// letting an earlier row win reproduces both, without depending on either: no shipped row
// carries more than one of these bits, and none of them is duplicated.
// The client walks the rows from the last down with an else-if chain over the bits, so a row
// lands only in its first matching slot and a shared bit goes to the lowest index; iterating
// forward with the earlier row winning gives the same table.
let mut gesture: [Option<u32>; GESTURE_FLAG_BITS.len()] = [None; GESTURE_FLAG_BITS.len()];
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
@@ -240,10 +195,7 @@ pub fn load_emote_sound_catalog(chain: &mut Chain) -> Result<EmoteSoundCatalog>
mod tests {
use super::*;
/// The five gesture slots resolve off the REAL shipped `Emotes.dbc` — the check that the
/// flag-bit scan finds the right rows, and that each bit really is carried by exactly one of
/// them. The expected ids/anims are the table `0x603a60` builds from the shipped data; the
/// *code* never names them.
/// The expected ids and anims are the table `0x603a60` builds from the shipped data.
#[test]
fn the_five_gesture_slots_scan_to_the_shipped_rows() {
let data = crate::wow_data_or_skip!();
@@ -260,9 +212,7 @@ mod tests {
assert_eq!(cat.gesture(slot), Some(emote_id), "{what} slot -> emote id");
assert_eq!(cat.anim(emote_id), Some(anim_id), "{what} emote -> AnimID");
}
// Each of the five bits is carried by exactly one shipped row, so the else-if chain and
// the walk direction cannot matter. If a patch ever broke this, the scan would silently
// pick a different row and the gestures would drift — so assert it, do not assume it.
// One shipped row per bit, so the walk order cannot matter.
for bit in GESTURE_FLAG_BITS {
let carriers = cat
.emote_flags
@@ -273,9 +223,6 @@ mod tests {
}
}
/// The joins hold on real 5875 data: WAVE resolves by name to the byte-decoded id 101 with
/// anim emote 3; some voice row exists for a human (race 1) male; the voice map carries the
/// male/female split (the survey's sample rows pair sexes per race).
#[test]
fn real_emote_chain_resolves() {
let data = crate::wow_data_or_skip!();
@@ -284,9 +231,7 @@ mod tests {
assert_eq!(cat.text_id("wave"), Some(101), "case-insensitive by name");
assert_eq!(cat.text_emote(101), Some(3), "WAVE plays anim emote 3");
assert_eq!(cat.anim(2), Some(66), "ONESHOT_BOW (id 2) plays AnimID 66");
// EmoteFlags for the director-verified posture-gate rows (`0x47db40`), ids
// from vmangos `SharedDefines.h`'s `Emote` enum: BOW=2, CHEER=4, LAUGH=11, RUDE=14,
// APPLAUD=21, SALUTE=66.
// The posture-gate rows (`0x47db40`), ids from vmangos `SharedDefines.h`'s `Emote` enum.
assert_eq!(cat.emote_flags(2), Some(0x4801), "ONESHOT_BOW EmoteFlags");
assert_eq!(cat.emote_flags(4), Some(0x0800), "ONESHOT_CHEER EmoteFlags");
assert_eq!(
@@ -305,8 +250,7 @@ mod tests {
Some(0x0800),
"ONESHOT_SALUTE EmoteFlags"
);
// The $ESD gathering chain (decision 0562): STATE_WORK_NOSHEATHE_MINING (233) is a
// spec-proc-2 state emote carrying the MiningHit kit; its one-shot cousins carry proc 0.
// STATE_WORK_NOSHEATHE_MINING (233), a proc-2 state emote carrying the mining kit.
assert_eq!(cat.spec_proc(233), Some(2), "mining state EmoteSpecProc");
assert_eq!(
cat.event_sound(233),
@@ -315,15 +259,12 @@ mod tests {
);
assert_eq!(cat.anim(233), Some(136), "mining state plays anim 136");
assert_eq!(cat.spec_proc(2), Some(0), "ONESHOT_BOW EmoteSpecProc");
// The posture branch (`DoEmote`'s `EmoteSpecProc == 1` → `SetStandState(param)`): the four
// stand states a slash emote can reach, byte-decoded off the shipped table. The values are
// vmangos `UnitStandStateType` (STAND 0 · SIT 1 · SLEEP 3 · KNEEL 8).
// Stand states are vmangos `UnitStandStateType` values (STAND 0, SIT 1, SLEEP 3, KNEEL 8).
assert_eq!(cat.posture_state(13), Some(1), "STATE_SIT sets SIT");
assert_eq!(cat.posture_state(12), Some(3), "STATE_SLEEP sets SLEEP");
assert_eq!(cat.posture_state(68), Some(8), "STATE_KNEEL sets KNEEL");
assert_eq!(cat.posture_state(26), Some(0), "STATE_STAND sets STAND");
// A proc-2 state emote and a one-shot are NOT posture emotes, however tempting their param
// looks: the gate is the proc column, not the param's presence.
// The gate is the proc column, not the param's presence.
assert_eq!(cat.posture_state(233), None, "mining state is proc 2");
assert_eq!(cat.posture_state(2), None, "ONESHOT_BOW is proc 0");
assert!(
@@ -1,14 +1,6 @@
//! **EnvironmentalDamage.dbc** — the environmental-damage feedback table: damage type →
//! `SpellVisualKit` id, played on the victim when `SMSG_ENVIRONMENTALDAMAGELOG` arrives (the
//! fall-landing dust puff and its five siblings).
//!
//! The client shape: init `0x603900`
//! zeroes a **6-slot table** (`[0xc4d8e4]`) and, for each record with `field1 < 6`, stores
//! `slot[field1] = field2` — so field 1 is the `EnvironmentalDamageType` enum (0 exhausted ·
//! 1 drowning · 2 fall · 3 lava · 4 slime · 5 fire; the wire's `damage_type`) and field 2 the
//! kit id. Sole reader `0x624fcc` (in the 0x1FC consequence method `0x624f30`) bounds the kit id
//! against `SpellVisualKit.dbc` and plays it. Build 5875 data: 0→871, 1→870, 2→**1066** (the
//! "DustCloud Land" kit), 3→1064, 4→1065, 5→1067.
//! `EnvironmentalDamage.dbc`: the `SpellVisualKit` played on the victim of
//! `SMSG_ENVIRONMENTALDAMAGELOG` (read at `0x624fcc` in `0x624f30`), by the wire's `damage_type`:
//! 0 exhausted, 1 drowning, 2 fall, 3 lava, 4 slime, 5 fire.
use crate::Chain;
use anyhow::{Context, Result};
@@ -18,14 +10,13 @@ use crate::dbc::{parse, u32_at};
const ENVIRONMENTAL_DAMAGE: &str = "DBFilesClient\\EnvironmentalDamage.dbc";
/// The 6-slot damage-type → `SpellVisualKit` table (the client's `[0xc4d8e4]`).
/// The client's 6-slot damage type → kit table (`[0xc4d8e4]`).
pub struct EnvironmentalDamageTable {
kits: [u32; 6],
}
impl EnvironmentalDamageTable {
/// The `SpellVisualKit` id for a wire `damage_type` (0–5). `None` for an out-of-range type or
/// an empty slot — the client's `field1 < 6` init guard and zero-init respectively.
/// The kit for a wire `damage_type`; `None` past type 5 or for an empty slot.
pub fn kit_id(&self, damage_type: u8) -> Option<u32> {
self.kits
.get(usize::from(damage_type))
@@ -34,7 +25,6 @@ impl EnvironmentalDamageTable {
}
}
/// EnvironmentalDamage.dbc — 3 fields in build 5875: ID, the damage-type enum, the kit id.
fn schema() -> Schema {
let mut s = Schema::new("EnvironmentalDamage");
for name in ["ID", "DamageType", "VisualKit"] {
@@ -43,8 +33,8 @@ fn schema() -> Schema {
s
}
/// Load the table from the patch chain, exactly as the client's init `0x603900` fills its 6-slot
/// store: zeroed slots, `slot[DamageType] = VisualKit` for each in-range record.
/// Load the table as the client's init `0x603900` fills it: zeroed, then
/// `slot[DamageType] = VisualKit` for each row whose type is below 6.
pub fn load_environmental_damage(chain: &mut Chain) -> Result<EnvironmentalDamageTable> {
let bytes = chain
.read_file(ENVIRONMENTAL_DAMAGE)
@@ -52,7 +42,6 @@ pub fn load_environmental_damage(chain: &mut Chain) -> Result<EnvironmentalDamag
table_from(&bytes)
}
/// The fill itself, split from the chain read so the golden test drives the identical path.
fn table_from(bytes: &[u8]) -> Result<EnvironmentalDamageTable> {
let rs = parse(bytes, schema(), "EnvironmentalDamage")?;
let mut kits = [0u32; 6];
@@ -70,8 +59,7 @@ fn table_from(bytes: &[u8]) -> Result<EnvironmentalDamageTable> {
mod tests {
use super::*;
/// A hand-built WDBC with the real 5875 rows (verified via benilla-extract 2026-07-16) plus an
/// out-of-range type-9 row that must hit the `field1 < 6` init guard and load nowhere.
/// The shipped rows, plus a type-9 row that the range guard must skip.
#[test]
fn table_loads_by_damage_type_with_the_range_guard() {
let rows: &[[u32; 3]] = &[
+9 -23
View File
@@ -1,17 +1,7 @@
//! Exhaustion.dbc — the rest-state table behind the client's rested-XP surface.
//!
//! The whole client contract (decision 1087): `GetRestState 0x48d350` indexes this table
//! **directly by the `PLAYER_BYTES_2` rest-state byte** (an ID→row-ptr array, `[0xc0dd78]`) and
//! returns `(row.ID, row.name[locale], row.factor)`; `GetXPExhaustion 0x48d3f0` multiplies the
//! rested pool by **row ID 1's factor, hard-coded** (2.0 in the shipped data — the "rested XP is
//! double" law is this one f32, not a client constant). The names come from this file's string
//! block, **not** GlobalStrings — which is why "Rested"/"Normal" localize with the install.
//!
//! Row layout (fieldCount 0xf / recordSize 0x3c, validated by the client loader `0x544da0`):
//! `ID@0`, `Xp@1`, `Factor@2` (f32), `OutdoorHours@3`, `InnHours@4` (both unread by the
//! bindings), `Name_Lang@5..12`, `NameFlags@13`, `Threshold@14`. Shipped 5875 rows: 1 "Rested"
//! 2.0 · 2 "Normal" 1.0 · 3/4 "XXXTired" 1.0/0.5 · 5 "XXXExhausted" 0.25 — rows 3..5 are the
//! never-shipped beta tiers FrameXML still carries dead branches for.
//! `Exhaustion.dbc`: the rest states. `GetRestState` (`0x48d350`) indexes it by the
//! `PLAYER_BYTES_2` rest-state byte (`[0xc0dd78]`) and returns the id, the name from this file's
//! string block (not GlobalStrings) and the factor. `GetXPExhaustion` (`0x48d3f0`) scales the
//! rested pool by row 1's factor, 2.0 in the data. Rows 3-5 are beta tiers never sent.
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
@@ -21,14 +11,13 @@ use crate::Chain;
const EXHAUSTION: &str = "DBFilesClient\\Exhaustion.dbc";
/// One Exhaustion.dbc row as the rest bindings consume it.
/// One rest state, as `GetRestState` returns it.
pub struct ExhaustionRow {
/// The row ID — also exactly the wire's rest-state byte (the client indexes by it).
/// Also the wire's rest-state byte, which the client indexes by.
pub id: u32,
/// The localized state name (`GetRestState`'s second return; enUS slot of this install).
/// The state name, from the enUS slot.
pub name: String,
/// The XP multiplier (`GetRestState`'s third return; row 1's value is `GetXPExhaustion`'s
/// scale).
/// The XP multiplier.
pub factor: f32,
}
@@ -47,7 +36,7 @@ fn exhaustion_schema() -> Schema {
s
}
/// Load Exhaustion.dbc from the patch chain — the rows for `UiScript::set_exhaustion_rows`.
/// Load Exhaustion.dbc from the patch chain.
pub fn load_exhaustion(chain: &mut Chain) -> Result<Vec<ExhaustionRow>> {
let bytes = chain
.read_file(EXHAUSTION)
@@ -68,9 +57,6 @@ pub fn load_exhaustion(chain: &mut Chain) -> Result<Vec<ExhaustionRow>> {
mod tests {
use super::load_exhaustion;
/// The shipped 5875 table, read as data rather than assumed — five rows: the rested factor
/// really is 2.0-as-data, and the beta tiers really ship with placeholder names. Skips
/// without client data.
#[test]
fn the_shipped_rest_states_carry_the_rested_double() {
let data = crate::wow_data_or_skip!();
+95 -250
View File
@@ -1,32 +1,12 @@
//! Unit reaction: the FactionTemplate.dbc catalog + the client's faction-reaction comparator.
//! Unit reaction: the `FactionTemplate.dbc` catalog and the 1.12 client's reaction comparator.
//!
//! A unit's `UNIT_FIELD_FACTIONTEMPLATE` indexes **FactionTemplate.dbc**; the real client decides
//! hostile/neutral/friendly by comparing two of those rows with one pure function — the reaction
//! comparator at `0x606640`. Every
//! `UnitReaction`/`CanAttack`/`CanAssist` predicate bottoms out in it, and it's what colours nameplates
//! and the ground selection ring (red/yellow/green). [`FactionTemplate::reaction_toward`] is that
//! function, branch-for-branch.
//! A unit's `UNIT_FIELD_FACTIONTEMPLATE` indexes `FactionTemplate.dbc`, and the reference's
//! comparator `0x606640` turns two rows into hostile, neutral or friendly; every `UnitReaction`,
//! `CanAttack` and `CanAssist` bottoms out in it. It is only the DBC half of `UnitReaction`
//! (`0x6061e0`), whose group, charm, PvP-flag and sanctuary overrides need session state.
//!
//! Record layout verified by the offsets the comparator reads (0x38-byte rows = 14 × u32, matching
//! vmangos `FactionTemplateEntry`): id@0, faction@1 (Faction.dbc id), flags@2, factionGroupMask@3,
//! friendGroupMask@4, enemyGroupMask@5, enemies[4]@6..9, friends[4]@10..13 (both id lists
//! 0-terminated).
//!
//! Also here: the **reputation identity** side of the decode — Faction.dbc's reputation slot,
//! parent pointer and race/class-gated base values ([`FactionInfo`]), the standing→rank thresholds
//! ([`reputation_rank`]), and the names of the wire flag byte's bits ([`faction_flags`]). The client
//! checks `FactionHasReputation` *before* the comparator (`0x606530`): a reputation faction's NPCs
//! colour by the player's rank, not by templates. The live standings are session state
//! (`SMSG_INITIALIZE_FACTIONS`) and live with the net layer.
//!
//! Two consumers share that identity now — the reaction decode above, and the **reputation pane**
//! (decision 1258, whose display law is `benilla-ui`'s `script::reputation`). Where both need the
//! same number they take it from the same function here, so a rank the pane draws and a colour the
//! nameplate paints can never disagree.
//!
//! Deliberately **not** here: the rest of the real `UnitReaction 0x6061e0` orchestration —
//! same-object/group/guild shortcuts, charm resolution, PvP-flag + sanctuary overrides. Those need
//! player/session state, not DBC rows; they land with the systems that own it.
//! Also `Faction.dbc`'s reputation identity: the reference checks `FactionHasReputation` before the
//! comparator (`0x606530`), so a reputation faction's NPCs colour by the player's rank.
use std::collections::HashMap;
@@ -40,17 +20,14 @@ const FACTION_TEMPLATE: &str = "DBFilesClient\\FactionTemplate.dbc";
const FACTION: &str = "DBFilesClient\\Faction.dbc";
const FACTION_GROUP: &str = "DBFilesClient\\FactionGroup.dbc";
/// One FactionTemplate.dbc row — exactly the fields the reaction comparator reads (plus `faction`,
/// which the id-list scans compare against).
/// One `FactionTemplate.dbc` row: the fields the reaction comparator reads.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FactionTemplate {
/// The row's `Faction.dbc` id — what another template's `enemies`/`friends` lists name.
/// The row's `Faction.dbc` id, what another template's `enemies`/`friends` lists name.
pub faction: u32,
/// The faction-group bits this template belongs to (Player/Alliance/Horde/Monster).
pub group_mask: u32,
/// Faction groups this template is friendly toward.
pub friend_group_mask: u32,
/// Faction groups this template is hostile toward.
pub enemy_group_mask: u32,
/// Explicit enemy `Faction.dbc` ids, 0-terminated.
pub enemies: [u32; 4],
@@ -58,9 +35,8 @@ pub struct FactionTemplate {
pub friends: [u32; 4],
}
/// A unit's base reaction toward another, on the client's own scale (`0x606640` returns exactly
/// these three; the extended reputation path widens the scale to 0..7, hence the gaps). The
/// nameplate/ring palette thresholds on this scale: `<= 1` red, `>= 4` green, else yellow.
/// A unit's base reaction toward another on the reference's scale: `0x606640` returns these three,
/// and the reputation path widens the scale to 0..7, hence the gaps.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Reaction {
Hostile = 1,
@@ -69,16 +45,9 @@ pub enum Reaction {
}
impl FactionTemplate {
/// The real client's faction-template reaction comparator — `self`'s reaction toward `other`.
///
/// Branch-for-branch `0x606640`: enemy
/// group mask, then the enemies id list, then friendship *both ways* (self's friend mask/list
/// toward other, then other's friend mask/list toward self), else neutral. Hostility is only
/// ever **self's** — and that asymmetry is load-bearing: a player template is enemy-masked
/// against the whole Monster group (why you *may attack* a yellow beast), while a starter-zone
/// kobold's template carries no enemy data at all. Colour by the **unit's** reaction toward the
/// player — the direction the nameplate resolver `0x7cbaa0` verifiably calls (`this` = the unit)
/// — or every wild beast would wrongly read hostile red.
/// `self`'s reaction toward `other`, branch for branch `0x606640`. Hostility is only ever
/// `self`'s, so colour by the unit's reaction toward the player, as the nameplate resolver
/// `0x7cbaa0` does: a player template is enemy-masked against the whole Monster group.
pub fn reaction_toward(&self, other: &FactionTemplate) -> Reaction {
if self.enemy_group_mask & other.group_mask != 0 {
return Reaction::Hostile;
@@ -117,52 +86,29 @@ impl FactionTemplate {
}
}
/// One Faction.dbc row's reputation identity: the reputation-list slot plus the race/class-gated
/// base values the client adds to the wire standing. Only rows with `rep_index >= 0` participate in
/// reputation (`FactionHasReputation`, the client's `[faction+4] >= 0` check).
/// One `Faction.dbc` row's reputation identity: its list slot and the race/class-gated base values
/// the reference adds to the wire standing.
#[derive(Debug, Clone, Copy)]
pub struct FactionInfo {
/// `reputationIndex` — the slot into the `SMSG_INITIALIZE_FACTIONS` standings array; `-1` = this
/// faction has no reputation (reaction falls through to the template comparator).
/// `reputationIndex`, the slot in the `SMSG_INITIALIZE_FACTIONS` standings, or -1 for none
/// (`FactionHasReputation` tests `[faction+4] >= 0`).
pub rep_index: i32,
/// `Faction.dbc` field 18 — `team` in mangos, `m_parentFactionID` in the later builds' WDB
/// names. The **parent pointer**: Stormwind/Ironforge/Darnassus/Gnomeregan Exiles all carry 469
/// (Alliance), the four Steamwheedle towns carry 169, the six battleground factions carry
/// 891/892, and `0` means no parent. It is the only tree edge 1.12's `Faction.dbc` has.
///
/// It is the reputation pane's **grouping key**, but *not* what makes a row a header — that is
/// the wire flag byte's [`faction_flags::HEADER`] bit. The distinction matters because the
/// client synthesizes a header for a group key that has no header row yet, and because two of
/// the pane's headers (`0` "Other" and `-1` "Inactive") name no faction at all.
/// `Faction.dbc` field 18 (`team` in mangos): the parent faction, 0 for none, the only tree
/// edge the 1.12 table has and the reputation pane's grouping key. A row is a header by
/// [`faction_flags::HEADER`], not by being a parent.
pub team: u32,
race_masks: [u32; 4],
class_masks: [u32; 4],
base: [i32; 4],
/// Per-slot reputation flags — bit `0x4` (mangos `FACTION_FLAG_HIDDEN`) on the matched slot
/// suppresses the unit tooltip's faction-name line.
/// Per-slot `ReputationFlags`; `0x4` on the matched slot hides the unit tooltip's faction line.
flags: [u32; 4],
}
impl FactionInfo {
/// The race/class-gated slot the **client** picks for a player of `race`/`class` (1-based ids),
/// or `None` if no slot fits.
///
/// `None` is not merely "no base value": it is the reputation pane's **membership gate** — the
/// client's list build calls its add inside this loop's accept block (`0x4d5555`), so a faction
/// no slot fits is not in the player's list at all.
///
/// This is `0x4d5500`–`0x4d5564`, and it differs from vmangos's `GetIndexFitTo`
/// in **two** ways that this project got wrong first time by reading the emulator instead:
///
/// 1. A slot with **both** masks zero is a **reject**, not a double wildcard (`0x4d550b`). Only
/// one of the two may be zero-as-wildcard.
/// 2. The loop has **no early exit** — the **last** matching slot wins, not the first.
///
/// On the shipped 1.12 `Faction.dbc` the distinction is invisible: over all 190 rows × 72 valid
/// race/class pairs, zero of the 13,680 evaluations disagree with the first-match rule (no row
/// has a both-zero slot with a non-zero base, and none has two matching slots with different
/// bases). It is written the client's way regardless, because a rule that is right only for the
/// data we happen to ship is not a rule — a patched DBC would diverge silently.
/// The slot the reference (`0x4d5500`..`0x4d5564`) picks for a `race`/`class`, both 1-based.
/// `None` also keeps the faction out of the reputation pane, whose list build adds a faction
/// only in this loop's accept block (`0x4d5555`). Unlike vmangos's `GetIndexFitTo`, a slot
/// with both masks zero is rejected (`0x4d550b`), and the last matching slot wins.
pub fn slot_for(&self, race: u8, class: u8) -> Option<usize> {
let race_mask = 1u32 << (race.max(1) - 1);
let class_mask = 1u32 << (class.max(1) - 1);
@@ -174,32 +120,22 @@ impl FactionInfo {
})
}
/// The base reputation for a player of `race`/`class` — the [`Self::slot_for`] slot's value,
/// `0` if none fits. This is the term the wire standing EXCLUDES: the total a rank is taken
/// from is this plus the wire value (byte-verified at `0x4d6370`: `wire + base`, and each
/// addend has exactly one writer image-wide).
/// The base reputation for a player of `race`/`class`, 0 if no slot fits. The wire standing
/// excludes it: a rank is taken from `wire + base` (`0x4d6370`).
pub fn base_for(&self, race: u8, class: u8) -> i32 {
self.slot_for(race, class).map_or(0, |i| self.base[i])
}
/// The DBC's **default** reputation flag byte for a player of `race`/`class` — the same slot
/// pick as [`Self::base_for`], reading the `ReputationFlags` column instead of the base
/// (vmangos `ReputationMgr::GetDefaultStateFlags`). `0` if no slot fits.
///
/// **The client never reads this column** (a byte-verified negative — the flag byte it displays
/// from is entirely the wire's). It is the *server's* seed for a fresh character's per-slot
/// state, and it is read here only to say what a faction is by default: notably that exactly
/// the pane's five header factions carry [`faction_flags::HEADER`].
/// The `ReputationFlags` column for a player of `race`/`class`, 0 if no slot fits: the server's
/// seed for a new character's flag byte (vmangos `ReputationMgr::GetDefaultStateFlags`). The
/// reference never reads this column; the byte it displays is the wire's.
pub fn default_flags_for(&self, race: u8, class: u8) -> u32 {
self.slot_for(race, class).map_or(0, |i| self.flags[i])
}
/// Whether the unit tooltip shows this faction's name to a player of `race`/`class` — the
/// byte loop in the unit builder `0x529fe0`: the FIRST of the four reputation slots that
/// matches decides, and it shows the line iff its flags lack the hidden bit `0x4`. The slot
/// match differs from [`Self::base_for`]'s in one spot the bytes insist on: a zero race mask
/// substitutes the CLASS mask as the match value, so a slot with both masks zero matches
/// nothing here (while `base_for` treats it as a wildcard). No matching slot → no line.
/// Whether the unit tooltip shows this faction's name to a player of `race`/`class`: the unit
/// builder `0x529fe0` takes the first matching slot, not the last, and shows the line unless
/// its flags carry `0x4`. A zero race mask matches on the class mask instead.
pub fn tooltip_shows_for(&self, race: u8, class: u8) -> bool {
let race_bit = 1u32 << (race.max(1) - 1);
let class_bit = 1u32 << (class.max(1) - 1);
@@ -218,11 +154,9 @@ impl FactionInfo {
}
}
/// Reputation rank thresholds (vanilla): total standing (base + wire) → rank `0..=7` (hated,
/// hostile, unfriendly, neutral, friendly, honored, revered, exalted). The rank scale **is** the
/// client's extended reaction scale — the ring/nameplate palette thresholds apply to it directly
/// (`<= 1` red, `>= 4` green, else yellow). Rank widths from the bottom (−42000): 36000, 3000,
/// 3000, 3000, 6000, 12000, 21000, 1000 (vmangos `PointsInRank`, matching the client).
/// Total standing (base + wire) to rank 0..=7, hated to exalted, which is also the reference's
/// extended reaction scale. Widths from −42000: 36000, 3000, 3000, 3000, 6000, 12000, 21000, 1000
/// (vmangos `PointsInRank`).
pub fn reputation_rank(total_standing: i32) -> u8 {
match total_standing {
i32::MIN..=-6001 => 0, // hated
@@ -236,53 +170,31 @@ pub fn reputation_rank(total_standing: i32) -> u8 {
}
}
/// The per-slot reputation flag byte the server keeps for each of the player's factions and sends
/// as the first field of every `SMSG_INITIALIZE_FACTIONS` entry.
///
/// **Named from the CLIENT's own reads, not from the emulators' enum** (the thirteen accesses to
/// the store at `0xb73294` are the complete image-wide population). That distinction is not
/// pedantry — the two disagree on bit `0x08`, which every emulator calls `INVISIBLE_FORCED` and
/// the client tests as **HEADER** (`entry.isHeader = (flags >> 3) & 1`, `0x4d5acb`). Believing the
/// emulator's name gets the *right rows* on screen for the wrong reason (the five factions carrying
/// it are exactly the pane's headers, so "hide these" and "these are headers" pick the same set)
/// and then gets the header machinery wrong everywhere it matters.
///
/// The same bit layout appears in `Faction.dbc`'s four `ReputationFlags` columns, which is where the
/// **server** seeds a slot's initial byte from (`ReputationMgr::GetDefaultStateFlags`). The client
/// never reads that column — the byte it displays from is entirely the wire's.
///
/// Two of these the client also *writes*, optimistically, because neither send is acked: [`AT_WAR`]
/// and [`INACTIVE`].
/// The per-faction flag byte, the first field of each `SMSG_INITIALIZE_FACTIONS` entry, named from
/// the reference's own reads of its store (`0xb73294`), not from the emulators' enum. The reference
/// writes [`AT_WAR`] and [`INACTIVE`] itself, optimistically, since neither send is acked.
pub mod faction_flags {
/// The faction is listed in the reputation pane at all. Off until the player first meets them;
/// the server lifts it and pushes `SMSG_SET_FACTION_VISIBLE`. The **only** flag gating list
/// membership.
/// Listed in the reputation pane, the only flag that gates membership; the server sets it on
/// first contact and sends `SMSG_SET_FACTION_VISIBLE`.
pub const VISIBLE: u8 = 0x01;
/// The player has declared war on this faction.
pub const AT_WAR: u8 = 0x02;
/// Suppresses the auto-reveal on a standing change and the rank-change chat notification. It
/// does **not** hide the row: a faction with this bit and [`VISIBLE`] is listed normally.
/// Suppresses the standing-change auto-reveal and rank-change chat line; the row stays listed.
pub const HIDDEN: u8 = 0x04;
/// **This row is a header.** Not "force-invisible" — see the module doc; the client's own test
/// is `(flags >> 3) & 1` at `0x4d5acb`. The five factions that carry it (Alliance, Horde,
/// Steamwheedle Cartel, and the two battleground blocs) are the pane's header rows.
/// The row is a header (`(flags >> 3) & 1`, `0x4d5acb`), a bit the emulators name
/// `INVISIBLE_FORCED`. The five factions carrying it are the pane's header rows.
pub const HEADER: u8 = 0x08;
/// Overrides [`AT_WAR`]: war can never be declared, so the pane's box is disabled. Your own
/// side's city factions carry it.
/// Overrides [`AT_WAR`]: war can never be declared; your own side's cities carry it.
pub const PEACE_FORCED: u8 = 0x10;
/// The player moved this faction to the pane's inactive bucket — which re-parents the row under
/// the synthetic "Inactive" header, rather than hiding it.
/// In the pane's inactive bucket, under the synthetic "Inactive" header rather than hidden.
pub const INACTIVE: u8 = 0x20;
/// The two competing factions of a rival pair. **The 1.12 client never tests this bit** — it is
/// here to name the byte completely, not because anything reads it.
/// One of a rival pair; the 1.12 client never tests this bit.
pub const RIVAL: u8 = 0x40;
}
impl Reaction {
/// A reputation rank (`0..=7`, [`reputation_rank`]) collapsed to the three-way scale, the way
/// the **nameplate** palette thresholds it (`0x7cbaa0`: `<= 1` hostile, `>= 4` friendly, else
/// neutral). The selection **ring** does *not* collapse — its own palette (`0x605960`) keys the
/// raw rank and has a distinct orange at rank 2 — so ring consumers use the rank directly.
/// A rank (0..=7) collapsed to three, as the nameplate palette `0x7cbaa0` does. The selection
/// ring's palette (`0x605960`) keys the raw rank instead, with its own orange at 2.
pub fn from_rank(rank: u8) -> Reaction {
match rank {
0..=1 => Reaction::Hostile,
@@ -292,60 +204,44 @@ impl Reaction {
}
}
/// FactionTemplate.dbc + Faction.dbc + FactionGroup.dbc loaded into id → row maps (the template id
/// is what a unit's `UNIT_FIELD_FACTIONTEMPLATE` carries; the faction id is the template's
/// `faction` field; the group names key by mask bit).
/// `FactionTemplate.dbc`, `Faction.dbc` and `FactionGroup.dbc` as id-to-row maps.
pub struct FactionCatalog {
templates: HashMap<u32, FactionTemplate>,
factions: HashMap<u32, FactionInfo>,
/// `1 << FactionGroup.MaskID` → the localized group Name ("Alliance", "Horde") — what
/// `GetZonePVPInfo`'s territory line formats (`0x48d540` reads FactionGroup.dbc Name of the
/// row whose bit ∈ the zone's FactionGroupMask).
/// `1 << MaskID` to the localized group name, `GetZonePVPInfo`'s territory line (`0x48d540`).
group_names: HashMap<u32, String>,
/// The same key → FactionGroup.dbc's **`InternalName`** (field 2), which is English on every
/// locale where `Name0` is not. Kept beside the localized map rather than derived from it
/// because `UnitFactionGroup` returns BOTH and they are not interchangeable: its first return
/// names a TEXTURE — stock builds `"Interface\TargetingFrame\UI-PVP-"..factionGroup`
/// (`PlayerFrame.lua:68`, `TargetFrame.lua:198`, `PartyMemberFrame.lua:125`) and
/// `"…\Battleground-"..UnitFactionGroup("player")` (`BattlefieldFrame.lua:195`) — so a
/// localized string there is a path that does not exist. `HonorFrame.lua:68` compares it to
/// the literal `"Alliance"` for the same reason.
/// The same key to `InternalName` (field 2), English on every locale: `UnitFactionGroup`'s
/// first return, which stock FrameXML concatenates into texture paths (`PlayerFrame.lua:68`).
group_internal_names: HashMap<u32, String>,
/// Faction id → the localized Name — what the item tooltip's "Requires <Faction> -
/// <Standing>" line prints.
/// Faction id to localized name, for the item tooltip's "Requires <Faction> - <Standing>".
names: HashMap<u32, String>,
/// Faction id → the localized Description — the paragraph the reputation pane's detail popup
/// prints under the faction name. Absent for most rows: only the pane's own factions carry one.
/// Faction id to localized description, the reputation pane's detail paragraph; most have none.
descriptions: HashMap<u32, String>,
}
impl FactionCatalog {
/// The template row for a `UNIT_FIELD_FACTIONTEMPLATE` id, or `None` for an id the DBC lacks.
/// The template row for a `UNIT_FIELD_FACTIONTEMPLATE` id.
pub fn template(&self, id: u32) -> Option<&FactionTemplate> {
self.templates.get(&id)
}
/// The reputation identity of a `Faction.dbc` id, `Some` only when the faction actually has a
/// reputation slot — the client's `FactionHasReputation` (`0x605fc0`, `[faction+4] >= 0`). For
/// these factions unit reaction is the **player's reputation rank**, checked *before* the
/// template comparator (`0x606530`).
/// A `Faction.dbc` id's reputation identity, `Some` only with a reputation slot: the
/// reference's `FactionHasReputation` (`0x605fc0`), tested before the comparator (`0x606530`).
pub fn reputation_faction(&self, faction_id: u32) -> Option<&FactionInfo> {
self.factions.get(&faction_id).filter(|f| f.rep_index >= 0)
}
/// The localized name of a `Faction.dbc` id — the reputation-requirement line's faction.
/// The localized name of a `Faction.dbc` id.
pub fn faction_name(&self, faction_id: u32) -> Option<&str> {
self.names.get(&faction_id).map(String::as_str)
}
/// The localized description of a `Faction.dbc` id — the reputation pane's detail paragraph.
/// `None` where the DBC leaves it empty, which is most non-pane factions.
/// The localized description of a `Faction.dbc` id, the reputation pane's detail paragraph.
pub fn faction_description(&self, faction_id: u32) -> Option<&str> {
self.descriptions.get(&faction_id).map(String::as_str)
}
/// Every faction that has a reputation slot, with its identity — the app builds the
/// player's faction → rank map from this (base + wire standing, ranked).
/// Every faction with a reputation slot, with its identity.
pub fn reputation_factions(&self) -> impl Iterator<Item = (u32, &FactionInfo)> {
self.factions
.iter()
@@ -353,11 +249,9 @@ impl FactionCatalog {
.map(|(&id, f)| (id, f))
}
/// The localized FactionGroup name of the (first) group bit set in `mask` — "Alliance" for 2,
/// "Horde" for 4; `None` for an unowned mask. The `GetZonePVPInfo` territory-line lookup.
/// The **English** group name for a mask — FactionGroup.dbc's `InternalName`. This is the one
/// `UnitFactionGroup`'s FIRST return must carry, because callers concatenate it into a texture
/// path; [`Self::faction_group_name`] is the localized twin for the second return.
/// The English `InternalName` of the first group bit in `mask`: `UnitFactionGroup`'s first
/// return, which callers build texture paths from. [`Self::faction_group_name`] is the
/// localized second return and the `GetZonePVPInfo` territory name.
pub fn faction_group_internal_name(&self, mask: u32) -> Option<&str> {
(0..32)
.map(|b| 1u32 << b)
@@ -374,19 +268,18 @@ impl FactionCatalog {
.map(String::as_str)
}
/// Number of template rows (for logging/diagnostics).
/// Number of template rows.
pub fn len(&self) -> usize {
self.templates.len()
}
/// Whether no rows loaded.
pub fn is_empty(&self) -> bool {
self.templates.is_empty()
}
}
/// FactionTemplate.dbc — 14 fields in build 5875 (`14 × 4 == 56`-byte records, the exact offsets the
/// client comparator reads; cross-checked with vmangos `FactionTemplateEntry`).
/// `FactionTemplate.dbc`: 14 fields, 56-byte rows at the offsets `0x606640` reads (vmangos
/// `FactionTemplateEntry`).
fn faction_template_schema() -> Schema {
let mut s = Schema::new("FactionTemplate");
for (name, ty) in [
@@ -408,9 +301,7 @@ fn faction_template_schema() -> Schema {
s
}
/// Faction.dbc — 37 fields in build 5875 (`ID`, `reputationIndex`, 4× race masks, 4× class masks,
/// 4× base values, 4× reputation flags, `team`, then 8+1 name and 8+1 description columns;
/// cross-checked with vmangos `FactionEntry` and the file header). We read the reputation identity.
/// `Faction.dbc`: 37 fields in build 5875 (vmangos `FactionEntry`).
fn faction_schema() -> Schema {
let mut s = Schema::new("Faction");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
@@ -439,9 +330,8 @@ fn faction_schema() -> Schema {
s
}
/// FactionGroup.dbc — 12 fields in build 5875 (48-byte records: `ID`, `MaskID` (the bit index),
/// an internal name, then the 8+1 localized Name block; layout per the `GetZonePVPInfo` reader
/// `0x48d540` — MaskID@+0x4, Name@+0xc).
/// `FactionGroup.dbc`: 12 fields, 48-byte rows; `MaskID` is a bit index, read by `0x48d540` at
/// `+0x4` with the name at `+0xc`.
fn faction_group_schema() -> Schema {
let mut s = Schema::new("FactionGroup");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
@@ -454,7 +344,7 @@ fn faction_group_schema() -> Schema {
s
}
/// Load FactionTemplate.dbc + Faction.dbc from the patch chain into a [`FactionCatalog`].
/// Load the three faction tables off the patch chain.
pub fn load_faction_catalog(chain: &mut Chain) -> Result<FactionCatalog> {
let bytes = chain
.read_file(FACTION_TEMPLATE)
@@ -500,12 +390,11 @@ pub fn load_faction_catalog(chain: &mut Chain) -> Result<FactionCatalog> {
flags: [at(14), at(15), at(16), at(17)],
},
);
// Name0 (enUS) — column 19 after ID/RepIndex/4·race/4·class/4·base/4·flags/Team.
// Name0 (enUS), column 19: after the ID, the index, 16 slot columns and `Team`.
if let Some(name) = crate::dbc::str_at(&rs, r, 19) {
names.insert(id, name);
}
// Desc0 (enUS) — column 28, past the 8 name columns and their flag word. Empty for
// most rows, so this map is far smaller than `names` and is left unsized.
// Desc0 (enUS), column 28, past the name block; empty on most rows.
if let Some(desc) = crate::dbc::str_at(&rs, r, 28) {
descriptions.insert(id, desc);
}
@@ -522,8 +411,7 @@ pub fn load_faction_catalog(chain: &mut Chain) -> Result<FactionCatalog> {
let (Some(mask_id), Some(name)) = (u32_at(r, 1), crate::dbc::str_at(&rs, r, 3)) else {
continue;
};
// Field 2 is `InternalName`, field 3 is `Name0` — see the schema above. Both are read
// because `UnitFactionGroup` returns the pair and only the localized half was kept before.
// Field 2 is `InternalName`, field 3 `Name0`; `UnitFactionGroup` returns both.
if let Some(internal) = crate::dbc::str_at(&rs, r, 2) {
group_internal_names.insert(1u32 << mask_id, internal);
}
@@ -556,10 +444,7 @@ mod tests {
}
}
/// The comparator's full branch domain, in the byte order `0x606640` tests them: enemy mask,
/// enemies list (with its 0-terminator + 4-entry bound), friend mask (A), friends list (A),
/// friend mask (B toward A's group), friends list (B naming A), neutral fall-through — and
/// enemy-beats-friend precedence.
/// Every branch, in the order `0x606640` tests them, and enemy-beats-friend precedence.
#[test]
fn comparator_branch_domain() {
let b = FactionTemplate {
@@ -567,42 +452,39 @@ mod tests {
..tpl(50)
};
// (A.enemyGroupMask & B.group) != 0 → hostile.
let a = FactionTemplate {
enemy_group_mask: 0b0010,
..tpl(1)
};
assert_eq!(a.reaction_toward(&b), Reaction::Hostile);
// A.enemies[i] == B.faction → hostile (in the last slot: the loop scans all 4).
// A.enemies[i] == B.faction → hostile, found in the last of the four slots.
let a = FactionTemplate {
enemies: [7, 8, 9, 50],
..tpl(1)
};
assert_eq!(a.reaction_toward(&b), Reaction::Hostile);
// The enemies list is 0-terminated: a match *after* a 0 is never reached.
// The enemies list is 0-terminated: a match after a 0 is never reached.
let a = FactionTemplate {
enemies: [7, 0, 50, 0],
..tpl(1)
};
assert_eq!(a.reaction_toward(&b), Reaction::Neutral);
// (A.friendGroupMask & B.group) != 0 → friendly.
let a = FactionTemplate {
friend_group_mask: 0b0010,
..tpl(1)
};
assert_eq!(a.reaction_toward(&b), Reaction::Friendly);
// A.friends[i] == B.faction → friendly.
let a = FactionTemplate {
friends: [50, 0, 0, 0],
..tpl(1)
};
assert_eq!(a.reaction_toward(&b), Reaction::Friendly);
// (B.friendGroupMask & A.group) != 0 → friendly (friendship read both ways).
// Friendship is read both ways: B's friend mask covers A's group.
let a = FactionTemplate {
group_mask: 0b1000,
..tpl(1)
@@ -613,17 +495,15 @@ mod tests {
};
assert_eq!(a.reaction_toward(&b_friendly), Reaction::Friendly);
// B.friends[i] == A.faction → friendly.
let b_names_a = FactionTemplate {
friends: [1, 0, 0, 0],
..b
};
assert_eq!(tpl(1).reaction_toward(&b_names_a), Reaction::Friendly);
// Nothing matches → neutral.
assert_eq!(tpl(1).reaction_toward(&b), Reaction::Neutral);
// Hostility is tested first: an enemy-mask hit wins over any friendship evidence.
// Hostility is tested first: an enemy-mask hit beats any friendship.
let a = FactionTemplate {
enemy_group_mask: 0b0010,
friend_group_mask: 0b0010,
@@ -633,16 +513,8 @@ mod tests {
assert_eq!(a.reaction_toward(&b_names_a), Reaction::Hostile);
}
/// End-to-end on the **real** build-5875 DBC, in the direction the client colours by (the unit's
/// reaction toward the player — verified in the binary: the nameplate resolver `0x7cbaa0` calls
/// `unit->UnitReaction(activePlayer)`): a human player is faction template 1; Marshal Dughan (12)
/// reads friendly and the Chicken (31) neutral (the exact ring colours a reference capture
/// recorded), a Defias Trapper (17) and mine Kobold Miner (26) hostile, and the starter-field
/// Kobold Vermin (25) **neutral** — the famous non-aggro yellow level-1 mob. That last one also
/// locks the direction: the *player's* template is enemy-masked against the whole Monster group,
/// so the reverse direction would wrongly read every yellow beast as hostile. Template ids are
/// the vmangos `creature_template.faction` rows. Guards the schema (a shifted column scrambles
/// every mask) and the comparator against ground truth.
/// Unit templates toward a human player (template 1), the direction `0x7cbaa0` colours by;
/// ids from vmangos `creature_template.faction`, expected colours from a reference capture.
#[test]
fn real_dbc_reactions_match_reference_capture() {
let data = crate::wow_data_or_skip!();
@@ -677,15 +549,11 @@ mod tests {
Reaction::Neutral,
"Kobold Vermin (non-aggro starter kobold)"
);
// The direction asymmetry itself: the player is hostile *toward* the vermin (attackable),
// while the vermin — what the ring colours by — is neutral toward the player.
// The player is hostile toward the vermin (attackable) while the vermin is neutral to it.
assert_eq!(human_player.reaction_toward(tpl(25)), Reaction::Hostile);
// The reputation identity: Stormwind (faction 72, both guard templates 11 + 12 point at it)
// has reputation slot 19 with a human base of 4000 — friendly rank 4 at zero wire standing,
// which is why every Stormwind NPC is green to a human **before** any template comparison
// (even in GM mode — director-verified on the reference client). A critter faction (the
// Chicken's 28) has no reputation slot → the comparator path.
// Stormwind (faction 72) has slot 19 and a human base of 4000, friendly before any
// template comparison; the Chicken's faction 28 has no slot.
let stormwind = cat
.reputation_faction(72)
.expect("Stormwind (72) has reputation");
@@ -693,26 +561,23 @@ mod tests {
assert_eq!(stormwind.base_for(1, 1), 4000, "human base with Stormwind");
assert_eq!(reputation_rank(stormwind.base_for(1, 1)), 4, "friendly");
assert!(cat.reputation_faction(28).is_none(), "critter: no rep");
// Orc (race 2) base with Stormwind is hated (−42000): rank 0 → hostile red.
// Orc (race 2) base with Stormwind is hated (−42000): rank 0, hostile.
assert_eq!(stormwind.base_for(2, 1), -42000);
assert_eq!(reputation_rank(stormwind.base_for(2, 1)), 0);
// The unit tooltip's faction-name line (the `0x529fe0` tail): a Stormwind guard shows
// "Stormwind" to a human — the ref's Marshal McBride "Level 20 / Stormwind / PvP" shot.
// The unit tooltip (`0x529fe0`) shows "Stormwind" under a Stormwind guard to a human.
assert_eq!(cat.faction_name(72), Some("Stormwind"));
assert!(
stormwind.tooltip_shows_for(1, 1),
"Stormwind's matched slot is not rep-hidden for a human"
);
// Players never get the line: the PLAYER,* factions carry no reputation slot, so the
// builder's rep-index gate drops it before the slot walk.
// Players never get the line: player factions have no reputation slot.
assert!(
cat.reputation_faction(tpl(1).faction).is_none(),
"the human player faction has no reputation slot"
);
}
/// The rank thresholds (vanilla `PointsInRank`), exact at every boundary.
#[test]
fn reputation_rank_thresholds() {
for (standing, rank) in [
@@ -735,7 +600,7 @@ mod tests {
] {
assert_eq!(reputation_rank(standing), rank, "standing {standing}");
}
// The palette mapping on the rank scale: <=1 red, >=4 green, else yellow.
// The nameplate's collapse: <= 1 hostile, >= 4 friendly, else neutral.
assert_eq!(Reaction::from_rank(0), Reaction::Hostile);
assert_eq!(Reaction::from_rank(1), Reaction::Hostile);
assert_eq!(Reaction::from_rank(2), Reaction::Neutral);
@@ -744,10 +609,6 @@ mod tests {
assert_eq!(Reaction::from_rank(7), Reaction::Friendly);
}
/// The GameObject faction term (decision 0764), pinned on the real `FactionTemplate.dbc`: the
/// factions that shipped GameObjects actually carry, resolved **GO → player** (the direction
/// `0x606640` uses). A column slip here would silently blank ~7,563 shipped GO spawns, or fail
/// to blank them — so the two decisive rows are asserted by value. Skips without client data.
#[test]
fn real_gameobject_factions_resolve_toward_both_player_templates() {
let data = crate::wow_data_or_skip!();
@@ -758,22 +619,17 @@ mod tests {
cat.template(2).expect("Horde player template"),
);
// 114 "monster" — what Deadmines' Factory Door and Stratholme's scenery portcullises carry.
// Hostile to BOTH sides, so `> 1` fails and the object is not highlightable for anyone.
// 114 "monster" (Deadmines' Factory Door): hostile to both, so no one can highlight it.
let monster = cat.template(114).expect("FactionTemplate 114");
assert_eq!(monster.reaction_toward(alliance), Reaction::Hostile);
assert_eq!(monster.reaction_toward(horde), Reaction::Hostile);
// 35 — the levers/torches, and (the reason this matters) Arathi Basin's capture banners.
// FRIENDLY to both, so the faction gate must never touch them.
// 35 (levers, torches, Arathi Basin's banners): friendly to both, never gated.
let usable = cat.template(35).expect("FactionTemplate 35");
assert_eq!(usable.reaction_toward(alliance), Reaction::Friendly);
assert_eq!(usable.reaction_toward(horde), Reaction::Friendly);
}
/// The real 5875 FactionGroup table: the territory-line names resolve by mask bit — Alliance
/// mask 2, Horde mask 4, nothing for an unowned 0 (the `GetZonePVPInfo` lookup, `0x48d540`).
/// Skips without client data.
#[test]
fn real_faction_group_names_by_mask() {
let data = crate::wow_data_or_skip!();
@@ -784,13 +640,6 @@ mod tests {
assert_eq!(cat.faction_group_name(0), None);
}
/// **`team` is the reputation pane's tree, on the real table.** 1.12's `Faction.dbc` has no
/// parent column — field 18 (`team`) is the only edge it carries — and every group the pane
/// draws a header for is a set of rows sharing one `team`. Asserted by value on the real DBC
/// because a one-column slip here would silently produce a flat list that still *renders*.
///
/// The counts are the whole table's, so a future patch chain that changes them fails loudly
/// rather than quietly regrouping the pane. Skips without client data.
#[test]
fn real_reputation_factions_group_under_their_team() {
let data = crate::wow_data_or_skip!();
@@ -811,9 +660,7 @@ mod tests {
for id in [76 /* Orgrimmar */, 81, 68, 530] {
assert_eq!(cat.reputation_faction(id).unwrap().team, 67, "faction {id}");
}
// The Steamwheedle towns sit under the cartel itself (169) — a header that is also a
// reputation faction in its own right (slot 10), which is why "is a header" cannot be
// "has no reputation slot".
// The Steamwheedle towns sit under the cartel (169), a header with its own slot (10).
for id in [21 /* Booty Bay */, 369, 470, 577] {
assert_eq!(
cat.reputation_faction(id).unwrap().team,
@@ -822,15 +669,14 @@ mod tests {
);
}
assert_eq!(cat.reputation_faction(169).unwrap().rep_index, 10);
// …and the parents themselves have no parent: they are the top of the tree.
for id in [469, 67, 169] {
assert_eq!(cat.reputation_faction(id).unwrap().team, 0, "faction {id}");
}
// The pane's leftovers — Argent Dawn and the rest — carry no team at all.
// Argent Dawn and the other ungrouped factions carry no team.
assert_eq!(cat.reputation_faction(529).unwrap().team, 0);
// Exactly five factions are named as a parent by somebody, and every one of them is a
// header the pane draws: Alliance, Horde, Steamwheedle Cartel, and the two BG blocs.
// Exactly five parents, each a pane header: Alliance, Horde, Steamwheedle Cartel and the
// two battleground blocs.
let mut parents: Vec<u32> = cat
.reputation_factions()
.map(|(_, f)| f.team)
@@ -841,7 +687,6 @@ mod tests {
parents.sort_unstable();
assert_eq!(parents, [67, 169, 469, 891, 892]);
// The detail popup's paragraph comes off the same row, past the eight name columns.
assert!(
cat.faction_description(529)
.is_some_and(|d| d.starts_with("An organization focused on protecting Azeroth")),
+26 -62
View File
@@ -1,27 +1,12 @@
//! Footsteps: the terrain-type chain (decision 0070 slice 3) —
//! `ground texture → GroundEffectTexture.TerrainType → TerrainType.SoundID ×
//! CreatureSoundData.FootstepID → FootstepTerrainLookup → SoundEntries (dry | splash)`.
//! Footsteps: ground texture → `GroundEffectTexture.TerrainType` → `TerrainType.SoundID`, joined
//! with `CreatureSoundData.FootstepID` in `FootstepTerrainLookup` to a dry and a splash
//! `SoundEntries` kit. `TerrainType.dbc` is `ID`, `Desc`, `FootstepSprayRun`, `FootstepSprayWalk`,
//! `SoundID`, `Flags`: 11 rows, `SoundID` 1 to 9 (DustyGrass shares Grass's 6) and 0 for None.
//!
//! Layouts — VERIFIED against build 5875 (headers + row decodes, 2026-07-02):
//! - `TerrainType` **11 × 6 × 24 B**: `ID, Desc(str), FootstepSprayRun, FootstepSprayWalk,
//! SoundID, Flags` — the full domain decoded: Dirt→1, Metallic→2, Stone→3, Snow→4, Wood→5,
//! Grass→6, Leaves→7, Sand→8, Soggy→9, DustyGrass→6, None→0.
//! - `FootstepTerrainLookup` **179 × 5 × 20 B**: `ID, CreatureFootstepID, TerrainSoundID,
//! SoundID(dry), SoundIDSplash`. Spot-checks: class 8 × terrain-sound 2 (Metallic) →
//! 650/1063; class 8 × 3 (Stone) → 653/1057.
//! - `GroundEffectTexture` field 6 is the `TerrainType` FK (the clutter catalog reads the same
//! table for doodads and rightly skips doodad-less rows; footsteps need every row, so this
//! module re-reads it into its own map).
//!
//! Class semantics (data-verified 2026-07-02; class-0 gate at `0x6233ec`): **class 7 is the
//! humanoid/character class** — its ten rows
//! are exactly the `CharacterMediumLarge*` kits; characters reach it through the ordinary
//! display→sound data chain (`creature_sound`, the model fallback), never a code default.
//! **Class 0 means "no footstep sounds"** — the client bails before any lookup; the lookup's
//! class-0 rows are the Ancient Protector's stomps (kit 661), reached only by a *nonzero* class
//! on its own row. A position with **no ground-effect layer is silent** — the client's sentinel
//! is −1 and the kit lookup's signed bounds check rejects it (`0x458450`; the
//! audible fingerprint is vanilla's famously quiet dirt roads).
//! Class 7 is the character class (its rows are the `CharacterMediumLarge*` kits), reached through
//! the display's sound data, never a code default. Class 0 means no footsteps: the reference bails
//! before any lookup (`0x6233ec`). A position with no ground-effect layer is silent: its sentinel
//! is -1, which the kit lookup's signed bounds check rejects (`0x458450`).
use std::collections::HashMap;
@@ -35,19 +20,17 @@ use crate::dbc::{parse, str_at, u32_at};
pub struct FootstepCatalog {
/// `GroundEffectTexture` id → `TerrainType` id (every row, including doodad-less ones).
effect_terrain: HashMap<u32, u32>,
/// `TerrainType` id → its `SoundID` class (the lookup's `TerrainSoundID` axis).
/// `TerrainType` id → its `SoundID` (the lookup's `TerrainSoundID` axis).
terrain_sound: HashMap<u32, u32>,
/// `TerrainType` id → its `Flags` word. In the shipped 5875 data bit 0 is set on exactly
/// Snow (3) and Sand (7) — the leaves-footprints surfaces (the reporter-visible pair).
/// `TerrainType` id → its `Flags`; bit 0, the footprint bit, is set on Snow (3) and Sand (7).
terrain_flags: HashMap<u32, u32>,
/// `(CreatureFootstepID, TerrainSoundID)` → `(dry kit, splash kit)`.
lookup: HashMap<(u32, u32), (u32, u32)>,
}
impl FootstepCatalog {
/// The `(dry, splash)` SoundEntries kits for a creature footstep class standing on the given
/// ground-effect layer. `None` = silence: no/unknown effect layer (the client's −1 sentinel,
/// module docs), or no lookup row for `(class, terrain)`.
/// The `(dry, splash)` `SoundEntries` kits for a footstep class on a ground-effect layer;
/// `None`, silence, for no layer or no row.
pub fn resolve(&self, footstep_class: u32, effect_id: Option<u32>) -> Option<(u32, u32)> {
self.resolve_terrain(
footstep_class,
@@ -55,44 +38,35 @@ impl FootstepCatalog {
)
}
/// The same `(dry, splash)` answer from a `TerrainType` id **directly**, skipping the
/// `GroundEffectTexture` hop. This is the tail both legs of the client's down-ray share: the
/// ADT leg reaches a terrain id through the ground-effect layer, the WMO leg carries one in
/// the surface itself.
/// The same kits from a `TerrainType` id: the reference's down-ray reaches one through the
/// ground-effect layer on terrain and reads it off the surface in a WMO.
pub fn resolve_terrain(&self, footstep_class: u32, terrain: u32) -> Option<(u32, u32)> {
let sound_class = self.terrain_sound.get(&terrain).copied()?;
self.lookup.get(&(footstep_class, sound_class)).copied()
}
/// Does the ground under this effect layer take footprint decals? `TerrainType.Flags` bit 0
/// through the same effect→terrain chain the sounds ride (INTERIM reading, decision 1006:
/// bit 0 fits the shipped data — set on exactly Snow and Sand — with the client's own gate
/// unconfirmed). No/unknown effect layer = no prints.
/// Whether the ground under an effect layer takes footprints: `TerrainType.Flags` bit 0, the
/// bit the reference's footprint gate tests (`0x699e8e`). No layer, no prints.
pub fn leaves_footprints(&self, effect_id: Option<u32>) -> bool {
effect_id
.and_then(|e| self.effect_terrain.get(&e))
.is_some_and(|&t| self.terrain_leaves_footprints(t))
}
/// The same `TerrainType.Flags` bit 0 gate from a terrain id **directly** — the form both legs
/// of the down-ray share, since the WMO leg carries a terrain id rather than an effect layer.
/// Set on exactly Snow (3) and Sand (7) in the shipped data; the unauthored WMO default
/// `10 "None"` is clear, which is why a building's floor takes no prints.
/// The footprint bit from a terrain id; the WMO default, 10 "None", is clear, so a building's
/// floor takes no prints.
pub fn terrain_leaves_footprints(&self, terrain: u32) -> bool {
self.terrain_flags
.get(&terrain)
.is_some_and(|flags| flags & 1 != 0)
}
/// The `TerrainType` id under a ground-effect layer — the chain's first hop on its own.
/// Exposed for the `surface_here` probe, which has to show *where* the chain lands, and where
/// it falls off, not just the kit it ends at.
/// The `TerrainType` id under a ground-effect layer, the chain's first hop.
pub fn terrain_of(&self, effect_id: u32) -> Option<u32> {
self.effect_terrain.get(&effect_id).copied()
}
/// A `TerrainType`'s `SoundID` — the `FootstepTerrainLookup` axis the kit is chosen on.
/// Companion to [`Self::terrain_of`]; the same map [`Self::resolve`] walks.
/// A `TerrainType`'s `SoundID`, the `FootstepTerrainLookup` axis.
pub fn sound_class_of(&self, terrain: u32) -> Option<u32> {
self.terrain_sound.get(&terrain).copied()
}
@@ -178,10 +152,8 @@ pub fn load_footstep_catalog(chain: &mut Chain) -> Result<FootstepCatalog> {
})
}
/// `FootprintTextures.dbc` — id → texture path (extensionless, e.g.
/// `textures\Footsteps\BaseFootprint`), the table `CreatureModelData.FootprintTextureID`
/// indexes. Shipped 5875 data: 6 rows (1 Base, 3 Cloven, 4 Bare, 5 Claw, 6 Hoof, 7 Paw), all
/// 32×32 pure-black-RGB BLPs under a soft alpha — the print decal's ink.
/// `FootprintTextures.dbc`, id → extensionless texture path (`textures\Footsteps\BaseFootprint`),
/// the table `CreatureModelData.FootprintTextureID` indexes.
pub fn load_footprint_textures(chain: &mut Chain) -> Result<HashMap<u32, String>> {
let bytes = chain
.read_file("DBFilesClient\\FootprintTextures.dbc")
@@ -204,10 +176,7 @@ pub fn load_footprint_textures(chain: &mut Chain) -> Result<HashMap<u32, String>
mod tests {
use super::*;
/// The full chain resolves on real 5875 data: a Metallic-terrain effect under footstep
/// class 8 yields the byte-decoded kits (650 dry / 1063 splash); the humanoid class 7
/// resolves the `CharacterMediumLarge*` kits (560 Dirt with no effect, 562 Grass on a
/// grass-terrain effect — the character-in-Elwynn case); an unknown class stays silent.
/// The chain on 5875 data: class 8 on Metallic, and the character class on dirt and grass.
#[test]
fn real_footstep_chain_resolves() {
let data = crate::wow_data_or_skip!();
@@ -215,7 +184,7 @@ mod tests {
let cat = load_footstep_catalog(&mut chain).expect("load footstep catalog");
assert_eq!(cat.len(), 179, "all lookup rows load");
// Some effect whose terrain is Metallic (TerrainType 1 → sound class 2).
// An effect over Metallic (`TerrainType` 1, sound 2).
let metallic = cat
.effect_terrain
.iter()
@@ -228,8 +197,6 @@ mod tests {
"class 8 on metallic → the byte-decoded row"
);
}
// The humanoid class (7): the dirt kit on a dirt-terrain effect, the grass kit on a
// grass one (class 0 is the Ancient Protector, module docs).
let effect_with_sound_class = |sc: u32| {
cat.effect_terrain
.iter()
@@ -250,15 +217,12 @@ mod tests {
"class 7 on grass → CharacterMediumLargeGrass"
);
}
// No ground-effect layer = silence (the −1 sentinel, B5); unknown class too.
// No ground-effect layer, or an unknown class, is silent.
assert_eq!(cat.resolve(7, None), None);
assert_eq!(cat.resolve(9999, None), None);
}
/// The footprint gate on the real data: `TerrainType.Flags` bit 0 is set on exactly Snow (3)
/// and Sand (7) — an effect layer over either takes prints, every other terrain (and the
/// no-layer sentinel) doesn't. And `FootprintTextures.dbc` decodes its six shipped rows to
/// the `textures\Footsteps\*` ink paths.
/// The footprint bit on 5875 data, Snow and Sand only, and the six `FootprintTextures` rows.
#[test]
fn footprint_gate_and_textures_on_real_data() {
let data = crate::wow_data_or_skip!();
+9 -29
View File
@@ -1,22 +1,8 @@
//! `GameTips.dbc` — the loading-screen "tip of the day" (decision 2077).
//!
//! The 74 one-line hints the reference draws over a loading screen, each already carrying its own
//! `|cffffd100Tip:|r` prefix and a trailing `\r\n` or two in the data. Nothing in FrameXML reads
//! them — `showGameTips`' only 1.12 mention there is
//! `UIOptionsFrameCheckButtons["SHOW_TIPOFTHEDAY_TEXT"] = { index = 44, cvar = "showGameTips" }`
//! and the tooltip string "Uncheck this to hide the tip of the day in the load screens", so the
//! whole feature is engine-side.
//!
//! **The table is the reference's own growable array** at `[0xc0dcd0]` (data) / `[0xc0dcd4]`
//! (count), stride `0x28` = this record size, filled by the DBC loader `0x545f90` from the run of
//! per-table loaders at `0x5404bb`. `CGlueMgr::EnterWorld` (`0x46b500`) walks the count at
//! `0x46b684` and stores the chosen slot back into the `gameTip` CVar.
//!
//! Layout — the real 5875 file (10883 bytes, **74** records × 10 × u32, 7903-byte string block):
//! `ID(0), Text[8](1..8), TextMask(9)` — the ordinary 1.12 localized-string shape, and only the
//! first locale slot is populated in this chain. **Record order is not id order** (the file opens
//! on id 396 and closes on 412, ids 201..412 with no duplicates), which is why the index the
//! reference persists is a *record* index into the file as loaded, never an id.
//! `GameTips.dbc`: the loading-screen tips, each with its own `|cffffd100Tip:|r` prefix and a
//! trailing `\r\n` in the data; FrameXML never reads them. The reference loads them in file order
//! into one array (`[0xc0dcd0]`, count `[0xc0dcd4]`, loader `0x545f90`), and
//! `CGlueMgr::EnterWorld` (`0x46b500`) stores the chosen record index, never an id, in the
//! `gameTip` CVar. Record order is not id order: the file opens on id 396.
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
@@ -26,21 +12,19 @@ use crate::dbc::{parse, str_at};
const GAME_TIPS: &str = "DBFilesClient\\GameTips.dbc";
/// The tips, in the file's own record order — the order the reference's array holds them in, and
/// so the order its persisted index counts through.
/// The tips in file record order, the order the persisted `gameTip` index counts through.
#[derive(Clone, Debug, Default)]
pub struct GameTipsCatalog {
tips: Vec<String>,
}
impl GameTipsCatalog {
/// A catalog over rows given directly — for a test that must run without an install.
/// A catalog over given tips, for a test without an install.
pub fn from_tips(tips: Vec<String>) -> Self {
GameTipsCatalog { tips }
}
/// The tip at a **record index**, or `None` when the index is past the table (an index read
/// back from a config file written by a different chain).
/// The tip at a record index; an index saved under another chain can fall past the table.
pub fn get(&self, index: usize) -> Option<&str> {
self.tips.get(index).map(String::as_str)
}
@@ -68,9 +52,7 @@ pub fn load_game_tips(chain: &mut Chain) -> Result<GameTipsCatalog> {
let rs = parse(&bytes, schema(), "GameTips")?;
let mut tips = Vec::with_capacity(rs.records().len());
for r in rs.records() {
// A row whose localized slot is empty is dropped rather than kept as a blank: the
// reference's array is what the index counts through, and a blank slot would be a
// loading screen with no tip on it for no reason a player could act on.
// A row with an empty text slot is dropped, not shown blank; the shipped file has none.
match str_at(&rs, r, 1) {
Some(text) if !text.trim().is_empty() => tips.push(text),
_ => {}
@@ -83,8 +65,6 @@ pub fn load_game_tips(chain: &mut Chain) -> Result<GameTipsCatalog> {
mod tests {
use super::*;
/// The shipped table: 74 tips, in file order, each already carrying the reference's own
/// colour prefix. Skips without client data.
#[test]
fn the_real_table_is_seventy_four_tips_in_file_order() {
let data = crate::wow_data_or_skip!();
+7 -21
View File
@@ -1,11 +1,5 @@
//! GameObject display resolution: `displayId` → world model path.
//!
//! A GameObject's `GAMEOBJECT_DISPLAYID` indexes **GameObjectDisplayInfo.dbc**, whose `modelName`
//! column is a direct path — mostly `.mdx`/`.mdl` (M2: chests, mailboxes, doodads) with a few `.wmo`
//! (large structures). Unlike creatures there's no model-data indirection or skins, so the catalog is
//! just `displayId → path`; the renderer dispatches the path through [`crate::load_object_model`].
//!
//! Layout verified against build 5875: 12 fields (ID@0, modelName@1 string, Sound[10]@2..11).
//! `GameObjectDisplayInfo.dbc`: a game object's `GAMEOBJECT_DISPLAYID` → a direct model path,
//! with no model-data indirection or skins; mostly `.mdx`/`.mdl`, a few `.wmo`.
use std::collections::HashMap;
@@ -23,29 +17,24 @@ pub struct GameObjectCatalog {
}
impl GameObjectCatalog {
/// The model path for a GameObject display id (`.mdx`/`.mdl`/`.wmo`), or `None`.
pub fn model_path(&self, display_id: u32) -> Option<&str> {
self.models.get(&display_id).map(String::as_str)
}
/// Number of display entries (diagnostics).
pub fn len(&self) -> usize {
self.models.len()
}
/// Whether the catalog is empty.
pub fn is_empty(&self) -> bool {
self.models.is_empty()
}
/// Every `(displayId, model path)` pair, unordered — the corpus-sweep entry point for "which
/// models can a GameObject ever be, and which display ids reach each one".
/// Every `(displayId, model path)` pair, in no order.
pub fn iter(&self) -> impl Iterator<Item = (u32, &str)> {
self.models.iter().map(|(&id, p)| (id, p.as_str()))
}
}
/// GameObjectDisplayInfo.dbc — 12 fields in build 5875: ID, modelName, then 10 sound refs.
fn schema() -> Schema {
let mut s = Schema::new("GameObjectDisplayInfo");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
@@ -71,23 +60,20 @@ pub fn load_gameobject_catalog(chain: &mut Chain) -> Result<GameObjectCatalog> {
Ok(GameObjectCatalog { models })
}
/// The per-display **sound-kit slots** (`Sound0..9` — the other 10 columns of the same table):
/// `{0 Stand, 1 Open, 2 Loop, 3 Close, 4 Destroy, 5 Opened, 6..9 Custom}` → SoundEntries.
/// Loaded separately from the model catalog so the audio consumer doesn't reach into the
/// renderer's cache (decision 0070); only displays with at least one non-zero slot are kept
/// (most of the 1638 rows are silent props).
/// Each display's ten `SoundEntries` slots (columns 2-11): 0 stand, 1 open, 2 loop, 3 close,
/// 4 destroy, 5 opened, 6-9 custom. Only displays with a nonzero slot are kept.
pub struct GameObjectSounds {
sounds: HashMap<u32, [u32; 10]>,
}
/// The `Sound[10]` slot indices with recorded meanings (wowdev, vanilla family).
/// `Sound[10]` slot indices, named as wowdev documents them.
pub mod go_sound_slot {
pub const OPEN: usize = 1;
pub const CLOSE: usize = 3;
}
impl GameObjectSounds {
/// The 10 sound-kit slots for a display id; `None` when the display has no sounds at all.
/// A display's ten slots; `None` when every slot is zero.
pub fn slots(&self, display_id: u32) -> Option<&[u32; 10]> {
self.sounds.get(&display_id)
}
+49 -140
View File
@@ -1,81 +1,49 @@
//! **The chat language scramble** — the reference's `0x49b560`, reimplemented.
//! The chat language scramble, the reference's `0x49b560`: the server sends foreign speech in
//! plaintext beside a language id, and the client garbles it.
//!
//! When someone speaks a language your character does not know, the *server* sends the sentence in
//! plaintext with a language id beside it and never rewrites it; turning it into gibberish is
//! entirely the client's job (B262 — opposite-faction speech rendered perfectly readable because
//! this step did not exist). This module is that step.
//!
//! **The oracle is an emulated run of the binary's own bytes** (`0x49b560` over the player's own
//! `LanguageWords.dbc`). [`tests::the_reference_golden_vectors`] carries all 35 of its golden
//! vectors verbatim; they are the oracle this file is graded against, and this implementation
//! reproduces every vector byte for byte.
//!
//! The shape, in one paragraph. The line is walked as an alternating sequence of separator runs and
//! words. Each word is hashed once with `SStrHash` (case-folded, so `hello`/`Hello`/`HELLO` share a
//! hash and therefore a substitute); the *same* hash decides both whether that particular word is
//! understood (`hash % 300 < skill` — a **per-word, graduated** gate, not an all-or-nothing switch)
//! and, when it is not, which replacement is drawn from the `(language, byte length)` bucket of
//! `LanguageWords.dbc`. The source word's capitalisation is then stamped onto the replacement one
//! character at a time. Nothing is random and nothing is stateful: the mapping is a pure function
//! of the word's bytes, stable within a session and across sessions.
//!
//! **Three call sites in the reference, and the flags are what separate them** ([`Garble`]):
//! chat (`0x49aa7c`, both flags off), the NPC gossip greeting (`0x4e22ab`, `keep_punct`), and
//! readable item text (`0x4e35b0`, both). So the same language barrier covers gossip and books —
//! benilla only wires chat today, but the routine is the whole routine, not the chat slice of it.
//! The line alternates separator runs and words. Each word is hashed once with case-folded
//! `SStrHash`; it is understood when `hash % 300 < skill`, and otherwise the same hash picks a
//! substitute of its byte length from `LanguageWords.dbc`, which takes the source's case letter
//! by letter. The result is a pure function of the word's bytes. The three call sites differ only
//! in [`Garble`]'s flags and cap: chat (`0x49aa7c`), the gossip greeting (`0x4e22ab`), item text
//! (`0x4e35b0`).
use crate::LanguageWords;
/// `SStrHash`'s mix table — the **16-dword** table at `0x80e4e0`, not Storm's public 0x500-entry
/// crypt table. Both nibbles of a byte index these same sixteen entries and the mix is a
/// **subtraction**, `T[hi] - T[lo]`.
/// `SStrHash`'s 16-entry mix table (`0x80e4e0`), not Storm's 0x500-entry crypt table: a byte
/// mixes as `T[hi] - T[lo]`.
const HASH_TABLE: [u32; 16] = [
0x486e26ee, 0xdcaa16b3, 0xe1918eef, 0x202dafdb, 0x341c7dc7, 0x1c365303, 0x40ef2d37, 0x65fd5e49,
0xd6057177, 0x904ece93, 0x1c38024f, 0x98fd323b, 0xe3061ae7, 0xa39b0fa1, 0x9797f25f, 0xe4444563,
];
/// Full fluency (`0x49b599 cmp esi,0x12c`). At or above this the line is copied verbatim without
/// even being tokenized; below it every word runs the per-word gate.
/// Full fluency (`0x49b599`): at or above it the line is copied untokenized.
pub const FLUENT_SKILL: u32 = 300;
/// The per-word understand test's modulus (`0x49b79c div ecx=0x12c`). It is the same 300 as
/// [`FLUENT_SKILL`] and that is not a coincidence — it makes the surviving fraction of a line
/// exactly `skill / 300`.
/// The per-word test's modulus (`0x49b79c`), so a line keeps about `skill / 300` of its words.
const UNDERSTAND_MODULUS: u32 = 300;
/// How many bytes of a word are copied into the reference's `0x110`-byte scratch buffer and
/// therefore hashed (`0x49b768`).
/// The bytes of a word copied into the reference's scratch buffer and hashed (`0x49b768`).
const MAX_HASHED_BYTES: usize = 0x100;
/// The **length key**'s clamp (`0x49b7c1`) — a separate quantity from [`MAX_HASHED_BYTES`], and a
/// re-implementation must keep them apart: a 45-byte word is hashed over all 45 bytes but looked up
/// as if it were 18 long. The shipped table's longest word is 17, so this never binds against
/// shipped content; it is the retry below that does the work for a long word.
/// The lookup length's clamp (`0x49b7c1`), apart from [`MAX_HASHED_BYTES`]: a 45-byte word hashes
/// all 45 bytes and is looked up at length 18.
const MAX_LENGTH_KEY: usize = 0x12;
/// The two flags `0x49b560` takes, plus the destination cap — i.e. which of the reference's three
/// call sites this is.
/// The flags and destination cap `0x49b560` takes, one set per call site.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Garble {
/// `keepAngle` — pass a `<…>` span through verbatim, opener and closer included. Set only on
/// readable item text (`0x4e3856`).
/// `keepAngle`: pass a `<…>` span through verbatim, brackets included.
pub keep_angle: bool,
/// `keepPunct` — copy each separator's own bytes instead of collapsing the whole run to one
/// space. Set on gossip (`0x4e22ab`) and item text; **off on the chat path**, which is why
/// `"hello, world."` comes back `"kazum magan "` with a trailing space.
/// `keepPunct`: copy separators instead of collapsing each run to one space; off for chat, so
/// `"hello, world."` comes back with a trailing space.
pub keep_punct: bool,
/// `dstSize` — the reference's destination buffer, **total size not remaining**. Output is
/// truncated to `dst_size - 1` bytes.
/// `dstSize`, the whole destination buffer: output stops at `dst_size - 1` bytes.
pub dst_size: usize,
}
impl Garble {
/// The chat path (`0x49aa7c` in the display chokepoint `0x49a870`): both flags off, `0x800`.
///
/// The cap cannot bind on real chat — vmangos refuses a `CMSG_MESSAGECHAT` body past 255 bytes
/// and no server-composed line approaches 2 KiB — but it is the reference's number and modelling
/// it costs nothing, so a hostile or merely odd line truncates where the real client truncates
/// instead of somewhere of our own invention.
/// Chat (`0x49aa7c`, in the display path `0x49a870`).
pub const CHAT: Self = Self {
keep_angle: false,
keep_punct: false,
@@ -89,7 +57,7 @@ impl Garble {
dst_size: 0x800,
};
/// Readable item text — letters and books (`0x4e3856`, then `ITEM_TEXT_READY`).
/// Readable item text, letters and books (`0x4e3856`, then `ITEM_TEXT_READY`).
pub const ITEM_TEXT: Self = Self {
keep_angle: true,
keep_punct: true,
@@ -97,13 +65,8 @@ impl Garble {
};
}
/// `SStrHash` (`0x64af90`) as `0x49b560` calls it: **case-folded** (`caseSensitive = 0`) with a zero
/// seed.
///
/// The fold is ASCII-only — `a`-`z` lift by 0x20 and `/` becomes `\` — so a Latin-1 accented capital
/// and its lowercase form hash differently, exactly as they do in the reference. The walk stops at a
/// NUL because the reference's does; a wire chat string cannot contain one, but a caller composing
/// text locally could.
/// `SStrHash` (`0x64af90`), case-folded with a zero seed as `0x49b560` calls it. The fold is
/// ASCII-only (`a`-`z` up, `/` to `\`), and the walk stops at a NUL, both as the reference does.
fn sstr_hash_folded(bytes: &[u8]) -> u32 {
let mut s1: u32 = 0x7FED_7FED;
let mut s2: u32 = 0xEEEE_EEEE;
@@ -133,14 +96,11 @@ fn sstr_hash_folded(bytes: &[u8]) -> u32 {
}
}
/// The sentinel `0x41aab0` returns for a malformed lead or continuation byte. It is `> 0xff`, so
/// [`is_word_char`] swallows a malformed run into the surrounding word rather than splitting on it —
/// a quirk worth preserving rather than "fixing", since it decides tokenization.
/// `0x41aab0`'s malformed-byte sentinel; above 0xff, so a malformed run stays inside its word.
const MALFORMED: u32 = 0x8000_0000;
/// The reference's UTF-8 decoder `0x41aab0`, which predates RFC 3629 and so accepts the **1–6 byte**
/// forms. Returns `(codepoint, bytes consumed)`; consumes one byte on a malformed lead so the walk
/// always advances.
/// The reference's UTF-8 decoder (`0x41aab0`), which accepts 1 to 6 byte forms; returns the
/// codepoint and the bytes consumed, one when malformed.
fn decode(bytes: &[u8]) -> (u32, usize) {
let b0 = bytes[0];
let (mut cp, n) = match b0 {
@@ -164,12 +124,7 @@ fn decode(bytes: &[u8]) -> (u32, usize) {
(cp, n)
}
/// A Latin-1 letter as `0x6c9c60` classifies one.
///
/// Transcribed from the binary's table rather than from what Latin-1 "should" say:
/// `0xDE` (thorn) is **not** in the accepted set even though `0xC0..=0xDD`, `0xDF` and
/// `0xE0..=0xFF` around it are. It cannot affect an ASCII line, and guessing the table is a worse
/// error than transcribing it.
/// A Latin-1 letter as `0x6c9c60`'s table classifies one, which leaves out `0xDE` (thorn).
fn is_latin1_letter(cp: u32) -> bool {
matches!(cp, 0x41..=0x5a | 0x61..=0x7a)
|| matches!(cp, 0xc0..=0xdd if cp != 0xd7)
@@ -177,36 +132,23 @@ fn is_latin1_letter(cp: u32) -> bool {
|| matches!(cp, 0xe0..=0xff if cp != 0xf7)
}
/// The word-character predicate `0x49b940`.
///
/// Three consequences that decide output and that a re-implementation guesses wrong: **digits are
/// word characters**, so `12345` is one token and gets substituted like a word; **an apostrophe
/// never splits a word**, so `don't` is one token; and **every codepoint above Latin-1 is
/// unconditionally a word character**, so CJK, Cyrillic and a malformed byte run are all swallowed
/// into words.
/// The word-character predicate (`0x49b940`): digits, the apostrophe and every codepoint above
/// Latin-1 are word characters.
fn is_word_char(cp: u32) -> bool {
is_latin1_letter(cp) || (0x30..=0x39).contains(&cp) || cp == 0x27 || cp > 0xff
}
/// Rewrite `src` as `language` sounds to a listener with `skill` in it.
///
/// Returns the text unchanged when `language` is 0 (Universal — `Languages.dbc` has no row 0, so the
/// reference checks this twice, in the caller *and* here) or when `skill >= 300`. Everything else
/// goes word by word.
///
/// The caller owns the gates this routine does not: the addon sentinel, the chat types that force
/// the language to 0, and the GM flag. See `benilla-app`'s chat feed.
/// `src` as `language` sounds to a listener with `skill` in it; unchanged for language 0
/// (Universal) or full skill. The addon, chat-type and GM gates are the caller's.
pub fn garble(words: &LanguageWords, language: u32, skill: u32, src: &str, mode: Garble) -> String {
let cap = mode.dst_size.saturating_sub(1);
if language == 0 || skill >= FLUENT_SKILL {
// `SStrCopy(dst, src, dstSize)` at `0x49b5a1` — the whole line, untokenized, so a
// fluent listener does not even get separator runs collapsed.
// `SStrCopy` at `0x49b5a1`: untokenized, so separator runs stay as they are.
return truncate_utf8(src, cap).to_string();
}
let Some(pool) = words.pool(language) else {
// A language with no rows cannot be garbled — the reference would find no node at any
// length and drop every word, emitting nothing. We keep the line instead: a missing pool
// is a broken install, and silently blanking chat is the worse failure.
// Deviation: a language with no words keeps the line, where the reference drops every
// word, because a missing pool is a broken install and blank chat would hide it.
return truncate_utf8(src, cap).to_string();
};
@@ -215,8 +157,7 @@ pub fn garble(words: &LanguageWords, language: u32, skill: u32, src: &str, mode:
let mut i = 0;
while i < bytes.len() {
// --- separator run ---------------------------------------------------------------
// One space per *run* on the chat path, latched per outer iteration: a leading run, a
// trailing run, `" "`, `", "`, `"!!!"` and a tab all collapse to exactly one space.
// Chat collapses each separator run, leading and trailing included, to one space.
let mut emitted_space = false;
while i < bytes.len() {
let (cp, n) = decode(&bytes[i..]);
@@ -261,19 +202,13 @@ pub fn garble(words: &LanguageWords, language: u32, skill: u32, src: &str, mode:
let word = &bytes[word_start..i.min(word_start + MAX_HASHED_BYTES)];
let hash = sstr_hash_folded(word);
// The per-word gate. This is the correction that matters most: partial skill is **not**
// "garbled like skill 0". A word survives exactly when `hash % 300 < skill`, so the
// surviving fraction of a line is `skill / 300` and *which* words survive is fixed by their
// bytes — at skill 150 a pangram comes back half-plain, the same half every time.
// The per-word gate: which words survive partial skill is fixed by their bytes.
if hash % UNDERSTAND_MODULUS < skill {
push(&mut out, word, cap);
continue;
}
// The length key steps *down* on a miss, and a length-1 miss drops the word entirely —
// nothing is emitted for it, not even a space. Unreachable against shipped content (every
// language has a word at every length from 1 up to its own maximum), reachable against a
// trimmed table.
// A miss retries one length shorter; a miss at length 1 drops the word, space and all.
let mut key = word.len().min(MAX_LENGTH_KEY);
let substitute = loop {
if let Some(s) = pool.nth_of_len(key, hash) {
@@ -289,14 +224,11 @@ pub fn garble(words: &LanguageWords, language: u32, skill: u32, src: &str, mode:
};
stamp_case(&mut out, word, substitute.as_bytes(), cap);
}
// Every substitute is ASCII and every verbatim span is a whole-codepoint slice of `src`, so the
// only way out of UTF-8 is the `0x100` hash clamp or the destination cap splitting a multi-byte
// character. Both are the reference's own truncations; we keep them and repair the encoding
// rather than moving the cut.
// Only the reference's own cuts, the hash clamp and the cap, can split a codepoint here.
String::from_utf8_lossy(&out).into_owned()
}
/// The chat path, which is the one benilla wires today.
/// [`garble`] on the chat path.
pub fn garble_chat(words: &LanguageWords, language: u32, skill: u32, src: &str) -> String {
garble(words, language, skill, src, Garble::CHAT)
}
@@ -307,16 +239,8 @@ fn push(out: &mut Vec<u8>, bytes: &[u8], cap: usize) {
out.extend_from_slice(&bytes[..room.min(bytes.len())]);
}
/// `0x49b8b0` — stamp the source word's capitalisation onto the substitute.
///
/// Two things a reader gets wrong if this is described as "capitalisation is preserved". The case is
/// decided **per character, from the source**, not first-letter-only and not from the substitute:
/// `hEllO` → `kAzuM`. And a non-letter in the source (a digit, an apostrophe) is not upper, so it
/// forces the substitute's character **lowercase**.
///
/// The emitted length is `min(len(source), len(substitute), budget)`. Those first two are usually
/// equal because the lookup is length-keyed — but not after a retry, which is exactly the long-word
/// case: a 20-byte word looked up at length 13 emits 13 bytes.
/// `0x49b8b0`: each character takes the case of the source's at the same position (`hEllO` to
/// `kAzuM`), a non-letter forcing lowercase, for the shorter of the two words.
fn stamp_case(out: &mut Vec<u8>, source: &[u8], substitute: &[u8], cap: usize) {
for (i, &s) in source.iter().enumerate() {
if i >= substitute.len() || out.len() >= cap {
@@ -330,8 +254,8 @@ fn stamp_case(out: &mut Vec<u8>, source: &[u8], substitute: &[u8], cap: usize) {
}
}
/// Truncate to at most `cap` bytes without splitting a codepoint — the verbatim paths' `SStrCopy`,
/// which is a byte copy in the reference and cannot produce invalid UTF-8 for us.
/// The verbatim paths' `SStrCopy`, at most `cap` bytes. Deviation: it stops at a codepoint
/// boundary where the reference's byte copy can split one, because a `&str` must stay UTF-8.
fn truncate_utf8(s: &str, cap: usize) -> &str {
if s.len() <= cap {
return s;
@@ -348,11 +272,8 @@ mod tests {
use super::*;
use crate::{load_language_words, Chain};
/// **The oracle.** Every one of the 35 golden vectors produced by emulating the reference's own
/// `0x49b560` over the shipped `LanguageWords.dbc` — not transcribed from a description of the
/// output, but the exact bytes the binary wrote.
///
/// `(language, skill, input, expected)`.
/// `(language, skill, input, expected)`: the reference's `0x49b560` output, emulated over the
/// shipped `LanguageWords.dbc`.
const GOLDEN: &[(u32, u32, &str, &str)] = &[
(1, 0, "hello", "kazum"),
(1, 0, "h", "o"),
@@ -417,10 +338,6 @@ mod tests {
}
}
/// No per-call state anywhere: the same call repeats identically, and so does the same word in
/// a different sentence. (The reference's own oracle proves the second half across separately
/// built emulator "sessions"; ours is a pure function, so within-process repetition is the
/// strongest form the property can take here.)
#[test]
fn the_mapping_is_a_pure_function_of_the_words_bytes() {
let data = crate::wow_data_or_skip!();
@@ -435,27 +352,22 @@ mod tests {
.map(|&(l, s, i, _)| garble_chat(&words, l, s, i))
.collect();
assert_eq!(first, again);
// The same word carries its substitute between sentences, not just within one — stated
// against the standalone result rather than a literal, so this asserts the *property*
// instead of re-recording whatever this file happens to produce.
// The same word keeps its substitute in another sentence.
let alone = garble_chat(&words, 1, 0, "hello");
assert_eq!(alone, "kazum", "the golden vector still anchors it");
let in_sentence = garble_chat(&words, 1, 0, "well hello there");
assert_eq!(in_sentence.split(' ').nth(1), Some(alone.as_str()));
}
/// `SStrHash` is case-folded, which is *why* `hello`/`Hello`/`HELLO` share a substitute — the
/// case stamp only decides how it is printed.
#[test]
fn the_hash_folds_case_and_is_seeded_the_storm_way() {
assert_eq!(sstr_hash_folded(b"hello"), sstr_hash_folded(b"HELLO"));
assert_eq!(sstr_hash_folded(b"hello"), sstr_hash_folded(b"hEllO"));
assert_ne!(sstr_hash_folded(b"hello"), sstr_hash_folded(b"world"));
// The empty string never reaches the table, so it returns the untouched seed.
// The empty string returns the untouched seed.
assert_eq!(sstr_hash_folded(b""), 0x7FED_7FED);
}
/// The tokenizer's three surprises, stated as tests so they cannot be "cleaned up" later.
#[test]
fn digits_apostrophes_and_high_codepoints_are_word_characters() {
assert!(is_word_char(u32::from(b'7')));
@@ -467,8 +379,6 @@ mod tests {
assert!(!is_word_char(u32::from(b'.')));
}
/// The flags the other two call sites set. Chat collapses `", "` to one space; gossip keeps
/// both bytes; item text additionally passes a `<…>` span straight through.
#[test]
fn the_gossip_and_item_text_flags_change_the_separators_only() {
let data = crate::wow_data_or_skip!();
@@ -483,14 +393,13 @@ mod tests {
garble(&words, 1, 0, "hello <Name> world", Garble::ITEM_TEXT),
"kazum <Name> magan"
);
// Without `keep_angle` the span is just separators and a word, so the name garbles too.
// Without `keep_angle` the name garbles too.
assert_ne!(
garble(&words, 1, 0, "hello <Name> world", Garble::GOSSIP),
"kazum <Name> magan"
);
}
/// The destination cap is the reference's, and it truncates rather than growing.
#[test]
fn the_destination_cap_truncates() {
let data = crate::wow_data_or_skip!();
@@ -1,43 +1,8 @@
//! GMTicketCategory.dbc — the ten trouble-ticket categories the Help window's "page a GM" list is
//! built from, and the id each one submits as (decision 1673).
//!
//! This table is the answer to a question the shipped FrameXML makes look unanswerable. Its
//! `HelpFrameGM_UpdateCategories(GetGMTicketCategories())` consumes the binding's varargs as
//! `(key, text)` PAIRS, and `HELPFRAME_FRAMES`/`GENERAL_HELPFRAME` are keyed `1..10` with ten
//! distinct titles — yet `GlobalStrings.lua` ships only `TICKET_TYPE1..4` ("Game Play",
//! "Harassment", "Stuck", "Bug"). Those four are not this list: they feed the OpenTicket
//! *dropdown*, which has no frame in 1.12 and whose `OnShow` call is commented out. The category
//! list is DBC data, and it lines up with `GENERAL_HELPFRAME`'s keys row for row:
//!
//! | id | name |
//! |---|---|
//! | 1 | Stuck |
//! | 2 | Behavior/Harassment |
//! | 3 | Guild |
//! | 4 | Item |
//! | 5 | Environmental |
//! | 6 | Non-Quest/Creep |
//! | 7 | Quest/Quest NPC |
//! | 8 | Technical |
//! | 9 | Account/Billing |
//! | 10 | Character |
//!
//! **benilla's own Help window no longer shows this list** (decision 1687): it goes straight from
//! Home to the ticket box and files under 0, "uncategorised". The catalog still ships, because
//! `GetGMTicketCategories()` is a real Era binding a third-party addon may call and because these
//! ids are still what the *server* names a ticket by — an existing ticket's category arrives on
//! `UPDATE_TICKET` and is echoed back on an edit.
//!
//! **The id is the wire value**, not just a list index: the clicked button stores it as
//! `HelpFrameOpenTicket.ticketType`, and that is what `NewGMTicket(category, text)` puts in
//! `CMSG_GMTICKET_CREATE`'s category field. So a catalog that renumbered on load would file every
//! ticket under the wrong heading, silently — which is why this is an ordered id-keyed read of the
//! file rather than a `Vec` indexed from zero.
//!
//! Record layout (10 rows in the shipped 5875 file, verified by loading it): `ID@0`,
//! `Name_Lang@1..8`, `NameFlags@9` — the same 8-locale + mask shape as [`crate::itembagfamily`].
//! Ids are 1..10 contiguous here, but nothing downstream may assume that: the ordered pairs are
//! what the binding pushes, and the id is what the wire carries.
//! `GMTicketCategory.dbc`, the ten categories of the Help window's GM list, which
//! `GetGMTicketCategories()` pushes as `(id, name)` pairs in file order; the `TICKET_TYPE1..4`
//! strings feed the OpenTicket dropdown, which 1.12 never shows. The id is the wire value: the
//! clicked button stores it as `HelpFrameOpenTicket.ticketType` and `NewGMTicket` sends it as
//! `CMSG_GMTICKET_CREATE`'s category, so the file's ids are kept, never renumbered.
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
@@ -47,8 +12,7 @@ use crate::Chain;
const GM_TICKET_CATEGORY: &str = "DBFilesClient\\GMTicketCategory.dbc";
/// GMTicketCategory.dbc, in file order — the order `GetGMTicketCategories()` pushes them in, which
/// is the order the ten `HelpFrameButton*` rows are painted in.
/// `GMTicketCategory.dbc` in file order, the order `GetGMTicketCategories()` pushes.
pub struct GmTicketCategoryCatalog {
categories: Vec<GmTicketCategory>,
}
@@ -56,9 +20,9 @@ pub struct GmTicketCategoryCatalog {
/// One row: the wire category id and its localized display name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GmTicketCategory {
/// The id `CMSG_GMTICKET_CREATE` carries — see the module doc's warning about renumbering.
/// The id `CMSG_GMTICKET_CREATE` carries.
pub id: u32,
/// The button label, from the active locale's `Name_Lang` slot.
/// The button label, from the enUS `Name_Lang` slot.
pub name: String,
}
@@ -68,7 +32,6 @@ impl GmTicketCategoryCatalog {
&self.categories
}
/// Row count, for the load log.
pub fn len(&self) -> usize {
self.categories.len()
}
@@ -88,11 +51,8 @@ fn gm_ticket_category_schema() -> Schema {
s
}
/// Load GMTicketCategory.dbc from the patch chain.
///
/// A row whose name is empty is skipped rather than pushed blank: the Help window paints one
/// button per returned pair and an unnamed button is a dead click, not a category. The shipped
/// 5875 file has no such row — this is a guard on a locale slot, not a modelled branch.
/// Load `GMTicketCategory.dbc` from the patch chain. A row with an empty name is skipped, as it
/// would paint a dead button; 5875 has none.
pub fn load_gm_ticket_categories(chain: &mut Chain) -> Result<GmTicketCategoryCatalog> {
let bytes = chain
.read_file(GM_TICKET_CATEGORY)
@@ -113,10 +73,8 @@ pub fn load_gm_ticket_categories(chain: &mut Chain) -> Result<GmTicketCategoryCa
mod tests {
use super::*;
/// The shipped 5875 table, read as data rather than assumed. Pins **both** halves that matter:
/// the ids (which go on the wire) and the order (which is the painted order), plus the row-for-
/// row correspondence with `GENERAL_HELPFRAME`'s ten keys that the module doc tabulates.
/// Skips without client data.
/// The 5875 ids (the wire values) in file order (the painted order), matching
/// `GENERAL_HELPFRAME`'s ten keys.
#[test]
fn the_shipped_categories_are_the_ten_help_window_rows_in_order() {
let data = crate::wow_data_or_skip!();
+64 -144
View File
@@ -1,24 +1,9 @@
//! Ground-effect doodads — the little grass tufts, weeds, flowers and pebbles scattered over the
//! terrain. This is the dense ground clutter that makes the real client's outdoors look lush; without
//! it our ground is bare texture.
//! Ground-effect doodads: the grass tufts, weeds, flowers and pebbles scattered over the terrain.
//!
//! Mechanism (verified against build 5875 + wowdev.wiki ADT/v18, 2026-05-26):
//! - Each terrain texture layer in an MCNK carries an `effectId` ([`benilla_adt`] `TextureLayer.effect_id`;
//! `0xFFFFFFFF` = none) that indexes [`GroundEffectTexture.dbc`].
//! - [`GroundEffectTexture.dbc`] (5875: **7 fields / 28 B** — `ID, doodadId[4], density, sound`; no
//! per-doodad weights, those arrive in 6.0) gives up to four [`GroundEffectDoodad.dbc`] refs + a
//! `density` (0→8, the client caps ~24: amount, then coverage/grouping).
//! - [`GroundEffectDoodad.dbc`] (5875: **3 fields / 12 B** — `ID, internalId, modelPath`) names the
//! model as a bare `*.mdl` filename; on disk in this client the real model is
//! `World\NoDXT\Detail\<stem>.m2` (an `MD20` M2 — verified `ElwGra01.m2` extracts and loads). These
//! models are **not in the MPQ listfile** but extract fine by exact path.
//!
//! Which cell gets which doodad set is **authored**, in two per-cell 8×8 grids in the MCNK header
//! (decoded onto [`ChunkMesh::pred_tex`] / [`ChunkMesh::no_effect_doodad`]): `predominantTexture`
//! (0x40) names the layer whose `effectId` applies, `noEffectDoodad` (0x50) hard-disables the cell.
//! The exact intra-cell scatter positions are client-internal, so the renderer scatters
//! deterministically at the authored density. This module owns the **data** (`effectId → (models,
//! density)`) and the faithful per-cell [`scatter_ground_doodads`].
//! An MCNK texture layer's `effectId` (`0xFFFFFFFF` none) indexes `GroundEffectTexture.dbc`, which
//! gives four `GroundEffectDoodad.dbc` slots and a density. Which layer a cell uses is authored in
//! two per-cell 8×8 grids in the MCNK header: `predominantTexture` (`0x40`) names the layer, and
//! `noEffectDoodad` (`0x50`) disables the cell.
use std::collections::HashMap;
@@ -31,77 +16,69 @@ use crate::terrain::ChunkMesh;
const GROUND_EFFECT_TEXTURE: &str = "DBFilesClient\\GroundEffectTexture.dbc";
const GROUND_EFFECT_DOODAD: &str = "DBFilesClient\\GroundEffectDoodad.dbc";
/// Detail-doodad models live here; the DBC stores a bare `*.mdl` name, the on-disk file is `.m2`.
/// Detail-doodad models: the DBC names a bare `*.mdl`, the file is `<stem>.m2` here, absent from
/// the MPQ listfile but readable by exact path.
const DETAIL_DIR: &str = "World\\NoDXT\\Detail\\";
/// A "none" doodad slot. Vanilla 5875 uses **only `0xFFFFFFFF` (the wiki's "-1")** as the empty
/// sentinel — `0` is a valid `internalId`, owned by `ElwFlo01.mdl` (the dark-red branched flower
/// in Elwynn). Empirically: across `GroundEffectTexture.dbc` (12 742 rows × 4 slots = 50 968),
/// 48 083 slots are `0xFFFFFFFF`, only 16 are `0`, and all 16 resolve to `ElwFlo01` — so `0` is
/// definitely an id, not a sentinel. (Treating it as empty silently dropped every `ElwFlo01`
/// placement, e.g. the slot-0 entry on the 12 Elwynn effects 507/720/734/741/747/753/761/765/
/// 777/801/805/868.)
/// The empty slot is `0xFFFFFFFF` alone: 0 is a real `internalId`, `ElwFlo01.mdl`'s, which 16
/// shipped slots name.
fn is_no_doodad(id: u32) -> bool {
id == 0xFFFF_FFFF
}
/// The detail doodads + density for one `effectId` (a [`GroundEffectTexture.dbc`] row).
/// One `GroundEffectTexture.dbc` row: the detail doodads and density for an `effectId`.
#[derive(Clone, Debug)]
pub struct GroundEffect {
/// The 4 `doodadId` slots in order, resolved to `World\NoDXT\Detail\*.m2` paths. `None` = an
/// empty slot (`0`/`-1`); the client treats those as "place nothing" so the slot pattern (incl.
/// duplicates) acts as the doodad weighting. Kept in slot order (not deduped) for that reason.
/// The four `doodadId` slots in order, resolved to model paths; `None` places nothing. Slot
/// order and duplicates are kept: the slot pattern is the weighting.
pub doodads: [Option<String>; 4],
/// Authored density (`0`..~`24`): per-cell doodad count; higher = denser, then grouped.
/// Doodads per visited cell, up to about 24 as shipped.
pub density: u32,
}
impl GroundEffect {
/// The non-empty resolved model paths (diagnostics; the placement uses [`Self::doodads`] directly).
/// The non-empty model paths, for diagnostics.
pub fn models(&self) -> Vec<&str> {
self.doodads.iter().filter_map(|d| d.as_deref()).collect()
}
}
/// `effectId → GroundEffect` from the GroundEffect* DBCs. Query with [`GroundEffectCatalog::effect`].
/// `effectId` to [`GroundEffect`], from the two ground-effect DBCs.
pub struct GroundEffectCatalog {
effects: HashMap<u32, GroundEffect>,
}
impl GroundEffectCatalog {
/// The doodads + density for a terrain layer's `effectId`, or `None` (no effect / no models).
/// A terrain layer's effect; a row that resolves no model is absent.
pub fn effect(&self, effect_id: u32) -> Option<&GroundEffect> {
self.effects.get(&effect_id)
}
/// Number of effect rows that resolve to at least one model (diagnostics).
/// Effect rows that resolve at least one model.
pub fn len(&self) -> usize {
self.effects.len()
}
/// Whether the catalog is empty.
pub fn is_empty(&self) -> bool {
self.effects.is_empty()
}
}
/// One placed ground-clutter doodad: a small upright `World\NoDXT\Detail\*.m2` with a random heading.
/// Unlike [`crate::Doodad`] (MDDF placement matrix) these stand straight with just a yaw, like a
/// creature — so they get their own type to avoid the MDDF rotation convention.
/// One placed ground-clutter doodad. It stands upright with only a yaw, unlike an MDDF
/// [`crate::Doodad`] and its rotation convention.
#[derive(Clone, Debug)]
pub struct GroundDoodadPlacement {
pub model: String,
/// Position in WoW world coords (X north, Y west, Z up), on the terrain surface.
/// WoW world coords (X north, Y west, Z up), on the terrain surface.
pub position: [f32; 3],
/// Heading in radians about the up axis (random — clutter has no authored facing).
/// Radians about the up axis, random: clutter has no authored facing.
pub yaw: f32,
/// Per-instance uniform scale, the client's random `[0.9, 1.1]` (`signed·0.1 + 1.0`).
/// Uniform scale, the reference's random `[0.9, 1.1]`.
pub scale: f32,
}
/// The vanilla client's 256-byte detail-doodad noise table (a permutation of 0..255), recovered from
/// `Marlamin/noggit-red`'s `ChunkAddDetailDoodads.cpp` (where it sits commented out beside an `'a'`
/// placeholder). Drives [`BlizzardRandomizer`].
/// The reference's detail-doodad noise table, a permutation of 0..255, as Noggit Red's
/// `ChunkAddDetailDoodads.cpp` recovers it.
#[rustfmt::skip]
pub(crate) const GROUND_EFFECT_NOISE: [u8; 256] = [
0x8e, 0x14, 0x27, 0x99, 0xfd, 0xaa, 0xc7, 0x08, 0xd5, 0xe6, 0x3e, 0x1f, 0xf6, 0xbb, 0x55, 0xda,
@@ -122,11 +99,8 @@ pub(crate) const GROUND_EFFECT_NOISE: [u8; 256] = [
0x96, 0xd1, 0x64, 0x26, 0xd7, 0x45, 0xcc, 0x4f, 0xc8, 0xb0, 0xe9, 0xb5, 0x00, 0xd6, 0x31, 0xea,
];
/// The vanilla client's detail-doodad PRNG (`FUN_004531e0` + the `FUN_0044ea80` seed-expand, a.k.a. the
/// Noggit `BlizzardRandomizer`). Seeded **once per chunk** from the chunk's global coords; the same
/// stream drives PASS-1 cell picks and PASS-2 positions/scale/yaw, so the layout is bit-exact to the
/// client and reload-stable. The lane math (`shuffle`) reproduces the binary exactly — see
/// [`scatter_ground_doodads`].
/// The reference's detail-doodad PRNG (`0x4531e0`, seed expanded by `0x44ea80`), seeded once per
/// chunk; one stream drives both scatter passes.
struct BlizzardRandomizer {
source: u32,
seed: u32,
@@ -141,19 +115,16 @@ impl BlizzardRandomizer {
Self { source, seed }
}
/// Little-endian `u32` read at byte offset `i` of the noise table. The 4-byte read wraps past 255
/// (circular); the client's exact boundary behaviour at `i ≥ 253` is unconfirmed (rare, ~1%).
/// Little-endian `u32` at byte `i` of the noise table; the lanes keep `i` at most 251.
fn noise32(i: u8) -> u32 {
let b = |k: u8| GROUND_EFFECT_NOISE[i.wrapping_add(k) as usize] as u32;
b(0) | (b(1) << 8) | (b(2) << 16) | (b(3) << 24)
}
/// Advance and return the next `source`. (`rol(v, 0) == v`.)
/// Advance and return the next `source`.
fn shuffle(&mut self) -> u32 {
// Per-lane index = byte − sub, and **when that goes negative add the lane's specific constant**
// (`0xf4/0xec/0xd4/0xbc`) — NOT a mod-256 wrap. This is the client's exact `FUN_004531e0` math
// (verified to the instruction); the result stays in `[0, 251]` so the 4-byte noise read is
// always in-bounds. (Using `wrapping_sub` diverges on ~11–16% of draws.)
// A negative lane index adds the lane's own constant, not a mod-256 wrap (`0x4531e0`); the
// result stays in `[0, 251]`.
let lane = |byte: u32, sub: i32, wrap: i32| -> u8 {
let idx = (byte & 0xFF) as i32 - sub;
(if idx < 0 { idx + wrap } else { idx }) as u8
@@ -172,56 +143,35 @@ impl BlizzardRandomizer {
self.source
}
/// A signed uniform float in `(-1, 1)` — the client's `genCoord` output used for in-cell position,
/// scale, and yaw: take the mantissa float in `[1, 2)`, then map by the roll's sign bit.
/// The reference's `genCoord`, for in-cell position, scale and yaw: a mantissa float in
/// `[1, 2)`, mapped by the roll's sign bit to `(0, 1]` or `[-1, 0)`.
fn signed_unit(&mut self) -> f32 {
let u = self.shuffle();
let f = f32::from_bits((u & 0x007F_FFFF) | 0x3F80_0000); // [1, 2)
if (u as i32) < 0 {
2.0 - f // (0, 1] → mapped to (−1, 0]
2.0 - f // (0, 1]
} else {
f - 2.0 // [1, 2) → [−1, 0) … together: (−1, 1)
f - 2.0 // [-1, 0)
}
}
}
/// The client's `frillDensity` CVar default — the number of (randomly-chosen, with-repeats) cells the
/// client visits per chunk. It is **not** all 64 cells — that's what makes the real clutter patchy
/// (some cells hit 2–3×, ~50 left bare) rather than an even carpet.
///
/// **Byte-read, not chosen**: `CVar::Register 0x63db90` at `0x68862e` passes name `0x8423d8`
/// `"frillDensity"`, default string `0x864644` `"16"`, help "Terrain frill density", record
/// `[0xc7f2f4]`. `SetWorldDetail 0x488dd0`
/// writes 16/32/48 over it per slider stop, so a stop IS a multiple of this constant — which is
/// why [`scatter_ground_doodads`] takes a multiplier and the CVar host converts at its edge
/// ([`crate::ground_effects::FRILL_DENSITY_MAX`] is that conversion's other bound).
/// The reference's `frillDensity` default (`CVar::Register` at `0x68862e`, record `[0xc7f2f4]`):
/// cells visited per chunk, drawn with repeats, so clutter is patchy. `SetWorldDetail`
/// (`0x488dd0`) writes 16, 32 or 48 per slider stop, hence the scatter's multiplier.
pub const FRILL_DENSITY: u32 = 16;
/// The top of the reference's own `frillDensity` range — its change callback `0x688de0` clamps the
/// written value to `[1, 256]`, so this is the densest ground cover the real client will scatter
/// (its renderer saturates first: `0x6b1d4b` computes the detail-doodad instance count as
/// `frillDensity << 6` capped at `0x2000`, reached at 128). Named here because both the scatter's
/// own clamp below and the `frillDensity` CVar arm in `benilla-app` have to agree on it.
/// The top of the reference's `frillDensity` range: its change callback (`0x688de0`) clamps to
/// `[1, 256]`, though the renderer saturates at 128 (`0x6b1d4b` caps `frillDensity << 6` at
/// `0x2000` instances). The scatter's clamp and `benilla-world`'s `set_frill_density` share it.
pub const FRILL_DENSITY_MAX: u32 = 256;
/// Scatter ground-clutter doodads over one terrain chunk — a **bit-exact port of the client's
/// `CMapChunk::CreateDetailDoodads`** (RE'd from `WoW.exe` @ `0x6bfc10`). One RNG stream per chunk,
/// seeded by **global chunk coords**
/// `(globalChunkY << 16) | globalChunkX`, then two passes:
///
/// - **PASS 1** picks `frillDensity` (×`density_scale`) random cells `(rng&7, rng&7)` — **with repeats,
/// no dedup** (2 RNG draws each). This is the source of the reference's patchiness: cells drawn more
/// than once get extra doodads, the ~50 unpicked cells stay bare.
/// - **PASS 2** visits exactly those cells in order. Per cell: skip if [`ChunkMesh::no_effect_doodad`];
/// the layer named by [`ChunkMesh::pred_tex`] (`predominantTexture`, **never** MCAL alpha) gives the
/// MCLY `effectId` → its [`GroundEffect`]. Then spawn `density` doodads; per doodad the exact draw
/// order is **rx, ry (always), then — only if the slot isn't empty — scale, yaw**. Slot is
/// `(n + listIndex) & 3` of the effect's 4 `doodadId`s; an **empty (−1/None) slot places nothing**
/// but still consumes the rx/ry draws (matching the client, so the stream stays aligned).
///
/// Empty-slot effects are *why bare-earth cells stay clear*: a layer whose effect is all-−1 resolves to
/// `None` (we drop those from the catalog). `density_scale` (debug slider) scales the per-chunk cell
/// count = the client's `frillDensity` lever.
/// Scatter ground clutter over one terrain chunk, a port of the reference's
/// `CMapChunk::CreateDetailDoodads` (`0x6bfc10`): one RNG stream per chunk, seeded by the global
/// chunk coords `(globalChunkY << 16) | globalChunkX`; pass 1 picks `frillDensity` cells
/// `(rng & 7, rng & 7)` with repeats, and pass 2 spawns `density` doodads in each, slot
/// `(n + listIndex) & 3`, from the layer [`ChunkMesh::pred_tex`] names, never MCAL alpha.
/// `density_scale` multiplies [`FRILL_DENSITY`].
pub fn scatter_ground_doodads(
chunk: &ChunkMesh,
catalog: &GroundEffectCatalog,
@@ -237,23 +187,17 @@ pub fn scatter_ground_doodads(
if frill == 0 {
return Vec::new();
}
// The chunk's 145 verts are MCVT's stride-17 interleave (B1):
// outer (r,c) @ r·17+c, inner (r,c) @ r·17+9+c. Outer = the corner grid; inner = each cell's
// CENTER vertex, carrying its own authored MCVT height (the bump that gives hilltops their
// rise). Clutter heights interpolate via the 4-triangle center-fan, NOT a 4-corner bilinear:
// matching the terrain mesh's subdivision so doodads sit flush on the surface (esp. over
// hilltops, where a bilinear interp would float them under the rising inner vertex).
// MCVT's stride-17 interleave: corner `(r, c)` at `r * 17 + c`, the cell's centre vertex at
// `r * 17 + 9 + c`.
let outer = |row: usize, col: usize| chunk.positions[row * 17 + col];
let inner = |row: usize, col: usize| chunk.positions[row * 17 + 9 + col];
let n_layers = chunk.layer_textures.len();
// ONE RNG stream for the whole chunk, seeded by GLOBAL chunk coords (Y high, X low) — matches the
// client, so the patch layout is globally unique (never repeats per tile) and reload-stable.
let global_x = tile_x * 16 + chunk.index_x;
let global_y = tile_y * 16 + chunk.index_y;
let mut rng = BlizzardRandomizer::new((global_y << 16) | (global_x & 0xFFFF));
// PASS 1: pick `frill` random cells (with repeats — do NOT dedup), 2 draws each.
// Pass 1: `frill` cells, two draws each, with repeats; never dedup.
let cells: Vec<(usize, usize)> = (0..frill)
.map(|_| {
let col = (rng.shuffle() & 7) as usize; // cellX
@@ -262,27 +206,27 @@ pub fn scatter_ground_doodads(
})
.collect();
// PASS 2: visit exactly those cells in order; gate + spawn.
// Pass 2: those cells, in order.
let mut out = Vec::new();
for (list_index, &(row, col)) in cells.iter().enumerate() {
let k = row * 8 + col;
if chunk.no_effect_doodad[k] {
continue; // client's explicit "no clutter" mask (roads/clearings)
continue; // the authored no-clutter mask (roads, clearings)
}
// Holes (B2): skip clutter over holed 2×2 blocks — the surface itself isn't drawn there.
// No clutter over a holed 2×2 block, where no surface is drawn.
if chunk.holes & (1u16 << ((row >> 1) * 4 + (col >> 1))) != 0 {
continue;
}
let layer = chunk.pred_tex[k] as usize;
if layer >= n_layers {
continue; // names a non-existent layer
continue;
}
let Some(eff) = chunk
.layer_effect_ids
.get(layer)
.and_then(|&eid| catalog.effect(eid))
else {
continue; // effect is none / all-(−1)-slots → the cell stays bare
continue; // no effect, or none of its slots resolves: the cell stays bare
};
let density = if eff.density == 0 { 8 } else { eff.density };
// Cell verts in our (row, col) frame: row steps south (-X), col steps east (-Y).
@@ -292,14 +236,14 @@ pub fn scatter_ground_doodads(
let br = outer(row + 1, col + 1);
let ctr = inner(row, col);
for n in 0..density as usize {
// Exact client draw order: rx, ry ALWAYS consumed; scale/yaw only when a model is placed.
// The reference's draw order: rx and ry always, scale and yaw only for a placed model.
let rx = rng.signed_unit();
let ry = rng.signed_unit();
let Some(model) = eff.doodads[(n + list_index) & 3].as_deref() else {
continue; // empty (−1) slot: places nothing, but rx/ry are already consumed
continue;
};
let scale = rng.signed_unit() * 0.1 + 1.0; // [0.9, 1.1]
let yaw = rng.signed_unit() * std::f32::consts::PI; // (−π, π)
let yaw = rng.signed_unit() * std::f32::consts::PI; // [-π, π]
let (fx, fy) = ((rx + 1.0) * 0.5, (ry + 1.0) * 0.5);
let position = fan_point(fx, fy, tl, tr, bl, br, ctr);
out.push(GroundDoodadPlacement {
@@ -313,11 +257,8 @@ pub fn scatter_ground_doodads(
out
}
/// Interpolate a point inside one MCNK cell at fractional `(fx, fy) ∈ [0, 1]²` (fx=east, fy=south)
/// across the **4-triangle center-fan** the terrain mesh itself draws — barycentric over the wedge
/// containing the point (CTR + two adjacent corners). The 4 wedges are bounded by the cell's two
/// diagonals (`fy=fx` and `fy=1-fx`). Matches the visible terrain so doodads sit flush on hilltops
/// (a 4-corner bilinear would sink them below the rising inner-center vertex).
/// The point at `(fx, fy)` in `[0, 1]²` (east, south) of an MCNK cell, on the terrain's drawn
/// 4-triangle centre fan; a corner bilinear would sink clutter under a raised centre vertex.
fn fan_point(
fx: f32,
fy: f32,
@@ -330,17 +271,13 @@ fn fan_point(
// (u, v, w) weights and the two corner vertices for the wedge.
let (u, v, w, va, vb) = if fx + fy <= 1.0 {
if fy <= fx {
// North wedge: (CTR, TL, TR) — u_ctr=2·fy, v_tl=1−fx−fy, w_tr=fx−fy
(2.0 * fy, 1.0 - fx - fy, fx - fy, tl, tr)
} else {
// West wedge: (CTR, BL, TL) — u_ctr=2·fx, v_bl=fy−fx, w_tl=1−fx−fy
(2.0 * fx, fy - fx, 1.0 - fx - fy, bl, tl)
}
} else if fy >= fx {
// South wedge: (CTR, BR, BL) — u_ctr=2·(1−fy), v_br=fx+fy−1, w_bl=fy−fx
(2.0 * (1.0 - fy), fx + fy - 1.0, fy - fx, br, bl)
} else {
// East wedge: (CTR, TR, BR) — u_ctr=2·(1−fx), v_tr=fx−fy, w_br=fx+fy−1
(2.0 * (1.0 - fx), fx - fy, fx + fy - 1.0, tr, br)
};
[
@@ -350,7 +287,6 @@ fn fan_point(
]
}
/// GroundEffectTexture.dbc — 5875: `ID, doodadId[4], density, sound` (7 fields).
fn texture_schema() -> Schema {
let mut s = Schema::new("GroundEffectTexture");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
@@ -362,7 +298,6 @@ fn texture_schema() -> Schema {
s
}
/// GroundEffectDoodad.dbc — 5875: `ID, internalId, modelPath` (3 fields).
fn doodad_schema() -> Schema {
let mut s = Schema::new("GroundEffectDoodad");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
@@ -371,29 +306,20 @@ fn doodad_schema() -> Schema {
s
}
/// Resolve a DBC doodad string (a bare `ElwGra01.mdl`) to its on-disk model `World\NoDXT\Detail\
/// ElwGra01.m2`. Keeps only the file stem (defensive against any stray path) and forces `.m2`.
/// A DBC doodad name, a bare `ElwGra01.mdl`, to its model `World\NoDXT\Detail\ElwGra01.m2`.
fn resolve_model(dbc_name: &str) -> String {
let file = dbc_name.rsplit(['\\', '/']).next().unwrap_or(dbc_name);
let stem = file.rsplit_once('.').map(|(s, _)| s).unwrap_or(file);
format!("{DETAIL_DIR}{stem}.m2")
}
/// Load the GroundEffect* DBCs from the patch chain into a [`GroundEffectCatalog`].
///
/// `key_by_internal_id` selects how `GroundEffectTexture.doodadId[k]` is matched to a
/// `GroundEffectDoodad` row. **The faithful value is `true`:** the client (verified in `WoW.exe`)
/// treats `doodadId[k]` as a **`GroundEffectDoodad.internalId`**
/// (field 1) and indexes its model cache by it — NOT the DBC row `ID` (field 0). In 5875 the two
/// differ by exactly one (`rowID = internalId + 1`), so keying by row ID is an off-by-one that picks
/// the neighbouring species (e.g. the main Elwynn grass effect resolves a *flower* into slot 0 and
/// shifts the grass). `false` reproduces that old bug for A/B comparison only.
/// Load both ground-effect DBCs off the patch chain. The reference matches `doodadId` against
/// `GroundEffectDoodad.internalId` (field 1), one less than the row `ID` in 5875: `true` is
/// faithful, `false` keys by row `ID`, an off-by-one kept only for A/B comparison.
pub fn load_ground_effect_catalog(
chain: &mut Chain,
key_by_internal_id: bool,
) -> Result<GroundEffectCatalog> {
// doodadId (an internalId) → resolved model path. Keyed by GroundEffectDoodad.internalId (field 1)
// when faithful; field 0 (row ID) is the legacy off-by-one path.
let key_field = if key_by_internal_id { 1 } else { 0 };
let doodads = {
let bytes = chain
@@ -416,7 +342,6 @@ pub fn load_ground_effect_catalog(
let mut effects = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
// Keep the 4 slots in order (not deduped): the client uses slot multiplicity as the weighting.
let mut slots: [Option<String>; 4] = Default::default();
for (i, slot) in slots.iter_mut().enumerate() {
let d = u32_at(r, 1 + i).unwrap_or(0);
@@ -425,7 +350,7 @@ pub fn load_ground_effect_catalog(
}
}
if slots.iter().all(Option::is_none) {
continue; // a "terrain type only" row (footsteps/sound, no doodads) — nothing to draw
continue; // a terrain-type row: footsteps and sound, no doodads
}
let density = u32_at(r, 5).unwrap_or(0);
effects.insert(
@@ -457,8 +382,6 @@ mod tests {
#[test]
fn no_doodad_sentinels() {
// 0xFFFFFFFF is the only empty marker; `0` is the valid internalId of ElwFlo01 (see the
// function's doc-comment for the empirical tally that settles this).
assert!(is_no_doodad(0xFFFF_FFFF));
assert!(!is_no_doodad(0));
assert!(!is_no_doodad(42));
@@ -478,7 +401,6 @@ mod tests {
#[test]
fn randomizer_is_deterministic_and_bounded() {
// Same seed → identical stream (clutter is stable every run).
let (mut a, mut b) = (
BlizzardRandomizer::new(0x0030_0020),
BlizzardRandomizer::new(0x0030_0020),
@@ -486,12 +408,10 @@ mod tests {
for _ in 0..1000 {
assert_eq!(a.shuffle(), b.shuffle());
}
// Different seeds diverge (different chunks ⇒ different layouts).
assert_ne!(
BlizzardRandomizer::new(1).shuffle(),
BlizzardRandomizer::new(2).shuffle()
);
// signed_unit stays in (-1, 1) — the client's genCoord range.
let mut r = BlizzardRandomizer::new(12345);
for _ in 0..10_000 {
let c = r.signed_unit();

Some files were not shown because too many files have changed in this diff Show More