From 8da634afa92070d1d222b6859aa40fa67abb054e Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:38:53 +0700 Subject: [PATCH] Comments in benilla-formats: one line of fact each, no history, no record numbers (#130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../examples/area_trigger_at.rs | 27 +- .../benilla-formats/examples/batch_alpha.rs | 11 +- crates/benilla-formats/examples/batch_lit.rs | 17 +- .../benilla-formats/examples/clutter_cut.rs | 44 +- .../benilla-formats/examples/clutter_size.rs | 38 +- .../benilla-formats/examples/clutter_state.rs | 26 +- crates/benilla-formats/examples/dbc_head.rs | 3 +- .../examples/dump_anim_names.rs | 5 +- .../examples/dump_bone_keys.rs | 11 +- .../benilla-formats/examples/dump_emitters.rs | 13 +- crates/benilla-formats/examples/dump_file.rs | 7 +- .../examples/dump_model_anim.rs | 24 +- crates/benilla-formats/examples/emdump.rs | 18 +- .../examples/eye_burial_anim.rs | 48 +- .../benilla-formats/examples/eye_quad_pass.rs | 55 +- .../benilla-formats/examples/fade_bucket.rs | 13 +- crates/benilla-formats/examples/gseqdump.rs | 9 +- .../benilla-formats/examples/impass_here.rs | 34 +- .../examples/liquid_depth_census.rs | 61 +- crates/benilla-formats/examples/list_chain.rs | 10 +- .../examples/m2_shared_section.rs | 32 +- .../benilla-formats/examples/mm_seam_probe.rs | 71 +- .../benilla-formats/examples/mm_water_pin.rs | 11 +- .../benilla-formats/examples/order_probe.rs | 11 +- crates/benilla-formats/examples/png_disc.rs | 6 +- .../benilla-formats/examples/scan_events.rs | 13 +- crates/benilla-formats/examples/seamswing.rs | 42 +- .../examples/spell_interrupt.rs | 3 +- crates/benilla-formats/examples/spell_row.rs | 6 +- crates/benilla-formats/examples/srci_dump.rs | 9 +- .../benilla-formats/examples/surface_here.rs | 38 +- crates/benilla-formats/examples/water_here.rs | 75 +- .../benilla-formats/examples/what_is_here.rs | 16 +- crates/benilla-formats/examples/where.rs | 16 +- .../examples/wmo_antiportal_census.rs | 135 +-- crates/benilla-formats/examples/wmo_bsp.rs | 62 +- .../examples/wmo_chunk_census.rs | 27 +- .../examples/wmo_collision_cost.rs | 31 +- .../benilla-formats/examples/wmo_coplanar.rs | 35 +- .../examples/wmo_dark_floors.rs | 27 +- .../benilla-formats/examples/wmo_doubled.rs | 52 +- .../examples/wmo_liquid_arms.rs | 51 +- .../examples/wmo_liquid_scope.rs | 93 +- crates/benilla-formats/examples/wmo_mocv.rs | 44 +- .../examples/wmo_ownerless_pools.rs | 92 +- crates/benilla-formats/src/anim_data.rs | 64 +- crates/benilla-formats/src/area_poi.rs | 74 +- crates/benilla-formats/src/area_sound.rs | 64 +- crates/benilla-formats/src/area_table.rs | 110 +-- crates/benilla-formats/src/area_trigger.rs | 95 +- crates/benilla-formats/src/auction_house.rs | 54 +- .../src/bank_bag_slot_prices.rs | 20 +- .../src/bin/benilla-extract/chaincensus.rs | 19 +- .../src/bin/benilla-extract/charatlas.rs | 58 +- .../src/bin/benilla-extract/charprocs.rs | 37 +- .../src/bin/benilla-extract/glueextent.rs | 27 +- .../src/bin/benilla-extract/kitanim.rs | 52 +- .../src/bin/benilla-extract/m2dump.rs | 341 ++----- .../src/bin/benilla-extract/main.rs | 661 ++++---------- .../src/bin/benilla-extract/scan/geometry.rs | 190 +--- .../src/bin/benilla-extract/scan/lighting.rs | 76 +- .../src/bin/benilla-extract/scan/material.rs | 672 ++++---------- .../src/bin/benilla-extract/scan/mod.rs | 61 +- .../src/bin/benilla-extract/scan/particles.rs | 167 +--- .../src/bin/benilla-extract/scan/sequence.rs | 258 ++---- .../src/bin/benilla-extract/scan/skeleton.rs | 139 +-- .../src/bin/benilla-extract/scan/world.rs | 82 +- .../src/bin/benilla-extract/shakecensus.rs | 69 +- .../src/bin/benilla-extract/spellvis.rs | 20 +- .../src/bin/benilla-extract/thudcensus.rs | 56 +- crates/benilla-formats/src/bin/mpqx.rs | 11 +- crates/benilla-formats/src/camera_shakes.rs | 137 +-- crates/benilla-formats/src/chain.rs | 86 +- .../src/characters/customization.rs | 201 ++--- .../benilla-formats/src/characters/geosets.rs | 248 ++--- crates/benilla-formats/src/characters/mod.rs | 29 +- .../src/characters/sections.rs | 642 +++---------- crates/benilla-formats/src/chat_channels.rs | 115 +-- crates/benilla-formats/src/chr_classes.rs | 153 +--- crates/benilla-formats/src/cinematics.rs | 258 ++---- .../benilla-formats/src/creature_families.rs | 238 +---- crates/benilla-formats/src/creature_sound.rs | 104 +-- crates/benilla-formats/src/creature_types.rs | 27 +- crates/benilla-formats/src/creatures.rs | 472 +++------- crates/benilla-formats/src/dbc.rs | 35 +- crates/benilla-formats/src/death_thud.rs | 83 +- crates/benilla-formats/src/durability.rs | 54 +- crates/benilla-formats/src/elevators.rs | 89 +- crates/benilla-formats/src/emit_timing.rs | 188 ++-- crates/benilla-formats/src/emote_text.rs | 171 +--- crates/benilla-formats/src/emotes.rs | 129 +-- .../src/environmental_damage.rs | 28 +- crates/benilla-formats/src/exhaustion.rs | 32 +- crates/benilla-formats/src/factions.rs | 345 ++----- crates/benilla-formats/src/footsteps.rs | 88 +- crates/benilla-formats/src/game_tips.rs | 38 +- crates/benilla-formats/src/gameobjects.rs | 28 +- crates/benilla-formats/src/garble.rs | 189 +--- .../benilla-formats/src/gm_ticket_category.rs | 66 +- crates/benilla-formats/src/ground_effects.rs | 208 ++--- crates/benilla-formats/src/install.rs | 198 +--- .../src/item_random_properties.rs | 89 +- crates/benilla-formats/src/item_sounds.rs | 34 +- crates/benilla-formats/src/item_visuals.rs | 218 ++--- crates/benilla-formats/src/itembagfamily.rs | 40 +- crates/benilla-formats/src/itemclass.rs | 40 +- crates/benilla-formats/src/items.rs | 203 ++--- crates/benilla-formats/src/itemsets.rs | 26 +- crates/benilla-formats/src/itemsubclass.rs | 173 ++-- crates/benilla-formats/src/languages.rs | 153 +--- crates/benilla-formats/src/lib.rs | 224 ++--- crates/benilla-formats/src/light.rs | 445 +++------ .../benilla-formats/src/light/atmosphere.rs | 167 +--- crates/benilla-formats/src/light/tables.rs | 18 +- crates/benilla-formats/src/liquid.rs | 407 +++------ crates/benilla-formats/src/loading_screen.rs | 24 +- crates/benilla-formats/src/lock.rs | 156 ++-- crates/benilla-formats/src/lock_type.rs | 64 +- crates/benilla-formats/src/macro_icons.rs | 67 +- crates/benilla-formats/src/maps.rs | 120 +-- crates/benilla-formats/src/material.rs | 79 +- .../benilla-formats/src/minimap_translate.rs | 50 +- crates/benilla-formats/src/models/anim.rs | 574 +++--------- .../src/models/anim_summary.rs | 95 +- .../benilla-formats/src/models/art_extent.rs | 209 +---- crates/benilla-formats/src/models/bounds.rs | 176 ++-- .../benilla-formats/src/models/collision.rs | 87 +- crates/benilla-formats/src/models/key_anim.rs | 197 ++-- .../benilla-formats/src/models/m2_batches.rs | 492 +++------- crates/benilla-formats/src/models/mat_anim.rs | 277 ++---- crates/benilla-formats/src/models/mod.rs | 79 +- crates/benilla-formats/src/models/records.rs | 247 ++--- crates/benilla-formats/src/models/tex_anim.rs | 124 +-- crates/benilla-formats/src/models/types.rs | 524 +++-------- .../benilla-formats/src/models/wmo/group.rs | 462 +++------- crates/benilla-formats/src/models/wmo/mod.rs | 28 +- crates/benilla-formats/src/models/wmo/root.rs | 186 ++-- crates/benilla-formats/src/npc_greeting.rs | 62 +- .../benilla-formats/src/page_text_material.rs | 33 +- crates/benilla-formats/src/particles.rs | 550 ++++-------- crates/benilla-formats/src/pet_stats.rs | 126 +-- crates/benilla-formats/src/quest_headers.rs | 18 +- crates/benilla-formats/src/quest_info.rs | 62 +- crates/benilla-formats/src/race_pvp_team.rs | 42 +- crates/benilla-formats/src/race_sound.rs | 19 +- crates/benilla-formats/src/ribbons.rs | 207 ++--- crates/benilla-formats/src/server_messages.rs | 60 +- crates/benilla-formats/src/sheathe.rs | 57 +- crates/benilla-formats/src/skill_lines/mod.rs | 325 ++----- .../benilla-formats/src/skill_lines/tests.rs | 135 +-- crates/benilla-formats/src/sound_entries.rs | 148 +-- crates/benilla-formats/src/sound_provider.rs | 48 +- crates/benilla-formats/src/sound_water.rs | 20 +- crates/benilla-formats/src/spell_focus.rs | 19 +- crates/benilla-formats/src/spell_mechanic.rs | 32 +- .../src/spell_visual/chain_effects.rs | 172 +--- .../benilla-formats/src/spell_visual/mod.rs | 522 +++-------- .../benilla-formats/src/spell_visual/tests.rs | 165 +--- .../benilla-formats/src/spells/cast_times.rs | 55 +- .../src/spells/catalog_tests.rs | 520 +++-------- .../src/spells/dispel_types.rs | 38 +- crates/benilla-formats/src/spells/display.rs | 847 ++++-------------- crates/benilla-formats/src/spells/duration.rs | 53 +- crates/benilla-formats/src/spells/forms.rs | 64 +- crates/benilla-formats/src/spells/immunity.rs | 109 +-- crates/benilla-formats/src/spells/mod.rs | 659 +++----------- crates/benilla-formats/src/spells/radius.rs | 13 +- crates/benilla-formats/src/spells/ranges.rs | 116 +-- crates/benilla-formats/src/spells/tokens.rs | 133 +-- .../benilla-formats/src/stable_slot_prices.rs | 34 +- crates/benilla-formats/src/stationery.rs | 46 +- crates/benilla-formats/src/talents.rs | 92 +- crates/benilla-formats/src/taxi.rs | 50 +- crates/benilla-formats/src/taxi_nodes.rs | 48 +- crates/benilla-formats/src/taxi_path.rs | 37 +- crates/benilla-formats/src/terrain.rs | 362 ++------ .../benilla-formats/src/text_filter_lists.rs | 54 +- crates/benilla-formats/src/tga.rs | 92 +- .../benilla-formats/src/transport_period.rs | 92 +- crates/benilla-formats/src/transports/mod.rs | 314 ++----- .../benilla-formats/src/transports/tests.rs | 48 +- crates/benilla-formats/src/unit_blood.rs | 149 +-- crates/benilla-formats/src/value_track.rs | 113 +-- crates/benilla-formats/src/vocal_ui_sounds.rs | 91 +- crates/benilla-formats/src/wdl.rs | 120 +-- crates/benilla-formats/src/weapon_impact.rs | 44 +- crates/benilla-formats/src/weapon_swing.rs | 37 +- crates/benilla-formats/src/wmo_area.rs | 97 +- crates/benilla-formats/src/world_map_area.rs | 52 +- .../src/world_map_continent.rs | 38 +- .../benilla-formats/src/world_map_overlay.rs | 39 +- crates/benilla-formats/src/world_state_ui.rs | 112 +-- crates/benilla-formats/src/zone_map.rs | 24 +- crates/benilla-formats/tests/billboards.rs | 13 +- .../benilla-formats/tests/blob_shadow_box.rs | 17 +- crates/benilla-formats/tests/blp_alpha.rs | 6 +- .../tests/camera_swim_pivot_drop.rs | 57 +- crates/benilla-formats/tests/chain.rs | 291 ++---- .../tests/chat_bubble_anchor_height.rs | 40 +- crates/benilla-formats/tests/ground_quad.rs | 34 +- .../tests/helm_vis_wants_a_helm.rs | 35 +- crates/benilla-formats/tests/item_icons.rs | 8 +- .../tests/light_area_blend_order.rs | 46 +- .../tests/light_no_row_fallback.rs | 38 +- .../tests/light_zero_key_band.rs | 43 +- .../benilla-formats/tests/m2_anim_events.rs | 26 +- .../m2_authored_box_is_the_animated_extent.rs | 30 +- crates/benilla-formats/tests/m2_billboard.rs | 10 +- .../tests/m2_billboard_card_face.rs | 28 +- crates/benilla-formats/tests/m2_bone_spin.rs | 46 +- .../benilla-formats/tests/m2_cold_breath.rs | 38 +- crates/benilla-formats/tests/m2_color.rs | 12 +- crates/benilla-formats/tests/m2_emissive.rs | 5 +- .../tests/m2_entity_mat_anim.rs | 71 +- crates/benilla-formats/tests/m2_env_map.rs | 21 +- .../tests/m2_global_seq_constant.rs | 13 +- .../benilla-formats/tests/m2_go_arm_book.rs | 46 +- .../benilla-formats/tests/m2_go_crate_lid.rs | 47 +- .../tests/m2_go_rookery_egg.rs | 49 +- crates/benilla-formats/tests/m2_idle_anim.rs | 19 +- crates/benilla-formats/tests/m2_lights.rs | 18 +- .../tests/m2_loader_idle_is_stand.rs | 33 +- .../tests/m2_playable_animation_lookup.rs | 33 +- .../tests/m2_portrait_camera.rs | 30 +- .../tests/m2_sequence_bounds.rs | 14 +- .../tests/m2_shared_section.rs | 29 +- .../tests/m2_swipe_claw_trail.rs | 37 +- .../tests/m2_uv_anim_variation.rs | 30 +- .../tests/particle_burst_gate.rs | 22 +- crates/benilla-formats/tests/particles.rs | 114 +-- .../benilla-formats/tests/quest_tag_names.rs | 20 +- crates/benilla-formats/tests/ribbons.rs | 7 +- .../tests/selection_ring_radius.rs | 46 +- crates/benilla-formats/tests/spell_catalog.rs | 52 +- .../tests/transport_speed_profile.rs | 36 +- .../tests/wmo_chunk_overrun.rs | 31 +- .../benilla-formats/tests/wmo_doorway_fade.rs | 36 +- crates/benilla-formats/tests/wmo_fogs.rs | 13 +- crates/benilla-formats/tests/wmo_lights.rs | 10 +- crates/benilla-formats/tests/wmo_liquid.rs | 54 +- .../tests/wmo_mocv_short_tail.rs | 31 +- crates/benilla-formats/tests/wow_data_env.rs | 13 +- crates/benilla-formats/tests/wow_data_none.rs | 14 +- 243 files changed, 6607 insertions(+), 19001 deletions(-) diff --git a/crates/benilla-formats/examples/area_trigger_at.rs b/crates/benilla-formats/examples/area_trigger_at.rs index d2bb3f95..df358770 100644 --- a/crates/benilla-formats/examples/area_trigger_at.rs +++ b/crates/benilla-formats/examples/area_trigger_at.rs @@ -1,23 +1,12 @@ -//! Where an area trigger actually **is**, and how to walk into it: -//! `cargo run -p benilla-formats --example area_trigger_at -- [id|map…]` +//! Each area trigger's volume, with a `.go` spot outside it and the facing that walks you in. +//! `cargo run -p benilla-formats --example area_trigger_at -- ...` //! -//! The client's half of every portal is pure geometry (`crate::area_trigger`'s law), and that -//! geometry lives only in `AreaTrigger.dbc` — the server's `areatrigger_teleport` row names the -//! *destination* but never the volume you have to cross to reach it. So a probe that wants to -//! reproduce a portal the way a player meets it — **walking in**, which the module's own warning -//! says is not the same as a `.go` that lands inside — has no way to aim without this. -//! -//! Prints each row's volume (sphere radius, or the oriented box) plus a ready-made `.go` for a spot -//! `APPROACH` yards **outside** it on the box's own axis, and the facing that then walks you -//! through. An argument about whether a portal fired is otherwise unfalsifiable: landing inside the -//! volume races the server's re-check and is silently ignored about one time in six (measured, see -//! `crate::area_trigger`). -//! -//! Output is Blizzard-derived; pipe it to the scratchpad, never into the repo. +//! The volume lives only in `AreaTrigger.dbc`; the server's teleport row holds the destination. A +//! `.go` that lands inside races the server's re-check against its stored position and is +//! sometimes ignored, so a portal is tested by walking in. +//! Output is Blizzard data: never commit it. -/// How far outside the volume to park the approach `.go`, in yards. Far enough that the trigger is -/// unambiguously *not* yet entered (so the walk-in is a real crossing), close enough that a few -/// seconds of held-forward covers it. +/// Yards outside the volume for the approach spot. const APPROACH: f32 = 12.0; fn main() -> anyhow::Result<()> { @@ -47,7 +36,7 @@ fn main() -> anyhow::Result<()> { "id {:5} map {:3} SPHERE r={:.1} at ({x:.2}, {y:.2}, {z:.2})", r.id, r.map_id, r.radius ); - // A sphere has no authored axis; approach along -Y, the arbitrary but stated choice. + // A sphere has no authored axis: approach along -Y, an arbitrary choice. let sy = y + r.radius + APPROACH; println!( " walk in: .go xyz {x:.2} {sy:.2} {z:.2} {} then face -Y (yaw {:.3}) and hold forward", diff --git a/crates/benilla-formats/examples/batch_alpha.rs b/crates/benilla-formats/examples/batch_alpha.rs index 3fc405af..42248464 100644 --- a/crates/benilla-formats/examples/batch_alpha.rs +++ b/crates/benilla-formats/examples/batch_alpha.rs @@ -1,12 +1,7 @@ -//! Print every render batch's **animated material alpha** across a named sequence — the factor the -//! renderer multiplies into the instance's render alpha (`A = instanceAlpha × colourAlpha × weight`). -//! +//! Every render batch's animated alpha over one sequence, `colourAlpha × weight` in +//! `A = instanceAlpha × colourAlpha × weight`. A batch that never reaches exactly 1.0 stays on the +//! fade blend twin, which writes depth even for a `no_depth_write` batch (M2 render flag 0x10). //! `cargo run -p benilla-formats --example batch_alpha -- 'Creature\Voidwalker\Voidwalker.m2' 0` -//! -//! The number that matters is whether a batch ever reads **exactly 1.0**: benilla swaps a faded -//! entity onto its `AlphaMode::Blend` twin whenever `alpha < 1.0`, and that twin forces depth-write -//! ON. A batch whose authored weight never reaches 1.0 therefore never returns to the steady -//! material — so a `no_depth_write` batch (M2 flag 0x10) starts writing depth it must never write. fn main() -> anyhow::Result<()> { let mut args = std::env::args().skip(1); diff --git a/crates/benilla-formats/examples/batch_lit.rs b/crates/benilla-formats/examples/batch_lit.rs index 19a6c290..2e718b1d 100644 --- a/crates/benilla-formats/examples/batch_lit.rs +++ b/crates/benilla-formats/examples/batch_lit.rs @@ -1,15 +1,8 @@ -//! Print every render batch's **lighting-relevant material state** — the UNLIT (0x01) / UNFOGGED -//! (0x02) render flags as our reader resolves them, beside the blend mode and texture. -//! -//! `cargo run -p benilla-formats --example batch_lit -- 'Interface\Glues\Models\UI_Tauren\UI_Tauren.m2'` -//! -//! Built for B121: a glue scene's ground is a multi-layer stack whose overlay layers are authored -//! **UNLIT** (drawn fullbright, `colour = c28[0]·E_SH + c28[1]` with `c28[0] = 0`, `0x70c190`), -//! which is why UI_Tauren can author no ambient-fill light and still show a lit ground in the -//! reference. The question this answers is the one a grep cannot: whether -//! the flag survives *our* batch reader's render-flag indexing to the `RenderSubmesh` the material -//! builder consumes. A batch printed `LIT` here that the asset authors UNLIT is a reader bug; the -//! asset's own flag table is a separate read (the render-flag array at header 0x84). +//! Every render batch's UNLIT (0x01) and UNFOGGED (0x02) flags as our reader resolves them, with +//! blend mode and texture. A batch printed `lit` that the asset authors UNLIT (the render-flag +//! array at header 0x84) is a reader bug. Glue-scene ground overlays are authored UNLIT and drawn +//! fullbright (`0x70c190`), which is how UI_Tauren's ground is lit with no ambient light. +//! `cargo run -p benilla-formats --example batch_lit -- ` fn main() -> anyhow::Result<()> { let virt = std::env::args() diff --git a/crates/benilla-formats/examples/clutter_cut.rs b/crates/benilla-formats/examples/clutter_cut.rs index e4e12584..6e65e4d9 100644 --- a/crates/benilla-formats/examples/clutter_cut.rs +++ b/crates/benilla-formats/examples/clutter_cut.rs @@ -1,30 +1,8 @@ -//! **How the ground-clutter fade boundary cuts** — pixel by pixel, or leaf by leaf? -//! +//! Whether the ground-clutter fade erodes a tuft or flips whole leaves, per detail atlas and mip. //! `cargo run -p benilla-formats --example clutter_cut [substring]` //! -//! The detail-doodad draw alpha-tests `texel.a × ramp(view_depth)` against `detailDoodadAlpha` -//! (128/255), so as the ramp falls the surviving set is `{ texel.a ≥ 0.502 / ramp }`. Whether that -//! reads as a tuft *eroding* — the boundary sweeping through a leaf a pixel at a time — or as the -//! leaf *flipping whole* is decided entirely by the alpha distribution **at the mip the tuft is -//! sampled at**. The alpha arithmetic is settled and identical either way, so when the cut looks -//! wrong the answer is in what is being cut, not in the cutting. -//! -//! Per atlas, per mip level, three numbers: -//! -//! * **`distinct`** — how many distinct alpha values the level carries. **`2` means binary**: the -//! product `a × ramp` is then either `0` or `ramp`, so the alpha test degenerates to a global -//! on/off and *every texel of every leaf crosses the threshold at the same view depth*. No -//! erosion is possible at such a level, at any distance, ever. -//! * **coverage** across the fade band — the share of the atlas still drawn at each depth. Sliding -//! down = the tuft erodes. Holding flat then dropping to zero = the tuft pops. -//! * **cliff** — how much of the still-drawn coverage goes in the last 0.2 yd before the crossing. -//! 100% means everything that was left leaves in one step. -//! -//! The header line also prints the BLP's own `compression / alpha_bits / alpha_type / has_mips`, -//! because the shipped art is not uniform: some detail atlases carry a properly averaged mip -//! pyramid and some carry a **binary-thresholded** one, and the two behave completely differently -//! at the boundary. Parsed inline from the documented BLP2 layout rather than through the decoder, -//! so this reports what the *file* says independently of how we read it. +//! The draw alpha-tests `texel.a × ramp(view_depth)` against 128/255, so the alpha distribution at +//! the sampled mip decides the cut: a level with only 2 distinct alphas crosses all at once. use std::collections::BTreeSet; @@ -39,14 +17,11 @@ fn ramp(view_depth: f32, far: f32) -> f32 { /// The detail-doodad alpha-test reference (`detailDoodadAlpha` = 128). const CUTOUT: f32 = 128.0 / 255.0; -/// Where in the band to sample coverage — clustered at the 61.11 yd crossing, where even a fully -/// opaque texel fails the test and the whole question lives. +/// Depths to sample coverage at, clustered at 61.11 yd, where even an opaque texel fails the test. const BANDS: [f32; 8] = [52.5, 54.0, 56.0, 58.0, 60.0, 61.0, 61.1, 61.2]; -/// What the BLP2 header says about itself: `compression` (1 = palettized, 2 = DXT, 3 = BGRA8), -/// `alpha_bits`, `alpha_type` (DXT sub-form: 1 = DXT3, 7 = DXT5) and the `has_mipmaps` byte at -/// offset 0x0B — whose value tracks, in the shipped detail art, whether the mip chain is averaged -/// or thresholded. +/// The BLP2 header's `compression` (1 palettized, 2 DXT, 3 BGRA8), `alpha_bits`, `alpha_type` +/// (1 DXT3, 7 DXT5) and `has_mips` (0x0B), which in the detail art tracks a thresholded chain. fn blp_header(bytes: &[u8]) -> Option<(u8, u8, u8, u8)> { (bytes.len() >= 20 && &bytes[0..4] == b"BLP2") .then(|| (bytes[8], bytes[9], bytes[10], bytes[11])) @@ -58,7 +33,6 @@ fn main() -> anyhow::Result<()> { let mut chain = benilla_formats::open_chain(&data)?; let catalog = benilla_formats::load_ground_effect_catalog(&mut chain, true)?; - // Every distinct detail texture the catalog can place. let mut models: BTreeSet = BTreeSet::new(); for id in 0..4096u32 { if let Some(e) = catalog.effect(id) { @@ -103,9 +77,7 @@ fn main() -> anyhow::Result<()> { None => println!("\n{name}"), } - // What an AVERAGED chain would carry, for comparison — mip0's alpha box-filtered down. - // This is the control for a binary chain: it says how much of the flat coverage is the - // art's own and how much is the authored mip pyramid throwing the gradient away. + // The control: mip0's alpha box-filtered down, what an averaged chain would carry. let mip0_alpha: Vec = mips.mips[0] .as_chunks::<4>() .0 @@ -174,8 +146,6 @@ fn main() -> anyhow::Result<()> { " mip{i} {w:3}x{h:3} distinct {distinct:<4} [{}] cliff@61 {cliff:3.0}%", covs.join(" ") ); - // The averaged control, printed only where the authored level is binary — that is the - // only case where the two can disagree, and seeing them side by side is the point. if i > 0 && distinct <= 2 { if let Some(avg) = averaged.get(i) { let mut seen = [false; 256]; diff --git a/crates/benilla-formats/examples/clutter_size.rs b/crates/benilla-formats/examples/clutter_size.rs index 46bedd63..23ac95c2 100644 --- a/crates/benilla-formats/examples/clutter_size.rs +++ b/crates/benilla-formats/examples/clutter_size.rs @@ -1,28 +1,17 @@ -//! **How big is one tuft, and how much DEPTH does it span?** — the geometry half of the -//! "why does the fade cut leaf-by-leaf instead of pixel-by-pixel" question. -//! -//! `cargo run -p benilla-formats --example clutter_size` -//! -//! The fade boundary is a **plane** at a fixed view depth (the reference texgens camera-space Z). -//! Whether that plane *slices* a tuft — some of it drawn, the rest gone, a per-pixel cut — or -//! passes it by in a single frame depends on how much view depth one tuft occupies. A tuft that -//! spans 1 yd of depth is sliced for as long as it takes the player to walk 1 yd. A tuft that -//! spans 5 cm is, for practical purposes, either wholly in or wholly out: it pops. -//! -//! So this prints, per detail model: the world-space bounding box, the tuft's **depth span at the -//! worst yaw** (its horizontal diagonal — the widest it can present to the camera), and the -//! screen-space size that implies at the 61.11 yd cutout crossing. +//! Each detail model's size and the view depth one tuft spans. The fade boundary is a plane at a +//! fixed view depth (the reference texgens camera-space Z), so a deep tuft is sliced and a thin +//! one pops. `cargo run -p benilla-formats --example clutter_size` use std::collections::BTreeSet; -/// Vertical FOV benilla's camera runs at (Bevy's `PerspectiveProjection` default, ≈ the -/// reference's 44.1°) over a 1080-tall viewport — the same basis `clutter_state` uses. +/// Pixels per yard at `distance` for benilla's 45° vertical FOV (the reference's is ≈44.1° at +/// 16:9) on a 1080-tall viewport, the same basis as `clutter_state`. fn px_per_yard(distance: f32) -> f32 { let fov: f32 = std::f32::consts::PI / 4.0; (1080.0 / 2.0) / ((fov / 2.0).tan() * distance) } -/// Where the `detailDoodadAlpha` = 128 cutout erases even a fully-opaque texel. +/// The view depth where the 128/255 cutout erases even an opaque texel. const CROSSING: f32 = 61.113; fn main() -> anyhow::Result<()> { @@ -109,17 +98,10 @@ fn main() -> anyhow::Result<()> { the per-pixel slice. Below roughly a tenth of a yard it is a single-frame pop instead." ); - // **Which mip a tuft is sampled at, and why the answer moves with the window.** This is the - // number the whole look turns on: the shipped Elwynn/Westfall/AeriePeaks atlases carry a binary - // alpha pyramid, and a fragment can only hold full alpha — and so die at the same depth as - // every other fragment of its leaf — inside a 2x2 all-opaque texel neighbourhood. Those run out - // between mip 2 and mip 3 (179/56/10/0/0/0 for levels 0-5, counted on the dominant Elwynn - // cell), so sampling past ~3 erodes continuously and sampling below it takes leaves whole. - // - // The reference's `+0.25` stage-0 LOD bias buys a quarter of a mip toward that cliff — but the - // sample LOD falls as the viewport grows, so the SAME bias lands differently on a bigger - // window. The reference itself was measured at lambda 2.9-3.3 in a 1152x648 capture and - // lambda ~2.45 at 1920x1080, i.e. the reference pops leaf-by-leaf at a modern resolution too. + // The sampled mip decides the cut: on the binary-alpha atlases a fragment keeps full alpha only + // inside an all-opaque 2x2 neighbourhood, which runs out between mip 2 and 3, so past ~3 a tuft + // erodes and below it leaves cross whole. The reference biases stage 0 by +0.25 (`0x6813d0`); + // its lambda was measured at 2.9-3.3 at 1152x648, and ~2.45 at 1920x1080 is computed from that. const DENSITY: f32 = 100.0; // texels per yard, the median from `clutter_state` println!("\nsampled mip at the {CROSSING:.1} yd crossing (median {DENSITY:.0} texels/yd art):"); for height in [648.0f32, 1080.0, 1440.0, 2160.0] { diff --git a/crates/benilla-formats/examples/clutter_state.rs b/crates/benilla-formats/examples/clutter_state.rs index bf7b560e..231df571 100644 --- a/crates/benilla-formats/examples/clutter_state.rs +++ b/crates/benilla-formats/examples/clutter_state.rs @@ -1,15 +1,8 @@ -//! Dump the **render state and texture alpha profile** of every ground-clutter (detail-doodad) model -//! the `GroundEffect*` catalog resolves — the inputs benilla's clutter draw derives its material from. -//! +//! Every ground-clutter model's batch render state and each detail texture's alpha per mip, the +//! inputs to the ~70 yd clutter fade. The reference's detail pass (`0x6b2b80`) ignores the batches' +//! blend and two-sidedness and forces one state for every tuft; the fade multiplies the texture +//! alpha, so its per-mip distribution is the fade's shape. //! `cargo run -p benilla-formats --example clutter_state` -//! -//! Two questions it answers, both load-bearing for the ~70 yd distance fade: -//! * what **blend mode / two-sidedness** each detail M2's batches author — the reference's -//! detail-doodad pass (`0x6b2b80`) ignores these and forces one state for every tuft, so a -//! per-batch value benilla passes through is a divergence to see, not to assume; -//! * how each detail texture's **alpha is distributed per mip level** — a cutout whose alpha is -//! binary (0/255) snaps out of an alpha test all at once, while a smoothly-distributed one -//! erodes gradually. The fade multiplies this alpha, so the distribution *is* the fade's shape. use std::collections::{BTreeMap, BTreeSet}; @@ -18,8 +11,7 @@ fn main() -> anyhow::Result<()> { let mut chain = benilla_formats::open_chain(&data)?; let catalog = benilla_formats::load_ground_effect_catalog(&mut chain, true)?; - // Every distinct model the catalog can place. `GroundEffectCatalog` exposes rows by id only, so - // sweep the id space the DBC uses (ids are small and dense; a miss costs one hash lookup). + // Every distinct model the catalog can place; it exposes rows by id only, so sweep the ids. let mut models: BTreeSet = BTreeSet::new(); for id in 0..4096u32 { if let Some(e) = catalog.effect(id) { @@ -72,8 +64,7 @@ fn main() -> anyhow::Result<()> { } } println!("\nbatch blend modes: {blends:?}"); - // Screen-space mip level per distance: the vertical FOV benilla's camera uses (Bevy's - // `PerspectiveProjection` default, ~= the reference's 44.1 deg) over a 1080-tall viewport. + // Mip level per distance, for benilla's 45° vertical FOV on a 1080-tall viewport. let fov: f32 = std::f32::consts::PI / 4.0; let px_per_yard = |d: f32| (1080.0 / 2.0) / ((fov / 2.0).tan() * d); densities.sort_by(f32::total_cmp); @@ -137,9 +128,8 @@ fn main() -> anyhow::Result<()> { Ok(()) } -/// Texels-per-yard for a batch: the atlas footprint its UVs cover divided by the world size its -/// geometry covers. With the screen-space pixels-per-yard at a distance this gives the mip level the -/// GPU selects there — and the mip level decides the alpha the cutout test sees. +/// Texels per yard for a batch, its UV span in atlas texels over its world span; against pixels per +/// yard at a distance it gives the mip sampled there. fn texel_density(sub: &benilla_formats::RenderSubmesh, atlas: f32) -> Option { if sub.positions.is_empty() || sub.uvs.len() != sub.positions.len() { return None; diff --git a/crates/benilla-formats/examples/dbc_head.rs b/crates/benilla-formats/examples/dbc_head.rs index 0dd123eb..a01fe286 100644 --- a/crates/benilla-formats/examples/dbc_head.rs +++ b/crates/benilla-formats/examples/dbc_head.rs @@ -1,5 +1,4 @@ -//! Throwaway: dump a DBC's header (record count / field count / record size) and the first rows' -//! raw dwords, so a claimed column offset can be checked against the real shipped file. +//! Each named DBC's header and first rows' raw dwords, to check a column offset against the file. fn main() -> anyhow::Result<()> { let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); let mut chain = benilla_formats::open_chain(&data)?; diff --git a/crates/benilla-formats/examples/dump_anim_names.rs b/crates/benilla-formats/examples/dump_anim_names.rs index 451571c7..0afeb0fd 100644 --- a/crates/benilla-formats/examples/dump_anim_names.rs +++ b/crates/benilla-formats/examples/dump_anim_names.rs @@ -1,6 +1,5 @@ -//! Session probe: print AnimationData.dbc rows (id, name, weapon flags, fallback) — -//! `cargo run -p benilla-formats --example dump_anim_names [id ...]`. Without ids, -//! prints all 208 rows. Built for the bowstring round (which sequences do bow M2s carry). +//! `AnimationData.dbc` rows (id, name, weapon flags, fallback), all of them without ids. +//! `cargo run -p benilla-formats --example dump_anim_names [id ...]` fn main() { let mut args = std::env::args().skip(1); diff --git a/crates/benilla-formats/examples/dump_bone_keys.rs b/crates/benilla-formats/examples/dump_bone_keys.rs index df65e16b..2002c8f4 100644 --- a/crates/benilla-formats/examples/dump_bone_keys.rs +++ b/crates/benilla-formats/examples/dump_bone_keys.rs @@ -1,7 +1,6 @@ -//! Session probe: which bones carry keys in a given sequence — `cargo run -p benilla-formats -//! --example dump_bone_keys [bone ...]`. With bone indices, prints their -//! key counts + first rotation keys; without, lists every keyed bone. Extract the file first -//! via `mpqx` (benilla-mpq). Built for the nocked-ammo investigation (decision 2273 follow-up). +//! Which bones carry keys in one sequence, with their first keys; bone indices narrow the list. +//! `cargo run -p benilla-formats --example dump_bone_keys [bone ...]`, the +//! file extracted first with `mpqx`. fn main() { let mut args = std::env::args().skip(1); @@ -64,9 +63,7 @@ fn main() { q[0], q[1], q[2], q[3] ); } - // T/S values too, not just counts — for a flare or a rise the VALUES are the - // effect (the gseqdump lesson, here for sequence keys), and per-axis (non-)uniformity - // of a scale key is load-bearing for the billboard law's per-axis preserve. + // Translation and scale values, not only counts: a billboard preserves scale per axis. for (t, v) in bk.translation.iter().take(8) { println!( " trans t={t:.3} ({:+.3},{:+.3},{:+.3})", diff --git a/crates/benilla-formats/examples/dump_emitters.rs b/crates/benilla-formats/examples/dump_emitters.rs index 388fe798..80cb059d 100644 --- a/crates/benilla-formats/examples/dump_emitters.rs +++ b/crates/benilla-formats/examples/dump_emitters.rs @@ -1,12 +1,7 @@ -//! Print a model's particle-emitter records — the authored numbers to hold a live -//! `WOW_PARTICLE_CENSUS` line against: `cargo run -p benilla-formats --example dump_emitters -- -//! 'World\...\RubyCrystalLarge01.m2'`. -//! -//! The pair is the check that matters (decision 0653): the census says how many particles are -//! *live*, this says how many the file asks for (`rate × lifespan`), and the over-life ramp says -//! how big they get. A mismatch is our sim's bug; a match moves the question to the look. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. +//! A model's particle-emitter records, the authored side of a live `WOW_PARTICLE_CENSUS` line: +//! the file asks for `rate × lifespan` live particles, and a census that disagrees is our bug. +//! `cargo run -p benilla-formats --example dump_emitters -- 'World\...\RubyCrystalLarge01.m2'` +//! Output is Blizzard data: never commit it. fn main() -> anyhow::Result<()> { let virt = std::env::args() diff --git a/crates/benilla-formats/examples/dump_file.rs b/crates/benilla-formats/examples/dump_file.rs index 4336fb5e..7d123e5f 100644 --- a/crates/benilla-formats/examples/dump_file.rs +++ b/crates/benilla-formats/examples/dump_file.rs @@ -1,7 +1,6 @@ -//! Dump any file from the 5875 patch chain to stdout — the generic MPQ extractor for -//! reference reads (FrameXML sources, DBC layouts): `cargo run -p benilla-formats -//! --example dump_file -- 'Interface\FrameXML\TradeSkillFrame.lua'`. Output is Blizzard -//! data — pipe it to the scratchpad, never into the repo. +//! Any file from the patch chain to stdout, such as a FrameXML source or a DBC. +//! `cargo run -p benilla-formats --example dump_file -- 'Interface\FrameXML\TradeSkillFrame.lua'` +//! Output is Blizzard data: never commit it. use std::io::Write; diff --git a/crates/benilla-formats/examples/dump_model_anim.rs b/crates/benilla-formats/examples/dump_model_anim.rs index 4d02a796..4e2dbc92 100644 --- a/crates/benilla-formats/examples/dump_model_anim.rs +++ b/crates/benilla-formats/examples/dump_model_anim.rs @@ -1,19 +1,8 @@ -//! What can this model move, at all? Print the [`M2AnimSummary`](benilla_formats::M2AnimSummary) -//! — every animation channel family — plus the texture-transform (UV-scroll) tracks, for one M2 or -//! for every M2 whose path matches a substring: -//! -//! ```text -//! cargo run -p benilla-formats --example dump_model_anim -- 'World\...\RubyCrystalLarge01.m2' -//! cargo run -p benilla-formats --example dump_model_anim -- maraudon # every match -//! ``` -//! -//! This is the **falsifier for a flicker report** (decision 0653): if the summary says a model is -//! fully static and the screen shows it changing every frame, the change is not coming from the -//! model, and the hunt moves to what is drawn *over* it (particles, a second doodad) or to the -//! renderer. Answering that for the Maraudon crystal took one run of this and one of -//! `WOW_NO_PARTICLES=1`. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. +//! Every animation channel family a model has ([`M2AnimSummary`](benilla_formats::M2AnimSummary)) +//! and its texture-transform tracks, for one M2 or each animated M2 matching a substring. A fully +//! static model that changes on screen is being changed by something drawn over it, or by the +//! renderer. Output is Blizzard data: never commit it. +//! `cargo run -p benilla-formats --example dump_model_anim -- ` use std::io::Cursor; @@ -24,8 +13,7 @@ fn main() -> anyhow::Result<()> { let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); let mut chain = benilla_formats::open_chain(&data)?; - // An exact path dumps in full; anything else is a substring sweep that prints only the models - // with something to say (a listing of "all static" is noise when you asked about a zone). + // An exact path dumps in full; a substring sweep skips fully static models. let exact = chain.contains(&arg); let names: Vec = if exact { vec![arg] diff --git a/crates/benilla-formats/examples/emdump.rs b/crates/benilla-formats/examples/emdump.rs index ff93abf7..bfa0f504 100644 --- a/crates/benilla-formats/examples/emdump.rs +++ b/crates/benilla-formats/examples/emdump.rs @@ -1,15 +1,6 @@ -//! `emdump ` — every particle emitter one model authors, one line each. -//! -//! The per-emitter counterpart to `benilla-extract partcensus`, which answers "which models in the -//! corpus carry emitters matching this flag mask?" but never what an individual emitter *is*. B16 -//! needed the other direction — the voidwalker's eye glow is emitters 1 and 2 of four, bone 60/61, -//! flags `0x0001` (plain anchored), `Add`/`GLOWBALL.BLP` — and reading that off a hex dump by hand -//! is exactly the kind of asset fact that should be one command. -//! -//! The header line carries the model's **reach** (how far its own transparent-pass batches sort -//! from its origin — `m2_owner_reach`) and the `Transparent3d` draw-order rung it produces -//! (`owner_last_rung`): the rung is what keeps a model's effects drawing after its own transparent -//! batches, and it is sized from that number, so the two belong on screen together. +//! Every particle emitter one model authors, one line each, under the model's reach +//! (`m2_owner_reach`) and the `Transparent3d` rung sized from it (`owner_last_rung`), which keeps +//! its effects drawing after its own transparent batches. `emdump ` fn main() -> anyhow::Result<()> { let virt = std::env::args().nth(1).unwrap(); @@ -18,8 +9,7 @@ fn main() -> anyhow::Result<()> { let bytes = chain.read_file(&virt)?; let dir = virt.rsplit_once('\\').map_or("", |(d, _)| d); let subs = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]).unwrap_or_default(); - // The renderer's own bound and rung, not a re-derivation of them: a survey tool has to print - // what the renderer will actually do. + // The renderer's own bound and rung, not a re-derivation of them. let reach = benilla_formats::m2_owner_reach(&subs); let rung = benilla_formats::owner_last_rung(reach); println!("{virt}: reach {reach:.3} yd -> draw-order rung {rung:.0}"); diff --git a/crates/benilla-formats/examples/eye_burial_anim.rs b/crates/benilla-formats/examples/eye_burial_anim.rs index a21b4c34..619b4c9f 100644 --- a/crates/benilla-formats/examples/eye_burial_anim.rs +++ b/crates/benilla-formats/examples/eye_burial_anim.rs @@ -1,36 +1,16 @@ -//! **Is the eye emitter still proud of the face once the model ANIMATES?** -//! -//! `eye_quad_pass` answers the depth contest at *bind* pose, where benilla already matches the -//! reference. Decision 0707 measured the failing case and found the split: benilla's freshest eye -//! particle — born this frame at the current bone pose — sits **1.6–4.7 cm behind the face our -//! renderer draws**, against **2.5–34 mm** at bind. "Faithful at rest" and "faithful in motion" are -//! different claims, and this is the instrument for the second one. -//! -//! It builds the bone palette ourselves, from our own parse, at sampled times across a sequence: -//! `palette[b] = parentPalette · T(pivot) · R(rot) · S(scale) · T(-pivot)`, the M2 skinning matrix. -//! Then, per sample, it reports the two numbers that decide the contest: -//! -//! - `gap` — the distance from the animated eye-bone pivot (where our particles are born, verified -//! `birth == joint`) to the **nearest skinned vertex** of the depth-writing batches. At bind this -//! is 0.0162 for bone 60 (measured on the shipped `Voidwalker.m2`). If our animation swings the -//! skin centimetres past the bone, this grows — and that growth *is* the bug. -//! - `pass%` — the constant-depth quad's surviving area against the animated shell, front view. -//! -//! `cargo run -p benilla-formats --example eye_burial_anim -- 'Creature\Voidwalker\Voidwalker.m2' 0` -//! -//! A `gap` that stays near its bind value across the cycle says our pose is self-consistent and 0707's -//! centimetres came from somewhere else in the renderer. A `gap` that blows out says our skinning or -//! our animation moved the face off the bone, and the fix is there. +//! Whether an eye emitter stays in front of the face while the model animates; `eye_quad_pass` +//! answers it at bind pose. At samples across one sequence it builds the M2 skinning palette from +//! our own parse and prints `gap`, the emitter's distance to the nearest skinned vertex of the +//! depth-writing batches (bone 60 of `Voidwalker.m2`: 0.0162 at bind), and `pass%`, the share of +//! the constant-depth quad the animated shell leaves visible. A gap that grows over the cycle +//! means our skinning or animation moves the face off the bone. +//! `cargo run -p benilla-formats --example eye_burial_anim -- [seq slot]` use benilla_formats::{ModelAnimation, RenderSubmesh, Skeleton}; use glam::{Mat4, Quat, Vec3}; -/// The M2 skinning palette at time `t` (seconds into `anim`), in file-bone order. -/// -/// The pivot dance is the whole point: a bone's animated rotation/scale act **about its own pivot**, -/// so the matrix is `T(pivot) · R · S · T(-pivot)` composed under the parent. At rest (no keys) every -/// factor is identity and the palette is identity — which is why an emitter record `position` that -/// equals its bone pivot maps to itself at bind. +/// The M2 skinning palette at `t` seconds into `anim`, in file-bone order: rotation and scale act +/// about the bone's pivot, `T(pivot + tr) · R · S · T(-pivot)` under the parent. fn palette(skel: &Skeleton, anim: &ModelAnimation, t: f32) -> Vec { let sample_v = |keys: &[(f32, [f32; 3])], t: f32, dflt: Vec3| -> Vec3 { if keys.is_empty() { @@ -88,7 +68,6 @@ fn palette(skel: &Skeleton, anim: &ModelAnimation, t: f32) -> Vec { out } -/// Skin one vertex by its 4 bone/weight pairs (weights already normalised by the parser). fn skin(p: [f32; 3], j: [u16; 4], w: [f32; 4], pal: &[Mat4]) -> Vec3 { let v = Vec3::from(p); let mut acc = Vec3::ZERO; @@ -199,10 +178,8 @@ fn main() -> anyhow::Result<()> { anim.bones.len() ); - // Which bones does the depth-writing shell actually skin to, and does any of them sit under a - // BILLBOARD bone? A billboard joint is re-aimed at the camera every frame and its children - // inherit that rotation, so shell geometry under one moves with the *camera* — which is exactly - // the signature of a burial that appears from one camera elevation and not another. + // Shell bones under a billboard bone: a billboard re-aims at the camera each frame and its + // children inherit that, so their skin moves with the camera. { let mut shell_bones: Vec = Vec::new(); for s in &subs { @@ -252,8 +229,7 @@ fn main() -> anyhow::Result<()> { if b >= skel.bones.len() { continue; } - // Only the additive quad emitters mounted flush in the shell are the B16 subject; the - // others are printed anyway so the contrast is on the same page. + // Every emitter prints; the eye quads are the additive ones mounted flush in the shell. println!( "\nemitter bone {b} pos ({:.4},{:.4},{:.4}) blend {:?}", e.position[0], e.position[1], e.position[2], e.blend diff --git a/crates/benilla-formats/examples/eye_quad_pass.rs b/crates/benilla-formats/examples/eye_quad_pass.rs index 2fd3b406..f2e39b4e 100644 --- a/crates/benilla-formats/examples/eye_quad_pass.rs +++ b/crates/benilla-formats/examples/eye_quad_pass.rs @@ -1,24 +1,13 @@ -//! **The flush-emitter depth contest, computed offline** — what fraction of a particle emitter's -//! constant-depth billboard survives `LEQUAL` against its own model's z-writing batches, from a -//! given camera direction. No client, no camera, no capture: pure geometry over *our* parse. -//! -//! This exists to hold benilla's asset side against the table measured for the reference's quad -//! writer (`0x7b2a50` — Voidwalker bone 60, half 0.0833: -//! front 43.4 %, 30° above 38.8 %, 60° left 43.3 %). Agreement means our mesh, our bone pivots and -//! our quad model are faithful and any missing glow is the *renderer's*; disagreement localises the -//! bug to the asset side, where it can be fixed without ever launching the game. -//! -//! `cargo run -p benilla-formats --example eye_quad_pass -- 'Creature\Voidwalker\Voidwalker.m2'` -//! -//! Model space, rest pose, WoW axes (X forward, Y left, Z up) — the emitter record `position` is -//! the quad centre (it is bit-identical to its bone's pivot, and the palette is identity at rest). -//! The occluder is every batch that is **not** `no_depth_write`, exactly the set the depth buffer -//! holds when the particle pass runs. +//! The share of a particle emitter's constant-depth quad that passes `LEQUAL` against its own +//! model's depth-writing batches, per camera direction, from our parse alone. The reference's quad +//! writer (`0x7b2a50`) gives 43.4% front, 38.8% 30° above and 43.3% 60° left for `Voidwalker.m2` +//! bone 60 at half-size 0.0833; a mismatch puts the bug in our asset side, a match in the renderer. +//! Model space, rest pose, WoW axes (X forward, Y left, Z up); the emitter `position`, equal to its +//! bone's pivot, is the quad centre. `cargo run -p benilla-formats --example eye_quad_pass -- ` use benilla_formats::RenderSubmesh; -/// Möller–Trumbore. Returns the ray parameter `t` of the hit, if the ray meets the triangle ahead -/// of its origin. +/// Möller-Trumbore: the ray parameter `t` of a hit ahead of the origin. fn ray_tri(orig: [f32; 3], dir: [f32; 3], a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> Option { let sub = |p: [f32; 3], q: [f32; 3]| [p[0] - q[0], p[1] - q[1], p[2] - q[2]]; let cross = |p: [f32; 3], q: [f32; 3]| { @@ -36,8 +25,7 @@ fn ray_tri(orig: [f32; 3], dir: [f32; 3], a: [f32; 3], b: [f32; 3], c: [f32; 3]) if det.abs() < 1e-9 { return None; // ray parallel to the triangle plane } - // Two-sided on purpose: the shell's inward-facing triangles occlude too — the depth buffer - // holds whatever rasterized, and these batches are authored two-sided or seen from inside. + // Two-sided on purpose: the depth buffer holds inward-facing triangles too. let inv = 1.0 / det; let s = sub(orig, a); let u = inv * dot(s, h); @@ -53,10 +41,8 @@ fn ray_tri(orig: [f32; 3], dir: [f32; 3], a: [f32; 3], b: [f32; 3], c: [f32; 3]) (t > 1e-6).then_some(t) } -/// The camera-facing orthonormal pair for a view direction (the two screen axes the quad's corners -/// are offset along — the third axis carries the shared depth). fn screen_axes(view: [f32; 3]) -> ([f32; 3], [f32; 3]) { - // `view` points from the camera toward the model. Any up-ish reference not parallel to it. + // `view` points from the camera toward the model; any up reference not parallel to it. let up = if view[2].abs() > 0.9 { [1.0, 0.0, 0.0] } else { @@ -78,9 +64,8 @@ fn screen_axes(view: [f32; 3]) -> ([f32; 3], [f32; 3]) { (right, real_up) } -/// Fraction of the quad's area that passes `LEQUAL` — i.e. whose sample point is not occluded by -/// any z-writing triangle between it and the camera. Constant depth: every sample sits on the plane -/// through `center` perpendicular to `view`, so "occluded" is simply "a triangle lies in front". +/// The share of the quad that passes `LEQUAL`: every sample sits at the quad's one depth, so it +/// fails iff a depth-writing triangle lies between it and the camera. fn pass_fraction( tris: &[([f32; 3], [f32; 3], [f32; 3])], center: [f32; 3], @@ -89,13 +74,12 @@ fn pass_fraction( n: usize, ) -> f32 { let (right, up) = screen_axes(view); - // Cast *toward* the camera: anything hit is in front of the sample point. + // Cast toward the camera: anything hit is in front of the sample point. let back = [-view[0], -view[1], -view[2]]; let mut passed = 0usize; let mut total = 0usize; for iy in 0..n { for ix in 0..n { - // Sample cell centres so the grid is unbiased at the quad's edges. let fx = (ix as f32 + 0.5) / n as f32 * 2.0 - 1.0; let fy = (iy as f32 + 0.5) / n as f32 * 2.0 - 1.0; let p = [ @@ -115,12 +99,8 @@ fn pass_fraction( passed as f32 / total as f32 } -/// Every triangle of every batch that writes depth — the occluder set, in model space. -/// -/// `all` ignores the per-material `no_depth_write` bit, i.e. models a renderer that writes depth for -/// the whole model. That is the counterfactual worth pricing: it is what benilla's distance-fade -/// blend twin does (it forces depth-write ON regardless of the flag), so the two columns bracket -/// "did we leak the model's own transparent cards into the depth buffer?". +/// The occluder set in model space: every triangle of every depth-writing batch, or with `all` of +/// every batch, as benilla's fade blend twin writes depth regardless of `no_depth_write`. fn occluders(subs: &[RenderSubmesh], all: bool) -> Vec<([f32; 3], [f32; 3], [f32; 3])> { let mut tris = Vec::new(); for s in subs { @@ -163,8 +143,7 @@ fn main() -> anyhow::Result<()> { tris.len() ); - // Camera directions, as (label, azimuth° about +Z from +X, elevation° above the horizontal). - // `view` points camera → model, so the camera sits at -view. + // (label, azimuth° about +Z from +X, elevation°); the camera sits at -view. let views: &[(&str, f32, f32)] = &[ ("front (+X)", 0.0, 0.0), ("15 above", 0.0, 15.0), @@ -178,8 +157,7 @@ fn main() -> anyhow::Result<()> { ]; for e in benilla_formats::parse_m2_particle_emitters(&bytes)?.iter() { - // Only the plane/quad emitters we can model this way, and only those actually mounted in - // the mesh — the point of the exercise. + // Meaningful only for a quad emitter mounted in the mesh; every emitter prints. println!( "\nemitter bone {} pos ({:.4}, {:.4}, {:.4}) blend {:?}", e.bone, e.position[0], e.position[1], e.position[2], e.blend @@ -189,7 +167,6 @@ fn main() -> anyhow::Result<()> { print!(" {set:9} half {half:.4}:"); for &(label, az, el) in views { let (a, l) = (az.to_radians(), el.to_radians()); - // Camera at azimuth `az`, elevation `el`, looking at the model: view = -(camera dir) let view = [-(a.cos() * l.cos()), -(a.sin() * l.cos()), -(l.sin())]; let f = pass_fraction(occ, e.position, half, view, 64); print!(" {label} {:.1}%", f * 100.0); diff --git a/crates/benilla-formats/examples/fade_bucket.rs b/crates/benilla-formats/examples/fade_bucket.rs index a710551b..3b84cace 100644 --- a/crates/benilla-formats/examples/fade_bucket.rs +++ b/crates/benilla-formats/examples/fade_bucket.rs @@ -1,12 +1,7 @@ -//! What distance-fade band does a doodad actually land in? Prints each matching M2's authored -//! bounding-sphere radius (the reference's `rec+0x68` source) and the band the radius selects: +//! Each matching M2's bounding-sphere radius (the reference's `rec+0x68` source) and the +//! distance-fade band it selects, which depends on size alone. Radii are model-local; a +//! placement's `scale` multiplies them. Output is Blizzard data: never commit it. //! `cargo run -p benilla-formats --example fade_bucket -- fence` -//! -//! The band is chosen by SIZE, never by what the thing is — so "fences fade at 40→50" is a claim -//! about a radius, not about a fence. This prints the radius so the claim can be checked instead -//! of remembered. Radii are model-local (pre-scale); a placement's `scale` multiplies them. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. fn main() -> anyhow::Result<()> { let pat = std::env::args() @@ -38,7 +33,7 @@ fn main() -> anyhow::Result<()> { Ok(()) } -/// The `FUN_00683f80` size buckets, as `model_fade.rs` encodes them. +/// The size buckets of `0x683f80`, in sync with `model_fade.rs`. fn band(r: f32) -> &'static str { match r { r if r > 7.0 => "never fades", diff --git a/crates/benilla-formats/examples/gseqdump.rs b/crates/benilla-formats/examples/gseqdump.rs index 99c89a25..ff6f3933 100644 --- a/crates/benilla-formats/examples/gseqdump.rs +++ b/crates/benilla-formats/examples/gseqdump.rs @@ -1,9 +1,6 @@ -//! `gseqdump ` — every global-sequence bone channel with its RAW KEYS. -//! -//! `benilla-extract m2anim` names which bones carry global-sequence channels and their periods, -//! but never the key VALUES — and for a twinkle (a scale loop on a star card) the values ARE the -//! effect: a 0→1 flicker and a 0→20 flare are the same one-line summary. Output is Blizzard -//! data — pipe it to the scratchpad, never into the repo. +//! Every global-sequence bone channel with its raw keys: for a twinkle the scale values are the +//! effect, and a 0-1 flicker and a 0-20 flare can share a period. `gseqdump ` +//! Output is Blizzard data: never commit it. fn main() -> anyhow::Result<()> { let virt = std::env::args() diff --git a/crates/benilla-formats/examples/impass_here.rs b/crates/benilla-formats/examples/impass_here.rs index 42f2c12f..043c5123 100644 --- a/crates/benilla-formats/examples/impass_here.rs +++ b/crates/benilla-formats/examples/impass_here.rs @@ -1,35 +1,21 @@ -//! Where the ADT's impassable chunks are around a pin: +//! A top-down map of the MCNK impassable flag (header flag bit 1) around a pin: north up, one +//! glyph per 33.333 yd chunk, across tile seams, since a band crosses them. `what_is_here` covers +//! models. Output is Blizzard data: never commit it. //! `cargo run -p benilla-formats --example impass_here -- [radius_chunks]` -//! e.g. `impass_here Azeroth -6601.98 -531.87 10`. -//! -//! An "invisible wall" report (B129) names a *place* — a `.go xyz` pin — and the first question is -//! whether the wall is authored terrain at all: an MCNK with header flag bit 1 (`MCNK_IMPASSABLE`) -//! set, or a WMO/M2 hull the reporter walked into. This prints -//! the flag as a top-down chunk map — north up, west left, the in-game map's orientation, one -//! glyph per 33.333 yd chunk — so the answer is a picture of the band rather than a guess, and the -//! pin's own chunk is marked. `what_is_here` answers the other half (is there a model here). -//! -//! The map crosses tile seams on purpose: the flag is per chunk and a band runs across ADT -//! boundaries, so a single-tile view is exactly how you miss the wall you are standing against — -//! B129's pin sits 1.5 yd from its wall, with the flagged chunk in the *next* tile east. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. use std::collections::HashMap; use benilla_formats::{impassable_at, world_to_tile, TileMesh, CHUNK_SIZE, TILE_SIZE}; -/// The map's NW origin: world x/y both run *down* from `+32 tiles` as the grid runs south/east. +/// The map's NW corner: world x and y fall from 32 tiles as the grid runs south and east. const MAP_CENTER: f32 = 32.0 * TILE_SIZE; -/// Global chunk index of a world coordinate on either axis — row from x (grows south), column from -/// y (grows east). The same falling-from-`MAP_CENTER` addressing the client's per-chunk lookups use -/// (the MCSH texel law, `0x69b350`). +/// Global chunk index of a world coordinate on either axis, row from x and column from y, falling +/// from `MAP_CENTER` as the client's per-chunk lookups do (`0x69b350`). fn chunk_index(coord: f32) -> i32 { ((MAP_CENTER - coord) / CHUNK_SIZE).floor() as i32 } -/// The world coordinate of a global chunk index's centre (inverse of [`chunk_index`], +½ chunk). fn chunk_centre(index: i32) -> f32 { MAP_CENTER - (index as f32 + 0.5) * CHUNK_SIZE } @@ -67,7 +53,7 @@ fn main() -> anyhow::Result<()> { println!(" '#' impassable · '.' passable · '@' the pin's chunk · ' ' no tile"); print!(" "); for dc in -radius..=radius { - // A tile seam every 16 chunks: mark it so a band's tile ownership is readable at a glance. + // A tile seam every 16 chunks. print!( "{}", if (pin_col + dc).rem_euclid(16) == 0 { @@ -94,8 +80,7 @@ fn main() -> anyhow::Result<()> { println!(); } - // The pin's own verdict, and how far the wall is in each cardinal direction — the number a - // retest needs ("walk east 1.5 yd and you should stop"). + // The pin's own chunk, and the distance to the wall in each cardinal direction. let pin_flag = flag_at(&mut chain, x, y); println!( "\npin chunk: {}", @@ -111,8 +96,7 @@ fn main() -> anyhow::Result<()> { ("west (+y)", 0.0, 1.0), ("east (-y)", 0.0, -1.0), ] { - // Step chunk by chunk from the pin until the flag flips, then report the distance to that - // chunk's *near boundary* — where a mover would actually be stopped. + // Step chunk by chunk until the flag flips; a mover stops at that chunk's near boundary. let hit = (1..=radius).find(|n| { let (sx, sy) = ( x + dx * *n as f32 * CHUNK_SIZE, diff --git a/crates/benilla-formats/examples/liquid_depth_census.rs b/crates/benilla-formats/examples/liquid_depth_census.rs index e6864acb..ee05e9a1 100644 --- a/crates/benilla-formats/examples/liquid_depth_census.rs +++ b/crates/benilla-formats/examples/liquid_depth_census.rs @@ -1,26 +1,8 @@ -//! What the shipped MCLQ **depth byte** actually is, per liquid kind — the instrument behind the -//! ocean depth-ramp settle: +//! The shipped MCLQ depth byte per liquid kind over wet-cell vertices: its histogram, its slope +//! against the real `surface - terrain` depth, mid-ramp shore pins and what each divisor does to +//! the picture. The reference maps the byte to its depth swatch per kind, river `min(d/42, 1)` and +//! ocean `min(d/255, 1)`. Output is Blizzard data: never commit it. //! `cargo run --release -p benilla-formats --example liquid_depth_census -- [map ...]` -//! (default: `Azeroth Kalimdor`, i.e. every ADT ocean and river in the vanilla world.) -//! -//! The reference indexes its water depth-swatch by this one byte through a per-kind LUT — river -//! `min(d/42, 1)`, ocean `min(d/255, 1)` — so the *look* of a sea is that LUT applied to whatever -//! the artists authored into the byte. A divisor argued from the binary alone can still be wrong -//! about the picture if the authored range never reaches it, so this measures the range, and the -//! byte→yard slope that turns it into a distance a swimmer can feel: -//! -//! - the **depth-byte histogram** per kind, over vertices that touch a wet cell (dry-cell verts -//! carry the FLT_MAX height sentinel and no meaningful byte); -//! - the **byte→yard slope**, least-squares through the origin against the real geometric depth -//! `surface − terrain` at each vertex, plus the correlation — which is also the check that byte 0 -//! of the water/ocean union IS a depth at all; -//! - a handful of **shore-band pins** — world coords where the depth byte is mid-ramp, i.e. the only -//! places the divisor is visible at all. Everything else is either the pale edge or the pinned-deep -//! open sea, and an A/B aimed anywhere else photographs two identical pictures; -//! - **what each candidate divisor does to the picture**: the share of wet water that lands on the -//! deep swatch row (V ≥ 0.95), on the shallow row (V ≤ 0.05), and the mean V. -//! -//! Output is Blizzard-derived data — read it, pipe it to the scratchpad, never into the repo. use std::collections::BTreeMap; @@ -28,34 +10,27 @@ use benilla_formats::{terrain_height_at, LiquidKind, CHUNK_SIZE}; /// MCLQ vertex-grid pitch: the 9×9 grid spans one 8-cell chunk. const UNIT: f32 = CHUNK_SIZE / 8.0; -/// MCLQ grid side (9 verts) and cell side (8). const GRID: usize = 9; const CELLS: usize = 8; -/// Cell-flag low nibble meaning "dry". const DRY_NIBBLE: u8 = 0x0f; /// Verts under no liquid carry FLT_MAX; `is_finite()` is true for it, so gate on magnitude. const HEIGHT_SENTINEL: f32 = 1.0e9; /// The per-kind accumulator: the byte histogram plus the paired (byte, yard) samples. struct Kind { - /// Count per raw depth byte, 0..=255. hist: Box<[u64; 256]>, - /// Vertices whose terrain height resolved, for the byte→yard fit. + /// Vertices whose terrain height resolved, for the byte-to-yard fit. paired: u64, - /// Σ byte·yd, Σ byte², Σ yd², Σ byte, Σ yd — enough for a through-origin slope and an r. + /// Σ byte·yd, Σ byte², Σ yd², Σ byte, Σ yd, for the through-origin slope and r. sum_by: f64, sum_bb: f64, sum_yy: f64, sum_b: f64, sum_y: f64, - /// Blocks seen. blocks: u64, - /// A spread of mid-ramp sample points: `(zone, map, x, y, byte, yards)`, at most one per - /// 2000-yd neighbourhood, so the list names distinct coastlines instead of 200 vertices of one - /// bay. Named by `AreaTable`, because a pin you can walk to is worth more than a coordinate. + /// Mid-ramp points `(zone, map, x, y, byte, yards)`, at most one per 2000-yd neighbourhood. pins: Vec<(String, String, f32, f32, u8, f32)>, - /// Σ yards and count per depth byte — the empirical byte→depth curve, which the single - /// through-origin slope cannot show once a kind's byte saturates (the ocean's does, hard). + /// Σ yards and count per byte: the depth curve a single slope hides where the byte saturates. yd_sum: Box<[f64; 256]>, yd_n: Box<[u64; 256]>, } @@ -98,7 +73,6 @@ impl Kind { self.hist.iter().sum() } - /// Byte at the given share of the distribution. fn percentile(&self, p: f64) -> u8 { let target = (self.total() as f64 * p) as u64; let mut seen = 0; @@ -129,9 +103,8 @@ impl Kind { Some((slope, r)) } - /// Least-squares `byte = k · yards` through the origin over a byte window only. The ocean - /// pins 83 % of its vertices at 255, which drags a whole-range fit toward the pile and hides - /// the ramp that actually paints the shore; this reads the ramp where it varies. + /// The through-origin slope over a byte window only: the ocean pins 83% of its vertices at + /// 255, and a whole-range fit hides the ramp that paints the shore. fn slope_in(&self, lo: u8, hi: u8) -> Option<(f64, u64)> { let (mut sby, mut syy, mut n) = (0.0, 0.0, 0u64); for b in lo..=hi { @@ -180,7 +153,6 @@ fn main() -> anyhow::Result<()> { let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); let mut chain = benilla_formats::open_chain(&data)?; - // Names for the shore-band pins. Optional: a census still reads without a DBC. let areas = benilla_formats::load_area_table_catalog(&mut chain).ok(); let mut kinds: BTreeMap = BTreeMap::new(); @@ -194,8 +166,7 @@ fn main() -> anyhow::Result<()> { let Ok(bytes) = chain.read_file(&path) else { continue; }; - // The mesh is only the TERRAIN reference here — the depth bytes come off the raw parse, - // so the census can never be a restatement of the divisor it is measuring. + // The mesh gives only the terrain; the depth bytes come off the raw parse. let Ok(mesh) = benilla_formats::adt_to_tile_mesh(&bytes) else { continue; }; @@ -209,8 +180,7 @@ fn main() -> anyhow::Result<()> { if block.vertices.len() < GRID * GRID { continue; } - // Kind from the majority WET cell nibble — the verified type source, and the - // same rule `build_liquid_mesh` uses, so the census bins as the renderer draws. + // Kind from the majority wet-cell nibble, the same rule as `build_liquid_mesh`. let mut counts = [0u32; 16]; let mut wet = [false; CELLS * CELLS]; for (c, w) in wet.iter_mut().enumerate() { @@ -256,13 +226,11 @@ fn main() -> anyhow::Result<()> { } let x = wx - row as f32 * UNIT; let y = wy - col as f32 * UNIT; - // Nudge off the exact lattice line so the terrain lookup lands inside a - // triangle rather than on a chunk seam. + // Nudge off the lattice so the terrain lookup lands inside a triangle. let ground = terrain_height_at(&mesh.chunks, [x - 0.01, y - 0.01, 0.0]); let yards = ground.map(|g| h - g).filter(|d| *d > 0.0 && *d < 500.0); let byte = block.vertices[n].depth_byte(); - // One mid-ramp pin per tile: where the ramp is neither pinned deep nor at - // the pale edge, which is the only band an A/B can see. + // Mid-ramp pins, the only band where the divisor shows on screen. if (40..=200).contains(&byte) && acc.pins.len() < 64 && !acc.pins.iter().any(|p| { @@ -342,7 +310,6 @@ fn main() -> anyhow::Result<()> { println!(" {map} {x:9.1} {y:9.1} byte {b:3} depth {yd:5.1} yd {zone}"); } } - // The coarse shape, so a bimodal or pinned distribution shows without a plot. print!(" histogram (16 buckets of 16):"); for b in 0..16 { let n: u64 = k.hist[b * 16..(b + 1) * 16].iter().sum(); diff --git a/crates/benilla-formats/examples/list_chain.rs b/crates/benilla-formats/examples/list_chain.rs index 361f2115..38547383 100644 --- a/crates/benilla-formats/examples/list_chain.rs +++ b/crates/benilla-formats/examples/list_chain.rs @@ -1,16 +1,12 @@ -//! List every file in the 5875 patch chain whose path contains a substring (case-insensitive): -//! `cargo run -p benilla-formats --example list_chain -- maraudon`. The "what is this thing -//! actually called" tool — a report names a *place* ("the red crystal in Maraudon"), and finding -//! the asset behind it is the first step of every scene-render diagnosis. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. +//! Every file in the patch chain whose path contains a substring, case-insensitive, optionally of +//! one extension. `cargo run -p benilla-formats --example list_chain -- maraudon [m2]` +//! Output is Blizzard data: never commit it. fn main() -> anyhow::Result<()> { let pat = std::env::args() .nth(1) .ok_or_else(|| anyhow::anyhow!("usage: list_chain [ext]"))? .to_lowercase(); - // An optional second argument filters by extension — `list_chain maraudon m2`. let ext = std::env::args() .nth(2) .map(|e| format!(".{}", e.to_lowercase())); diff --git a/crates/benilla-formats/examples/m2_shared_section.rs b/crates/benilla-formats/examples/m2_shared_section.rs index 5f608414..1bcc80b1 100644 --- a/crates/benilla-formats/examples/m2_shared_section.rs +++ b/crates/benilla-formats/examples/m2_shared_section.rs @@ -1,29 +1,15 @@ -//! **Which M2 batches share their triangles with another batch** — the coplanar-sibling census: -//! `cargo run -p benilla-formats --example m2_shared_section -- [path-substring]`. -//! -//! An M2 skin *section* is a run of triangles; a *batch* is one draw of one section. When two -//! batches name the SAME section they rasterize the SAME triangles — a base layer and a shine / -//! reflect layer authored on top of it (`ARMORREFLECT3`, `BALISTASHINE02`). The reference draws -//! both from one vertex array under depth-write + LEQUAL, so the second always wins the tie -//! exactly (`0x70b3f6`, `0x70c190`). -//! -//! We only match that if both batches take the SAME vertex-transform path. They do not when a -//! consolidator (`static_gx`, `terrain_stream::merge`) admits one and refuses the other: the -//! retained lane bakes world positions on the CPU and the entity lane rotates on the GPU, and the -//! two agree only to the last few ULPs — enough, on coplanar triangles, to turn the depth test -//! into a per-pixel coin flip that re-rolls whenever the camera moves. That is the ballista's -//! flickering bolt heads and shields. -//! -//! So this prints, per model, every multi-batch section and whether its batches DISAGREE on the -//! facts the consolidators exclude on (env-mapped UVs, render flags `0x10` no-depth-write / `0x08` -//! no-depth-test, additive blend 3/4). A `SPLIT` line is a model that can z-fight against itself. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. +//! M2 batches that share a skin section, and so the same triangles, with another batch. The +//! reference draws both from one vertex array under depth-write and LEQUAL, so the later one wins +//! the tie exactly (`0x70b3f6`, `0x70c190`). We match that only when both take the same vertex +//! path: a consolidator (`static_gx`, `terrain_stream::merge`) bakes positions on the CPU, which +//! differ from the GPU's in the last ULPs, so a section it splits z-fights (a `SPLIT` line). +//! Output is Blizzard data: never commit it. +//! `cargo run -p benilla-formats --example m2_shared_section -- [path-substring]` use std::io::Cursor; -/// The facts every consolidator excludes on — a batch answering `true` can never ride the -/// retained/merged lanes, so it stays on the entity path whatever its siblings do. +/// What every consolidator excludes on: a `true` batch stays on the entity path whatever its +/// siblings do. fn refusable(model: &benilla_m2::M2Model, batch: &benilla_m2::SkinBatch) -> bool { let mat = model.materials.get(batch.material_index as usize); let flags = mat.map_or(0, |m| m.flags.bits()); diff --git a/crates/benilla-formats/examples/mm_seam_probe.rs b/crates/benilla-formats/examples/mm_seam_probe.rs index ef93d0a2..1ae79700 100644 --- a/crates/benilla-formats/examples/mm_seam_probe.rs +++ b/crates/benilla-formats/examples/mm_seam_probe.rs @@ -1,11 +1,7 @@ -//! TEMP (B141): rasterise the WMO-interior minimap **offline**, from the shipped tiles and the -//! client's own placement + alpha test, so "where do the black lines come from" is a measurement on -//! the data rather than a reading of a screenshot. -//! -//! Writes three PNGs: `colour` (what the composite would show), `cover` (white where SOME tile -//! passes the 224/255 test), and `any` (white where some tile has alpha > 0 at all). A line that is -//! black in `cover` but white in `any` is the alpha test cutting art that exists; black in both is -//! a genuine hole in the bake — or a placement that never covers that spot. +//! The WMO-interior minimap rasterised offline from the shipped tiles, with the client's placement +//! and 224/255 alpha test, written as `mm_{colour,cover,any,owner,seam}.png` under `MM_OUT`. Black +//! in `cover` (some tile passes the test) but white in `any` (some tile has alpha) is the test +//! cutting art; black in both is a hole in the bake or a spot no placement covers. use std::collections::BTreeMap; use std::io::Cursor; @@ -54,7 +50,6 @@ fn model_to_world(rot_deg: [f32; 3], p: [f32; 3]) -> [f32; 3] { let p = rot_y(p, ry - std::f32::consts::PI); rot_x(p, std::f32::consts::FRAC_PI_2) } -/// Inverse of [`model_to_world`] (the rotation is orthonormal, so: same angles, reversed order). fn world_to_model(rot_deg: [f32; 3], p: [f32; 3]) -> [f32; 3] { let (rx, ry, rz) = ( rot_deg[0].to_radians(), @@ -81,7 +76,6 @@ fn world_to_model(rot_deg: [f32; 3], p: [f32; 3]) -> [f32; 3] { struct Tile { gi: usize, - /// The drawn rect (grid cell + outer-edge bleed). x0: f32, y0: f32, tw: f32, @@ -106,7 +100,7 @@ fn main() -> anyhow::Result<()> { let mut chain = benilla_formats::open_chain(&data)?; let trs = benilla_formats::load_minimap_translate(&mut chain)?; - // Find the placement whose model bbox contains the pin, over every WMO placed in this ADT. + // The WMO placement in this ADT whose origin is nearest the pin. let (tc, tr) = benilla_formats::world_to_tile(pin[0], pin[1]); let adt_name = format!("World\\Maps\\Azeroth\\Azeroth_{tc}_{tr}.adt"); let bytes = chain.read_file(&adt_name)?; @@ -127,8 +121,7 @@ fn main() -> anyhow::Result<()> { } } let (model, origin, rot, _) = best.expect("no WMO placement in this ADT"); - // MM_MODEL="x,y[,z]" pins in the WMO's MODEL space instead — how a captured reference frame - // states the player's position, so a capture can be reproduced exactly. + // `MM_MODEL="x,y[,z]"` pins in the WMO's model space instead, as a reference capture states it. let pm = match std::env::var("MM_MODEL") { Ok(v) => { let n: Vec = v.split(',').map(|t| t.trim().parse().unwrap()).collect(); @@ -163,8 +156,7 @@ fn main() -> anyhow::Result<()> { } eprintln!("{} groups, model bbox {glo:?} .. {ghi:?}", infos.len()); - // MM_AUDIT: our computed per-group tile grid against the one actually authored in the trs. - // A short grid leaves world the composite can never cover, whatever the group selection does. + // `MM_AUDIT`: our per-group tile grid against the one authored in the translate table. if std::env::var("MM_AUDIT").is_ok() { let (mut bad, mut tiles_ours, mut tiles_theirs) = (0usize, 0usize, 0usize); for (gi, info) in infos.iter().enumerate() { @@ -203,10 +195,7 @@ fn main() -> anyhow::Result<()> { return Ok(()); } - // MM_BLP: what the tile art actually declares and holds — the alpha histogram of every tile - // near the pin, so "transparent" is a fact about the file rather than about our decoder. - // MM_FIT: the authored tile's PIXEL size against the group's world extent — i.e. what the bake's - // real yards-per-texel is, per group, instead of the 0.5 we assume. + // `MM_FIT`: each group's real yards per texel, from its authored tile size and its extent. if std::env::var("MM_FIT").is_ok() { println!(" gi authored px grid extent yd yd/texel (x, y)"); let mut ratios: Vec = Vec::new(); @@ -237,7 +226,7 @@ fn main() -> anyhow::Result<()> { info.bbox_max[0] - info.bbox_min[0], info.bbox_max[1] - info.bbox_min[1], ); - // Tile art is transposed vs the model axes: image WIDTH runs along model Y, HEIGHT along X. + // This read pairs model X with image height and rows: the layout below, transposed. let (nx, ny) = ((mc + 1) as f32, (mr + 1) as f32); let ypt_x = ex / (dim.1 as f32 * ny); let ypt_y = ey / (dim.0 as f32 * nx); @@ -265,9 +254,7 @@ fn main() -> anyhow::Result<()> { return Ok(()); } - // MM_ANCHOR: where inside its power-of-two texture does a single-tile group's OPAQUE art sit, - // and how big is it — against the group's own extent at 0.5 yd/texel. This is what says whether - // the bake pads (art at one corner, transparent remainder) or stretches, and which corner. + // `MM_ANCHOR`: where a single-tile group's opaque art sits in its power-of-two texture. if std::env::var("MM_ANCHOR").is_ok() { println!(" gi tex opaque box (u0,u1)x(v0,v1) extent/0.5 texels fill"); for (gi, info) in infos.iter().enumerate().take(40) { @@ -311,8 +298,7 @@ fn main() -> anyhow::Result<()> { return Ok(()); } - // MM_MULTI: the same anchor read on MULTI-tile groups, which is what says which name index runs - // along which model axis and where the power-of-two padding lands across a row of tiles. + // `MM_MULTI`: the same on multi-tile groups, for which name index runs along which axis. if std::env::var("MM_MULTI").is_ok() { let mut shown = 0; for (gi, info) in infos.iter().enumerate() { @@ -369,6 +355,7 @@ fn main() -> anyhow::Result<()> { return Ok(()); } + // `MM_BLP`: each tile's declared header and alpha histogram, as the file holds them. if std::env::var("MM_BLP").is_ok() { for gi in 0..infos.len() { for c in 0..4u32 { @@ -414,14 +401,10 @@ fn main() -> anyhow::Result<()> { let (ny, twy) = group_axis_grid(info.bbox_max[1] - info.bbox_min[1]); for col in 0..nx { for row in 0..ny { - // MEASURED tile layout (MM_ANCHOR / MM_MULTI, read off the shipped art): image u - // runs along model +X with u_global = 0 at the group's bbox X-MIN, column index - // advancing with +X; image v runs along model Y INVERTED, the grid anchored at its - // BOTTOM so the last row's last texel sits at the group's Y anchor. - // The client's rect: cells stride `tw` and share their interior edges, and a cell - // on the grid's boundary is grown 1.0 yd on that side alone (`0x6a5270`). - // `MM_NOBLEED` drops the bleed, which is what B141 was: without it two groups' art - // abuts instead of overlapping by 2 yd. + // Measured off the shipped art: image u runs along model +X from the bbox X-min, + // columns advancing with +X; v runs along model Y inverted. Cells stride `tw` and + // share interior edges; a boundary cell grows 1.0 yd on its outer side + // (`0x6a5270`), and `MM_NOBLEED` drops that, so neighbouring groups only abut. let bleed = if std::env::var("MM_NOBLEED").is_ok() { 0.0 } else { @@ -474,7 +457,8 @@ fn main() -> anyhow::Result<()> { for k in cache.iter().filter(|(_, v)| v.is_none()).take(4) { eprintln!(" MISS {}", k.0); } - // Same order the app draws in: by group bbox Z-mid relative to the player, ascending. + // Farthest group Z-mid first. The reference paints ascending by signed `Zmid - playerZ`, the + // player's own group last (`0x4ebeb0`). tiles.sort_by(|a, b| { (a.midz - pm[2]) .abs() @@ -488,8 +472,7 @@ fn main() -> anyhow::Result<()> { let mut anyv = vec![0u8; (px * px) as usize]; let mut owner = vec![0u8; (px * px * 3) as usize]; let bilinear = std::env::var("MM_BILINEAR").is_ok(); - // OPTIONAL UNDERLAY: the outdoor ADT minimap tiles (`Azeroth\\map_.blp`), sampled through - // the placement so they land under the WMO tiles in the same frame. `MM_TERRAIN=1`. + // `MM_TERRAIN`: an outdoor ADT tile underlay; the reference's interior composite has none. if std::env::var("MM_TERRAIN").is_ok() { let mut adt_cache: BTreeMap<(u32, u32), Option>> = BTreeMap::new(); for iy in 0..px { @@ -533,12 +516,8 @@ fn main() -> anyhow::Result<()> { if mx < t.x0 || mx >= t.x0 + t.tw || my < t.y0 || my >= t.y0 + t.th { continue; } - // MM_BILINEAR reproduces the reference's LINEAR sampler (CLAMP_TO_EDGE), so the - // alpha the 224/255 test sees is the interpolated one — which is what decides how - // far a tile's opaque art actually reaches past its last opaque texel CENTRE - // (0.122 of a texel at ref 224, against nearest's half-texel to the texel EDGE). - // The client samples `[0.5/W, 1−0.5/W]` across the drawn rect, so the W texel - // CENTRES span it exactly: step = rect / (W−1), texel 0's centre on the rect edge. + // `MM_BILINEAR` is the reference's LINEAR, CLAMP_TO_EDGE sampler. The client + // samples `[0.5/W, 1-0.5/W]` across the drawn rect, so its W texel centres span it. let fu = (mx - t.x0) / (t.tw / (t.w as f32 - 1.0)); let fv = (t.y0 + t.th - my) / (t.th / (t.h as f32 - 1.0)); let (u, v) = ( @@ -572,9 +551,7 @@ fn main() -> anyhow::Result<()> { } } } - // A HAIRLINE is an uncovered run at most 4 px wide with coverage on BOTH sides — a seam - // between two tiles, as opposed to the large unauthored gaps (streets, the exterior group) - // that are simply not part of any interior group's bake. + // A hairline is an uncovered run of at most 4 px with coverage on both sides: a tile seam. let mut seam = colour.clone(); let mut hair = 0usize; let mut runs: BTreeMap = BTreeMap::new(); @@ -620,8 +597,8 @@ fn main() -> anyhow::Result<()> { px, image::ColorType::Rgb8, )?; - // The VISIBLE DISC — what the blit actually shows: radius `MM_DISC` yards about the player. - // The reference capture reports 95.84% painted / 4.16% clear over exactly this. + // `MM_DISC`: the painted share of the visible disc of that radius in yards; the reference + // leaves 4.16% of it clear in its Stormwind capture. if let Ok(v) = std::env::var("MM_DISC") { let rd: f32 = v.parse().unwrap(); let c0 = px as f32 * 0.5; diff --git a/crates/benilla-formats/examples/mm_water_pin.rs b/crates/benilla-formats/examples/mm_water_pin.rs index 8c337ee1..77f63720 100644 --- a/crates/benilla-formats/examples/mm_water_pin.rs +++ b/crates/benilla-formats/examples/mm_water_pin.rs @@ -1,6 +1,6 @@ -//! TEMP (B141/N05): what the WMO's liquid actually looks like around a pin — which groups hold -//! water there, their MOGP flags/liquid type, their MLIQ base heights, and whether two surfaces -//! abut, overlap or step. Run: `cargo run -p benilla-formats --example mm_water_pin -- x y z [r]`. +//! The WMO liquid around a pin: the groups holding water there, their MOGP flags, liquid type and +//! MLIQ heights, and how many surfaces claim each point. +//! `cargo run -p benilla-formats --example mm_water_pin -- x y z [r]` use std::io::Cursor; const MAP_CENTER: f32 = 17066.666; @@ -146,9 +146,7 @@ fn main() -> anyhow::Result<()> { ); } - // How many DISTINCT groups claim each point as wet? Two coplanar surfaces over the same ground - // are drawn twice — a transparent water quad composited twice is visibly darker, and at equal - // depth they also z-fight. Sampled on a 0.25 yd lattice over the pin's window. + // Groups claiming each point as wet; a point claimed twice draws darker and z-fights. let step = 0.25f32; let n = (2.0 * r / step) as i32; let mut hist = std::collections::BTreeMap::::new(); @@ -201,7 +199,6 @@ fn main() -> anyhow::Result<()> { Ok(()) } -/// The cell index `(mx, my)` falls in, if it is inside the grid at all. fn cell_at(m: &benilla_formats::LiquidMesh, mx: f32, my: f32) -> Option { let (cols, rows) = (m.grid[0] as usize, m.grid[1] as usize); if cols < 2 || rows < 2 { diff --git a/crates/benilla-formats/examples/order_probe.rs b/crates/benilla-formats/examples/order_probe.rs index 0f890f5e..1b5d7d47 100644 --- a/crates/benilla-formats/examples/order_probe.rs +++ b/crates/benilla-formats/examples/order_probe.rs @@ -1,10 +1,7 @@ -//! Throwaway: the sort/admission inputs the trainer & craft windows key on. Two modes: -//! -//! * `line ...` — a line's abilities with their raw -//! `SkillLineAbility.dbc` columns (`req_skill_value` col 7, `reqtrainpoints` col 14), their -//! `Spell.dbc` name/rank, `castUI`, `attributes` and `spellLevel`. -//! * `castui ` — every spell whose `castUI == n`, with the skill line (if any) its first -//! `SkillLineAbility` row names: the client's craft-admission key vs benilla's SLA join. +//! The inputs the trainer and craft windows sort and admit on. `line ...` prints a +//! line's abilities with `SkillLineAbility.dbc` columns 7 (`req_skill_value`) and 14 +//! (`reqtrainpoints`) and their spell's `castUI`, attributes and level; `castui ` prints every +//! spell with that `castUI`, the client's craft admission key, beside its skill line. fn main() -> anyhow::Result<()> { let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); let mut chain = benilla_formats::open_chain(&data)?; diff --git a/crates/benilla-formats/examples/png_disc.rs b/crates/benilla-formats/examples/png_disc.rs index 1c673603..f04a7f39 100644 --- a/crates/benilla-formats/examples/png_disc.rs +++ b/crates/benilla-formats/examples/png_disc.rs @@ -1,6 +1,6 @@ -//! TEMP (B141): what fraction of a captured minimap disc is the composite's black clear. -//! `cargo run -p benilla-formats --example png_disc -- [cx cy r]` — defaults to the whole -//! image's inscribed circle, which is what the `WOW_MM_PROBE` crop is. +//! The share of a captured minimap disc that is the composite's black clear; the default circle is +//! the image's inscribed one, which is the `WOW_MM_PROBE` crop. +//! `cargo run -p benilla-formats --example png_disc -- [cx cy r]` fn main() -> anyhow::Result<()> { let a: Vec = std::env::args().skip(1).collect(); let img = image::open(&a[0])?.to_rgb8(); diff --git a/crates/benilla-formats/examples/scan_events.rs b/crates/benilla-formats/examples/scan_events.rs index db55a8ac..f4440e0c 100644 --- a/crates/benilla-formats/examples/scan_events.rs +++ b/crates/benilla-formats/examples/scan_events.rs @@ -1,12 +1,7 @@ -//! Which models author a given M2 animation event? `cargo run -p benilla-formats --example -//! scan_events -- '$CCH' weapon` — scans every `.m2` in the 5875 chain whose path contains the -//! (optional, case-insensitive) substring and prints each model authoring the 4CC, with the -//! event's bone and raw WoW position. The sibling of `dump_attach`: that answers "what does THIS -//! model author", this answers "who else authors THIS event" — the question every event-keyed -//! consumer (bowstring `$WTT`, fishing line `$CCH`, GO sound slots `$GC0`) eventually asks when -//! deciding whether the ident alone is a safe gate. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. +//! Every M2 authoring a given animation-event 4CC, with the event's bone and raw position, to +//! check whether the ident alone is a safe gate (`$WTT` bowstring, `$CCH` fishing line). +//! `cargo run -p benilla-formats --example scan_events -- '$CCH' [path-substring]` +//! Output is Blizzard data: never commit it. fn le_u32(b: &[u8], o: usize) -> u32 { u32::from_le_bytes(b[o..o + 4].try_into().unwrap()) diff --git a/crates/benilla-formats/examples/seamswing.rs b/crates/benilla-formats/examples/seamswing.rs index cdb65c66..6ba18fe6 100644 --- a/crates/benilla-formats/examples/seamswing.rs +++ b/crates/benilla-formats/examples/seamswing.rs @@ -1,26 +1,14 @@ -//! Where does a welded billboard bone's geometry SIT relative to its pivot — how far, and along -//! which local axis? -//! -//! Two readings, and the second is the one that decides anything. **Distance** answers the seam -//! question: a partially-weighted seam vertex is dragged by its weight times the re-orientation and -//! linear-blend skinning shrinks it toward the pivot on the way (the candy-wrapper), so a seam ring -//! sitting ON the pivot hinges cleanly and one sitting far out tears. -//! -//! **Direction answers whether the geometry moves at all.** A spherical billboard maps the bone's -//! local axes onto fixed camera axes, so an offset lying along a single local axis lands on the same -//! camera axis from every angle: it does not sweep, it only loses its roll and its foreshortening. -//! Only an offset spread across the axes sweeps. Magnitude alone cannot tell those apart, and -//! reading a 0.29 yd radius as a 0.29 yd arc is exactly how decision 0847 withdrew a correct change -//! — the spikes it measured run **along** their bone (worst vertex 12° off axis), so the sweep it -//! described never existed (the spherical basis `0x71547c`, and this tool's own output). -//! -//! Usage: `cargo run -p benilla-formats --example seamswing -- ` +//! Where a welded billboard bone's geometry sits relative to its pivot: how far, and along which +//! local axis. Distance decides the seam, since linear-blend skinning drags a partly weighted +//! vertex toward the pivot. Direction decides whether the geometry moves at all: a spherical +//! billboard (`0x71547c`) maps local axes onto fixed camera axes, so an offset along one axis only +//! loses its roll and foreshortening, while one spread across the axes sweeps. +//! `cargo run -p benilla-formats --example seamswing -- ` use benilla_formats::open_chain; fn main() -> anyhow::Result<()> { let mut args = std::env::args().skip(1); - // An explicit dir still wins; with none, ask the one resolver (decision 1175). let data = args .next() .map(std::path::PathBuf::from) @@ -37,8 +25,7 @@ fn main() -> anyhow::Result<()> { for &b in &denied { let bone = &model.bones[b as usize]; let piv = [bone.pivot.x, bone.pivot.y, bone.pivot.z]; - // Every vertex with any weight on this bone: its weight, and its distance from the pivot — - // which is the radius the re-orientation sweeps it around. + // Every vertex weighted to this bone, with its weight and its distance from the pivot. let (mut pure, mut seam) = (Vec::new(), Vec::new()); for v in &model.vertices { for i in 0..4 { @@ -83,12 +70,7 @@ fn main() -> anyhow::Result<()> { if let Some(&(w, _)) = seam.first() { println!(" seam weight on this bone: {w:.2}"); } - // DIRECTION, which is what actually decides whether re-orientation moves the geometry. - // A spherical billboard maps the bone's local axes to fixed camera axes, so an offset that - // lies along ONE local axis lands on that camera axis from every angle: it does not sweep, - // it only loses its roll and its foreshortening. An offset spread across the axes does - // sweep. Magnitude alone cannot tell these apart — decision 0847 read a 0.29 yd radius as a - // 0.29 yd arc and withdrew a correct change on the strength of it. + // Direction: the local axis the offsets run along, and how far the worst vertex strays. if !pure.is_empty() { let mut lo = [f32::MAX; 3]; let mut hi = [f32::MIN; 3]; @@ -98,10 +80,8 @@ fn main() -> anyhow::Result<()> { hi[k] = hi[k].max(o[k]); } } - // The DIRECTION axis is the one the offsets are largest along — NOT the one they are - // most spread across. Those differ here and confusing them inverts the reading: this - // spike runs along z (|z| ≈ 0.29) but is widest across y (spread 0.105, its cross - // section). Report both, named, so neither can be mistaken for the other. + // The axis the offsets are largest along is not the one they spread widest across (a + // spike's cross section); both print, named. let mean_abs = |k: usize| pure.iter().map(|&(_, o)| o[k].abs()).sum::() / pure.len() as f32; let along = (0..3) @@ -110,8 +90,6 @@ fn main() -> anyhow::Result<()> { let widest = (0..3) .max_by(|&a, &c| (hi[a] - lo[a]).total_cmp(&(hi[c] - lo[c]))) .unwrap(); - // How far the WORST vertex strays from that one axis — the number that decides whether - // "runs along a single axis" is a fair description of the whole batch or only its mean. let worst_deg = pure .iter() .filter(|&&(d, _)| d > 1e-6) diff --git a/crates/benilla-formats/examples/spell_interrupt.rs b/crates/benilla-formats/examples/spell_interrupt.rs index 7cf57532..238583fa 100644 --- a/crates/benilla-formats/examples/spell_interrupt.rs +++ b/crates/benilla-formats/examples/spell_interrupt.rs @@ -1,5 +1,4 @@ -//! Throwaway: print the interrupt-flag columns for named spell ids, so a claim about what -//! cancels a spell locally can be checked against the real shipped Spell.dbc. +//! The interrupt-flag and attribute columns of `Spell.dbc` for the given spell ids. fn main() -> anyhow::Result<()> { let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); let mut chain = benilla_formats::open_chain(&data)?; diff --git a/crates/benilla-formats/examples/spell_row.rs b/crates/benilla-formats/examples/spell_row.rs index e08de5b0..375f586a 100644 --- a/crates/benilla-formats/examples/spell_row.rs +++ b/crates/benilla-formats/examples/spell_row.rs @@ -1,7 +1,5 @@ -//! Throwaway: print the cast-arm targeting facts for named spell ids — `Targets`, -//! `EffectImplicitTargetA[0]`, the three effects, and the item-target gate columns — so a claim -//! about which targeting seam a spell arms can be checked against the real shipped Spell.dbc -//! instead of reasoned about. +//! The targeting columns of `Spell.dbc` for the given spell ids: `Targets`, +//! `EffectImplicitTargetA[0]`, the first effect and the equipped-item gate columns. fn main() -> anyhow::Result<()> { let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); let mut chain = benilla_formats::open_chain(&data)?; diff --git a/crates/benilla-formats/examples/srci_dump.rs b/crates/benilla-formats/examples/srci_dump.rs index 59279abb..be56dfd3 100644 --- a/crates/benilla-formats/examples/srci_dump.rs +++ b/crates/benilla-formats/examples/srci_dump.rs @@ -1,6 +1,6 @@ -//! Throwaway: dump `SkillRaceClassInfo.dbc` rows admitting a race/class, with the flags word in -//! hex — the table the client's `GetSkillLineInfo` reads for its 0x400 single-rank cap and 0x20 -//! unlearnable bit. Usage: `cargo run -p benilla-formats --example srci_dump -- `. +//! `SkillRaceClassInfo.dbc` rows admitting a race and class, flags in hex: `GetSkillLineInfo` reads +//! its 0x400 single-rank cap and 0x20 unlearnable bit here. +//! `cargo run -p benilla-formats --example srci_dump -- ` fn main() -> anyhow::Result<()> { let mut args = std::env::args().skip(1); let race: u32 = args.next().unwrap_or_else(|| "4".into()).parse()?; @@ -8,7 +8,6 @@ fn main() -> anyhow::Result<()> { let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); let mut chain = benilla_formats::open_chain(&data)?; - // Skill line names, so the dump reads. let lines = chain.read_file("DBFilesClient\\SkillLine.dbc")?; let name_of = |id: u32| -> String { let g = |b: &[u8], o: usize| u32::from_le_bytes(b[o..o + 4].try_into().unwrap()); @@ -48,7 +47,7 @@ fn main() -> anyhow::Result<()> { g(base + 20), g(base + 24), ); - // race/class 0 = dump EVERY row (masks unfiltered, duplicates kept). + // Race or class 0 dumps every row, unfiltered, duplicates kept. let all = race == 0 || class == 0; if !all && (race_mask & rmask == 0 || class_mask & cmask == 0 || seen.contains(&skill)) { continue; diff --git a/crates/benilla-formats/examples/surface_here.rs b/crates/benilla-formats/examples/surface_here.rs index 01e3c51d..2ee75e2b 100644 --- a/crates/benilla-formats/examples/surface_here.rs +++ b/crates/benilla-formats/examples/surface_here.rs @@ -1,28 +1,14 @@ -//! What surface the footstep chain resolves under a patch of world: +//! The footstep surface the ADT leg resolves around a pin, as a top-down map of `TerrainType`s +//! (north up, west left), and at the pin the whole chain to the sound kit. The client arbitrates +//! this against a WMO probe and the nearer surface wins, so indoors this is the ground under the +//! floor; `RUST_LOG=benilla_app::sound=debug` logs which leg answered in a live run. Output is +//! Blizzard data: never commit it. //! `cargo run -p benilla-formats --example surface_here -- [radius_yd] [class]` -//! e.g. `surface_here Azeroth -5595.89 -529.48 24`. -//! -//! A footstep bug is reported as a *sound* ("I'm walking on snow indoors") but it is decided far -//! upstream, by which surface the down-ray claims under the foot. This prints that claim as a -//! top-down map — north up, west left — so the answer is a picture of the ground rather than an -//! impression of the audio: per cell the `TerrainType` the chain lands on, and at the pin the full -//! walk through to the `SoundEntries` kit that would actually play. -//! -//! **This is the ADT leg only** — deliberately. The client (and, since decision 1161, benilla) -//! arbitrates this against a WMO probe and the nearer surface wins, so inside a building the live -//! answer comes from the floor, not from here. What this map shows indoors is the ground *under* -//! that floor: the counterfactual. That is what makes a wrong-surface report legible — B236's -//! sequel was "snow indoors", and this printed 1681 of 1681 cells Snow beneath the Kharanos inn. -//! To see which leg actually answered in a live run, use `RUST_LOG=benilla_app::sound=debug` and -//! read the `footstep: wmo|adt terrain N` line. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. use std::collections::HashMap; -/// `TerrainType.SoundID` → its authored name and map glyph. The domain is closed and verified -/// against build 5875 (`benilla_formats::footsteps` module docs): the eleven rows collapse to ten -/// sound classes, `DustyGrass` sharing `Grass`'s. +/// `TerrainType.SoundID` to its name and map glyph; the eleven shipped rows use ten sound classes, +/// `DustyGrass` sharing `Grass`'s. fn class_glyph(sound_class: u32) -> (char, &'static str) { match sound_class { 0 => ('.', "None"), @@ -47,8 +33,7 @@ fn main() -> anyhow::Result<()> { let x: f32 = args.next().ok_or_else(usage)?.parse()?; let y: f32 = args.next().ok_or_else(usage)?.parse()?; let radius: f32 = args.next().map_or(Ok(20.0), |r| r.parse())?; - // 7 is the humanoid/character class (its ten rows are the `CharacterMediumLarge*` kits); 8 is - // the small-character class. Both reach it as ordinary data, never a code default. + // Class 7 is the character class (the `CharacterMediumLarge*` kits), 8 the small character. let class: u32 = args.next().map_or(Ok(7), |c| c.parse())?; let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); @@ -56,8 +41,7 @@ fn main() -> anyhow::Result<()> { let cat = benilla_formats::load_footstep_catalog(&mut chain)?; let tiles = benilla_formats::MapTiles::load(&mut chain, &map)?; - // One sample per cell of a square centred on the pin, north (+x) up and west (+y) left — the - // orientation the in-game map uses, so a spot on this grid is a spot on their screen. + // One sample per cell of a square on the pin, north (+x) up and west (+y) left, as the map. let cols = 41usize; let step = (radius * 2.0) / (cols - 1) as f32; let mut meshes: HashMap<(u32, u32), Option> = HashMap::new(); @@ -90,7 +74,7 @@ fn main() -> anyhow::Result<()> { *census.entry(terrain).or_default() += 1; let glyph = match terrain.and_then(|t| cat.sound_class_of(t)) { Some(sc) => class_glyph(sc).0, - // No effect layer / unknown terrain — the client's −1 sentinel, a SILENT footfall. + // No effect layer or unknown terrain: the client's -1 sentinel, a silent footfall. None => ' ', }; line.push(glyph); @@ -118,7 +102,7 @@ fn main() -> anyhow::Result<()> { println!(" {n:5} {label}"); } - // The pin itself, walked end to end — the line a bug report can be checked against. + // The pin itself, walked end to end. let effect = sample(&mut chain, &mut meshes, x, y); println!("\nat the pin exactly:"); println!(" GroundEffectTexture: {effect:?}"); diff --git a/crates/benilla-formats/examples/water_here.rs b/crates/benilla-formats/examples/water_here.rs index 8ef2c692..b0f66157 100644 --- a/crates/benilla-formats/examples/water_here.rs +++ b/crates/benilla-formats/examples/water_here.rs @@ -1,43 +1,15 @@ -//! What the ADT liquid actually **is** under a pin — the shoreline instrument: +//! The ADT liquid under a pin against the terrain it lies on: the MCLQ blocks, overlapping and +//! stepped cells, and where the 4.167-yd wet-cell lattice ends short of or past the waterline. +//! Output is Blizzard data: never commit it. //! `cargo run -p benilla-formats --example water_here -- [radius_yd]` -//! e.g. `water_here Azeroth -12512.69 -180.21 40`. -//! -//! A water-at-the-shore report ("there's a step in it", "the foam runs past the waterline") is a -//! question about three surfaces that only ever agree in the middle of a lake: the MCLQ **wet-cell -//! lattice** (4.167-yd cells, the granularity everything on the water path clips to), the liquid -//! **surface height** on those cells, and the **terrain** underneath. This prints all three over one -//! neighbourhood so the geometry can be read instead of guessed: -//! -//! - the **block census** — one line per MCLQ block: kind, wet cells, surface-height span, depth-byte -//! span. Where a step would be *authored*, this is where it shows: two blocks over one chunk, or a -//! block whose own plane has relief; -//! - the **overlap** scan — cells claimed by two blocks at once, which would composite the -//! translucent water twice; -//! - the **seam** scan — every pair of adjacent wet cells whose planes disagree, so an authored step -//! can be told apart from a rendered one before anyone opens the renderer; -//! - the **lattice-vs-waterline** scan — dry cells the ground still runs under. Whole wet cells are -//! the only thing the renderer draws, so wherever the lattice runs out before the ground climbs -//! through the plane, the water ends on a 4.167-yd straight edge in open water: a shape no -//! shoreline has; -//! - its **mirror** — wet cells the ground climbs out of, which is the skirt of liquid-lattice -//! geometry that lies over dry sand. Nothing renders there only because the terrain drew first -//! and won the depth test, so this is the exact budget a depth bias spends: a decal pulled `b` -//! yards toward the eye paints `b / slope` yards of that skirt; -//! - the **shoreline slope** at the pin, and what a coplanarity *lift* costs there. A decal held `l` -//! yards above the water plane keeps painting for `l / slope` yards past the waterline, on dry -//! ground — the arithmetic behind B348's foam-on-the-sand, and the reason that settle is a depth -//! bias now and not a lift. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. use std::collections::BTreeMap; use benilla_formats::{terrain_height_at, ChunkMesh, CHUNK_SIZE}; -/// One MCLQ cell edge, in yards — the granularity of `wet`, and of every clip on the water path. +/// One MCLQ cell edge in yards, the granularity of `wet` and of every clip on the water path. const CELL: f32 = CHUNK_SIZE / 8.0; -/// How far past the reported radius the block census still enumerates, so that a cell at the edge -/// of the answer has its real neighbours to be judged against (one chunk clears it). +/// How far past the radius the census enumerates, so an edge cell has its real neighbours. const MARGIN: f32 = CHUNK_SIZE; /// One seam between two adjacent wet cells whose surface planes disagree. @@ -50,14 +22,13 @@ struct Seam { /// One MCLQ cell of one block, resolved into world terms. struct Cell { - /// Cell-centre world XY. x: f32, y: f32, - /// The block's surface height over the cell (bilinear at the centre, as the queries sample it). + /// Surface height at the cell centre, bilinear, as the queries sample it. surface: f32, /// Terrain height at the same point, `None` where the tile has a hole. ground: Option, - /// Which chunk/block this cell came from — `(chunk index, block index)`. + /// `(chunk index, block index)`. owner: (usize, usize), kind: benilla_formats::LiquidKind, } @@ -86,10 +57,7 @@ fn main() -> anyhow::Result<()> { let Some(nw) = chunk.positions.first() else { continue; }; - // The chunk's own footprint against the pin box (chunk spans south/east of its NW corner), - // widened by MARGIN so every cell inside the radius has its real neighbours loaded — a cell - // at the box edge would otherwise read as "the lattice stops here" when it is only the - // enumeration that stopped. + // The chunk's footprint (south and east of its NW corner) against the box plus `MARGIN`. if nw[0] - CHUNK_SIZE > x + radius + MARGIN || nw[0] < x - radius - MARGIN || nw[1] - CHUNK_SIZE > y + radius + MARGIN @@ -158,8 +126,7 @@ fn main() -> anyhow::Result<()> { } // ── Overlap: two blocks claiming one patch of world, at two heights ────────────────────── - // Key a cell by its lattice index, floored — two DIFFERENT cells of one block must never - // collide, or the overlap report cries wolf on its own rounding. + // Key a cell by its floored lattice index, so two cells of one block never collide. let key = |c: &Cell| ((c.x / CELL).floor() as i32, (c.y / CELL).floor() as i32); let mut by_key: BTreeMap<(i32, i32), Vec> = BTreeMap::new(); for (n, c) in cells.iter().enumerate() { @@ -212,12 +179,9 @@ fn main() -> anyhow::Result<()> { println!(" {:?} -> {:?} dz {:+.3} yd", s.from, s.to, s.dz); } - // ── Where the LATTICE stops vs where the WATERLINE is ──────────────────────────────────── - // The renderer draws whole wet cells and nothing else, so the water's visible boundary is the - // *lattice* edge wherever the lattice runs out before the ground climbs through the plane. That - // edge is a 4.167-yd straight line in open water — the shape a shoreline never has. Sample every - // dry cell that touches a wet one on a 0.25-yd grid and report how deep the water would have - // been there. + // ── Where the lattice stops vs where the waterline is ──────────────────────────────────── + // The renderer draws whole wet cells only, so where the lattice ends before the ground rises + // through the plane the water ends on a straight cell edge; sample each dry neighbour's depth. let mut short_cells: Vec<(f32, (i32, i32))> = Vec::new(); for k in by_key.keys().copied().collect::>() { for nb in [ @@ -260,13 +224,9 @@ fn main() -> anyhow::Result<()> { ); } - // ── The mirror: WET cells the ground climbs out of ─────────────────────────────────────── - // The scan above finds water that is missing; this one finds water geometry that is *over dry - // land*. Whole wet cells are the unit the renderer draws, so wherever the ground climbs through - // the plane inside a cell, the liquid mesh — and every decal built on the same lattice — carries - // a skirt of geometry up the beach. Nothing renders there only because the terrain drew first - // and won the depth test, which makes this number the exact **budget a depth bias spends**: a - // decal pulled `b` yards toward the eye paints `b / slope` yards of that skirt onto the sand. + // ── The mirror: wet cells the ground climbs out of ─────────────────────────────────────── + // The liquid mesh and every decal on its lattice carry a skirt up the beach that only the depth + // test hides, so a decal pulled `b` yards toward the eye paints `b / slope` yards of it. let mut skirts: Vec<(f32, f32, (i32, i32))> = Vec::new(); // (height above plane, run inland, cell) for (k, idxs) in by_key.iter() { let c = &cells[idxs[0]]; @@ -319,9 +279,8 @@ fn main() -> anyhow::Result<()> { } // ── The waterline's slope, and what a coplanarity lift costs horizontally ──────────────── - // A decal on the water plane, lifted `l` yards to win the coplanar tie, keeps painting for - // `l / slope` yards past the waterline — up the beach, on dry ground. Measure the slope where - // the plane actually meets the ground nearest the pin. + // A decal lifted `l` yards off the water plane paints `l / slope` yards past the waterline; the + // slope is measured where the plane meets the ground nearest the pin. let mut best: Option<(f32, f32, f32, f32)> = None; // (dist to pin, x, y, slope) for c in &cells { let Some(g) = c.ground else { continue }; diff --git a/crates/benilla-formats/examples/what_is_here.rs b/crates/benilla-formats/examples/what_is_here.rs index f9ed651d..a8bf63e6 100644 --- a/crates/benilla-formats/examples/what_is_here.rs +++ b/crates/benilla-formats/examples/what_is_here.rs @@ -1,14 +1,7 @@ -//! What is actually standing at a world coordinate: +//! The WMO and M2 doodad placements within a radius of a pin, nearest first. Two batches of one WMO +//! share its authored MOBA order; a WMO and a nearby doodad have no ordering relationship. +//! Output is Blizzard data: never commit it. //! `cargo run -p benilla-formats --example what_is_here -- [radius_yd]` -//! e.g. `what_is_here Kalimdor 295.72 -3689.61 60`. -//! -//! A bug report names a *place* — a `.go xyz` pin — and every scene-render diagnosis starts by -//! turning that pin into the **placements** behind it: which WMOs and which M2 doodads, with their -//! distance from the pin. The distinction is load-bearing for draw-order defects: two batches of one -//! WMO share the authored MOBA order the pipeline biases by, while a WMO and a nearby M2 doodad are -//! separate lanes with no ordering relationship at all. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. fn main() -> anyhow::Result<()> { let mut args = std::env::args().skip(1); @@ -24,8 +17,7 @@ fn main() -> anyhow::Result<()> { let (tx, ty) = tiles.tile_at(x, y); println!("{map}: pin ({x}, {y}) is tile {tx}_{ty}; scanning r={radius} yd across 3x3"); - // The pin can sit near a tile seam and the building it names be authored in the neighbour, so - // sweep the 3x3 — a placement is deduped by uniqueId, which is stable across the tiles it spans. + // Sweep the 3x3 tiles; a placement spanning several is deduped by its uniqueId. let mut seen_wmo = std::collections::HashSet::new(); let mut seen_doodad = std::collections::HashSet::new(); let mut wmos: Vec<(f32, String, [f32; 3], u32)> = Vec::new(); diff --git a/crates/benilla-formats/examples/where.rs b/crates/benilla-formats/examples/where.rs index 445dec61..930a350c 100644 --- a/crates/benilla-formats/examples/where.rs +++ b/crates/benilla-formats/examples/where.rs @@ -1,16 +1,6 @@ -//! `cargo run -p benilla-formats --example where` — **where does benilla think the install is?** -//! -//! The resolver's own answer, printed (decision 1175). Two callers, and both are the reason this -//! is a program rather than a comment: -//! -//! - `scripts/gates.sh`, to decide whether the enforcer gate can run. It used to hand-roll half -//! the rule (`[ -n "$WOW_DATA" ] || [ -d WoW/Data ]`), which is exactly the duplication 1175 -//! exists to end — a gate that disagrees with the client about where the install is will skip -//! on a machine where the client would have run, or run on one where it cannot. -//! - a human whose client came up with no world. "It looked in these four places and none of them -//! existed" is actionable; "the terrain just doesn't appear" is not. -//! -//! Exits **0** when an install was found and **1** when none was, so a shell can branch on it. +//! The install benilla's resolver finds, or every place it looked; exits 1 when there is none. +//! `scripts/gates.sh` asks this rather than repeating the rule. +//! `cargo run -p benilla-formats --example where` fn main() -> std::process::ExitCode { match benilla_formats::wow_data() { diff --git a/crates/benilla-formats/examples/wmo_antiportal_census.rs b/crates/benilla-formats/examples/wmo_antiportal_census.rs index 5badef28..be9f00fa 100644 --- a/crates/benilla-formats/examples/wmo_antiportal_census.rs +++ b/crates/benilla-formats/examples/wmo_antiportal_census.rs @@ -1,63 +1,13 @@ -//! **Do any 1.12.1 WMOs author an ANTIPORTAL group, and if so which MOGP bit says so?** -//! `cargo run -p benilla-formats --example wmo_antiportal_census`. +//! Whether any shipped WMO group has the wowdev `ANTIPORTAL` shape (no MOBA render batches, only a +//! MOGI box) and which MOGP flag bit, if any, marks it: a histogram of every bit, the batch-less +//! groups found without reading flags, and the two cross-tabulated. +//! `cargo run -p benilla-formats --example wmo_antiportal_census` //! -//! Later WoW documentation (wowdev.wiki `SMOGroupFlags`) names a high MOGP `flags` bit -//! `ANTIPORTAL`: a group with **no render batches at all**, kept only for its bounding box, which -//! the portal flood uses as an occluder rather than something it ever draws. Nothing confirms that -//! bit for build 5875 — the portal flood -//! ([`benilla_world::wmo_portal`]) only ever branches on `flags & 0x48` (EXTERIOR / EXTERIOR_LIT) -//! and never treats any group as occlusion-only. So the question is purely empirical: does the -//! shipped 5875 data contain such a group at all, and which bit(s) does it carry? -//! -//! **Bit-agnostic by construction.** Rather than assume the wowdev bit number applies unchanged to -//! this build, this walks every group of every root WMO in the install and builds two independent -//! signals, then cross-tabulates them: -//! -//! 1. A histogram of **every** MOGP flag bit (0..31) — how many groups set it, how many distinct -//! roots contain at least one. -//! 2. The **batch-less set** — every group whose MOGP-owned MOBA chunk has zero render batches, -//! found without looking at flags at all. This is the wowdev ANTIPORTAL *shape*: no geometry to -//! draw, kept only for `MOGI`'s bounding box. -//! -//! If 1.12 ships any antiportal-shaped groups, they land in the batch-less set regardless of which -//! bit (if any) names them, and the cross-tab against the flag histogram is what tells us — from -//! the data, not a doc — which bit (if any) 5875 actually uses for it. -//! -//! **The run (2026-09-01, 815/815 roots, 5220/5220 groups, zero read/parse failures): yes, but no -//! single bit names it.** 15 groups across 9 roots ship with zero MOBA render batches — Naxxramas' -//! `frostwyrm_final01.wmo` (7 tiny connector groups), three of AQ40's boss-encounter "enterance" -//! shells, and the three Hyjal `worldtreeroots` collision volumes among them (see the batch-less -//! census below for the full list) — so the wowdev *shape* is real in 5875. But the cross-tab finds -//! **no MOGP bit set by all 15 and rare among the other 5205**: bit 0 (`0x1`) is set on literally -//! every group in the corpus (batch-less or not) and so distinguishes nothing; EXTERIOR (`0x8`) -//! covers 13/15 but also 1052/5205 batch-having groups; every other bit covers fewer than a third of -//! the batch-less set. Whatever these 15 groups are for — several read as pure collision volumes by -//! name/folder (`collidabledoodads\hyjal\worldtreeroots`) rather than deliberate portal occluders — -//! 5875 does not flag them with a dedicated ANTIPORTAL bit the way later clients' `SMOGroupFlags` -//! documentation describes. That is a fact about the *data*, not yet about the *engine*: whether -//! `WoW.exe` 5875's occluder pass (if it has one) singles these 15 out by shape rather than by flag -//! is a question about the binary this census doesn't answer. -//! -//! Only three MOGP bits are named anywhere in benilla's own code today (verified by grep over -//! `benilla-world/src/wmo_portal`, `benilla-assets/src/wmo.rs`, and -//! `benilla-formats/src/models/wmo`, 2026-09-01): `0x8` EXTERIOR, `0x40` EXTERIOR_LIT -//! ([`benilla_world::wmo_portal`]'s flood-defer/lighting-class bits), and `0x40000` SHOW_SKYBOX -//! ([`benilla_formats::WmoGroupInfo::show_skybox`]). The other candidate bits a hypothetical -//! ANTIPORTAL census might reach for — `0x1` (HAS_BSP/MOBN), `0x4` (VERTEXCOLOR), `0x200` -//! (HAS_LIGHTS/MOLR), `0x800` (HAS_LIQUID/MLIQ), `0x2000` (INTERIOR), `0x800000`/`0x1000000` -//! (second MOCV/MOTV) — are *not* named or tested anywhere in our reader: we discover MOLR/MLIQ/a -//! second MOTV by the sub-chunk's presence, never by these flag bits, and INTERIOR is our own -//! `flags & 0x48 == 0` derived test, not a single bit. None of those six get a name in the table -//! below; only a bit our code already states gets one, per the census's own charter. -//! -//! Every field per group comes from the same bytes the runtime reads: [`wmo_group_header`]'s -//! `flags` (MOGP `+0x08`, full 32 bits — **not** the two-bit-derived `interior`/`show_skybox` bools -//! `parse_wmo_root`'s MOGI reader exposes), `benilla_wmo::parse_wmo`'s `render_batches`/ -//! `vertex_positions` (MOBA/MOVT), `wmo_group_header`'s `portal_ref_count` (MOPR, sliced from the -//! group's own header span), and the root's [`WmoGroupInfo`] bounding box (MOGI, the same box the -//! portal code treats as a loose AABB of the group's volume). -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. +//! 15 shipped groups are batch-less, and no bit singles them out. The 1.12 client has no antiportal +//! branch on the group flags (the only bits above `0x10000` it tests are `0x40000` at `0x6b42e0` +//! and `0x20000` at `0x6c46a6`), so a batch-less group is invisible geometry, not an occluder. +//! Flags are MOGP's own 32 bits at `+0x08`, in every shipped group a superset of the MOGI copy. +//! Output is Blizzard data: never commit it. use std::collections::{BTreeMap, BTreeSet}; use std::io::Cursor; @@ -65,48 +15,39 @@ use std::io::Cursor; use benilla_formats::{parse_wmo_root, wmo_group_header, Chain}; use benilla_wmo::{parse_wmo, ParsedWmo}; -/// A bit counts as "rare" — and earns an example listing — below this many groups. Chosen well -/// above the size of any plausible antiportal set (a handful of buildings at most) and well below -/// EXTERIOR/EXTERIOR_LIT, which sit in the thousands. +/// Below this many groups a bit is rare and gets an example listing. const RARE_THRESHOLD: u32 = 200; -/// Example rows kept per rare bit. const MAX_EXAMPLES: usize = 40; -/// Batch-less rows printed in full before the report falls back to "N more, elided". const MAX_BATCHLESS_PRINTED: usize = 200; -/// Root-failure examples kept for the audit trail. const MAX_FAILURE_EXAMPLES: usize = 20; -/// Rows in the "most EXTERIOR groups" table (see its section for why that ranking is worth having). const MAX_EXTERIOR_RANK: usize = 25; -/// MOGP EXTERIOR — the bit the deferred-window worklist and its Pass-2 replay key on. +/// The MOGP EXTERIOR bit. const EXTERIOR: u32 = 0x8; -/// One group's full fact row — what every example listing and the batch-less census print. +/// One group's facts, as the listings print them. #[derive(Clone)] struct GroupRow { - /// The ROOT's chain path (e.g. `world\wmo\...\foo.wmo`); the group file is `{stem}_{NNN}.wmo`. + /// The root's chain path; the group file is `{stem}_{NNN}.wmo`. root_path: String, group_index: u32, - /// MOGP `flags` @ `+0x08`, all 32 bits, unfiltered. + /// MOGP `flags` at `+0x08`, all 32 bits. flags: u32, /// MOBA render-batch count. batches: usize, /// MOVT vertex count. vertices: usize, - /// MOPR portal-ref count (this group's slice, from its own MOGP header span). + /// MOPR portal-ref count, this group's slice from its own MOGP header. portal_refs: u16, - /// MOGI bounding box, WMO model space (WoW axes) — `None` if the root's MOGI table is shorter - /// than its declared group count (never observed, but not asserted away). + /// MOGI bounding box in model space; `None` if the MOGI table is shorter than the group count. bbox: Option<([f32; 3], [f32; 3])>, } impl GroupRow { - /// `{root}#{group}` for compact example rows. fn label(&self) -> String { format!("{}#{:03}", self.root_path, self.group_index) } - /// Bounding-box extent (max − min) per axis, or `?` if MOGI didn't carry one. fn bbox_str(&self) -> String { match self.bbox { Some((min, max)) => { @@ -121,8 +62,7 @@ impl GroupRow { } } -/// Per-bit tally: how many groups/roots set it, split by batch-less vs. batch-having, plus a -/// bounded example list for the rare-bit report. +/// Per-bit tally of groups and roots, batch-less against batch-having, with examples. #[derive(Default)] struct BitStat { groups: u32, @@ -132,14 +72,12 @@ struct BitStat { examples: Vec, } -/// Normalize a chain path the way the MPQ hash compares them (matches `wmo_ownerless_pools`). +/// A chain path normalised as the MPQ hash compares it. fn key(name: &str) -> String { name.replace('/', "\\").to_ascii_lowercase() } -/// Bench a known-name label for a MOGP bit — filled in ONLY for bits benilla's own code already -/// names as a flag test (verified 2026-09-01; see the module doc for what was checked and ruled -/// out). Everything else prints blank: this census must not invent names the data alone can't back. +/// A name only for the MOGP bits benilla's own code tests as flags; the rest print blank. fn known_name(bit: u32) -> &'static str { match bit { 3 => "EXTERIOR (0x8)", @@ -154,12 +92,8 @@ fn main() -> anyhow::Result<()> { .ok_or_else(|| anyhow::anyhow!("no 1.12.1 install found (set $WOW_DATA)"))?; let chain = Chain::open(&data)?; - // Every ROOT `.wmo` the chain lists, across every mounted archive — a group file's stem ends - // `_NNN`, a root's does not (same heuristic as `wmo_ownerless_pools`). The listfile is not a - // provably complete index of every archive (`Chain::list`'s own doc), so a root reachable ONLY - // by name and absent from every listfile would be missed here; nothing in the corpus is known - // to do that, but the failure counts below exist so a gap like it would show up as a number - // rather than silence. + // Every root `.wmo` the chain lists (a group file's stem ends `_NNN`, a root's does not); a + // root absent from every listfile is missed. let roots: BTreeSet = chain .list()? .into_iter() @@ -219,10 +153,8 @@ fn main() -> anyhow::Result<()> { continue; } }; - // Two independent reads of the same bytes, deliberately: `wmo_group_header` for the raw - // MOGP `flags`/MOPR span (the load-bearing 32 bits this census exists to histogram), and - // `benilla_wmo::parse_wmo` for MOBA/MOVT counts — the same split `wmo_group_submeshes` - // itself makes. + // `wmo_group_header` for the raw MOGP flags and MOPR span, `parse_wmo` for the MOBA and + // MOVT counts, the split `wmo_group_submeshes` makes. let Some(header) = wmo_group_header(&gbytes) else { group_parse_fail += 1; continue; @@ -306,17 +238,10 @@ cross-tab below for which flag bit(s) the data implicates.", } } - // ==== which buildings own MANY exterior groups ================================================ - // - // Not an antiportal question — a *consumer* one, and the reason it sits in this census rather - // than in a throwaway script. The reference draws a building's EXTERIOR groups only through the - // deferred portal windows the interior flood leaves behind, each group tested against its own - // window's sub-frustum (`0x6b3c73`–`0x6b3d6f`; benilla decision 1826). - // A building with ONE exterior group cannot show that law at work: a single whole-envelope shell - // has a box wide enough to intersect any window, so it draws from everywhere and looks identical - // either way — which is exactly why Stormwind (306 groups, one `0x8`) is the wrong subject to - // eyeball the behaviour on. The buildings below, whose shells are split into many groups, are - // where per-window culling is visible at all, and so where a retest belongs. + // ==== which buildings own many exterior groups ============================================ + // The reference draws exterior groups only through the deferred portal windows the interior + // flood leaves, each against its own window's sub-frustum (`0x6b3c73`-`0x6b3d6f`); only a + // building whose shell is split into many groups shows that culling. println!("\n---- roots by EXTERIOR (0x8) group count, top {MAX_EXTERIOR_RANK} ----"); let mut ranked: Vec<(&String, &u32)> = exterior_per_root.iter().collect(); ranked.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0))); @@ -432,11 +357,7 @@ cross-tab below for which flag bit(s) the data implicates.", } // ==== cross-tab: which bit(s) does the batch-less set actually carry? ========================= - // For every bit, how it splits across the batch-less/batch-having populations — the - // flag-agnostic and flag-based signals collide here. A bit set by (close to) ALL batch-less - // groups and by (close to) none of the batch-having ones is the data's own answer to "which bit - // means antiportal in 5875"; the reverse (never set on a batch-less group) rules a candidate out - // regardless of what later documentation calls it. + // A bit set by every batch-less group and rare among the rest would mark antiportals. let other_total = groups_scanned - batchless.len() as u32; println!( "\n---- cross-tab: bit occurrence, batch-less ({} groups) vs. batch-having ({} groups) ----", diff --git a/crates/benilla-formats/examples/wmo_bsp.rs b/crates/benilla-formats/examples/wmo_bsp.rs index 371dad88..78da8ef9 100644 --- a/crates/benilla-formats/examples/wmo_bsp.rs +++ b/crates/benilla-formats/examples/wmo_bsp.rs @@ -1,35 +1,20 @@ -//! Which WMO faces the reference's collision BSP can actually **reach** — and which ones we collide -//! that it never tests: +//! Which WMO faces the reference's collision BSP can reach, against the faces we collide. //! `cargo run -p benilla-formats --example wmo_bsp -- ` -//! e.g. `wmo_bsp cavernsoftime`. //! -//! The reference never brute-forces a WMO's triangles. Every collision query descends the group's -//! **MOBN** BSP and tests only the faces its leaves list in **MOBR**, then applies the per-face MOPY -//! reject mask (leaf `0x6bc700`: a BSP-leaf face is skipped iff `(MOPY.flags & rejectMask) != 0` -//! — walking `0x84`, camera `0x82`). We parse no BSP at all: `accumulate_wmo_group_faces` walks -//! **every** MOPY-passing triangle. So the two agree only where MOBR happens to reference -//! everything the mask keeps. -//! -//! This tool measures that gap per group — how many faces we collide that the reference's BSP cannot -//! reach, and where they are — which would be the difference between "solid wall" and "walk straight -//! through" at a WMO the file never asked to be solid. **Measured on CavernsOfTime (decision 0963): -//! the gap is ZERO across all 35 groups** — MOBR's leaves reach every non-DETAIL face, so our -//! brute-force set is exactly the reference's reachable set and the shortcut costs nothing but time. -//! Re-run it on any WMO whose collision is under suspicion before assuming the same. -//! -//! `inward` is the other half of the same question, for *rendering*: the share of a group's faces -//! whose winding normal points at the group's own centroid. A hollow room hull authored to be seen -//! from inside runs high (CoT's 34 interior groups: 59–100%), which is why the reference — single-sided since -//! MOMT `0x04` is clear — is see-through from outside, and why we were not until 0680. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. +//! The reference tests only the faces a group's MOBN leaves list in MOBR, skipping one iff +//! `MOPY.flags & rejectMask != 0`: walking's box query at leaf `0x6bca50` with mask `0x84`, the +//! camera and LOS segment query at leaf `0x6bc700` with `0x82`. `accumulate_wmo_group_faces` +//! walks every face the mask keeps, so the two agree only where MOBR reaches them all, as it does +//! on CavernsOfTime. `inward` counts faces turned toward the centroid: with MOMT `0x04` clear the +//! reference culls back faces, so a hull built to be seen from inside is see-through from outside. +//! Output is Blizzard data: never commit it. use std::collections::HashSet; use benilla_wmo::{parse_wmo, ParsedWmo}; -/// The walking gather's persistent MOPY reject bit (DETAIL/decal) — mask `0x84` (`0x6315f0`) minus -/// the transient `0x80` visited bit. +/// Walking's persistent MOPY reject bit (DETAIL): mask `0x84` (`0x6315f0`) minus the transient +/// `0x80` visited bit. const MOPY_DETAIL: u8 = 0x04; fn main() -> anyhow::Result<()> { @@ -96,16 +81,12 @@ fn main() -> anyhow::Result<()> { .collect() }) .unwrap_or_default(); - // Reachability, not mere presence: a face listed in MOBR but sitting under a leaf the - // descent never enters is never tested. Walk MOBN from node 0 and union the leaves' spans. + // Reachability, not presence: a face under a leaf the descent never enters is never tested. let (bsp_reach, bad) = descend(nodes, &mobr); - // What the reference's walking query can ever hit: a face the BSP lists AND whose MOPY - // clears DETAIL. What we hit: every face whose MOPY clears DETAIL. + // Walking in the reference hits reachable faces that clear DETAIL; ours hits all that do. let (mut bsp_hit, mut we_hit, mut gap) = (0usize, 0usize, 0usize); let mut gap_aabb = [[f32::MAX; 3], [f32::MIN; 3]]; - // Which way the shell presents. A hollow interior hull is authored to be seen from INSIDE: - // its winding normals point at the group's own centroid, so single-sided it is invisible - // (and see-through) from outside. Counted over every face, render or not. + // Faces whose winding normal points at the group's centroid, over every face. let mut centroid = [0.0f64; 3]; for p in &group.vertex_positions { for (c, v) in [p.x, p.y, p.z].into_iter().enumerate() { @@ -182,7 +163,6 @@ fn main() -> anyhow::Result<()> { Ok(()) } -/// A face's three positions, or `None` if any index is out of range. fn face_verts(group: &benilla_wmo::WmoGroup, f: usize) -> Option<[[f32; 3]; 3]> { let mut out = [[0.0f32; 3]; 3]; for (k, slot) in out.iter_mut().enumerate() { @@ -193,8 +173,8 @@ fn face_verts(group: &benilla_wmo::WmoGroup, f: usize) -> Option<[[f32; 3]; 3]> Some(out) } -/// The right-hand-rule normal of a face in its authored winding — the direction the face PRESENTS -/// under the reference's `GL_BACK`/CCW cull (and ours: `wow_to_bevy` is a proper rotation). +/// A face's right-hand normal in its authored winding: the side it presents under the reference's +/// CCW back-face cull, and ours, since `wow_to_bevy` is a proper rotation. fn winding_normal(group: &benilla_wmo::WmoGroup, f: usize) -> Option<[f32; 3]> { let [a, b, c] = face_verts(group, f)?; let (u, v) = ( @@ -208,7 +188,6 @@ fn winding_normal(group: &benilla_wmo::WmoGroup, f: usize) -> Option<[f32; 3]> { ]) } -/// A face's centroid. fn face_centroid(group: &benilla_wmo::WmoGroup, f: usize) -> Option<[f32; 3]> { let vs = face_verts(group, f)?; let mut out = [0.0f32; 3]; @@ -218,13 +197,10 @@ fn face_centroid(group: &benilla_wmo::WmoGroup, f: usize) -> Option<[f32; 3]> { Some(out) } -/// Walk the MOBN tree from node 0 and return the set of MOBR **face indices the descent can -/// actually reach**, plus a count of malformed references (out-of-range children or face spans). -/// -/// Node layout (wowdev, stride `0x10` — the stride itself is the client's, `0x692f20`): -/// `u16 flags · i16 negChild · i16 posChild · u16 nFaces · u32 faceStart · f32 planeDist`. -/// `flags & 0x4` = leaf. The `bad` counter is the falsifier: read the layout wrong and -/// children/spans go out of range immediately rather than quietly. +/// The MOBR face indices a descent from MOBN node 0 can reach, and the count of out-of-range +/// children or spans, which a misread layout drives up at once. A node is `u16 flags, i16 negChild, +/// i16 posChild, u16 nFaces, u32 faceStart, f32 planeDist`, stride `0x10` (`0x692f20`), and +/// `flags & 0x4` marks a leaf. fn descend(nodes: &[u8], mobr: &[u16]) -> (HashSet, usize) { let n = nodes.len() / 0x10; let (mut seen, mut reach, mut bad) = (vec![false; n], HashSet::new(), 0usize); diff --git a/crates/benilla-formats/examples/wmo_chunk_census.rs b/crates/benilla-formats/examples/wmo_chunk_census.rs index 73880a56..506a328d 100644 --- a/crates/benilla-formats/examples/wmo_chunk_census.rs +++ b/crates/benilla-formats/examples/wmo_chunk_census.rs @@ -1,20 +1,11 @@ -//! Chunk-integrity census over WMO files: walk the top-level chunk list the way the loader's -//! [`find_wmo_chunk`] walk does and report every file whose chunk stream does not tile exactly — a -//! chunk whose declared size overruns EOF. The reference **clamps** such a chunk (`0x6c3a60` / -//! `0x6c3f80` read chunks while the 8-byte header is in-bounds and clamp the last chunk to EOF — -//! e.g. `Undercity_144.wmo`'s MOGP runs 1 B past EOF, tolerated), so an overrun here must cost us -//! nothing; a file listed as losing a chunk is a bug. -//! -//! ```text -//! cargo run -p benilla-formats --example wmo_chunk_census -- # one building -//! cargo run -p benilla-formats --example wmo_chunk_census -- --all # the whole corpus -//! ``` -//! -//! Output is Blizzard-derived — pipe it to the scratchpad, never into the repo. +//! WMO files whose top-level chunk stream does not tile exactly, a chunk's declared size running +//! past EOF, and whether our loader still reads them. The reference clamps the last chunk to EOF +//! (`0x6c3a60`, `0x6c3f80`: chunks are read while the 8-byte header is in bounds), so +//! `Undercity_144.wmo`'s MOGP one byte past EOF loads, and a file our loader loses is a bug. +//! `cargo run -p benilla-formats --example wmo_chunk_census -- | --all` +//! Output is Blizzard data: never commit it. -/// Walk `b`'s chunk stream as a pure **data** question: is it exactly tiled, or does some chunk -/// declare more bytes than the file holds? Returns `(tag, declared_end, file_len)` for the first -/// over-declared chunk. Whether the loader survives it is asked separately, of the loader itself. +/// The first chunk declaring more bytes than the file holds, as `(tag, declared_end, file_len)`. fn overrun(b: &[u8]) -> Option<(String, usize, usize)> { let mut off = 0usize; while off + 8 <= b.len() { @@ -66,8 +57,8 @@ fn main() -> anyhow::Result<()> { continue; } checked += 1; - // The loader's own verdict, not a re-implementation of it: a group file must yield its MOGP - // header (flags + portal-ref span — losing it dead-ends the portal flood), a root must parse. + // The loader's own verdict: a group must yield its MOGP header, which the portal flood + // needs, and a root must parse. let is_group = b.len() > 16 && &b[12..16] == b"PGOM"; let loads = if is_group { benilla_formats::wmo_group_header(&b).is_some() diff --git a/crates/benilla-formats/examples/wmo_collision_cost.rs b/crates/benilla-formats/examples/wmo_collision_cost.rs index b23ef2e2..e70bcbe6 100644 --- a/crates/benilla-formats/examples/wmo_collision_cost.rs +++ b/crates/benilla-formats/examples/wmo_collision_cost.rs @@ -1,34 +1,15 @@ -//! What a WMO's **collision** actually costs, and whether its walking gather has a floor at all: +//! Each WMO's collision triangle counts, walking against camera gather, and the groups whose +//! walking gather (MOPY DETAIL `0x04` dropped) has no walkable face where the camera's does. The +//! triangle count drives how long a placement takes to turn solid, against the mover's +//! `SETTLE_TIMEOUT`. Output is Blizzard data: never commit it. //! `cargo run -p benilla-formats --example wmo_collision_cost -- ...` -//! e.g. `wmo_collision_cost stratholme.wmo zulgurubcity`. -//! -//! The falsifier for a **fall-through** report, which has exactly two shapes and they take opposite -//! fixes: -//! -//! * **No floor to stand on** — the walking gather (`flags & 0x04` DETAIL dropped) came back with no -//! upward-facing faces in a group the camera gather *does* see. Then the collider is present and -//! correct and still cannot hold you: the authored floor is flagged decal-only. A data fact, fixed -//! by widening the gather, and visible here without entering the game. -//! * **A floor that arrives too late** — the faces are there, but the placement's trimesh is big -//! enough that its off-thread build plus the per-frame attach budget runs past the mover's settle -//! timeout, so gravity switches on over geometry that is not solid yet. A *timing* fact; this -//! prints the triangle count that drives it, so the margin can be compared against -//! `SETTLE_TIMEOUT` without guessing which buildings are the heavy ones. -//! -//! Both readings are properties of the shipped file, so read them here rather than inferring them -//! from a fall you may or may not be able to reproduce on your own machine — the timing shape -//! reproduces only on a machine slow enough, which is exactly why it reaches players and not us. -//! -//! Output is Blizzard-derived; pipe it to the scratchpad, never into the repo. use benilla_formats::{accumulate_wmo_group_camera_collision, accumulate_wmo_group_collision}; -/// Faces at least this upward-facing count as a **floor** — the mover's own walkable test -/// (`GROUND_COS`, ~50° from vertical). A group with collidable faces but none of them walkable is -/// a wall/ceiling shell you slide down, not something you can stand on. +/// A face at least this upward-facing is a floor, as the mover's `GROUND_COS` (about 50° from +/// vertical) judges it. const WALKABLE_COS: f32 = 0.64; -/// One gather's shape: how many triangles, and how many of them you could stand on. struct Gather { tris: usize, walkable: usize, diff --git a/crates/benilla-formats/examples/wmo_coplanar.rs b/crates/benilla-formats/examples/wmo_coplanar.rs index 0a06920e..8eed3dd7 100644 --- a/crates/benilla-formats/examples/wmo_coplanar.rs +++ b/crates/benilla-formats/examples/wmo_coplanar.rs @@ -1,34 +1,23 @@ -//! Which batches of a WMO are **coplanar with each other**, and by how much: +//! Which batches of a WMO are coplanar, with each other or with themselves: per pair, the smallest +//! plane gap between near-parallel overlapping faces. A `0.000` gap is a true tie the depth test +//! cannot decide; anything else is a separation to set against the depth buffer's resolution at +//! that range. Output is Blizzard data: never commit it. //! `cargo run -p benilla-formats --example wmo_coplanar -- [gap_yd]` -//! e.g. `wmo_coplanar orctower 0.05`. -//! -//! The falsifier for a z-fighting report. Two surfaces that swap which one is in front do it for one -//! of two reasons, and they take different fixes: a **true tie** (the author put both faces on the -//! same plane, so the depth test has nothing to decide with and the winner is whoever drew last) or -//! **depth precision** (a real but tiny separation that quantises away). The distinction is a -//! property of the *file*, not of the frame — so read it here rather than inferring it from pixels. -//! -//! Prints, for every pair of batches whose geometry overlaps in space, the smallest plane gap between -//! near-parallel faces that actually overlap. A `0.000` gap is a tie; anything else is a separation -//! to compare against the depth buffer's resolution at that range. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. use benilla_formats::RenderSubmesh; /// Faces this close to parallel are treated as the same plane orientation (≈2.6°). const PARALLEL_DOT: f32 = 0.999; -/// One triangle reduced to its plane plus the box it occupies — everything the gap test needs. +/// One triangle as its plane and bounding box. struct Face { normal: [f32; 3], /// Plane offset: `normal · vertex`. d: f32, min: [f32; 3], max: [f32; 3], - /// The triangle's own corners, kept so a batch can be compared against **itself**: two faces of - /// one flat quad share an edge and are trivially coplanar, which is not a defect. Real overlapping - /// sheets share no vertex. + /// The corners, so a batch can be compared with itself: two halves of one flat quad share a + /// vertex, overlapping sheets do not. verts: [[f32; 3]; 3], } @@ -76,12 +65,10 @@ fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } -/// Do the two boxes overlap, allowing `slack` of separation on every axis? fn boxes_touch(a: &Face, b: &Face, slack: f32) -> bool { (0..3).all(|c| a.min[c] - slack <= b.max[c] && b.min[c] - slack <= a.max[c]) } -/// Do the triangles share a corner? Two halves of a quad do; two stacked sheets do not. fn shares_a_vertex(a: &Face, b: &Face) -> bool { a.verts.iter().any(|p| { b.verts @@ -90,9 +77,8 @@ fn shares_a_vertex(a: &Face, b: &Face) -> bool { }) } -/// The smallest plane gap between near-parallel, spatially-overlapping faces of `a` and `b`, or -/// `None` if no such pair sits within `gap`. When `a` and `b` are the same batch, edge-sharing -/// neighbours are skipped — they are how a flat surface is built, not a defect. +/// The smallest plane gap within `gap` between near-parallel overlapping faces of `a` and `b`; +/// within one batch, faces sharing a vertex are skipped. fn closest_coplanar(a: &[Face], b: &[Face], gap: f32, same_batch: bool) -> Option { let mut best: Option = None; for (i, fa) in a.iter().enumerate() { @@ -122,8 +108,7 @@ fn main() -> anyhow::Result<()> { let pat = args .next() .ok_or_else(|| anyhow::anyhow!("usage: wmo_coplanar [gap_yd]"))?; - // Report pairs whose faces sit within this gap. The default is generous — a real z-fight needs a - // gap far under a centimetre, but seeing the near misses tells you where the margin actually is. + // Pairs within this gap; the default is wide enough to show near misses too. let gap: f32 = args.next().map_or(Ok(0.05), |g| g.parse())?; let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); diff --git a/crates/benilla-formats/examples/wmo_dark_floors.rs b/crates/benilla-formats/examples/wmo_dark_floors.rs index 44f6e6b8..a5813ca6 100644 --- a/crates/benilla-formats/examples/wmo_dark_floors.rs +++ b/crates/benilla-formats/examples/wmo_dark_floors.rs @@ -1,21 +1,9 @@ -//! **Every WMO in the chain that authors a surface too dark to render on its own** — the world sweep -//! behind decision 0956's Dire Maul report: +//! Every WMO surface baked near-black at `MOCV.α = 0` that only the doorway fade +//! (`FixColorVertexAlpha`) lights: without the fade the TRANS and INT batches render the bake as +//! is, a black floor. A vertex is dark at luminance < 48 and alpha 0 when the fade lifts it past +//! 200; a triangle counts when all three are, and batches rank by that area. +//! Output is Blizzard data: never commit it. //! `cargo run --release -p benilla-formats --example wmo_dark_floors [-- ]` -//! -//! The bug 0956 fixed was never Dire Maul's: a WMO transition group may bake a surface at near-black -//! with `MOCV.α = 0`, leaving the runtime bright-doorway fade (`FixColorVertexAlpha`) to light it. -//! Skip the fade and the interior TRANS/INT law renders the bake literally — a black floor. One -//! building was *reported*; this asks the question the report could not, which is how many others -//! were sitting there unreported. -//! -//! Severity is **area, not vertex count**. A vertex is *dark* when the file bakes it near-black at -//! zero alpha (luminance < 48, `α == 0`) and the fade lifts it past 200 — it would have drawn black -//! and now draws lit. A **triangle** counts when all three of its vertices are, and its model-space -//! area is summed: that is literally the square yardage of black floor a player would have walked -//! over (floor, wall or ceiling alike). Batches are reported with their share of their own surface, so a wholly-black walkway -//! (Dire Maul: 100%) sorts above a darkened seam. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. use benilla_wmo::{parse_wmo, ParsedWmo}; @@ -36,9 +24,8 @@ struct Hit { batch: usize, class: &'static str, texture: String, - /// Square yards of triangle whose every vertex was dark. + /// Square yards of triangles whose every vertex is dark. area: f32, - /// That area as a share of the batch's own total. share: f32, } @@ -100,7 +87,7 @@ fn main() -> anyhow::Result<()> { if !is_dark.iter().any(|&d| d) { continue; } - // MOBA is laid out TRANS, INT, EXT — only the first two render the bake. + // MOBA runs TRANS, INT, EXT; only the first two render the bake. let (trans_n, int_n) = benilla_formats::wmo_group_header(&gbytes) .map(|_| ()) .and_then(|()| { diff --git a/crates/benilla-formats/examples/wmo_doubled.rs b/crates/benilla-formats/examples/wmo_doubled.rs index 265a0aa1..76253877 100644 --- a/crates/benilla-formats/examples/wmo_doubled.rs +++ b/crates/benilla-formats/examples/wmo_doubled.rs @@ -1,46 +1,28 @@ -//! Which faces of a WMO are **authored twice** — two triangles on the same three positions: +//! Which faces of a WMO are authored twice, two triangles on the same three positions, with each +//! pair's MOMT flags. Opposite winding is how 1.12 authors cloth seen from both sides on a +//! single-sided material: the reference culls each copy from its back, so one covers any pixel, +//! and a renderer that draws the batches two-sided makes the copies z-fight. MOMT `0x04` +//! (UNCULLED) set means the file asks for two-sided drawing. `wmo_coplanar` cannot see these, as it +//! skips same-batch faces that share a vertex. Output is Blizzard data: never commit it. //! `cargo run -p benilla-formats --example wmo_doubled -- ` -//! e.g. `wmo_doubled orctower`. -//! -//! The falsifier `wmo_coplanar` structurally cannot be. That tool skips same-batch face pairs that -//! share a vertex ("two halves of a quad") — but a thin sheet whose two *sides* are authored by -//! reusing the same vertices with **reversed winding** shares all three, so a doubled canvas awning -//! is invisible to it by construction (it reported the B38 tent's nearest partner at 1.91 yd while -//! the tent's own two sides sat at 0.000). This tool asks the opposite question: which triangles -//! occupy the SAME three positions? -//! -//! Doubled-with-opposite-winding is not a defect — it is how 1.12 authors visible-from-both-sides -//! cloth when the material is single-sided: the real client backface-culls each copy from the wrong -//! side, so exactly one covers any pixel. It *becomes* a defect the moment the renderer draws WMO -//! batches two-sided — ours did until `3af8854f` honoured MOMT `0x04` (B38, decision 0680): both -//! copies then rasterize at depths equal to the ulp, and the winner per pixel is floating-point -//! noise — latched while the camera is bit-still, flickering while anything creeps. That is the -//! defect this tool was built to name, and it is now closed; it stays as the census. Each pair -//! prints with its -//! batches' MOMT flags: `0x04` (UNCULLED) set means the file *asked* for two-sided and the double -//! draw is authored intent; clear means the renderer's shortcut is manufacturing a z-tie the file -//! never contained. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. use std::collections::HashMap; use benilla_wmo::{parse_wmo, ParsedWmo}; -/// Positions are matched to a tenth of a millimetre — far under authoring precision, far over f32 -/// noise at building scale. +/// Positions match to a tenth of a millimetre, under authoring precision and over f32 noise. const QUANT: f32 = 10_000.0; fn key(p: [f32; 3]) -> [i64; 3] { p.map(|c| (c * QUANT).round() as i64) } -/// A triangle's identity (order-free) plus the data needed to classify its partner. +/// A triangle's batch and facing, to classify its partner. struct Tri { batch: usize, - /// Geometric normal in original winding order — two copies with `dot < 0` are opposite-wound. + /// Geometric normal in authored winding; two copies with `dot < 0` are opposite-wound. normal: [f32; 3], - /// Mean authored (MONR) normal, to name which side of the sheet this copy is. + /// Summed authored (MONR) normals, naming which side of the sheet this copy is. authored: [f32; 3], } @@ -66,8 +48,7 @@ fn main() -> anyhow::Result<()> { }; println!("{path}\n"); - // The root's MOMT table, raw — the submeshes don't carry material flags, and the flags decide - // whether a doubled pair is authored intent (0x04 UNCULLED) or a renderer-manufactured tie. + // The root's raw MOMT table: the submeshes carry no material flags. let root_bytes = chain.read_file(&path.to_ascii_lowercase())?; let (materials, textures, tex_map) = match parse_wmo(&mut std::io::Cursor::new(&root_bytes))? { ParsedWmo::Root(r) => (r.materials, r.textures, r.texture_offset_index_map), @@ -89,8 +70,7 @@ fn main() -> anyhow::Result<()> { let subs = benilla_formats::load_wmo(&mut chain, &path)?; - // One global bucket per geometric triangle, across every batch: any bucket holding two entries - // is a doubled face, wherever its copies live. + // One bucket per geometric triangle across every batch; two entries make a doubled face. let mut buckets: HashMap<[[i64; 3]; 3], Vec> = HashMap::new(); for (bi, s) in subs.iter().enumerate() { for t in s.indices.as_chunks::<3>().0 { @@ -117,9 +97,8 @@ fn main() -> anyhow::Result<()> { } } - // Doubled-face census per batch pair, split by winding — opposite is the two-sided-sheet - // authoring; same-winding true duplicates would be a different (and stranger) finding. - /// Per batch pair: doubled-face count + each side's summed authored normal. + // Per batch pair, split by winding: opposite is a two-sided sheet, same is a true duplicate. + /// Per batch pair: the doubled-face count and each side's summed authored normal. type OppositeCensus = HashMap<(usize, usize), (usize, [f32; 3], [f32; 3])>; let mut opposite: OppositeCensus = HashMap::new(); let mut same: HashMap<(usize, usize), usize> = HashMap::new(); @@ -131,8 +110,7 @@ fn main() -> anyhow::Result<()> { if dot(a.normal, b.normal) < 0.0 { let e = opposite.entry(pair).or_insert((0, [0.0; 3], [0.0; 3])); e.0 += 1; - // Accumulate each side's authored normal by batch order, so the report can say - // "batch A faces up, batch B faces down" rather than just "they differ". + // Each side's authored normal in batch order, to say which way each faces. let (first, second) = if a.batch <= b.batch { (a, b) } else { (b, a) }; for c in 0..3 { e.1[c] += first.authored[c]; diff --git a/crates/benilla-formats/examples/wmo_liquid_arms.rs b/crates/benilla-formats/examples/wmo_liquid_arms.rs index 31d1bd95..f89ad538 100644 --- a/crates/benilla-formats/examples/wmo_liquid_arms.rs +++ b/crates/benilla-formats/examples/wmo_liquid_arms.rs @@ -1,38 +1,18 @@ -//! **Which WMO water ARM each pool takes**, over the whole shipped corpus: -//! `cargo run -p benilla-formats --example wmo_liquid_arms`. -//! -//! The reference does not have *one* WMO water renderer. `0x6b62e0` dispatches the type nibble to a -//! category, and category 0 (river/water — nibbles 0/4/8) then splits again on the owning group's -//! `MOGP.flags & 0x48`: -//! -//! * `& 0x48 != 0` → **EXTERIOR**, kernel `0x6b6630`. A 9-float (`0x24`) vertex — position, an up -//! normal, a colour dword, `u,v` — and the kernel binds the pixel program -//! `Shaders\Pixel\MapObjExtWater0.bls` at `0x6b6654` (`GxRsSet(0x3f, …)`, gated on `[0xc9607c]`). -//! * `& 0x48 == 0` → **INTERIOR**, kernel `0x6b6420`. A 6-float (`0x18`) vertex — position, a colour -//! dword, `u,v` — with **no normal at all**, the colour coming from the map object's own light -//! records (`CMapObj+0x1d8`). -//! -//! The arm split is **category 0 only**. Magma and slime (nibbles 2/3/6/7) go to category 1 and one -//! shared kernel `0x6b68f0` whatever their group's flags say, so their rows below are context, not -//! two arms — they are reported because the same building often holds both, and because a reader -//! comparing group counts would otherwise assume the split is universal. -//! -//! Neither water arm is the **ADT** path (`0x6851b0`/`0x685010` → `ocean0_s.bls`), which is the only -//! one with a depth-ramp texture on stage 0 and the only one whose vertex carries no colour. -//! -//! benilla renders all three through one material and one shader — the ADT one. This census says how -//! much content each arm actually owns, so the divergence can be sized instead of guessed: the two -//! reported sites (B136 Blackfathom Deeps, and Stormwind's canals) land on opposite arms, and -//! neither is the path we implement. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. +//! Which WMO water arm each shipped liquid group takes. The reference's dispatch `0x6b62e0` splits +//! water (nibbles 0/4/8) on the owning group's `MOGP.flags & 0x48`: set runs the exterior kernel +//! `0x6b6630`, a 9-float vertex with an up normal, binding `MapObjExtWater0.bls` at `0x6b6654`; +//! clear runs the interior kernel `0x6b6420`, a 6-float vertex with no normal, coloured from the +//! map object's lights (`CMapObj+0x1d8`). Magma and slime (nibbles 2/3/6/7) take `0x6b68f0` +//! whatever the flags, and neither arm is the ADT path (`0x6851b0`, `0x685010`, `ocean0_s.bls`). +//! Output is Blizzard data: never commit it. +//! `cargo run -p benilla-formats --example wmo_liquid_arms` use std::collections::BTreeMap; use benilla_formats::{wmo_group_header, wmo_group_liquid_mesh, Chain}; -/// MOGP `flags & 0x48` — the reference's interior test (`0x6b3f90`, and the liquid dispatch's own -/// `[owner+0x10] & 0x48` at `0x6b62e0`). Zero ⇒ interior. +/// The reference's interior test, zero meaning interior (`0x6b3f90`; the liquid dispatch +/// `0x6b62e0` tests `[owner+0x10] & 0x48`). const EXTERIOR_MASK: u32 = 0x48; #[derive(Default, Clone, Copy)] @@ -46,11 +26,8 @@ fn main() -> anyhow::Result<()> { .ok_or_else(|| anyhow::anyhow!("no 1.12.1 install found (set $WOW_DATA)"))?; let reader = Chain::open(&data)?; - // Group files are `_NNN.wmo`; the roots are what the chain lists, so walk the listing - // once and keep the group files directly — a root's group count is not knowable without parsing - // it, and the names are self-describing. let mut by_arm: BTreeMap<(bool, String), Tally> = BTreeMap::new(); - // Per building, so a site can be looked up rather than re-derived. + // Per building: (interior, exterior) water groups. let mut buildings: BTreeMap = BTreeMap::new(); for entry in reader.list()? { @@ -125,7 +102,7 @@ fn main() -> anyhow::Result<()> { } ); if !water { - continue; // the totals below are the WATER arms; fullbright takes neither + continue; // the totals below are the water arms; fullbright takes neither } if *interior { int_g += t.groups; @@ -139,9 +116,7 @@ fn main() -> anyhow::Result<()> { "\nWATER arms — INTERIOR {int_g} groups / {int_t} wet tiles EXTERIOR {ext_g} groups / {ext_t} wet tiles" ); - // Buildings carrying WATER on both arms: a single-shader renderer cannot be right for the whole - // building. (Undercity holds both flags too, but its liquid is slime, which takes neither arm — - // hence the water-only filter.) + // Buildings with water on both arms; Undercity's liquid is slime, which takes neither. let mixed: Vec<_> = buildings .iter() .filter(|(_, (i, e))| *i > 0 && *e > 0) diff --git a/crates/benilla-formats/examples/wmo_liquid_scope.rs b/crates/benilla-formats/examples/wmo_liquid_scope.rs index dbc27e78..f2544594 100644 --- a/crates/benilla-formats/examples/wmo_liquid_scope.rs +++ b/crates/benilla-formats/examples/wmo_liquid_scope.rs @@ -1,53 +1,34 @@ -//! Where liquid **scoping** can still get the wrong answer, read off the shipped files rather than -//! argued: `cargo run -p benilla-formats --example wmo_liquid_scope`. +//! Where WMO liquid scoping can claim the wrong position, read off every placed WMO (from an ADT +//! or a WDT), each hit with a `.go`. `cargo run -p benilla-formats --example wmo_liquid_scope` //! -//! The "swim in air" family (0634 → 0635 → 0696 → 0701) is one defect met at successively finer -//! grain: a liquid footprint has no bound of its own, so each round found a wider set of positions -//! it was still claiming. This is the falsifier that says which bounds are actually load-bearing on -//! shipped content, and where — an argument about WMO liquid is otherwise unfalsifiable from inside -//! the game, where you can only stand in one room at a time. +//! - STOREY: a pool's wet cells over another group's floor, submerged by placement scoping alone. +//! - OVERHANG: how far wet cells reach past their own group's XY box, up to 25 yd, which is why +//! benilla bounds a pool by that box's Z floor only where the reference tests the whole box +//! (`0x6a4e00`). +//! - DRY-GROUP: a group with a `groupLiquid` other than `0xf` and no MLIQ, which the reference +//! answers with an unconditional hit at height `FLT_MAX` (`0x6b9f10`). //! -//! * **STOREY** — a placement holding a pool in one group and a **floor below it** in another. -//! Placement scoping alone claims the lower room (submerged on dry stone, the B60 shape). This is -//! what found decision 0701's Undercity site, and it now also reports whether 0701's floor rule -//! closes each hit. Tested against the pool's real **wet cells**, not its bounding box: a liquid -//! grid is sparse, so a bbox routinely spans dry ground it never touches (decision 0635). -//! * **OVERHANG** — how far a pool's wet cells reach *outside* their own group's box. This is why -//! 0701 bounds a pool by its group's Z **floor** and not by the reference's whole per-group AABB: -//! on shipped content a pool overhangs its own room by up to 25 yd in XY, so an XY box test would -//! newly reject pools that work today. -//! * **DRY-GROUP** — a group whose MOGP `groupLiquid` (@0x34) is not the `0xf` no-liquid sentinel -//! but that carries **no MLIQ chunk**. The reference's `0x6b9f10` is reported to answer such a -//! group with an unconditional hit at height `FLT_MAX`; we answer *nothing*, and since 0634/0696 -//! suppress the ADT leg indoors, the room reads DRY. **Still open** — three placed groups. -//! -//! Only placements found in a shipped ADT are reported — an unplaced WMO cannot be stood in — and -//! each hit prints a `.go` for the lower room's floor so it can be looked at. -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. +//! Output is Blizzard data: never commit it. use std::collections::BTreeMap; use std::io::Cursor; use benilla_formats::LiquidMesh; -/// MOGP `groupLiquid` @ 0x34: the value meaning "this group holds no liquid". +/// MOGP `groupLiquid` (`+0x34`) for a group holding no liquid. const NO_LIQUID: u32 = 0xf; -/// Half the 34133⅓-yard map (32 tiles × 533⅓) — the MODF→world origin shift. +/// Half the 34133⅓-yard map (32 tiles × 533⅓), the MODF-to-world origin shift. const MAP_CENTER: f32 = 17066.666; -/// What one group file says about liquid. struct Group { liquid_type: u32, - /// The group's MLIQ surface in WMO model space, if it has one. liquid: Option, - /// MOGP bounding box (model space), @0x0c. + /// MOGP bounding box in model space, at `+0x0c`. lo: [f32; 3], hi: [f32; 3], } -/// One placement of a model in the world. struct Site { map: String, /// Placement origin in WoW world coords. @@ -93,16 +74,14 @@ fn read_group(bytes: &[u8]) -> Option { }) } -/// Does this group file carry an MLIQ sub-chunk at all? (The MOGP payload is a 68-byte header -/// followed by its own sub-chunks.) Distinct from [`Group::liquid`], which is `None` for an -/// all-holes grid too. +/// Whether the group carries an MLIQ sub-chunk after MOGP's 68-byte header; [`Group::liquid`] is +/// also `None` for an all-holes grid. fn has_mliq(bytes: &[u8]) -> bool { chunk(bytes, b"PGOM").is_some_and(|m| m.len() > 0x44 && chunk(&m[0x44..], b"QILM").is_some()) } -/// The model→world rotation for a MODF placement, in the **WoW** frame (Z up) — the `in_wow` half -/// of `benilla_assets::coords::placement_rotation`, as a plain 3×3 so this example needs no math -/// dependency. `Rx(90°)·Ry(ry−180°)·Rz(−rx)·Rx(rz−90°)`, applied to a model-space point. +/// A MODF placement's model-to-world rotation in the WoW frame (Z up), the `in_wow` half of +/// `benilla_assets::coords::placement_rotation`: `Rx(90°)·Ry(ry-180°)·Rz(-rx)·Rx(rz-90°)`. fn model_to_world(rot_deg: [f32; 3], p: [f32; 3]) -> [f32; 3] { let (rx, ry, rz) = ( rot_deg[0].to_radians(), @@ -214,11 +193,7 @@ fn main() -> anyhow::Result<()> { } } } - // A WMO-only map has no ADT at all — the whole world is the WDT's single `MODF` (20 of the 43 - // shipped maps, decision 0688). Scanning only ADTs therefore declared the Deeprun Tram, the - // jails and every instance-shaped dungeon *unplaced*, which is the one thing this census uses - // to decide a group is unreachable. The tram's own flooded sections are among the sites this - // check was written for, so the blind spot was hiding exactly the sites asked about. + // A WMO-only map has no ADT: its world is the WDT's single `MODF` (20 of the 43 shipped maps). let wdts: Vec = chain .list()? .into_iter() @@ -239,9 +214,8 @@ fn main() -> anyhow::Result<()> { let Some(g) = wdt.global_wmo() else { continue }; let lower = name.to_lowercase(); let map = lower.split(['\\', '/']).nth(2).unwrap_or("?").to_string(); - // No `MAP_CENTER − v` remap here: a global WMO's MODF is authored in world coords already - // (0688, falsified against all 26 of the server's entry points). Every shipped one is - // (0,0,0), so a `.go` printed for one of these is the model-space offset itself. + // No `MAP_CENTER - v` remap: a global WMO's MODF is in world coords already, and every + // shipped one sits at (0,0,0). placed .entry(g.model.to_lowercase().replace('/', "\\")) .or_default() @@ -259,8 +233,7 @@ fn main() -> anyhow::Result<()> { let mut storey: Vec<(f32, String)> = Vec::new(); let mut overhang: Vec<(f32, String)> = Vec::new(); let mut floor_misses = 0usize; - // groupLiquid value -> (groups with an MLIQ chunk, groups without) — the sentinel sanity check: - // `0xf` is only the "no liquid" marker if it is what the overwhelming majority of dry groups say. + // groupLiquid value -> (groups with an MLIQ chunk, groups without). let mut type_census: BTreeMap = BTreeMap::new(); for (model, sites) in &placed { @@ -277,9 +250,7 @@ fn main() -> anyhow::Result<()> { } else { e.1 += 1; if g.liquid_type != NO_LIQUID { - // The ROOM, not the building origin: a placement origin routinely sits - // inside rock, so a `.go` to it is unusable for an eyeball check. Aim at the - // group box's own centre, just off its floor. + // Aim at the room's box centre, just off its floor, not the placement origin. let mid = |a: usize| (g.lo[a] + g.hi[a]) * 0.5; dry_groups.push(format!( " {model} group {gi:03} liquidType {} box z [{:.1}..{:.1}]\n room {}\n origin{}", @@ -294,7 +265,7 @@ fn main() -> anyhow::Result<()> { groups.push(g); } - // STOREY: for each pool, the lower groups its WET CELLS actually hang over. + // STOREY: for each pool, the lower groups its wet cells hang over. for (ai, a) in groups.iter().enumerate() { let Some(lq) = a.liquid.as_ref() else { continue; @@ -314,27 +285,23 @@ fn main() -> anyhow::Result<()> { if ai == bi { continue; } - // The claim only fires where the query would: a point in B under the pool's height - // and inside a wet cell's XY. Take B's own floor as the standing surface. + // Where the query fires: under the pool's height, in a wet cell, on B's floor. let stand = b.lo[2] + 1.5; let mut best: Option<([f32; 4], f32)> = None; for (xy, h) in &cells { if *h <= stand { - continue; // the pool is at or below this floor — no false submersion + continue; // the pool is at or below this floor: no false submersion } let overlap = xy[0] < b.hi[0] && xy[2] > b.lo[0] && xy[1] < b.hi[1] && xy[3] > b.lo[1]; - // …and B must not simply BE the pool's own room: skip when B's box contains the - // liquid height, i.e. the pool is inside B and the submersion is real. + // Skip B when its box holds the liquid height: that submersion is real. if overlap && *h > b.hi[2] && best.is_none_or(|(_, bh)| *h > bh) { best = Some((*xy, *h)); } } if let Some((xy, h)) = best { let pt = [(xy[0] + xy[2]) * 0.5, (xy[1] + xy[3]) * 0.5, stand]; - // Would a FLOOR under the pool — "a pool cannot claim anything below its own - // group's box" — close this one? The candidate fix, checked per hit rather - // than assumed to generalize from the site that motivated it. + // Whether the floor rule (nothing below the pool's group box) closes this. let floored = stand < a.lo[2]; if !floored { floor_misses += 1; @@ -368,12 +335,8 @@ fn main() -> anyhow::Result<()> { println!(" {d:>7.2} yd {line}"); } - // ---- the ARCHIVE-WIDE census. ----------------------------------------------------------- - // The placed-model census below answers "what does shipped, reachable content use". This one - // answers the prior question: is `groupLiquid` a field the 1.12 authors USED at all? If every - // MLIQ-carrying group in the whole archive says `0xf`, then the type-override branch never - // fires on real data and a stray non-`0xf` value on a group with no liquid is content noise, - // not a feature we are failing to implement. + // ---- the archive-wide census. ----------------------------------------------------------- + // `groupLiquid` over every group file in the archive, placed or not. let mut all_census: BTreeMap = BTreeMap::new(); let mut odd: Vec = Vec::new(); for name in chain.list()?.into_iter().map(|e| e.name) { diff --git a/crates/benilla-formats/examples/wmo_mocv.rs b/crates/benilla-formats/examples/wmo_mocv.rs index 1fbb68bc..4a3567d4 100644 --- a/crates/benilla-formats/examples/wmo_mocv.rs +++ b/crates/benilla-formats/examples/wmo_mocv.rs @@ -1,27 +1,14 @@ -//! What a WMO's surfaces are **baked** to — the per-group MOCV census behind a "this floor is black" -//! report: +//! Each WMO group's MOCV bake per batch, up-facing vertices apart from the rest, with its MOGP +//! flags, batch class and the bake after the doorway fade. The reference lights an interior surface +//! only from the bake: an INT batch draws `tex × MOCV` and a TRANS batch lerps to it by `MOCV.a`, +//! and `FixColorVertexAlpha` (`0x6c43d0`) brightens interior vertices toward white within 6.67 yd +//! of a portal to an exterior group. A model-space `x,y,z` keeps only the groups whose box holds +//! it; `--survey` prints only the fade per group. Output is Blizzard data: never commit it. //! `cargo run -p benilla-formats --example wmo_mocv -- [gN | x,y,z]` -//! -//! An interior WMO surface takes no exterior light in the reference: an INT-class batch draws -//! `tex × MOCV` and a TRANS-class batch lerps to it by `MOCV.a`, so the *only* thing between a black -//! floor and a lit one is the bake in the file — plus the runtime portal fixup the client applies -//! over it (`FixColorVertexAlpha` `0x6c43d0`, which brightens interior verts -//! toward white within 6.67 yd of an exterior-neighbour portal). A "dark floor here" report therefore -//! has exactly two shapes, told apart by numbers rather than by looking: either the file bakes it -//! dark (and the fixup is what lights it), or our reader/classifier mislabels the batch. This prints -//! both halves — the group's MOGP flags and per-batch class, and the MOCV spread of each batch's -//! vertices split by facing, so a FLOOR (n.z up) reads apart from the walls sharing its batch. -//! -//! With a model-space `x,y,z` it reports only the groups whose bounding box contains the point (a -//! `.gps` pin, inverse-transformed by the placement, names the group the director is standing in). -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. use benilla_wmo::{parse_wmo, ParsedWmo}; -/// Walk a WMO file's top-level chunks for `magic` (FourCC reversed on disk), returning its payload. -/// The examples can't use the crate-private reader, and a group file's MOGP header is the only thing -/// here that isn't already on a parsed struct. +/// The payload of a WMO file's top-level chunk `magic`, FourCC reversed as on disk. fn chunk<'a>(bytes: &'a [u8], magic: &[u8; 4]) -> Option<&'a [u8]> { let mut off = 0usize; while off + 8 <= bytes.len() { @@ -93,11 +80,7 @@ fn main() -> anyhow::Result<()> { let filter = args.next(); let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); let mut chain = benilla_formats::open_chain(&data)?; - // A `.wmo` argument is taken as a literal chain path, but only if the chain HAS it: `stormwind.wmo` - // ends in `.wmo` and is not a path, and the old "ends_with ⇒ literal" rule turned that into a hard - // read error. Read as "no groups affected" from a grepped log, it cost a wrong all-clear on - // Stormwind, Ironforge and Stratholme. Falling back to the substring search cannot mislead: the - // resolved path is printed, and a genuine miss still errors. + // A `.wmo` argument is a literal path only if the chain has it; `stormwind.wmo` is a substring. let lower = pat.to_lowercase(); let path = if lower.ends_with(".wmo") && chain.read_file(&lower).is_ok() { pat.clone() @@ -205,8 +188,7 @@ fn main() -> anyhow::Result<()> { if has_colors { "yes" } else { "ABSENT" }, group.render_batches.len(), ); - // The fade's blast radius on this group: how many authored slots it rewrites, and by how much - // — the number that says whether the doorway fixup is a targeted lift or a wash over the room. + // How many authored slots the doorway fade rewrites on this group, and by how much. if let Some(fixed) = benilla_formats::wmo_group_fixed_colors(&gbytes, &wmo_root) { let lum = |c: [f32; 3]| 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]; let (mut changed, mut before, mut after) = (0usize, 0.0f32, 0.0f32); @@ -251,7 +233,7 @@ fn main() -> anyhow::Result<()> { .vertex_indices .get(start..start + batch.count as usize) .unwrap_or(&[]); - // Split by facing: a floor's up-facing verts are what a "dark floor" report is about. + // Split by facing, so a floor's up-facing verts read apart from its walls. let (mut up, mut other) = (Stat::new(), Stat::new()); for &i in idx { let i = i as usize; @@ -274,10 +256,8 @@ fn main() -> anyhow::Result<()> { println!(" up-facing {}", up.line()); println!(" other {}", other.line()); } - // The same census *after* the reader's bright-doorway fade — what the renderer actually - // uploads. A batch that reads black above and white here is a floor the file never baked and - // the runtime fixup lights (a WMO transition corridor); one that stays black in both is a - // genuinely dark room. + // The same census after the reader's doorway fade, as the renderer uploads it: black above + // and white here is a surface only the fade lights. let subs = benilla_formats::wmo_group_submeshes(&gbytes, &wmo_root)?; println!( " --- after FixColorVertexAlpha ({} submeshes) ---", diff --git a/crates/benilla-formats/examples/wmo_ownerless_pools.rs b/crates/benilla-formats/examples/wmo_ownerless_pools.rs index 92daac53..3e470ea7 100644 --- a/crates/benilla-formats/examples/wmo_ownerless_pools.rs +++ b/crates/benilla-formats/examples/wmo_ownerless_pools.rs @@ -1,49 +1,14 @@ -//! **Which buildings' embedded pools have no visibility authority**, over the whole shipped corpus: -//! `cargo run -p benilla-formats --example wmo_ownerless_pools`. +//! Which WMO roots have embedded pools but no `WmoPortalInstance` to own them. A placement gets an +//! instance only with a portal graph or an authored `WMOAreaTable` identity, the spawn site's +//! `has_portals || m.wmo_id != 0` (`terrain_stream::spawn`), and a pool's `WmoGroupVis`/`WmoRoom` +//! scope is keyed to it. No shipped root is both portal-less and unnamed: the only `wmoID == 0` +//! root, `pvp_alterac_ent01.wmo`, has portals. +//! `cargo run -p benilla-formats --example wmo_ownerless_pools` //! -//! A placed WMO gets a `WmoPortalInstance` only when it has a portal graph **or** an authored -//! `WMOAreaTable` identity — `has_portals || m.wmo_id != 0`, the spawn site in -//! `benilla-world/src/terrain_stream/spawn/mod.rs`. Everything about a building's *rooms* hangs off -//! that instance: it is the per-placement entity the group-visible set lives on, and it is what a -//! pool's `WmoGroupVis`/`WmoRoom` scope is keyed to. -//! -//! So a root that has **neither** portals **nor** a wmoID is instance-less — and until decision 1652 -//! that meant its embedded MLIQ pools were tagged with *neither* `WmoGroupVis` nor `ExteriorScene`, -//! i.e. no system ever wrote their `Visibility` at all, while the same placement's walls were -//! tagged `ExteriorScene` unconditionally and window-culled normally. A sealed well room drawing a -//! disc of water in mid-air is what that *would* look like from outside. -//! -//! Whether any shipped root actually falls in that cell is the question — an unreachable branch and -//! a widespread defect are the same code, and only the corpus tells them apart. -//! -//! **The answer is zero** (run 2026-08-27, decision 1652): of 815 roots, 448 carry no portal graph -//! and exactly **one** carries `wmoID == 0` — `pvp_alterac_ent01.wmo` — and that one *does* have -//! portals. The two conditions never coincide in shipped data, so the instance-less branch is dead -//! and 1652's unconditional tag on pools closes a hole no building reaches. That is worth a census -//! rather than a guess in either direction: it is the difference between a fix that earns a sighting -//! and one that must never be credited with one. -//! -//! Two facts per root, both read from the bytes the runtime reads: -//! -//! * **instance** — `MOPT` and `MOPR` both non-empty (`benilla_assets::WmoModel::portal_infos` / -//! `portal_refs`), or `MOHD.wmoID @0x20 != 0` ([`benilla_formats::wmo_root_id`], the -//! `WMOAreaTable.WMOID` key). -//! * **liquid** — any group file for which [`benilla_formats::wmo_group_liquid_mesh`] yields a -//! surface. That is the *same* call `WmoModel::group_liquids` makes, so a root counted wet here is -//! exactly a root that spawns pool entities at placement. (`resolve_shared_liquid_cells` can drop -//! one group's mesh afterwards, but only in favour of a sibling group of the same root, so it -//! cannot change a root's wet/dry verdict.) A group whose MOGP `groupLiquid` @`0x34` declares -//! whole-group submersion but carries no `MLIQ` grid (13 in the archive, decision 1000) is **not** -//! counted: it spawns no pool entity, so there is nothing for an instance to own. The liquid-group -//! total cross-checks against `wmo_liquid_arms`, which reaches the same groups from the other side -//! — the listfile's `_NNN` group files rather than `MOHD.nGroups` per root. -//! -//! Roots are also split by whether the world actually **places** them — an unplaced WMO can never be -//! stood next to, so it cannot show the defect. Placements come from every ADT's `MODF` plus the -//! WDT global-WMO of the 20 WMO-only maps (decision 0688 — scanning only ADTs would call the Deeprun -//! Tram and every instance-shaped dungeon unplaced). -//! -//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. +//! A root has liquid when [`benilla_formats::wmo_group_liquid_mesh`] yields a surface for any of +//! its groups, the call `WmoModel::group_liquids` makes; a group flooded by `groupLiquid` alone +//! spawns no pool and is not counted. Placements come from every ADT's `MODF` and each WMO-only +//! map's WDT global WMO. Output is Blizzard data: never commit it. use std::collections::{BTreeMap, BTreeSet}; use std::io::Cursor; @@ -54,38 +19,35 @@ use benilla_formats::{ /// What one root says about the two axes. struct Root { - /// `MOHD.wmoID @0x20` — the `WMOAreaTable.WMOID` key; `0` ⇒ the building has no authored identity. + /// `MOHD.wmoID` (`+0x20`), the `WMOAreaTable.WMOID` key; 0 means no authored identity. wmo_id: u32, - /// `MOPT` ∧ `MOPR` both non-empty — the spawn site's `has_portals`. + /// `MOPT` and `MOPR` both non-empty, the spawn site's `has_portals`. has_portals: bool, /// Group files the root declares (`MOHD.nGroups`). groups: u32, /// Groups whose MLIQ resolves to a liquid surface. liquid_groups: u32, - /// Wet cells across those surfaces — the size of what draws. wet_tiles: u32, - /// Maps the root is placed on (empty ⇒ never placed). + /// Maps the root is placed on; empty if never placed. maps: BTreeSet, } impl Root { - /// The spawn site's test, verbatim: no instance ⇒ nothing owns this building's rooms. + /// The spawn site's test: without an instance nothing owns the building's rooms. fn has_instance(&self) -> bool { self.has_portals || self.wmo_id != 0 } } -/// Normalize a chain path the way the MPQ hash compares them, so ADT `MWMO` names and listfile -/// names land on the same key. +/// A chain path normalised as the MPQ hash compares it, so ADT `MWMO` and listfile names agree. fn key(name: &str) -> String { name.replace('/', "\\").to_ascii_lowercase() } -/// Every WMO root the world actually places, mapped to the maps it appears on: ADT `MODF` first, -/// then the WDT global-WMO that IS the world on a WMO-only map (decision 0688). +/// Every placed WMO root and the maps it is on: ADT `MODF`s, then each WMO-only map's WDT global. fn placed_roots(chain: &Chain) -> anyhow::Result>> { let mut placed: BTreeMap> = BTreeMap::new(); - // `World\Maps\\_XX_YY.adt` — the map name is the third path component. + // `World\Maps\\_XX_YY.adt`: the map name is the third path component. let map_of = |name: &str| key(name).split('\\').nth(2).unwrap_or("?").to_string(); let names: Vec = chain.list()?.into_iter().map(|e| e.name).collect(); @@ -137,13 +99,8 @@ fn main() -> anyhow::Result<()> { let placed = placed_roots(&chain)?; eprintln!("{} distinct WMO roots placed in the world", placed.len()); - // A group file's stem ends `_NNN`; a root's does not. The chain lists both. - // - // The listfile is NOT a complete index (`Chain::list`'s own doc: a file absent from every - // archive's listfile is readable by name but not enumerated), so the scanned set is the UNION of - // the listed roots and every root an ADT/WDT actually references. A placed-but-unlisted root is - // exactly the one this census must not miss, and the two counts printed below say whether the - // union ever added anything. + // Roots are the listed `.wmo` files whose stem lacks `_NNN`, plus every placed root the + // listfile misses: a file absent from every listfile is readable but not enumerated. let listed: BTreeSet = chain .list()? .into_iter() @@ -177,8 +134,7 @@ fn main() -> anyhow::Result<()> { let Ok(bytes) = chain.read(k) else { continue; }; - // `parse_wmo_root` is the group-count authority (`MOHD.nGroups`); a file that fails it is - // not a root and has no rooms to own. + // `parse_wmo_root` gives the group count (`MOHD.nGroups`); a file it rejects has no rooms. let Ok(root) = parse_wmo_root(&bytes) else { continue; }; @@ -243,9 +199,7 @@ fn main() -> anyhow::Result<()> { .count(); let liquid_groups: u32 = census.values().map(|r| r.liquid_groups).sum(); - // The two axes on their own. The cross-tab cannot say WHICH half of `has_portals || wmo_id != 0` - // carries a root, and "no root is instance-less" is a claim that has to be falsifiable from the - // same run rather than taken on the strength of one collapsed cell. + // Each axis alone: the cross-tab cannot say which half of `has_portals || wmo_id != 0` holds. let portal_less = census.values().filter(|r| !r.has_portals).count(); let unnamed = census.values().filter(|r| r.wmo_id == 0).count(); println!( @@ -258,9 +212,7 @@ fn main() -> anyhow::Result<()> { {liquid_groups} liquid groups in all" ); - // Every root on the rarer axis, named: `wmoID == 0` is what makes the instance depend on the - // portal graph at all, so the handful that have it are the whole falsifiable surface of the - // cross-tab's empty cell. + // Every `wmoID == 0` root by name: only these depend on their portal graph for an instance. println!("\nroots with wmoID 0:"); for (path, r) in census.iter().filter(|(_, r)| r.wmo_id == 0) { println!( diff --git a/crates/benilla-formats/src/anim_data.rs b/crates/benilla-formats/src/anim_data.rs index bfb54aac..1f0dcdde 100644 --- a/crates/benilla-formats/src/anim_data.rs +++ b/crates/benilla-formats/src/anim_data.rs @@ -1,32 +1,9 @@ -//! `AnimationData.dbc` — the per-animation policy row behind the sheath reconcile (decision 0080) -//! and, later, missing-clip fallback resolution. -//! -//! Layout — VERIFIED against build 5875 (header + full 208-row decode with name joins, -//! 2026-07-03): **208 × 7 × 28 B**: `ID(0), Name(str, 1), WeaponFlags(2), BodyFlags(3), _col4(4), -//! _col5(5), Fallback(6)`. -//! -//! - **WeaponFlags (col 2)** — the sheath-reconcile bits the client tests on every `PlayAnimation` -//! (`0x5fdf80`). The identification is the binary's: the reconcile indexes the -//! `WowClientDB` cache (`0xc0e070`, loader-asserted 7 columns × 28 B against -//! `DBFilesClient\AnimationData.dbc`) by the current sequence's AnimationData id, and all three -//! flag tests read row offset `+0x8` = column 2. -//! The bits: `4` force-stow (casts, Swim 42–45, Mount 91, -//! SitChair 102–104, Loot 50), `0x10` force-stow (every Emote, AttackUnarmed 16/117, -//! SitGround 96–98, Kneel 114–116, AttackBow/Rifle/Thrown), `0x20` force-draw-melee (armed -//! attacks 17–19/85–88, Ready1H/2H/2HL 26–28, specials, FishingCast/Loop 133–134). The column's -//! exact 5875 value set is `{0, 4, 16, 20, 32}` — precisely the tested bit triple. -//! - **Fallback (col 6)** — identified from the data (the chains are unambiguous: -//! Attack2H→Attack1H→AttackUnarmed, Sprint→Run, JumpLandRun→Run, Sleep→SleepDown, Drowned→Drown, -//! WalkBackwards→Walk, StealthWalk→Walk; col 5 read as a fallback yields nonsense — Attack2H→Stop, -//! Sleep→SpellCast). **Not the primary runtime missing-clip mechanism** (decision 0082, amending -//! this file's earlier reading): the real client's per-id fallback for ids `< 203` is a single -//! indexed read of the M2's own baked `PlayableAnimationLookup` (a per-model precomputed cache of -//! *this* column's walk against that model's actual sequence set — see -//! `benilla_formats::PlayableAnim` / `benilla_assets::ModelAnimations::resolve`). This column is -//! PATH 2 of that resolver (ids ≥ 203 only, `AnimationData.dbc`'s 208 rows) and the *source* the -//! baked tables were computed from. `0` = fall back to Stand. -//! - Cols 4/5 are unidentified here (4 reads priority-like — 500 on Death/Dead/Drown, 10 on -//! locomotion; 5 flag-like) and are not exposed. +//! `AnimationData.dbc`, the per-animation policy: 208 rows of `ID`, `Name`, `WeaponFlags`, +//! `BodyFlags`, two unidentified columns and `Fallback`. The reference's sheath reconcile +//! (`0x5fdf80`, on every `PlayAnimation`) reads `WeaponFlags` at row `+0x8` of the `0xc0e070` cache +//! for the requested id, not a model's substitute: `4` and `0x10` stow, `0x20` draws melee, and +//! 5875 sets no other bit. `Fallback` is walked only for ids past a model's baked +//! `PlayableAnimationLookup` (203 and up), which was computed from it. use std::collections::HashMap; @@ -39,7 +16,7 @@ use crate::Chain; /// One animation's policy row. #[derive(Clone, Copy)] pub struct AnimEntry { - /// The sheath-reconcile bits (see the module doc). `0` for most rows. + /// The sheath-reconcile bits (col 2). pub weapon_flags: u32, /// The substitute animation id when a model lacks this clip (`0` = Stand). pub fallback: u16, @@ -48,15 +25,13 @@ pub struct AnimEntry { /// `AnimationData.dbc` id → its policy row. pub struct AnimDataCatalog { rows: HashMap, - /// id → the row's Name string (col 1, `"Attack1H"`) — the human face for debug readouts - /// (the inspector's anim line). Beside [`AnimEntry`] so the policy row stays `Copy`. + /// The Name column (col 1, `"Attack1H"`) for debug readouts, kept apart so [`AnimEntry`] + /// stays `Copy`. names: HashMap, } impl AnimDataCatalog { - /// Build a catalog directly from rows — the synthetic-table entry point for tests exercising - /// missing-clip resolution's PATH 2 walk (decision 0082, `benilla_assets::ModelAnimations::resolve`) - /// without a real `AnimationData.dbc`. + /// A catalog of synthetic rows with no names, for tests without an `AnimationData.dbc`. pub fn from_rows(rows: impl IntoIterator) -> Self { Self { rows: rows.into_iter().collect(), @@ -64,13 +39,13 @@ impl AnimDataCatalog { } } - /// The sheath-reconcile WeaponFlags for an animation id (`0` for an unknown id — no policy). + /// The WeaponFlags for an animation id, `0` (no policy) for an unknown one. pub fn weapon_flags(&self, id: u16) -> u32 { self.rows.get(&id).map_or(0, |r| r.weapon_flags) } - /// The fallback animation for a model lacking clip `id` — `None` when the row has none - /// (fallback 0 = Stand, left to the caller's own id-list fallbacks) or the id is unknown. + /// The fallback for a model lacking clip `id`; `None` for Stand (`0`), which the caller + /// resolves itself. pub fn fallback(&self, id: u16) -> Option { self.rows .get(&id) @@ -78,8 +53,7 @@ impl AnimDataCatalog { .filter(|&f| f != 0 && f != id) } - /// The row's Name string (`"Run"`, `"Attack1H"`) — `None` for an unknown id or a - /// synthetic-table catalog ([`Self::from_rows`] keeps no names). + /// The row's Name (`"Run"`); a [`Self::from_rows`] catalog has none. pub fn name(&self, id: u16) -> Option<&str> { self.names.get(&id).map(String::as_str) } @@ -134,9 +108,7 @@ pub fn load_anim_data_catalog(chain: &mut Chain) -> Result { mod tests { use super::*; - /// The real 5875 table: the WeaponFlags value set is exactly the reconcile's tested bit - /// triple, the decision-0080 spot rows carry the recorded bits, and the fallback chains - /// resolve (Attack2H→Attack1H, Sprint→Run). Skips without client data. + /// The 5875 table: WeaponFlags within the reconcile's three bits, spot rows, fallback chains. #[test] fn real_animation_data_decodes() { let data = crate::wow_data_or_skip!(); @@ -144,7 +116,6 @@ mod tests { let cat = load_anim_data_catalog(&mut chain).expect("load AnimationData"); assert_eq!(cat.len(), 208); - // The whole column stays within the tested bit triple {0, 4, 0x10, 0x14, 0x20}. for (&id, row) in &cat.rows { assert!( matches!(row.weapon_flags, 0 | 4 | 0x10 | 0x14 | 0x20), @@ -153,7 +124,6 @@ mod tests { ); } - // Decision 0080's transcribed highlights. assert_eq!(cat.weapon_flags(42), 4, "Swim force-stows"); assert_eq!(cat.weapon_flags(91), 4, "Mount force-stows"); assert_eq!(cat.weapon_flags(102), 4, "SitChairLow force-stows"); @@ -169,18 +139,16 @@ mod tests { assert_eq!(cat.weapon_flags(133), 0x20, "FishingCast force-draws"); assert_eq!(cat.weapon_flags(0), 0, "Stand carries no policy"); - // The ranged-handling trio the reconcile exempts (0x69/0x6a/0x70). + // The ranged-handling trio the reconcile exempts. for id in [105, 106, 112] { assert_eq!(cat.weapon_flags(id), 0, "Load* anims carry no policy"); } - // The name column (the inspector's anim line). assert_eq!(cat.name(0), Some("Stand")); assert_eq!(cat.name(5), Some("Run")); assert_eq!(cat.name(17), Some("Attack1H")); assert_eq!(cat.name(255), None, "unknown id has no name"); - // The fallback chains. assert_eq!(cat.fallback(18), Some(17), "Attack2H → Attack1H"); assert_eq!(cat.fallback(17), Some(16), "Attack1H → AttackUnarmed"); assert_eq!(cat.fallback(143), Some(5), "Sprint → Run"); diff --git a/crates/benilla-formats/src/area_poi.rs b/crates/benilla-formats/src/area_poi.rs index d390895d..e5b4a6d1 100644 --- a/crates/benilla-formats/src/area_poi.rs +++ b/crates/benilla-formats/src/area_poi.rs @@ -1,27 +1,8 @@ -//! `AreaPOI.dbc` — the world-map's named points of interest: inns, flight masters, dungeon -//! entrances, battleground nodes — everything the minimap's nearest-3 POI blips (decision 0203 -//! phase 3) and the world map's POI icons draw from. -//! -//! **Client-only: vmangos carries no struct for this table** (never loaded server-side). Layout -//! pinned by inspection this session (header + full 339-row decode, cross-checked against a -//! community reference struct for the same build, 2026-07-07): **339 × 29 × 116 B**: -//! `ID(0), Importance(1), Icon(2), FactionID(3), Pos[3](4-6, world x/y/z), ContinentID(7), -//! Flags(8), AreaID(9), Name_lang(10, enUS string) + 7 other-locale slots(11-17) + NameFlags(18), -//! Description_lang(19, enUS string) + 7 other-locale slots(20-26) + DescriptionFlags(27), -//! WorldStateID(28)` — the field the task brief's 28-column enumeration left unaccounted for. -//! -//! Every field past `Pos`/`Name`/`Description` was proven by scanning all 339 rows, not assumed: -//! `Importance` ∈ `{0,2,3,4}` and `Icon` takes 43 distinct small values (both enum-shaped); -//! `FactionID` ∈ `{0,83,84}` (a `FactionTemplate` FK used for only the faction-tinted rows); -//! `ContinentID` ∈ `{0,1,30,37,529}` — Azeroth/Kalimdor plus three battleground map ids; -//! `AreaID` ranges `−1..=3427` including both `−1` (raw, stored as `u32::MAX` — the -//! `WMOAreaTable`-style "no group" sentinel) and a distinct `0`; every row's `Pos.x`/`Pos.y` falls -//! well inside `±17066` (the world-map half-extent); every row's enUS `Name_lang` is non-empty; -//! and **field 28 is `0` on 209 rows and a small nonzero id on the other 130**, each of those 130 -//! landing in one of a handful of tight numeric bands (`1301-1304`, `1325-1340`, …) — exactly the -//! shape of a `WorldState` id, confirmed on a named example: id 1613 "Stables" (an Arathi Basin -//! node, `ContinentID` 529) carries `WorldStateID` 1770 and `Description_lang` "In Conflict", the -//! live capture-state label a BG node's map icon shows. +//! `AreaPOI.dbc`, the named points of interest (inns, flight masters, dungeon entrances, +//! battleground nodes) behind the minimap's landmark blips and the world map's icons. Client-only: +//! vmangos never loads it. 339 rows of 29 columns (116 B): `ID`, `Importance`, `Icon`, +//! `FactionID`, `Pos[3]`, `ContinentID`, `Flags`, `AreaID`, then `Name` and `Description` as +//! 9-column loc-strings (enUS, 7 other locales, flags), then `WorldStateID`. use std::collections::HashMap; @@ -33,40 +14,34 @@ use crate::Chain; const AREA_POI: &str = "DBFilesClient\\AreaPOI.dbc"; -/// One `AreaPOI.dbc` row — a named world-map point of interest. +/// One `AreaPOI.dbc` row. #[derive(Clone, Debug)] pub struct AreaPoi { - /// Map-icon prominence tier (`{0,2,3,4}` in 5875 — higher shows at more zoomed-out levels). + /// Prominence tier, `{0, 2, 3, 4}` in 5875; the minimap ranks its landmark arrows by it. pub importance: u32, - /// The map-icon art index (43 distinct icons in 5875). + /// The map icon, a cell of `POIIcons.blp`. pub icon: u32, - /// `FactionTemplate.dbc` FK for a faction-tinted icon; `0` = untinted (the common case). + /// `FactionTemplate.dbc` id for a faction-tinted icon, `0` for none. pub faction_id: u32, /// World position `(x, y, z)`. pub pos: [f32; 3], - /// The map id this POI's `pos` is expressed in (Azeroth `0`, Kalimdor `1`, or a - /// battleground/instance map id). + /// The map `pos` is in: `0` Azeroth, `1` Kalimdor, or a battleground map. pub continent_id: u32, pub flags: u32, - /// `AreaTable.dbc` id, or `u32::MAX` (raw `−1`) for a continent-wide POI — distinct in this - /// data from a plain `0` (see the module doc). + /// `AreaTable.dbc` id, or `u32::MAX` (raw `-1`) for a continent-wide POI. pub area_id: u32, - /// enUS display name (locale slot 0) — non-empty on every 5875 row. + /// enUS display name (locale slot 0). pub name: String, - /// enUS description (locale slot 0) — empty on the ~2/3 of rows with no live status text. + /// enUS live status text (`"In Conflict"`), empty on most rows. pub description: String, - /// `WorldState` id driving this POI's live label/icon (e.g. a battleground node's capture - /// state); `0` = no live state (209 of 339 rows). + /// The `WorldState` id driving the live label and icon (a battleground node's capture + /// state), `0` for none. pub world_state_id: u32, } -/// `AreaPOI.dbc` — every row in **file order**, plus the id index. -/// -/// File order is not tidiness: the reference's world-map landmark builder `0x4a67a0` walks the -/// whole table by record (`[0xc0e054]`, count `[0xc0e058]`, stride `0x74`) and appends survivors -/// in that order, so the landmark *index* a Lua `GetMapLandmarkInfo(i)` reads is the DBC's own -/// order. A hash iteration would also make our landmark list a different permutation every frame, -/// which the world map's change-diff would read as a change and repaint on. +/// Every `AreaPOI.dbc` row in file order, plus an id index. The order is the reference's: its +/// landmark builder `0x4a67a0` walks the table by record (`[0xc0e054]`, count `[0xc0e058]`), so +/// `GetMapLandmarkInfo(i)` indexes in DBC order. pub struct AreaPoiCatalog { rows: Vec<(u32, AreaPoi)>, by_id: HashMap, @@ -77,9 +52,7 @@ impl AreaPoiCatalog { self.by_id.get(&id).map(|&i| &self.rows[i].1) } - /// Every row in **file order** — the reference builder's walk (see the type doc). The - /// nearest-3 minimap selection (decision 0203 phase 3) filters this by `continent_id` + - /// distance itself; the world map's landmark pass runs the `0x4a67a0` gate chain over it. + /// Every row in file order. pub fn rows(&self) -> impl Iterator { self.rows.iter().map(|(id, poi)| (*id, poi)) } @@ -93,9 +66,6 @@ impl AreaPoiCatalog { } } -/// 29 fields per the module doc: the two loc-string blocks are 9 dwords each (a string + 7 -/// other-locale string slots + a flags dword), matching the `TaxiNodes` schema pattern -/// (`crate::schema_for`). fn schema() -> Schema { let mut s = Schema::new("AreaPOI"); for name in ["ID", "Importance", "Icon", "FactionID"] { @@ -151,9 +121,7 @@ pub fn load_area_poi_catalog(chain: &mut Chain) -> Result { mod tests { use super::*; - /// The real 5875 table proves the layout: every row's enUS name is readable text, continent - /// ids and world positions land in the expected ranges, and the pinned Arathi Basin "Stables" - /// example resolves its `WorldStateID` + live status description. Skips without client data. + /// The 5875 layout: readable names, positions in range, and Arathi Basin's Stables node. #[test] fn real_area_poi_layout_sanity() { let data = crate::wow_data_or_skip!(); @@ -172,7 +140,6 @@ mod tests { "world x/y within the map half-extent: {:?}", poi.pos ); - // 0/1 are the continents; the rest are battleground/instance map ids — all small. assert!(poi.continent_id < 10_000, "continentId is a small map id"); if poi.world_state_id != 0 { worldstate_nonzero += 1; @@ -184,7 +151,6 @@ mod tests { "a meaningful share of rows carry a live WorldStateID: {worldstate_nonzero}" ); - // The pinned Arathi Basin "Stables" node. let stables = cat.get(1613).expect("id 1613 (Stables)"); assert_eq!(stables.name, "Stables"); assert_eq!(stables.continent_id, 529, "Arathi Basin's map id"); diff --git a/crates/benilla-formats/src/area_sound.rs b/crates/benilla-formats/src/area_sound.rs index 87400f2b..8aed2517 100644 --- a/crates/benilla-formats/src/area_sound.rs +++ b/crates/benilla-formats/src/area_sound.rs @@ -1,20 +1,7 @@ -//! The zone→audio data plane: **AreaTable** (the zone/subzone row the MCNK `areaId` points at) -//! joined to **ZoneMusic** / **SoundAmbience** / **ZoneIntroMusicTable** (decision 0070, slice 1). -//! -//! Layouts — VERIFIED against build 5875 (header + row decodes, 2026-07-02): -//! - `AreaTable.dbc` **1081 × 25 × 100 B**: `ID(0), ContinentID(1), ParentAreaID(2), AreaBit(3), -//! Flags(4), SoundProviderPref(5), SoundProviderPrefUnderwater(6), AmbienceID(7), ZoneMusic(8), -//! IntroSound(9), ExplorationLevel(10), AreaName_lang(11..19), FactionGroupMask(20), -//! LiquidTypeID[4](21..24)`. Spot-check row 12: "Elwynn Forest", Ambience 35, ZoneMusic 1. -//! NOTE the wowdev-wiki 5875 struct appends TBC+ fields (28 cols) — **wrong**; 25 verified -//! (decision 0070). -//! - `ZoneMusic.dbc` **99 × 8 × 32 B**: `ID, SetName(str), SilenceMin[2], SilenceMax[2], -//! Sounds[2]` — the `[2]` arrays are `[day, night]`, intervals in **ms**, `Sounds` → -//! SoundEntries kits (type 28). Row 1: "Zone-Forest", 180 000–300 000 ms, kit 2523/2523. -//! - `ZoneIntroMusicTable.dbc` **43 × 5 × 20 B**: `ID, Name(str), SoundID, Priority, -//! MinDelayMinutes`. Row 61: "Valley of Heroes", 2541, 1, 60. -//! - `SoundAmbience.dbc` **68 × 3 × 12 B**: `ID, AmbienceID[2] = [day, night]` → looping -//! SoundEntries kits (type 50). Row 22: day 4163 / night 4204. +//! The zone audio tables: `AreaTable` (the row an MCNK `areaId` names) joined to `ZoneMusic`, +//! `SoundAmbience` and `ZoneIntroMusicTable`. `AreaTable.dbc` has 25 columns in 5875, not the 28 +//! of later builds: the eleven `area_schema` names, `AreaName_lang` (11-19), `FactionGroupMask` +//! and `LiquidTypeID[4]`. use std::collections::HashMap; @@ -27,19 +14,18 @@ use crate::dbc::{parse, str_at, u32_at}; /// The audio-relevant slice of one `AreaTable` row. pub struct AreaEntry { pub id: u32, - /// Parent zone (subzone rows inherit unset audio fields from it; 0 = a top-level zone). + /// The parent zone, `0` for a top-level one; a subzone inherits its unset audio ids. pub parent: u32, - /// `SoundAmbience.dbc` FK (0 = inherit/none). + /// `SoundAmbience.dbc` id, `0` to inherit. pub ambience: u32, - /// `ZoneMusic.dbc` FK (0 = inherit/none). + /// `ZoneMusic.dbc` id, `0` to inherit. pub zone_music: u32, - /// `ZoneIntroMusicTable.dbc` FK (0 = inherit/none). + /// `ZoneIntroMusicTable.dbc` id, `0` to inherit. pub intro_sound: u32, - /// `SoundProviderPreferences.dbc` FK — the dry-land reverb preset (0 = inherit/none; only - /// 8 areas in 1.12, all dungeon floors — interiors carry theirs on `WMOAreaTable`). + /// `SoundProviderPreferences.dbc` id of the dry-land reverb preset, `0` to inherit; + /// interiors carry theirs on `WMOAreaTable`. pub sound_provider: u32, - /// `SoundProviderPreferences.dbc` FK while the listener is submerged (pref 11 "Underwater" - /// on 568 areas; 0 = inherit/none). + /// The preset while the listener is submerged (11 is "Underwater"), `0` to inherit. pub sound_provider_underwater: u32, /// The zone/subzone display name (enUS column). pub name: String, @@ -49,24 +35,24 @@ pub struct AreaEntry { pub struct ZoneMusicEntry { pub id: u32, pub set_name: String, - /// Silence between tracks, milliseconds, uniform in `[min, max]`. + /// Silence between tracks in ms, uniform in `[min, max]`. pub silence_min: [u32; 2], pub silence_max: [u32; 2], - /// SoundEntries kits (type 28) — the track pool per phase. + /// `SoundEntries` kits (type 28), the track pool per phase. pub sounds: [u32; 2], } -/// One `ZoneIntroMusicTable` row — the entry fanfare. +/// One `ZoneIntroMusicTable` row, the fanfare on entering an area. pub struct ZoneIntroEntry { pub id: u32, pub sound_id: u32, /// Higher wins when nested areas compete. pub priority: u32, - /// Replay throttle, minutes. + /// Replay throttle in minutes. pub min_delay_minutes: u32, } -/// One `SoundAmbience` row: the looping ambience kits, `[day, night]`. +/// One `SoundAmbience` row: the looping ambience kits (type 50), `[day, night]`. pub struct SoundAmbienceEntry { pub id: u32, pub kits: [u32; 2], @@ -80,7 +66,7 @@ pub struct AreaSoundCatalog { ambience: HashMap, } -/// An area's fully-resolved audio identity (after the parent walk). +/// An area's audio after the parent walk. pub struct AreaAudio<'a> { pub area_name: &'a str, pub music: Option<&'a ZoneMusicEntry>, @@ -99,20 +85,18 @@ impl AreaSoundCatalog { self.zone_music.get(&id) } - /// A `ZoneIntroMusicTable` row by id (the WMO interior override path resolves intros - /// directly — `WMOAreaTable.IntroSound` — instead of through an area row). + /// A `ZoneIntroMusicTable` row by id, for a `WMOAreaTable.IntroSound` interior override. pub fn intro(&self, id: u32) -> Option<&ZoneIntroEntry> { self.intros.get(&id) } - /// A `SoundAmbience` row by id (the WMO interior override path). + /// A `SoundAmbience` row by id, for a WMO interior override. pub fn ambience_row(&self, id: u32) -> Option<&SoundAmbienceEntry> { self.ambience.get(&id) } - /// Resolve an `areaId` to its audio rows, walking `ParentAreaID` for any FK that is 0 on the - /// subzone row (the standard vanilla inheritance: a subzone without its own music plays the - /// zone's). Cycle-guarded; `None` fields mean "no music/ambience anywhere up the chain". + /// An `areaId`'s audio rows, each id left `0` taken from the nearest ancestor up + /// `ParentAreaID`: a subzone without its own music plays its zone's. pub fn resolve(&self, area_id: u32) -> Option> { let first = self.areas.get(&area_id)?; let (mut music, mut intro, mut ambience) = (None, None, None); @@ -325,10 +309,7 @@ pub fn load_area_sound_catalog(chain: &mut Chain) -> Result { mod tests { use super::*; - /// End-to-end on the **real** 5875 tables: Elwynn Forest (area 12) resolves to the - /// "Zone-Forest" music set (3–5 min silence, kit 2523) and ambience 35; a subzone with its - /// own row zeroes (Crystal Lake 87, parent 12) inherits Elwynn's music through the parent - /// walk. Skips without client data. + /// The 5875 tables: Elwynn Forest (area 12) and a subzone inheriting its music. #[test] fn real_area_chain_resolves_elwynn() { let data = crate::wow_data_or_skip!(); @@ -355,7 +336,6 @@ mod tests { assert_eq!(music.sounds, [2523, 2523]); assert!(elwynn.ambience.is_some(), "Elwynn has ambience"); - // A subzone of Elwynn inherits through the parent walk when its own FKs are 0. let sub = cat .areas .values() diff --git a/crates/benilla-formats/src/area_table.rs b/crates/benilla-formats/src/area_table.rs index 1241b491..a07f69cb 100644 --- a/crates/benilla-formats/src/area_table.rs +++ b/crates/benilla-formats/src/area_table.rs @@ -1,20 +1,6 @@ -//! `AreaTable.dbc` — the area catalog: id → (map, parent zone, explore bit, name). The world map -//! (decision 0203 phase 2) resolves the player's MCNK `areaId` to its **top-level zone** through -//! the parent chain here (`SetMapToCurrentZone`), and labels zones with the localized `AreaName` -//! (the WorldMapArea string is the art *folder* — "Elwynn" — while the display name lives here — -//! "Elwynn Forest"). Phase 3's exploration overlays key off [`AreaTableRow::explore_flag`]. -//! -//! Layout — VERIFIED against build 5875 (2026-07-07, real-row decode: Northshire Valley id 9 → -//! zone 12 = Elwynn Forest; Booty Bay id 35 → zone 33 = Stranglethorn Vale; cross-checked against -//! mangoszero's `AreaTableEntry`): **25 × u32 cols**: `ID(0), MapID(1), ZoneID(2 — the parent -//! area, 0 = this row IS a top-level zone), ExploreFlag(3), Flags(4), … ExplorationLevel(10, the -//! binary's `+0x28`), AreaName(11)` + loc block, `FactionGroupMask(20)`. Sibling readers of the -//! same file: `quest_headers.rs` (id → name only), `area_sound.rs` (the audio columns). -//! -//! The PvP columns (zone-splash arc, decision 0287) — census of the real 5875 table: -//! `FactionGroupMask` is 2/4/0 (Alliance/Horde/neither) on zone rows and ~always 0 on subzone -//! rows; `Flags` bit `0x80` marks exactly the three FFA duel pits (Battle Ring 2177, The Rumble -//! Cage 2857, The Maul 3217 + its UNUSED twin) — *not* the enclosing "Gurubashi Arena" row. +//! `AreaTable.dbc`, the area catalog. The world map walks an MCNK `areaId` up the parent chain to +//! its top-level zone (`SetMapToCurrentZone`) and labels it with the localized `AreaName` +//! ("Elwynn Forest"; `WorldMapArea` holds the art folder, "Elwynn"). use std::collections::HashMap; @@ -26,28 +12,21 @@ use crate::dbc::{parse, str_at, u32_at}; const AREA_TABLE: &str = "DBFilesClient\\AreaTable.dbc"; -/// One `AreaTable.dbc` row (the columns the map arc reads). +/// One `AreaTable.dbc` row, the columns the map reads. #[derive(Clone, Debug)] pub struct AreaTableRow { /// `Map.dbc` id the area lives on. pub map_id: u32, - /// The parent area's id — `0` means this row is itself a top-level zone. + /// The parent area's id; `0` means this row is itself a top-level zone. pub zone_id: u32, - /// The exploration bit index (`PLAYER_EXPLORED_ZONES` bitset / `WorldMapOverlay` join, - /// phase 3). + /// The exploration bit index into `PLAYER_EXPLORED_ZONES`, also the `WorldMapOverlay` join. pub explore_flag: u32, - /// Area flags (col 4). Bit `0x80` = FFA duel pit ("PvP Area" / `isArena`, decision 0287); - /// bit `0x1` = snow; capitals carry `0x138`. + /// Bit `0x80` marks an FFA duel pit row (not its arena's), `0x1` snow; capitals carry `0x138`. pub flags: u32, - /// `FactionGroup.dbc` mask of the owning side (col 20): 2 = Alliance, 4 = Horde, 0 = neither - /// (contested — or a subzone row deferring to its zone). + /// `FactionGroup.dbc` mask of the owner: 2 Alliance, 4 Horde, 0 contested or a subzone row. pub faction_group_mask: u32, - /// `ExplorationLevel` (col 10, the binary's `AreaTable+0x28`) — **signed**, and read only as - /// `>= 0` vs `< 0`. It is the world-map landmark builder's exploration gate: a landmark whose - /// `AreaID` lands on a row with `>= 0` here stays hidden until the player has explored that - /// area ([`Self::explore_flag`]'s bit); `-1` exempts it (`0x4a6890`–`0x4a68f3`). The 5875 table - /// is `-1` on exactly one row and `>= 0` on the other 1080, so in practice the gate applies - /// wherever an `AreaID` resolves at all. + /// `ExplorationLevel` (`AreaTable+0x28`), read for its sign: a world-map landmark on a row + /// `>= 0` stays hidden until the area is explored, `-1` exempts it (`0x4a6890`-`0x4a68f3`). pub exploration_level: i32, /// The localized display name ("Elwynn Forest"). pub name: String, @@ -59,9 +38,7 @@ pub struct AreaTableCatalog { } impl AreaTableCatalog { - /// A catalog over rows given directly, rather than read off the chain — for a caller that - /// needs a table of exactly known shape (the map-arc gate tests build two-row ones, so that - /// what a gate does with `ExplorationLevel -1` is asserted rather than hoped for). + /// A catalog over rows given directly, for tests that need a table of known shape. pub fn from_rows(rows: Vec<(u32, AreaTableRow)>) -> Self { Self { by_id: rows.into_iter().collect(), @@ -72,14 +49,12 @@ impl AreaTableCatalog { self.by_id.get(&id) } - /// The display name for `id`, or `None`. + /// The localized display name for `id`. pub fn name(&self, id: u32) -> Option<&str> { self.by_id.get(&id).map(|r| r.name.as_str()) } - /// The top-level zone containing `area_id`: walk the `zone_id` parent chain until a row whose - /// parent is 0 (possibly `area_id` itself). `None` for an unknown id. Depth-guarded — the - /// 5875 chains are 1–2 deep, anything deeper is data corruption, not geography. + /// The top-level zone above `area_id`, or itself; a chain deeper than eight is corrupt data. pub fn top_zone(&self, area_id: u32) -> Option { let mut id = area_id; for _ in 0..8 { @@ -92,24 +67,9 @@ impl AreaTableCatalog { None } - /// Is `area_id` **cold** — does a unit standing here puff visible breath? - /// - /// The client's `0x67e9c0`, byte-exact: - /// - /// ```text - /// cold ⟺ ((leaf.Flags & 0x2) || no valid parent ? leaf.Flags : parent.Flags) & 0x1 - /// ``` - /// - /// **One hop, never a chain walk** ([`Self::top_zone`] is a different question with a - /// different answer). Bit `0x1` is authored on exactly four leaf rows in 5875 — Dun Morogh, - /// Winterspring, Razorfen Downs, Naxxramas — and the single-hop inheritance is what spreads it - /// to the 45 areas that are actually cold: every sub-area of Dun Morogh and Winterspring - /// carries `0x40` (bit `0x2` clear) and therefore reads its zone's flags instead. Bit `0x2` is - /// the opt-out that makes a sub-area answer for itself. - /// - /// This is **independent of the weather**: snowfall and breath vapour share no input, and - /// there is no indoor suppression — standing in the Thunderbrew Distillery is as cold as the - /// road outside, because indoor-ness only changes *which row* resolves, not this test. + /// Whether a unit standing in `area_id` puffs visible breath (`0x67e9c0`): bit `0x1` of the + /// leaf's flags when it sets bit `0x2` or has no parent row, else of its parent's. One hop, + /// never a chain walk, and no weather or indoor input. pub fn is_cold(&self, area_id: u32) -> bool { let Some(leaf) = self.by_id.get(&area_id) else { return false; @@ -122,13 +82,7 @@ impl AreaTableCatalog { row.flags & 0x1 != 0 } - /// The id of the zone **named** `name`, case-insensitively — the reverse of [`Self::name`], - /// for the one caller that has a name and needs the id: `/who`'s `z-"Elwynn Forest"` term, - /// which goes on the wire as a zone id (decision 0668). - /// - /// Names are not unique across the table (a subzone can share its zone's name, instance rows - /// repeat), so a **top-level** row (`zone_id == 0`) wins over any other match — that is what - /// "zone" means to the caller. Linear, which is fine at one call per query. + /// The id of the area named `name` (any case) for `/who`'s `z-` term; a top-level row wins. pub fn id_for_name(&self, name: &str) -> Option { let mut fallback = None; for (id, row) in &self.by_id { @@ -143,9 +97,7 @@ impl AreaTableCatalog { fallback } - /// Every row, unordered — for the rare query that is a whole-table **scan by flag** rather - /// than a lookup by id. The chat auto-join's city-word row (`Flags & 0x200`) is the first such - /// consumer, and it is a scan in the client too. + /// Every row, unordered, for a scan by flag (the chat auto-join's `Flags & 0x200` city row). pub fn rows(&self) -> impl Iterator { self.by_id.values() } @@ -159,9 +111,6 @@ impl AreaTableCatalog { } } -/// 25 u32-wide columns; only -/// `ID/MapID/ZoneID/ExploreFlag/Flags/ExplorationLevel/AreaName/FactionGroupMask` are read (see -/// module doc). fn schema() -> Schema { let mut s = Schema::new("AreaTable"); for i in 0..25 { @@ -192,8 +141,7 @@ pub fn load_area_table_catalog(chain: &mut Chain) -> Result { continue; }; let faction_group_mask = u32_at(r, 20).unwrap_or(0); - // Signed: `-1` is the "no exploration requirement" row. Absent reads as `-1` for the - // same reason — an unreadable column must not invent a gate. + // Signed; an absent column reads as `-1`, no exploration gate. let exploration_level = u32_at(r, 10).map_or(-1, |v| v as i32); by_id.insert( id, @@ -215,9 +163,6 @@ pub fn load_area_table_catalog(chain: &mut Chain) -> Result { mod tests { use super::*; - /// The real 5875 table: the verified parent chains (Northshire → Elwynn, Booty Bay → - /// Stranglethorn), a top-level zone resolving to itself, and the display-vs-art-folder name - /// split. Skips without client data. #[test] fn real_area_table_parent_chains_and_names() { let data = crate::wow_data_or_skip!(); @@ -234,29 +179,23 @@ mod tests { assert_eq!(northshire.name, "Northshire Valley"); assert_eq!(cat.top_zone(9), Some(12)); - // A leaf two hops down: Booty Bay → Stranglethorn Vale (top-level). + // Booty Bay (35) sits under Stranglethorn Vale (33), a top-level zone. assert_eq!(cat.top_zone(35), Some(33)); - // A top-level zone resolves to itself; its display name differs from the art folder. assert_eq!(cat.top_zone(12), Some(12)); assert_eq!(cat.name(12), Some("Elwynn Forest")); - // The phase-3 explore bit is populated (Elwynn's is 126 in 5875). assert_eq!(cat.get(12).expect("Elwynn").explore_flag, 126); - // The PvP columns (decision 0287): ownership masks on zone rows… assert_eq!(cat.get(12).expect("Elwynn").faction_group_mask, 2); assert_eq!(cat.get(14).expect("Durotar").faction_group_mask, 4); assert_eq!(cat.get(33).expect("Stranglethorn").faction_group_mask, 0); - // …and the FFA-pit bit on the leaf pit rows, NOT the enclosing arena row. + // The FFA-pit bit is on the pit row, not the enclosing arena row. assert_ne!(cat.get(2177).expect("Battle Ring").flags & 0x80, 0); assert_eq!(cat.get(1741).expect("Gurubashi Arena").flags & 0x80, 0); } - /// The cold-breath predicate on the shipped table (`0x67e9c0`): bit `0x1` is authored on - /// exactly four rows, and the ONE-HOP parent inheritance is what makes the sub-areas cold. - /// This is the test that would have caught reading the leaf alone — every place a player - /// actually stands in Dun Morogh is a sub-area whose own flags are `0x40`. + /// The sub-areas carry `0x40`, so only the one-hop inheritance makes them cold. #[test] fn cold_areas_inherit_one_hop_from_their_zone() { let data = crate::wow_data_or_skip!(); @@ -274,7 +213,6 @@ mod tests { assert!(cat.is_cold(id), "{label} is cold"); } - // The sub-areas: own flags `0x40` (bit 0x1 clear, bit 0x2 clear) → they read the zone's. for (id, label) in [ (131, "Kharanos"), (132, "Coldridge Valley"), @@ -286,8 +224,8 @@ mod tests { assert!(cat.is_cold(id), "{label} inherits its zone's cold"); } - // Negative controls, including the sharp one: Gnomeregan the Dun Morogh sub-area (133) is - // cold; Gnomeregan the dungeon row (721), which is its own top-level zone, is not. + // Gnomeregan the Dun Morogh sub-area (133) is cold; the dungeon row (721), its own + // top-level zone, is not. assert!(cat.is_cold(133), "Gnomeregan the sub-area"); for (id, label) in [ (721, "Gnomeregan (dungeon)"), diff --git a/crates/benilla-formats/src/area_trigger.rs b/crates/benilla-formats/src/area_trigger.rs index 5998ddb2..9b00944a 100644 --- a/crates/benilla-formats/src/area_trigger.rs +++ b/crates/benilla-formats/src/area_trigger.rs @@ -1,18 +1,6 @@ -//! `AreaTrigger.dbc` — the world's invisible trip-wires, and the containment law that fires them. -//! -//! A trigger is a volume the *client* watches: when the player walks into one, the client sends -//! `CMSG_AREATRIGGER` naming its id and the **server** decides what that means — a teleport (every -//! instance entrance, the Darnassus/Rut'theran portals), a quest's "explore here" objective, the -//! inn's rested-XP state, a battleground's entrance list. The client owns only the geometry; it -//! never knows what any trigger does. -//! -//! Layout — 10 × 4-byte columns (record stride `0x28`, which is what the reference's own scan -//! strides by): `ID(0), MapID(1), X(2), Y(3), Z(4), Radius(5), BoxLength(6), BoxWidth(7), -//! BoxHeight(8), BoxYaw(9)`. Positions are WoW world space (the space `bevy_to_wow` produces), -//! yaw in radians. -//! -//! The containment law is [`AreaTriggerRow::contains`] — read from the reference's `0x5e22d0`, -//! which is the only place this math lives. +//! `AreaTrigger.dbc`, the world's trigger volumes. The client owns only the geometry, sending +//! `CMSG_AREATRIGGER` on entering one; the server alone decides what it does. Rows stride `0x28`, +//! as the reference's scan does; positions are WoW world space (`bevy_to_wow`), yaw in radians. use std::collections::HashMap; @@ -24,8 +12,7 @@ use crate::dbc::{f32_at, parse, u32_at}; const AREA_TRIGGER: &str = "DBFilesClient\\AreaTrigger.dbc"; -/// One `AreaTrigger.dbc` row: a sphere (`radius != 0`) or an oriented box (`radius == 0`) sitting -/// on one map, in WoW world space. +/// One `AreaTrigger.dbc` row: a sphere (`radius != 0`) or an oriented box (`radius == 0`). #[derive(Clone, Copy, Debug)] pub struct AreaTriggerRow { /// The id that goes on the wire in `CMSG_AREATRIGGER`. @@ -34,33 +21,20 @@ pub struct AreaTriggerRow { pub map_id: u32, /// Centre, WoW world space. pub position: [f32; 3], - /// Sphere radius (yd). `0` ⇒ this row is a box; see [`Self::contains`]. + /// Sphere radius in yards; `0` makes the row a box. pub radius: f32, - /// **Full** box extents along the box's own local X/Y/Z (yd) — halved by the containment test, - /// which is how the reference reads them. + /// Full box extents along its local X/Y/Z in yards; the containment test halves them. pub box_size: [f32; 3], - /// The box's yaw about world Z (radians, CCW from +X), i.e. where its local +X points. + /// The box's yaw about world Z in radians, CCW from +X: where its local +X points. pub box_yaw: f32, } impl AreaTriggerRow { - /// Is `p` (WoW world space) inside this volume? - /// - /// The reference's containment predicate `0x5e22d0`, an orchestration over gx's matrix ops — - /// the comparison it owns is what this reproduces: - /// - /// - **`radius != 0` ⇒ sphere**, tested in **3D** and **inclusively**: the reference computes - /// `Σ(centre − p)²` and returns "inside" on `radius² >= dist²`. - /// - **`radius == 0` ⇒ oriented box**: the point is carried into the box's own frame (translate - /// to the centre, rotate by **−`box_yaw`** about Z) and compared against ±half of each - /// `box_size` component, **strictly** on all six faces (`-h < l < h`). The reference does the - /// transform through gx (`0x7bdc40` load / `0x7bdd60` rotate-about-Z / `0x7bd700` apply) and - /// owns only the comparison; vmangos's `IsPointInAreaTriggerZone` reaches the same law from - /// the other side (it rotates the player by `2π − box_orientation`, which is this rotation). - /// - /// The server re-runs the same test with **5 yd of slop** before acting - /// (`MiscHandler.cpp:641`), so a marginally tight answer here still opens the door; a - /// marginally *loose* one is simply ignored. + /// Whether `p` is inside, as the reference's `0x5e22d0` tests it. A sphere is a plain 3D + /// distance check, inclusive (`radius² >= Σ(centre − p)²`); a box carries `p` into its frame + /// through gx's matrix ops (`0x7bdc40` load, `0x7bdd60` rotate by `-box_yaw`, `0x7bd700` + /// apply), strict against half of `box_size` on all six faces. The server re-tests with 5 yd + /// of slop (`MiscHandler.cpp:641`). pub fn contains(&self, p: [f32; 3]) -> bool { if self.radius != 0.0 { let d = [ @@ -72,8 +46,7 @@ impl AreaTriggerRow { return self.radius * self.radius >= dist_sq; } - // World → box-local: the box's local +X points along `box_yaw`, so the inverse rotation is - // by −yaw about Z. Z is unrotated (the volume never pitches or rolls). + // World to box-local: rotate by -yaw about Z; the volume never pitches or rolls. let (sin, cos) = (-self.box_yaw).sin_cos(); let dx = p[0] - self.position[0]; let dy = p[1] - self.position[1]; @@ -89,31 +62,25 @@ impl AreaTriggerRow { } } -/// Every `AreaTrigger.dbc` row, bucketed by map — the per-map window the reference narrows to -/// before each check (`0x5e2080` walks the map-sorted table for a `[first, end)` index range; -/// bucketing answers the same question without depending on the file staying sorted). -/// -/// **File order is preserved inside each bucket**, because the check takes the *first* containing -/// row and volumes do overlap (an instance entrance sits inside its building's larger trigger). +/// Every row bucketed by map, the window the reference narrows to (`0x5e2080`). File order holds +/// in a bucket: the check takes the first containing row, and volumes overlap. pub struct AreaTriggerCatalog { by_map: HashMap>, len: usize, } impl AreaTriggerCatalog { - /// The triggers on `map_id`, in file order (empty for a map with none). + /// The triggers on `map_id`, in file order. pub fn on_map(&self, map_id: u32) -> &[AreaTriggerRow] { self.by_map.get(&map_id).map_or(&[], |v| v.as_slice()) } - /// The **first** trigger on `map_id` containing `p` — the reference's own pick - /// (`0x5e2110` breaks its scan on the first hit). + /// The first trigger on `map_id` containing `p`, where `0x5e2110` stops its scan. pub fn first_containing(&self, map_id: u32, p: [f32; 3]) -> Option<&AreaTriggerRow> { self.on_map(map_id).iter().find(|t| t.contains(p)) } - /// A row by id (linear over its map's bucket is not available here — ids are unique table-wide, - /// so this walks every bucket; used by tests and diagnostics, never per frame). + /// A row by id, walking every bucket: for tests and diagnostics, never per frame. pub fn get(&self, id: u32) -> Option<&AreaTriggerRow> { self.by_map.values().flatten().find(|t| t.id == id) } @@ -127,7 +94,7 @@ impl AreaTriggerCatalog { } } -/// 10 u32/f32-wide columns (see module doc). +/// The 10-column `AreaTrigger.dbc` schema. pub fn area_trigger_schema() -> Schema { let mut s = Schema::new("AreaTrigger"); s.add_field(SchemaField::new("ID", FieldType::UInt32)); @@ -185,7 +152,6 @@ mod tests { } } - /// The sphere leg: 3D, and inclusive exactly on the surface (the reference's `radius² >= d²`). #[test] fn sphere_is_3d_and_inclusive() { let t = sphere(5.0); @@ -195,12 +161,11 @@ mod tests { "exactly on the surface is in" ); assert!(!t.contains([15.1, 20.0, 30.0])); - // Z counts: straight up out of the sphere is out, which a 2D test would miss. + // Z counts: straight up out of the sphere is out. assert!(!t.contains([10.0, 20.0, 36.0])); assert!(t.contains([10.0, 20.0, 34.0])); } - /// The box leg: half-extents, strict faces, and the yaw actually rotating the volume. #[test] fn box_is_half_extents_strict_and_yawed() { let axis_aligned = AreaTriggerRow { @@ -214,12 +179,10 @@ mod tests { assert!(axis_aligned.contains([4.9, 1.9, 0.9])); // Half, not full: 6 yd along a 10-yd box is outside. assert!(!axis_aligned.contains([6.0, 0.0, 0.0])); - // Strict on the face itself. assert!(!axis_aligned.contains([5.0, 0.0, 0.0])); assert!(!axis_aligned.contains([0.0, 0.0, 1.0])); - // Rotate the same box a quarter turn: the long axis now runs along world +Y, so a point - // that was inside along X is outside, and its mirror along Y is inside. + // A quarter turn puts the long axis along world +Y. let turned = AreaTriggerRow { box_yaw: std::f32::consts::FRAC_PI_2, ..axis_aligned @@ -228,8 +191,6 @@ mod tests { assert!(turned.contains([0.0, 4.0, 0.0])); } - /// The real 5875 table: the Darnassus portal pair the reports name (B70), a box row and a - /// sphere row both present, and the per-map bucketing. Skips without client data. #[test] fn real_area_triggers_load_and_contain_their_own_centres() { let data = crate::wow_data_or_skip!(); @@ -237,21 +198,16 @@ mod tests { let cat = load_area_trigger_catalog(&mut chain).expect("load AreaTrigger"); assert_eq!(cat.len(), 432, "5875 ships 432 area triggers"); - // The two the Darnassus report rides on (B70), both on Kalimdor, both 10-yd spheres. They - // pin the coordinate columns from *outside* the client: vmangos's `areatrigger_teleport` - // sends 527 ("Darnassus - Exit") to (8785.79, 966.98, 30.20) — which is trigger **542**'s - // own position here, and vice versa. Two independent sources, agreeing to a couple of yards, - // on a pair of doorways that teleport to each other. + // The Darnassus portal pair, 10-yd spheres on Kalimdor: vmangos's `areatrigger_teleport` + // sends 527 to (8785.79, 966.98, 30.20), trigger 542's position here, and 542 to 527's. let exit = *cat.get(527).expect("trigger 527 (Darnassus - Exit)"); let entrance = *cat.get(542).expect("trigger 542 (Darnassus - Entrance)"); assert_eq!((exit.map_id, entrance.map_id), (1, 1)); assert!(exit.contains([9947.0, 2630.0, 1318.0]), "{exit:?}"); assert!(entrance.contains([8799.0, 970.0, 30.0]), "{entrance:?}"); - // …and 25 yd away is outside a 10-yd sphere. assert!(!entrance.contains([8824.0, 970.0, 30.0])); - // Every volume contains its own centre — the one invariant that catches a column shift - // (a size read as a coordinate, or a radius read from the wrong slot, breaks it). + // Every volume contains its own centre, which a column shift breaks. for t in cat.on_map(1) { assert!( t.contains(t.position), @@ -260,15 +216,12 @@ mod tests { ); } - // Both shapes are represented in the shipped table. - // Both shapes ship: 80 boxes, the rest spheres. let boxes = cat.on_map(0).iter().filter(|t| t.radius == 0.0).count(); assert!( boxes > 0 && boxes < cat.on_map(0).len(), "map 0 has both shapes" ); - // Buckets are per map, and the two continents carry most of the table. assert!(cat.on_map(1).iter().all(|t| t.map_id == 1)); assert_eq!((cat.on_map(0).len(), cat.on_map(1).len()), (133, 121)); } diff --git a/crates/benilla-formats/src/auction_house.rs b/crates/benilla-formats/src/auction_house.rs index 30fe8024..b670aeaa 100644 --- a/crates/benilla-formats/src/auction_house.rs +++ b/crates/benilla-formats/src/auction_house.rs @@ -1,29 +1,6 @@ -//! AuctionHouse.dbc — the **rates** each auction house charges: the deposit it takes to list an -//! item, and the cut it takes out of a sale. -//! -//! One consumer, and it is why the table has to be loaded rather than hardcoded: the sell pane's -//! `Deposit:` line has to show a number *before* the item is listed, so the client computes it -//! locally — `GetAuctionHouseDepositRate()` is a straight read of [`AuctionHouseCatalog::deposit_percent`] -//! at the house the session is talking to. Which house that is arrives on the wire: -//! `MSG_AUCTION_HELLO`'s reply carries a `houseId` alongside the auctioneer guid, and it keys this -//! table (decision 1511). Send the window the wrong id and the pane quietly shows Blackwater's 25% -//! where a city auctioneer charges 5%. -//! -//! Record layout (7 rows in the shipped 5875 file, verified by reading it): `ID@0`, `FactionID@1`, -//! `DepositPercent@2`, `CutPercent@3`, `Name_Lang@4..11`, `NameFlags@12`. Ids are 1..=7 and -//! contiguous, but this is keyed rather than indexed for the reason every catalog here is keyed: a -//! `houseId` off the wire is data, not an index we control. -//! -//! The shipped table is two tiers — the six faction houses charge **5% / 5%**, and Blackwater -//! (id 7, the neutral goblin house at faction 369) charges **25% / 15%**. That spread is the whole -//! economic point of the neutral auction house, so it is data worth reading rather than a constant -//! worth inlining. -//! -//! **The rate is only half of the deposit.** The server's charge also scales by stack count, by -//! duration, and by its own `Rate.Auction.Deposit` config multiplier, none of which live here; and -//! the real client's displayed number is computed with an intermediate truncation the server's is -//! not, so the two can disagree by a copper or two on cheap items. Decision 1511 records that -//! divergence and what benilla does about it — this module only supplies the percentage. +//! `AuctionHouse.dbc`, each auction house's deposit and cut rates. The server scales the deposit +//! further by stack count, duration and `Rate.Auction.Deposit`, and the reference's displayed +//! figure truncates midway where the server's does not, so they can differ by a copper or two. use std::collections::HashMap; @@ -38,40 +15,37 @@ const AUCTION_HOUSE: &str = "DBFilesClient\\AuctionHouse.dbc"; /// One auction house's row. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuctionHouseInfo { - /// The faction this house serves (`369` = Booty Bay, the neutral house). + /// The faction this house serves (`369`, Booty Bay, for the neutral house). pub faction: u32, - /// Percent of the item's vendor sell price taken to list it, before stack/duration scaling. + /// Percent of the item's vendor sell price taken to list it, before stack and duration scaling. pub deposit_percent: u32, /// Percent of the winning bid taken out of the seller's proceeds. pub cut_percent: u32, - /// The house's name ("Stormwind Auction House"), as shipped. + /// The house's name (`"Stormwind Auction House"`). pub name: String, } -/// AuctionHouse.dbc keyed by the `houseId` that `MSG_AUCTION_HELLO`'s reply carries. +/// `AuctionHouse.dbc` keyed by the `houseId` that `MSG_AUCTION_HELLO`'s reply carries. pub struct AuctionHouseCatalog { houses: HashMap, } impl AuctionHouseCatalog { - /// The whole row for `house_id`; `None` for an id with no row. + /// The row for `house_id`. pub fn get(&self, house_id: u32) -> Option<&AuctionHouseInfo> { self.houses.get(&house_id) } - /// The listing deposit rate, in percent — `GetAuctionHouseDepositRate()`'s answer. `None` for - /// an id with no row, which leaves the caller to decide (the sell pane shows no deposit rather - /// than inventing one). + /// The listing deposit rate in percent, `GetAuctionHouseDepositRate()`'s answer. pub fn deposit_percent(&self, house_id: u32) -> Option { self.houses.get(&house_id).map(|h| h.deposit_percent) } - /// The sale cut, in percent. + /// The sale cut in percent. pub fn cut_percent(&self, house_id: u32) -> Option { self.houses.get(&house_id).map(|h| h.cut_percent) } - /// Row count, for the load log. pub fn len(&self) -> usize { self.houses.len() } @@ -94,7 +68,7 @@ fn auction_house_schema() -> Schema { s } -/// Load AuctionHouse.dbc from the patch chain. +/// Load `AuctionHouse.dbc` from the patch chain. pub fn load_auction_houses(chain: &mut Chain) -> Result { let bytes = chain .read_file(AUCTION_HOUSE) @@ -124,9 +98,6 @@ pub fn load_auction_houses(chain: &mut Chain) -> Result { mod tests { use super::load_auction_houses; - /// The shipped 5875 table, read as data. The two tiers pinned here are the arithmetic the sell - /// pane rests on: every faction house is 5%/5%, and the neutral goblin house is 25%/15% — the - /// spread that makes cross-faction trading cost something. Skips without client data. #[test] fn the_shipped_houses_carry_the_two_rate_tiers() { let data = crate::wow_data_or_skip!(); @@ -135,7 +106,6 @@ mod tests { assert_eq!(cat.len(), 7, "the whole shipped table"); - // The six faction houses: a flat 5% to list, 5% of the sale. for id in 1..=6 { let h = cat.get(id).unwrap_or_else(|| panic!("house {id}")); assert_eq!(h.deposit_percent, 5, "house {id} deposit"); @@ -146,14 +116,12 @@ mod tests { Some("Stormwind Auction House") ); - // Blackwater — the neutral house, and the reason this is a table and not a constant. let neutral = cat.get(7).expect("house 7"); assert_eq!(neutral.faction, 369, "Booty Bay"); assert_eq!(neutral.deposit_percent, 25); assert_eq!(neutral.cut_percent, 15); assert_eq!(neutral.name, "Blackwater Auction House"); - // Past the table: no row. The sell pane shows no deposit rather than inventing one. assert_eq!(cat.deposit_percent(8), None); } } diff --git a/crates/benilla-formats/src/bank_bag_slot_prices.rs b/crates/benilla-formats/src/bank_bag_slot_prices.rs index ca006bf0..5e048f43 100644 --- a/crates/benilla-formats/src/bank_bag_slot_prices.rs +++ b/crates/benilla-formats/src/bank_bag_slot_prices.rs @@ -1,11 +1,6 @@ -//! `BankBagSlotPrices.dbc` — the bank's purchase-ladder price table (decision 0604). -//! -//! 12 rows × 2 `u32` columns (`ID`, price in copper). Verified from the real 5875 `dbc.MPQ` -//! (dumped this session): rows 1–6 = 1000, 10000, 100000, 250000, 500000, 1000000; rows 7–12 a -//! `999999999` sentinel — only the first 6 bank bag slots are actually purchasable -//! (`GetNumBankSlots()` reports `full` at 6; the sentinel rows exist but are unreachable). -//! `CMSG_BUY_BANK_SLOT` carries no slot index — the server buys slot `purchased_count + 1` itself -//! (`PLAYER_BYTES_2` byte 2) — so the client's only job is pricing that next slot. +//! `BankBagSlotPrices.dbc`: the copper price of each bank bag slot. Rows 7-12 hold an unreachable +//! `999999999` sentinel, as `GetNumBankSlots()` reports full at 6. `CMSG_BUY_BANK_SLOT` carries no +//! index: the server buys the slot after the count in `PLAYER_BYTES_2` byte 2. use anyhow::{Context, Result}; use benilla_dbc::{FieldType, Schema, SchemaField}; @@ -20,10 +15,7 @@ const BANK_BAG_SLOT_PRICES: &str = "DBFilesClient\\BankBagSlotPrices.dbc"; pub struct BankBagSlotPrices(HashMap); impl BankBagSlotPrices { - /// The cost of the *next* bank bag slot, given `purchased_count` already bought (slot - /// `purchased_count + 1`, 1-based to match the DBC's row ids). `None` past the table — no row - /// for that slot number at all, not merely the sentinel (the 6-purchasable-slot cap is a data - /// fact, not something this reader hardcodes). + /// The price of slot `purchased_count + 1`; a sentinel row still reads as its price. pub fn next_slot_price(&self, purchased_count: u8) -> Option { self.0.get(&(u32::from(purchased_count) + 1)).copied() } @@ -52,7 +44,6 @@ pub fn load_bank_bag_slot_prices(chain: &mut Chain) -> Result table_from(&bytes) } -/// The parse itself, split from the chain read so the golden test drives the identical path. fn table_from(bytes: &[u8]) -> Result { let rs = parse(bytes, schema(), "BankBagSlotPrices")?; let mut prices = HashMap::with_capacity(rs.records().len()); @@ -68,8 +59,7 @@ fn table_from(bytes: &[u8]) -> Result { mod tests { use super::*; - /// A hand-built WDBC with the real 5875 rows (verified against `dbc.MPQ` this session, - /// decision 0604): 6 real prices then the 6-row `999999999` sentinel. + /// A WDBC with the shipped rows: six prices, then six `999999999` sentinel rows. fn synthesize() -> Vec { let rows: &[[u32; 2]] = &[ [1, 1_000], diff --git a/crates/benilla-formats/src/bin/benilla-extract/chaincensus.rs b/crates/benilla-formats/src/bin/benilla-extract/chaincensus.rs index acd153b9..539cf43c 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/chaincensus.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/chaincensus.rs @@ -1,23 +1,13 @@ -//! `chaincensus`: the whole-table view of the **beam/chain** system (decision 0955) — every -//! `SpellVisualKit` that draws a `SpellChainEffects` beam, the row it names, and the spells that -//! reach it through which lifecycle stage. -//! -//! The scope instrument for B161 ("Chain Lightning has no chain effect"). It answers, from the -//! shipped table rather than from expectation, the two questions that turned that one report into -//! a system: *which* spells are affected (not just Chain Lightning — every drain, funnel and eye -//! beam in the game), and whether the small-int decode of `CharParamZero` is the real mechanism — -//! because if it is, **every** live slot must resolve to a real row of an 18-row table and name a -//! texture that matches the spell. A decode that were merely plausible would scatter. -//! -//! Read the `stage` column with the flag: `channel` kits ship flag 1, `cast` kits flag 0, and that -//! flag — not the type key's name — is the only thing the client distinguishes. +//! `chaincensus`: every `SpellVisualKit` that draws a `SpellChainEffects` beam, the row it names +//! and the spells that reach it by stage. Each live slot must name one of the table's 18 rows, or +//! the small-int decode of `CharParamZero` is wrong. The client tells the two kinds apart by the +//! flag alone, never the type key: `channel` kits ship flag 1, `cast` kits flag 0. use std::collections::{BTreeMap, BTreeSet}; use anyhow::Result; use benilla_formats::{char_proc_type, Chain, SpellVisualCatalog, VisualStages}; -/// A `SpellVisual` stage's column selector — one of the five lifecycle-kit fields. type StagePick = fn(&VisualStages) -> u32; const STAGES: [(&str, StagePick); 5] = [ @@ -35,7 +25,6 @@ struct Reach { stages: BTreeSet<&'static str>, } -/// Every kit → the spells that reach it, and by which stage. fn kit_reachability( chain: &mut Chain, visuals: &SpellVisualCatalog, diff --git a/crates/benilla-formats/src/bin/benilla-extract/charatlas.rs b/crates/benilla-formats/src/bin/benilla-extract/charatlas.rs index ba300fc8..8ff0b1e2 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/charatlas.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/charatlas.rs @@ -1,26 +1,8 @@ -//! `charatlas` — composite one character's body atlas off the chain and report **what painted -//! what**. -//! -//! The instrument the two "outfit texture" reports needed and nobody had. A dressed character's -//! body is one 256² atlas of ten fixed tiles (bbox table `0xb42450`), and every visible defect in -//! that class — a garment that stops early, a boot repainting a robe's hem, a bare band below the -//! knee — is one tile receiving the wrong contribution. Reading that off a screenshot means -//! guessing; reading it off the atlas means measuring. -//! -//! Three things it prints, all derived from the composite's own law -//! ([`benilla_formats::equip_blits`]), never a second transcription: -//! -//! 1. **The plan** — every equipment contribution in blit order, with the file each name actually -//! resolved to (or `MISSING`, which is the silent skip that reads as "the texture ends early"). -//! 2. **The per-tile diff** vs the same character composited naked, per atlas ROW. A tile whose -//! lower rows go unpainted is a garment that stops early; a row count that changes when one slot -//! is added is that slot's footprint. -//! 3. **The geosets** the same equipment selects, with the atlas rows each one samples — so "which -//! tile does the robe's skirt read?" is answered next to "what is in that tile". -//! -//! The tile↔geoset pairing is the whole diagnosis: geoset 1302 (the robe skirt) samples atlas rows -//! 112–223, which straddles the LegUpper **and LegLower** tiles — so a boot's LegLower contribution -//! lands on a robe's hem even though the boot's own geometry is disabled under a robe. +//! `charatlas`: composite one character's body atlas off the chain and report what painted what: +//! the equipment blits in [`benilla_formats::equip_blits`] order with the file each resolved to +//! (`MISSING` leaves base skin), the rows each tile repaints against the naked body, and the +//! geosets the equipment selects. Geoset 1302, the robe skirt, samples atlas rows 112-223 across +//! the LegUpper and LegLower tiles, so a boot's LegLower blit shows on a robe's hem. use anyhow::{Context, Result}; use benilla_formats::{ @@ -28,8 +10,7 @@ use benilla_formats::{ CharSections, CharacterGeosets, EmblemLayer, EquipGeosets, GuildEmblem, ItemDisplay, }; -/// The ten atlas tiles by group, for the per-tile report — the five head/left-column ones included, -/// so a head-section regression shows up in the same table as an equipment one. +/// The body's ten fixed tiles in its 256² atlas (the reference's bbox table `0xb42450`). const TILES: [(&str, u32, u32, u32, u32); 10] = [ ("g0 ArmUpper", 0, 0, 128, 64), ("g1 ArmLower", 0, 64, 128, 64), @@ -43,13 +24,12 @@ const TILES: [(&str, u32, u32, u32, u32); 10] = [ ("g7 Foot", 128, 224, 128, 32), ]; -/// The worn bodyslots the composite takes, in `equipment` order (bodyslot − 2). +/// The worn bodyslots the composite takes, in `equipment` order (bodyslot - 2). const SLOT_NAMES: [&str; 8] = [ "shirt", "chest", "belt", "pants", "boots", "wrist", "gloves", "tabard", ]; -/// One character appearance + what it wears — the whole input, so a report is reproducible from the -/// command line that made it. +/// One appearance and what it wears: the whole input, so its command line reproduces a report. pub struct Look { pub race: u8, pub sex: u8, @@ -58,17 +38,13 @@ pub struct Look { pub facial_hair: u8, pub hair_style: u8, pub hair_color: u8, - /// The eight worn display ids in `equipment` order; `0` = the slot is empty. + /// The eight worn display ids in `equipment` order; 0 is an empty slot. pub slots: [u32; 8], - /// The wearer's guild tabard, or `None` for "no guild" — the same input the world composite - /// takes (decision 1704). It only paints over a tabard whose display asks for it, so passing - /// one without a guild-emblem tabard in the tabard slot is a no-op, on purpose. + /// The guild tabard; it paints only over a tabard display that asks for an emblem. pub emblem: Option, } -/// A BLP's header as the composite cares about it: the dimensions it blits at and the alpha depth -/// that picks REPLACE (0) / 1-bit key (1) / blend (≥2) — the three that decide whether a -/// contribution *covers* the one under it or lets it through. +/// A BLP2's size and alpha depth, which picks the blit: replace (0), 1-bit key (1) or blend (2+). fn blp_shape(chain: &mut Chain, path: &str) -> Option<(u32, u32, u8)> { let b = chain.read_file(path).ok()?; if b.len() < 20 || &b[0..4] != b"BLP2" { @@ -112,9 +88,7 @@ pub fn charatlas(chain: &mut Chain, look: &Look, out: Option<&std::path::Path>) } } - // (1) The plan — the composite's own order, with what each name resolved to. Worn garments and - // the guild tabard's three layers come through the same list, because they land in the same - // rows and the question ("what repainted this cell?") is the same one. + // (1) The plan, in the composite's order; worn garments and the three emblem layers share it. println!("\nequipment blits (by ascending cell; later covers earlier within a tile):"); for step in equip_blits(&equipment, look.emblem, false) { let (_x, y, w, h) = equip_tile(step.layer).expect("layer < 8"); @@ -222,7 +196,6 @@ pub fn charatlas(chain: &mut Chain, look: &Look, out: Option<&std::path::Path>) }) .collect(); let rows = painted.iter().filter(|n| **n > 0).count(); - // Where a tile stops is the diagnosis, so name the run rather than dumping 64 numbers. let first = painted.iter().position(|n| *n > 0); let last = painted.iter().rposition(|n| *n > 0); let span = match (first, last) { @@ -235,18 +208,15 @@ pub fn charatlas(chain: &mut Chain, look: &Look, out: Option<&std::path::Path>) ); } - // (3) The geosets this same equipment selects. The atlas rows each samples are the model's - // (`benilla-extract m2batch ` prints the UV extents these come from), so the - // two halves of "which art lands on which geometry" sit in one report. + // (3) The geosets this equipment selects; `m2batch` on the race model prints their UV extents. let mut eq = EquipGeosets::default(); for (i, d) in equipment.iter().enumerate() { if let Some(d) = d { eq.bodyslots[i] = Some(d.geoset_groups); } } - // B3's gate is the ArmLower tile's occupancy — read off the same plan printed above (1864). + // The shirt-cuff geoset's gate is the ArmLower tile's occupancy, read off the same plan. eq.forearm_dressed = forearm_dressed(&equipment); - // Sorted + deduplicated at the source (`visible_geosets`). let ids = geosets.visible_geosets(look.race, look.sex, look.hair_style, look.facial_hair, &eq); println!("\nvisible geosets: {ids:?}"); diff --git a/crates/benilla-formats/src/bin/benilla-extract/charprocs.rs b/crates/benilla-formats/src/bin/benilla-extract/charprocs.rs index 4e71cfcb..310ec327 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/charprocs.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/charprocs.rs @@ -1,22 +1,14 @@ -//! `charprocs`: census the `SpellVisualKit` **CharProc** columns (fields 15–34) — the character-half -//! of a kit: what a kit does to the *body* (its alpha, its tint) rather than at an attach point. -//! -//! The scope instrument for the aura-state CharProc system (B114 — "Stealth shows nothing on the -//! character"). It answers, from the shipped table rather than from expectation: which proc **types** -//! exist, how many kits carry each, which **lifecycle stage** reaches them from a live spell, and — -//! for the state stage, whose kits live for an aura's whole life — exactly which spells reach which -//! proc with which parameter. A type that shows up only behind `cast`/`impact` is a discrete-play -//! concern; a type behind `state` is an aura-lifetime one. +//! `charprocs`: census the `SpellVisualKit` CharProc columns (fields 15-34), what a kit does to +//! the body rather than at an attach point: which proc types exist, which stage reaches each from +//! a live spell, and every state-stage proc, which lasts as long as its aura. use std::collections::{BTreeMap, BTreeSet}; use anyhow::Result; use benilla_formats::{char_proc_type, Chain, SpellVisualCatalog, TrailProc, VisualStages}; -/// A `SpellVisual` stage's column selector — one of the five lifecycle-kit fields. type StagePick = fn(&VisualStages) -> u32; -/// The five `SpellVisual` lifecycle stages, in field order — the label plus its column selector. const STAGES: [(&str, StagePick); 5] = [ ("precast", |s| s.precast), ("cast", |s| s.cast), @@ -25,7 +17,6 @@ const STAGES: [(&str, StagePick); 5] = [ ("channel", |s| s.channel), ]; -/// A human label for the proc types benilla models by name. fn type_name(ty: i32) -> &'static str { match ty { char_proc_type::TINT => "TINT (body RGB)", @@ -38,12 +29,12 @@ fn type_name(ty: i32) -> &'static str { } } -/// Census every kit's CharProc slots, then reachability per stage, then the state-stage detail. +/// Census the CharProc slots, their reach by stage, the state-stage procs and the weapon trails. pub fn run(chain: &mut Chain) -> Result<()> { let spells = benilla_formats::load_spell_catalog(chain)?; let visuals = benilla_formats::load_spell_visual_catalog(chain)?; - // 1. The raw table census: proc type → the kits carrying it. + // 1. Every proc type and the kits carrying it. let mut by_type: BTreeMap> = BTreeMap::new(); let mut kits_with_any = 0usize; for kit_id in visuals.kit_ids() { @@ -70,8 +61,7 @@ pub fn run(chain: &mut Chain) -> Result<()> { ); } - // 2. Reachability: which stage of which spell's visual actually reaches each proc type. A kit - // nobody's visual chain names is authored-but-dead as far as the client is concerned. + // 2. Each proc type's reach by stage from a live spell; a kit no visual names never plays. let mut by_stage: BTreeMap<(&str, i32), BTreeSet> = BTreeMap::new(); // The state stage's detail rows: (spell id, name, kit, proc type, params[0]). let mut state_rows: Vec<(u32, String, u32, i32, f32)> = Vec::new(); @@ -107,7 +97,7 @@ pub fn run(chain: &mut Chain) -> Result<()> { ); } - // 3. The state stage in full — these are the aura-lifetime procs, one line per (spell, proc). + // 3. Every state-stage proc, which lasts its aura's life, one line per spell and proc. println!( "\nSTATE-stage CharProcs — the aura-lifetime set ({} spell/proc pair(s)):", state_rows.len() @@ -120,10 +110,7 @@ pub fn run(chain: &mut Chain) -> Result<()> { ); } - // 4. The TRAIL set in full. Its shape is unlike the others': the proc carries no model and no - // emitter, so a kit whose slot list is EMPTY still has a visual — and 18 of these are exactly - // that, which is the single fact that decides whether the proc is worth building. The census - // prints the slot count beside every row so that number stays checkable rather than quoted. + // 4. The weapon-trail kits; a trail needs no effect slot, so it draws on a kit with none. let trail_kits: BTreeMap = visuals .kit_ids() .filter_map(|id| Some((id, visuals.kit(id)?.trail_proc()?))) @@ -160,9 +147,8 @@ pub fn run(chain: &mut Chain) -> Result<()> { let anim = kit.and_then(|k| k.anim_id); let [r, g, b] = trail.rgb(); let spells = reach.get(kit_id); - // `CharParamOne` is printed even though the type-8 arm never reads it (`0x60d80a`'s three - // `_ftol`s take Zero/Two/Three only). Not every shipped row carries `20.0` there, and a - // census that hides the column cannot catch that — Sinister Strike's kit 399 carries 15.0. + // The type-8 arm never reads `CharParamOne` (`0x60d80a`), but it is printed: not every row + // carries 20.0 there, and Sinister Strike's kit 399 carries 15.0. let unread = kit .and_then(|k| { k.char_procs() @@ -187,8 +173,7 @@ pub fn run(chain: &mut Chain) -> Result<()> { Ok(()) } -/// The kits a spell's visual chain reaches, printed as one line per stage — the per-spell view of -/// the census above (used by `spellvis`'s CharProc lines; kept here beside the type names). +/// Print one kit's CharProc slots, one line each. pub fn print_kit_procs(visuals: &SpellVisualCatalog, kit_id: u32, indent: &str) { let Some(kit) = visuals.kit(kit_id) else { return; diff --git a/crates/benilla-formats/src/bin/benilla-extract/glueextent.rs b/crates/benilla-formats/src/bin/benilla-extract/glueextent.rs index 15070f2a..92cd5147 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/glueextent.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/glueextent.rs @@ -1,14 +1,6 @@ -//! `glueextent` — how wide each shipped glue scene's art is, off the chain (decision 1619). -//! -//! The instrument behind the glue framing ceiling: for every `UI_*` diorama it prints the measured -//! [`benilla_formats::ArtExtent`] (the same call the client makes at scene spawn), the authored -//! 4:3 half-extents it is measured against, and the derived facts the framing law acts on — the -//! window aspect at which the art runs out of **width** under 1587's hor+ (past it the law zooms -//! in, holding the width) and the aspect at which it runs out of **height** for a narrow window. -//! -//! Beside the law's own reading (opaque batches, front faces) it prints two looser ones — opaque + -//! alpha-tested, and every batch — so a scene whose measured edge looks wrong against a capture can -//! be read for *which* batches sit at that edge without a second tool. +//! `glueextent`: every shipped glue scene's [`benilla_formats::ArtExtent`], measured off the chain +//! (the numbers `SHIPPED_GLUE_SCENES` transcribes), against its authored 4:3 half-extents, and the +//! window aspects at which its art runs out of width and of height. use anyhow::{Context, Result}; use benilla_formats::{ @@ -16,7 +8,7 @@ use benilla_formats::{ parse_m2_render_submeshes, Chain, Coverage, CoverageReader, ModelBlend, GLUE_AUTHORED_ASPECT, }; -/// The seven shipped scenes: the login gate + the six race stages (Gnome→Dwarf, Troll→Orc share). +/// The login gate and the six race stages (Gnome shares Dwarf's, Troll shares Orc's). const SCENES: [&str; 7] = [ "MainMenu", "Human", "Orc", "Dwarf", "NightElf", "Scourge", "Tauren", ]; @@ -39,10 +31,8 @@ pub fn glueextent(chain: &mut Chain, batches: bool) -> Result<()> { }; let t0 = authored_half_height(cam.fov); let h0 = t0 * GLUE_AUTHORED_ASPECT; - // The law's reading — every batch painting by its texels (the module's rule) — and the - // two readings on either side of it, so a scene whose measured edge looks wrong against - // a capture can be read for which rule moved it: opaque batches only, and every batch - // as if it painted fully. + // The texel rule the shipped table is measured by, bracketed by opaque batches only and + // by every batch painting fully. let mut reader = CoverageReader::new(chain); let mut paints: Vec> = Vec::with_capacity(subs.len()); for s in &subs { @@ -76,8 +66,6 @@ pub fn glueextent(chain: &mut Chain, batches: bool) -> Result<()> { opaque_only, with_all, ); - // Which batches sit at the measured edge: every opaque batch, with its blend, so a - // suspicious number can be traced to a card without a second command. let mut kinds = [0usize; 5]; for s in &subs { kinds[match s.blend { @@ -101,8 +89,7 @@ pub fn glueextent(chain: &mut Chain, batches: bool) -> Result<()> { cam.near_clip, ); if batches { - // Every batch's footprint in the frame, in units of t0 (so `±1.333` is the authored - // 4:3 box's side, `±1.0` its top/bottom): which card sets the edge. + // Each batch's footprint in units of t0: ±1.333 is the 4:3 box's side, ±1.0 its top. println!( " {:>3} {:<9} {:<3} {:<6} {:>5} {:>5} {:>4} {:>14} {:>14} tex", "idx", "blend", "2s", "paints", "front", "back", "clip", "x'/t0", "y'/t0" diff --git a/crates/benilla-formats/src/bin/benilla-extract/kitanim.rs b/crates/benilla-formats/src/bin/benilla-extract/kitanim.rs index a75c714e..a7852231 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/kitanim.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/kitanim.rs @@ -1,34 +1,15 @@ -//! `kitanim`: census the `SpellVisualKit` **animation** column (field 2) — the half of a kit that -//! plays a clip on the unit's *own body*, as opposed to the attach-point effect models -//! (`spellvis`), the CharProcs (`charprocs`) or the camera shake (`shakecensus`). -//! -//! The scope instrument for "the spell landed on me and my character did nothing". A kit's anim is -//! the only part of a visual that can be *invisible by omission*: an effect model that fails to -//! spawn leaves a trace line, while an anim that is never asked for, or is asked for and instantly -//! overwritten, leaves nothing at all. So the population has to be read off the shipped table: -//! -//! - **by stage**, because the stage decides the lifetime. A `cast`/`impact` anim is a one-shot on -//! the caster/victim; a **`state`** anim belongs to an aura's whole life, which is a different -//! consumer entirely (`creature_anim::spell_visual::arm_aura_state_fx`'s slot watcher, not -//! `play_impact`). -//! - **anim-only kits are called out**, because they are the class an "does this kit do anything?" -//! test drops. That test is the B114 shape: the aura-state watcher used to arm only on effect -//! models, so Stealth — whose whole visual is one CharProc — showed nothing. A state kit whose -//! whole visual is an *anim* fails the same way one level over. -//! - **wound-branch ids are marked**, because 8/9/10 (`StandWound`/`CombatWound`/`CombatCritical`) -//! do not play as themselves: the client's kit player hands them to the severity-0 flinch -//! (`0x60f3b8: push 0; call 0x60ea70`), so they are a different mechanism wearing the same -//! column. +//! `kitanim`: census the `SpellVisualKit` animation column (field 2), the clip a kit plays on the +//! unit's own body, by stage: a `cast` or `impact` anim is a one-shot on the caster or victim, a +//! `state` anim lasts the aura's life. Anim-only kits are marked, as a consumer keyed on effect +//! models never sees them. use std::collections::{BTreeMap, BTreeSet}; use anyhow::Result; use benilla_formats::{AnimDataCatalog, Chain, VisualStages}; -/// A `SpellVisual` stage's column selector — one of the five lifecycle-kit fields. type StagePick = fn(&VisualStages) -> u32; -/// The five `SpellVisual` lifecycle stages, in field order — the label plus its column selector. const STAGES: [(&str, StagePick); 5] = [ ("precast", |s| s.precast), ("cast", |s| s.cast), @@ -37,14 +18,13 @@ const STAGES: [(&str, StagePick); 5] = [ ("channel", |s| s.channel), ]; -/// The kit-player's wound branch: ids 8–10 are handed to the severity-0 flinch instead of being -/// played as themselves (`0x60f3b8`), so they are counted apart from the real body clips. +/// Ids 8-10 (`StandWound`, `CombatWound`, `CombatCritical`): the kit player hands them to the +/// severity-0 flinch instead of playing them (`0x60f3b8: push 0; call 0x60ea70`). fn is_wound_id(id: u16) -> bool { (8..=10).contains(&id) } -/// One state-stage kit's row: everything that decides whether a consumer keyed on effect models -/// alone would see it. +/// One state-stage kit: what decides whether a consumer keyed on effect models sees it. struct StateKit { anim: u16, slots: usize, @@ -55,19 +35,16 @@ struct StateKit { } impl StateKit { - /// Nothing but the anim — the class an effects/sound/proc test drops on the floor. fn anim_only(&self) -> bool { self.slots == 0 && !self.world && !self.sound && self.procs == 0 } } -/// Census the anim column: the raw table, then reachability per stage, then the two stages whose -/// consumers differ — `state` (aura lifetime) in full, `impact` (the victim's one-shot) ranked. +/// Census the anim column, its reach by stage, every `state` kit and the top `impact` kits. pub fn run(chain: &mut Chain) -> Result<()> { let spells = benilla_formats::load_spell_catalog(chain)?; let visuals = benilla_formats::load_spell_visual_catalog(chain)?; - // `AnimationData.dbc` is only the naming column here — a missing table degrades to bare ids - // rather than failing the census (the same optional shape every DBC consumer takes). + // `AnimationData.dbc` only names the ids, so a missing table prints `?` rather than failing. let anims = benilla_formats::load_anim_data_catalog(chain).ok(); let name = |id: u16| -> String { anims @@ -77,7 +54,7 @@ pub fn run(chain: &mut Chain) -> Result<()> { .to_string() }; - // 1. The raw table: which anim ids the shipped kits ask for at all. + // 1. Which anim ids the shipped kits ask for. let mut by_anim: BTreeMap> = BTreeMap::new(); for kit_id in visuals.kit_ids() { if let Some(anim) = visuals.kit(kit_id).and_then(|k| k.anim_id) { @@ -104,8 +81,7 @@ pub fn run(chain: &mut Chain) -> Result<()> { ); } - // 2. Reachability: a kit no spell's visual chain names is authored-but-dead, and the STAGE is - // what picks the consumer, so both are counted here. + // 2. The reach by stage; a kit no spell's visual names never plays. let mut stage_kits: BTreeMap<&str, BTreeSet> = BTreeMap::new(); let mut stage_spells: BTreeMap<&str, BTreeSet> = BTreeMap::new(); let mut state: BTreeMap = BTreeMap::new(); @@ -157,8 +133,7 @@ pub fn run(chain: &mut Chain) -> Result<()> { ); } - // 3. The STATE stage in full — the aura-lifetime set, which is the whole reason this census - // exists. `ANIM-ONLY` marks the kits an effects/sound/proc arm test never sees. + // 3. Every state kit; `ANIM-ONLY` marks those with no effect slot, world effect, sound or proc. let state_spells: usize = state .values() .flat_map(|k| k.spells.iter()) @@ -194,8 +169,7 @@ pub fn run(chain: &mut Chain) -> Result<()> { } } - // 4. The IMPACT stage, ranked — the VICTIM's body one-shot, the other consumer. Kept to a - // ranked list rather than spelled out: the population is the whole damage table. + // 4. The impact kits, the victim's one-shot, ranked by spell count. let impact_spells: usize = impact .values() .flat_map(|(_, s)| s.iter()) diff --git a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs index 00161d54..1ba48336 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs @@ -1,7 +1,4 @@ -//! Per-model M2 dump printers: `m2coll`, `m2seq`, `m2attach`, `m2anim`, `m2bones`, `m2batch` — -//! the single-model diagnostics that read one `.m2` and print everything a given concern -//! (collision hull, sequences, attachment points, animation channels, bone table, render -//! batches) actually carries. +//! The single-model M2 dumps: each reads one `.m2` and prints all it carries for one concern. use anyhow::{Context, Result}; use benilla_formats::{Chain, M2AnimSummary}; @@ -40,10 +37,8 @@ pub fn m2coll(chain: &mut Chain, internal_path: &str) -> Result<()> { Ok(()) } -/// Dump every sequence's EVENT keyframes: time (s from sequence start), 4CC ident, payload. -/// The event-order instrument (decision 0279): on attack clips, whether `$CPP` (the victim -/// defense dispatch) precedes `$AH0-3`/`$CAH` (the impact dispatch) decides which of the two -/// mutually-exclusive victim reactions the shared swing record feeds. +/// Dump every sequence's event keyframes. On an attack clip, whether `$CPP` (the victim's defense) +/// precedes `$AH0-3`/`$CAH` (the impact) picks which exclusive reaction the swing record feeds. pub fn m2events(chain: &mut Chain, internal_path: &str) -> Result<()> { let name = normalize(internal_path); let data = chain @@ -83,19 +78,13 @@ pub fn m2seq(chain: &mut Chain, internal_path: &str) -> Result<()> { .read_file(&name) .with_context(|| format!("reading '{name}' from chain"))?; let seqs = benilla_formats::parse_m2_animations(&data); - // `band` is the sequence's absolute window on the model's global keyframe timeline: every - // non-global-sequence track selects its keys from it, so it is what says whether a track is - // actually keyed HERE or is holding a clamped value from some other sequence's band. - // NB `idx` is this list's index, not the file's: zero-duration sequences are dropped. + // `band` is the window on the keyframe timeline every non-global-sequence track keys in. `idx` + // is not the file slot: zero-duration sequences are dropped. println!( "idx anim mode dur(s) mspd blend band(ms) freq replay bones keys" ); for (i, s) in seqs.iter().enumerate() { - // How much data the sequence's own time band actually holds: bones with any - // keyed track, and total keys across T/R/S (clamp constants included — a bone - // unkeyed in this band pins to its nearest authored key, see `read_bone_track`). - // Uneven coverage across same-id variations is what exposed the task-#14 tilt - // (HumanMale Stand idx 136 keys 13 fewer bones than the head). + // Bones with any keyed track in this band, and total T/R/S keys, clamp constants included. let bones = s .bones .iter() @@ -111,14 +100,11 @@ pub fn m2seq(chain: &mut Chain, internal_path: &str) -> Result<()> { s.anim_id, if s.looping { "loop " } else { "clamp" }, s.duration, - // `mspd` = the sequence's authored design movement speed (yd/s) — the DIVISOR of the - // locomotion playback rate (`speed / (mspd · |modelScale|)`, `0x5fe2f0` - // @0x5fe4be..0x5fe550). `0.00` ⇒ not a locomotion sequence, so it plays at rate 1×. + // `mspd`: the authored move speed (yd/s) dividing the locomotion rate, + // `speed / (mspd · |modelScale|)` (`0x5fe2f0` at 0x5fe4be..0x5fe550); 0 plays at 1×. s.move_speed, - // `blend` = the sequence's authored blend-IN time (s) — how long the client cross-fades - // from the outgoing pose into this one on a blended arm (op4 `blendFlag != 0`, the - // `+0x98 -> +0xc4` snapshot decayed over `1/blendTime`). `0.000` = an instant cut, and - // that is what a doll's turn looks like without it. + // `blend`: the blend-in time (s) the client cross-fades over on a blended arm (op4 + // `blendFlag != 0`, the `+0x98 -> +0xc4` snapshot decayed over `1/blendTime`); 0 cuts. s.blend_time, s.start_ms, s.end_ms, @@ -138,9 +124,7 @@ pub fn m2attach(chain: &mut Chain, internal_path: &str) -> Result<()> { .read_file(&name) .with_context(|| format!("reading '{name}' from chain"))?; let attachments = benilla_formats::parse_m2_attachments(&data)?; - // The position is raw WoW model space (X forward, Y left, Z up), as the M2 stores it — "where - // on the model does this rider actually sit?" needs it as much as the bone does: an item glow - // hangs on ids 0..4, spread along a weapon's length (decision 0805). + // Raw WoW model space (X forward, Y left, Z up); item glows hang on ids 0..4 along a weapon. println!("id bone position (WoW model space)"); for a in &attachments { let [x, y, z] = a.position; @@ -150,8 +134,6 @@ pub fn m2attach(chain: &mut Chain, internal_path: &str) -> Result<()> { Ok(()) } -/// One texture-transform track line for the `m2anim` dump: key count, interp/gseq tags, and the -/// first/last keys (enough to read a scroll direction + rate off a waterfall). fn print_txfm_track(name: &str, t: &benilla_m2::M2Track) { if t.keys.is_empty() { println!(" {name}: -"); @@ -172,7 +154,6 @@ fn print_txfm_track(name: &str, t: &benil ); } -/// The `m2anim` subcommand's dump — one section per channel family. fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { println!("sequences: {}", s.sequence_count); println!( @@ -204,10 +185,6 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { s.texture_transform_count ); println!("particle emitters: {}", s.particle_emitter_count); - // The full defs (pos/blend/texture/shape/rate-keys/ramps) alongside the summary's bone links — - // which emitter is the flame and which the glow is unreadable from bone+flags alone (the - // blood-spurt starburst diagnosis, decision 0141; a flame that "doesn't burn" is usually - // visible right here: an unresolved texture, or a burst rate track whose first key is 0). let defs = benilla_formats::parse_m2_particle_emitters(bytes).unwrap_or_default(); for (i, e) in s.emitter_bones.iter().enumerate() { println!( @@ -222,13 +199,9 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { } ); let Some(d) = defs.get(i) else { continue }; - // The emission MODEL first: a BURST emitter fires one ftol(rate) puff at its rate edge - // and never pours — reading its keys as a continuous rate is the exact misdiagnosis - // behind the Eviscerate 0.5s-vs-2s gap (`0x718ec8` → `0x7b5550`). + // A burst emitter fires one `ftol(rate)` puff at its rate edge and never pours + // (`0x718ec8` → `0x7b5550`). let burst = if d.burst() { "BURST " } else { "" }; - // PER-SEQUENCE timing (the runtime's actual sampling unit — the old print showed the two - // tracks rebased onto sequence 0's band, which read as authoritative and was exactly the - // B27 misparse). The quiet case stays quiet: one constant rate, no gate anywhere. let views = d.timing.slot_views(); let rate = match d.timing.constant_rate() { Some(r) => format!("{burst}rate {r:.1}/s"), @@ -247,10 +220,9 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { format!("{burst}rate/seq [{}]", per.join(" ")) } }; - // The enabled gate, per sequence slot (seconds from the slot's band start) — a one-shot - // effect's choreography, and a state GameObject's "which clips actually fire this". + // The enabled gate per sequence slot, in seconds from the slot's band start. let rate = if views.iter().all(|(_, _, e)| e.is_none()) { - rate // no gate authored anywhere (the overwhelmingly common shape) — no noise + rate // no gate authored anywhere, the common case } else { let per: Vec = views .iter() @@ -273,8 +245,7 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { .collect(); format!("{rate} enabled/seq [{}]", per.join(" ")) }; - // A tail's streak length is |velocity|·tail_time — without it "how long is this - // streak" needs a hand-parse of the raw record (the Eviscerate diagnosis gap). + // A tail's streak length is |velocity| · tail_time. let tail = if d.head_tail >= 1 { format!( " tail {:.2}s{}", @@ -288,10 +259,7 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { } else { String::new() }; - // The parameter channels sample per frame ([`benilla_formats::EmitParams`]); the compact - // line shows their opening values, and any channel that actually MOVES prints its full - // keyed ramp below — the view whose absence hid Frost Nova's 0.19 → 13.2 yd emission- - // radius ride behind a flat "radius [0.19..0.19]" (decision 0844). + // Each parameter channel's opening value; one that moves prints its keyed ramp below. let now = d.params.sample(None, 0.0, 0.0); println!( " {:?} {:?} {} {rate} life {:.2}s speed {:.2} grav {:.2} drag {:.1}{tail} twinkle [{:.2}..{:.2}] spd {:.1} pct {:.2} spin {:.2}", @@ -323,16 +291,15 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { } } } - // The kernel spread (`0x7b8d70`, `0x7b8890`): a sphere's ranges are latitude/longitude - // about +X (area = min/max shell radius); a plane's are the ±θ/±φ cone about +Z (area = - // the spawn rectangle). `(lat ±π, lon ±0)` reads directly as the edge-on ring family. + // The kernel spread (`0x7b8d70`, `0x7b8890`): a sphere's ranges are latitude and longitude + // about +X, its area the shell radii; a plane's a cone about +Z, its area the rectangle. let spread = match d.shape { benilla_formats::ParticleShape::Sphere => format!( "radius [{:.2}..{:.2}] lat ±{:.2} lon ±{:.2}", now.area_length, now.area_width, now.vertical_range, now.horizontal_range ), - // Spline repurposing (loader `0x70fa5e`–`0x70fae5`): area = tMin/tMax, - // vRange = tangent-spin ψ, hRange = scatter. + // A spline repurposes them (loader `0x70fa5e`-`0x70fae5`): area is tMin/tMax, vRange + // the tangent spin, hRange the scatter. benilla_formats::ParticleShape::Spline => match &d.spline { Some(s) => format!( "spline {} pts [{:.2} {:.2} {:.2} ..], t [{:.2}..{:.2}] spin ±{:.2} scatter {:.2}", @@ -357,16 +324,14 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { } else { String::new() }; - // The per-emitter model references: geometry (3-D model particles) and recursion - // (child emitters). if let Some(g) = &d.geometry_model { println!(" MODEL-PARTICLES: {g}"); } if let Some(r) = &d.recursion_model { println!(" CHILD-EMITTERS: {r}"); } - // The emitter-motion terms (`0x7b5230`): the follow-delta response - // line's authored (speed → fraction) samples, and the velocity-inherit scale. + // The emitter-motion terms (`0x7b5230`): the follow-delta response's (speed, fraction) + // samples and the velocity-inherit scale. let motion = match (d.follow_emitter(), d.inherits_emitter_motion()) { (false, false) => String::new(), (f, i) => { @@ -400,9 +365,7 @@ fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { ); } println!("ribbon emitters: {}", s.ribbon_emitter_count); - // A keyed look track prints its full `(ms, value)` ramp — the value[0]-only display once - // masked HolySmite's slash ribbons (height keyed 0 → 0.167 → 0 printed as `+0.00`, reading - // as "no ribbon" when the model authors a flare). + // A keyed ribbon track prints its whole `(ms, value)` ramp; its first value alone can read 0. let scalar = |t: &benilla_formats::ValueTrack| -> String { match t.keys.len() { 0 | 1 => format!("{:.2}", t.first()), @@ -466,8 +429,6 @@ pub fn m2anim(chain: &mut Chain, internal_path: &str) -> Result<()> { .with_context(|| format!("parsing M2 animation summary '{name}'"))?; print_m2anim_summary(&summary, &data); - // Texture-transform detail (0130 phase 3 grounding): the parsed TRS tracks plus the - // batch → lookup → transform wiring, straight from the full parser. let fmt = benilla_m2::parse_m2(&mut std::io::Cursor::new(&data[..])) .with_context(|| format!("parsing M2 '{name}'"))?; let m = fmt.model(); @@ -491,18 +452,13 @@ pub fn m2anim(chain: &mut Chain, internal_path: &str) -> Result<()> { } } } - // Color-alpha + texture-weight keys in full (they're tiny scalar tracks): the "how does this - // effect fade" instrument — the UI cooldown model's finish-flash ramp was pinned from exactly - // this dump (decision 0137 phase 4). if !m.color_alpha_tracks.is_empty() { println!("=== color alpha tracks (per M2Color) ==="); for (i, t) in m.color_alpha_tracks.iter().enumerate() { println!(" color {i}: interp {}, keys {:?}", t.interp, t.keys); } } - // The RGB half of the same M2Colors: the per-batch tint the client multiplies into the vertex - // colour. An effect that reads white where the reference reads coloured is usually visible - // right here (the Frost Nova purple-mist diagnosis). + // The M2Colors' RGB half: the per-batch tint the client multiplies into the vertex colour. if m.color_rgb_tracks.iter().any(|t| t.keys.len() > 1) { println!("=== color rgb tracks (per M2Color) ==="); for (i, t) in m.color_rgb_tracks.iter().enumerate() { @@ -524,9 +480,6 @@ pub fn m2anim(chain: &mut Chain, internal_path: &str) -> Result<()> { println!(" weight {i}: interp {}, keys {:?}", t.interp, t.keys); } } - // Bone SCALE keys per sequence — the "how does this element grow" instrument (the cooldown - // star's finish-flash pulse is a bone-scale curve, decision 0263's INTERIM). Scale-keyed - // bones only; effect/UI models keep this tiny. let seqs = benilla_formats::parse_m2_animations(&data); let any_scaled = seqs .iter() @@ -560,12 +513,8 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> { .with_context(|| format!("parsing M2 '{name}'"))?; let m = fmt.model(); let seqs = benilla_formats::parse_m2_animations(&data); - // `ign` is `flags & 0x7` spelled out — which of the parent's Translate/Scale/Rotate this bone - // REFUSES, taking the model root's instead (decision 0945). The raw flags hex could always be - // read for it and never was: three billboard rounds and a mount round each looked at - // `RidingHorse` bone 30's `0x00000006` and none read it as "the saddle discards the gallop", - // which decision 0932 then measured as a 21° rider swing and called faithful. A bone-table - // dump exists to answer "what does this bone actually inherit"; now it says so in words. + // `ign` spells out `flags & 0x7`: which of its parent's translation, scale and rotation the + // bone refuses, taking the model root's instead. println!( "idx keybone flags bb ign parent pivot \ keyed (seq[idx] T/R/S counts)" @@ -604,9 +553,7 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> { b.pivot.z, keyed.join(" "), ); - // Small tracks get their actual key values — two sequences can share a key COUNT while - // holding different values (the questgiver-marker seq 0 vs 190 lesson: counts alone - // mislabeled them "the same"). + // Small tracks print their key values: equal key counts can hide different values. for (si, s) in seqs.iter().enumerate() { let Some(bk) = s.bones.iter().find(|bk| bk.bone as usize == i) else { continue; @@ -619,17 +566,9 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> { .collect(); println!(" seq{si} (anim {}) T: {}", s.anim_id, keys.join(" ")); } - // Rotation keys as axis-angle (model-space WoW axes, Z up) — the "which way does - // this element actually turn" instrument: an emitter/billboard orientation bug - // needs the spin axis as ground truth, which a bare key COUNT never shows. Small - // tracks print every key; a long track (a swirl/rotor loop) prints first/last plus - // the axis of the first key-to-key increment — the spin axis itself. - // - // A long track also prints `swing` — the largest angle any key makes with the first, - // i.e. the track's actual AMPLITUDE. `step` is only the first increment, so a summary - // without this cannot answer "how far does this bone swing", which is the question a - // "does the rider inherit the mount's gallop pitch" investigation puts to it. Reading - // `step` as the amplitude under-reports a 20° swing as 1.5°. + // Rotation keys as axis-angle (WoW model axes, Z up). A long track prints its first and + // last keys, `step` (the first increment, whose axis is the spin axis) and `swing` (the + // largest angle any key makes with the first, the amplitude `step` is not). if !bk.rotation.is_empty() { let aa = |q: &[f32; 4]| { let w = q[3].clamp(-1.0, 1.0); @@ -653,7 +592,7 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> { let (t0, q0) = &bk.rotation[0]; let (_t1, q1) = &bk.rotation[1]; let (tn, qn) = bk.rotation.last().unwrap(); - // increment = q1 · q0⁻¹ — its axis is the track's spin axis. + // increment = q1 · q0⁻¹, whose axis is the spin axis. let inv0 = [-q0[0], -q0[1], -q0[2], q0[3]]; let inc = [ q1[3] * inv0[0] + q1[0] * inv0[3] + q1[1] * inv0[2] - q1[2] * inv0[1], @@ -661,9 +600,8 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> { q1[3] * inv0[2] + q1[0] * inv0[1] - q1[1] * inv0[0] + q1[2] * inv0[3], q1[3] * inv0[3] - q1[0] * inv0[0] - q1[1] * inv0[1] - q1[2] * inv0[2], ]; - // The amplitude: the largest angle any key makes with the first. `2·acos|⟨qi,q0⟩|` - // is the geodesic angle between two unit quats, sign-folded so a double-cover - // flip doesn't read as a 360° swing. + // `2·acos|⟨qi,q0⟩|` is the angle between two unit quats, sign-folded so a + // double-cover flip does not read as a 360° swing. let swing = bk .rotation .iter() @@ -682,12 +620,6 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> { ); } } - // Scale keys. A bone whose ONLY channel is scale — the pulsing card a spell - // effect hangs off a billboard, the freezing trap's ice shard — printed nothing - // here at all: the header row said `S2` and no detail line followed, so "how big - // does this thing actually get, and in which sequence" could not be answered from - // the dump. Non-uniform scale is exactly the interesting case (a square card - // stretched into a column), so all three components print. if !bk.scale.is_empty() && bk.scale.len() <= 8 { let keys: Vec = bk .scale @@ -702,14 +634,7 @@ pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> { Ok(()) } -/// Dump an M2's render batches as the renderer sees them, preceded by the model-level material -/// state the **static visibility cull** reads (`0x707680`: a batch is skipped -/// when `colorAlpha · transparencyWeight ≤ 0`). Batches this dump *lists* are ones that survived -/// that cull — when one of them turns out to be a stray primitive in game, these tables are where -/// the answer has to be, so they print together. -/// The facing readout for one batch (see the call site in [`m2batch`]): the first triangle's -/// winding normal, the mean authored vertex normal, and their dot. `None` for a batch with no -/// triangle to measure. +/// One batch's facing: its first triangle's winding normal, its mean vertex normal and their dot. fn winding(s: &benilla_formats::RenderSubmesh) -> Option { let tri = s.indices.get(..3)?; let p = |i: u32| s.positions.get(i as usize).copied(); @@ -724,8 +649,7 @@ fn winding(s: &benilla_formats::RenderSubmesh) -> Option { }; let norm = |v: [f32; 3]| { let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); - // A degenerate (zero-area) triangle has no direction to report — say so rather than - // printing NaNs that read like data. + // A zero-area triangle has no direction; say so rather than print NaNs that read as data. (l > 1e-9).then(|| [v[0] / l, v[1] / l, v[2] / l]) }; let facet = norm(cross(sub(b, a), sub(c, a))); @@ -741,10 +665,8 @@ fn winding(s: &benilla_formats::RenderSubmesh) -> Option { (Some(f), Some(n)) => format!("{:+.2}", f[0] * n[0] + f[1] * n[1] + f[2] * n[2]), _ => "-".to_string(), }; - // A billboard card authored back-to-front against the law's `+X`-at-the-viewer: the renderer - // turns its normals round so the card is lit off the face it presents (decision 0788). `vnorm` - // alone cannot answer this — it is the MEAN, so a batch whose normals cancel reads the same as - // one flat plane — hence the shape's own verdict here rather than an eyeball off the numbers. + // A billboard card wound away from its `+X` viewer: the renderer turns its normals round so it + // is lit off the face it presents. The mean `vnorm` cannot show this, so the shape decides. let lit_face = if s.billboard_card_faces_away() { " LIT-FACE-FLIP" } else { @@ -757,23 +679,13 @@ fn winding(s: &benilla_formats::RenderSubmesh) -> Option { )) } -/// Which BONES a batch's vertices actually ride, and — for a billboard batch — whether the batch is -/// a rigid card at all. -/// -/// The reference skins **per vertex**: every vertex is placed by its own (up to four) bone matrices -/// and weights, so a batch that straddles a billboard bone and a static one *deforms* — the static -/// end stays welded to the body while the billboard end swings to the camera. benilla instead splits -/// a batch into per-billboard-bone submeshes keyed on each **triangle's first vertex** and rotates -/// each group rigidly about that bone's pivot. The two agree only when a group's vertices are all -/// 100% on the one billboard bone; where they aren't, we tear geometry off the model that the -/// reference keeps attached. `MIXED` is exactly that condition, and `SPLIT-W` the softer form (a -/// vertex blended across bones, which a rigid group cannot express at all). +/// The bones a batch's vertices ride and, for a billboard batch, whether it is a rigid card. The +/// reference skins each vertex through its own bone weights, so only geometry wholly on the +/// billboard bone turns as one card: `MIXED` counts vertices off it, `SPLIT-W` blended vertices. fn skin_census(s: &benilla_formats::RenderSubmesh) -> Option { if s.joints.is_empty() { return None; } - // Vertices per PRIMARY bone (`bone_indices[0]` — the key our batch split groups on), in - // ascending bone order so two models' lines compare by eye. let mut per_bone: std::collections::BTreeMap = std::collections::BTreeMap::new(); for j in &s.joints { *per_bone.entry(j[0]).or_default() += 1; @@ -802,9 +714,7 @@ fn skin_census(s: &benilla_formats::RenderSubmesh) -> Option { if split > 0 { verdict.push_str(&format!(" SPLIT-W: {split} verts blended across bones")); } - // The distinct skin TUPLES with their vertex counts — a rigid group is one tuple at weight 1; - // anything else names the seam the split has to cut along. Capped so a 300-vert creature batch - // can't drown the dump (the question only ever has a handful of answers on a card-sized batch). + // Distinct skin tuples and their vertex counts, capped at 8; a rigid card is one at weight 1. let mut tuples: std::collections::BTreeMap = std::collections::BTreeMap::new(); for (j, w) in s.joints.iter().zip(&s.weights) { let key = (0..4) @@ -871,13 +781,10 @@ pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> { m.transparency_lookup, ); if let Ok(skin) = m.parse_embedded_skin(&data, 0) { - // One line per batch, because the batch → material/track mapping is the thing an alpha - // question turns on and it is NOT inferable from the render flags: `mat` indexes the - // materials list above, `color` the colour-alpha tracks (`ffff` = none), and `weight` - // indexes `transLookup` → the transparency track. The reference's combine is + // `mat` indexes the materials above, `color` the colour-alpha tracks (`ffff` for none) + // and `weight` `transLookup`. The reference skips a batch whose // `A = instanceAlpha × colors[color].alpha × transparency[transLookup[weight]].weight` - // (`0x707680`), so these three name every input to a batch's - // visibility. + // is 0 or less (`0x707680`). println!("skin batches ({}):", skin.batches().len()); for (i, b) in skin.batches().iter().enumerate() { let w = m @@ -919,9 +826,7 @@ pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> { if s.billboard.is_some() { flags.push("BILLBOARD"); } - // This batch's texcoords are GENERATED (`texture_unit_lookup > 2`), so the `uv` line below - // reports the authored UVs the runtime does *not* read — a degenerate span there is the - // asset saying "supply these", not a defect in the model. + // Texcoords generated at runtime (`texture_unit_lookup > 2`): the `uv` line is unused. if s.env_map { flags.push("ENV-MAP"); } @@ -931,16 +836,11 @@ pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> { if s.uv_anim.is_some() { flags.push("uv-anim"); } - // A character runtime slot (body atlas / hair / object / extra skin) has no embedded path — - // name the slot rather than a misleading bare NONE. let tex = match (&s.texture, s.char_slot) { (Some(t), _) => t.clone(), (None, Some(slot)) => format!(""), (None, None) => "NONE".into(), }; - // Model-space extent + centre: a batch that renders as a stray primitive is explained by - // where and how big it actually is (a degenerate zero-area batch is a different bug from a - // real card the reference hides some other way). let ext = |axis: usize| -> (f32, f32) { s.positions .iter() @@ -970,13 +870,8 @@ pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> { flags.join(" "), tex, ); - // The batch's UV extent, and the texel-per-yard density it implies. Two questions this - // answers that nothing else did: does the batch tile (range outside 0..1 — the sampler - // repeats, and a wrap discontinuity across a triangle blows up the derivative and drags - // the sampled mip to the coarsest level), and is the authored density so far above the - // screen's that even mip 0 is a minification? A cutout batch is where either shows up - // first, because a coarser mip does not merely soften it — it dissolves the silhouette - // the alpha key cuts (the Dun Morogh snow-fir report, B52). + // The UV extent and its density per yard. A range past 0..1 tiles, and a wrap across a + // triangle drags its mip to the coarsest level, which dissolves an alpha-keyed cutout. if !s.uvs.is_empty() && !s.positions.is_empty() { let uext = |axis: usize| { s.uvs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), t| { @@ -985,7 +880,6 @@ pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> { }; let (u, v) = (uext(0), uext(1)); let (du, dv) = (u.1 - u.0, v.1 - v.0); - // Model-space diagonal of the batch, as the yard scale the UV span stretches over. let diag = { let (x, y, z) = (ext(0), ext(1), ext(2)); ((x.1 - x.0).powi(2) + (y.1 - y.0).powi(2) + (z.1 - z.0).powi(2)).sqrt() @@ -1005,17 +899,11 @@ pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> { if diag > 1e-6 { du.max(dv) / diag } else { 0.0 }, ); } - // Which way the geometry FACES, in model space — the question a single-sided batch that - // renders when it shouldn't (or doesn't when it should) turns on. `facet` is the winding - // normal of the first triangle (`(p1−p0)×(p2−p0)`, WoW model axes); `vnorm` is the mean - // authored vertex normal. `dot` compares them: a batch whose winding disagrees with its own - // authored normals is wound back-to-front, and single-sided (`two_sided` absent above) it is - // culled from the side the author lit. + // A batch whose winding (`facet`) disagrees with its authored normals (`vnorm`) is wound + // back to front: single-sided, it is culled from the side the author lit. if let Some(w) = winding(s) { println!(" {w}"); } - // Which bones the batch's vertices ride — and whether a billboard group is a rigid card or - // a strip we tore off a static neighbour (see [`skin_census`]). if let Some(k) = skin_census(s) { println!(" {k}"); } @@ -1023,11 +911,9 @@ pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> { Ok(()) } -/// One track's value across sequence `seq_idx`'s band, under the reference's own key-search law -/// (`0x713d50`): the search window is `ranges[seq_idx]`, and a window that -/// collapses (`lo >= hi`) resolves to the single key `keys[lo]`. Returns `(lo, hi, held)` — the -/// value range the batch takes across the band, and whether the band keys nothing (so the value is -/// the bracket hold rather than authored motion). +/// A track's `(lo, hi, held)` over sequence `seq_idx`'s band, by the reference's key search +/// (`0x713d50`): the window is `ranges[seq_idx]`, and a collapsed window (`lo >= hi`) holds +/// `keys[lo]`, so `held` means the band keys nothing. fn band_span( track: &benilla_m2::M2ScalarTrack, seq_idx: usize, @@ -1055,15 +941,9 @@ fn band_span( (held, held, true) } -/// Dump an M2's **per-sequence material alpha**: every colour-alpha / transparency track's keys, -/// then the combined per-batch factor (`colour.alpha × transparency.weight`, the reference's -/// combine in `0x707680`) for **every sequence band**, not just the first. -/// -/// This is the "which batches does the reference hide, and when" instrument. A batch whose factor -/// is `0` in a band is one the real client **skips entirely** in that animation (`A ≤ 0` culls -/// before the blend mode is read), so a row of zeros under Stand and ones under Death is a batch -/// authored to appear only on death — exactly the voidwalker/banshee shape. `m2batch` gives the -/// batch → track wiring this reads; `m2seq` gives the bands. +/// Dump an M2's per-sequence material alpha: every colour-alpha and transparency track, then each +/// batch's `colour.alpha × transparency.weight` (the reference's combine, `0x707680`) per sequence +/// band. The reference skips a batch whose factor is 0 or less in that animation. pub fn m2alpha(chain: &mut Chain, internal_path: &str) -> Result<()> { let name = normalize(internal_path); let data = chain @@ -1076,10 +956,9 @@ pub fn m2alpha(chain: &mut Chain, internal_path: &str) -> Result<()> { println!("no embedded skin — no batches to combine"); return Ok(()); }; - // Sequences in **file order**, straight off the header array (count@0x1c/ofs@0x20, stride - // 0x44: anim id u16 @+0x00, band start/end u32 @+0x04/+0x08). Deliberately NOT - // `parse_m2_animations`, which drops zero-duration sequences and so renumbers the list — and - // the per-sequence `ranges` array below is indexed by the FILE slot. + // Sequences in file order off the header array (count at 0x1c, offset at 0x20, stride 0x44: + // u16 anim id at +0, u32 band start and end at +4 and +8), not `parse_m2_animations`, which + // drops zero-duration sequences: `ranges` is indexed by file slot. let seqs: Vec<(u16, (u32, u32))> = { let n = u32::from_le_bytes(data[0x1c..0x20].try_into().unwrap_or_default()) as usize; let o = u32::from_le_bytes(data[0x20..0x24].try_into().unwrap_or_default()) as usize; @@ -1119,8 +998,7 @@ pub fn m2alpha(chain: &mut Chain, internal_path: &str) -> Result<()> { .map(|&(ms, v)| format!("{ms}={v:.3}")) .collect::>() .join(" "), - // The per-sequence key windows the reference indexes by the playing sequence's file - // slot — printed so the `*` hold cells below can be checked against the file itself. + // The key windows the reference indexes by the playing sequence's file slot. if t.ranges.is_empty() { "none (whole-track search)".to_string() } else { @@ -1142,10 +1020,9 @@ pub fn m2alpha(chain: &mut Chain, internal_path: &str) -> Result<()> { println!(" #{i:<3} {}", keys(t)); } - // Each skin batch's two factor tracks, resolved exactly as the draw loop resolves them: - // colorIndex indexes `colors[]` DIRECTLY (out of range — incl. the 0xffff sentinel — means the - // factor doesn't apply); textureWeightComboIndex goes through `transLookup` (and applies only - // when the batch's textureCount is non-zero). + // As the draw loop resolves them: `colorIndex` indexes `colors[]` directly, and out of range + // (0xffff included) means no factor; the weight goes through `transLookup`, and only when + // `textureCount` is non-zero. let batches = skin.batches(); let color_of = |b: &benilla_m2::SkinBatch| m.color_alpha_tracks.get(b.color_index as usize); let weight_of = |b: &benilla_m2::SkinBatch| { @@ -1166,8 +1043,7 @@ pub fn m2alpha(chain: &mut Chain, internal_path: &str) -> Result<()> { for (i, &(anim_id, band)) in seqs.iter().enumerate() { print!("{i:>4} {anim_id:>5} {:>7}..{:<7}", band.0, band.1); for b in batches { - // A gseq-clocked track ignores the sequence band entirely (it runs on the global - // sequence's own clock), so report its full range rather than a band slice. + // A gseq track keeps its global sequence's clock, not the band: print its full range. let span = |t: Option<&benilla_m2::M2ScalarTrack>| match t { None => (1.0, 1.0, false), Some(t) if t.keys.is_empty() => (1.0, 1.0, false), @@ -1187,8 +1063,7 @@ pub fn m2alpha(chain: &mut Chain, internal_path: &str) -> Result<()> { } else { format!("{lo:.2}..{hi:.2}") }; - // `HIDE` marks a batch the reference never draws in this sequence: the combine can - // only reach 0 there, and `A ≤ 0` skips the batch outright. + // `HIDE`: the combine stays at or below 0 here, and `A ≤ 0` skips the batch. let cell = if hi <= 0.0 { format!("{cell} HIDE") } else { @@ -1203,10 +1078,8 @@ pub fn m2alpha(chain: &mut Chain, internal_path: &str) -> Result<()> { reference's collapsed key window holds (`0x713d50`)" ); - // The same question asked of OUR bake, per RENDER batch — which is not the same index space: - // a batch spanning several billboard bones splits into one submesh per bone, so `m2batch`'s - // render list runs longer than the skin list. This half is what the renderer will actually do, - // so a disagreement with the table above is a bug in the bake, not in the art. + // Our bake per render batch (a billboard batch may split per bone, lengthening the list); + // disagreeing with the table above is a bake bug, not the art's. let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or(""); let subs = benilla_formats::parse_m2_render_submeshes(&data, dir, &[]) .with_context(|| format!("parsing M2 render submeshes '{name}'"))?; @@ -1243,32 +1116,21 @@ pub fn m2alpha(chain: &mut Chain, internal_path: &str) -> Result<()> { Ok(()) } -/// Decode a particle emitter's file-flag word into the mechanism names the runtime keys off -/// (each is a [`benilla_formats::ParticleEmitterDef`] predicate), plus any bit the loader maps -/// to nothing — an unmapped bit on a model that looks wrong is a lead, not noise. +/// An emitter's file flags as the mechanisms the runtime keys off, plus any unmapped bits. fn part_flags(flags: u32) -> String { const NAMED: [(u32, &str); 14] = [ - // The reference has NO "lit" bit — 0x1 is the UNLIT flag, the inverse of the wiki lore - // (`0x70bb00`). Naming it the way the binary reads it is the - // point: an emitter WITHOUT this bit is the one that takes the scene's light, and that - // silent majority-of-one is what a dump has to make visible. + // 0x1 is unlit, not lit (`0x70bb00`): an emitter without it takes the scene's light. (0x0001, "unlit"), - // Runtime `rt+0x1ac` bit 0x10 (loader `0x70fd13` → `0x7b5d00`): the emitter's live - // particles are heap-sorted back-to-front on view depth before the quad writer runs - // (`0x7b3a10`), instead of drawn in pool order. NOT YET IMPLEMENTED — we draw pool - // order for every emitter; harmless where a cloud is one flat colour (alpha coverage is - // order-independent), visible where it is not. + // Runtime `rt+0x1ac` bit 0x10 (loader `0x70fd13` → `0x7b5d00`): the reference heap-sorts + // live particles back to front before the quad writer (`0x7b3a10`); we draw pool order. (0x0002, "depthSortParticles(TODO)"), - // NOT unmapped, and printing it as a lead sent one session hunting it as the ride switch - // (1578): file `0x8` feeds the SECOND runtime flag word, `rt+0x194` bit 1 = NOT(file 0x8) - // (loader `0x70fd01`), the vertex-format/blend word, orthogonal to simulation space. - // Bit 1's own reader is not identified; bit 0's (from file `0x1`) picks a 4- vs 8-word - // vertex stride. Half the corpus authors it. + // File 0x8 sets `rt+0x194` bit 1 = NOT(0x8) (loader `0x70fd01`), the vertex-format word, + // not the ride switch; bit 1's reader is untraced, and bit 0 (file 0x1) picks a 4- or + // 8-word vertex stride. Half the corpus authors 0x8. (0x0008, "vertexFormat(rt+0x194 b1, reader unread)"), - // **The ride-vs-trail switch** (spawn `0x7b8a9a`, draw `0x7b3e6f`; decision 1578): SET - // stores emitter-LOCAL and re-applies the live emitter matrix at draw (a rigid ride); - // CLEAR bakes the birth into WORLD and never re-applies it, so a moving host lays a trail - // `speed × lifetime` long. 30.4% of the corpus, 71% of `Item\ObjectComponents`. + // The ride-or-trail switch (spawn `0x7b8a9a`, draw `0x7b3e6f`): set keeps particles + // emitter-local, riding the live emitter; clear bakes birth into world space, so a moving + // host lays a trail `speed × lifetime` long. (0x0010, "modelSpace(ride)"), (0x0020, "sizeByInstanceScale"), (0x0040, "inheritEmitterMotion"), @@ -1305,7 +1167,7 @@ fn keys_str(keys: &[(f32, f32)]) -> String { .join(" ") } -/// Dump one M2's particle emitters in full — see the `M2part` command doc. +/// Dump one M2's particle emitters in full. pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { let name = normalize(internal_path); let data = chain @@ -1344,9 +1206,7 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { 1 => "tail", _ => "head+tail", }, - // The scene-light verdict the renderer will take (`ParticleEmitterDef::lit`) — the - // difference between "this sheet is shaded by the world" and "this sheet is a - // full-white cutout", which no other line here shows. + // Whether the renderer lights this emitter from the scene (`ParticleEmitterDef::lit`). if e.lit { "LIT" } else { "unlit" } ); println!( @@ -1364,13 +1224,8 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { println!(" recursion model (child emitters): {r}"); } if let Some(s) = &e.spline { - // EVERY control point, and at full precision. This used to print `first .. last`, - // which is actively misleading on a cubic Bézier: the path is `3K+1` points and the - // two INTERIOR handles of each segment are what bend it, so an emitter whose ends - // both sit at the origin can still fling its particles a long way — or not move them - // at all. Reading "(0,0,0) .. (0,0,0)" as "the particles never travel" is a guess - // the truncated line invites and cannot support (B228 nearly rested on it). - // Wrapped at 4 per line so a long path stays readable. + // Every control point at full precision: the path is `3K+1` points of cubic Bézier, + // bent by each segment's interior handles, so its ends alone say nothing of its reach. let pt = |p: &[f32; 3]| format!("({:.4},{:.4},{:.4})", p[0], p[1], p[2]); println!( " spline: {} control points ({} cubic segment(s)), model-local:", @@ -1383,7 +1238,6 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { } } - // Emission timing: the rate/gate pair, per file sequence slot. match e.timing.constant_rate() { Some(r) => println!(" rate {r:.2}/s (same in every sequence)"), None => { @@ -1420,9 +1274,7 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { } ); - // The nine emission parameter tracks: the flat ones on one line, the moving ones spelled - // out per slot (decision 0844 — a flattened track is exactly how a spread-out effect - // collapses to a point). + // The nine emission parameter tracks: flat ones on one line, moving ones per slot. let views = e.params.channel_views(); let mut flat: Vec = Vec::new(); let mut animated: Vec = Vec::new(); @@ -1481,10 +1333,7 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { c[0], c[1], c[2], c[3] ); } - // 4 decimals, not 3: UI models author their sizes in thousandths, where `{:.3}` turns the - // autocast shine's 0.0015 mid-key into a printed "0.002" — a 33% error that a transcriber - // reads as authored truth (B228). The colour ramp above stays at 3 because 0..1 channels - // do not have that problem. + // Four decimals: UI models author sizes in thousandths, as the autocast shine's 0.0015. println!( " size {:.4} -> {:.4} -> {:.4} yd (half-extent)", ol.scale[0], ol.scale[1], ol.scale[2] @@ -1503,12 +1352,9 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { ol.repeat[1] ); - // The derived read: what this record actually puts on screen. let rate = e.timing.peak_rate(); - // A burst emitter's count is NOT its peak rate: the burst fires on the rising edge of - // `enabled && rate > 0`, and an emitter whose gate closes on the same keyframe its rate - // opens fires nothing at all. Reading `peak_rate` here reported 50 phantom particles for - // `Strike_Impact_Chest`'s flare and sent a whole diagnosis 2.7x over on count. + // A burst's count is not its peak rate: it fires on the rising edge of + // `enabled && rate > 0`, and one whose gate closes as its rate opens fires nothing. let burst = e.timing.first_burst(Some(0)); let life = e.params.peak_lifespan(); let speed = views[0] @@ -1517,8 +1363,7 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { .flatten() .flat_map(|k| k.iter().map(|&(_, v)| v.abs())) .fold(0.0f32, f32::max); - // Drag caps total travel near `speed/drag` (the integrator's exponential decay); with no - // drag a particle simply coasts for its lifetime. + // Drag caps travel near `speed/drag`; without drag a particle coasts for its whole life. let reach = if e.drag > 1e-6 { (speed / e.drag).min(speed * life) } else { @@ -1548,17 +1393,9 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { Ok(()) } -/// Dump an M2's **camera table**, in raw file-index order — the index space a `` widget's -/// `Model:SetCamera(n)` walks (decision 2027; the selection is raw, `cameraLookup` is not -/// consulted there, so the table position IS the answer and the record's `type` is not). -/// -/// Prints, per record: its `type` word, the eye and look-at target at rest (`base + key 0`, raw -/// WoW model space), the diagonal fov in radians and degrees, near/far, the roll, and the key -/// count of each of the three tracks — the last column being the one that says whether the camera -/// is a still rig (every character/creature/interface camera in the chain) or an authored path -/// (the `Cameras\*.m2` fly-bys). The `cameraLookup` table is printed beside it, because the -/// portrait bake selects through it and the pane does not, and confusing the two is a one-line -/// mistake with a wrong picture at the end of it. +/// Dump an M2's camera table in raw file-index order, the index `Model:SetCamera(n)` selects by +/// without `cameraLookup`, which prints beside it because the portrait bake selects through it. +/// Key counts tell a still rig from an authored path (the `Cameras\*.m2` fly-bys). pub fn m2cam(chain: &mut Chain, internal_path: &str) -> Result<()> { let name = normalize(internal_path); let data = chain diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index b8775663..cf1fd72a 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -1,13 +1,6 @@ -//! `benilla-extract` — Phase 1 CLI over WoW 1.12.1 (build 5875) MPQ archives. -//! -//! Implemented: `list`, `extract` (raw bytes), operating on the **patch chain** -//! (later archives override earlier; real names come from patch.MPQ's `(listfile)`). -//! BLP->PNG and DBC->CSV come next. -//! -//! This file is the stable face: the clap [`Cli`]/[`Command`] definitions, the dispatch `match`, -//! and the tiny helpers shared across concerns. The heavier subcommands live in sibling modules by -//! concern: per-model M2 dump printers ([`m2dump`]), corpus-scan reports ([`scan`]), and the spell -//! visual chain ([`spellvis`]). +//! `benilla-extract`: a CLI over the 1.12.1 (build 5875) MPQ patch chain, where later archives +//! override earlier ones and names come from `patch.MPQ`'s `(listfile)`. This file holds the clap +//! commands, their dispatch and the shared helpers; the subcommands live in modules by concern. use std::path::PathBuf; @@ -58,28 +51,20 @@ enum Command { internal_path: String, /// Output `.png` file. output: PathBuf, - /// Also write every authored mip level (`.mip.png`) and print a per-level - /// texel census: how many texels the author left transparent vs not, the luma range of - /// each class, and how many sit below 128 — the set that DARKENS under a Mod2x lane, - /// which reads no alpha. The "what does the far sampler see" instrument (B358). + /// Also write every mip (`.mip.png`) and census each: transparent and opaque + /// texels, their luma, and how many sit below 128, which darken under alpha-blind Mod2x. #[arg(long)] mips: bool, }, - /// Composite ONE character's body atlas off the chain and report what painted what: the - /// equipment blits in blit order (with the file each region name resolved to, or `MISSING`), - /// the per-tile rows repainted vs the same character naked, and the geoset set the same - /// equipment selects. The "this garment stops early / that slot repainted this one" instrument - /// — a dressed body is ten fixed atlas tiles, and every defect in that class is one tile - /// receiving the wrong contribution. + /// Composite one character's body atlas and report what painted what. Charatlas { - /// `ChrRaces` id (1 human · 2 orc · 3 dwarf · 4 night elf · 5 undead · 6 tauren · 7 gnome · - /// 8 troll). + /// `ChrRaces`: 1 human, 2 orc, 3 dwarf, 4 night elf, 5 undead, 6 tauren, 7 gnome, 8 troll. #[arg(long)] race: u8, /// 0 male · 1 female. #[arg(long)] sex: u8, - /// `skinColor` — the CharSections variation the base skin + head sections key on. + /// `skinColor`, the CharSections variation the base skin and head sections key on. #[arg(long, default_value_t = 0)] skin: u8, /// `faceType`. @@ -94,29 +79,21 @@ enum Command { /// `hairColor`. #[arg(long, default_value_t = 0)] hair_color: u8, - /// The eight worn `ItemDisplayInfo` ids, comma-separated, in bodyslot order: - /// shirt,chest,belt,pants,boots,wrist,gloves,tabard. `0` leaves a slot empty; a short list - /// leaves the rest empty. + /// The eight worn `ItemDisplayInfo` ids, comma-separated in bodyslot order (shirt, chest, + /// belt, pants, boots, wrist, gloves, tabard); a 0 or a short list leaves slots empty. #[arg(long, value_delimiter = ',', default_value = "0")] slots: Vec, - /// The wearer's guild tabard, comma-separated in - /// `emblemStyle,emblemColor,borderStyle,borderColor,backgroundColor` order — the five - /// indices `SMSG_GUILD_QUERY_RESPONSE` carries (decision 1704). Omit for "no guild". Only - /// paints when the tabard slot holds a guild-emblem display (20621 is the shipped one). + /// The guild tabard, `emblemStyle,emblemColor,borderStyle,borderColor,backgroundColor` as + /// `SMSG_GUILD_QUERY_RESPONSE` carries them; only a guild-emblem tabard (20621) shows it. #[arg(long, value_delimiter = ',')] emblem: Option>, /// Write the composited atlas here as a PNG (256²). #[arg(long)] out: Option, }, - /// Measure every shipped glue scene's **art extent** (decision 1619): how far the opaque art - /// of each `UI_*` diorama covers around its authored camera 0, and the window aspects past - /// which the glue framing law stops widening and zooms instead. The instrument behind B330 - /// (the login backdrop's edges showing at 16:9) — the same measurement the client makes at - /// scene spawn, printed for all seven scenes with the looser readings beside it. + /// Measure each shipped glue scene's art extent and the window aspects where it runs out. Glueextent { - /// Also print every batch's footprint in the frame (front/back/clipped triangle counts, - /// projected extents) — which card sets the measured edge. + /// Also print every batch's footprint: front, back and clipped triangles and its extents. #[arg(long)] batches: bool, }, @@ -127,583 +104,297 @@ enum Command { /// Output `.csv` file. output: PathBuf, }, - /// Dump a spell's visual chain: spell → SpellVisual stages → each kit's anim/sound (decision - /// 0099's phase-2 instrument; columns per decision 0107). + /// Dump a spell's visual chain: its `SpellVisual` stages and each kit's contents. Spellvis { /// The `Spell.dbc` id (e.g. 133 = Fireball). spell_id: u32, }, - /// Census the **beam/chain** system (decision 0955): the 18-row `SpellChainEffects` table, - /// then every `SpellVisualKit` that draws a beam — the row it names, its beam count and - /// flag, and the spells that reach it through which lifecycle stage. The scope instrument - /// for B161 ("Chain Lightning has no chain effect"), and the check that the `CharParamZero` - /// small-int decode is the real mechanism: every live slot must land on a real row. + /// Census the beam system: `SpellChainEffects` and every `SpellVisualKit` that draws a beam. Chaincensus, - /// Census the **camera-shake** system (decision 1540): the 24 shipped `CameraShakes.dbc` - /// presets and every `CreatureModelData` row that names one, through the footstep column or - /// the death-thud column. The scope instrument for B298 ("walking past an Ancient Protector - /// shakes no screen"), and the check that fields 11/12 really are `CameraShakes` keys — every - /// live value must land on a real row. + /// Census the camera-shake system: the 24 `CameraShakes.dbc` presets and all that names one. Shakecensus, - /// Census the **death thud** — the body-fall sound a corpse makes on landing (`$DTH` → - /// `0x6236e0`, the sibling of the camera shake above). Two halves: the `DeathThudLookups.dbc` - /// matrix in full (`SizeClass × TerrainTypeSoundID` → the named land/water `SoundEntries` - /// kits), and the population sweep of which creature M2s key a `$DTH` at all, with the size - /// class their displays resolve to. The scope instrument for "a big corpse hits the ground - /// silently": it separates authored silence (an empty water column) from a real gap. + /// Census the death thud a corpse makes on landing (`$DTH`, `0x6236e0`): the + /// `DeathThudLookups.dbc` grid and which creature models key a `$DTH`. Thudcensus, - /// Census the `SpellVisualKit` **CharProc** columns (a kit's effect on the BODY — its alpha, - /// its tint): which proc types the shipped table carries, which lifecycle stage reaches each - /// from a live spell, and every state-stage (aura-lifetime) proc in full. The scope instrument - /// for the aura-state CharProc system. + /// Census the `SpellVisualKit` CharProc columns, a kit's effect on the body, by stage. Charprocs, - /// Census the `SpellVisualKit` **animation** column (field 2) — the half of a kit that plays a - /// clip on the unit's own BODY, as opposed to its attach-point effect models, its CharProcs or - /// its camera shake. Which anim ids the shipped table asks for, which lifecycle stage reaches - /// each (the stage picks the consumer: a `cast`/`impact` anim is a one-shot on the - /// caster/victim, a `state` anim belongs to an aura's whole life), then the state set in full - /// and the impact set ranked. The scope instrument for "the spell landed on me and my - /// character did nothing" — an anim that is never asked for leaves no trace to grep, unlike an - /// effect model that fails to spawn. `ANIM-ONLY` marks the state kits whose whole visual is the - /// anim, the class a "does this kit do anything?" test drops (the B114 shape one level over). + /// Census the `SpellVisualKit` animation column (field 2), a clip on the unit's body, by stage. Kitanim, - /// Dump an M2's collision hull as the mover collides with it: vertex/triangle counts, the - /// model-space AABB (WoW axes, Z up), and its extents — the "what does walking into this - /// actually hit" instrument (the step-up climb-vs-slide asset question, decision 0195; a - /// placement's own scale still multiplies these numbers). + /// Dump an M2's collision hull: counts and the model-space AABB (WoW axes, Z up), unscaled. M2coll { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Dump an M2's animation sequences in file order: `AnimationData.dbc` id, loop/clamp flag, - /// duration, authored design speed (the rate divisor), variation frequency, replay range — - /// the variation/replay instrument (decisions 0114/0117; sequences sharing an id are its - /// variation chain). + /// Dump an M2's sequences in file order: anim id, loop or clamp, duration, design speed (the + /// rate divisor), variation frequency and replay range; sequences sharing an id are variations. M2seq { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Dump an M2's **camera table** by raw file index — the index space a `` widget's - /// `Model:SetCamera(n)` walks (decision 2027): type, rest eye/target, diagonal fov, near/far, - /// roll, and each track's key count (which says still rig vs authored path). The - /// `cameraLookup` table is printed beside it — the portrait bake selects through that, the - /// pane does not. + /// Dump an M2's camera table by raw file index, the index `Model:SetCamera(n)` selects by; + /// the portrait bake selects through `cameraLookup` instead, printed beside it. M2cam { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Dump an M2's animation EVENT keyframes per sequence (`$CSS`/`$CAH`/`$AH0-3`/`$CPP`/`$HIT`…, - /// time + payload) — the event-order instrument (decision 0279: whether `$CPP` precedes the - /// impact tag decides defense-anim vs flinch on the shared swing record). + /// Dump an M2's animation event keyframes per sequence; whether `$CPP` precedes the impact + /// tag decides defense animation or flinch. M2events { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Dump an M2's attachment points (id + bone) — which sheath/held/effect anchors a model - /// actually has (decisions 0072/0122; a placement whose id is absent hangs nothing). + /// Dump an M2's attachment points (id and bone); a placement on an absent id hangs nothing. M2attach { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Dump an M2's animation-channel summary: sequence-0 bone motion, global-sequence bone - /// channels, transparency/color track key counts, texture transforms, and — per particle - /// emitter — its full def: shape, blend mode, head/tail, emission-rate keys (burst emitters - /// key `0 → peak → 0`), lifespan/speed/scale, position/area, resolved texture, and the - /// over-life color/alpha/size ramps. The one-command diagnosis for "this effect looks wrong / - /// doesn't show" (decisions 0130/0141); paired with `doodadscan`. + /// Dump an M2's animation channels, texture transforms and every particle emitter in full. M2anim { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Dump an M2's bone table: KeyBoneID, flags (billboard bits tagged), the `flags & 0x7` - /// parent-ignore bits spelled out (`ign` — which of the parent's Translate/Scale/Rotate the - /// bone refuses), parent, pivot, and which sequences key each bone (T/R/S key counts) — the - /// bone-attach / billboard-geometry instrument (decisions 0028/0122: which bone a rider - /// actually rides and whether it faces the camera; 0945: what it actually inherits from its - /// parent, which is how a saddle travels with the gallop without rotating with it). + /// Dump an M2's bone table, with the parent channels each bone ignores (`flags & 0x7`). M2bones { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Dump an M2's render batches as the renderer sees them: geoset, blend/flags - /// (emissive/additive/two-sided/billboard/depth), texture, vertex count, and whether an - /// alpha/UV animation baked — the "why doesn't this part of the model draw (or draw wrong)" - /// instrument (the Orgrimmar-bonfire missing-flame diagnosis). + /// Dump an M2's render batches as the renderer sees them. M2batch { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Dump an M2's **per-sequence material alpha**: every colour-alpha/transparency track's raw - /// keys, then the combined per-batch factor for EVERY sequence band — the "which batches does - /// the reference hide, and in which animation" instrument. A `HIDE` cell is a batch the real - /// client skips outright in that sequence (`A <= 0` culls before the blend mode is read, - /// `0x707b3a`–`0x707b5c`). Pair with `m2batch` (the batch -> track wiring) and `m2seq` (the - /// bands). + /// Dump an M2's per-sequence material alpha: each colour-alpha and transparency track, then + /// every batch's combined factor per sequence band. `HIDE` marks a batch the reference skips + /// in that sequence: `A <= 0` culls before the blend mode is read (`0x707b3a`-`0x707b5c`). M2alpha { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Dump ONE M2's particle emitters in full — every authored field of every record, as the - /// runtime parses it: bone/position/shape/blend/head-tail, texture + atlas, the decoded file - /// flags, the rate/gate timing and the nine emission tracks (flagged `ANIM` with their keys - /// when they actually move), drag/spin/tail/twinkle, spline and geometry/recursion models, - /// and the over-life colour/size/flipbook ramps. Closes with a derived read per emitter — - /// steady-state live count, ballistic reach, size range — so "this flame is a bright blob, - /// not a spread-out fire" is answered from the asset before anything is blamed on the - /// renderer. The per-model counterpart to the corpus sweeps (`partcensus`, `partscan`). + /// Dump one M2's particle emitters in full, with each one's derived count, reach and size. M2part { /// Internal path to the `.m2` (forward or back slashes accepted). internal_path: String, }, - /// Sweep every `.m2` and report where **addressing an attachment by id** through the model's - /// AttachLookup (the reference's `0x710310`) disagrees with scanning its record table — the - /// duplicate-id census behind decision 0805 (item glows hang on ids 0..4 of the item model). + /// Sweep every `.m2` for where addressing an attachment by id through AttachLookup + /// (`0x710310`) disagrees with scanning its records; item glows hang on the item's ids 0..4. Attachscan { /// Optional path prefix to restrict the sweep (e.g. `Item\ObjectComponents\Weapon`). prefix: Option, }, - /// Sweep every `.m2` in the chain and list the models carrying RIBBON emitters (header - /// `0x134`) — the population instrument for the ribbon subsystem (weapon trails, streamers): - /// which content actually authors one, before we build for it. + /// Sweep every `.m2` and list the models carrying ribbon emitters (header `0x134`). Ribbonscan, - /// Sweep every `.m2` and census its particle emitters' over-life **flipbook** fields: cell - /// ramps that play BACKWARDS (`begin > end` — legal, shipped, and fatal to a reader that - /// clamps into the pair), tail streaks whose ramp differs from the head's, indices past the - /// atlas (the reference wraps the column and lets the row run off), per-segment repeat counts, - /// and the two degenerate shapes the reference itself falls back on. Decision 0685. + /// Sweep every `.m2` and census particle flipbook fields: backward ramps (`begin > end`, legal + /// and shipped), tail ramps unlike the head's, indices past the atlas (the reference wraps the + /// column, the row runs off), repeat counts, and the reference's two degenerate fallbacks. Cellscan, - /// Sweep every `.m2` (optionally under a path prefix) and list the models whose MATERIAL - /// table authors the MULTIPLY blend modes 5 (Mod) / 6 (Mod2x) — the ARMORREFLECT sheen - /// family (decision 0528): the population instrument for the multiply-blend mechanism. + /// Sweep every `.m2` for materials with the multiply blends 5 (Mod) and 6 (Mod2x), the + /// armor-reflect sheen family. Blendscan { /// Internal-path prefix filter (e.g. `item\objectcomponents\weapon`), case-insensitive; /// all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and classify its BILLBOARD usage: - /// which arms it authors (spherical / lock-X / lock-Y / lock-Z), and whether geometry rides - /// them DIRECTLY (verts skinned to the billboard bone — the per-batch card path) or - /// INHERITED (verts on a descendant — the joint-palette path, decision 0205). The - /// population instrument for the billboard mechanism: which arms real content exercises, so - /// a class of spell visuals is closed by mechanism instead of tested spell-by-spell. + /// Sweep every `.m2` and classify its billboards: which arms it authors (spherical, lock-X, + /// -Y or -Z) and whether geometry is skinned to the billboard bone or rides a descendant. Bbscan { /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and classify each BILLBOARD batch by - /// which way its geometry FACES. A billboard bone puts the model's +X toward the viewer, so a - /// batch's winding normal decides whether it is ever seen: +X faces the camera, −X faces away - /// and — single-sided — is backface-culled by the reference from every angle. The population - /// instrument for "the card renders but shouldn't": the away+single-sided list is exactly the - /// authored placeholder geometry the reference hides and a forced-two-sided renderer reveals. + /// Sweep every `.m2` and classify each billboard batch by facing: a billboard bone turns +X to + /// the viewer, so a single-sided batch facing -X is culled by the reference from every angle. Bbfacescan { - /// Internal-path prefix filter (e.g. `world\generic`), case-insensitive; all models if omitted. + /// Internal-path prefix filter (e.g. `world\generic`), case-insensitive; all if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and report models that author flat - /// ground-plane render geometry: batches whose vertices all sit at model-space z≈0 (WoW axes, - /// Z up), which sloped terrain buries (Battle Shout's crescents are the canonical case — 6 - /// batches, each a 4-vert quad, every vertex exactly z=0, each quad skinned 100% to a single - /// bone). Flat batches sub-classify QUAD-1BONE (that exact crescent shape) vs OTHER-FLAT — the - /// population instrument for how general the ground-plane renderer mechanism must be. + /// Sweep every `.m2` for flat ground-plane batches (every vertex at model-space z≈0), which + /// sloped terrain buries; `QUAD-1BONE` is Battle Shout's crescent shape, a quad on one bone. Groundscan { /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and census the batches carrying - /// **degenerate authored vertex normals** — `(0,0,0)`, which the shipped corpus really does - /// author (the AQ40 Qiraji Brainwasher's sleeves, Uldaman Ironaya's skirt). The reference - /// consumes such a normal as the zero vector and its order-2 SH collapses to the flat DC term, - /// so the surface draws lit; a renderer that `normalize()`s it gets NaN, `clamp(NaN)` floors - /// the whole lighting factor to 0, and the batch renders PURE BLACK over its correct texture. - /// The population instrument for that class (decision 1268, bug B134) — `ALL` marks a batch - /// where every vertex is degenerate, and the tail names the worst-hit models. + /// Sweep every `.m2` for batches with zero authored vertex normals: the reference takes one as + /// is and its order-2 SH collapses to the flat DC term, so the surface draws lit, where + /// `normalize()` gives NaN and a black batch. `ALL` marks a batch degenerate at every vertex. Normalscan { /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and measure how far its authored header - /// bounding box — the model's **all-animation** vertex extent, and the box the reference - /// derives its doodad cull sphere from (`rec+0x5c`/`rec+0x68`) — reaches past its **bind-pose** - /// vertex extent. The population instrument for decision 1259: an animated placement's submesh - /// keeps its transform at the placement origin while the joint palette moves its vertices, so a - /// bind-pose bound culls an object whose geometry is still on screen. The ambient critters are - /// the extreme — `World\critter\birds\Bird01.m2` authors a 67 yd box around a 1.2 yd body, - /// because its root bone flies it 64 yd along a circuit. Also reports the reverse (`SHORT`: - /// bind-pose geometry OUTSIDE the authored box), which is why the fix unions the two boxes. + /// Sweep every `.m2` and measure how far its header bounds, the all-animation extent the + /// reference derives its doodad cull sphere from (`rec+0x5c`/`rec+0x68`), reach past its + /// bind-pose extent; `SHORT` marks bind-pose geometry outside the header box. Animboundscan { /// Internal-path prefix filter (e.g. `world\critter`), case-insensitive; all if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and census the geometry a - /// **non-character** spawn draws that the reference may not: MULTI-GEOSET models (more than - /// one `skinSectionId` — only the character compositor selects among them, so every other - /// spawn path draws all of them), UNTEX batches (no embedded texture and no character runtime - /// slot) and TINY batches (at most 2 faces). The population instrument behind the stray - /// untextured-primitive reports; `m2batch` then explains a single model in full. + /// Sweep every `.m2` for geometry a non-character spawn draws that the reference may not: + /// multi-geoset models (only the character compositor picks among geosets), untextured batches + /// and tiny ones (at most 2 faces). Geosetscan { /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every model named by **GameObjectDisplayInfo.dbc** and resolve what the reference's - /// GameObject animation arm plays in each reachable `GAMEOBJECT_STATE` × `GAMEOBJECT_ANIMPROGRESS` - /// substate (the substate table `0x5f3c30`, the `0x8607e4` LUT, the four-way missing-sequence - /// remap `0x5f3972`) — beside the generic loader seed the same model gets at build (`0x710153`, - /// animation id 0 through `playableAnimationLookup`). The population instrument for "which - /// GameObjects can the wire state actually be SEEN on": a model whose every substate lands on the - /// seed's own sequence is state-blind, so skipping the arm on its GO type costs nothing, while a - /// STATE-SENSITIVE model renders in the wrong pose the moment its type is left off the machine. - /// Also counts the models whose pose depends on the missing-sequence remap (benilla plays - /// nothing there today, i.e. bind pose) and the ones reaching a rate-0 freeze leg. + /// Sweep every `GameObjectDisplayInfo` model and resolve what the reference's GameObject + /// animation arm plays per reachable state and anim-progress substate (substate table + /// `0x5f3c30`, LUT `0x8607e4`, missing-sequence remap `0x5f3972`) against the loader's seed + /// (`0x710153`, animation 0 through `playableAnimationLookup`); a model whose every substate + /// lands on the seed's sequence is state-blind. Goanimscan, - /// Sweep every `.m2` and census the **event table's positional half**: the `bone` and - /// `position` every `M2Event` record carries beside its 4CC. The reference's event dispatchers - /// hand their arms the event's own world point (the authored `position` through its bone's live - /// matrix and the model's placement), while a consumer that plays at the model root uses the - /// placement alone — so this reports, per 4CC, how many records sit off the origin and how many - /// ride a bone any sequence keys. Where both are zero the two are the same point. + /// Sweep every `.m2` and census each `M2Event`'s `bone` and `position`: the reference's + /// dispatchers play at the event's own world point, so per 4CC it counts the records off the + /// origin and those on a bone some sequence keys. Eventmarkerscan { /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted. prefix: Option, }, - /// Census the **GameObject display sound slots** (`GameObjectDisplayInfo.Sound[0..9]`) against - /// the only thing that can reach them. Exactly one function in the reference reads those - /// columns (`0x5f4010`) and it is called only from the GO M2 anim-event dispatcher - /// (`0x5f3e20`): `$GO0..5` -> slots 0..5, `$GC0..3` -> slots 6..9. So a filled column is - /// audible only when the display's own model authors the matching event tag AND that tag sits - /// on a sequence the GameObject animation arm can actually play. Reports, per slot: columns - /// filled, of those how many are tagged, how many of those are on an armable sequence, and how - /// many name a LOOPING (0x200) kit — the flag that selects `0x5f4010`'s emitter-pool lane over - /// its one-shot lane. + /// Census `GameObjectDisplayInfo.Sound[0..9]`: only `0x5f4010` reads them, called only from + /// the GameObject anim-event dispatcher (`0x5f3e20`), `$GO0..5` to slots 0-5 and `$GC0..3` to + /// 6-9. Per slot: filled, tagged by the display's model, on an armable sequence, and naming a + /// looping kit (0x200, which picks `0x5f4010`'s emitter-pool lane over its one-shot lane). Goslotscan, - /// Sweep every `.m2` (optionally under a path prefix) and census the models whose batch - /// visibility is PER SEQUENCE — geometry the reference draws in one animation and skips in - /// another (the verified `A <= 0` alpha cull). The population instrument for "a single-sequence - /// material bake draws the wrong set"; `m2alpha` then explains one model in full. + /// Sweep every `.m2` for batches whose visibility changes per sequence, drawn in one + /// animation and culled by `A <= 0` in another. Alphascan { /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and census the **effect-model animation - /// lifecycle**: which models author the `Stand`(0) -> `Hold`(158) -> `Decay`(159) triple, and - /// what a consumer that arms ONE sequence and never advances renders for each. A spell-effect - /// model puts its grow-in in the clamping `Stand`, its sustained pulse in the looping `Hold` - /// and its fade-out in `Decay` — so arming only the first freezes on the birth's last frame for - /// the effect's whole life (the frozen Ice Barrier shield). The population instrument for that - /// class: FREEZE / hold-loops / decay-only counts, corpus-wide; `m2seq` explains one model. + /// Sweep every `.m2` for the effect lifecycle `Stand`(0), `Hold`(158), `Decay`(159): arming + /// only the clamping Stand freezes the effect on its last frame instead of pulsing in Hold. Fxlifescan { /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and measure how far our SKELETAL parse - /// sits from the reference's sampler, per bone track per sequence band: empty bands (our - /// nearest-key clamp vs the file's own `interpolation_ranges` window), held band edges (our - /// hold vs the reference's ongoing lerp toward the next key), and STEP tracks (`interp == 0`, - /// which the reference copies and we interpolate). The population instrument behind decision - /// 0133's named residual — and the safety check that a band's keys never fall outside the - /// window the reference searches. + /// Sweep every `.m2` and measure our skeletal parse against the reference's sampler, per bone + /// track and band: empty bands (our nearest-key clamp, its `interpolation_ranges` window), held + /// band edges (our hold, its lerp toward the next key) and step tracks (`interp == 0`, which it + /// copies and we interpolate). It also checks no band's keys fall outside the searched window. Bonescan { /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and census every particle-emitter - /// FEATURE the corpus authors — shapes, head/tail, blend modes (incl. the folded Mod/Mod2x), - /// every file-flag bit, spin signs, twinkle gates, atlas tiling, spline emitters, and the - /// record fields the renderer doesn't parse yet (geometry-model "model particles", - /// recursion-model child emitters, the rate track's interp word) — each with counts, example - /// models, and the spells whose visual chain plays them. The population instrument that turns - /// "this spell looks wrong" reports into a ranked mechanism worklist, closing feature classes - /// corpus-wide instead of spell-by-spell. + /// Sweep every `.m2` and census each particle-emitter feature the corpus authors. Partcensus { /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and list the particle emitters whose - /// **file slot 0 is dead while a later slot is alive** (`peak0 <= 0 < peakN`). The reference - /// samples the PLAYING sequence's rate window every frame, so an emitter whose burst is keyed - /// in a later variation is ordinary content — but a consumer that PINS slot 0 renders it as - /// nothing at all, for ever, on every placement, while the emitter still builds, pools and - /// ticks. The population instrument for that silent class (decision 0760; found on - /// `BlastedLandsLightningbolt01.m2`, the Blasted Lands strike that never fires). + /// Sweep every `.m2` for emitters whose file slot 0 is dead while a later slot lives + /// (`peak0 <= 0 < peakN`): the reference samples the playing sequence's rate window, so a + /// consumer pinned to slot 0 never emits them. Partslotscan { /// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and census the batches the bake routes - /// to a **per-placement material** — the ones whose UV loop or M2Color RGB tint loop is not the - /// same in every FILE sequence slot, so no material shared by every instance can be right for - /// them (decision 1408). The texture-transform twin of `partslotscan`. - /// - /// It asks the **bake**, not a copy of it: `RenderSubmesh::uv_seq` / `rgb_seq` are `Some` - /// exactly when `SeqLoops::uniform()` refused the shared lane, so the census and the runtime - /// cannot disagree by construction. It used to transcribe the sampler instead — it was written - /// to SIZE this fix, before the fix existed — and once the fix landed the twin drifted from it: - /// the transcription compared periods and value EXTENTS at 1e-3, which calls - /// `Spells\AdrenalineRush_Cast_Base` the same loop in both slots, where the bake's own tail key - /// differs by a full 1.0 (slot 0 ends blue, slot 1 returns to red). - /// - /// Per (batch, channel): **SHARED** — the set is `None`, the slots agree, nothing changes for - /// it (with the sub-count that actually animates on that lane) — and **PER-PLACEMENT**, the set - /// is `Some`. The latter splits by WHY the slots disagree, which is the question of whether - /// `uniform()`'s exact float equality is *earning* the routing it does: **DEAD-0** (slot 0 - /// bakes nothing while a later slot animates — the B98 shape, `BlackrockStatueLavaBubble.m2` - /// keying its whole UV flipbook inside file slot 1, a 50/50 variation of animation id 0, so the - /// slot-0 bake returns `None` and every placement is frozen for ever), **WRAP-ONLY** and - /// **KEYS-EPSILON** — the over-application buckets, where the slots hold the same authored loop - /// and are split only by the wrap flag or by sub-epsilon noise in a rebased key — and - /// **REAL-DIFFERS** (genuinely different loops, or slot 0 alive against a dead later slot). - /// Over the shipped corpus both over-application buckets are **empty**, so nothing is on the - /// per-placement lane that a coarser comparison would have left off it. - /// - /// Each bucket closes with its content family and a named worst-hit tail (batches, file - /// sequence slots). `World\` is the only family the per-placement lane reaches at all — a - /// placed doodad or WMO prop; `Creature\`/`Spells\` and the `World\Goober\` GameObject - /// displays resolve their own sequence through the entity lane — so a bucket living outside it - /// costs nothing whatever it says. + /// Sweep every `.m2` for batches the bake routes to a per-placement material, whose UV or + /// M2Color tint loop differs across file sequence slots, read from the bake itself (`uv_seq` + /// and `rgb_seq` are `Some` exactly when `SeqLoops::uniform()` refuses the shared lane) and + /// bucketed by why: `DEAD-0`, `WRAP-ONLY`, `KEYS-EPSILON` or `REAL-DIFFERS`. Only placed + /// doodads and WMO props reach the lane; creatures, spells and GameObjects use the entity lane. Uvslotscan { /// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and list the models whose animation is - /// authored **entirely outside the bone tracks**: sequences exist, per-sequence consumers exist - /// (particle emitters, ribbons, material alpha/colour, UV transforms), and not one sequence - /// keys a bone. A sequence clock that rides a bone-animation clip has nothing to ride on such a - /// model — no clip, no player, no "which sequence, how far in" — so every per-sequence consumer - /// degrades to file slot 0 at t = 0, for ever. The population instrument for that silent class - /// (decision 0941; found on the Molten Core rune + flame ring). `[GO]` marks a - /// `GameObjectDisplayInfo` model — the hosted-clock lane, where the freeze is total. + /// Sweep every `.m2` for models animated entirely outside the bone tracks: no sequence keys a + /// bone, so a clock riding a bone clip leaves every per-sequence consumer at slot 0, t = 0. + /// `[GO]` marks a `GameObjectDisplayInfo` model, where the freeze is total. Seqclockscan { /// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and list batches whose texture is authored - /// **CLAMP** (`M2Texture.flags` bit 0/1 clear) while the batch's UVs run **outside `0..1`** — the - /// population a repeat-sampling renderer draws wrong. That margin is deliberate: clamped it - /// samples the sheet's transparent border and the card fades to nothing; wrapped it folds into - /// the opposite edge and draws as solid geometry with a seam at the crossing (decision 0763, - /// bugs B52/B96 — the Dun Morogh snow-firs and the Plaguelands bush). + /// Sweep every `.m2` for batches whose texture is authored clamp on an axis (`M2Texture.flags` + /// bit 0 or 1 clear) while their UVs run outside 0..1 there on purpose: clamped they fade into + /// the sheet's transparent border, wrapped they draw solid with a seam where they cross. Uvwrapscan { /// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every model the **spell-visual chain** can reach — every `SpellVisualEffectName` - /// path a kit's ten effect slots, a `SpellVisual` row's missile model or its dest-anchored - /// model names — and census the batches whose **texture transform animates**, then classify - /// each by what a consumer that runs NONE of it renders. - /// - /// Decision 0271 deferred this channel on the claim that "no effect model in the current - /// corpus needs it"; this is that claim, made countable — it is what 2282 read to size the - /// missing scroll, and it is how the same question was re-asked of the unit / GameObject / - /// held-item corpus that 2295 then fixed (`entityuvscan`, its twin). It asks the **bake**, - /// not a transcription of it: a batch is in scope - /// exactly when `tex_anim` emitted a loop on any of the three channels, the same test the - /// lanes that DO run them use (`ui_models` 2019, `spell_fx` 2282). - /// - /// The classes are what a frozen batch draws, judged from the texture's own alpha through its - /// authored address mode — because a CLAMP-authored sheet's border is what a UV outside `0..1` - /// samples, and on this corpus that border is transparent: - /// - /// - **INVISIBLE** — frozen, every texel the batch reaches is transparent, while the scroll - /// reaches painted ones: the batch renders **nothing at all**. `Spells\SwipeCaster.m2` - /// (druid Swipe) is the class: two 51-vertex claw-trail strips whose UVs are authored at - /// `u[+0.945..+1.944]` over a 16×16 CLAMP sheet, so frozen they sample column 15 alone — - /// alpha 0 — and the whole of their visible existence is the `−0.97` U scroll. - /// - **FROZEN** — it draws, statically: the scroll is the motion it loses. - /// - **HELD** — keyed to a constant non-identity offset, so a lane that seeds none draws it - /// mis-registered rather than still. - /// - **NEVER** / **UNKNOWN** — flagged, never counted as INVISIBLE: nothing painted at any - /// point of the loop, or no alpha lane to judge from (`Mod`/`Mod2x`, an undecodable sheet). - /// - /// Each batch prints its per-axis reasoning (address mode, authored and frozen UV spans, the - /// texel indices those reach) so the call is checkable, and the report closes with the INVISIBLE - /// listing joined back through the chain: which spells reach it, and through which lifecycle - /// stage (`precast`/`cast`/`impact`/`state`/`channel`/`missile`/`area`). + /// Sweep every model the spell-visual chain reaches (kit effect slots, missile and + /// dest-anchored models) for batches whose texture transform animates, classed by what a + /// consumer running none of it draws, from the texture's alpha through its address mode: + /// `INVISIBLE` (frozen, it samples only transparent texels while the scroll reaches painted + /// ones), `FROZEN`, `HELD`, `NEVER` or `UNKNOWN`. It closes with the `INVISIBLE` batches joined + /// to the spells that reach them. Fxuvscan { /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all reachable effect /// models if omitted. prefix: Option, }, - /// Sweep every model the **ENTITY lane** can render — every `CreatureDisplayInfo` → - /// `CreatureModelData` body (NPCs, critters, mounts, and every PLAYER, which resolves through - /// the same chain, decision 0041), every `GameObjectDisplayInfo` model, every - /// `ItemDisplayInfo` left/right model joined to the `Item\ObjectComponents\` folder the - /// archives actually hold it in, and the corpse lane's `DeathSkeleton` bone piles — - /// and census the batches whose **texture transform animates**, then classify each by what a - /// consumer that runs NONE of it renders. - /// - /// The twin of `Fxuvscan`, asked of the corpus that record's fix did NOT reach. Decision 2282 - /// gave the spell-effect lane its texture transform and named this as its first deferral: - /// `model_render::batch::Materials::entity_variants` passed `play_uv = false`, and `build` - /// does `play_uv.then_some(sub.uv_anim.as_ref()).flatten()` — so every unit, player, - /// GameObject and held-item batch was handed no loop and drew its authored UVs, untransformed, - /// for ever. Nobody had measured how much content that is; this is that measurement, it is - /// what sized **decision 2295**, which closed it, and it shares `fxuvscan`'s whole per-batch - /// reader (`uv_batch`) so the two corpora cannot be judged by two different rules. It stays - /// because the census is how the claim "this lane needs no channel" is kept checkable — which - /// is the one thing 0271's deferral was missing. - /// - /// **Read the corpus from the tables, never from a path prefix.** "The path looks broken" and - /// "the path is reachable" are different questions and only the second one is worth a session - /// (2282's own closing note). A held item's directory is not even a column — the equipment lane - /// picks `Weapon`/`Shield`/`Shoulder`/`Head`/`Ammo`/`Quiver` from the slot the item is worn in - /// — so the sweep asks the archives which folder holds each display's basename, and expands a - /// helm stem into its sixteen per-race/sex files the way the attach does. - /// - /// The classes are `fxuvscan`'s, judged the same way — from the texture's own alpha through the - /// batch's authored address mode (INVISIBLE / NEVER / FROZEN / HELD / UNKNOWN; that command's - /// doc is where they are explained) — with one thing this corpus adds: a CREATURE batch's - /// `Monster1/2/3` sheet is **blank in the M2** and filled per display from - /// `CreatureDisplayInfo.textureVariation`, so one batch has as many sheets as the model has - /// skins and the verdict is asked once per skin. A character composite (body atlas, hair, - /// object skin) has no authored sheet at all and reads UNKNOWN, which is the honest answer - /// rather than a silent pass. - /// - /// **The load-bearing column is the CLOCK**, because it decides the SHAPE of the fix and not - /// merely its size: - /// - /// - **GSEQ** — every live loop rides a global sequence, a free-running per-scene clock the - /// reference anchors once per instance at attach. One shared material uniform is faithful - /// there; that is 0136 choice 1's lane, already built. - /// - **BAND** — every live loop rides its sequence band, i.e. the instance's own play head. Two - /// units playing different animations, or the same one at different phases, are at different - /// offsets, and no shared uniform can serve both: that is 2282's per-instance `UvLoop`. - /// - **MIXED** / **HOLD** — the batch's channels disagree, or it is keyed to a constant - /// non-identity offset that needs a seed and no clock at all. - /// - /// Each affected batch prints its three channels (translation / rotation / scaling — a sweep - /// that looked only at translation would have missed `GroundingTotem_Impact` entirely, which is - /// scale-only), its per-axis texel reasoning, both frozen verdicts, its clock, which file - /// sequence slots carry the keys and what those sequences are *called*, and — where the bake - /// refused the shared lane — `uvslotscan`'s own `uniform()` verdict for the same set. The - /// report closes with every affected model sorted by POPULATION: how many table rows can put it - /// on screen, which is the difference between one gnome terminal and every murloc in the game. - /// - /// **And then the same corpus again for the M2COLOR TINT**, because the UV channel is not the - /// only one this lane drops. `build` passes `sub.rgb_anim.as_ref()` *unconditionally* — there - /// is no `play_rgb` to flip — so a tint-animating entity batch is seeded at the loop's first - /// key and then never re-sampled, because `doodad_anim::register_tint`'s only call sites (like - /// `register_uv`'s) are in the world streamer's `assemble.rs`. The hole is the missing - /// REGISTRATION, not a missing argument, which is why flipping `play_uv` alone would only - /// trade one frozen frame for another. - /// - /// A tint is a **multiply**, so its failure mode is the wrong colour rather than missing - /// geometry, and the classes say how wrong: BLACK (the frozen tint kills the batch), STRONG, - /// SLIGHT, NEGLIGIBLE — bands over one printed number, the worst per-channel distance between - /// the frozen value and anything the loop reaches. Two qualifications the raw count would - /// overstate without: a batch whose slot 0 bakes nothing seeds **white**, so it is the right - /// colour until a later slot's animation plays and wrong only *during* it (the `slots` line - /// names which); and the **ALPHA** channel is counted beside them precisely because this lane - /// DOES serve it — `attach::dress::spawn_part` gives every part a `MatAnim` that - /// `sample_mat_anim` ticks per instance. One of the three material-animation channels runs. + /// `fxuvscan`'s census over the entity lane's models, as the tables reach them: every + /// `CreatureDisplayInfo` body (players included), `GameObjectDisplayInfo` model, + /// `ItemDisplayInfo` model in whichever `Item\ObjectComponents\` folder holds it (a helm as its + /// 16 race and sex files) and `DeathSkeleton`. A creature's `Monster1/2/3` sheet is + /// filled per display from `textureVariation`, so each skin is judged. The clock column is + /// `GSEQ` (a global sequence, which the reference anchors once per instance at attach), `BAND` + /// (the instance's own play head), `MIXED` or `HOLD`. It then repeats for the M2Color tint + /// (`BLACK`, `STRONG`, `SLIGHT`, `NEGLIGIBLE`), with the alpha channel counted beside it. Entityuvscan { /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all reachable entity /// models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and census the batches whose texture - /// coordinates are **GENERATED, not authored** — the sphere-map environment stages - /// (`texture_unit_lookup[texCoordSet] > 2`, the reference's gate at `0x70b8bd`). Such a batch - /// carries no usable UVs *by design* (the artist collapses the mesh onto one point because the - /// runtime supplies the coordinates), so a renderer that reads the vertex UV paints the whole - /// surface in **one texel** of a reflection sheet — silently, with no missing geometry to - /// notice. `DEGENERATE` marks exactly that population; the rest carry unused leftover UVs and - /// misdraw as a static smear instead. The instrument behind the Deeprun Tram glass tube. + /// Sweep every `.m2` for batches whose texture coordinates are generated, the sphere-map + /// stages (`texture_unit_lookup[texCoordSet] > 2`, the reference's gate at `0x70b8bd`). Their + /// UVs go unused by design; `DEGENERATE` marks those collapsed to one point, which a renderer + /// reading vertex UVs paints as one texel. Envmapscan { /// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` and report which sampler ADDRESS MODES the corpus asks of each texture - /// path, and how many paths are asked for more than one — the design check behind decision - /// 0763 (the mode lives on the sampler, which rides the `Image`, which is keyed by path). + /// Sweep every `.m2` and report the sampler address modes the corpus asks of each texture + /// path, and how many paths are asked for more than one. Texmodescan { /// Internal-path prefix filter, case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and count the two halves of the - /// **owner-last draw-order** law per model: the EFFECTS it authors (particle emitters + - /// ribbon trails) and the TRANSPARENT-pass batches of its own body those effects must draw - /// after (decisions 0719/0721), plus the model's reach and the draw-order rung that reach - /// produces. The population instrument for "how much does this fix, besides the one creature - /// it was found on": a model with effects AND transparent batches of its own is one whose - /// effects our distance sort could interleave with its own body; one without never had the - /// defect at all. + /// Sweep every `.m2` and count both halves of the owner-last draw order: each model's effects + /// (particle emitters and ribbons) and its own transparent-pass batches, with reach and rung. Fxordercensus { /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and census the **3-D model particles**: - /// emitters carrying a geometry-model reference, whose shard instances render the GEOMETRY - /// model's own submeshes stamped with the OWNER model's owner-last draw-order rung. Per - /// (owner, geometry) pair: the owner's rung and reach plus the geometry's material family - /// tuples (blend, two_sided, additive, no_depth_write, no_depth_test) — the exact keys the - /// shard materials are built from. The ground truth sizing the pipeline-warm menagerie's - /// shard rows: rung histogram, family-tuple set split by transparent-pass membership, and - /// the unresolvable-path bound. + /// Sweep every `.m2` for 3-D model particles, emitters whose shards draw a geometry model's + /// submeshes at the owner's draw-order rung: per (owner, geometry) pair, the rung, reach and + /// material family tuples the shard materials are built from. Shardcensus { /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and list the models where the - /// **loader-idle sequence is not file slot 0** while benilla's render content gate leaves it - /// unarmed — so every per-sequence bake (emission rate/gate, the nine parameter channels, - /// material alpha) degrades to slot 0, a sequence the instance is not playing. On the - /// `DuelingFlag`-shaped GameObjects (Spawn/Stand/Despawn) slot 0 is the **Spawn flourish**: - /// the population instrument for decision 0936, found on the Stormwind battlefield banner - /// pouring its spawn sparkle-tails for ever. + /// Sweep every `.m2` for models whose loader-idle sequence is not file slot 0 while benilla's + /// render content gate leaves them unarmed, so every per-sequence bake reads slot 0, a sequence + /// the instance is not playing; on `DuelingFlag`-shaped GameObjects that is the Spawn flourish. Idleslotscan { /// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` and census the **animation-driven sound emitters**: the models whose - /// sequences carry a `$DSL` (doodad sound loop) / `$DSE` (its release token) / `$DSO` (doodad - /// sound one-shot) / `$SND` (generic one-shot) marker, the `SoundEntries` kit each names with its 3D parameters, and — - /// the column this exists for — whether the carrying sequence is REST-posed, i.e. one the - /// render content gate (decision 0130) never builds a rig for. A placed lamp's hum is a single - /// `$DSL` on a sequence that keys no bone at all, so the whole class is unreachable through an - /// `AnimationPlayer`: the population instrument for "every world doodad is silent" (B345). + /// Sweep every `.m2` for animation sound markers (`$DSL` doodad loop, `$DSE` its release, + /// `$DSO` doodad one-shot, `$SND` one-shot) with each kit's 3-D parameters, and whether the + /// carrying sequence is rest-posed, one the render content gate builds no rig for. Soundeventscan { /// Internal-path prefix to limit the sweep (e.g. `world`); all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and list the models whose PARTICLE - /// emitters carry any of the given file-flag bits (`M2ParticleEmitter+0x04`) — the - /// population instrument for particle-flag mechanisms (which content actually authors - /// e.g. the `0x1000` plane-quad leg), so a class of effects is closed by mechanism - /// instead of tested spell-by-spell. + /// Sweep every `.m2` and list the models whose particle emitters carry any of the given + /// file-flag bits (`M2ParticleEmitter+0x04`). Partscan { /// The flag mask to match, hex or decimal (e.g. `0x1000`). mask: String, /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted. prefix: Option, }, - /// Sweep every `.m2` (optionally under a path prefix) and report which models author M2 - /// dynamic LIGHT blocks — the population instrument for the mechanism (decision 0016, - /// `0x718960`). Per model: its point (`type==1`, the GL - /// hot-spot caster) vs directional (ambient-feed) light counts, then per point light its - /// bone/position/diffuse colour×intensity/attenuation/visibility-gate. The closing summary — - /// totals, a breakdown by top-level content family (Creature/Item.ObjectComponents/ - /// Character/World/Spells/other), and a diffuse-palette tally — is the real deliverable: - /// benilla only spawns these lights for ADT-placed doodads and WMO props today, so it answers - /// how much of the entity path (creatures, held items, GameObjects) is missing them. + /// Sweep every `.m2` for M2 dynamic lights (`0x718960`): per model its point (`type==1`) and + /// directional light counts, each point light's bone, position, colour, attenuation and + /// visibility gate, then totals by content family and a diffuse-palette tally. M2lightscan { /// Internal-path prefix filter (e.g. `item\objectcomponents`), case-insensitive; all /// models if omitted. prefix: Option, }, - /// Sweep every WMO ROOT and cross-tab the two halves of the skybox mechanism: the root's - /// **MOSB** skybox model against its groups' `0x40000` flag. `0x40000` is undocumented, so this - /// is what *identifies* it — across all 815 roots the bit never appears without a MOSB. Note - /// what that does and does NOT buy: it establishes `flag => MOSB`, never which group the - /// renderer tests (the reference's law is the flood-VISITED group, `0x6b42e0`; decision 0773 - /// correcting 0767). - /// Also the population instrument: which 1.12 buildings replace the `Light.dbc` gradient dome - /// with an authored sky (Stratholme's burning city is the only one reachable), and how much + /// Sweep every WMO root and cross-tab its MOSB skybox model against its groups' `0x40000` + /// flag, which never appears without a MOSB in all 815 roots; the reference tests it on the + /// flood-visited group (`0x6b42e0`). Also which buildings replace the `Light.dbc` dome with an + /// authored sky, and in how many groups: only Stratholme's is reachable in 1.12. Skyboxscan, - /// Dump all 18 `LightIntBand` rows of the `Light.dbc` entry covering a world position at a - /// time of day — the band-semantics instrument (which row holds which colour at which hour; - /// the celestial-diffuse band question, decision 0485). ⚠ This is ONE params record RAW — - /// near a sphere's falloff edge the LIVE light is mostly the continent global and looks - /// nothing like these rows; `lightblend` shows what actually wins at the position (0706). + /// Dump all 18 `LightIntBand` rows of the `Light.dbc` entry covering a position at a time of + /// day. One params record, raw: near a sphere's falloff edge the live light is mostly the + /// continent global, and `lightblend` shows what wins there. Lightbands { /// Map id (0 = Eastern Kingdoms, 1 = Kalimdor). map: u32, @@ -719,21 +410,18 @@ enum Command { /// Game minute-of-day (0..1440; 720 = noon). minute: u32, }, - /// Dump every `LightIntBand`/`LightFloatBand` row of an explicitly named `LightParams` id — the - /// instrument for rows **no position can reach**, because nothing in `Light.dbc` references them: - /// magma submersion reads the fixed global row 7 and slime row 6 (VERIFIED `0x6d2371`), not the - /// zone's underwater slot. + /// Dump every `LightIntBand` and `LightFloatBand` row of a named `LightParams` id, for rows no + /// position reaches: magma submersion reads the fixed global row 7 and slime row 6 + /// (`0x6d2371`), not the zone's underwater slot. Lightparam { /// `LightParams.dbc` id (1-based). id: u32, /// Game minute-of-day (0..1440; 720 = noon). minute: u32, }, - /// Dump the **per-weather-slot** light resolve at a world position: which `LightParams` id each - /// of the five `Light.dbc` slots names (clear / clear-underwater / storm / storm-underwater / - /// death), which of them are UNSET (and so fall back to clear), and the fog/ambient/diffuse the - /// blend resolves for each. `lightbands` only ever shows the clear slot, so an underwater or - /// ghost atmosphere that reads wrong could not be told from data that simply has no such record. + /// Dump the per-weather-slot light resolve at a position: the `LightParams` id each of the five + /// `Light.dbc` slots names (clear, clear underwater, storm, storm underwater, death), the unset + /// ones that fall back to clear, and each one's fog, ambient and diffuse. Lightslots { /// Map id (0 = Eastern Kingdoms, 1 = Kalimdor). map: u32, @@ -749,11 +437,8 @@ enum Command { /// Game minute-of-day (0..1440; 720 = noon). minute: u32, }, - /// Dump every light SPHERE covering a world position — distance, falloff, blend alpha, each - /// sphere's clear ambient/sun — then the faithful area-blend result vs the single `pick_light` - /// sample. The instrument for "which palette actually wins HERE": a position near a zone - /// sphere's falloff edge resolves mostly the continent global, and the raw per-params band dump - /// (`lightbands`) cannot show that. + /// Dump every light sphere covering a position (distance, falloff, blend alpha, clear ambient + /// and sun), then the area-blend result against the single `pick_light` sample. Lightblend { /// Map id (0 = Eastern Kingdoms, 1 = Kalimdor). map: u32, @@ -769,9 +454,8 @@ enum Command { /// Game minute-of-day (0..1440; 720 = noon). minute: u32, }, - /// Bulk-scan placed doodads (MDDF) and WMO doodad-set-0 props (MODF → MODS/MODD) across a - /// `(2·tile_radius+1)²` block of ADT tiles around a world position, and report how much of that - /// content animates (see `m2anim`) — the doodad-animation perf/scope instrument. + /// Scan placed doodads (MDDF) and WMO doodad-set-0 props (MODF, MODS, MODD) over a + /// `(2·tile_radius+1)²` block of ADT tiles around a position, and report how much animates. Doodadscan { /// Map directory name (e.g. `Azeroth`). map: String, @@ -784,36 +468,25 @@ enum Command { /// ADT tile radius around the center tile to scan (0 = just the containing tile). tile_radius: u32, }, - /// Dump a WMO root's placed-prop tables: every MODD doodad with its MODS set membership and - /// its OWNING group(s) read from the group files' MODR lists — the relation the reference - /// instantiates from (a *visible* group's own refs, `0x695aa0`), so a prop referenced by NO - /// group is one the real client never creates at all (the divergence decision 0689 names, - /// which benilla still spawns). The "which props exist in this building, who owns them, and - /// which would the reference even draw" instrument. + /// Dump a WMO root's placed props: each MODD doodad with its MODS sets and the groups whose + /// MODR names it. The reference creates props only from a group's MODR refs (`0x695aa0`), so + /// it never creates one no group names; benilla still spawns those. Wmodoodads { - /// Internal path to the WMO **root** (forward or back slashes accepted). + /// Internal path to the WMO root (forward or back slashes accepted). internal_path: String, /// Case-insensitive substring of the prop's model path (e.g. `lightray`); all if omitted. filter: Option, }, - /// Sweep every WMO **root** (optionally under a path prefix) and list the placed MODD props - /// the INTERIOR lighting lane commits as **literal black**. That lane's entire base light is - /// the MODD entry's own baked colour (ambient `cap96`, diffuse `floor112` — `0x694e90` → - /// `0x6a77e0`), and the floor leg is a hue-preserving scale by `112/max`, so a colour of - /// exactly `#000000` has nothing to raise and both words come out zero: the prop is lit by - /// nothing but its owning group's MOLR fixtures, and a group with none in range draws a pure - /// black silhouette. The `ALSO-EXT` column is the divergence half — a prop an EXTERIOR group's - /// MODR *also* names is one the reference reaches through that group too, while our - /// single-instance first-referrer-wins ownership pins it to the interior lane from every angle. + /// Sweep every WMO root for placed MODD props the interior lane lights literal black: its base + /// light is the MODD colour (ambient `cap96`, diffuse `floor112`, `0x694e90` → `0x6a77e0`), and + /// the `112/max` floor keeps `#000000` black. An exterior referrer sky-lights a prop instead + /// (`0x695aa0`); `RESCUED` counts those an interior group names first. Darkpropscan { /// Internal-path prefix to limit the sweep (e.g. `world\wmo\azeroth`); all if omitted. prefix: Option, }, - /// List individual doodad (MDDF) and WMO (MODF) placements around a world position whose - /// model path contains a substring — position, Euler rotation (deg), scale, uniqueId. The - /// "point at a thing in the world, tell me exactly how it's placed" instrument: an - /// orientation bug needs the placement rotation as ground truth, which `doodadscan`'s - /// aggregates never show. + /// List the doodad (MDDF) and WMO (MODF) placements around a position whose model path + /// contains a substring: position, Euler rotation (deg), scale and uniqueId. Placescan { /// Map directory name (e.g. `Azeroth`). map: String, @@ -828,12 +501,9 @@ enum Command { /// Case-insensitive substring of the model path (e.g. `instanceportal`). filter: String, }, - /// Print the terrain MCSH baked-shadow bit at a world position — the per-instance - /// terrain-shade selector an exterior M2 doodad samples once at its base (sun intensity - /// 2.5 lit / 0.5 shadowed, `0x69e4ad`; decisions 0063/0747) — plus an ASCII texel - /// neighborhood. A doodad base near a shadow edge sits one ~0.52 yd texel from the OTHER - /// intensity family, so two adjacent fence pieces can land on opposite sides — the - /// "why is ONE of these blown out" instrument. + /// Print the terrain MCSH baked-shadow bit at a position, which an exterior M2 doodad samples + /// once at its base (sun scale 1.0 lit, 0.5 shadowed, `0x698cb4`), and the texel + /// neighbourhood: one ~0.52 yd texel can split adjacent pieces across a shadow edge. Shadeat { /// Map directory name (e.g. `Azeroth`). map: String, @@ -851,10 +521,8 @@ fn normalize(path: &str) -> String { path.replace('/', "\\") } -/// The grouping/lookup key for a model path referenced from MDDF/MODD (`doodadscan`'s instance -/// tables): lowercase, `.mdx`/`.mdl` → `.m2`. Mirrors `benilla_formats`' own (crate-private) -/// `model_path` normalization so two placements of the same model authored under different -/// casing/extension collapse to one row instead of double-counting it. +/// A placed model path's grouping key, lowercase with `.mdx`/`.mdl` as `.m2`; it must match +/// `benilla_formats`' crate-private `model_path` normalization. fn model_key(raw: &str) -> String { let lower = raw.to_ascii_lowercase(); match lower @@ -874,7 +542,6 @@ fn parse_u32_maybe_hex(s: &str) -> Result { } } -/// `"yes"`/`"-"` for a compact table cell. fn yn(b: bool) -> &'static str { if b { "yes" @@ -953,7 +620,7 @@ fn main() -> Result<()> { output, } => { let name = normalize(&internal_path); - // Note which archive the file resolved from (handy for debugging overrides). + // The archive the file resolved from, which shows an override. let source = chain .find_file_archive(&name) .map(|p| p.display().to_string()); diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/geometry.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/geometry.rs index 1a383fb2..42c1c3c1 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/geometry.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/geometry.rs @@ -1,38 +1,22 @@ -//! Corpus scans over **what geometry a model draws** — the shape of its batches, independent of -//! how they are textured or shaded. -//! -//! Billboard cards and which way they face (`bbscan`, `bbfacescan`), geosets and the untextured / -//! single-triangle strays (`geosetscan`), flat ground-plane quads (`groundscan`), and degenerate -//! authored vertex normals (`normalscan`). What MATERIAL a batch carries is [`super::material`]'s -//! question. +//! Corpus scans over a model's geometry: billboards, geosets, ground quads, bounds, normals. use std::collections::{BTreeMap, HashMap}; use anyhow::Result; use benilla_formats::Chain; -/// Sweep every `.m2` (under `prefix`, if given) and classify every BILLBOARD batch by which way its -/// geometry faces — see the `Bbfacescan` command doc for why the sign decides visibility. -/// -/// A billboard bone puts the model's **+X** toward the viewer (the spherical arm, `0x71547c`), -/// so a batch whose winding normal is +X faces the camera and a −X one faces away. Single-sided -/// (`two_sided` false, i.e. no material `0x04`), the away-facing ones are backface-culled by the -/// reference from every angle — they are authored placeholders the author never saw. +/// Classify every billboard batch by facing: a billboard bone turns the model's +X toward the +/// viewer (the spherical arm, `0x71547c`), so a single-sided batch (no material `0x04`) whose +/// winding faces −X is culled by the reference from every angle. pub fn bbfacescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut cards) = (0u32, 0u32); - // The four card populations. `away_single` is the one the renderer's forced-two-sided override - // changes: those and only those become visible when a card is not allowed to be culled. + // The four card populations; `away_single` is what a forced two-sided draw would reveal. let (mut toward, mut away_single, mut away_two, mut edge_on) = (0u32, 0u32, 0u32, 0u32); - // …and the batches that are not cards at all. A facing only exists for a batch that IS one - // plane; a closed solid has faces every way round, backface culling never hides it, and - // sampling its first triangle answers a question it doesn't have (see `plane_normal`). + // Billboard batches that are not one plane: a solid has no single facing. let mut solid = 0u32; - // …and the other side of the same gate: batches the split REFUSED because their geometry is - // welded to a billboard bone (`RenderSubmesh::welded_billboard`, decisions 0839/0841). These - // carry no `billboard` at all, so the card loop below never sees them — they are counted from - // the flag the render lanes read, which is what makes this a cross-check of `bbscan`'s SEAM - // column (same models, counted from the two ends of the rule) rather than a re-derivation. + // Batches the card split refused as welded to a billboard bone: they carry no `billboard`, + // so they are counted from the render lanes' flag, a cross-check of `bbscan`'s SEAM column. let (mut welded, mut weld_models) = (0u32, 0u32); for name in names { let Ok(bytes) = chain.read_file(&name) else { @@ -50,12 +34,10 @@ pub fn bbfacescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { for (i, s) in subs.iter().enumerate() { let Some(bb) = &s.billboard else { continue }; cards += 1; - // Not one plane ⇒ not a card. Counted apart rather than classified by a triangle. if s.plane_normal().is_none() { solid += 1; continue; } - // The winding normal's X component: +1 faces the viewer, −1 faces away. let Some(fx) = facet_x(s) else { edge_on += 1; continue; @@ -96,8 +78,7 @@ pub fn bbfacescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// The X component of a batch's first-triangle winding normal, in WoW model space — `None` when the -/// triangle is degenerate or the normal lies in the YZ plane (nothing to decide a facing from). +/// The X of a batch's first-triangle unit winding normal, WoW model space; `None` if degenerate. fn facet_x(s: &benilla_formats::RenderSubmesh) -> Option { let tri = s.indices.get(..3)?; let p = |i: u32| s.positions.get(i as usize).copied(); @@ -115,11 +96,8 @@ fn facet_x(s: &benilla_formats::RenderSubmesh) -> Option { (len > 1e-9).then(|| n[0] / len) } -/// The widest max/min axis spread a scale track ever holds — `1.0` for a uniform track (the -/// overwhelming majority: a glow card that only pulses in size), `> 1` for one that stretches its -/// bone along an axis. A key with an axis at or below zero contributes `1.0`: a zero axis is the -/// reference's own "hide this" (the eyelid blink's retracted lid), not a stretch, and dividing by -/// it would report infinity. +/// The widest max/min axis ratio a scale track holds, 1 for uniform. A key with an axis at zero +/// counts as 1: a zero axis hides the bone (the eyelid blink), it does not stretch it. fn scale_spread(keys: &[(T, [f32; 3])]) -> f32 { keys.iter() .map(|(_, s)| { @@ -135,23 +113,12 @@ fn scale_spread(keys: &[(T, [f32; 3])]) -> f32 { .fold(1.0f32, f32::max) } -/// Sweep every `.m2` (under `prefix`, if given) and classify its billboard usage — see the -/// `Bbscan` command doc. Output per model: the authored arms and how many vertices ride each -/// DIRECTLY (primary bone is the billboard bone — the card path) vs INHERITED (primary bone -/// descends from one — the joint-palette path, decision 0205), then the same question for the -/// model's **particle emitters and ribbons** (`fx[…]`) — the population behind decision 0813: an -/// emitter on (or under) a billboard bone has a camera-dependent origin, because the reference -/// folds the record position through the *replaced* palette matrix (`0x7190a9`–`0x71910c`). -/// -/// The `NONUNIF[…]` column is the third population: billboard bones whose SCALE is animated -/// **non-uniformly** (in any sequence band, or on a global-sequence loop), listed as -/// `bone:arm×ratio` where the ratio is the widest max/min axis spread the bone ever holds. The -/// billboard law preserves the bone's scale under the substituted camera basis (`T·R_cam·S`), so -/// such a bone stretches its card along one model axis — the Lightwell's lock-Z shaft -/// (`World\Goober\G_HolyLightWell.m2` bone 0, `×4.26`) is a 0.12 yd card pulled into a 4.5 yd -/// column of light. Any consumer that reduces the scale to one scalar renders these squat and -/// blown-out instead (bug B169), and a `d` in the direct column is where that lands on a **card**, -/// whose transform is rebuilt from the joint rather than skinned from it. +/// Per model with billboard bones, the vertices riding each arm directly (the card path) or +/// through a descendant (the palette path), and three populations: `fx[…]`, emitters and ribbons +/// on a billboard chain, whose position the reference folds through the replaced palette matrix +/// (`0x7190a9`–`0x71910c`); `SEAM[…]`, bones the card split refuses; `NONUNIF[…]`, +/// `bone:arm×ratio` for non-uniform animated scale, which the reference keeps under the camera +/// basis (`T·R_cam·S`), so the card stretches along one axis (`G_HolyLightWell.m2` bone 0, ×4.26). pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let arm = |k: benilla_formats::BillboardKind| match k { @@ -164,14 +131,9 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { // Corpus totals: models exercising each arm, split by how the geometry rides it. let mut direct_models: HashMap<&'static str, u32> = HashMap::new(); let mut inherited_models: HashMap<&'static str, u32> = HashMap::new(); - // …and the effect riders (particles/ribbons on a billboard chain). let mut fx_models = 0u32; let mut fx_total: HashMap = HashMap::new(); - // …and the seam population (see the classification below). let (mut seam_models, mut seam_bones) = (0u32, 0u32); - // …and the non-uniform-scale population: billboard bones that stretch their card along one - // model axis, split by whether geometry rides them DIRECTLY (the card path, where a scalar - // scale loses the stretch outright) or only by inheritance (the palette path). let (mut nonunif_models, mut nonunif_bones, mut nonunif_direct) = (0u32, 0u32, 0u32); for name in names { let Ok(bytes) = chain.read_file(&name) else { @@ -191,9 +153,8 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { continue; } hits += 1; - // Nearest billboard ancestor (self included) per bone — the arm whose palette - // replacement a vertex on this bone inherits. Bounded walk (M2 parents precede - // children; the bound is just a malformed-file guard). + // The nearest billboard bone at or above `i`, whose palette replacement a vertex on `i` + // inherits; the bound guards a malformed parent chain. let ancestor_arm = |mut i: usize| -> Option<(bool, benilla_formats::BillboardKind)> { let mut hops = 0; loop { @@ -217,22 +178,15 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { None => {} } } - // SEAM bones: billboard bones whose geometry is welded to the rest of the model — by a - // partial vertex weight, or by a triangle it shares with a static neighbour. The reference - // skins per vertex, so such geometry BENDS (a flap's root stays on the body while its tip - // swings to the camera); a rigid card cannot express that at all, and moving the group - // rigidly tears the flap in two. These are the bones the split declines (decision 0839), - // read through the renderer's own predicate so this census cannot drift from it. + // SEAM: billboard bones welded to the model by a partial weight or a shared triangle. The + // reference skins per vertex, so the geometry bends; a rigid card would tear it. let seam = benilla_formats::non_separable_billboard_bones(&bytes); let fmt_counts = |m: &HashMap<&str, u32>| { let mut v: Vec = m.iter().map(|(k, n)| format!("{k}:{n}")).collect(); v.sort(); v.join(" ") }; - // The EFFECT riders: a particle emitter / ribbon whose bone chain reaches a billboard - // bone has a camera-dependent live frame (its record position rides the replaced palette - // matrix), so a consumer that places it at the rest pose puts it in the wrong place. - // `d` = the effect's own bone is the billboard bone, `i` = it descends from one. + // Effects on a billboard chain: `d` on the billboard bone itself, `i` below one. let mut fx: HashMap = HashMap::new(); let mut tally = |tag: &str, bone: u16| { if let Some((direct, k)) = ancestor_arm(bone as usize) { @@ -258,7 +212,6 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { v.join(" ") }; let bones: String = kinds.iter().flatten().map(|&k| arm(k)).collect(); - // NON-UNIFORM billboard scale (see the `NONUNIF` column in the doc above). let seqs = benilla_formats::parse_m2_animations(&bytes); let gseq = benilla_formats::parse_m2_global_sequence_bones(&bytes); let mut nonunif: BTreeMap = BTreeMap::new(); @@ -277,16 +230,14 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { *e = e.max(r); } } - // Only the BILLBOARD bones matter — an ordinary bone's non-uniform scale rides the joint - // palette like any other transform and nothing reduces it. + // Only billboard bones: an ordinary bone's scale rides the joint palette unreduced. nonunif.retain(|&b, _| kinds.get(b).copied().flatten().is_some()); let nonunif_col = if nonunif.is_empty() { String::new() } else { nonunif_models += 1; nonunif_bones += nonunif.len() as u32; - // Does geometry ride this bone DIRECTLY (primary bone == it, and separable)? That is - // the card path — where the stretch has to survive a transform rebuild. + // The card path: geometry rides the bone directly and the split accepts it. let direct_bone = |b: usize| { !seam.contains(&(b as u16)) && m.vertices.iter().any(|v| v.bone_indices[0] as usize == b) @@ -368,29 +319,15 @@ pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (under `prefix`, if given) and census the **geometry a non-character model -/// draws that the reference may not** — the population instrument behind the bug channel's -/// "stray untextured primitive" family. Three independent signals per model, all read through the -/// renderer's own batch resolution (`m2batch`'s), so the report can't drift from the mechanism: -/// -/// - **MULTI-GEOSET** — more than one distinct `skinSectionId`. The character compositor selects -/// among these; every other spawn path draws **all** of them, so this is exactly the population -/// an unfiltered creature/doodad/effect draw over-renders (`Creature\Banshee\Banshee.m2` is the -/// pinned case: `0`×17 + `402`×9). -/// - **UNTEX** — batches with no texture *and* no runtime slot that fills one: neither a character -/// composite slot ([`benilla_formats::RenderSubmesh::char_slot`]) nor a creature skin variation -/// ([`benilla_formats::RenderSubmesh::skin_slot`], filled at spawn from `CreatureDisplayInfo`). -/// Both fills are ordinary, so counting them would drown the signal — 324 of 420 `Creature\` -/// models carry a skin slot. What is left is geometry nothing can texture. -/// - **TINY** — batches of at most 2 faces: the literal single-triangle/quad primitives. -/// -/// A model is listed when it trips any of the three. `m2batch` then explains one model in full. +/// List the models whose batches trip any of three signals, read through the renderer's own +/// batch resolution: `MULTI-GEOSET`, more than one `skinSectionId` (the reference builds every +/// geoset visible, `0x70ebd0`, and only the character compositor hides any); `UNTEX`, no texture +/// and no character or creature-skin slot to fill one; `TINY`, at most two faces. pub fn geosetscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut hits) = (0u32, 0u32); let (mut multi_models, mut untex_models, mut tiny_models) = (0u32, 0u32, 0u32); - // Top-level directory → multi-geoset model count, so the report says *where* the population - // lives (Creature/, Spells/, World/…) rather than only how big it is. + // Top-level directory → multi-geoset model count. let mut by_dir: BTreeMap = BTreeMap::new(); for name in names { let Ok(bytes) = chain.read_file(&name) else { @@ -462,26 +399,17 @@ pub fn geosetscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (under `prefix`, if given) and report models that author flat ground-plane -/// render geometry — the population instrument for the class of spell effects that lie in the -/// model-space XY plane at z≈0 (WoW axes, Z up) and get buried by sloped terrain (Battle Shout's -/// crescents are the canonical case: 6 batches, each a 4-vert quad, every vertex exactly z=0, each -/// quad skinned 100% to a single bone). Per batch (same batch/geoset resolution `m2batch` uses): -/// FLAT if every vertex has `|z| <= 0.01` in model space; flat batches sub-classify QUAD-1BONE -/// (the crescent shape — [`benilla_formats::RenderSubmesh::ground_quad`], the ground-fx decal -/// lane's own detector) vs OTHER-FLAT (flat but not that shape, staying on the ordinary render -/// path) — which decides how general the renderer mechanism has to be. +/// List the models with flat batches, every vertex at model-space `|z| <= 0.01`: effects that lie +/// on the ground, which sloped terrain buries (Battle Shout's six single-bone quads). A flat batch +/// is `QUAD-1BONE` when [`benilla_formats::RenderSubmesh::ground_quad`], the decal lane's +/// detector, takes it, else `OTHER-FLAT`. pub fn groundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut hits, mut all_flat, mut mixed) = (0u32, 0u32, 0u32, 0u32); let (mut quad_total, mut other_total) = (0u32, 0u32); - // The hover census: every ground-quad-SHAPED batch whose uniform plane sits ABOVE z = 0 - // (any height — the shape test is the renderer's own, the ceiling is lifted), the population - // that decides where the decal lane's hover ceiling (`GROUND_HOVER_MAX`) can sit. + // Quad-shaped batches whose plane sits above z = 0, uncapped: where `GROUND_HOVER_MAX` can sit. let mut hovers: Vec<(f32, String)> = Vec::new(); - // The TINT census: ground quads whose whole colour is a CONSTANT M2Color - // (`GroundQuad::tint`) — the population a decal consumer draws white unless it carries the - // vertex-colour bake across (the Flare's two washes on the neutral `GENERICGLOW*` radials). + // Ground quads with a constant M2Color tint (`GroundQuad::tint`), which a decal must carry. let (mut tinted_total, mut tinted_models) = (0u32, 0u32); for name in names { let Ok(bytes) = chain.read_file(&name) else { @@ -515,9 +443,6 @@ pub fn groundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { if !blend_modes.contains(&bm) { blend_modes.push(bm); } - // The RENDERER's own detector, so this report is exactly what the ground-fx - // decal lane will do with each batch — the instrument can't drift from the - // mechanism it measures. if let Some(q) = s.ground_quad() { quad_count += 1; if q.tint != [1.0; 3] && !tints.contains(&q.tint) { @@ -589,24 +514,10 @@ pub fn groundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (under `prefix`, if given) and measure how far its authored header bounding -/// box — the model's **all-animation** vertex extent, and the box the reference derives its doodad -/// cull sphere from — reaches past its **bind-pose** vertex extent. -/// -/// The population instrument for decision 1259. A placed model's submesh entity keeps its transform -/// at the placement origin while the joint palette moves its vertices, so a bind-pose mesh bound -/// stops describing what is drawn the moment the model animates: cull with it and the object blinks -/// out while its geometry is still on screen. `SLACK` is how many yards the authored box reaches -/// past the bind-pose box on its worst face — for the ambient critters (birds, bats, butterflies, -/// wasps) that is tens of yards against a sub-yard body, which is the whole bug. -/// -/// `SHORT` is the same measure with the signs reversed: yards of bind-pose geometry sticking out of -/// the *authored* box. It is not zero in the shipped corpus, which is why the fix unions the two -/// boxes instead of swapping one for the other — the reference never trips over it because it tests -/// the box's circumsphere, which swallows the overhang. -/// -/// `ANIM` marks the models the widened bound actually applies to: any bone track with more than one -/// key, or any global-sequence bone channel. A static model keeps its tighter per-batch bound. +/// Rank the animated models by how far the header box, the all-animation extent the reference +/// builds its doodad cull sphere from, reaches past the bind-pose vertex extent (`SLACK`, yards on +/// the worst face): a bind-pose bound culls such a model while its geometry is on screen. `SHORT` +/// is bind-pose geometry outside the header box, which the reference's circumsphere swallows. pub fn animboundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; // (slack, short, animates, bind-pose half-diagonal, name) @@ -650,8 +561,7 @@ pub fn animboundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let half_diag = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2)).sqrt() * 0.5; - // "Animates" the way `doodad_anim::classify` means it: geometry that MOVES relative to the - // placement transform — a keyed bone track, or a free-running global-sequence channel. + // Animates as `doodad_anim::classify` means it: a keyed bone track or a global sequence. let keyed = benilla_formats::parse_m2_animations(&bytes) .iter() .any(|a| { @@ -681,9 +591,7 @@ pub fn animboundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { "{:>9} {:>8} {:>9} {:>4} MODEL", "SLACK", "SHORT", "BIND-R", "ANIM" ); - // The class the bug is about: the authored box dwarfs the body, so the animation carries the - // model clean out of any bind-pose bound. Ranked by that ratio, not by raw slack — a 200 yd - // waterfall with 200 yd of slack is never culled either way. + // Ranked by slack over body size: a large model with large slack is never culled either way. let mut ranked: Vec<&(f32, f32, bool, f32, String)> = rows.iter().filter(|r| r.2 && r.0 > 1.0).collect(); ranked.sort_by(|a, b| (b.0 / b.3.max(0.05)).total_cmp(&(a.0 / a.3.max(0.05)))); @@ -696,18 +604,10 @@ pub fn animboundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// `normalscan` — census the batches carrying **degenerate authored vertex normals** (`(0,0,0)`). -/// -/// The shipped corpus authors them, and the reference draws those surfaces lit: its `Model2.bls` -/// vertex program consumes the normal as the zero vector, so the order-2 SH quadratic form -/// collapses to its DC term. A renderer that -/// `normalize()`s the same datum gets NaN, `clamp(NaN, 0, 1)` floors the lighting factor to 0, and -/// the batch renders **pure black over its correct texture** — bug B134's Qiraji Brainwasher -/// sleeves and Ironaya skirt, and the reason the shader's normalize is guarded (decision 1268). -/// -/// This is the population instrument for that class: how many models are on it, how much of each -/// batch is degenerate, and which models are worst hit. `ALL` marks a batch with no usable normal -/// at all — the one that renders as a solid black shape rather than a shaded gradient. +/// Census the batches with degenerate `(0,0,0)` authored normals. The reference lights them: its +/// `Model2.bls` vertex program takes the zero normal as is, collapsing the order-2 SH form to its +/// DC term, where a `normalize()` gives NaN and draws the batch black. `ALL` marks a batch with no +/// usable normal. pub fn normalscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; // (degenerate verts, total verts, batches touched, all-degenerate batches, model) diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs index 17a21534..46268083 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs @@ -1,9 +1,4 @@ -//! Corpus scans over **what lights a model** — the population instruments for the lighting -//! lanes, on both sides of the WMO/M2 boundary. -//! -//! `darkpropscan` asks which placed WMO props the interior lane commits as literal black -//! (decision 0969's census), `m2lightscan` which M2s author dynamic light blocks at all, and -//! `shadeat` reads the terrain MCSH shadow bit that decides a doodad's sun gain. +//! Corpus scans over what lights a model: dark WMO props, M2 light blocks, terrain shadow. use std::collections::BTreeMap; @@ -12,23 +7,11 @@ use benilla_formats::{Chain, M2Light}; use crate::model_key; -/// Sweep every WMO **root** (under `prefix`, if given) and list the placed MODD props whose -/// INTERIOR lighting lane commits **literal black**. -/// -/// The interior lane's whole base light is the MODD entry's own baked colour field: ambient = -/// `cap96(colour)`, diffuse = `floor112(colour)` (`0x694e90` create → `0x6a77e0`). The floor leg -/// *raises* a dim colour to max 112 — but -/// it is a hue-preserving scale by `112/max`, so a colour of exactly `#000000` has nothing to raise -/// and both words come out zero. Such a prop is lit by nothing but its owning group's MOLR fixture -/// lights, and a group carrying none (or none within its authored attenuation disk) leaves it a -/// pure black silhouette. -/// -/// The colour field is zero on ~3% of shipped MODDs — the baker leaves it unbaked for props it -/// treats as exterior — so what decides the symptom is the **class of the groups that reference the -/// prop**, and EXTERIOR WINS (decision 0969: the reference's def is per (MODD, placement) and -/// `0x695aa0` makes the exterior bit absorbing). A prop any exterior group's MODR names is therefore -/// sky-lit and never listed here; the `RESCUED` tally counts them, because taking the *first* -/// referrer instead is exactly what drew Booty Bay's entrance arch as a black silhouette. +/// List the placed MODD props that the interior lane lights literal black. Its base light is the +/// MODD colour, ambient `cap96`, diffuse `floor112` (`0x694e90` → `0x6a77e0`), and the floor is a +/// hue-preserving scale by `112/max`, so `#000000` stays black and only the owning group's MOLR +/// lights reach the prop. Exterior wins: any exterior referrer makes the prop sky-lit +/// (`0x695aa0`); `RESCUED` counts those an interior group names first. pub fn darkpropscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let roots = super::wmo_roots(chain, prefix)?; @@ -44,7 +27,6 @@ pub fn darkpropscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { }; roots_scanned += 1; modds_total += root.doodads().len() as u32; - // Nothing to classify without a zero-colour MODD — skip the group reads entirely. if !root.doodads().iter().any(|d| d.color[..3] == [0, 0, 0]) { continue; } @@ -78,11 +60,10 @@ pub fn darkpropscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } zero_colour += 1; let Some(referrers) = refs.get(&(i as u16)) else { - continue; // ORPHAN: no group names it — the exterior default + continue; // no group names it: lit as exterior }; let owner = referrers[0]; - // EXTERIOR WINS over every interior referrer (decision 0969) — the MODD-colour lane is - // for props referenced by interior groups ONLY. + // Exterior wins over every interior referrer. if !referrers .iter() .all(|g| infos.get(*g as usize).is_some_and(|gi| gi.interior)) @@ -92,9 +73,8 @@ pub fn darkpropscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } continue; } - // The owning group's MOLR omni lights, gated by their own attenuation disk measured - // from the prop's origin (the spawn fold uses the loaded M2's bounds reference point; - // the origin is within a model radius of it, so this is the census approximation). + // The owning group's MOLR omni lights in range of the prop's origin; the spawn measures + // from the M2's bounds reference point, within a model radius of it. let in_range = light_refs .get(&owner) .map(|ls| { @@ -156,11 +136,10 @@ pub fn darkpropscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// How many rows of the closing colour tally print (the rest are counted, never silently dropped). +/// Rows of the closing colour tally that print; the rest are counted. const TALLY_ROWS: usize = 20; -/// Cheap warm/cool/neutral hue classification of a `diffuse_color`, used only to eyeball the -/// colour-tally section of `m2lightscan`'s summary — the warm-torch family vs anything unusual. +/// A rough warm, cool or neutral tag for a light colour in `m2lightscan`'s tally. fn hue_tag(r: f32, g: f32, b: f32) -> &'static str { if r >= g && r > b * 1.15 { "warm" @@ -171,9 +150,7 @@ fn hue_tag(r: f32, g: f32, b: f32) -> &'static str { } } -/// Per-family tally for `m2lightscan`'s summary: how many models in this content family carry -/// lights, how many `type==1` point lights they author in total, how many of those are dark -/// (`visibility_off`), and a handful of example paths. +/// One content family's row in `m2lightscan`'s summary; `dark` counts `visibility_off` lights. #[derive(Default)] struct FamilyStats { models: u32, @@ -182,23 +159,14 @@ struct FamilyStats { examples: Vec, } -/// Sweep every `.m2` (optionally under a path prefix) and report which models author M2 dynamic -/// LIGHT blocks — the population instrument for the mechanism (decision 0016, `0x718960`). Per -/// model (only models with ≥1 light, printed -/// sorted by path): its `type==1` point-light count vs directional (`type==0`, ambient-feed, not -/// a discrete GL light) count, then per POINT light: bone, model-space position, `diffuse_color × -/// diffuse_intensity` (raw colour, intensity, and the product), authored attenuation start/end, -/// and an `OFF` tag when [`M2Light::visibility_off`] — the one shape (a static `0` visibility -/// key) that keeps a light dark (`0x71646d`). The closing summary is the real deliverable: totals, -/// a breakdown by top-level content family ([`super::family_of`]) — benilla only spawns these -/// lights for ADT-placed doodads and WMO props today, so this answers how much of the entity path -/// (creatures, held items, GameObjects) is actually missing them — and a cheap diffuse -/// colour×intensity tally ([`hue_tag`]). +/// List the models that author M2 light blocks (`0x718960`): per point light (`type` 1) its bone, +/// position, diffuse colour times intensity and attenuation, `OFF` when a static 0 visibility key +/// keeps it dark (`0x71646d`); directional lights (`type` 0) feed ambient and are only counted. +/// The summary totals them by content family and by colour. pub fn m2lightscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; - // Rounded `(r, g, b) × 100` (int-keyed to stay orderable) — a cheap grouping key for the - // authored diffuse×intensity palette across point lights. + // Diffuse times intensity in hundredths, as integers so the key orders. type ColorKey = (i32, i32, i32); let (mut scanned, mut hits, mut total_point, mut total_dark) = (0u32, 0u32, 0u32, 0u32); @@ -305,7 +273,6 @@ pub fn m2lightscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let tag = hue_tag(r, g, b); println!("{count:>4}x ({r:.2}, {g:.2}, {b:.2}) {tag:<5} e.g. {example}"); } - // Never let the top-20 read as "that's all of them". if let Some(rest) = ranked.len().checked_sub(TALLY_ROWS).filter(|n| *n > 0) { println!(" … and {rest} rarer colours (top {TALLY_ROWS} shown)"); } @@ -313,10 +280,9 @@ pub fn m2lightscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// The terrain MCSH shadow bit at a world position + an ASCII texel neighborhood (`#` shadowed, -/// `.` lit, `?` off-tile/no-chunk). One MCSH texel is `TILE_SIZE/1024` ≈ 0.52 yd; the grid spans -/// ±8 texels so a doodad base sitting one texel from a shadow edge — the 2.5-vs-0.5 intensity -/// cliff — is visible at a glance. +/// Print the terrain MCSH shadow bit, which sets a doodad's sun gain (2.5 lit, 0.5 shadowed), at +/// a world position and the ±8 texels around it (`#` shadowed, `.` lit, `?` no chunk); a texel is +/// `TILE_SIZE / 1024`, about 0.52 yd. pub fn shadeat(chain: &mut Chain, map: &str, x: f32, y: f32) -> Result<()> { let tiles = benilla_formats::load_tiles_around(chain, map, x, y, 0) .with_context(|| format!("loading the tile under ({x}, {y}) on {map}"))?; diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/material.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/material.rs index 38d4a30d..af913e50 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/material.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/material.rs @@ -1,30 +1,13 @@ -//! Corpus scans over **how a batch is textured and blended** — the material side of a model's -//! render submeshes. -//! -//! Blend modes (`blendscan`), per-sequence batch visibility through the alpha combine -//! (`alphascan`), sampler address modes and the UVs that need them (`uvwrapscan`, `texmodescan`), -//! generated-texcoord environment stages (`envmapscan`), and the batches whose animated -//! texture-transform / tint loop is not the same in every sequence slot, which the bake routes to -//! a per-placement material (`uvslotscan`). -//! -//! `fxuvscan` is the intersection of the first two, asked of one corpus: the spell-effect models, -//! whose animated texture transform decides — through their CLAMP-authored UVs — whether a -//! consumer that runs none of it loses the batch's motion or its whole existence. It sized -//! decision 2282, which built the consumer; it stays the way that question is asked at all — and -//! `entityuvscan` is that same question asked of the unit / GameObject / held-item corpus, which -//! sized decision 2295. Both lanes run their transform today; what the two scans are for is -//! keeping "this lane needs no channel" a countable claim rather than an assumed one. They share -//! their whole per-batch reader. +//! Corpus scans over how a batch is textured and blended, and what the effect and entity corpora's +//! animated texture transforms and tints render for a consumer that runs none of them. use std::collections::BTreeMap; use anyhow::{Context, Result}; use benilla_formats::{Chain, KeyAnim, SeqLoops}; -/// Sweep every `.m2` (under `prefix`, if given) and list the models whose MATERIAL table authors -/// blend mode 5 (Mod) / 6 (Mod2x) — the multiply-blend census (decision 0528). One line per -/// matching model: its per-material `(flags, blend)` pairs and path. The raw header read (materials -/// count/ofs at `0x84`, 4-byte `{u16 flags, u16 blend}` records) matches `benilla-m2`'s parse. +/// List the models whose material table authors blend mode 5 (Mod) or 6 (Mod2x): materials at +/// `0x84`/`0x88`, 4-byte `{u16 flags, u16 blend}` records. pub fn blendscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut hits) = (0u32, 0u32); @@ -58,17 +41,8 @@ pub fn blendscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (under `prefix`, if given) and census the models whose batch visibility is -/// **per sequence** — geometry the reference draws in one animation and skips in another, via the -/// alpha combine (`A = colourAlpha × weight`, `A ≤ 0` culls; `0x707680`). -/// -/// This is the population instrument for the class of bug where a client bakes the material tracks -/// once and draws the result forever: every model listed here has at least one batch whose authored -/// visibility CHANGES between sequences, so a single-sequence bake is guaranteed to be wrong for it -/// in some animation. Per model it reports how many batches are **hidden in the model's first -/// sequence** (what a doodad-shaped bake would show) versus hidden in *some* sequence, so the two -/// failure directions — drawing geometry that should be hidden, and hiding geometry that should -/// draw — are separated. `m2alpha` then explains one model in full. +/// List the models whose batch visibility changes between sequences through the alpha combine +/// (`A = colourAlpha × weight`, `A ≤ 0` culls; `0x707680`), which a single-sequence bake misses. pub fn alphascan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut hits) = (0u32, 0u32); @@ -87,8 +61,7 @@ pub fn alphascan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { if seq_count < 2 { continue; // a one-sequence model can't disagree with itself } - // A batch is "hidden in slot s" when its combined factor is 0 across that whole band. The - // sampling grid is coarse on purpose — a batch that so much as flickers non-zero is drawn. + // Hidden in slot s: the combined factor is 0 at every sample; any flicker above 0 draws. let hidden_in = |sub: &benilla_formats::RenderSubmesh, slot: usize| -> bool { sub.alpha_anim.as_ref().is_some_and(|a| { (0..=16u16).all(|k| a.sample(Some(slot), f32::from(k) * 0.25, 0.0) <= 0.0) @@ -122,7 +95,6 @@ pub fn alphascan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { *by_dir.entry(top.to_ascii_lowercase()).or_default() += 1; rows.push((name, varies, first, any)); } - // Loudest first: the models where the most geometry changes hands between sequences. rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); println!( "model varies hid@seq0 hid@any" @@ -141,19 +113,9 @@ pub fn alphascan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (optionally under a path prefix) and list the batches whose texture is -/// authored **CLAMP** (`M2Texture.flags` bit 0/1 clear) while the batch's own UVs run **outside -/// `0..1`** — the exact population a repeat-sampling renderer draws wrong (decision 0763, B52/B96). -/// -/// The margin outside `0..1` is deliberate authoring: clamped, it samples the texture's transparent -/// border and the card fades out to nothing. Sampled with repeat it wraps into the opposite edge — -/// on a cutout sheet, the opaque middle — so the margin draws as solid geometry with a hard seam -/// where u or v crosses the wrap. That is why a snow-fir grows pale plates with a crease down each -/// bough, and why the artefact never looked like an extra primitive: it is the *same* card, -/// sampling the wrong texels. -/// -/// `over` is how far past the edge the batch reaches, in UV units — the width of the wrongly-drawn -/// margin as a fraction of the sheet. +/// List the clamp-authored batches (`M2Texture.flags` bit 0 or 1 clear) whose UVs leave `0..1`: +/// clamped, the margin samples the transparent border, where repeat draws the opposite edge with a +/// seam. `over` is how far past the edge the batch reaches, in UV units. pub fn uvwrapscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut batches, mut hits, mut models) = (0u32, 0u32, 0u32, 0u32); @@ -179,9 +141,7 @@ pub fn uvwrapscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { }) }; let (u, v) = (ext(0), ext(1)); - // Only an axis authored CLAMP can be drawn wrong by repeat; a wrapping axis is meant - // to tile. A hair of float slop past the edge is not a margin — require 1/512 of a - // sheet, well under the thinnest authored border and well over rounding. + // Only a clamp axis draws wrong under repeat; under 1/512 of a sheet is float slop. const SLOP: f32 = 1.0 / 512.0; let bad_u = !s.wrap_x && (u.0 < -SLOP || u.1 > 1.0 + SLOP); let bad_v = !s.wrap_y && (v.0 < -SLOP || v.1 > 1.0 + SLOP); @@ -234,13 +194,8 @@ pub fn uvwrapscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` and report, per texture path, which sampler ADDRESS MODES the corpus asks of -/// it — and how many paths are asked for **more than one** (decision 0763). -/// -/// The design question behind it: the address mode lives on the GPU sampler, which in our asset -/// layer is a property of the loaded `Image`, which is keyed by path. If a `.blp` is only ever -/// asked for one mode, path-keying stays correct and the mode can simply ride the load. Every path -/// asked for two needs two uploads, or one of its users renders wrong. +/// List the texture paths the corpus asks for in more than one address mode: the mode rides the +/// loaded `Image`, keyed by path, so such a path needs one upload per mode. pub fn texmodescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; // texture path -> set of (wrap_x, wrap_y) asked for, as a 4-bit mask @@ -285,36 +240,19 @@ pub fn texmodescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (optionally under a path prefix) and census the batches whose texture -/// coordinates are **GENERATED, not authored** — the sphere-map environment stages -/// (`texture_unit_lookup[texCoordSet] > 2`, the reference's gate at `0x70b8bd`). -/// -/// The population instrument for a silent class: such a batch carries no usable UVs *by design* — -/// the artist collapses the whole mesh onto one point because the runtime is meant to supply the -/// coordinates — so a renderer that reads the vertex UV draws the entire surface in **one texel** -/// of a reflection sheet. Nothing about that failure is loud: no missing geometry, no error, just a -/// flat wash of whatever colour happens to sit at that corner (`GnomeSubwayGlass.m2` → the Deeprun -/// Tram tube's yellow, `AKGNOMEREFLECT.BLP` texel 0,0 = 225,221,142, doubled by its Mod2x blend). -/// -/// **DEGENERATE** marks the batches where the authored UVs collapse to a single point — the ones -/// that render as a flat colour field. The rest carry leftover UVs that merely go unused, so they -/// misdraw as a static smear of the sheet instead: wrong, but not obviously so. Both are fixed by -/// the same mechanism; the split says which reports a renderer's env support explains. +/// List the batches whose texture coordinates are generated, not authored: the sphere-map +/// environment stages (`texture_unit_lookup[texCoordSet] > 2`, gated at `0x70b8bd`). A +/// `DEGENERATE` batch's authored UVs collapse to one point, so reading them draws one flat texel +/// (`GnomeSubwayGlass.m2`: `AKGNOMEREFLECT.BLP` texel 0,0, doubled by Mod2x). pub fn envmapscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut batches, mut hits, mut models, mut degenerate) = (0u32, 0u32, 0u32, 0u32, 0u32); - // Which blend modes and which sheets the mechanism actually serves — a Mod2x env layer tints - // what is behind it (glass), an Add one lays a highlight over the surface (the metal sheen). + // The blend modes and sheets env stages use: Mod2x tints what is behind (glass), Add a sheen. let mut by_blend: BTreeMap = BTreeMap::new(); let mut by_sheet: BTreeMap = BTreeMap::new(); - // **The fallback census.** `stage_is_env_mapped` reads an OUT-OF-RANGE `texture_unit_lookup` - // index as env — the reference's own unguarded read, and the only safe direction. But that - // branch is the one way the gate can *invent* env-mapping on a model whose art never asked for - // it, so it is counted separately: a hit is trustworthy exactly when it came from a real - // `>= 3` entry. `empty_table` is the degenerate shape of the same thing (no table at all ⇒ - // every batch falls through), broken out because it would tar a whole model rather than a - // stage. + // `stage_is_env_mapped` reads an out-of-range `texture_unit_lookup` index as env, as the + // reference's unguarded read does; those hits, and models with no table at all, count apart. let (mut from_oob, mut empty_table) = (0u32, 0u32); for name in names { let Ok(bytes) = chain.read_file(&name) else { @@ -349,8 +287,6 @@ pub fn envmapscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { *by_blend.entry(format!("{:?}", s.blend)).or_default() += 1; let sheet = s.texture.as_deref().unwrap_or("NONE").to_string(); *by_sheet.entry(sheet.clone()).or_default() += 1; - // Does the authored UV set collapse to a point? Then the vertex data cannot even - // approximate the sheet and the batch renders as one flat colour. let span = |axis: usize| { s.uvs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), t| { (lo.min(t[axis]), hi.max(t[axis])) @@ -407,15 +343,13 @@ pub fn envmapscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// The two per-batch channels the bake resolves **per file sequence slot** — the pair -/// `models::m2_batches` hands the runtime as `uv_seq` / `rgb_seq`. Both are `C3Vector` tracks, so -/// one classifier serves both; only how many components survive the bake differs. +/// The two per-batch channels the bake resolves per file sequence slot (`uv_seq`, `rgb_seq`), +/// both `C3Vector` tracks, so one classifier serves both. #[derive(Clone, Copy, PartialEq, Eq)] enum Channel { - /// The texture transform's **translation** track (`tex_anim::bake_uv_seqs`), read off - /// `RenderSubmesh::uv_seq` and its shared-lane twin `uv_anim`. + /// The texture transform's translation (`tex_anim::bake_uv_seqs`): `uv_seq`, or `uv_anim`. Uv, - /// The M2Color **RGB** tint track (`mat_anim::bake_rgb_seqs`), read off `rgb_seq` / `rgb_anim`. + /// The M2Color RGB tint (`mat_anim::bake_rgb_seqs`): `rgb_seq`, or `rgb_anim`. Rgb, } @@ -434,7 +368,6 @@ impl Channel { } } - /// The field name the report quotes, so a reader can grep the struct the numbers came from. fn field(self) -> &'static str { match self { Self::Uv => "uv_seq", @@ -442,9 +375,6 @@ impl Channel { } } - /// Axis names for the value-range cells — one per component the bake keeps. The UV pair stays - /// `x`/`y`: the bake carries the track's raw components and how the reference maps them onto - /// U/V is still under RE (`tex_anim`'s module doc). fn axes(self) -> &'static [&'static str] { match self { Self::Uv => &["x", "y"], @@ -453,36 +383,22 @@ impl Channel { } } -/// How far apart two baked loops may be and still be the **same authored function**. Tight on -/// purpose: this is the noise floor of a `u32` millisecond key rebased through an `f32` divide, not -/// an authoring tolerance — a disagreement anyone could see is orders of magnitude larger. +/// How far apart two baked loops may be and still be one authored loop: the noise of a `u32` ms +/// key rebased through an `f32` divide. const KEY_EPS: f32 = 1e-4; -/// Why one batch's slots disagree — the sub-classification of every batch the bake routed to the -/// per-placement lane, i.e. of every set whose [`SeqLoops::uniform`] came back `None`. -/// -/// The question these four answer is whether that verdict is **earned**. `uniform()` compares -/// `KeyAnim` by `PartialEq` — exact float equality on the clock flags, the period and every key — -/// so two slots holding the same authored loop can still be split by a single flag or by -/// sub-epsilon noise in a rebased timestamp. `Dead0` and `RealDiffers` are real divergence, where -/// one material per placement is the only right answer; `WrapOnly` and `KeysEpsilon` are batches a -/// coarser comparison would have left on the shared lane, and their count IS the over-application. +/// Why a batch's slots disagree, for every set [`SeqLoops::uniform`] refused. `uniform()` is exact +/// float equality, so `WrapOnly` and `KeysEpsilon` are sets a coarser test would share; `Dead0` +/// and `RealDiffers` need a material per placement. #[derive(Clone, Copy, PartialEq, Eq)] enum Why { - /// Slot 0 bakes nothing while a later slot bakes a loop — the B98 shape, the founding case, and - /// the only class a slot-0-pinned bake could not render at all. + /// Slot 0 bakes nothing while a later slot bakes a loop. Dead0, - /// Every slot bakes the same loop except [`KeyAnim::wrap`]: the sequences carrying it disagree - /// on their own loop flag, so one band clamps at its tail where another wraps. Visible only at - /// the very end of a one-shot band — and never at all on a placed doodad, which loops. + /// The slots differ only in [`KeyAnim::wrap`]: one band clamps at its tail where another wraps. WrapOnly, - /// Every slot agrees to within [`KEY_EPS`] on every number and on every flag — the slots differ - /// only by float noise in the rebased key times, and exact `PartialEq` is the whole reason they - /// are here. + /// The slots agree within [`KEY_EPS`] on every number and flag: float noise only. KeysEpsilon, - /// Genuinely different loops: a different key count, a different interpolation or clock law, - /// numbers apart by more than noise — or slot 0 alive against a later slot that holds still, - /// DEAD-0's mirror. + /// Different loops (key count, clock law or values), or slot 0 alive beside a dead later slot. RealDiffers, } @@ -503,7 +419,6 @@ impl Why { } } - /// The one-line reading of the bucket, printed beside its counts. fn blurb(self) -> &'static str { match self { Self::Dead0 => { @@ -529,16 +444,13 @@ impl Why { /// One batch-channel's read of the bake. struct Verdict { why: Why, - /// The largest numeric disagreement between slot 0 and any other slot. `0.0` means the slots - /// are bit-identical — which, for a set the bake kept, means only a *flag* differs. + /// The largest disagreement between slot 0 and another slot; 0 means only a flag differs. delta: f32, - /// The report line's detail cell. detail: String, } -/// The largest absolute disagreement between two baked loops over everything a consumer reads: the -/// period, and every key's time and value. `None` when they are not the same **shape** at all — a -/// different key count or a different interpolation/clock law is a different function, not noise. +/// The largest difference between two baked loops in period, key times and values; `None` when +/// their key count or clock law differs. fn loop_delta>(a: &KeyAnim, b: &KeyAnim) -> Option { if a.step != b.step || a.gseq != b.gseq || a.keys.len() != b.keys.len() { return None; @@ -553,9 +465,7 @@ fn loop_delta>(a: &KeyAnim, b: &KeyAnim) -> Option { Some(d) } -/// `[.L]` — one character per **file** sequence slot: `L` where the slot bakes a loop, `.` where -/// its key window moves nothing. Slot 0 is leftmost, so a leading `.` reads as the B98 shape at a -/// glance. Long tables (a creature's fifty-odd sequences) are truncated to a readable head. +/// `[.L]`, one character per file sequence slot from slot 0: `L` bakes a loop, `.` moves nothing. fn slot_cell(slots: &[Option>]) -> String { const HEAD: usize = 28; let bits: String = slots @@ -570,8 +480,7 @@ fn slot_cell(slots: &[Option>]) -> String { } } -/// `[Wc.]` — the per-slot clock law, the other half of what `PartialEq` compares: `W` wrap, `c` -/// clamp, `.` dead. The whole content of a WRAP-ONLY row. +/// `[Wc.]`, each slot's clock law: `W` wrap, `c` clamp, `.` dead. fn wrap_cell(slots: &[Option>]) -> String { slots .iter() @@ -583,8 +492,7 @@ fn wrap_cell(slots: &[Option>]) -> String { .collect::() } -/// `1.500s wrap 28k x[+0.000..+0.938] y[…]` — one baked loop as its consumer sees it: the clock, -/// the size, and the range it sweeps on every component the bake keeps. +/// One baked loop as `1.500s wrap 28k x[+0.000..+0.938] y[…]`: period, clock, keys, ranges. fn loop_cell>(l: &KeyAnim, ch: Channel) -> String { let range = ch .axes() @@ -610,13 +518,11 @@ fn loop_cell>(l: &KeyAnim, ch: Channel) -> String { ) } -/// Read one batch-channel's per-slot set exactly as the runtime does — [`SeqLoops::slots`], the -/// same values [`SeqLoops::uniform`] compared — and say why they disagree. +/// Why one batch-channel's slots disagree, read through [`SeqLoops::slots`] as the runtime does. fn classify + PartialEq>(set: &SeqLoops, ch: Channel) -> Verdict { let slots = set.slots(); let cell = slot_cell(slots); - // A set exists only when SOME slot bakes, so a dead slot 0 is by construction a dead slot 0 - // beside a live later one — no second test needed. + // A set exists only when some slot bakes, so a dead slot 0 has a live later one. let Some(base) = slots.first().and_then(Option::as_ref) else { let (s, live) = slots .iter() @@ -638,9 +544,7 @@ fn classify + PartialEq>(set: &SeqLoops, ch: Channel) -> Verd detail: format!("{cell} slot 0 {} vs slot {s} dead", loop_cell(base, ch),), }; }; - // Name WHAT diverges, not merely that something did: a value-range cell hides a timing - // difference (same extremes, different key times) behind identical-looking brackets, and - // that is exactly the ambiguity this scan exists to remove. + // Name what diverges: a value range hides a timing difference behind identical brackets. let reason = match loop_delta(base, l) { Some(d) if d <= KEY_EPS => { delta = delta.max(d); @@ -676,50 +580,36 @@ fn classify + PartialEq>(set: &SeqLoops, ch: Channel) -> Verd } } -/// Per-channel corpus counters. Model counts are per bucket and count a model once if **any** of -/// its batches lands there, so they overlap rather than partition. +/// Per-channel corpus counters; a model counts once in each bucket any of its batches lands in. #[derive(Default)] struct Tally { - /// The set is `None`: every slot bakes the same loop, so the batch keeps the shared lane. + /// Batches whose slots all bake the same loop, which keep the shared lane. shared_b: u32, - /// …and of those, the ones that actually animate there (`uv_anim`/`rgb_anim` with `period > 0`). + /// Of those, the ones that animate there (`period > 0`). shared_live_b: u32, shared_live_m: u32, - /// The set is `Some` — batches and models routed to the per-placement lane, by [`Why`]. + /// Batches and models on the per-placement lane, by [`Why`]. per_b: [u32; 4], per_m: [u32; 4], - /// Models with at least one per-placement batch on this channel, whatever the reason. per_m_any: u32, - /// WRAP-ONLY batches whose slots are **bit-identical** apart from the flag; the rest carry - /// sub-epsilon key noise on top of it. + /// WRAP-ONLY batches bit-identical apart from the flag. wrap_exact: u32, } -/// One model's per-placement batches in one (channel, bucket) — the row the family census and the -/// worst-hit tail are both built from. +/// One model's per-placement batches in one (channel, bucket). struct Hit { name: String, ch: Channel, why: Why, batches: u32, - /// The model's **file** sequence slot count, straight off the set the bake built. slots: usize, } -/// How many rows of the worst-hit tail print (the rest are counted, never silently dropped). const TAIL_ROWS: usize = 25; -/// Sweep every `.m2` (optionally under a path prefix) and census the batches the bake routes to a -/// **per-placement material** because their UV / tint loop is not the same in every file sequence -/// slot — see the `Uvslotscan` command doc for the why. -/// -/// It reads the bake's own verdict (`RenderSubmesh::uv_seq` / `rgb_seq`, `Some` exactly when -/// [`SeqLoops::uniform`] refused the shared lane) rather than re-deriving it: this scan used to -/// carry a transcribed sampler because the fix it was sizing did not exist yet, and once it did the -/// twin drifted from it — the very failure `idleslotscan`'s rule names ("beside the parse, so the -/// census asks the renderer's own question instead of a hand-copied twin that can drift from it"). -/// Every number below is therefore what the runtime sees, and the four PER-PLACEMENT buckets say -/// how much of it `uniform()`'s exact float equality is *earning*. +/// Census the batches the bake gives a per-placement material because their UV or tint loop +/// differs between file sequence slots, read off the bake's own verdict (`uv_seq`/`rgb_seq`, +/// `Some` when [`SeqLoops::uniform`] refused the shared lane), with the `Why` of each. pub fn uvslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut batches) = (0u32, 0u64); @@ -737,9 +627,7 @@ pub fn uvslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { scanned += 1; batches += subs.len() as u64; let mut lines: Vec = Vec::new(); - // This model's own contribution, folded into the corpus tally once per channel below: - // (shared, shared-and-live) batch counts, per-bucket batch counts, and the file sequence - // slot count — which only a batch carrying a set can report, because the set IS the table. + // This model's counts per channel: (shared, shared and live), per bucket, its slot count. let mut shared = [(0u32, 0u32); 2]; let mut per = [[0u32; 4]; 2]; let mut slots = [0usize; 2]; @@ -855,9 +743,6 @@ pub fn uvslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { t.wrap_exact ); } - // Where each bucket's population LIVES, and which models carry most of it. `World\` is a - // placed doodad or WMO prop — the only lane the per-placement material reaches — so a - // bucket that is mostly `Creature\`/`Spells\` costs nothing today whatever it says. for w in Why::ALL { let rows: Vec<&Hit> = hits .iter() @@ -894,50 +779,31 @@ pub fn uvslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } // --------------------------------------------------------------------------- -// fxuvscan — what the SPELL-EFFECT corpus's animated texture transforms render -// for a consumer that runs none of them. -// -// Everything down to `uv_batch` is shared with `entityuvscan` below, which asks -// the identical question of the corpus 2282's fix did NOT reach. The sheet -// decode, the two frozen states, the per-axis texel reasoning and the five -// classes live here once on purpose: two sweeps that classified the same batch -// by two slightly different rules would be worse than one sweep. +// fxuvscan, and the per-batch reader down to `uv_batch` that `entityuvscan` shares // --------------------------------------------------------------------------- -/// How many spells print per INVISIBLE model before the rest are counted. const FX_SPELL_ROWS: usize = 12; -/// How many unreadable model paths print before the rest are counted. const FX_UNREAD_ROWS: usize = 20; -/// How many time samples the animated sweep takes across each slot's loop — enough to bound a -/// scroll that only crosses the sheet for part of its period (the reported box is the union, so -/// it only ever over-states what the animation reaches, never under-states it). +/// Time samples per slot's loop in the animated sweep; the reported box is their union. const FX_ANIM_SAMPLES: usize = 64; -/// One texture's mip-0 channels, as the visibility question needs them. -/// -/// Alpha decides it for the modes that read alpha — `Blend`/`AlphaTest`, and M2 mode 4 `Add` -/// (`SRC_ALPHA/ONE`), which is most of the effect corpus. An `Opaque` batch paints whatever it -/// covers and a `Mod`/`Mod2x` batch's equation reads no alpha at all (decision 0528), so neither -/// is called from the sheet. The one mode that needs [`Self::rgb`] is **3, `NoAlphaAdd`** -/// (`ONE/ONE`): it adds the texel's colour with the alpha channel untouched, so an alpha-0 border -/// with non-zero RGB still puts light on the screen. `BloodSpurtSmall01`'s border column is -/// exactly that shape — alpha 0, RGB up to 107 — so the distinction is not hypothetical. +/// One texture's mip-0 alpha, plus [`Self::rgb`] for blend 3 `NoAlphaAdd` (`ONE/ONE`), which adds +/// colour whatever the alpha (`BloodSpurtSmall01`'s border: alpha 0, RGB up to 107); `Opaque`, +/// `Mod` and `Mod2x` batches are not judged from the sheet. struct Sheet { w: u32, h: u32, /// Row-major, one byte per texel. alpha: Vec, - /// Row-major `max(r, g, b)` — what an `ONE/ONE` add would put on the screen. + /// Row-major `max(r, g, b)`. rgb: Vec, } impl Sheet { - /// The inclusive texel index ranges one axis's UV span `[lo, hi]` reaches under the batch's own - /// address mode — clamped to the edge texel (`wrap == false`, the border-sampling case this - /// whole scan turns on) or folded back into `0..1`. A wrapping span of a full sheet or more - /// reaches every texel. + /// The inclusive texel ranges a UV span `[lo, hi]` reaches: clamped to the edge texel, or + /// folded into `0..1` when `wrap`. fn axis(lo: f32, hi: f32, n: u32, wrap: bool) -> Vec<(u32, u32)> { let last = n.saturating_sub(1); let texel = |t: f32| ((t * n as f32) as i64).clamp(0, i64::from(last)) as u32; @@ -956,13 +822,8 @@ impl Sheet { } } - /// The most this sheet can PAINT anywhere in the UV box `u × v` — the greatest alpha, or, when - /// `with_rgb` (a batch that may be `NoAlphaAdd`), the greater of alpha and `max(r, g, b)`. - /// `0` exactly when every texel the box reaches contributes nothing. - /// - /// The box is the batch's UV **bounding box**, which is a superset of the texels its triangles - /// actually cover, so a `0` here is conservative in the one direction that matters: it cannot - /// call a batch invisible that in fact paints. + /// The most the sheet paints in the UV box `u × v`: its greatest alpha, and with `with_rgb` + /// its greatest `max(r, g, b)`. The box bounds the triangles, so a 0 is never a false one. fn paint( &self, u: (f32, f32), @@ -993,8 +854,8 @@ impl Sheet { } } -/// Decode one texture's mip-0 alpha channel, memoised by lowercased path. `None` for a path the -/// chain has no BLP for, or one that will not decode — reported as `UNKNOWN`, never a silent pass. +/// One texture's [`Sheet`], memoised by lowercased path; `None`, reported `UNKNOWN`, if it will +/// not load. fn fx_sheet<'a>( cache: &'a mut BTreeMap>, chain: &mut Chain, @@ -1020,8 +881,7 @@ fn fx_sheet<'a>( .as_ref() } -/// One effect model's place in the spell-visual chain: the `SpellVisualEffectName` rows that name -/// it, and every spell that reaches it with the lifecycle stages it arrives through. +/// An effect model's `SpellVisualEffectName` rows and the spells that reach it, by stage. #[derive(Default)] struct FxReach { /// The table's own spelling of the path (`.mdx`), for display. @@ -1031,9 +891,8 @@ struct FxReach { spells: BTreeMap)>, } -/// Fold one `SpellVisualEffectName` id's model into the corpus, and — when `spell` is given — note -/// that that spell reaches it through that lifecycle stage. An id with no row or an empty path -/// (the table's absent-model convention) folds to nothing, which is the client's own no-op. +/// Fold one `SpellVisualEffectName` id's model into the corpus, noting `spell`'s stage when given; +/// an id with no row or path adds nothing. fn fx_record( out: &mut BTreeMap, visuals: &benilla_formats::SpellVisualCatalog, @@ -1043,11 +902,8 @@ fn fx_record( let Some(path) = visuals.effect_path(effect) else { return; }; - // One shipped row spells its path with a LEADING separator - // (`\\spells\\HellFire_FirePuff_Caster_Base.mdx`, `SpellVisualEffectName` 1180). The chain has - // the model under the same name without it, so the key drops leading separators while the - // DISPLAY path keeps the table's own spelling — the quirk stays visible instead of being - // silently smoothed away. + // `SpellVisualEffectName` 1180 spells its path with a leading separator; the chain holds it + // without one, so the key drops it and the display path keeps it. let e = out .entry(crate::model_key(path.trim_start_matches(['\\', '/']))) .or_default(); @@ -1064,15 +920,9 @@ fn fx_record( } } -/// Every model the spell-visual chain can reach, keyed by [`crate::model_key`] — walked from the -/// **tables**, not from the spells, so a kit no shipped spell names is still swept (it is still -/// content the lane can be asked to play); the spell join is the second pass over the same map. -/// -/// The reachable set is every kit's ten effect slots (`VisualKit::effects` — the nine bone-attach -/// slots plus the world/state plant), every `SpellVisual` row's missile model (field 7) and its -/// dest-anchored model (field 12, the DynamicObject's own `.mdx`). The stage labels are the five -/// lifecycle kit columns plus `missile` and `area`, the latter covering both dest-anchored columns -/// (field 12's model and field 13's area kit) — one lane, one name. +/// Every model the spell-visual tables reach, keyed by [`crate::model_key`], then joined to the +/// spells: every kit's ten effect slots, and each `SpellVisual` row's missile model (field 7) and +/// dest-anchored model (field 12). Stage `area` covers field 12 and the area kit (field 13). fn fx_corpus( visuals: &benilla_formats::SpellVisualCatalog, spells: &benilla_formats::SpellCatalog, @@ -1125,24 +975,18 @@ fn fx_corpus( out } -/// What a consumer that runs NO texture-transform animation renders for one batch whose transform -/// the bake DID key. +/// What a consumer that runs no texture-transform animation renders for a keyed batch. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum FxClass { - /// The transform moves, and frozen the batch samples no painted texel while the animation does: - /// the batch renders **nothing at all**. The `SwipeCaster` class. + /// Frozen, the batch samples no painted texel while the animation does: it draws nothing. Invisible, - /// The transform moves, and the batch paints nothing at any point of the loop — so the missing - /// UV animation is not what is wrong with it. Flagged, never counted as INVISIBLE. + /// It paints nothing at any point of the loop, so the frozen transform is not its fault. Never, - /// The transform moves and the batch draws — statically, where the scroll is the motion. + /// It draws, but statically. Frozen, - /// The transform is keyed but never moves within its band: a constant NON-identity offset the - /// bake carries as a `period == 0` hold. A lane that seeds no offset draws the batch at the - /// identity instead — a static mis-registration, not a missing scroll. + /// A constant non-identity offset (`period == 0`), which a lane seeding none draws misplaced. Held, - /// No call from the sheet: a `Mod`/`Mod2x` batch (its blend equation reads no alpha) or a - /// texture the chain will not hand over. Flagged. + /// No call from the sheet: a `Mod`/`Mod2x` batch, or a texture that will not load. Unknown, } @@ -1187,9 +1031,8 @@ impl FxClass { } } -/// The batch's texture-transform state at `(slot, t)` — the triple [`benilla_formats::uv_transform`] -/// composes, read the way the one lane that RUNS it reads it (`ui_models::UvPart::write_rows`): the -/// per-slot set when the bake built one, else the single shared loop. +/// The texture-transform triple [`benilla_formats::uv_transform`] composes at `(slot, t)`: the +/// per-slot set when the bake built one, else the shared loop. fn fx_state( sub: &benilla_formats::RenderSubmesh, slot: usize, @@ -1213,8 +1056,7 @@ fn fx_state( (trans, rot, scale) } -/// How many file sequence slots this batch's transform was baked across (1 when only the shared -/// single-slot loop exists), and the longest loop period in slot `slot`. +/// How many file sequence slots the batch's transform was baked across, at least 1. fn fx_slots(sub: &benilla_formats::RenderSubmesh) -> usize { let n = |v: Option| v.unwrap_or(0); n(sub.uv_seq.as_ref().map(|s| s.slots().len())) @@ -1223,8 +1065,7 @@ fn fx_slots(sub: &benilla_formats::RenderSubmesh) -> usize { .max(1) } -/// The longest period any of the batch's three transform channels loops on in slot `slot`; -/// `0.0` when every one of them is a constant hold there. +/// The longest transform period in slot `slot`; 0 when every channel holds. fn fx_period(sub: &benilla_formats::RenderSubmesh, slot: usize) -> f32 { let t = match (&sub.uv_seq, &sub.uv_anim) { (Some(s), _) => s.seq(Some(slot)).map_or(0.0, |l| l.period), @@ -1244,8 +1085,7 @@ fn fx_period(sub: &benilla_formats::RenderSubmesh, slot: usize) -> f32 { t.max(r).max(s) } -/// The batch's UV bounding box carried through [`benilla_formats::uv_transform`] — all four -/// corners, so a rotation or scale is bounded rather than assumed away. +/// The UV bounding box through [`benilla_formats::uv_transform`], all four corners. fn fx_box( u: (f32, f32), v: (f32, f32), @@ -1264,9 +1104,7 @@ fn fx_box( out } -/// Does this batch's texture transform animate? The UI lane's own test, verbatim: any of the three -/// channels baked to something ([`benilla_formats::RenderSubmesh::uv_anim`] and the three -/// per-sequence sets). Shared, so the two sweeps cannot drift into asking it differently. +/// Whether the batch's texture transform is keyed: the shared loop or any per-sequence set. fn uv_keyed(sub: &benilla_formats::RenderSubmesh) -> bool { sub.uv_anim.is_some() || sub.uv_seq.is_some() @@ -1274,14 +1112,8 @@ fn uv_keyed(sub: &benilla_formats::RenderSubmesh) -> bool { || sub.uv_scale_seq.is_some() } -/// Does this model author M2 blend mode **3** (`NoAlphaAdd`, `ONE/ONE`) anywhere? -/// -/// The bake folds modes 3 and 4 into one `Blend` + `additive` pair, and no `RenderSubmesh` carries -/// the source material back (the billboard split means batches are not even 1:1 with skin batches), -/// so the question is asked of the model's MATERIAL TABLE — the same raw read `blendscan` does -/// (count/ofs at `0x84`, 4-byte `{u16 flags, u16 blend}`). A model with no mode-3 material has -/// alpha-gated additives and the alpha test is exact; one that has any gets the stricter -/// alpha-or-RGB test on its additive batches, which can only make INVISIBLE harder to claim. +/// Whether the material table (read as `blendscan` does) authors blend 3 `NoAlphaAdd`: the bake +/// folds modes 3 and 4 into `additive`, so such a model's additive batches are judged on RGB too. fn authors_no_alpha_add(bytes: &[u8]) -> bool { let at = |o: usize| -> Option { Some(u32::from_le_bytes(bytes.get(o..o + 4)?.try_into().ok()?)) @@ -1297,27 +1129,20 @@ fn authors_no_alpha_add(bytes: &[u8]) -> bool { } } -/// One animating batch's whole texture-transform read — the per-batch body [`fxuvscan`] and -/// [`entityuvscan`] share, so the two sweeps cannot answer the same question differently. -/// -/// The caller supplies the sheet path because the two corpora do not resolve it the same way: an -/// effect model's batch names its own `.blp`, while a CREATURE batch's `Monster1/2/3` slot is blank -/// in the M2 and filled per display from `CreatureDisplayInfo.textureVariation` — so the same batch -/// has as many sheets as the model has skins, and the visibility question has to be asked of each. +/// One keyed batch's texture-transform read, shared by [`fxuvscan`] and [`entityuvscan`]. The +/// caller supplies the sheet: a creature batch's `Monster1/2/3` slot is filled per display from +/// `CreatureDisplayInfo`, so it has one sheet per skin. struct UvBatch { - /// The verdict under the seed a `play_uv = false` lane renders at (the identity). + /// The verdict at the identity, the offset a material built with `play_uv = false` seeds. class: FxClass, - /// The verdict under the track's first key — what the same batch becomes on a lane that hands - /// `model_material` the loop but never ticks it. + /// The verdict at the first key, where a lane that seeds the loop and never ticks it sits. key_class: FxClass, - /// `uv_anim.sample(0.0)` — the translation seed, `[0, 0]` when the shared loop is absent. + /// `uv_anim.sample(0.0)`, `[0, 0]` without a shared loop. seed: [f32; 2], - /// The report block, in print order. lines: Vec, } -/// Read one batch's texture transform — see [`UvBatch`]. The caller has already established that -/// the transform is keyed ([`uv_keyed`]) and that the batch has UVs to transform. +/// Read one batch's texture transform; the caller has checked [`uv_keyed`] and that it has UVs. fn uv_batch( sheets: &mut BTreeMap>, chain: &mut Chain, @@ -1335,27 +1160,15 @@ fn uv_batch( let slots = fx_slots(sub); let moves = (0..slots).any(|s| fx_period(sub, s) > 0.0); - // THE TWO FROZEN STATES, because our codebase has two seeding conventions and they - // are not the same picture: - // - // - **identity** — the offset an effect part actually renders at. Its material is - // built by `model_render::batch::Materials::entity_variants`, whose `play_uv` is - // `false`, so `model_material` is handed no loop and seeds `sun_scale.zw = (0, 0)`: - // the batch draws its AUTHORED UVs, untransformed. This is the primary verdict. - // - **first key** — `uv_anim.sample(0.0)`, what the same builder seeds when a lane - // DOES hand it the loop (the doodad and off-world lanes). Reported beside it, - // because a fix that wires the loop in without ticking it lands the batch here. - // - // Rotation and scale are at the identity in both: the translation seed is the only - // transform channel a shared material carries at all (the affine pair is the UI - // lane's per-pane table row, decision 2019). + // Two frozen states: the identity, which a material built with `play_uv = false` seeds + // (`sun_scale.zw = (0, 0)`, the authored UVs), and the first key, which `model_material` + // seeds when handed the loop. Rotation and scale stay at the identity in both: a shared + // material carries only the translation seed. let seed = sub.uv_anim.as_ref().map_or([0.0, 0.0], |a| a.sample(0.0)); let (fu, fv) = fx_box(au, av, [0.0, 0.0], [0.0, 0.0, 0.0, 1.0], [1.0, 1.0]); let (ku, kv) = fx_box(au, av, seed, [0.0, 0.0, 0.0, 1.0], [1.0, 1.0]); - // ANIMATED REACH. The union over every slot and a uniform sweep of its loop — an - // over-approximation by construction, so "nothing painted here either" is a claim the - // geometry cannot contradict. + // The animated reach: the union over every slot's sampled loop, an over-approximation. let mut ru = (f32::MAX, f32::MIN); let mut rv = (f32::MAX, f32::MIN); for slot in 0..slots { @@ -1385,7 +1198,6 @@ fn uv_batch( ), None => (0, 0, 0, "—".to_string()), }; - // One rule, run twice — once per frozen state, so the two verdicts cannot drift apart. let verdict = |painted: u8| { if !moves { FxClass::Held @@ -1413,11 +1225,8 @@ fn uv_batch( n(sub.uv_scale_seq.is_some(), "S"), ) }; - // The per-axis reasoning, read off the TEXELS rather than off the span: a CLAMP axis - // whose frozen span runs past an edge samples that edge's texel for every bit of it - // past the edge, so a span like `[+0.951..+1.950]` over a 16-texel sheet is not - // "mostly outside" — it is column 15, alone, for its whole length. That collapse is the - // whole mechanism, so the line names it. + // Per axis, in texels: a clamp span past an edge samples the edge texel, so `[+0.951..+1.950]` + // over 16 texels is column 15 alone. let axis = |wrap: bool, a: (f32, f32), f: (f32, f32), n: u32| { let (texels, verdict) = match n { 0 => ("—".to_string(), "no sheet"), @@ -1503,21 +1312,18 @@ fn uv_batch( } } -/// One classified batch — the row the class listings and the spell join are both built from. struct FxHit { - /// The corpus key (lowercased `.m2`), so the reach map joins straight onto it. + /// The corpus key (lowercased `.m2`) the reach map joins on. key: String, batch: usize, - /// The verdict under the seed the effect lane actually renders at (the identity). + /// The verdict at the identity seed. class: FxClass, - /// The verdict under the track's first key — what the same batch becomes on a lane that hands - /// `model_material` the loop but never ticks it. + /// The verdict at the first-key seed. key_class: FxClass, } -/// Sweep every model the SPELL-VISUAL CHAIN can reach and census the batches whose **texture -/// transform animates** — then classify each by what a consumer that runs none of it renders. See -/// the `Fxuvscan` command doc for the why. +/// Classify every keyed texture-transform batch the spell-visual chain reaches by what a consumer +/// that runs none of it renders. pub fn fxuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let visuals = benilla_formats::load_spell_visual_catalog(chain)?; let spells = benilla_formats::load_spell_catalog(chain)?; @@ -1530,14 +1336,9 @@ pub fn fxuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let mut per_class: BTreeMap = BTreeMap::new(); let mut per_key_class: BTreeMap = BTreeMap::new(); let mut hits: Vec = Vec::new(); - // Every model the chain names that the archives do not carry, or will not parse — listed, - // never a silent hole in the sweep. let mut unread: Vec = Vec::new(); - // How many batches the two frozen states disagree about (see the classification below): the - // number that says whether "what does a no-UV-animation consumer render" has one answer here - // or two. let mut seed_disagrees = 0u32; - // Of the batches that DO draw frozen, how many draw at UVs the loop never opens at. + // Batches that draw frozen at UVs the loop never opens at. let mut misregistered = 0u32; for (key, reach) in &corpus { @@ -1577,8 +1378,6 @@ pub fn fxuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { if read.class != read.key_class { seed_disagrees += 1; } - // A batch that draws today but at a picture the loop never opens at: the identity is - // not the seed, so what is on screen is neither the animation nor its first frame. if read.class == FxClass::Frozen && (read.seed[0] != 0.0 || read.seed[1] != 0.0) { misregistered += 1; } @@ -1647,9 +1446,8 @@ pub fn fxuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { ); if !unread.is_empty() { println!(); - // Every one of these on the shipped 1.12 chain is a DEAD TABLE ROW, not a hole in the - // sweep: `SpellVisualEffectName` still carries the Warcraft-III-era `Particles\*.mdl` - // names the 1.12 archives never shipped. The extension split says so at a glance. + // On 1.12 each is a dead `SpellVisualEffectName` row, mostly `Particles\*.mdl` names the + // archives never shipped. let mdl = unread .iter() .filter(|u| u.to_ascii_lowercase().contains(".mdl ")) @@ -1720,9 +1518,7 @@ pub fn fxuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { c, ); } - // The batches the first-key seed alone condemns: they draw today, and would stop drawing on a - // lane that seeds the loop's opening value without running it. Named, because that is exactly - // the shape of a half-finished fix. + // Batches invisible only at the first-key seed, where a lane that never ticks the loop sits. let key_only: Vec<&FxHit> = hits .iter() .filter(|h| h.key_class == FxClass::Invisible && h.class != FxClass::Invisible) @@ -1750,9 +1546,7 @@ pub fn fxuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } } - // The deliverable: the distinct spells that reach an INVISIBLE batch at all, named in full — - // the set a player can cast and see nothing for. `+` marks a spell that is only reached under - // the first-key seed, never under the one the lane renders at today. + // The spells that reach an invisible batch; `+` marks one reached only at the first-key seed. let mut reached: BTreeMap, bool)> = BTreeMap::new(); for h in hits @@ -1789,59 +1583,46 @@ pub fn fxuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } // --------------------------------------------------------------------------- -// entityuvscan — what the UNIT / GAMEOBJECT / HELD-ITEM corpus's animated -// texture transforms render on the lane that runs none of them. +// entityuvscan: the unit, GameObject and held-item corpus // --------------------------------------------------------------------------- -/// How many display ids print per model before the rest are counted. const ENTITY_ID_ROWS: usize = 12; -/// How many unreadable model paths print before the rest are counted. const ENTITY_UNREAD_ROWS: usize = 20; -/// The `Item\ObjectComponents\\` folders an `ItemDisplayInfo` **left** model column can land -/// in, and the kind label each one carries. The directory is NOT a column of the table: it is -/// chosen by the slot the item is worn in (`entities::equipment::ensure_item_model`), which is a -/// property of the *item*, not of the display — so a census asks the archives which of them -/// actually holds the file. +/// The `Item\ObjectComponents\\` folders an `ItemDisplayInfo` left model can live in. The +/// table has no folder column (the worn slot picks it), so the census asks the archives. const ITEM_DIRS_L: [&str; 4] = ["Weapon", "Shield", "Shoulder", "Quiver"]; -/// …and of a **right** model column: the right pauldron, or an arrow/bullet. +/// The folders a right model can live in: the right pauldron, or an arrow or bullet. const ITEM_DIRS_R: [&str; 2] = ["Shoulder", "Ammo"]; -/// Helm files are per race and sex — `_.m2`, prefix by race id, `M`/`F` by sex -/// (`ensure_item_model`'s own table, pinned against the MPQ listing in decision 0074). +/// Helm files are per race and sex, `_.m2`: this prefix by race id, then `M` or `F`, +/// as `ensure_item_model` spells them. const HELM_RACE_PREFIX: [&str; 8] = ["Hu", "Or", "Dw", "Ni", "Sc", "Ta", "Gn", "Tr"]; -/// One entity-lane model's place in the DBCs: every table row that can put it on screen, so a -/// broken batch joins back to something a director can go and look at. +/// Every table row that can put an entity-lane model on screen. #[derive(Default)] struct EntityReach { /// The table's own spelling of the path (`.mdx`), for display. path: String, /// The `CreatureModelData` row ids naming it (normally one). creature_models: std::collections::BTreeSet, - /// The `CreatureDisplayInfo` rows that reach it — the POPULATION number: one model reached by - /// 300 display rows is a different fix from one reached by 1. + /// The `CreatureDisplayInfo` rows that reach it, the population measure. creature_displays: Vec, - /// The playable `(race, sex)` bodies among those displays (`ChrRaces` cols 4/5 → the same - /// CreatureDisplayInfo chain a streamed unit takes, decision 0041), spelled as the glue does. + /// The playable `(race, sex)` bodies among those displays (`ChrRaces` columns 4 and 5). player_bodies: Vec, - /// `GameObjectDisplayInfo` rows naming it. go_displays: Vec, /// `ItemDisplayInfo` rows naming it, and through which `Item\ObjectComponents\` join. item_displays: BTreeMap>, - /// The bone-pile skeletons (`DeathSkeleton`) — the corpse lane's own models, which - /// no display table names. + /// The corpse lane's bone piles (`DeathSkeleton`), which no display table names. bones: Vec, - /// Also named by the SPELL-VISUAL chain, i.e. already swept by `fxuvscan` and already running - /// its transform on the effect lane since 2282. Marked, never dropped: a model can be held in - /// a hand (this lane, frozen) and thrown as a missile (that lane, animated) on the same tick. + /// Also reached by the spell-visual chain, so also swept by `fxuvscan`. also_fx: bool, } impl EntityReach { - /// How many table rows can put this model on screen — the rough instance-population proxy. + /// Table rows that can put this model on screen, a rough population measure. fn rows(&self) -> usize { self.creature_displays.len() + self.go_displays.len() @@ -1908,26 +1689,21 @@ impl EntityReach { #[derive(Default)] struct EntityCorpus { models: BTreeMap, - /// `CreatureDisplayInfo` rows walked, and the `CreatureModelData` rows no display reaches - /// (unreachable content — counted, never swept). + /// `CreatureDisplayInfo` rows walked, and `CreatureModelData` rows no display reaches. creature_rows: usize, creature_orphan_models: usize, /// `GameObjectDisplayInfo` rows walked, and those naming a `.wmo` (no M2 batches to animate). go_rows: usize, go_wmo: usize, - /// `ItemDisplayInfo` model columns walked, and the two ways one can fail to reach a file: the - /// basename ships under an `Item\ObjectComponents\` folder this column is never joined to - /// (`item_other_dir` — every one of these is a thrown weapon named in BOTH columns, so the - /// model is already in the corpus through its left column and only the extra display-id join - /// is dropped), or nothing ships it at all (`item_absent` — a dead row, the shape `fxuvscan` - /// found in `SpellVisualEffectName`). + /// `ItemDisplayInfo` model columns walked; `item_other_dir` names a file under a folder its + /// column never joins (thrown weapons, swept through the left column), `item_absent` a file + /// nothing ships. item_rows: usize, item_other_dir: usize, item_absent: usize, } -/// Fold one model path into the corpus under [`crate::model_key`], keeping the table's own -/// spelling for display. +/// Fold one model path into the corpus under [`crate::model_key`], keeping the table's spelling. fn entity_record<'a>( out: &'a mut BTreeMap, path: &str, @@ -1941,15 +1717,9 @@ fn entity_record<'a>( e } -/// Every model the ENTITY lane can render, walked from the tables that name one — never from a -/// path prefix, because reachability is the whole question ("the path looks broken" and "the path -/// is reachable" are different questions, 2282's own closing note). -/// -/// Four sources, one per `build_parts` caller that is not the spell-effect lane: -/// `CreatureDisplayInfo` → `CreatureModelData` (every NPC, critter, mount — and every PLAYER body, -/// which resolves through the same chain, decision 0041), `GameObjectDisplayInfo`, -/// `ItemDisplayInfo`'s two model columns joined to the `Item\ObjectComponents\` folder the archives -/// actually hold them in, and the corpse lane's `DeathSkeleton` bone piles. +/// Every model the entity lane can render, walked from the tables: `CreatureDisplayInfo` → +/// `CreatureModelData` (player bodies included), `GameObjectDisplayInfo`, `ItemDisplayInfo`'s two +/// model columns joined to the folders that hold them, and the corpse lane's bone piles. fn entity_corpus(chain: &mut Chain) -> Result { let mut out = EntityCorpus::default(); @@ -2016,15 +1786,12 @@ fn entity_corpus(chain: &mut Chain) -> Result { } // --- held / worn item geometry ---------------------------------------------------------- - // The archives decide which folder a display's basename lives in, because the table does not - // carry one: the equipment lane picks the directory from the slot the item is worn in. let item_files: Vec = super::m2_names(chain, Some("item\\objectcomponents"))? .into_iter() .map(|n| n.to_ascii_lowercase()) .collect(); let files: std::collections::HashSet<&str> = item_files.iter().map(String::as_str).collect(); - // …and the same listing keyed by BASENAME, so an unjoinable column can say which of the two - // failures it is rather than being counted as one lump. + // The same listing by basename, to tell `item_other_dir` from `item_absent`. let by_base: std::collections::HashSet<&str> = item_files .iter() .filter_map(|f| f.rsplit_once('\\').map(|(_, b)| b)) @@ -2053,8 +1820,7 @@ fn entity_corpus(chain: &mut Chain) -> Result { placed = true; } } - // A HELM ships as sixteen per-race/sex files under one stem, and the display row names - // the stem alone — so the census expands it the way the attach does. + // A helm row names only the stem; the files are per race and sex. if col == 0 { let stem = name.strip_suffix(".m2").unwrap_or(name); for prefix in HELM_RACE_PREFIX { @@ -2078,9 +1844,7 @@ fn entity_corpus(chain: &mut Chain) -> Result { } } - // Every id list above is built by walking a HashMap, so it comes out in a different order - // every run. Sorted here, once, because an instrument whose report reshuffles cannot be - // diffed against its own previous output. + // The id lists come from HashMap walks: sort them so the report diffs run to run. for e in out.models.values_mut() { e.creature_displays.sort_unstable(); e.go_displays.sort_unstable(); @@ -2088,7 +1852,7 @@ fn entity_corpus(chain: &mut Chain) -> Result { e.bones.sort(); } - // --- the mark: which of these the spell-effect lane already runs ------------------------- + // --- the models the spell-visual chain also reaches ------------------------------------- if let (Ok(visuals), Ok(spells)) = ( benilla_formats::load_spell_visual_catalog(chain), benilla_formats::load_spell_catalog(chain), @@ -2102,22 +1866,18 @@ fn entity_corpus(chain: &mut Chain) -> Result { Ok(out) } -/// Which clock one batch's keyed transform runs on — the question that decides the SHAPE of the -/// fix, not just its size (0136 choice 1 versus 2282's per-instance lane). +/// Which clock a batch's keyed transform runs on. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum UvClock { - /// Every live loop is clocked on a **global sequence**: a free-running per-scene ms clock the - /// reference anchors once per instance at attach (`0x70eae1`, `CM2Model+0x68`). One - /// shared uniform on a free-running clock is faithful here — 0136's lane, unchanged. + /// Every live loop runs on a global sequence, a free-running clock the reference anchors once + /// per instance at attach (`0x70eae1`, `CM2Model+0x68`), so one shared uniform serves it. Gseq, - /// Every live loop is clocked on its **sequence band**: the host's own playing-clip time, so - /// two instances playing different animations — or the same one at different phases — are at - /// different offsets. A shared uniform cannot be right for both. + /// Every live loop runs on its sequence band, the host's play head, which no shared uniform + /// serves. Band, /// The batch's three channels disagree: some global, some band. Mixed, - /// Keyed but never moving — a constant non-identity hold (`period == 0`), which needs a seed - /// and no clock at all. + /// A constant hold (`period == 0`): a seed, no clock. Hold, } @@ -2154,20 +1914,20 @@ impl UvClock { /// One transform channel's read of the clock question. #[derive(Default)] struct ChannelClock { - /// `[L.L]` per file sequence slot — `None` when the channel carries no per-slot set. + /// `[L.L]` per file slot; `None` without a per-slot set. cell: Option, - /// File slots whose loop is LIVE (`period > 0`). + /// File slots whose loop is live (`period > 0`). live: Vec, /// `true` for each live loop clocked on a global sequence. gseq: Vec, /// The leading live loop as the report prints it: `(period, wrap, gseq)`. lead: Option<(f32, bool, bool)>, - /// The channel's one loop is the SHARED slot-0 bake — every sequence plays the same one. + /// The channel's one loop is the shared slot-0 bake. uniform: bool, } impl ChannelClock { - /// `gseq 2.000s wrap` / `band 1.500s clamp` / `—`. + /// `gseq 2.000s wrap`, `band 1.500s clamp`, `held`, or a dash. fn cell_text(&self) -> String { match self.lead { None if self.cell.is_none() => "—".to_string(), @@ -2181,8 +1941,7 @@ impl ChannelClock { } } -/// Read one channel's per-slot set (or its shared slot-0 twin, for the translation channel which is -/// the only one that has one) the way the runtime reads it. +/// Read one channel's per-slot set, or the translation channel's shared slot-0 loop. fn channel_clock + PartialEq>( set: Option<&SeqLoops>, shared: Option<&KeyAnim>, @@ -2213,7 +1972,6 @@ fn channel_clock + PartialEq>( out } -/// One classified entity batch — the row every listing and the population tail are built from. struct EntityHit { key: String, batch: usize, @@ -2222,22 +1980,18 @@ struct EntityHit { clock: UvClock, /// `T`/`R`/`S`, in that order, for the channels the bake keyed. chans: [bool; 3], - /// The batch's translation lane: `true` when it carries a per-sequence set (`uv_seq`), which is - /// `uvslotscan`'s PER-PLACEMENT population; `false` when it keeps the shared slot-0 loop. + /// The translation has a per-sequence set (`uv_seq`), `uvslotscan`'s per-placement population. per_seq_trans: bool, - /// …and whether that shared loop is LIVE — `uvslotscan`'s "carry a LIVE loop there" count, the - /// number this sweep's corpus must be a subset of. + /// The shared translation loop is live, `uvslotscan`'s live shared count. live_shared_trans: bool, } -/// Sweep every model the UNIT / GAMEOBJECT / HELD-ITEM tables reach and census the batches whose -/// texture transform animates — see the `Entityuvscan` command doc for the why. +/// Classify the keyed texture-transform and tint batches the unit, GameObject and item tables +/// reach. pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let corpus = entity_corpus(chain)?; let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); let anim_names = benilla_formats::load_anim_data_catalog(chain).ok(); - // Loaded once, beside the corpus, because the per-batch sheet question asks it per MODEL: a - // creature batch's `Monster1/2/3` slot is filled from the display's own texture variations. let creatures = benilla_formats::load_creature_catalog(chain).unwrap_or_default(); let mut sheets: BTreeMap> = BTreeMap::new(); @@ -2254,20 +2008,17 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let mut misregistered = 0u32; // The `uvslotscan` cross-check: this corpus's share of the two whole-corpus translation counts. let (mut live_shared, mut per_seq) = (0u32, 0u32); - // Batches whose sheet is filled at RUNTIME (a creature skin variation, a character composite), - // and those where two of a model's skins disagree about what a frozen batch paints. + // Batches whose sheet is filled at runtime (a creature skin, a character composite), and + // those where two of a model's skins disagree about what a frozen batch paints. let (mut skin_filled, mut skin_disagrees) = (0u32, 0u32); - // --- the TINT half of the same sweep (the second dead channel on this lane) --- + // --- the tint half of the same sweep --- let (mut tint_models, mut tint_batches) = (0u32, 0u32); let mut tint_class: BTreeMap = BTreeMap::new(); let mut tint_clock: BTreeMap = BTreeMap::new(); let mut tint_hits: Vec = Vec::new(); let mut tint_blocks: Vec<(String, Vec)> = Vec::new(); let (mut tint_live_shared, mut tint_per_seq) = (0u32, 0u32); - // …and the ALPHA channel beside them, which this lane DOES serve — counted so the report can - // say which of the three material-animation channels is a hole and which is not, rather than - // leaving the reader to assume all three are the same. let (mut alpha_models, mut alpha_batches, mut alpha_live) = (0u32, 0u32, 0u32); let keys: Vec = corpus.models.keys().cloned().collect(); @@ -2294,9 +2045,7 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { continue; }; batches += subs.len() as u64; - // Any of the three channels keeps the model: a batch can animate its tint and not its UVs - // (four do), and gating on `uv_keyed` alone is exactly the shape of predicate this sweep - // exists to catch. + // Any of the three channels keeps the model: a batch can animate its tint and not its UVs. if !subs .iter() .any(|sub| uv_keyed(sub) || tint_keyed(sub) || sub.alpha_anim.is_some()) @@ -2304,16 +2053,13 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { continue; } let no_alpha_add = authors_no_alpha_add(&bytes); - // File sequence slot -> its `AnimationData.dbc` id, so a BAND loop can be named by the - // animations that carry it rather than by an opaque slot number. A slot with no entry is a - // zero-duration sequence (`parse_m2_animations` drops those; the bake does not). + // File slot -> `AnimationData.dbc` id; a missing slot is a zero-duration sequence, which + // the parse drops and the bake keeps. let slot_anim: BTreeMap = benilla_formats::parse_m2_animations(&bytes) .iter() .map(|a| (a.seq_index, a.anim_id)) .collect(); - // The creature skins this model is rendered with — `Monster1/2/3` is blank in the M2 and - // filled per display from `CreatureDisplayInfo.textureVariation`, so a batch on one of - // those slots has as many sheets as the model has skins. + // The creature skins per `Monster1/2/3` slot, from each display's `textureVariation`. let mut skins: [std::collections::BTreeSet; 3] = Default::default(); for d in &corpus.models[&key].creature_displays { let Some(m) = creatures.model(*d) else { @@ -2326,8 +2072,7 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } } - // WHICH BANDS carry the keys — the load-bearing half for a band loop, because it says - // whether the loop is one animation's or every animation's. Shared by both channels. + // Which bands carry the live loops, named by animation. let slot_line = |c: &ChannelClock| -> String { match (&c.cell, c.uniform) { (Some(cell), _) => { @@ -2350,7 +2095,7 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } }; - // --- the TINT + ALPHA pass over the same batches ----------------------------------- + // --- the tint and alpha pass over the same batches --------------------------------- let mut tlines: Vec = Vec::new(); let mut model_has_alpha = false; for (bi, sub) in subs.iter().enumerate() { @@ -2428,9 +2173,7 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } anim_batches += 1; - // WHICH SHEET. An authored path where the batch carries one; otherwise the display's - // own skin variations, each asked separately — the frozen verdict is a property of the - // texture, and two skins of one model can answer differently. + // The sheet: the authored path, else each of the display's skins, which can differ. let candidates: Vec = match (&sub.texture, sub.skin_slot, sub.char_slot) { (Some(t), _, _) => vec![t.clone()], (None, Some(slot), _) => { @@ -2441,8 +2184,7 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { .unwrap_or_default() } (None, None, Some(_)) => { - // A runtime character composite (body atlas, object skin, hair): there is no - // authored sheet to judge, and UNKNOWN is what that has to read as. + // A runtime character composite has no sheet to judge: UNKNOWN. skin_filled += 1; Vec::new() } @@ -2463,7 +2205,6 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } let read = &reads[0].1; - // THE CLOCK, per channel, exactly as the runtime resolves it. let t = channel_clock(sub.uv_seq.as_ref(), sub.uv_anim.as_ref()); let r = channel_clock::<[f32; 4]>(sub.uv_rot_seq.as_ref(), None); let s = channel_clock::<[f32; 2]>(sub.uv_scale_seq.as_ref(), None); @@ -2537,8 +2278,7 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { if s.cell.is_some() { lines.push(format!(" slots S {}", slot_line(&s))); } - // …and, when the translation set exists, WHY the bake refused the shared lane — the - // `uniform()` question `uvslotscan` asks, asked here of the same set. + // Why the bake refused the shared lane, as `uvslotscan` reports it. if let Some(set) = sub.uv_seq.as_ref() { let v = classify(set, Channel::Uv); lines.push(format!(" uv_seq {:<12} {}", v.why.label(), v.detail)); @@ -2621,9 +2361,7 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { counts for the same two lanes (its `of those … carry a LIVE loop there` and \ `PER-PLACEMENT` lines) — this corpus is a subset of that one by construction." ); - // The two translation lanes NAMED, not just counted: `uvslotscan` prints the same two - // populations over the whole corpus, so listing the models here makes the subset claim - // checkable by eye instead of on trust. + // The two translation lanes by model, to check against `uvslotscan`'s listings. let mut shared_models: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); let mut per_seq_models: BTreeMap<&str, u32> = BTreeMap::new(); for h in &hits { @@ -2669,8 +2407,7 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } } - // Every model carrying a batch of class `c`, with the rows that reach it — the join back from - // a verdict to something a director can go and stand in front of. + // Every model with a batch of class `c`, and the table rows that reach it. let listing = |title: &str, note: &str, pick: fn(&EntityHit) -> FxClass, c: FxClass| { let rows: Vec<&EntityHit> = hits.iter().filter(|h| pick(h) == c).collect(); if rows.is_empty() { @@ -2710,9 +2447,7 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { c, ); } - // The batches the first-key seed alone condemns: they draw today, and would stop drawing on a - // lane that seeds the loop's opening value without running it. Named, because that is exactly - // the shape of a half-finished fix. + // Batches invisible only at the first-key seed, where a lane that never ticks the loop sits. let key_only: Vec<&EntityHit> = hits .iter() .filter(|h| h.key_class == FxClass::Invisible && h.class != FxClass::Invisible) @@ -2740,8 +2475,6 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } } - // The deliverable: every affected model, what it renders today, and how much of the world - // reaches it — sorted by POPULATION, because that is what sizes the fix. let mut models: BTreeMap<&str, (Vec<&EntityHit>, usize)> = BTreeMap::new(); for h in &hits { let e = models.entry(h.key.as_str()).or_default(); @@ -2783,8 +2516,7 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } // ===================================================================================== - // The M2COLOR TINT half. Same corpus, same walk, a different dead channel — and a - // different failure mode, because a tint is a multiply and a UV offset is a lookup. + // The M2Color tint half: a tint multiplies, where a UV offset looks up. // ===================================================================================== println!(); println!( @@ -2889,7 +2621,6 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { already." ); - // The join-back for the tint, by class — same shape as the UV listings. let tlisting = |title: &str, c: TintClass| { let rows: Vec<&TintHit> = tint_hits.iter().filter(|h| h.class == c).collect(); if rows.is_empty() { @@ -2930,7 +2661,6 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { tlisting(c.label(), c); } - // …and the population tail, sorted the same way the UV one is. let mut tmodels: BTreeMap<&str, (Vec<&TintHit>, usize)> = BTreeMap::new(); for h in &tint_hits { let e = tmodels.entry(h.key.as_str()).or_default(); @@ -2963,20 +2693,16 @@ pub fn entityuvscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } // --------------------------------------------------------------------------- -// The M2COLOR TINT half of the same census — the second dead channel on the -// same lane, found by asking where `register_tint` is called from. +// The M2Color tint half of the entity census // --------------------------------------------------------------------------- -/// Does this batch's M2Color tint animate? The runtime's own test: the shared slot-0 bake, or the -/// per-sequence set the bake keeps when the slots disagree. The twin of [`uv_keyed`], and written -/// as a union for the same reason — a predicate spelled `rgb_anim.is_some()` silently skips a batch -/// whose slot 0 is a dead hold beside a live later slot (the tint corpus has four of those). +/// Whether the batch's tint is keyed: the shared slot-0 loop, or the per-sequence set, which +/// alone carries a batch whose slot 0 is dead. fn tint_keyed(sub: &benilla_formats::RenderSubmesh) -> bool { sub.rgb_anim.is_some() || sub.rgb_seq.is_some() } -/// How many file sequence slots this batch's tint was baked across (1 when only the shared -/// single-slot loop exists) — [`fx_slots`]'s tint twin. +/// How many file sequence slots the tint was baked across, at least 1. fn tint_slots(sub: &benilla_formats::RenderSubmesh) -> usize { sub.rgb_seq.as_ref().map_or(1, |s| s.slots().len()).max(1) } @@ -2990,9 +2716,7 @@ fn tint_period(sub: &benilla_formats::RenderSubmesh, slot: usize) -> f32 { } } -/// The tint at `(slot, t)`, read the way a consumer that RAN this channel would read it — the -/// per-slot set when the bake built one, else the single shared loop ([`fx_state`]'s shape). -/// White where neither exists, which is the tint identity. +/// The tint at `(slot, t)`: the per-slot set, else the shared loop, else white. fn tint_state(sub: &benilla_formats::RenderSubmesh, slot: usize, t: f32) -> [f32; 3] { match (&sub.rgb_seq, &sub.rgb_anim) { (Some(s), _) => s.seq(Some(slot)).map_or([1.0; 3], |l| l.sample(t)), @@ -3001,33 +2725,22 @@ fn tint_state(sub: &benilla_formats::RenderSubmesh, slot: usize, t: f32) -> [f32 } } -/// Rec.709 relative luminance — a reading aid for "how far apart are these two tints", not a -/// perceptual model. The per-channel deltas beside it are the evidence; this is the single number -/// that sorts a hue swap from a brightness swing. +/// Rec.709 relative luminance, a reading aid beside the per-channel deltas. fn luma(c: [f32; 3]) -> f32 { 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2] } -/// What a consumer that seeds the tint and never re-samples it renders. -/// -/// A tint is a **multiply**, so the failure mode is never "invisible geometry" the way a UV freeze -/// can be — it is the wrong colour, and how wrong is the only question worth asking. The bands are -/// a reading aid over one measured number (the worst per-channel distance between the frozen value -/// and anything the loop reaches, in 0..1 tint units); every batch prints that number, so the call -/// is checkable and the thresholds are not load-bearing. +/// What a consumer that seeds the tint and never re-samples it renders, banded by the worst +/// per-channel distance between the seed and anything the loop reaches (0..1 tint units). #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum TintClass { - /// The frozen tint is (near) BLACK while the loop reaches lit values: the multiply kills the - /// batch's colour outright, so it draws black — or, on an additive batch, not at all. The one - /// band where "wrong colour" becomes "wrong existence". + /// The seed is near black while the loop lights up: it draws black, or nothing if additive. Black, - /// The frozen value is a quarter of the tint range or more away from what the loop reaches — - /// a colour difference nobody has to look for. + /// 0.25 or more away. Strong, /// Between 0.05 and 0.25 away: visible side by side, easy to miss alone. Slight, - /// Under 0.05 away — the loop barely moves, or it moves and comes back. Frozen is - /// unobjectionable, and counting it as a bug would overstate the hole. + /// Under 0.05 away: the loop barely moves. Negligible, } @@ -3060,38 +2773,28 @@ impl TintClass { } } -/// One batch's whole tint read. struct TintBatch { class: TintClass, - /// What `model_material` actually bakes into `tint.xyz`: `rgb_anim.sample(0.0)` — the SHARED - /// slot-0 loop's first key — or identity white when there is no slot-0 bake at all. + /// What `model_material` seeds into `tint.xyz`: the shared loop's first key, else white. seed: [f32; 3], - /// …and which of those two it was. A white seed is the tint twin of the UV lane's DEAD-0: the - /// batch's live tint lives in a later sequence slot the shared material cannot see. + /// The seed is white: there is no shared loop, and the tint lives in a later slot. seed_is_white: bool, /// The worst per-channel distance between the seed and anything the loop reaches. delta: f32, - /// This batch carries a per-sequence set (`rgb_seq`) — `uvslotscan`'s PER-PLACEMENT population. per_seq: bool, - /// …and the shared slot-0 loop is live (`period > 0`) — its SHARED-live population. live_shared: bool, lines: Vec, } -/// Read one batch's animated tint — see [`TintBatch`]. The caller has established [`tint_keyed`]. +/// Read one batch's keyed tint; the caller has checked [`tint_keyed`]. fn tint_batch(sub: &benilla_formats::RenderSubmesh, bi: usize) -> TintBatch { let slots = tint_slots(sub); - // THE SEED, read off the builder rather than assumed: `model_render::model_material` does - // `rgb_anim.map_or([1,1,1], |a| a.sample(0.0))` and writes it to `tint.xyz`, and - // `batch::Materials::build` hands it `sub.rgb_anim.as_ref()` UNCONDITIONALLY — there is no - // `play_rgb` gate to flip. So unlike the UV channel, which freezes at the identity, the tint - // freezes at the loop's OPENING COLOUR… except where slot 0 baked nothing, and then it freezes - // at white while every live slot tints. + // `model_material` seeds `tint.xyz` with `rgb_anim`'s first key, or white without one, and + // the builder always hands it the loop, so an unticked tint holds its opening colour. let seed = sub.rgb_anim.as_ref().map_or([1.0; 3], |a| a.sample(0.0)); let seed_is_white = sub.rgb_anim.is_none(); - // Everything the loop reaches, over every slot and a uniform sweep of each — the same - // over-approximating union `uv_batch` takes, for the same reason. + // Everything the loop reaches: the same sampled union `uv_batch` takes. let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]); let mut delta = 0.0f32; for slot in 0..slots { @@ -3156,20 +2859,15 @@ fn tint_batch(sub: &benilla_formats::RenderSubmesh, bi: usize) -> TintBatch { } } -/// One classified entity tint batch — the row the listings and the population tail are built from. struct TintHit { key: String, batch: usize, class: TintClass, clock: UvClock, - /// The colour the material is frozen at — printed in the listings, because "which wrong - /// colour" is the whole content of a tint bug. + /// The colour the material is seeded with. seed: [f32; 3], - /// **Is the freeze visible at REST?** True when file sequence slot 0 — the band a resting - /// instance plays — carries a live loop. False means the seed is the right colour in every - /// state but the ones a later slot keys, so the batch is wrong only *while that animation - /// plays*. Four of this corpus's five are that second shape, and reporting them as flatly - /// broken would overstate the hole. + /// Slot 0, the band a resting instance plays, carries a live loop; otherwise the seed holds + /// until a later slot's animation plays. rest_wrong: bool, seed_is_white: bool, delta: f32, diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs index 9cf28ccb..a102c5b9 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs @@ -1,24 +1,5 @@ -//! Corpus-scan reports: the **population instruments** that sweep many models or placements to -//! answer "how big is this class, and where does it live", rather than dumping one asset (that is -//! [`crate::m2dump`]'s job). Every one of them is reached as `scan::` from the dispatch in -//! [`crate::main`]; the submodules group them by the question they answer, not by the file format -//! they read: -//! -//! - [`world`] — placed content: a WMO root's own tables, an ADT block's placements. -//! - [`lighting`] — what lights a model: the WMO prop lanes, M2 light blocks, terrain shadow. -//! - [`geometry`] — what geometry a model draws: billboards, geosets, flat ground quads. -//! - [`material`] — how a batch is textured and blended: blend modes, UV wrap, env stages, -//! the batches whose UV/tint loop differs between sequence slots, and what the spell-effect -//! (`fxuvscan`) and unit/GameObject/held-item (`entityuvscan`) corpora's animated texture -//! transforms render for a consumer that runs none of them. -//! - [`particles`] — particle and ribbon emitters, and the features the corpus authors. -//! - [`skeleton`] — the bone tree and the attachment table that addresses it. -//! - [`sequence`] — which sequence an arm plays, what breaks when it is the wrong one, and -//! which of its event markers an arm can reach at all. -//! -//! Split out of a single 3.9k-line `scan.rs` once it had grown ~30 independent sweeps: the file -//! was one *kind* of thing, but not one concern, and nothing in it was shared across families -//! except the corpus listing below. +//! Corpus-scan reports: sweeps over many models or placements that measure how big a class is +//! and where it lives, grouped by the question they answer rather than the format they read. use anyhow::{Context, Result}; use benilla_formats::Chain; @@ -45,13 +26,8 @@ pub use sequence::{ pub use skeleton::{attachscan, bonescan, eventmarkerscan}; pub use world::{doodadscan, placescan, skyboxscan, wmodoodads}; -/// Every `.m2` in the chain, in listfile order, narrowed to a path `prefix` when one is given. -/// -/// The opening line of nearly every sweep here, and — before the split — twenty-two copy-pasted -/// copies of it. Names come back in their listfile casing (that is what a report prints and what -/// [`Chain::read_file`] is handed); the `.m2` test and the prefix match are both done on a -/// lowercased copy, and the prefix is normalized to the chain's own `\` separators, so a caller -/// may pass either slash in either case. +/// Every `.m2` in the chain in listfile order and casing, narrowed to a path `prefix` matched +/// case-insensitively with either slash. pub(crate) fn m2_names(chain: &mut Chain, prefix: Option<&str>) -> Result> { let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); Ok(chain @@ -66,16 +42,8 @@ pub(crate) fn m2_names(chain: &mut Chain, prefix: Option<&str>) -> Result_NNN.wmo`. Only the root carries -/// the tables a sweep wants (MOHD/MODS/MODD/MOGI/MOLT/MOSB); a group file handed to -/// [`benilla_formats::parse_wmo_root`] is just a parse failure to skip. Underscore then exactly -/// three digits is the whole test — and it is here, once, because the two sweeps that used to -/// spell it inline had drifted into spelling it *differently* (one guarded a stem shorter than -/// four characters, the other did not, so a hypothetical `123.wmo` was a root to one and a group -/// to the other). +/// Every WMO root in the chain, narrowed like [`m2_names`]. Only a root carries the +/// MOHD/MODS/MODD/MOGI/MOLT/MOSB tables; a group file is `_NNN.wmo`, exactly three digits. pub(crate) fn wmo_roots(chain: &mut Chain, prefix: Option<&str>) -> Result> { let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); Ok(chain @@ -96,9 +64,7 @@ pub(crate) fn wmo_roots(chain: &mut Chain, prefix: Option<&str>) -> Result` path component for consistent family-key display -/// regardless of how a given asset's listfile entry happened to be cased (`WEAPON`/`weapon`/ -/// `Weapon` all collapse to `Weapon`). +/// Title-case an `Item\ObjectComponents\` component so its listfile casings share one key. fn title_case(s: &str) -> String { let mut chars = s.chars(); match chars.next() { @@ -109,17 +75,8 @@ fn title_case(s: &str) -> String { } } -/// The top-level content-family bucket for an internal M2 path — the summary dimension for "how -/// much content is affected, and of what KIND", shared by [`lighting::m2lightscan`] and -/// [`material::uvslotscan`]. Derived from the path's first one or two components, -/// case-insensitively. -/// -/// The split that earns its keep is `World\` versus everything else: a `World\` model is a placed -/// doodad or WMO prop, which reaches the screen through the shared-material lanes (the M2-light -/// spawn, the `UvAnimMaterials`/`MatAnimTable` registries), while a `Creature\`/`Spells\` one -/// reaches an entirely different consumer. `World\Goober\` (GameObject displays) splits off again -/// because it is the hosted lane, not the ADT/WMO one; everything not otherwise named folds into -/// `other`. +/// The content family of an M2 path, a sweep's summary dimension: `World\` is placed doodads and +/// WMO props, `World\Goober\` GameObject displays. pub(super) fn family_of(name: &str) -> String { let comps: Vec<&str> = name.split('\\').collect(); let low = |s: &str| s.to_ascii_lowercase(); diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/particles.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/particles.rs index f50fae13..9d0709aa 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/particles.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/particles.rs @@ -1,9 +1,5 @@ -//! Corpus scans over **particle and ribbon emitters** — which of the emitter system's features -//! the shipped data actually authors, and which shapes a consumer can get wrong. -//! -//! The feature census (`partcensus`), the flipbook ramps (`cellscan`), the per-slot emission -//! windows (`partslotscan`), the 3-D geometry-model shards (`shardcensus`), and the draw-order -//! population effects share with their owner's own transparent batches (`fxordercensus`). +//! Corpus scans over particle and ribbon emitters: the features the data authors, and the shapes +//! a consumer can get wrong. use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; @@ -12,14 +8,9 @@ use benilla_formats::Chain; use crate::model_key; -/// Sweep every `.m2` in the chain and list the models carrying RIBBON emitters, with each -/// model's `+0xc0` **enable-gate** census beside the count — the population instrument for -/// "which trails are state-dependent, and where does the gate actually key". -/// -/// A `GATED` line names the sequences a trail is dark in; `MID-SEQ` marks the models whose gate -/// flips *inside* a band rather than only at its start, which a band-start-only reader cannot -/// express (decision 1011 — `G_FrostTrap`'s streamers light 200 ms into the trigger and nowhere -/// else, so a band-start read says "never" and an ungated consumer says "always"). +/// List the models with ribbon emitters and each ribbon's `+0xc0` enable gate per sequence; +/// `MID-SEQ` marks a gate that flips inside a band, not only at its start (`G_FrostTrap`'s +/// streamers light 200 ms into the trigger and nowhere else). pub fn ribbonscan(chain: &mut Chain) -> Result<()> { let names = super::m2_names(chain, None)?; let (mut scanned, mut hits, mut gated, mut mid_seq) = (0u32, 0u32, 0u32, 0u32); @@ -35,7 +26,6 @@ pub fn ribbonscan(chain: &mut Chain) -> Result<()> { } hits += 1; let defs = benilla_formats::parse_m2_ribbon_emitters(&bytes).unwrap_or_default(); - // Per ribbon: the sequences it can be dark in, and whether the gate keys mid-band. let mut notes: Vec = Vec::new(); let (mut any_gate, mut any_mid) = (false, false); for (i, d) in defs.iter().enumerate() { @@ -55,12 +45,8 @@ pub fn ribbonscan(chain: &mut Chain) -> Result<()> { per.sort(); notes.push(format!(" r{i} {}", per.join(" "))); } - // The BLEND census beside the gate one. A ribbon takes its blend from a real M2 material - // (`materialIndices[0]` → render-flags `blend`), so unlike a particle emitter it can in - // principle author any mode — and the two the parse folds (`Opaque`, covering file modes - // 0/1/5/6) render with no alpha test at all, which is exactly what painted the particle - // side's AlphaKey debris as solid squares. Tallied here so the fold stays a MEASURED - // approximation instead of an assumed-empty one. + // A ribbon's blend is its material's (`materialIndices[0]` → render flags), so unlike a + // particle's it can be any mode; the tally shows what the parse folds each raw mode to. for d in &defs { let raw = d.blend_mode.map_or("?".into(), |m| m.to_string()); *blends @@ -89,9 +75,7 @@ pub fn ribbonscan(chain: &mut Chain) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (under `prefix`, if given) and list the models whose particle emitters -/// carry any bit of `mask` in their file flags — see the `Partscan` command doc. One line per -/// matching emitter: its index, full flag word, shape/type, and the model path. +/// List the particle emitters whose file flags carry any bit of `mask`. pub fn partscan(chain: &mut Chain, mask: u32, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut hits, mut emitters) = (0u32, 0u32, 0u32); @@ -130,11 +114,9 @@ pub fn partscan(chain: &mut Chain, mask: u32, prefix: Option<&str>) -> Result<() Ok(()) } -/// The census's raw-extras view of one on-disk emitter record: the fields the shipped -/// [`benilla_formats::ParticleEmitterDef`] deliberately does not carry (yet). Read straight off -/// the record bytes (stride/header per the parser's module doc): the two model-filename M2Arrays -/// at `+0x18` (geometry model — 3-D "model particles") and `+0x20` (recursion model — per-particle -/// child emitters), and the emission-rate track's interpolation word (`+0xdc`, 0 = step). +/// Raw fields of one emitter record (stride `0x1f8`): the geometry-model (`+0x18`) and +/// recursion-model (`+0x20`) path `M2Array`s, and the emission-rate track's interpolation word +/// (`+0xdc`, 0 = step) and key count. struct RecordExtras { geometry_model: Option, recursion_model: Option, @@ -142,7 +124,6 @@ struct RecordExtras { rate_keys: u32, } -/// Read the raw extras for every emitter record in an M2 (empty if not an MD20 or no emitters). fn record_extras(bytes: &[u8]) -> Vec { const STRIDE: usize = 0x1f8; let u16_at = |o: usize| u16::from_le_bytes([bytes[o], bytes[o + 1]]); @@ -215,8 +196,7 @@ fn spell_attribution(chain: &mut Chain) -> HashMap> { map } -/// One census dimension's tally. `model_count` counts every distinct model exactly; -/// `models` keeps the first 64 (sorted) for the example listings. +/// One census dimension: `model_count` counts every model, `models` keeps the first 64, sorted. #[derive(Default)] struct Tally { emitters: u32, @@ -239,8 +219,7 @@ impl Tally { } } -/// Sweep every `.m2` (under `prefix`, if given) and census which particle-emitter FEATURES the -/// corpus actually authors — see the `Partcensus` command doc. +/// Census which particle-emitter features the corpus authors, with the spells behind rare ones. pub fn partcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let attribution = spell_attribution(chain); let names = super::m2_names(chain, prefix)?; @@ -270,12 +249,8 @@ pub fn partcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { benilla_formats::ParticleShape::Sphere => hit("shape:sphere"), benilla_formats::ParticleShape::Spline => hit("shape:SPLINE"), } - // ANISOTROPIC plane rectangles — the population on which the areaLength↔areaWidth axis - // pairing is observable at all (a SQUARE area renders identically either way). 0563 and - // 0566 both pre-named "a 90°-wrong ANISOTROPIC effect" as this lane's suspect, but no - // instrument could LIST that population, so the swapped pairing outlived both audits - // until Gressil's 0.1 × 1.1 blade smoke drew its curtain across the blade. Bucketed by - // aspect so a thin curtain (load-bearing) separates from a near-square (invisible). + // Anisotropic plane areas, the only ones where the areaLength/areaWidth axis pairing + // shows (a square renders the same either way), bucketed by aspect. let now = d.params.sample(None, 0.0, 0.0); if d.shape == benilla_formats::ParticleShape::Plane { let lo = now.area_length.abs().min(now.area_width.abs()); @@ -289,10 +264,8 @@ pub fn partcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } } } - // ANIMATED parameter channels (decision 0844): the population the value[0] flatten - // silently mis-rendered — Frost Nova's emission radius riding its ring out, Arcane - // Explosion's riding its dome. Tallied per channel so the census names WHICH knob - // actually moves in the corpus. + // Parameter channels that animate, per channel (Frost Nova's emission radius rides + // its ring out). const PARAM_KEYS: [&str; 9] = [ "param-anim:speed", "param-anim:speedVar", @@ -394,8 +367,8 @@ pub fn partcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } if x.rate_keys > 1 && x.rate_interp != 0 { hit("rate:LERP-RAMP(interp!=0)"); - // A BURST emitter with a lerp rate track would arm a near-zero count at its - // rising edge — if the corpus authored one, the burst edge law needs a re-look. + // A burst emitter with a lerped rate would arm a near-zero count at its + // rising edge. if d.burst() { hit("rate:BURST+LERP(suspect)"); } @@ -418,8 +391,6 @@ pub fn partcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { ); } - // The full model list — with spell attribution — for the dimensions that decide mechanism - // scope (the UPPERCASE keys: unimplemented or folded legs, plus the odd corners). let detail: &[&str] = &[ "MODEL-PARTICLES(geometry)", "CHILD-EMITTERS(recursion)", @@ -471,23 +442,11 @@ pub fn partcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` and census its particle emitters' over-life **flipbook** fields — the -/// population instrument behind decision 0685 (the reverse-playing cell ramp). -/// -/// One line per emitter that is interesting on any axis, then the totals. The axes are exactly the -/// ways a flipbook reader can be wrong, each of which shipped data does exercise: -/// -/// - `INVERTED` — a `begin > end` pair. Legal, and it means *play the sheet backwards*; a reader -/// that clamps into `[begin, end]` mangles it (and in Rust panics outright). -/// - `TAIL-RAMP` — the tail streak's own ramp differs from the head's, on an emitter that draws a -/// tail. The two are independently authored (file +0x168.. vs +0x174..); handing the head's cell -/// to the streak animates it through a sheet the author pinned to one cell. -/// - `PAST-ATLAS` — a cell index beyond `rows·cols`. The reference masks the COLUMN and leaves the -/// ROW unbounded, so the index wraps to row 0 rather than holding the last cell. -/// - `REPEAT` — a per-segment repeat count ≠ 1, i.e. the sheet cycles more than once per segment. -/// - `NON-POW2` / `MID` — the two shapes the reference itself degrades on (a 1×1 fallback, and a -/// `mid` of 0/1 that walks its own sampler into a NaN). Both are empty in 1.12.1 and are here so -/// that stays checkable. +/// Census the emitters' over-life flipbook fields: `INVERTED` (`begin > end`) plays the sheet +/// backwards, which a `clamp` into it panics on; `TAIL-RAMP`, a tail ramp (`+0x174`) unlike the +/// head's (`+0x168`); `PAST-ATLAS`, a cell past `rows·cols`, which the reference wraps to row 0 +/// (it masks the column only); `REPEAT`, a segment repeat other than 1; `NON-POW2` and `MID`, +/// shapes the reference degrades on (a 1×1 fallback, a NaN sampler), none in 1.12.1. pub fn cellscan(chain: &mut Chain) -> Result<()> { let names = super::m2_names(chain, None)?; @@ -524,7 +483,7 @@ pub fn cellscan(chain: &mut Chain) -> Result<()> { pair(&ol.head_cells[1]) ); } - // Only a tail-drawing emitter (particleType 1/2) can show a tail-ramp difference. + // Only a tail-drawing emitter (particleType 1 or 2) shows a tail ramp. if e.head_tail >= 1 && ol.tail_cells != ol.head_cells { tail_ramp += 1; println!( @@ -542,8 +501,7 @@ pub fn cellscan(chain: &mut Chain) -> Result<()> { let atlas = e.tile_rows * e.tile_cols; if ramps.iter().any(|r| r.begin >= atlas || r.end >= atlas) { past_atlas += 1; - // On a 1×1 sheet every index resolves to the same texture, so only a real atlas - // can show the wrap. + // On a 1×1 sheet every index is the same texture: only a real atlas shows the wrap. if atlas > 1 { past_atlas_real += 1; println!( @@ -576,19 +534,10 @@ pub fn cellscan(chain: &mut Chain) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (optionally under a path prefix) and list the emitters whose **file slot 0 is -/// dead while another slot is alive** — the shape that makes a pinned-slot-0 consumer silently -/// render nothing (decision 0760, found on `BlastedLandsLightningbolt01.m2`, B63). -/// -/// The reference samples the **playing** sequence's rate window every frame (`0x71850d`); a -/// consumer that pins slot 0 instead is only correct while -/// slot 0 carries the emitter's whole story. When an author keys the burst in a *later* variation — -/// a lightning strike that fires on 5 % of arms, an ambient prop with a rare flourish — slot 0 is a -/// flat zero and the pinned consumer emits nothing, for ever, on every placement. That is invisible -/// from the outside: the emitter is built, pooled, and ticking; it just never births a particle. -/// -/// `peak0` is slot 0's own peak rate, `peakN` the best any other slot reaches. A listed emitter has -/// `peak0 <= 0 < peakN`. `slots` counts FILE sequence slots (the axis `EmitTiming` bakes on). +/// List the emitters whose file slot 0 never emits while another slot does (`peak0 <= 0 < +/// peakN`, the peak rates). The reference samples the playing sequence's rate window every frame +/// (`0x71850d`), so a consumer pinned to slot 0 never births a particle from them. `slots` counts +/// file sequence slots, the axis `EmitTiming` bakes on. pub fn partslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut with_emitters, mut emitters, mut dead0) = (0u32, 0u32, 0u32, 0u32); @@ -654,25 +603,15 @@ pub fn partslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (optionally under a path prefix) and census the corpus's **3-D model -/// particles**: the emitters whose record carries a geometry-model reference (`+0x18` — a shard -/// instance renders that GEOMETRY model's own submeshes, not a billboard quad). Per OWNER model: -/// the owner-last draw-order rung its shards are stamped with at runtime -/// (`owner_last_rung(m2_owner_reach(owner submeshes))`); per referenced GEOMETRY model: the -/// material family tuples its render submeshes author — (blend, two_sided, additive, -/// no_depth_write, no_depth_test), the exact key the shard's material is built from. -/// -/// This is the **ground truth for the pipeline-warm menagerie's shard rows**: warming a pipeline -/// per (rung × family) cell is only worth its table if the corpus's cells are enumerable, and this -/// census is what enumerates them — the rung histogram sizes the rung axis, the family-tuple set -/// (split by transparent-pass membership, the renderer's own test) sizes the material axis, and -/// the unresolved-path count bounds what the table can never cover. +/// Census the 3-D model particles, emitters with a geometry model (`+0x18`) whose shards draw that +/// model's submeshes: per owner the draw-order rung its shards are stamped with +/// (`owner_last_rung(m2_owner_reach(..))`), per geometry model the material families its shards +/// are built from. The rung and family sets size the pipeline-warm table's shard rows. pub fn shardcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; - // A submesh's material family: the tuple the shard material is keyed by, rendered compactly - // (`Blend+2s+add`), plus its transparent-pass membership — the renderer's own test, where - // `additive` forces the pass whatever the authored blend says (`model_material`). + // A submesh's material family label and whether it draws in the transparent pass, which + // `additive` forces whatever the blend (`model_material`). let fam = |s: &benilla_formats::RenderSubmesh| { let transparent = s.additive || matches!( @@ -692,8 +631,7 @@ pub fn shardcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { (label, transparent) }; - // Each distinct geometry model is read, parsed, and tallied exactly once; the cache keeps its - // (family, transparent) list per submesh for the per-pair lines. `None` = unresolvable. + // Each geometry model is read and tallied once; `None` marks an unresolvable path. let mut geo_cache: BTreeMap>> = BTreeMap::new(); let mut fam_transparent: BTreeMap = BTreeMap::new(); let mut fam_opaque: BTreeMap = BTreeMap::new(); @@ -709,9 +647,8 @@ pub fn shardcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { if benilla_formats::parse_m2_particle_emitters(&bytes).is_err() { continue; } - // Distinct geometry references, normalized the way the chain resolves model paths - // (lowercase, `\` separators, `.mdx`/`.mdl` → `.m2` — `model_key` mirrors the crate's own - // `model_path`). + // Distinct geometry paths, normalized as the chain resolves them (`model_key` mirrors + // the crate's `model_path`). let geos: BTreeSet = record_extras(&bytes) .iter() .filter_map(|e| e.geometry_model.as_deref()) @@ -721,8 +658,7 @@ pub fn shardcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { continue; } owners += 1; - // The rung the owner's shards are stamped with: the renderer's own bound and rung, at - // placement scale 1 (a scaled placement multiplies the reach). + // The owner's rung at placement scale 1; a scaled placement multiplies the reach. let dir = name.rsplit_once('\\').map_or("", |(d, _)| d); let owner_subs = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]).unwrap_or_default(); @@ -810,19 +746,12 @@ pub fn shardcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (optionally under a path prefix) and count, per model, the two halves of the -/// **owner-last draw-order** law: the EFFECTS a model authors (particle emitters + ribbon trails) -/// and the TRANSPARENT-pass batches of its own body those effects must draw after (decisions -/// 0719/0721). A model with both is one the rung actually changes; a model with effects and no -/// transparent batch of its own never had the defect and is listed only in the totals. -/// -/// This is the population instrument the two decisions were argued from. "Does this fix anything -/// besides the voidwalker's eyes?" is not a question to answer by naming plausible creatures — -/// it is a count, and the count is what says whether the mechanism closes a class or a case. +/// Count per model the two halves of the owner-last draw order: the effects it authors (particle +/// emitters and ribbons) and its own transparent-pass batches they must draw after. Only a model +/// with both is changed by the rung, and only those are listed. pub fn fxordercensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; - // Per top-level content family (Creature / Item / Spells / World / …), so the totals say - // WHERE the class lives rather than only how big it is. + // Top-level directory → (models with effects, of those at risk). let mut family: BTreeMap = BTreeMap::new(); let (mut scanned, mut with_fx, mut at_risk) = (0u32, 0u32, 0u32); let mut rungs: BTreeMap = BTreeMap::new(); @@ -839,9 +768,8 @@ pub fn fxordercensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { continue; } with_fx += 1; - // The occluders: batches the renderer puts in the one distance-sorted transparent list. - // `additive` forces that pass whatever the authored blend says (`model_material`), so the - // test mirrors the renderer's rather than reading the blend word alone. + // Batches in the distance-sorted transparent pass, which `additive` forces whatever the + // blend (`model_material`). let dir = name.rsplit_once('\\').map_or("", |(d, _)| d); let subs = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]).unwrap_or_default(); let occluders: Vec<&benilla_formats::RenderSubmesh> = subs @@ -857,7 +785,6 @@ pub fn fxordercensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { }) .collect(); let transparent = occluders.len(); - // The renderer's own bound and the renderer's own rung — not a re-derivation of them. let reach = benilla_formats::m2_owner_reach(&subs); let fam = name.split('\\').next().unwrap_or("?").to_string(); family.entry(fam).or_default().0 += 1; @@ -869,7 +796,7 @@ pub fn fxordercensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { .entry(name.split('\\').next().unwrap_or("?").to_string()) .or_default() .1 += 1; - // At placement scale 1 — the survey number; a scaled placement multiplies the reach. + // At placement scale 1; a scaled placement multiplies the reach. let rung = benilla_formats::owner_last_rung(reach); *rungs.entry(rung as u32).or_default() += 1; println!( diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/sequence.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/sequence.rs index b7e87fe8..727c227b 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/sequence.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/sequence.rs @@ -1,13 +1,5 @@ -//! Corpus scans over **which sequence is playing** — the animation-arm side, where being on the -//! wrong sequence (or on none at all) silently mis-renders everything keyed to it. -//! -//! The GameObject substate arm (`goanimscan`), the effect-model Stand/Hold/Decay lifecycle -//! (`fxlifescan`), the loader-idle slot the per-sequence bakes degrade away from (`idleslotscan`), -//! and the models with no keyed bone at all, so no clock ever advances (`seqclockscan`). -//! -//! Plus the arm's *reach*: which models author a sound-event marker the rig gate never arms -//! (`soundeventscan`) — the same failure one level down, where the sequence is right and nothing -//! runs its clock. +//! Corpus scans over which sequence is playing: GameObject substates, the effect-model lifecycle, +//! the idle slot, clockless models, and the markers a sequence carries. use std::collections::{BTreeMap, HashSet}; @@ -16,10 +8,9 @@ use benilla_formats::Chain; use crate::model_key; -/// The GameObject animation arm's LUT (`.data 0x8607e4`): -/// internal **substate** → the `AnimationData.dbc` id the object layer arms. +/// The GameObject arm's LUT (`.data 0x8607e4`): internal substate → the `AnimationData.dbc` id. const SUBSTATE_ANIM: [u16; 13] = [ - 145, // 0 Spawn — NO client path produces this substate + 145, // 0 Spawn (one-shot on create, `0x5f382f` → `0x5f8c50`) 147, // 1 Closed (rest) 148, // 2 Open (motion) 149, // 3 Opened (rest) @@ -27,18 +18,16 @@ const SUBSTATE_ANIM: [u16; 13] = [ 150, // 5 Destroy (motion) 151, // 6 Destroyed (rest) 152, // 7 Rebuild (motion) - 153, 154, 155, 156, // 8..11 Custom0-3 — reachable only via SMSG_GAMEOBJECT_CUSTOM_ANIM + 153, 154, 155, 156, // 8..11 Custom0-3 (one-shots through `0x5f8c50`) 157, // 12 Despawn ]; -/// The **transient** (transition-motion) substates among the reachable ones — the rows slot 14 -/// `0x5f4120` advances off at the arm's window end (2 Open → 3 Opened, 4 Close → 1 Closed, -/// 5 Destroy → 6 Destroyed). Their duration is the object layer's, never the clip's loop bit. +/// The transition substates, which slot 14 (`0x5f4120`) advances at the arm's window end +/// (2 Open → 3 Opened, 4 Close → 1 Closed, 5 Destroy → 6 Destroyed), whatever the clip's loop bit. const MOTION_SUBSTATES: [usize; 3] = [2, 4, 5]; -/// The six substates a `GAMEOBJECT_STATE` × `GAMEOBJECT_ANIMPROGRESS` pair can actually produce -/// (`0x5f3c30`). Substate 0 (Spawn) has no producer at all, and 8..12 come from other opcodes -/// entirely. +/// The six substates a `GAMEOBJECT_STATE` × `GAMEOBJECT_ANIMPROGRESS` pair produces (`0x5f3c30`); +/// Spawn (0), Custom0-3 (8..11) and Despawn (12) come from the one-shot channel `0x5f8c50`. const REACHABLE: [(usize, &str); 6] = [ (1, "READY settled"), (4, "READY mid "), @@ -48,9 +37,8 @@ const REACHABLE: [(usize, &str); 6] = [ (5, "ALT mid "), ]; -/// The four-way remap (`0x5f3972`): what the arm actually requests when the model doesn't author -/// the substate's LUT id. Returns `(id, rate0)` — `rate0` marks the two legs that freeze a *motion* -/// clip at frame 0 to stand in for a missing *rest* pose. +/// The four-way remap (`0x5f3972`) for a model that lacks the substate's LUT id, as `(id, rate0)`: +/// `rate0` marks the two legs that freeze a motion clip at frame 0 in place of a missing rest pose. fn go_remap(m: &benilla_m2::M2Model, id: u16) -> (u16, bool) { if m.owns_animation(id) { return (id, false); @@ -77,14 +65,13 @@ fn go_remap(m: &benilla_m2::M2Model, id: u16) -> (u16, bool) { 149 if m.owns_animation(148) => (149, false), 149 if m.owns_animation(146) => (146, true), 149 => (151, false), - // Outside the door group there is no remap — the id goes to op4 as-is. + // Outside the door group the id goes to op4 as is. other => (other, false), } } -/// op4's own id → played sequence resolve (`0x7121a0` via `0x711bf0`): the model's -/// `playableAnimationLookup` row (when the id is in range), then `animationLookup` to a file slot. -/// `None` when nothing playable comes out — the reference arms nothing and the pose simply stands. +/// op4's id resolve (`0x7121a0` via `0x711bf0`): `playableAnimationLookup` when the id is in +/// range, then `animationLookup` to a file slot. With no slot the reference arms nothing. fn go_resolve_slot(m: &benilla_m2::M2Model, id: u16) -> Option<(u16, u16)> { let played = m .playable_animation_lookup @@ -94,9 +81,8 @@ fn go_resolve_slot(m: &benilla_m2::M2Model, id: u16) -> Option<(u16, u16)> { (slot != 0xffff).then_some((played, slot)) } -/// The generic loader seed (`0x71019b`): resolve id 0, and arm **id 0** when the model owns what -/// that resolves to — only the degenerate leg (owning nothing reachable) falls back to the raw -/// `animations[0]` dword. +/// The loader seed (`0x71019b`): id 0 when the model owns what id 0 resolves to, else the +/// file-order-first sequence's own id (`animations[0]`). fn go_loader_seed( m: &benilla_m2::M2Model, seqs: &[benilla_formats::ModelAnimation], @@ -108,18 +94,16 @@ fn go_loader_seed( if m.owns_animation(resolved) { go_resolve_slot(m, 0) } else { - // The degenerate leg: `animations[0]`'s low16 — the file-order-first sequence's own id. go_resolve_slot(m, seqs.first()?.anim_id) } } -/// Sweep every model named by **GameObjectDisplayInfo.dbc** and resolve, per model, what the -/// reference's GameObject animation arm plays in each reachable `GAMEOBJECT_STATE` × -/// `GAMEOBJECT_ANIMPROGRESS` substate — see the `Goanimscan` command doc. +/// Resolve, for every `GameObjectDisplayInfo` model, what the reference's GameObject arm plays in +/// each state substate, printing the models where that differs from the loader seed. pub fn goanimscan(chain: &mut Chain) -> Result<()> { let catalog = benilla_formats::load_gameobject_catalog(chain).context("GameObjectDisplayInfo.dbc")?; - // displayId → path, deduped to one entry per model (many displays share a model). + // Model path → its display ids; many displays share a model. let mut models: BTreeMap> = BTreeMap::new(); for (id, path) in catalog.iter() { let key = model_key(path); @@ -132,17 +116,12 @@ pub fn goanimscan(chain: &mut Chain) -> Result<()> { } let (mut parsed, mut no_seq, mut blind, mut sensitive, mut needs_remap, mut rate0) = (0u32, 0u32, 0u32, 0u32, 0u32, 0u32); - // The transition half (decision 1151): a MOTION substate whose resolved sequence is bit-0-clear - // is one the kernel wraps for ever — so it is bounded only by the object layer's completion - // advance (`0x5f4120`), and a consumer that arms it by the loop bit instead flaps. And `replay` - // decides whether that window is one band or several. + // A motion substate on a looping sequence (flags bit 0 clear) wraps until the completion + // advance (`0x5f4120`) ends it; the replay range sets how many bands that window spans. let (mut looping_motion, mut looping_motion_sensitive, mut multi_replay) = (0u32, 0u32, 0u32); - // The VARIATION half (`0x5f3aee: push -1`): the GameObject arm rolls a `_rand`-weighted - // variation, while the loader seed underneath it (`0x71019b`) takes an explicit variation 0. So - // a model that authors a CHAIN on a reachable substate's id plays something the seed never - // reaches — and a consumer resolving the id to its head variation renders the whole rest of the - // chain unreachable. Onyxia's lava traps are the case (`ONYZIASLAIRLAVATRAP`: Stand ×2, and - // only the 10 %-weighted second one spurts lava). + // The arm rolls a weighted variation (`0x5f3aee` pushes -1) where the loader seed takes + // variation 0 (`0x71019b`), so a chain on a substate's id reaches sequences the seed cannot + // (`ONYZIASLAIRLAVATRAP`: two Stands, only the 10%-weighted second spurts lava). let mut chained = 0u32; let mut chained_paths: Vec = Vec::new(); for (path, displays) in &models { @@ -171,16 +150,12 @@ pub fn goanimscan(chain: &mut Chain) -> Result<()> { remapped |= req != lut; froze |= r0; differs |= armed.map(|(_, s)| s) != seed.map(|(_, s)| s); - // The played sequence's own kernel law. `slot` is the M2's FILE slot, which is not this - // list's index (zero-duration sequences are dropped), so match on `seq_index`. + // `slot` is a file slot, not an index here (zero-duration sequences are dropped). let played = armed.and_then(|(_, slot)| seqs.iter().find(|s| s.seq_index == slot as usize)); - // A transient substate (a transition motion) armed on a band the kernel wraps: what - // ends it is the completion advance (`0x5f4120`), never the clip. let motion = MOTION_SUBSTATES.contains(&sub); flaps |= motion && !r0 && played.is_some_and(|s| s.looping); replays |= played.is_some_and(|s| (s.min_replay, s.max_replay) != (0, 0)); - // A variation chain on THIS substate's armed id: >1 sequence sharing it. let variations = armed.map_or(0, |(id, _)| seqs.iter().filter(|s| s.anim_id == id).count()); has_chain |= variations > 1; @@ -230,8 +205,7 @@ pub fn goanimscan(chain: &mut Chain) -> Result<()> { } remapped.then(|| needs_remap += 1); froze.then(|| rate0 += 1); - // Only the state-SENSITIVE models are worth printing: on every other one the arm lands on - // the same sequence the loader seed already holds, so `GAMEOBJECT_STATE` is unobservable. + // Print only state-sensitive models; on the rest every substate plays the seed's sequence. if differs { println!("{path} ({} sequences, displays {displays:?})", seqs.len()); println!( @@ -280,33 +254,16 @@ pub fn goanimscan(chain: &mut Chain) -> Result<()> { Ok(()) } -/// `AnimationData.dbc` ids of the effect-model lifecycle triple (names read from the real DBC). +/// `AnimationData.dbc` ids of the effect-model lifecycle: Stand, Hold, Decay. const ANIM_STAND: u16 = 0; const ANIM_HOLD: u16 = 158; const ANIM_DECAY: u16 = 159; -/// Sweep every `.m2` (optionally under a path prefix) and census the **effect-model animation -/// lifecycle**: which models author the `Stand`(0) → `Hold`(158) → `Decay`(159) triple, and what a -/// consumer that arms ONE sequence and never advances would render for each. -/// -/// The reference arms an effect instance's default track — `animationLookup[0]`, i.e. `Stand` — and -/// a model that also owns `Hold` authors `Stand` as a **birth** (grow-in, clamp flag set) with the -/// sustained pulse living in the separate looping `Hold` sequence, and the fade-out in `Decay`. -/// A one-sequence consumer therefore FREEZES on the last frame of the birth for the effect's whole -/// life — no pulse, no fade — which is exactly what "the Ice Barrier shield is frozen" looks like. -/// -/// The classification is that failure, made countable: -/// - **FREEZE** — owns `Hold`, and the armed `Stand` clamps: the reference pulses, we hold a pose. -/// - **hold-loops** — owns `Hold` but `Stand` itself loops: still wrong (the wrong clip), but moving. -/// - **decay-only** — owns `Decay` and no `Hold`: only the reap leg is unrendered. -/// -/// It also counts the **file-order divergence**: the reference arms `animationLookup[0]`, so a -/// consumer that arms the file's *first slot* instead is additionally wrong on any model whose slot -/// 0 is not its `Stand` (the `DuelingFlag.m2` Spawn/Stand/Despawn shape, decision 0637). The two -/// bugs are independent and the closing line separates them. -/// -/// The population instrument for the mechanism, so it is closed corpus-wide rather than spell by -/// spell; `m2seq` then explains one model in full. +/// Census the effect models with a `Hold` (158) or `Decay` (159) leg. The reference arms +/// `animationLookup[0]`, `Stand`, which beside a `Hold` is a clamped birth before the looping +/// pulse, so a consumer that never advances freezes on its last frame (`FREEZE`); `hold-loops` +/// has a looping `Stand`, `decay-only` no `Hold`. It also lists the models whose file slot 0 is +/// not `Stand` (`DuelingFlag.m2`: Spawn, Stand, Despawn), where arming slot 0 is wrong outright. pub fn fxlifescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut freeze, mut hold_loops, mut decay_only) = (0u32, 0u32, 0u32, 0u32); @@ -322,13 +279,12 @@ pub fn fxlifescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { if seqs.is_empty() { continue; } - // What the reference arms: `animationLookup[0]` — Stand — never the file-order-first slot - // (they diverge on any model whose sequence 0 is not its Stand). + // The reference arms `animationLookup[0]`, Stand, not file slot 0. let armed = seqs.iter().find(|s| s.anim_id == ANIM_STAND); let hold = seqs.iter().find(|s| s.anim_id == ANIM_HOLD); let decay = seqs.iter().find(|s| s.anim_id == ANIM_DECAY); if hold.is_none() && decay.is_none() { - continue; // neither leg authored — no lifecycle to miss + continue; // no lifecycle leg authored } let class = match (hold.is_some(), armed) { (true, Some(a)) if !a.looping => { @@ -344,8 +300,6 @@ pub fn fxlifescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { "decay-only" } }; - // The file-order divergence, independent of the lifecycle bug: what a slot-0 consumer arms - // versus the reference's `animationLookup[0]`. let arm = match (armed, seqs[0].anim_id) { (None, first) => { no_stand += 1; @@ -389,8 +343,6 @@ pub fn fxlifescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { "of those, the file-order divergence (a slot-0 consumer arms the wrong sequence outright): \ {slot0_not_stand} with slot 0 != Stand, {no_stand} with no Stand at all" ); - // Named in full, never left to the row cap: this set is small and each member is a distinct - // "arms the wrong sequence from frame one" case. for (name, _, arm, ..) in rows.iter().filter(|r| r.2 != "slot0") { println!(" {name:<58} {arm}"); } @@ -401,20 +353,10 @@ pub fn fxlifescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (optionally under a path prefix) and list the models where the **loader-idle -/// sequence is not file slot 0** while benilla's render content gate declines to arm it — so every -/// per-sequence bake (`EmitTiming`/`EmitParams`/`AlphaAnim`) degrades to slot 0, a sequence the -/// instance is not playing (decision 0936, found on the Stormwind battlefield banner). -/// -/// The reference arms the loader-idle sequence on **every** M2 instance at load (`0x70ebd0`'s -/// tail), so "which sequence is this playing" always has an answer. -/// benilla skips the arm when looping the idle would render identically to the static mesh — sound -/// for the *mesh*, and silently wrong for anything keyed on the sequence *identity*. The two only -/// disagree visibly when the idle is not slot 0, which is the `DuelingFlag` shape: -/// `Spawn(145) / Stand(0) / Despawn(157)`, where slot 0 is the **Spawn flourish**. -/// -/// Reports, per model: the emitters whose enabled/rate gate at t = 0 differs between slot 0 and the -/// idle slot (`FX`), and the batches whose combined material-alpha factor differs (`ALPHA`). +/// List the models whose loader-idle sequence is not file slot 0 and is a rest pose the content +/// gate leaves unarmed, with the emitters (`FX`) and batches (`ALPHA`) that differ at t = 0 +/// between the two slots. The reference arms the idle on every instance at load (`0x70ebd0`'s +/// tail), so a per-sequence bake follows it, never slot 0 (`DuelingFlag.m2`'s slot 0 is Spawn). pub fn idleslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut unarmed, mut window) = (0u32, 0u32, 0u32); @@ -432,8 +374,7 @@ pub fn idleslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { .unwrap_or_default() .first() .map_or(0, |p| p.resolved_id); - // benilla's render content gate (`benilla_assets`' `idle_pose_differs`): a bind-pose idle - // is not armed at all, so nothing ever overrides the per-sequence bakes' opening slot. + // The content gate (`benilla_assets`' `idle_pose_differs`) arms no rig for a rest pose. if anims .iter() .any(|a| a.anim_id == idle_id && !a.is_rest_pose()) @@ -446,7 +387,7 @@ pub fn idleslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { .find(|a| a.anim_id == idle_id) .map_or(0, |a| a.seq_index); if idle == 0 { - continue; // degrading to slot 0 happens to BE the idle slot — no divergence + continue; // the idle is slot 0: nothing differs } window += 1; let mut lines = Vec::new(); @@ -497,30 +438,12 @@ pub fn idleslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` (optionally under a path prefix) and list the models whose animation is -/// authored **entirely outside the bone tracks** — sequences exist, per-sequence consumers exist -/// (particle emitters, ribbons, material alpha/colour, UV transforms), and **not one sequence -/// keys a bone**. -/// -/// That combination is the blind spot of a renderer whose sequence clock rides a *bone* animation -/// clip: with no keyed bone there is no clip, so there is no player, so nothing ever says which -/// sequence is playing or how far into it — and every per-sequence consumer degrades to "file slot -/// 0, at t = 0", permanently. The emitters still build, pool and tick; they just read the wrong -/// column of a table that never advances. Found on the Molten Core rune + flame ring (decision -/// 0941): the rune's slot 0 is its *Closed* band, where every emission rate key is 0 (no flames at -/// all), and the ring's spline spawn-window opens `0 → 1` over its first second, so frozen at t=0 -/// all 180 particles are born at one point of a 2.8-yd circle — one bright blob. -/// -/// The `[GO]` mark is the cross-tab that matters: a `GameObjectDisplayInfo` model reaches the -/// world through the **hosted** clock (`EmitClock::Host`), which is the lane that freezes at -/// `t = 0`. A placed doodad of the same shape still runs its spawn-age clock (only its *slot* is -/// pinned — decision 0760's axis), so it is wrong in a different, milder way. -/// -/// PARTIAL models — some sequences key bones, some don't — are tallied, not listed: there the -/// clock exists, but the unkeyed sequences are unreachable (no clip to arm), so a GameObject -/// substate or a creature animation id that resolves to one plays nothing. +/// List the models whose sequences drive per-sequence consumers (emitters, ribbons, material +/// alpha and colour, UV transforms) while no sequence keys a bone, so their sequence clock cannot +/// hang on bone curves. `[GO]` marks a `GameObjectDisplayInfo` model, whose emitters run on the +/// host's clock (`EmitClock::Host`); models where only some sequences key a bone are `PARTIAL`. pub fn seqclockscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - // Which models a GameObject can display — the hosted-clock lane. + // The models a GameObject can display, whose emitters run on the host's clock. let go_models: HashSet = benilla_formats::load_gameobject_catalog(chain) .map(|c| c.iter().map(|(_, p)| model_key(p)).collect()) .unwrap_or_default(); @@ -538,8 +461,7 @@ pub fn seqclockscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { continue; } with_seqs += 1; - // Exactly the runtime's own test (`build_animation_clip`): a sequence becomes a clip iff - // some bone track produced a key inside its band. + // Whether a sequence poses a bone, as `build_animation_clip`'s flag decides it. let keyed = seqs .iter() .filter(|a| { @@ -548,17 +470,15 @@ pub fn seqclockscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { }) }) .count(); - // Counted over EVERY model with sequences, consumers or not: it measures clip - // REACHABILITY (can an arm find this sequence at all), which is a property of the model. + // Counted over every model with sequences, consumers or not. if keyed > 0 && keyed < seqs.len() { partial += 1; } let Ok(s) = benilla_formats::parse_m2_animation_summary(&bytes) else { continue; }; - // The per-sequence consumers: everything that samples a track on the playing sequence's - // clock. A constant (≤1 key) colour/alpha track is excluded — it reads the same in every - // slot, so a frozen clock costs it nothing. + // Everything that samples a track on the playing sequence's clock; a constant (≤1 key) + // colour or alpha track reads the same in every slot and is left out. let consumers = s.particle_emitter_count + s.ribbon_emitter_count + s.color_alpha_tracks.1 @@ -566,9 +486,7 @@ pub fn seqclockscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + s.transparency_tracks.1 + s.texture_transform_count; if keyed == 0 && consumers == 0 { - // The same shape with nothing to sample: a clock costs it nothing and buys it - // nothing. Counted because it IS the cost side of giving every sequenced model a - // clock — these are the models that gain an inert one. + // No keyed bone and nothing to sample: a clock here is inert. inert += 1; } if consumers > 0 && keyed == 0 { @@ -603,25 +521,11 @@ pub fn seqclockscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` and census the **animation-driven sound emitters**: the models whose -/// sequences carry a `$DSL` (doodad sound loop) / `$DSE` (its release token) / `$DSO` (doodad -/// sound one-shot) / `$SND` (generic one-shot) event marker, the `SoundEntries` kit each names, -/// and — the column this exists for — whether the carrying sequence is **rest-posed**. -/// -/// `$DSE` is in the set because it is half of `$DSL`'s lifecycle and it is **lane-specific**: the -/// placed-doodad handler `0x6951e0` has an arm for it, the GameObject dispatcher `0x5f3e20` has -/// none at all, so which models author it decides whether -/// a consumer may route the token uniformly. -/// -/// Why that column decides everything: `benilla_assets`' render content gate -/// (`idle_pose_differs` → [`benilla_formats::ModelAnimation::is_rest_pose`], decision 0130) skips -/// building a rig for a sequence that would render as the static mesh, and the whole point of -/// that gate is that it is a question **about pixels only**. A placed lamp's Stand band keys no -/// bone at all — it is pure rest pose — yet it carries the one `$DSL` marker that is its hum. So -/// every model in the `REST` column is one whose sound the arm can never reach through a rig: its -/// events need a clock that does not depend on there being anything to animate. -/// -/// Reports the per-model rows, then a tag/kit histogram and the `REST`-gated share. +/// Census the `$DSL` (loop), `$DSE` (its release), `$DSO` and `$SND` (one-shots) markers per model, +/// the `SoundEntries` kit each names, and whether the carrying sequence is a rest pose (`REST`), +/// which gets no rig ([`benilla_formats::ModelAnimation::is_rest_pose`]), so only a rig-free clock +/// reaches its marker (a lamp's Stand keys no bone yet carries its `$DSL` hum). `$DSE` has an arm +/// in the placed-doodad handler `0x6951e0` and none in the GameObject dispatcher `0x5f3e20`. pub fn soundeventscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { const SOUND_TAGS: [&[u8; 4]; 4] = [b"$DSL", b"$DSE", b"$DSO", b"$SND"]; let kits = benilla_formats::load_sound_kit_catalog(chain).ok(); @@ -639,14 +543,9 @@ pub fn soundeventscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { continue; } scanned += 1; - // The spawn tier `benilla_world::doodad_anim::classify` would give this model, from the - // same three inputs it reads: a boneless model is `Static` outright (the 0035 guard); - // otherwise the 0130 content gate picks `FirstSeq` when the loader-idle clip's pose - // differs from bind, else `GlobalSeqOnly` when free-running channels exist, else - // `Static`. `is_rest_pose` is the gate's own predicate (decision 0936 pushed it down - // beside the parse so this census cannot drift from it); the tier matters here because - // only the two animated tiers get an anim-root ENTITY at all — a `Static` carrier has - // nothing but loose submeshes to hang an emitter on. + // The spawn tier `benilla_world::doodad_anim::classify` gives this model: `Static` when + // boneless, else `FirstSeq` when the idle is not a rest pose, else `GlobalSeqOnly` with + // global-sequence channels, else `Static`. let bones = benilla_m2::parse_m2(&mut std::io::Cursor::new(&bytes)) .map_or(0, |f| f.model().bones.len()); let idle_id = benilla_formats::parse_m2_playable_animation_lookup(&bytes) @@ -677,8 +576,7 @@ pub fn soundeventscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { if marks.is_empty() { continue; } - // The gate is asked of the sequence that CARRIES the marker: that is the one whose - // clock has to run for the marker to be reached at all. + // Asked of the sequence that carries the marker: its clock must run to reach it. let rest = a.is_rest_pose(); model_rest_gated |= rest; for e in &marks { @@ -752,15 +650,14 @@ pub fn soundeventscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// The ten `GameObjectDisplayInfo.Sound[n]` slots, in column order — the names the reference's -/// own substate family gives them. +/// The sound-slot names of the ten `GameObjectDisplayInfo.Sound[n]` columns, in column order. const GO_SLOT_NAMES: [&str; 10] = [ "Stand", "Open", "Loop", "Close", "Destroy", "Opened", "Custom0", "Custom1", "Custom2", "Custom3", ]; -/// The event tag that reaches each display slot (the dispatcher `0x5f3e20`'s ten call sites): -/// `$GO0..5` → slots 0..5, `$GC0..3` → slots 6..9. +/// The event tag that plays each display slot through `0x5f3e20`: `$GO0..5` → slots 0..5, +/// `$GC0..3` → slots 6..9. fn go_slot_tag(slot: usize) -> [u8; 4] { if slot < 6 { [b'$', b'G', b'O', b'0' + slot as u8] @@ -769,14 +666,11 @@ fn go_slot_tag(slot: usize) -> [u8; 4] { } } -/// The model sequence **file slots** the GameObject arm can ever put on screen: the loader seed -/// (`0x71019b`) plus every substate any client path produces — the six `GAMEOBJECT_STATE` × -/// `GAMEOBJECT_ANIMPROGRESS` ones ([`REACHABLE`]), the four Custom ones (opcode `0xb3`) and -/// Despawn (`0x215`) — each through the four-way remap (`0x5f3972`) and op4's -/// `playableAnimationLookup` resolve. -/// -/// This is the reachability half of [`goslotscan`]: an authored `$GOn` on a sequence outside this -/// set is a marker no arm can ever cross, so its display slot is dead however the DBC is filled. +/// The file slots the GameObject arm can put on screen: the loader seed (`0x71019b`), the six +/// state substates through the four-way remap (`0x5f3972`), and the Custom0-3 (opcode `0xb3`) and +/// Despawn (`0x215`) one-shots, resolved through `playableAnimationLookup`. Spawn, which the +/// create path arms (`0x5f382f`), is left out, so a marker only its sequence carries reads as +/// unarmable. fn go_armable_slots( m: &benilla_m2::M2Model, seqs: &[benilla_formats::ModelAnimation], @@ -791,15 +685,15 @@ fn go_armable_slots( out } -/// Census the **GameObject display sound slots** against the model events that are the only thing -/// that can reach them — see the `Goslotscan` command doc for what the join proves. +/// Census the GameObject display sound slots against the `$GOn`/`$GCn` model events, the only +/// path that plays them (`0x5f4010`, called from `0x5f3e20` alone). pub fn goslotscan(chain: &mut Chain) -> Result<()> { let catalog = benilla_formats::load_gameobject_catalog(chain).context("GameObjectDisplayInfo.dbc")?; let sounds = benilla_formats::load_gameobject_sounds(chain).context("GameObjectDisplayInfo.dbc")?; let kits = benilla_formats::load_sound_kit_catalog(chain).ok(); - // displayId → model path, so the same model parsed once serves every display that names it. + // Model path → the displays that name it, so each model is parsed once. let mut per_model: BTreeMap> = BTreeMap::new(); for (id, path) in catalog.iter() { if sounds.slots(id).is_some() { @@ -809,18 +703,16 @@ pub fn goslotscan(chain: &mut Chain) -> Result<()> { for ids in per_model.values_mut() { ids.sort_unstable(); } - // Per slot: displays carrying a kit · of those, models authoring the tag · of those, on an - // armable sequence · and how many of the live ones name a LOOPING (0x200) kit — the lane - // select `0x458830` hands `0x5f4010`, which is the emitter-pool arm rather than a one-shot. + // Per slot: displays with a kit, of those with the tag authored, of those on an armable + // sequence, and the live ones whose kit loops (flag 0x200), which `0x5f4010` plays through + // the emitter pool (`0x461d80`) rather than as a one-shot. let mut filled = [0u32; 10]; let mut tagged = [0u32; 10]; let mut reachable = [0u32; 10]; let mut looping = [0u32; 10]; - // Filled columns whose model authors no reaching tag at all — dead data in the reference, and - // exactly what a state-transition-driven consumer would play that the reference never does. + // Filled columns whose model authors no reaching tag: dead data the reference never plays. let mut dead = [0u32; 10]; - // Markers authored where the display's column is 0 — the tag fires and `0x458830` fails a null - // id, so the reference is silent. Counted per tag so "authored but silent" is visible. + // Tags authored where the column is 0: the tag fires and `0x458830` fails on the null id. let mut silent_tag = [0u32; 10]; let mut unarmable_tag = [0u32; 10]; let (mut models_seen, mut wmo_or_missing) = (0u32, 0u32); diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/skeleton.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/skeleton.rs index 04ac5196..2cf4e4ca 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/skeleton.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/skeleton.rs @@ -1,8 +1,4 @@ -//! Corpus scans over the **bone tree and what addresses it** — the skeleton's own tracks, and -//! the attachment table things hang off. -//! -//! `bonescan` measures our skeletal parse against the reference's sampler band by band; -//! `attachscan` censuses where a table scan disagrees with the reference's `attachLookup`. +//! Corpus scans over the bone tree and what addresses it: bone tracks, attachments, event markers. use std::collections::{BTreeMap, BTreeSet}; @@ -11,15 +7,9 @@ use benilla_formats::Chain; use crate::model_key; -/// Sweep every `.m2` and census its **attachment addressing**: how many records it authors, how -/// many attach ids its AttachLookup resolves, and — the point of the report — where the two -/// disagree, i.e. where "scan the table for a record with this id" answers differently from the -/// reference's `lookup[id]` (`0x710310`). -/// -/// Built for decision 0805 (item glows hang on the item model's ids 0..4) to answer the question -/// that decides whether the lookup can be adopted globally: which models change hands? One line -/// per divergent model — `+id` = an id only the lookup reaches, `-id` = an id only a table scan -/// reaches (a record the reference cannot address at all), `id:a→b` = same id, different record. +/// List the models where a table scan for an attachment id and the reference's `lookup[id]` +/// (`0x710310`) disagree: `+id` only the lookup reaches, `-id` only the scan reaches (the +/// reference cannot address it), `id:a→b` a different record. pub fn attachscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; let (mut scanned, mut with_points, mut divergent) = (0u32, 0u32, 0u32); @@ -37,8 +27,6 @@ pub fn attachscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { continue; } with_points += 1; - // What a table scan would answer (first record per id — the pre-0805 rule) against what - // the lookup answers, compared by the record each id lands on. let mut scan: BTreeMap = BTreeMap::new(); for (i, a) in model.attachments.iter().enumerate() { scan.entry(a.id).or_insert(i); @@ -85,12 +73,8 @@ pub fn attachscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// One bone `M2Track` read straight off the file bytes (v256 stride `0x1c`: interp@0, gseq@2, -/// interpolation_ranges `M2Array`@0x04/0x08, timestamps@0x0c/0x10, values@0x14/0x18). -/// -/// Deliberately raw rather than via `parse_m2_animations`: this instrument's whole job is to -/// compare what the **file** says against what our parser currently emits, so it must not go -/// through the parser under test. +/// One bone `M2Track` read raw, so the check never goes through the parser it tests: v256 stride +/// `0x1c`, interp@0, gseq@2, then `M2Array`s ranges@0x04, timestamps@0x0c, values@0x14. struct RawBoneTrack { interp: u16, gseq: u16, @@ -151,12 +135,11 @@ impl RawBoneTrack { }) } - /// The reference's sample at absolute time `t_ms` for sequence file slot `slot` — `0x713d50` - /// verbatim, then the sampler's own lerp/step leg (`0x713ea0`, `0x71af20`). + /// The reference's sample at absolute `t_ms` for file slot `slot`: `0x713d50`'s key search, + /// then the lerp or step of `0x713ea0`/`0x71af20`. fn reference(&self, slot: usize, t_ms: u32) -> Option<[f32; 4]> { let last = self.ts.len().checked_sub(1)?; - // `0x713d59`: the window is `ranges[slot]` when the array is present, else the whole - // key list. + // `0x713d59`: the window is `ranges[slot]` when present, else the whole key list. let (lo, hi) = match self.ranges.get(slot) { Some(&(lo, hi)) => (lo as usize, hi as usize), None => (0, last), @@ -174,7 +157,7 @@ impl RawBoneTrack { break; } } - // `0x713e45`: `k1 = k0+1`, bounded by the TOTAL key count — never by the window's `hi`. + // `0x713e45`: `k1 = k0 + 1`, bounded by the total key count, not the window's `hi`. if self.interp == 0 || k0 + 1 > last { return Some(self.vals[k0]); } @@ -191,9 +174,8 @@ impl RawBoneTrack { Some(out) } - /// What `models::anim::read_bone_track` emits for this band today: the in-band keys, or — when - /// the band holds none — the single **nearest** out-of-band key (benilla decision 0133). - /// Returns `(value_at_band_start, value_at_band_end, band_was_empty)`. + /// A plain hold, the answer without the `ranges` window: the band's first and last own keys, + /// or the nearest out-of-band key when it has none, as `(start, end, band_was_empty)`. fn benilla_today(&self, start: u32, end: u32) -> Option<([f32; 4], [f32; 4], bool)> { let inb: Vec = (0..self.ts.len()) .filter(|&k| self.ts[k] >= start && self.ts[k] <= end) @@ -213,9 +195,8 @@ impl RawBoneTrack { Some((self.vals[k], self.vals[k], true)) } - /// Distance between two sampled values in the channel's own units: **degrees** of rotation for - /// a quaternion (numerically stable near identity — `acos(dot)` has a ~0.04° floor in f32), - /// **model units** for a translation/scale vector. + /// Distance in the channel's units: degrees for a quaternion, by the chord form since + /// `acos(dot)` floors near 0.04° in f32; model units otherwise. fn delta(&self, a: [f32; 4], b: [f32; 4]) -> f32 { if self.comps == 4 { let norm = |q: [f32; 4]| { @@ -246,30 +227,14 @@ impl RawBoneTrack { } } -/// Sweep every `.m2` (optionally under a path prefix) and measure, per bone track and per sequence -/// band, how far **our** skeletal parse sits from the **reference's** sampler — the population -/// instrument behind benilla decision 0133's named residual ("an empty band clamps to the nearest -/// authored key … a named approximation of the mid-gap lerp"). -/// -/// Three separately-reported disagreements, each a distinct mechanism: -/// -/// - **EMPTY bands** — a band with no keys of its own. We hold the nearest authored key; the -/// reference holds `keys[ranges[slot].lo]`, or lerps across the bracket when the window spans two -/// keys. The 0133 residual proper. -/// - **HELD edges** — a keyed band whose first key is late / last key is early. Bevy holds the edge -/// key; the reference keeps interpolating toward the neighbouring **out-of-band** key, because -/// `0x713d50`'s `k1 = k0+1` is bounded by the total key count and not by the window. -/// - **STEP tracks** — `interpolation_type == 0`. The reference's samplers branch on it and copy -/// `keys[k0]` with no interpolation; our bone parse emits keys and lets Bevy interpolate, so a -/// snap becomes a glide. -/// -/// Plus the safety check the whole idea rests on: whether any band's own keys fall **outside** the -/// window `ranges[slot]` would search (they never do — if they did, adopting the window would -/// reintroduce the garbage pose 0133 records). +/// Measure, per bone track and sequence band, how far a plain key hold sits from the reference's +/// sampler (`0x713d50`): an empty band samples the `ranges[slot]` window, past a band's last key +/// the reference keeps lerping toward `k0 + 1` (bounded by the total key count, not the window), +/// and a step track (`interp == 0`) copies `keys[k0]` where the emitted clips glide. It also +/// checks that no band's keys fall outside its own window, which makes reading the window safe. pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let names = super::m2_names(chain, prefix)?; - // Above the f32 noise floor of the stable angle formula (~1e-4°) by two orders, and far below - // anything an eye could catch — a slot over this is a real authored difference. + // Two orders above the angle formula's f32 noise (about 1e-4°), far below what shows. const ROT_EPS: f32 = 0.01; // degrees const VEC_EPS: f32 = 1e-4; // model units let (mut scanned, mut models_step, mut models_empty_differ, mut models_edge_differ) = @@ -283,7 +248,7 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { let mut gseq_orphan_models: BTreeSet = BTreeSet::new(); let mut gseq_restrict_models: BTreeSet = BTreeSet::new(); let mut emitted_extra = 0u64; - // (empty-band peak, keyed-edge peak) per channel kind — the bound, not a threshold count. + // (empty-band peak, keyed-edge peak) per channel kind. let (mut peak_rot, mut peak_vec) = ((0.0f32, 0.0f32), (0.0f32, 0.0f32)); let mut emitted_models: BTreeSet = BTreeSet::new(); // Worst offender per class: (delta, model, bone, channel, slot). @@ -304,7 +269,7 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { .map(|s| u32::from_le_bytes(s.try_into().unwrap()) as usize) .unwrap_or(0) }; - // Sequences in FILE order (count@0x1c/ofs@0x20, stride 0x44) — the order `ranges` indexes. + // Sequences in file order (count@0x1c, ofs@0x20, stride 0x44), the order `ranges` indexes. let (sn, so) = (u32_at(0x1c), u32_at(0x20)); let seqs: Vec<(u32, u32)> = (0..sn) .map_while(|i| { @@ -312,7 +277,7 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { (e + 0x44 <= b.len()).then(|| (u32_at(e + 4) as u32, u32_at(e + 8) as u32)) }) .collect(); - // globalSequences @0x14/0x18 — a duration per entry; 0 means the loop has no period. + // globalSequences @0x14/0x18: a duration per entry, 0 for no period. let (gn, go) = (u32_at(0x14), u32_at(0x18)); let gseq_period = |g: u16| -> u32 { let i = g as usize; @@ -340,8 +305,7 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { if tr.interp == 0 { step_tracks += 1; m_step += 1; - // The step deviation only becomes visible when a band holds TWO keys: that is - // where the reference snaps and we glide. Measure the size of the snap. + // A step shows only where a band holds two keys: the reference snaps there. for (slot, &(start, end)) in seqs.iter().enumerate() { if end <= start { continue; @@ -362,14 +326,10 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } } } - // A global-sequence track runs on its own clock. `read_bone_track` keeps the - // SINGLE-key case (a constant channel — the stowed-weapon rest quats) and leaves - // the multi-key case to `parse_m2_global_sequence_bones` → the `GlobalSeqDrive` - // lane, which needs a non-zero `globalSequences[gseq]` period. A multi-key channel - // on a ZERO-period global sequence falls between the two and is sampled by - // neither — census that gap, and the shape of the `ranges` window the reference - // would still apply here (`0x713d50` selects the window BEFORE it resolves the gseq - // clock, so a restrictive window would clip the loop). + // A global-sequence track runs on its own clock: one key is a constant in every + // clip, more go to `parse_m2_global_sequence_bones`, which needs a non-zero + // period, so a multi-key track on a zero period plays nowhere. `0x713d50` picks + // the `ranges` window before the gseq clock, so a narrow window clips the loop. if tr.gseq != 0xffff { gseq_tracks += 1; if tr.ts.len() > 1 { @@ -427,8 +387,7 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } else { peak.1 = peak.1.max(d); slots_keyed += 1; - // Does this band's own key set sit inside the window the reference - // searches? If not, adopting the window would drop playable keys. + // Reading the window is safe only if the band's own keys sit inside it. if let Some(&(lo, hi)) = tr.ranges.get(slot) { let inb: Vec = (0..tr.ts.len()) .filter(|&k| tr.ts[k] >= start && tr.ts[k] <= end) @@ -448,9 +407,8 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } } } - // The other half of the check: what our parser ACTUALLY emits. A band-slot whose emitted - // key count exceeds its own in-band key count is one where the head/tail sample differed - // from the edge key and had to be carried — the only slots this parse changes. + // What the parse emits: a band-slot with more keys than its own in-band count carries a + // head or tail sample from the window. for a in benilla_formats::parse_m2_animations(&b) { for bk in &a.bones { for (off, comps, emitted) in [ @@ -487,7 +445,6 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { if m_edge > 0 { models_edge_differ += 1; } - // Keep the worst-offender lists bounded without losing the tail. for w in [&mut worst_empty, &mut worst_edge] { if w.len() > 4096 { w.sort_by(|a, b| b.0.total_cmp(&a.0)); @@ -583,34 +540,19 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Sweep every `.m2` and census the **event table's positional half** — the `bone` and `position` -/// fields every `M2Event` record carries beside its 4CC and timestamps. -/// -/// The question it exists to answer: the reference's animation-event dispatchers hand their arms -/// the event's **own** world point (`0x5f3e20`'s `[ebp+0x10]`, `&M2SceneCallback.position` — the -/// authored `position` transformed by its bone's live matrix and the model's placement), while a -/// consumer that plays every anim-event sound at the model root is using the placement alone. That -/// difference is worth code only where the records are actually *off* the origin, or on a bone -/// that *moves*; where every record of a tag sits at the origin on a static bone the two are the -/// same point and the simpler consumer is not wrong. -/// -/// So, per 4CC: how many records, how many sit more than [`OFF_ORIGIN`] from the model origin (the -/// `position` field is already model-space — it is the marker's REST point, which is why -/// `build_markers` subtracts the bone pivot to get a bone-local offset), how many ride a bone -/// **whose chain any sequence keys** (the bone's own track being empty says nothing: it inherits -/// its parent's matrix), and the largest offset seen with the model that authors it. +/// Census each 4CC's `M2Event` records by `bone` and `position`. The reference hands an event's +/// arm its own world point, the model-space rest `position` moved by its bone's live matrix and the +/// placement (`0x5f3e20`'s `[ebp+0x10]`), which differs from the model root only for a record off +/// the origin or on a bone whose chain any sequence keys. pub fn eventmarkerscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - /// Yards from the model origin past which a marker is "off the origin". Well under any - /// audible 3D difference, so it is a *presence* threshold, not a significance one. + /// Yards from the model origin past which a marker counts as off it: a presence threshold. const OFF_ORIGIN: f32 = 0.01; let names = super::m2_names(chain, prefix)?; // 4CC → (records, off-origin, on a keyed bone, max offset, the model + bone at that max) let mut per_tag: BTreeMap = BTreeMap::new(); let (mut scanned, mut carriers) = (0u32, 0u32); - // Models authoring the SAME 4CC more than once, and those whose duplicates disagree about - // where they are: the reference's kernel fires a specific RECORD and snapshots that record's - // point, so a consumer that resolves the tag by a first-match table scan answers the wrong - // point exactly here. + // Models that author a 4CC more than once, and those whose copies sit apart: the reference + // fires a specific record and takes its point, so a first-match lookup by tag is wrong there. let (mut dup_models, mut dup_split) = (0u32, 0u32); let mut dup_examples: Vec = Vec::new(); for name in names { @@ -626,10 +568,7 @@ pub fn eventmarkerscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { continue; } carriers += 1; - // Which bones any sequence keys at all. A marker's own bone being unkeyed is NOT enough - // to make it static — the bone inherits its parent's matrix — so the test walks the - // ancestor chain, which is the difference between "sits still" and "sits still relative to - // something that moves". + // The bones any sequence keys; a marker moves if any bone up its parent chain is keyed. let keyed: BTreeSet = benilla_formats::parse_m2_animations(&bytes) .iter() .flat_map(|a| a.bones.iter().map(|b| b.bone)) diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/world.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/world.rs index 94e8a2c5..b6c1191e 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/world.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/world.rs @@ -1,8 +1,4 @@ -//! Corpus scans over **placed world content** — what a WMO root or an ADT tile block puts in -//! the world, and how much of it the reference would even draw. -//! -//! One `.wmo` root's own tables (`wmodoodads`, `skyboxscan`) and one map region's placements -//! (`placescan`, `doodadscan`). How a placed prop is LIT is a separate question — [`super::lighting`]. +//! Corpus scans over placed world content: a WMO root's own tables and an ADT region's placements. use std::collections::{BTreeMap, HashMap, HashSet}; @@ -11,12 +7,9 @@ use benilla_formats::{Chain, M2AnimSummary}; use crate::{model_key, yn}; -/// Dump a WMO root's placed-prop tables: every MODD doodad with its MODS set membership and its -/// OWNING group(s) read from the group files' MODR lists — the relation the reference instantiates -/// from (`0x695aa0` loops a *visible* group's own refs). A prop referenced by NO group is never -/// created by the real client at all — the -/// divergence decision 0689 names and benilla still spawns. This answers "which props exist here, -/// who owns them, and which would the reference even draw" in one read (the B30/B32 question). +/// Dump a WMO root's MODD props with their MODS sets and owning groups, read from the group files' +/// MODR lists. The reference creates a prop only from a visible group's refs (`0x695aa0`), so it +/// never draws a prop no group names (`ORPHAN`); benilla spawns one with no room gate. pub fn wmodoodads(chain: &mut Chain, raw_path: &str, filter: Option<&str>) -> Result<()> { let root_path = raw_path.replace('/', "\\").to_ascii_lowercase(); let bytes = chain @@ -26,7 +19,7 @@ pub fn wmodoodads(chain: &mut Chain, raw_path: &str, filter: Option<&str>) -> Re .with_context(|| format!("parsing WMO root '{root_path}'"))?; let set_names = mods_set_names(&bytes); - // MODD index -> the groups whose MODR reference it (the ownership relation). + // MODD index -> the groups whose MODR names it. let stem = root_path.strip_suffix(".wmo").unwrap_or(&root_path); let mut owners: BTreeMap> = BTreeMap::new(); let mut groups_read = 0u32; @@ -121,9 +114,8 @@ pub fn wmodoodads(chain: &mut Chain, raw_path: &str, filter: Option<&str>) -> Re Ok(()) } -/// The MODS set names (`char name[20]` per 32-byte record) — `WmoDoodadSet` keeps only the ranges, -/// so read the names off the raw root bytes here (top-level chunks are `[magic][size][data]`, magic -/// on disk reversed: MODS → `SDOM`). +/// The MODS set names, `char name[20]` at the head of each 32-byte record, read off the raw root +/// (chunk magics are reversed on disk: `SDOM`) since `WmoDoodadSet` keeps only the ranges. fn mods_set_names(bytes: &[u8]) -> Vec { let mut off = 0usize; while off + 8 <= bytes.len() { @@ -156,31 +148,14 @@ fn mods_set_names(bytes: &[u8]) -> Vec { Vec::new() } -/// Sweep every WMO **root** in the chain and report the two halves of the skybox mechanism: which -/// roots author a **MOSB** skybox model, and which carry groups flagged `0x40000` -/// ([`benilla_formats::WmoGroupInfo::show_skybox`]). -/// -/// This is the instrument that *identifies* the flag — and **exactly how far that identification -/// reaches is the point**. `0x40000` is undocumented, so the cross-tab is what establishes it means -/// anything at all: the bit never appears on a group whose root names no skybox, across all 815 -/// roots. That is a one-way implication, `flag ⇒ MOSB`, and the summary prints it so the claim is -/// re-checkable in one command rather than trusted from a decision record. -/// -/// **It does not, and cannot, say which group the RENDERER tests** — and reading it as if it did is -/// the mistake decision 0767 made (superseded by 0773). The law is that `0x40000` is tested -/// inside the portal flood (`0x6b42e0` in `0x6b41c0`) on the group being *visited*, so the predicate -/// is "any flood-reached group carries the bit". A census over static asset bytes has no way to see -/// that distinction; only the binary did. -/// -/// It is also the population instrument for the mechanism: which buildings in 1.12 replace the -/// `Light.dbc` gradient dome with an authored sky, and how much of each one does it. Stratholme's -/// city shell sets the bit on 61 of its 83 groups; the only other roots that set it at all are the -/// four Caverns of Time shells, which ship in the 5875 data with no 1.12 instance to enter. +/// Cross-tab every WMO root's MOSB skybox model against its groups flagged `0x40000` +/// ([`benilla_formats::WmoGroupInfo::show_skybox`]): across the 815 roots the flag never appears +/// without a MOSB. Which group counts is not in the assets: the reference tests the bit on each +/// group its portal flood visits (`0x6b42e0` in `0x6b41c0`). Only Stratholme's shell (61 of 83 +/// groups) and the four unused Caverns of Time shells set it. pub fn skyboxscan(chain: &mut Chain) -> Result<()> { - // Roots only, because only the root carries MOSB/MOGI. let names = super::wmo_roots(chain, None)?; - // The cross-tab that identifies the flag: roots with/without a MOSB × groups with/without 0x40000. let (mut both, mut mosb_only, mut flag_only, mut neither) = (0u32, 0u32, 0u32, 0u32); let mut scanned = 0u32; let mut hits: Vec<(String, String, usize, usize)> = Vec::new(); @@ -251,10 +226,7 @@ pub fn skyboxscan(chain: &mut Chain) -> Result<()> { Ok(()) } -/// List individual doodad (MDDF) and WMO (MODF) placements around a world position whose model -/// path contains `filter` (case-insensitive) — the per-placement position / Euler rotation / -/// scale / uniqueId ground truth an orientation investigation needs (`doodadscan` only -/// aggregates). +/// List the MDDF and MODF placements near a world position whose model path contains `filter`. pub fn placescan( chain: &mut Chain, map: &str, @@ -300,9 +272,8 @@ pub fn placescan( Ok(()) } -/// Bulk-scan placed doodads (MDDF) and WMO doodad-set-0 props (MODF → MODS/MODD) across a -/// `(2·tile_radius+1)²` block of ADT tiles around a world position, and report how much of that -/// content animates. +/// Count the placed doodads (MDDF) and WMO set-0 props (MODF → MODS/MODD) in the +/// `(2·tile_radius+1)²` tiles around a world position, and how much of that content animates. pub fn doodadscan( chain: &mut Chain, map: &str, @@ -314,12 +285,10 @@ pub fn doodadscan( .with_context(|| format!("loading tiles around ({center_x}, {center_y}) on {map}"))?; eprintln!("{} tile(s) loaded", tiles.len()); - // Direct M2 placements (MDDF) — deduped by uniqueId, which a tile-straddling doodad - // repeats identically across every tile it touches (decision-0021 terrain streamer's own - // dedup key; see `benilla_formats::Doodad::unique_id`). + // MDDF placements, deduped by uniqueId: a doodad straddling tiles repeats in each one. let mut seen_doodad_ids: HashSet = HashSet::new(); let mut m2_instances: HashMap = HashMap::new(); - // WMO placements (MODF) — same dedup, by their own uniqueId. + // MODF placements, deduped the same way. let mut seen_wmo_ids: HashSet = HashSet::new(); let mut wmo_instances: HashMap = HashMap::new(); for (_, tile) in &tiles { @@ -345,10 +314,7 @@ pub fn doodadscan( wmo_instances.len() ); - // Fold each unique WMO's doodad-set-**0** M2 props into the same instance table (set 0 is - // the WMO's always-on global set, per `WmoDoodadSet` doc), each multiplied by that WMO's - // own (deduped) placement count — one building placement = one instance of every set-0 prop - // it carries. + // Each WMO placement adds one instance of every set-0 prop, the always-on global set. let mut wmo_root_failures = 0u32; for (wmo_path, &count) in &wmo_instances { let root_path = wmo_path.to_ascii_lowercase(); // matches `load_wmo`'s own normalization @@ -374,7 +340,6 @@ pub fn doodadscan( eprintln!(" ({wmo_root_failures} WMO root(s) failed to read/parse — skipped)"); } - // Per-unique-model animation summary — one parse per model regardless of instance count. let mut summaries: HashMap = HashMap::new(); let mut parse_failures: Vec<(String, String)> = Vec::new(); for model in m2_instances.keys() { @@ -453,9 +418,7 @@ pub fn doodadscan( } } - // The rare material channels by NAME (each is <1% of instances, so the top-30 table - // almost never surfaces them): the exact models the phase-2/3 material-animation work - // verifies against. + // The rare material channels by name: each is under 1% of instances, below the top 30. println!(); println!("=== material-channel models (animated transparency / color / UV) ==="); let mut rare: Vec<(&String, &u32)> = m2_instances @@ -480,8 +443,7 @@ pub fn doodadscan( ); } - // Moving-seq0 models with a variation chain, by NAME (the random-variation arm `0x695100`): - // the exact placed models where variationIdx −1 vs 0 is visible at all. + // Moving seq-0 models with a variation chain, where the random pick (`0x695100`) shows. println!(); println!("=== moving-seq0 multi-variation models ==="); let mut varied: Vec<(&String, &u32)> = m2_instances @@ -501,9 +463,7 @@ pub fn doodadscan( ); } - // Emitters hosted on a moving bone chain, by NAME (0130 phase 4 grounding): the exact - // placed models where emitter bone-follow is visible at all — an emitter on a static - // chain sits at its rest pose whether or not we attach it. + // Emitters on a moving bone chain: the placed models where emitter bone-follow shows. println!(); println!("=== emitter-on-moving-bone models ==="); let mut movers: Vec<(&String, &u32)> = m2_instances diff --git a/crates/benilla-formats/src/bin/benilla-extract/shakecensus.rs b/crates/benilla-formats/src/bin/benilla-extract/shakecensus.rs index a4eee7c3..bee26f19 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/shakecensus.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/shakecensus.rs @@ -1,30 +1,7 @@ -//! `shakecensus`: the whole-table view of the **camera-shake** system (decisions 1540/1849) — the -//! 24 shipped `CameraShakes.dbc` presets, and everything that names one: the creature models -//! (footstep + death thud), and the `SpellEffectCameraShakes.dbc` groups the spell side reaches -//! them through. -//! -//! The scope instrument for B298 ("walking past an Ancient Protector shakes no screen"). It -//! answers, from the shipped data rather than from expectation, the two questions that turn that -//! one report into a system: *which* creatures are affected (not just the Ancients — every kodo, -//! giant, titan and dragon in the game), and whether `CreatureModelData` fields 11/12 really are -//! `CameraShakes` keys — because if they are, every live value must land on a real row of a -//! 24-row table, and the amplitudes must rank by mass. A column map that were merely plausible -//! would scatter across the id space and hit holes. -//! -//! Read the output beside the module doc of [`benilla_formats::CameraShakeCatalog`]: the creature -//! rows and the spell rows are visibly different shapes (phase, duration, the direction triples), -//! and that split is itself the check. -//! -//! The **group** half (decision 1849) is the same instrument one indirection out: 58 of the 1772 -//! shipped `SpellVisualKit` rows name a group in field 14, every value must land on the 9-row -//! table, and the census names the kits so a spell-side shake can be traced from a preset back to -//! the kit that fires it. Then the `$SHK` sweep, whose **host** column is the one that matters — -//! only a GameObject or DynamicObject decodes the tag, so a marker on a creature or spell-effect -//! model, or on one nothing hosts at all, is authored-but-inert. -//! -//! The roll-up at the end is deliberately **two tiers**, because they answer different questions: a -//! preset can be *named by a table* and still have nothing that fires it. Keeping them apart is -//! what caught this census's own field-12 mistake (see the `$SHK` block). +//! `shakecensus`: the 24 shipped `CameraShakes.dbc` presets and all that names one: creature +//! models (`CreatureModelData` fields 11 and 12, footstep and death thud), the 9 +//! `SpellEffectCameraShakes.dbc` groups that `SpellVisualKit` field 14 names, and `$SHK` animation +//! events. Every named id must land on a real row, or the column map is wrong. use std::collections::{BTreeMap, BTreeSet}; @@ -38,8 +15,7 @@ struct Users { death_thud: Vec, } -/// A model path reduced to a comparison key: lowercase, back-slashed, extension dropped. The DBCs -/// name models `.mdx`; the archives hold them as `.m2`, and the casing is arbitrary in both. +/// A model path's comparison key: DBCs name models `.mdx`, the archives hold `.m2`, in any case. fn mdx_key(path: &str) -> String { let p = path.to_ascii_lowercase().replace('/', "\\"); match p.rsplit_once('.') { @@ -48,7 +24,6 @@ fn mdx_key(path: &str) -> String { } } -/// The model's own file name — the census reads better without the `Creature\\Foo\\` prefix. fn short(path: &str) -> String { path.rsplit('\\').next().unwrap_or(path).to_string() } @@ -78,7 +53,7 @@ pub fn shakecensus(chain: &mut Chain) -> Result<()> { } } - // Which groups name each preset, and which kits name each group — the spell side's two hops. + // The spell side's two hops: the groups naming each preset, the kits naming each group. let mut group_users: BTreeMap> = BTreeMap::new(); for g in shakes.groups() { for id in g.shakes() { @@ -145,8 +120,7 @@ pub fn shakecensus(chain: &mut Chain) -> Result<()> { ); } - // Every value the creature columns actually carry must resolve — a miss would say the column - // map is wrong, which is the whole point of running this. + // Every value the creature columns carry must resolve; a miss means the column map is wrong. let mut dangling = Vec::new(); for (model_id, path, footstep, thud) in &models { for (col, id) in [ @@ -210,24 +184,13 @@ pub fn shakecensus(chain: &mut Chain) -> Result<()> { } } - // The third producer: the `$SHK` animation event, whose payload is a GROUP id (never a preset - // id). Only the GameObject and DynamicObject dispatchers decode it — `CGUnit_C::HandleAnimEvent` - // does not — so a `$SHK` on a creature M2 is inert, and this sweep is what says whether the - // shipped content authors any at all. + // The third producer, the `$SHK` animation event, whose payload is a group id, never a preset. println!("\n$SHK animation events — the third producer (GameObject / DynamicObject only)\n"); - // The HOST column is what decides whether a marker is live at all: only the GameObject - // (typemask 0x20, `0x5f3e20`) and DynamicObject (0x40, `0x5d58c0`) dispatchers decode `$SHK`. - // A marker on a creature M2 reaches `CGUnit_C::HandleAnimEvent`, which does not decode the tag - // (decision 1540); one on a model that is only ever a bone-attached `CEffect` reaches the fixed - // `$SND`/`$HIT` router `0x61f6f0`. Both are authored-but-inert. - // - // The DynamicObject side is easy to get wrong and this census got it wrong once: the anchor's - // area model is `SpellVisual` **field 12**, gated by field 11 ([`VisualStages::area_effect`] / - // `area_gate` — `0x5d57c0`, benilla's `entities::dest_fx`). Judging - // the `Spells\*` carriers by "no display table names it" reported six presets dead that are - // not: Warlock **Inferno** (SpellVisual 4859 → effect 2362 `Infernal_Impact_Base`) and - // Ragnaros's **Meteor** (7479 → 3007 `Meteor_Impact_Base`) both plant one, and both models - // carry `$SHK` group 2. So the host set is three, not two. + // Only the GameObject (typemask 0x20, `0x5f3e20`) and DynamicObject (0x40, `0x5d58c0`) + // dispatchers decode `$SHK`; on a creature it reaches `CGUnit_C::HandleAnimEvent` and on a + // bone-attached `CEffect` the fixed `$SND`/`$HIT` router `0x61f6f0`, and neither decodes it. + // A DynamicObject's area model is `SpellVisual` field 12, gated by field 11 (`0x5d57c0`): + // Inferno's visual 4859 and Meteor's 7479 plant one (effects 2362, 3007) with `$SHK` group 2. let gos = benilla_formats::load_gameobject_catalog(chain)?; let go_models: BTreeSet = gos.iter().map(|(_, path)| mdx_key(path)).collect(); let creature_models: BTreeSet = creatures.model_paths().map(mdx_key).collect(); @@ -316,10 +279,8 @@ pub fn shakecensus(chain: &mut Chain) -> Result<()> { tally(&shk_live_groups) ); - // The roll-up, in two tiers, because they answer different questions. A preset can be NAMED by - // a table and still have nothing that fires it — presets 7/8/9 sit only in group 2, and a group - // no kit and no `$SHK` marker names is a dead row. A shipped-but-unreachable preset is a - // finding, not a bug; it is printed rather than inferred so nobody has to guess. + // Two tiers: a preset a table names can still have nothing that fires it, as a group no kit + // and no live `$SHK` marker names is dead. let named: BTreeSet = users .keys() .copied() diff --git a/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs b/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs index 92a8d614..af26b7ca 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs @@ -1,6 +1,4 @@ -//! `spellvis`: dump a spell's visual chain — spell → SpellVisual stages → each kit's anim/sound/ -//! camera shake/attach effects, plus the missile block — decision 0099's phase-2 instrument -//! (columns per decisions 0107 and 1849). +//! `spellvis`: dump a spell's visual chain, every stage's kit and the missile. use anyhow::Result; use benilla_formats::Chain; @@ -38,9 +36,7 @@ pub fn run(chain: &mut Chain, spell_id: u32) -> Result<()> { kit.anim_id.map_or("—".into(), |a| a.to_string()), kit.sound.map_or("—".into(), |s| s.to_string()), ); - // The kit's CAMERA SHAKE (field 14, decision 1849): a - // `SpellEffectCameraShakes` GROUP id, expanded to the presets it fires. - // `benilla-extract … shakecensus` is the whole-table view. + // Field 14: a `SpellEffectCameraShakes` group id, expanded to its presets. if let Some(group) = kit.shake { match shakes.group(group) { Some(g) => println!( @@ -55,11 +51,9 @@ pub fn run(chain: &mut Chain, spell_id: u32) -> Result<()> { ), } } - // The kit's CharProcs (fields 15-34): what it does to the BODY. + // The kit's CharProcs (fields 15-34), what it does to the body. crate::charprocs::print_kit_procs(&visuals, kit_id, " "); - // The kit's BEAM, if it draws one (decision 0955): the chain CharProc's - // decoded `SpellChainEffects` row. `benilla-extract … chaincensus` is the - // whole-table view. + // The beam, if any: the chain CharProc's decoded `SpellChainEffects` row. if let Some(c) = kit.chain_proc() { match visuals.chain_effect(c.effect_id) { Some(e) => println!( @@ -81,7 +75,6 @@ pub fn run(chain: &mut Chain, spell_id: u32) -> Result<()> { ), } } - // The kit's attach-point emitter slots (phase 3): tag + effect model. for (tag, effect) in kit.effects() { println!( " attach {tag:#04x} effect {effect:<5} -> {}", @@ -93,9 +86,8 @@ pub fn run(chain: &mut Chain, spell_id: u32) -> Result<()> { } } } - // The missile block (phase 4): the projectile exists whenever Speed > 0; its model is - // field 7's SpellVisualEffectName (else the ammo/ErrorCube fallbacks) and it homes to - // field 9's dest-attach ordinal on a live target. + // A missile exists whenever `Speed > 0`: its model is field 7's `SpellVisualEffectName`, else + // the ammo or ErrorCube fallback, and it homes on a live target's field-9 dest-attach ordinal. if spell.speed > 0.0 { let tag = benilla_formats::MISSILE_ATTACH_TABLE .get(stages.missile_attach as usize) diff --git a/crates/benilla-formats/src/bin/benilla-extract/thudcensus.rs b/crates/benilla-formats/src/bin/benilla-extract/thudcensus.rs index 5f24fa1a..7235cd07 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/thudcensus.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/thudcensus.rs @@ -1,29 +1,14 @@ -//! `thudcensus`: the whole-table view of the **death thud** — the body-fall sound a corpse makes -//! as it lands (`$DTH` → `0x6236e0`), the sibling of the same event's camera shake. -//! -//! Two halves, because the report it exists for ("a big corpse hits the ground silently") has two -//! possible causes and they look identical in game: -//! -//! 1. **The table** — the `DeathThudLookups.dbc` matrix in full, `SizeClass × TerrainTypeSoundID` -//! → the land and water `SoundEntries` kits, named. An empty cell here is *authored* silence -//! (Sand and Soggy have no water column at all), and telling that apart from a bug is the -//! whole point of printing the grid rather than the rows. -//! 2. **The population** — which creature M2s actually key a `$DTH` at all, on which sequences, -//! and what size class each resolves to. A model that keys none is silent in the reference -//! too; a model that keys one and still sounds wrong is a runtime question, not a data one. -//! -//! The cross-check that validates the column map: every live `DeathThudLookups` sound id must -//! land on a real `SoundEntries` row, every terrain axis value on a real `TerrainTypeSounds` row, -//! and every model's `SizeClass` inside `0..=4` — a shifted field would scatter across the id -//! space and hit holes, exactly as `shakecensus` checks fields 11/12. +//! `thudcensus`: the death thud a corpse makes as it lands (`$DTH`, `0x6236e0`): the full +//! `DeathThudLookups.dbc` grid, `SizeClass × TerrainTypeSoundID` to land and water `SoundEntries` +//! kits, and the creature models that key a `$DTH`. An empty cell is authored silence (Sand and +//! Soggy have no water column), as is a model with no `$DTH`. use std::collections::{BTreeMap, BTreeSet}; use anyhow::Result; use benilla_formats::Chain; -/// A model path reduced to a comparison key: lowercase, back-slashed, extension dropped. The DBCs -/// name models `.mdx`; the archives hold them as `.m2`. +/// A model path's comparison key: DBCs name models `.mdx`, the archives hold `.m2`. fn mdx_key(path: &str) -> String { let p = path.to_ascii_lowercase().replace('/', "\\"); match p.rsplit_once('.') { @@ -32,8 +17,7 @@ fn mdx_key(path: &str) -> String { } } -/// The `SizeClass` names the shipped kits give themselves (`DeathThudSmallDirt` … -/// `DeathThudColossalWood`). +/// The `SizeClass` names the kits carry, `DeathThudSmallDirt` to `DeathThudColossalWood`. const SIZE_NAMES: [&str; 5] = ["Small", "Medium", "Large", "Giant", "Colossal"]; fn size_name(class: i32) -> String { @@ -50,8 +34,7 @@ pub fn thudcensus(chain: &mut Chain) -> Result<()> { let creatures = benilla_formats::load_creature_catalog(chain)?; // ── 1 · the table ──────────────────────────────────────────────────────────────────────── - // The terrain axis, with the TerrainType rows that reach each id — a terrain sound nothing - // names is unreachable however well-populated its column is. + // The terrain axis with the TerrainType rows naming each id; an unnamed sound is unreachable. let mut terrain_names: BTreeMap> = BTreeMap::new(); for terrain in 0..64 { if let Some(sound) = steps.sound_class_of(terrain) { @@ -104,9 +87,7 @@ pub fn thudcensus(chain: &mut Chain) -> Result<()> { } // ── 2 · the population ─────────────────────────────────────────────────────────────────── - // Which creature models key a `$DTH`, and the size class(es) the displays that reach each one - // resolve to. A model whose displays disagree is normal — 2 971 displays override their - // model's column — and the census prints the whole set so a mis-sized thud is visible. + // 2971 displays override their model's size class, so each model prints the whole set. let mut resolved: BTreeMap> = BTreeMap::new(); for (display, model) in creatures.display_models() { if let Some(class) = creatures.size_class(display) { @@ -204,16 +185,10 @@ pub fn thudcensus(chain: &mut Chain) -> Result<()> { off_axis.len() ); - // ── 3 · where a corpse is SILENT ───────────────────────────────────────────────────────── - // The asymmetry the table half implies and only this sweep sizes. Both lookups are keyed on - // `TerrainType.SoundID`, and `TerrainType 10 "None"` — the unauthored default a WMO surface - // takes when its `MOMT+0x20` says nothing — has `SoundID = 0`. `FootstepTerrainLookup` has a - // **row at terrain sound 0** for 17 footstep classes; `DeathThudLookups` has **none at all**. - // So the same floor that creaks underfoot swallows the body that lands on it, and that is - // authored data in both directions, not a gap on either side. - // - // What this section measures is how much of the shipped building stock that covers, because - // "a corpse landed silently indoors" is a *correct* observation that looks exactly like a bug. + // ── 3 · where a corpse is silent ───────────────────────────────────────────────────────── + // Both lookups key on `TerrainType.SoundID`, and TerrainType 10 "None", the default of a WMO + // surface whose `MOMT+0x20` is unset, has `SoundID` 0. `FootstepTerrainLookup` has rows there + // for 17 footstep classes and `DeathThudLookups` none, so a corpse on such a floor is silent. let ftl_at_zero: Vec = (0..256) .filter(|c| steps.resolve_terrain(*c, 10).is_some()) .collect(); @@ -244,9 +219,7 @@ pub fn thudcensus(chain: &mut Chain) -> Result<()> { for ground_type in root.material_ground_types() { materials += 1; *by_terrain.entry(ground_type).or_default() += 1; - // A material thuds iff its terrain id reaches a nonzero TerrainTypeSounds class that - // the thud table has a row for. Size class is irrelevant to *whether* — every class - // 0..=4 is populated on the same terrain axis. + // Size class 0 stands for all: every class 0..=4 has rows on the same terrain axis. if steps .sound_class_of(ground_type) .is_some_and(|ts| thuds.resolve(0, ts).is_some()) @@ -284,8 +257,7 @@ pub fn thudcensus(chain: &mut Chain) -> Result<()> { "\n{silent_roots} of {wmo_roots} WMO roots have NO surface that can thud; {} do", thudding.len() ); - // All of them, not a head: the question this list answers is "does THIS building thud", and a - // truncated list cannot answer it. 121 lines is a census, not a flood. + // Untruncated: the list answers whether a given building thuds. for p in &thudding { println!(" thuds somewhere: {p}"); } diff --git a/crates/benilla-formats/src/bin/mpqx.rs b/crates/benilla-formats/src/bin/mpqx.rs index 3e7389a5..fc8a3cb5 100644 --- a/crates/benilla-formats/src/bin/mpqx.rs +++ b/crates/benilla-formats/src/bin/mpqx.rs @@ -1,9 +1,6 @@ -//! One-off extraction helper (session tool, not shipped): `mpqx `. -//! Reads through the SAME patch chain as the runtime ([`benilla_formats::Chain`], the full vanilla -//! mount law) — it used to hand-roll a partial archive list in `benilla-mpq` and silently -//! miss whole archives (`wmo.MPQ`: "not found" for every building/ship). A path the top archive -//! **delete-marks** is reported as DELETED (exit 2) — the client doesn't load it; look for a -//! `Blizzard_*` addon replacement (decision 0246). +//! Extraction helper, not shipped: `mpqx `, read through the +//! runtime's patch chain ([`benilla_formats::Chain`]). It exits 2 on a path the top archive +//! delete-marks, which the client does not load. use std::path::Path; @@ -32,7 +29,7 @@ fn main() { from ); } - // Chain::read distinguishes the tombstone in its message; surface it with the old exit code. + // `Chain::read` names a delete-marked path in its error message. Err(e) if e.to_string().contains("deleted from patch chain") => { eprintln!("{e} — the client does not load this path (see decision 0246)"); std::process::exit(2); diff --git a/crates/benilla-formats/src/camera_shakes.rs b/crates/benilla-formats/src/camera_shakes.rs index 24e890ad..de7bb993 100644 --- a/crates/benilla-formats/src/camera_shakes.rs +++ b/crates/benilla-formats/src/camera_shakes.rs @@ -1,69 +1,12 @@ -//! `CameraShakes.dbc` — the shipped camera-shake presets. +//! `CameraShakes.dbc`, the 24 shipped camera-shake presets, and `SpellEffectCameraShakes.dbc`, +//! the 9 groups of up to three presets fired at one point. //! -//! A shake is **not** authored per model or per animation: the model names a preset id, and this -//! 24-row table holds the shape. Two id spaces reach it (decisions 1540, 1849): -//! -//! - **The big-creature footstep/death thud** names a preset **directly** — -//! `CreatureModelData.FootstepShakeSize` (field 11) and `.DeathThudShakeSize` (field 12), read -//! here through [`CreatureCatalog::footstep_shake`](crate::CreatureCatalog::footstep_shake). -//! - **Everything else names a GROUP** — `SpellEffectCameraShakes.dbc`, the 9-row indirection read -//! here too ([`SpellShakeGroup`]): up to three `CameraShakes` ids fired at one point. Both -//! `SpellVisualKit` field 14 and the `$SHK` animation event speak this id space and never the -//! preset one (`[0xc0d814]`, bound `0xc0d818`). -//! -//! **Layout — VERIFIED** against build 5875 (header + row decode, 2026-08-22): `24 × 8 × 32 B`, -//! string block empty. The **column names** are the conventional map (wowdev.wiki + vmangos -//! `DBCStructure.h`), and the shipped values corroborate them structurally rather than by -//! assertion: rows 4/5/6, 7/8/9, 13/14/15, 16/17/18, 36/37/38 and 76/77/78 are **direction -//! triples** — identical `Duration` within a triple, `Direction` running 0·1·2 — which is what a -//! per-axis column looks like and nothing else does. -//! -//! **The two families are visibly distinct**, which is the check that the creature columns really -//! do index this table: -//! -//! | family | rows | `Type`/`Direction` | `Duration` | `Phase` | `Coefficient` | -//! |---|---|---|---|---|---| -//! | creature (footstep + thud) | 1, 2, 10, 11, 12 | `1` / `2` | 0.40–0.65 s | **nonzero** | 1.0–2.0 | -//! | spell (the direction triples) | 4–9, 13–18, 36–38, 76–78 | 0 or 1 / 0·1·2 | 0.6–20 s | **0.0** | 0.4–3.0 | -//! -//! and within the creature family the amplitude ranks by mass exactly as it should: the Ancient -//! Protector and the kodo take row 1 (`amplitude 2.0`), the Ancient of Lore/War, the giants and -//! the dragons take row 2 (`amplitude 7.0`), and the death thuds (rows 11/12) are longer and -//! stronger than the footsteps. -//! -//! **The semantics** — what `ShakeType` and `Direction` select, and the distance attenuation the -//! evaluator applies — were settled against the reference in decision 1540 and live beside the -//! evaluator, in `benilla-app`'s `camera_shake`. This module stays the shipped data and nothing -//! more. -//! -//! ## The group table — `SpellEffectCameraShakes.dbc` (9 rows × 4 fields, recsize 16) -//! -//! Every **spell-side** producer names a row of *this* table, never a `CameraShakes` id: the -//! reference's shake spawner is reached through a group that fires **up to three presets at one -//! point**. Layout verified against build 5875 the same way ([`SpellShakeGroup`]): `ID` plus three -//! `CameraShakes` ids at `+0x4/+0x8/+0xc`, zeros skipped, string block empty. The shipped rows: -//! -//! | group | slots | group | slots | -//! |---|---|---|---| -//! | 1 | 4 · 5 · 6 | 6 | 4 · 5 · 6 | -//! | 2 | 7 · 8 · 9 | 7 | 18 · 17 · 18 | -//! | 3 | 1 | 26 | 36 · 37 · 38 | -//! | 4 | 15 · 14 · 15 | 66 | 76 · 77 · 78 | -//! | 5 | 11 | | | -//! -//! **The three slots are slots, not axes.** Group 4 lists `15` twice and group 7 lists `18` twice — -//! a per-axis reading cannot survive that. The *intent* is clearly a per-axis triple (the ids named -//! are the `direction` 0·1·2 members of a `CameraShakes` family), but the duplicate simply loses the -//! evaluator's strict-`>` tie-break and contributes nothing. -//! -//! **Reachability.** The creature columns name `CameraShakes` {1, 2, 10} (footstep) and -//! {10, 11, 12, 38} (thud); the nine groups name {1, 4, 5, 6, 7, 8, 9, 11, 14, 15, 17, 18, 36, 37, -//! 38, 76, 77, 78}. Their union is 21 of the 24 shipped rows — **13, 16 and 56 are named by neither -//! table**. All three are `direction 0` rows; 13 and 16 are the missing member of a family whose -//! other two *are* named (14/15 by group 4, 17/18 by group 7), and 56 stands alone. See -//! `benilla-extract shakecensus`, which prints the whole map — and keeps "named by a table" apart -//! from "has a live producer behind it", which are the same 21 rows here only because every group -//! turns out to have a producer. +//! Two id spaces, never conflated: the creature footstep and death-thud columns +//! (`CreatureModelData` fields 11 and 12, read through +//! [`CreatureCatalog::footstep_shake`](crate::CreatureCatalog::footstep_shake)) name a preset +//! directly, while `SpellVisualKit` field 14 and the `$SHK` animation event name a group, never a +//! preset (`[0xc0d814]`, bound `0xc0d818`). Column names follow vmangos `DBCStructure.h`; what +//! `ShakeType` and `Direction` select is the evaluator's, `benilla-app`'s `camera_shake`. use std::collections::HashMap; @@ -76,17 +19,13 @@ use crate::Chain; const CAMERA_SHAKES: &str = "DBFilesClient\\CameraShakes.dbc"; const SPELL_EFFECT_CAMERA_SHAKES: &str = "DBFilesClient\\SpellEffectCameraShakes.dbc"; -/// One `CameraShakes.dbc` row — the authored shape of a shake, in the table's own units. -/// -/// Every field below `id` is data as shipped; see the module doc for what is verified (the layout -/// and the family split) and what still awaits the reference (the evaluator's reading of them). +/// One `CameraShakes.dbc` row: a shake's authored shape, in the table's own units. #[derive(Debug, Clone, Copy, PartialEq)] pub struct CameraShake { pub id: u32, - /// `ShakeType` — 0 or 1 across the shipped table; every creature row is `1`. + /// `ShakeType`: 0 or 1 as shipped; every creature row is 1. pub shake_type: u32, - /// `Direction` — 0·1·2, running consistently across the spell rows' triples; every creature - /// row is `2`. + /// `Direction`: 0, 1 or 2, one per member of a spell-side triple; every creature row is 2. pub direction: u32, pub amplitude: f32, pub frequency: f32, @@ -96,32 +35,25 @@ pub struct CameraShake { pub coefficient: f32, } -/// One `SpellEffectCameraShakes.dbc` row — **up to three [`CameraShake`] ids fired at one point**. -/// -/// This is the id space every *spell-side* producer speaks: `SpellVisualKit` field 14 and the -/// `$SHK` animation event both name a **group**, never a preset. Zero is the empty slot; the -/// reference walks the three in order and skips them (`edi = 3`, `0x6ecb40`). +/// One `SpellEffectCameraShakes.dbc` row: up to three [`CameraShake`] ids fired at one point. The +/// reference walks the three slots in order and skips a zero (`0x6ecb40`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SpellShakeGroup { pub id: u32, - /// The three slots as shipped, zeros included — iterate with [`Self::shakes`]. + /// As shipped, zeros included; [`Self::shakes`] skips them. pub slots: [u32; 3], } impl SpellShakeGroup { - /// The populated slots, in the reference's walk order. A group can name the same preset twice - /// (group 4 is `15 · 14 · 15`); the duplicate is kept here and loses the evaluator's tie-break. + /// The populated slots in walk order. A duplicate (group 4 is `15, 14, 15`) is kept: the slots + /// are slots, not axes, and it loses the evaluator's strict `>` tie-break. pub fn shakes(&self) -> impl Iterator + '_ { self.slots.iter().copied().filter(|&id| id != 0) } } -/// The camera-shake tables: `CameraShakes.dbc` keyed by row id, and the -/// `SpellEffectCameraShakes.dbc` groups that index it — **two id spaces, never conflated** -/// (the same per-table split [`crate::SpellVisualCatalog`] keeps). -/// -/// `Default` is the **empty** catalog — the same thing "the DBC failed to load" already means to -/// every consumer (every lookup misses and the caller takes its documented fallback). +/// Both camera-shake tables, presets and groups each keyed by their own id. `Default` is the empty +/// catalog, which is what a failed load means to every consumer. #[derive(Default)] pub struct CameraShakeCatalog { rows: HashMap, @@ -129,8 +61,7 @@ pub struct CameraShakeCatalog { } impl CameraShakeCatalog { - /// One preset by id. `None` for id 0 (the "no shake" value both creature columns use) and for - /// any id the shipped table does not carry. + /// One preset by id; 0, the creature columns' "no shake", is never a row. pub fn get(&self, id: u32) -> Option<&CameraShake> { self.rows.get(&id) } @@ -148,8 +79,7 @@ impl CameraShakeCatalog { self.rows.is_empty() } - /// One `SpellEffectCameraShakes.dbc` group by id — the spell side's whole lookup. `None` for - /// id 0 (the "no shake" value the kit column uses) and for any id the 9-row table lacks. + /// One group by id; 0, the kit column's "no shake", is never a row. pub fn group(&self, id: u32) -> Option<&SpellShakeGroup> { self.groups.get(&id) } @@ -163,16 +93,14 @@ impl CameraShakeCatalog { self.groups.len() } - /// Seed one preset — for fixtures exercising the evaluator without a client install (the same - /// builder shape [`crate::SpellVisualCatalog::with_chain_effect`] uses). The live path is - /// [`load_camera_shakes`]. + /// Seed one preset, for fixtures that run the evaluator without an install. #[must_use] pub fn with_row(mut self, row: CameraShake) -> Self { self.rows.insert(row.id, row); self } - /// Seed one group. See [`Self::with_row`]. + /// Seed one group, for fixtures. #[must_use] pub fn with_group(mut self, group: SpellShakeGroup) -> Self { self.groups.insert(group.id, group); @@ -205,12 +133,8 @@ fn spell_effect_camera_shakes_schema() -> Schema { s } -/// Read both camera-shake tables off the patch chain. -/// -/// `SpellEffectCameraShakes.dbc` is loaded beside `CameraShakes.dbc` rather than on demand: it is -/// nine rows, it ships in the same archive, and every spell-side producer needs both to resolve a -/// single shake. A missing or malformed table is a hard error like every other DBC (1300) — the -/// caller's fallback is to run with no shake system at all, which is what an empty catalog means. +/// Read both camera-shake tables off the patch chain. A missing or malformed table is an error; +/// the caller's fallback is the empty catalog, no shakes at all. pub fn load_camera_shakes(chain: &mut Chain) -> Result { let bytes = chain .read_file(CAMERA_SHAKES) @@ -259,11 +183,8 @@ pub fn load_camera_shakes(chain: &mut Chain) -> Result { mod tests { use std::collections::BTreeSet; - /// The nine shipped `SpellEffectCameraShakes.dbc` groups, verbatim — the id space every - /// spell-side producer speaks. Sparse ids (`…7, 26, 66`) and duplicate slots (group 4 lists - /// `15` twice, group 7 lists `18` twice) are both load-bearing: the sparseness is what makes a - /// wrong column map impossible to miss, and the duplicate is what proves the three slots are - /// **slots, not axes**. + /// The nine shipped groups, verbatim. The sparse ids expose a wrong column map; the duplicate + /// slots in groups 4 and 7 show the three are slots, not axes. #[test] fn the_spell_shake_groups_decode_as_shipped() { let data = crate::wow_data_or_skip!(); @@ -299,8 +220,7 @@ mod tests { ); } - /// **The property that licenses the column map**: every id the group table names must land on a - /// real row of the 24-row preset table. A merely plausible map would dangle. + /// The check on the column map: a merely plausible one would leave group slots dangling. #[test] fn every_group_slot_resolves_to_a_preset() { let data = crate::wow_data_or_skip!(); @@ -318,8 +238,7 @@ mod tests { "the 18 presets the spell side reaches" ); - // The census the module doc states: with the creature columns' {1,2,10} and - // {10,11,12,38}, three shipped rows are named by NEITHER table. + // The footstep column names {1, 2, 10} and the thud column {10, 11, 12, 38}. let creature: BTreeSet = [1, 2, 10, 11, 12, 38].into_iter().collect(); let all: BTreeSet = cat.iter().map(|r| r.id).collect(); let unreached: Vec = all.difference(&(&named | &creature)).copied().collect(); diff --git a/crates/benilla-formats/src/chain.rs b/crates/benilla-formats/src/chain.rs index 2ef67c90..980900b1 100644 --- a/crates/benilla-formats/src/chain.rs +++ b/crates/benilla-formats/src/chain.rs @@ -1,16 +1,6 @@ -//! The vanilla patch chain — a priority-ordered set of MPQ archives, read through `benilla-mpq`. -//! -//! Replaces `wow-mpq`'s `PatchChain` *and* the old `ChainReader` (decision 0021). Those were two types -//! because `wow-mpq`'s `Archive::open` re-parsed the hash/block (and the useless `(attributes)`) tables -//! on every open, so `ChainReader` bolted a `Mutex>` handle-cache on top to avoid -//! re-paying that per read. `benilla_mpq::Archive` now caches its parsed tables in an `Arc` and reads -//! `&self` (a fresh OS handle per read, no seek-state sharing), so the cache is gone and one `Chain` -//! serves both the `&self` concurrent Bevy `AssetReader` path and the `&mut` streaming-loader path. -//! -//! Later archives override earlier ones for files sharing an internal path (so a patch archive -//! wins); a read resolves a name to the highest-priority archive that holds it. Base content -//! archives carry no `(listfile)`, so resolution is by name **hash**, which works without one. -//! Which archives mount, and in what order, is [`mount_order`]'s law (decision 1300). +//! The vanilla patch chain: a priority-ordered set of MPQ archives, read through `benilla-mpq`. A +//! read resolves a name to the highest-priority archive holding it, so a patch wins; base archives +//! carry no `(listfile)`, so resolution is by name hash. use std::collections::HashSet; use std::path::Path; @@ -28,13 +18,12 @@ pub struct ChainEntry { /// A priority-ordered patch chain of MPQ archives (`Send + Sync`; reads are `&self` and lock-free). pub struct Chain { - /// Ascending priority — later archives win. `resolve` scans back-to-front. + /// Ascending priority: later archives win. archives: Vec, } -/// `patch-?.MPQ` with the reference's FindFirstFileW semantics: `?` matches **exactly one** -/// character, case-insensitively — `patch-3.MPQ` mounts, `patch-10.MPQ` does not (the glob -/// template `0x82edbc` and its wrapper `0x42ad10`). +/// `patch-?.MPQ` as the reference's `FindFirstFileW` glob matches it (template `0x82edbc`, wrapper +/// `0x42ad10`): `?` is exactly one character, any case, so `patch-10.MPQ` never mounts. fn is_patch_glob_match(name: &str) -> bool { let lower = name.to_ascii_lowercase(); let Some(mid) = lower @@ -46,14 +35,10 @@ fn is_patch_glob_match(name: &str) -> bool { mid.chars().count() == 1 } -/// The vanilla mount law over a `Data` directory listing, **ascending priority** (decision 1300; -/// the mounter `0x403740`): the ten -/// [`VANILLA_BASE_ORDER`] archives at their fixed priorities, then `patch.MPQ`, then every -/// `patch-?.MPQ` sorted ascending by case-folded name — the binary sorts its glob matches -/// *descending* (`strnicmp`) and walks the array backwards, so the order is deterministic, never -/// filesystem enumeration; `patch-3` overrides `patch-2` — then `speech2.MPQ` above every patch. -/// Names are matched case-insensitively (the reference runs on a case-insensitive filesystem) and -/// returned as found on disk; absent archives are simply not in the result. +/// The reference's mount order over a `Data` listing, ascending priority (`0x403740`): the ten +/// [`VANILLA_BASE_ORDER`] archives, `patch.MPQ`, every `patch-?.MPQ` by case-folded name (the +/// reference sorts descending with `strnicmp` and walks backwards), then `speech2.MPQ`. Names +/// match case-insensitively and come back as found on disk. fn mount_order(dir_names: &[String]) -> Vec { let find = |want: &str| { dir_names @@ -75,13 +60,11 @@ fn mount_order(dir_names: &[String]) -> Vec { } impl Chain { - /// Open a chain from a vanilla `Data` directory (every archive [`mount_order`] finds in it, - /// lowest priority first) or a single `.MPQ` file (just that archive). + /// Open a `Data` directory's archives in [`mount_order`], or a single `.MPQ` file. /// - /// An archive that exists but fails to open is a hard error, deliberately: the reference logs - /// `"Failed to open archive"` and continues, but a silent skip turns a corrupt `dbc.MPQ` — or a - /// modder's malformed `patch-3.MPQ` — into cryptic missing-file failures far downstream. Same - /// composite on a healthy install; a clear error instead of a quirk on a broken one (1300). + /// Deviation: an archive that fails to open is an error, where the reference logs + /// `"Failed to open archive"` and goes on, because a skipped corrupt archive surfaces only as + /// missing files far downstream. pub fn open(path: &Path) -> Result { let mut archives = Vec::new(); if path.is_dir() { @@ -113,34 +96,29 @@ impl Chain { Ok(Self { archives }) } - /// The highest-priority archive with an *entry* for `name` (readable file **or** delete-marker), - /// if any. Stops at the winning archive — including a tombstone, which correctly shadows any - /// lower-priority copy (decision 0246). Callers that want "readable" must check - /// [`Archive::is_delete_marker`]. + /// The highest-priority archive with an entry for `name`, a delete marker included, as a + /// tombstone shadows every lower copy: check [`Archive::is_delete_marker`] for a readable file. fn resolve(&self, name: &str) -> Option<&Archive> { self.archives.iter().rev().find(|a| a.contains(name)) } - /// Whether the chain holds `name` as a **readable** file (accepts `/` or `\`; case-insensitive). - /// A path whose winning entry is a delete-marker is *not* present — the client deleted it (0246). + /// Whether `name` (`/` or `\`, any case) is a readable file, not a delete marker. pub fn contains(&self, name: &str) -> bool { self.resolve(name) .is_some_and(|a| !a.is_delete_marker(name)) } - /// The path of the archive `name` resolves to (the winning override) — for debugging / extract. + /// The path of the archive `name` resolves to, for debugging and extraction. pub fn find_file_archive(&self, name: &str) -> Option<&Path> { self.resolve(name).map(|a| a.path()) } - /// Read a file by internal path (accepts `/` or `\`), from its winning archive. `&self`: safe to - /// call concurrently (the Bevy `AssetReader` does). + /// Read a file by internal path (`/` or `\`) from its winning archive. pub fn read(&self, name: &str) -> Result> { let archive = self .resolve(name) .ok_or_else(|| anyhow!("file not in patch chain: {name}"))?; - // A tombstone shadows every lower copy: the path is deleted from the composite, so this is a - // clean "not found", not a fall-through to a stale base version (decision 0246). + // A tombstone deletes the path from the composite: not found, never a stale lower copy. if archive.is_delete_marker(name) { bail!( "file deleted from patch chain: {name} (tombstoned by {})", @@ -152,19 +130,14 @@ impl Chain { .with_context(|| format!("reading {name} from {}", archive.path().display())) } - /// `&mut` alias of [`Chain::read`] — kept so the streaming-loader call sites that thread a - /// `&mut Chain` read exactly as they did against `wow-mpq`'s `PatchChain`. + /// `&mut` alias of [`Chain::read`] for call sites that thread a `&mut Chain`. pub fn read_file(&mut self, name: &str) -> Result> { self.read(name) } - /// List the chain's named files with sizes. Dev/extract use only — files absent from every - /// listfile (most of `texture.MPQ`) are reachable by name but not enumerated. - /// - /// Unions the `(listfile)` of **every** archive that carries one: each archive's listfile names - /// only the files *it* holds, so resolving `(listfile)` like an ordinary overridden file (as this - /// used to) returns just the top patch archive's sliver — 92 names from `patch-2.MPQ` instead of - /// the chain's ~86k. Sizes still resolve per-name to the winning archive. + /// The chain's named files with sizes, for development and extraction; a file in no listfile + /// (most of `texture.MPQ`) is readable by name but not listed. Unions every archive's + /// `(listfile)`, as each names only its own files; sizes come from the winning archive. pub fn list(&self) -> Result> { let mut seen = HashSet::new(); let mut out = Vec::new(); @@ -174,12 +147,12 @@ impl Chain { }; for raw in String::from_utf8_lossy(&listfile).split([';', '\r', '\n']) { let name = raw.trim(); - // Dedupe across archives the way MPQ hashing compares names: case-insensitive, `/`≡`\`. + // Dedupe the way MPQ hashing compares names: any case, `/` and `\` alike. if name.is_empty() || !seen.insert(name.replace('/', "\\").to_ascii_lowercase()) { continue; } if let Some(a) = self.resolve(name) { - // A tombstoned path isn't a file in the composite — don't list it (0246). + // A tombstoned path is not a file in the composite. if a.is_delete_marker(name) { continue; } @@ -207,11 +180,9 @@ mod tests { assert!(is_patch_glob_match("patch-2.MPQ")); assert!(is_patch_glob_match("patch-3.MPQ")); assert!(is_patch_glob_match("PATCH-A.mpq")); - // Zero or two-plus characters: FindFirstFileW's `?` is exactly one. assert!(!is_patch_glob_match("patch-.MPQ")); assert!(!is_patch_glob_match("patch-10.MPQ")); assert!(!is_patch_glob_match("patch-33.MPQ")); - // Not the glob's shape at all. assert!(!is_patch_glob_match("patch.MPQ")); assert!(!is_patch_glob_match("patch-2.MPQ.bak")); assert!(!is_patch_glob_match("mypatch-2.MPQ")); @@ -219,8 +190,7 @@ mod tests { #[test] fn mount_order_is_the_carved_law() { - // A shuffled install with a custom patch, plus files the mounter must ignore: - // base.MPQ (telemetry-only in the reference), backup.MPQ, loose non-archives. + // base.MPQ is telemetry-only in the reference and never mounts. let dir = owned(&[ "patch-2.MPQ", "backup.MPQ", @@ -249,8 +219,6 @@ mod tests { #[test] fn patch_sort_is_ascending_and_case_folded() { - // Later wins in `Chain`, so ascending case-folded order makes patch-3 override patch-2 - // and `patch-B` override `patch-a` ('a' < 'b' after the strnicmp-style fold). let dir = owned(&["patch-B.MPQ", "patch-3.MPQ", "patch-a.MPQ", "patch-2.MPQ"]); assert_eq!( mount_order(&dir), diff --git a/crates/benilla-formats/src/characters/customization.rs b/crates/benilla-formats/src/characters/customization.rs index 93870c0d..f420ef5b 100644 --- a/crates/benilla-formats/src/characters/customization.rs +++ b/crates/benilla-formats/src/characters/customization.rs @@ -1,24 +1,9 @@ -//! Character-creation source data (decision 0423): the per-race body displayIds (ChrRaces), the -//! race/class combos (CharBaseInfo), and the five appearance-dial *ranges* per (race, sex) derived -//! from CharSections + CharHairGeosets + CharacterFacialHairStyles. -//! -//! **The ranges are data-derived, never hardcoded** (the client's CharSections grid, built at -//! `0x476020`). Each dial cycles index `0..count-1`; the counts come from: -//! - **skin color** = distinct `ColorIndex` of CharSections `SECTION_SKIN` (variation 0) for (race, sex); -//! - **face** = distinct `VariationIndex` of `SECTION_FACE`; -//! - **hair style** = distinct `VariationID` of **CharHairGeosets** for (race, sex), `max(1, …)` (the -//! client's `0x478540` count fetcher — bald is style 0); -//! - **hair color** = distinct `ColorIndex` of `SECTION_HAIR`; -//! - **facial hair** = distinct `VariationID` of **CharacterFacialHairStyles** for (race, sex). -//! -//! By construction every `(skin, face, hairStyle, hairColor, facialHair)` tuple in the product of a -//! (race, sex)'s ranges passes vmangos's `Player::ValidateAppearance` — the server checks, in order, -//! that CharSections rows exist for `SKIN(0, skin)`, `FACE(face, skin)` (face is keyed by *skinColor*), -//! `HAIR(hairStyle, hairColor)`, and — unless the (race, sex) is facial-hair-excluded (Tauren, or a -//! female that isn't Night Elf / Undead) — `FACIAL_HAIR(facialHair, hairColor)`, plus a -//! CharacterFacialHairStyles row for `facialHair`. The real 5875 grids are *complete* over each -//! coupled pair, so the independent per-dial counts never produce an invalid tuple — asserted -//! exhaustively against the transcribed predicate in the real-data test. +//! Character-creation source data from ChrRaces, CharBaseInfo and CharStartOutfit, and the five +//! appearance-dial ranges per (race, sex), counted from the DBCs: skin colour, face and hair colour +//! off the CharSections rows the client groups into its grid (`0x476020`), hair and facial-hair +//! styles off CharHairGeosets and CharacterFacialHairStyles. Every tuple in a (race, sex)'s ranges +//! passes vmangos's `Player::ValidateAppearance`, since the shipped grids are complete over each +//! coupled pair. use std::collections::{HashMap, HashSet}; @@ -35,11 +20,7 @@ const CHAR_HAIR_GEOSETS: &str = "DBFilesClient\\CharHairGeosets.dbc"; const CHAR_FACIAL_HAIR_STYLES: &str = "DBFilesClient\\CharacterFacialHairStyles.dbc"; const CHAR_SECTIONS: &str = "DBFilesClient\\CharSections.dbc"; -// CharSections `SectionType` values the range derivation reads (the hair apply `0x4784c0` pushes -// 3; the same constants `sections.rs` uses — kept local so the two concerns don't couple). -// `SECTION_FACIAL_HAIR` (2) isn't here: the facial-hair *count* comes from -// CharacterFacialHairStyles, not CharSections (the test's ValidateAppearance transcription defines -// it there, where it's used). +// CharSections `SectionType` values the dial ranges read (hair is 3 at `0x4784c0`). const SECTION_SKIN: u8 = 0; const SECTION_FACE: u8 = 1; const SECTION_HAIR: u8 = 3; @@ -47,12 +28,10 @@ const SECTION_HAIR: u8 = 3; /// A CharSections availability key: `(race, sex, sectionType, variation, color)`. type SectionKey = (u8, u8, u8, u8, u8); -/// The 8 playable races (ChrRaces ids 1–8); higher rows (Goblin = 9) aren't character-creatable. +/// The playable races, ChrRaces ids 1–8; Goblin (9) and above are not creatable. const PLAYABLE_RACES: [u8; 8] = [1, 2, 3, 4, 5, 6, 7, 8]; -/// The 8 playable races' ChrRaces client fileStrings (col 15), a **frozen fact** of the build — the -/// load-time misparse guard for the string-column positions in [`load_races`] (if col 15 drifted, -/// these wouldn't match), and the GlueStrings key stems (`RACE_INFO_SCOURGE`, not `…_UNDEAD`). +/// The shipped ChrRaces fileStrings (col 15), guarding [`load_races`]' string columns. const KNOWN_FILES: [(u8, &str); 8] = [ (1, "Human"), (2, "Orc"), @@ -64,15 +43,12 @@ const KNOWN_FILES: [(u8, &str); 8] = [ (8, "Troll"), ]; -/// The **raw** 1.12 `CharBaseInfo` content, race → classes (a frozen fact of the file; class ids: -/// Warrior 1, Paladin 2, Hunter 3, Rogue 4, Priest 5, Shaman 7, Mage 8, Warlock 9, Druid 11). Used -/// only as the load-time misparse guard (proves the 2-byte parse landed) — note it is NOT the -/// playable set: the file carries one dead pair, Dwarf-Mage, which the real client never offers -/// (see [`UNUSED_COMBOS`]). +/// The shipped `CharBaseInfo` rows by race, the load-time guard on the 2-byte parse; not the +/// playable set, as it holds the dead Dwarf-Mage row ([`UNUSED_COMBOS`]). const KNOWN_COMBOS: [(u8, &[u8]); 8] = [ (1, &[1, 2, 4, 5, 8, 9]), // Human (2, &[1, 3, 4, 7, 9]), // Orc - (3, &[1, 2, 3, 4, 5, 8]), // Dwarf — 8 (Mage) is the dead row, stripped after the guard + (3, &[1, 2, 3, 4, 5, 8]), // Dwarf; 8 (Mage) is the dead row (4, &[1, 3, 4, 5, 11]), // Night Elf (5, &[1, 4, 5, 8, 9]), // Undead (6, &[1, 3, 7, 11]), // Tauren @@ -80,19 +56,11 @@ const KNOWN_COMBOS: [(u8, &[u8]); 8] = [ (8, &[1, 3, 4, 5, 7, 8]), // Troll ]; -/// The `CharBaseInfo` pairs the real client never offers on the create screen — leftovers in the -/// data, not creatable content. Dwarf-Mage (3, 8) sits fully populated in both CharBaseInfo *and* -/// CharStartOutfit, yet the real client hardcodes the skip: its class-list builder (`0x4706b0`) -/// iterates CharBaseInfo and drops rows matching the literal `race == 3 && class == 8` — two x86 -/// immediates, the only such pair binary-wide (decisions 0549/0550). Stripped here right after the -/// raw-parse guard — the same mechanism, one layer down — so `allows`/`classes_for_race` answer -/// what the real client offers. +/// `CharBaseInfo` pairs the client never offers: its class-list builder `0x4706b0` skips the +/// literal `race == 3 && class == 8` (Dwarf-Mage), though the row and its outfit are populated. const UNUSED_COMBOS: [(u8, u8); 1] = [(3, 8)]; -/// One renderable item of a class's level-1 starting outfit (CharStartOutfit.dbc, decision 0527): -/// the item's **ItemDisplayInfo** id — the same kind of id the char-enum equipment carries, so it -/// feeds the equipment pipeline directly with no Item.dbc/template hop — and its InventoryType (the -/// worn slot). Non-worn entries (bags, food, hearthstone — InventoryType 0) are dropped at load. +/// One worn item of a CharStartOutfit row: its ItemDisplayInfo id and its InventoryType. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct StartOutfitItem { pub display_id: u32, @@ -109,66 +77,53 @@ pub struct DialRanges { pub facial_hair: u8, } -/// Character-creation source data: race body displayIds, race/class combos, and per-(race, sex) -/// appearance-dial ranges. Loaded once from the DBCs ([`Self::load`]). +/// Character-creation source data, loaded once from the DBCs ([`Self::load`]). pub struct CharCreateCatalog { /// race → (male displayId, female displayId), from ChrRaces cols 4/5. displays: HashMap, - /// race → the ChrRaces client fileString (col 15: `"Human"`, `"Scourge"`, …) — the stem the glue - /// screens key GlueStrings entries on (`RACE_INFO_`, `ABILITY_INFO_`). + /// race → ChrRaces fileString (col 15), the GlueStrings key stem (`RACE_INFO_`). files: HashMap, - /// race → (facial-hair customization token per sex [male, female] — cols 26/27 — and the hair - /// customization token — col 28). The glue tokens behind the per-race dial labels - /// (`HAIR__STYLE`, `FACIAL_HAIR_`); a facial token of `"NONE"` hides that dial. + /// race → ([male, female] facial-hair token, cols 26/27; hair token, col 28). custom_tokens: HashMap, - /// The (race, class) combos CharBaseInfo permits. combos: HashSet<(u8, u8)>, - /// (race, sex) → the five dial counts. ranges: HashMap<(u8, u8), DialRanges>, - /// (race, class, sex) → the level-1 starting-outfit items the create preview dresses in - /// (CharStartOutfit, decision 0527) — worn slots only, DisplayId + InventoryType. + /// (race, class, sex) → the worn starting-outfit items. start_outfits: HashMap<(u8, u8, u8), Vec>, } impl CharCreateCatalog { - /// The body model displayId for a (race, sex) — resolved through the ordinary CreatureDisplayInfo - /// chain, the same as any streamed unit (decision 0041). `None` for a non-playable race. + /// The body displayId for a (race, sex), resolved through CreatureDisplayInfo like any unit. pub fn body_display(&self, race: u8, sex: u8) -> Option { self.displays .get(&race) .map(|&(m, f)| if sex == 0 { m } else { f }) } - /// Whether a race may be created as a class (CharBaseInfo). + /// Whether a race may be created as a class (CharBaseInfo less `UNUSED_COMBOS`). pub fn allows(&self, race: u8, class: u8) -> bool { self.combos.contains(&(race, class)) } - /// The ChrRaces client fileString for a race (`"Human"`, `"Scourge"`, …) — the GlueStrings key - /// stem. `None` for a non-playable race. + /// The ChrRaces fileString for a race (`"Human"`, `"Scourge"`), the GlueStrings key stem. pub fn race_file(&self, race: u8) -> Option<&str> { self.files.get(&race).map(String::as_str) } - /// The hair-customization glue token for a race (`"NORMAL"`, `"HORNS"`) — the dial-label key - /// (`HAIR__STYLE` / `HAIR__COLOR`). + /// A race's hair glue token (`"NORMAL"`, `"HORNS"`), keying `HAIR__STYLE`/`_COLOR`. pub fn hair_customization(&self, race: u8) -> Option<&str> { self.custom_tokens.get(&race).map(|(_, h)| h.as_str()) } - /// The facial-hair-customization glue token for a (race, sex) (`"NORMAL"`, `"MARKINGS"`, …) — - /// the dial-label key (`FACIAL_HAIR_`); `"NONE"` means the dial is hidden (the ref's - /// `GetFacialHairCustomization() == "NONE"` branch). + /// A (race, sex)'s facial-hair glue token, keying `FACIAL_HAIR_`; the glue hides the dial + /// when `GetFacialHairCustomization()` is `"NONE"`. pub fn facial_hair_customization(&self, race: u8, sex: u8) -> Option<&str> { self.custom_tokens .get(&race) .map(|(f, _)| f[(sex as usize).min(1)].as_str()) } - /// The classes a race may be created as, ascending by class id. The real client's grid order is - /// CharBaseInfo *row* order (its builder `0x4706b0` walks the table, decision 0550) — which in - /// the 5875 file is ascending class id for all 8 races (verified byte fact), so the sort here is - /// order-equivalent on the real data. + /// The classes a race may be created as, ascending by id: the client lists them in CharBaseInfo + /// row order (`0x4706b0`), which is ascending class id in the shipped file. pub fn classes_for_race(&self, race: u8) -> Vec { let mut cs: Vec = self .combos @@ -180,24 +135,20 @@ impl CharCreateCatalog { cs } - /// The five appearance-dial counts for a (race, sex). `None` for a non-playable race. + /// The five appearance-dial counts for a (race, sex). pub fn ranges(&self, race: u8, sex: u8) -> Option { self.ranges.get(&(race, sex)).copied() } - /// The renderable starting-outfit items for a (race, class, sex) — the level-1 gear the create - /// screen dresses the preview in (decision 0527). Empty for a combo with no CharStartOutfit row. + /// The worn level-1 gear the create preview dresses a (race, class, sex) in. pub fn start_outfit(&self, race: u8, class: u8, sex: u8) -> &[StartOutfitItem] { self.start_outfits .get(&(race, class, sex)) .map_or(&[], Vec::as_slice) } - /// Load the character-creation catalog from the patch chain (ChrRaces + CharBaseInfo + - /// CharSections + CharHairGeosets + CharacterFacialHairStyles). Two load-time self-checks fail - /// loudly on a misparse: the parsed combos must equal the frozen 1.12 table (guards the 2-byte - /// CharBaseInfo layout), and every playable (race, sex) must have a body display + all five - /// ranges nonzero. + /// Load the catalog from the patch chain. The misparse guards compare the raw combos, so they + /// run before `UNUSED_COMBOS` are stripped. pub fn load(chain: &mut Chain) -> Result { let (displays, files, custom_tokens) = load_races(chain)?; let combos = load_combos(chain)?; @@ -231,7 +182,6 @@ impl CharCreateCatalog { Ok(catalog) } - /// Load-time misparse guards (see [`Self::load`]). fn self_check(&self) -> Result<()> { for (race, classes) in KNOWN_COMBOS { let got = self.classes_for_race(race); @@ -251,12 +201,8 @@ impl CharCreateCatalog { ); } } - // CharStartOutfit byte-layout guard (like the CharBaseInfo one): Human(1) Warrior(1) male(0) - // wears the recruit set — shirt disp 9891 (inv 4/BODY), pants 9892 (7/LEGS), boots 10141 - // (8/FEET), plus the worn shortsword 1542 (21/MAINHAND) and small shield 18730 (14/SHIELD). - // If the packed race/class/gender head or the DisplayId/InventoryType array offsets drifted, - // these (display, inv) pairs wouldn't line up. Bags/food (InventoryType 0) are dropped, so - // the count is exactly these five worn items. + // CharStartOutfit layout guard: the shipped Human Warrior male set, (display, + // InventoryType) for the shirt, pants, boots, shortsword and shield. let hw = self.start_outfit(1, 1, 0); for &(disp, inv) in &[ (9891u32, 4u8), @@ -302,12 +248,8 @@ impl CharCreateCatalog { } } -/// ChrRaces → per playable race: the (male, female) displayIds, the client fileString, and the glue -/// customization tokens. 29 fields in build 5875 (record size 116); we read cols 0 (RaceID), -/// 4 (MaleDisplayId), 5 (FemaleDisplayId), 15 (ClientFileString), 26/27 (FacialHairCustomization -/// male/female) and 28 (HairCustomization) — the rest (name strings + spell/sound ids) are declared -/// `UInt32` (a string column is a 4-byte offset, read and ignored). The string-column positions are -/// guarded by [`CharCreateCatalog::self_check`]'s frozen fileString table. +/// ChrRaces (29 fields, 116-byte records) → per playable race: displayIds (cols 4/5), fileString +/// (15), facial-hair (26/27) and hair (28) tokens; other string columns are read as ignored `u32`s. #[allow(clippy::type_complexity)] // one pass over one DBC → the catalog's three race-keyed maps fn load_races( chain: &mut Chain, @@ -352,10 +294,7 @@ fn load_races( Ok((displays, files, tokens)) } -/// CharBaseInfo → the (race, class) combos. A **special** DBC: 2 columns of **one byte each** -/// (record size 2, not 8), so the generic 4-byte-field reader can't touch it — parsed by hand off the -/// raw record block. Byte 0 = RaceID, byte 1 = ClassID (verified against the 5875 file + the known -/// combo table). +/// CharBaseInfo → (race, class) pairs: two one-byte fields, parsed by hand off the record block. fn load_combos(chain: &mut Chain) -> Result> { let bytes = chain .read_file(CHAR_BASE_INFO) @@ -381,15 +320,9 @@ fn load_combos(chain: &mut Chain) -> Result> { Ok(combos) } -/// CharStartOutfit → (race, class, sex) → the worn starting items. A **special** DBC (like -/// CharBaseInfo): 41 columns / 152 bytes with a byte-packed race/class/gender/unused head, so the -/// generic 4-byte-field reader can't touch it — parsed by hand off the raw record block. Layout, -/// byte-verified against the 5875 file: `ID` u32 @0; `Race`/`Class`/`Gender`/unused u8 @4/5/6/7; -/// `ItemId[12]` i32 @8 (dropped — we render by display); **`DisplayId[12]` i32 @56**; -/// **`InventoryType[12]` i32 @104**. Each row keeps the (DisplayId, InventoryType) pair of every -/// **worn** slot (both ≥ 1 — this drops the empty `-1` tail and the InventoryType-0 bag/food/hearth -/// entries, which never render). DisplayId is already an ItemDisplayInfo id (the char-enum -/// equipment convention), so it feeds the equipment pipeline with no template hop. +/// CharStartOutfit → (race, class, sex) → worn items, parsed by hand (41 fields, 152 bytes): `ID` +/// u32 @0, race/class/gender u8 @4/5/6, `ItemId[12]` @8, `DisplayId[12]` @56, `InventoryType[12]` +/// @104. A slot is kept when both are ≥ 1, dropping the `-1` tail and the InventoryType-0 bags. fn load_start_outfits(chain: &mut Chain) -> Result>> { let bytes = chain .read_file(CHAR_START_OUTFIT) @@ -432,10 +365,8 @@ fn load_start_outfits(chain: &mut Chain) -> Result Result> { let bytes = chain .read_file(CHAR_SECTIONS) @@ -460,8 +391,7 @@ fn load_available_sections(chain: &mut Chain) -> Result> { Ok(avail) } -/// CharHairGeosets → (race, sex) → the set of hair-style `VariationID`s. 6 fields (ID, Race, Sex, -/// Variation, GeosetID, unused); we key on cols 1/2/3, as the client's `0x478540` does. +/// CharHairGeosets → (race, sex) → hair-style variations, keyed on cols 1/2/3 as `0x478540` does. fn load_hair_geosets(chain: &mut Chain) -> Result>> { let bytes = chain .read_file(CHAR_HAIR_GEOSETS) @@ -482,9 +412,7 @@ fn load_hair_geosets(chain: &mut Chain) -> Result> Ok(map) } -/// CharacterFacialHairStyles → (race, sex) → the set of facial-hair `VariationID`s. 9 fields; the -/// keys are cols 0/1/2 (Race, Sex, Variation, as `0x478740` keys them — note col 0 is Race here, -/// not an ID). +/// CharacterFacialHairStyles → (race, sex) → variations, keyed on cols 0/1/2 as `0x478740` does. fn load_facial_hair_styles(chain: &mut Chain) -> Result>> { let bytes = chain .read_file(CHAR_FACIAL_HAIR_STYLES) @@ -521,7 +449,6 @@ fn section_axis_count( .len() as u8 } -/// Derive the five dial counts for a (race, sex) from the section domain + the two geoset tables. fn derive_ranges( race: u8, sex: u8, @@ -532,9 +459,8 @@ fn derive_ranges( DialRanges { skin: section_axis_count(avail, race, sex, SECTION_SKIN, false), face: section_axis_count(avail, race, sex, SECTION_FACE, true), - // The hair-style count is the client's CharHairGeosets fetcher (`max(1, …)` — a race/sex with - // no rows still has the bald style), NOT the CharSections HAIR variation count (Orc female has - // one extra HAIR variation with no geoset; offering it would be a dead style). + // Hair styles are CharHairGeosets variations, at least 1 (bald), not CharSections HAIR + // variations: Orc female has one HAIR variation with no geoset. hair_style: hair_geo .get(&(race, sex)) .map_or(1, |v| v.len().max(1) as u8), @@ -543,9 +469,7 @@ fn derive_ranges( } } -/// A schema of `n` `UInt32` fields named `f0..fn` — for the DBCs we read purely by index (the field -/// *names* are irrelevant; string columns are 4-byte offsets read as ints and ignored). The parser -/// requires the count to match the file header, which is the alignment guard. +/// `n` `UInt32` fields for a DBC read by index; the parser's field-count check guards the layout. fn all_u32_schema(name: &str, n: usize) -> Schema { let mut s = Schema::new(name); for i in 0..n { @@ -558,13 +482,11 @@ fn all_u32_schema(name: &str, n: usize) -> Schema { mod tests { use super::*; - /// CharSections `SECTION_TYPE_FACIAL_HAIR` — used only by the predicate transcription (the runtime - /// facial-hair count comes from CharacterFacialHairStyles, so the lib never references it). + /// CharSections `SECTION_TYPE_FACIAL_HAIR`, needed only by the transcribed check. const SECTION_FACIAL_HAIR: u8 = 2; - /// vmangos's `Player::ValidateAppearance`, transcribed verbatim (`Player.cpp:326`): the exact - /// predicate the server runs on `CMSG_CHAR_CREATE`. `GetCharSectionEntry` is an available-row - /// lookup (`avail` already excludes the `0x1`-flagged rows), and face is keyed by *skinColor*. + /// vmangos's `Player::ValidateAppearance` (`Player.cpp:326`), run on `CMSG_CHAR_CREATE`; + /// `avail` already excludes the `0x1`-flagged rows, and face is keyed by skin colour. fn validate_appearance( avail: &HashSet, facial: &HashMap<(u8, u8), HashSet>, @@ -586,7 +508,7 @@ mod tests { if !has(SECTION_HAIR, hair, hair_color) { return false; } - // Tauren, and any female that isn't Night Elf (4) or Undead (5), have no FACIAL_HAIR sections. + // Tauren and all females but Night Elf (4) and Undead (5) have no FACIAL_HAIR sections. let excluded = race == 6 || (sex == 1 && race != 4 && race != 5); if !excluded && !has(SECTION_FACIAL_HAIR, facial_hair, hair_color) { return false; @@ -596,10 +518,6 @@ mod tests { .is_some_and(|vs| vs.contains(&facial_hair)) } - /// The whole Phase-2 contract on the **real** 5875 DBCs: the 16 body displayIds resolve to a - /// character body model; the parsed combos equal the frozen 1.12 table; the derived ranges are - /// dense `0..n-1` on every dial; and — the load-bearing guarantee — every tuple in the product of - /// a (race, sex)'s ranges passes the transcribed `ValidateAppearance`. Skips without client data. #[test] fn char_create_catalog_matches_the_5875_dbcs() { let data = crate::wow_data_or_skip!(); @@ -607,7 +525,6 @@ mod tests { let cat = CharCreateCatalog::load(&mut chain).expect("load char-create catalog"); let creatures = crate::load_creature_catalog(&mut chain).expect("load creatures"); - // 1 · The 16 body displayIds resolve through the CreatureDisplayInfo chain to a body model. for race in PLAYABLE_RACES { for sex in [0u8, 1] { let display = cat.body_display(race, sex).expect("has display"); @@ -625,8 +542,7 @@ mod tests { } } - // 2 · The catalog answers the *playable* sets: the frozen raw table (guarded at load) - // minus the dead pairs the real client never offers (UNUSED_COMBOS — Dwarf-Mage). + // The playable sets are the raw table less the dead pairs. for (race, classes) in KNOWN_COMBOS { let playable: Vec = classes .iter() @@ -646,11 +562,8 @@ mod tests { CharBaseInfo row must not reach the create screen)" ); - // 2b · The glue customization tokens land in the right columns (spot-checks pin both the - // column positions and the male/female order of cols 26/27 against the dumped 5875 rows: - // Tauren cycle horns, males normal facial hair, Human females piercings, Night Elf - // females markings, trolls tusks both sexes). Note: no 5875 row is "NONE" — every - // (race, sex) has a facial dial in this build. + // The glue tokens against the shipped rows, pinning cols 26/27's male/female order; no + // shipped row is "NONE". assert_eq!(cat.hair_customization(6), Some("HORNS"), "Tauren hair tok"); assert_eq!(cat.hair_customization(1), Some("NORMAL"), "Human hair tok"); assert_eq!( @@ -674,11 +587,7 @@ mod tests { "Troll male facial tok" ); - // 2c · CharStartOutfit (decision 0527): every creatable (race, class, sex) combo dresses - // in ≥1 worn item, and the InventoryType semantics land — the Human Warrior recruit set - // (guarded at load too) and the Human Mage's robe (InventoryType 20, chest column). - // (Creatable = the playable sets; the dead Dwarf-Mage pair also has a populated outfit - // row, but nothing can ever wear it.) + // Every creatable combo wears at least one item; the Human Mage wears a robe (inv 20). for (race, classes) in KNOWN_COMBOS { for &class in classes .iter() @@ -704,7 +613,7 @@ mod tests { "Human Mage male should wear a robe (inv 20, disp 12647): {mage:?}" ); - // 3 + 4 · Ranges dense; every producible tuple passes ValidateAppearance. + // Every tuple in the dial ranges passes ValidateAppearance. let avail = load_available_sections(&mut chain).expect("sections"); let facial = load_facial_hair_styles(&mut chain).expect("facial hair"); for race in PLAYABLE_RACES { diff --git a/crates/benilla-formats/src/characters/geosets.rs b/crates/benilla-formats/src/characters/geosets.rs index 2e487e2f..78c2f19f 100644 --- a/crates/benilla-formats/src/characters/geosets.rs +++ b/crates/benilla-formats/src/characters/geosets.rs @@ -10,10 +10,9 @@ const CHAR_HAIR_GEOSETS: &str = "DBFilesClient\\CharHairGeosets.dbc"; const CHAR_FACIAL_HAIR: &str = "DBFilesClient\\CharacterFacialHairStyles.dbc"; const HELMET_GEOSET_VIS: &str = "DBFilesClient\\HelmetGeosetVisData.dbc"; -/// The default 16 region-base geosets (`cc+0x144`, set by the ctors `0x476810`/`0x476960`) — -/// increments of 100 (group 7's base is the outlier 702). Entries 0–3 (hair + the 3 facial-hair -/// groups) are overwritten by the customization; 4–15 (the equipment-group bases: -/// glove/boot/sleeve/…/cloak) stay as these bare-skin defaults. +/// The 16 default region bases of `cc+0x144` (ctors `0x476810`/`0x476960`); group 7's is 702. +/// Entries 0–3, hair and facial hair, are replaced by the customization; 4–15 are the equipment +/// groups' bare defaults. const REGION_BASES: [u16; 16] = [ 1, 101, 201, 301, 401, 501, 601, 702, 801, 901, 1001, 1101, 1201, 1301, 1401, 1501, ]; @@ -22,48 +21,33 @@ const REGION_BASES: [u16; 16] = [ pub struct CharacterGeosets { /// (race, sex, hairStyle) → hair `GeosetID` (group 0). hair: HashMap<(u8, u8, u8), u32>, - /// (race, sex, facialHair) → the 3 facial-hair geoset variations (DBC fields gA/gB/gC, in file - /// order — gA→group 1 (+100), gB→group 3 (+300), gC→group 2 (+200), per `0x478660`). + /// (race, sex, facialHair) → three geoset variations in file order, for groups 1, 3, 2 + /// (`0x478660`). facial: HashMap<(u8, u8, u8), [u32; 3]>, - /// HelmetGeosetVisData row id → its 5 **race** bitmasks (consumer `0x4799a0`): a set bit - /// `1 << race` in column *c* forces one region-base slot back to its group base — hiding the - /// styled hair/facial geoset (and the ears) under a worn helm. + /// HelmetGeosetVisData row id → five race bitmasks (`0x4799a0`): bit `1 << race` forces a hair, + /// facial-hair or ear slot back to its group base under a helm. helmet_vis: HashMap, } -/// The worn geoset selectors the equipment branches of `0x477520` read (decisions 0074, 1864): per -/// bodyslot 2–9 (shirt, chest, belt, pants, boots, wrist, gloves, tabard) the item's -/// `geosetGroup[0..2]` (`None` = the slot is empty), plus the cloak's `geosetGroup[0]`, plus the -/// worn helm's `HelmetGeosetVisData` row pair (`[male, female]` — ItemDisplayInfo cols 12/13). -/// Default = naked. -/// -/// **Two of the eight branches are not fed by an ItemDisplayInfo column at all** and take their own -/// field here (decision 1864): B3 reads the ArmLower *composite grid*, and B6 reads a client flag. +/// What the equipment branches of `0x477520` read: each bodyslot 2–9's three `geosetGroup`s +/// (shirt, chest, belt, pants, boots, wrist, gloves, tabard), the cloak's first, and the helm's +/// `HelmetGeosetVisData` rows (`[male, female]`, ItemDisplayInfo cols 12/13). Default is naked. #[derive(Default, Clone, Copy)] pub struct EquipGeosets { pub bodyslots: [Option<[u32; 3]>; 8], pub cloak: Option, pub helm_vis: Option<[u32; 2]>, - /// B3's gate: does anything **other than the shirt** dress the forearm? — the six dwords - /// `cc+0x26c..cc+0x280` (ArmLower cells 1..6). Fill it from - /// [`forearm_dressed`](crate::forearm_dressed), which reads the one composite plan. + /// Branch B3's gate: something other than the shirt dresses the forearm (`cc+0x26c..cc+0x280`, + /// ArmLower cells 1–6); fill it from [`forearm_dressed`](crate::forearm_dressed). pub forearm_dressed: bool, - /// B6's gate: `[cc+0xc]`, "the guild registrar's tabard designer is open on this character" - /// (its setter `0x5e07fb`). Set while that window is up so the body wears a previewable tabard - /// with an **empty** tabard slot; nothing else in the client ever sets it. + /// Branch B6's gate `[cc+0xc]`: the tabard designer is open (setter `0x5e07fb`), so the body + /// wears a previewable tabard over an empty tabard slot. pub tabard_preview: bool, } impl CharacterGeosets { - /// The geoset IDs a character of this appearance renders — the opening block (the naked set) - /// plus **all eight** equipment branches B1–B8 of `0x477520` (decisions 0074, 1864 — - /// implemented from its *arithmetic*; the branch comments below name each one at its address). - /// A body submesh whose `skinSectionId` is in this set is drawn; all others hidden. Most - /// branches gate on an ItemDisplayInfo `geosetGroup` field being non-zero ("section present"); - /// B3 and B6 do not, and read [`EquipGeosets::forearm_dressed`] / [`EquipGeosets::tabard_preview`]. - /// - /// Returned **sorted and deduplicated**: the client toggles a per-submesh flag, where several - /// branches re-enable a base the opening loop already set, so a list needs normalising once. + /// The geosets (`skinSectionId`s) this appearance draws, sorted and deduplicated: the naked + /// set, then the eight equipment branches B1–B8 of `0x477520`. pub fn visible_geosets( &self, race: u8, @@ -72,26 +56,21 @@ impl CharacterGeosets { facial_hair: u8, equip: &EquipGeosets, ) -> Vec { + // The naked set: the region bases and geoset 0, the body. let mut set = REGION_BASES.to_vec(); - set.push(0); // geoset 0 (the body), enabled unconditionally - // Entry 0 (group 0 = hair): the chosen hairstyle's geoset, clamped ≥1 (`0x478540` - // returns `max(1, geosetId)`, so a bald style still resolves to geoset 1, the scalp). + set.push(0); + // Hair: `0x478540` returns `max(1, geosetId)`, so a bald style shows geoset 1, the scalp. if let Some(&g) = self.hair.get(&(race, sex, hair_style)) { set[0] = g.max(1) as u16; } - // Entries 1–3 (groups 1/2/3 = facial hair): DBC variation + the group base (`0x478660` adds - // 100/300/200 to fields gA/gB/gC respectively). Absent row ⇒ the default bases 101/201/301 stand. + // Facial hair: `0x478660` adds 100/300/200 to the row's fields; no row keeps the bases. if let Some(&[a, b, c]) = self.facial.get(&(race, sex, facial_hair)) { - set[1] = (a + 100) as u16; // gA → group 1 - set[3] = (b + 300) as u16; // gB → group 3 - set[2] = (c + 200) as u16; // gC → group 2 + set[1] = (a + 100) as u16; + set[3] = (b + 300) as u16; + set[2] = (c + 200) as u16; } - // A worn helm's vis row (`0x4799a0`): each of the 5 columns is a - // **race** bitmask; a set `1 << race` bit forces one region-base slot back to its group - // base, dropping the styled geoset the customization just selected — hair to the bare scalp - // (1), the facial groups to their bases, and the ears to **701** (over the 702 default no - // customization touches). Runs after the customization overwrites, exactly the client's - // order; the slots {0,1,2,3,7} are disjoint from every equipment branch below. + // A helm's vis row (`0x4799a0`) forces slots back to their bases, the ears to 701, after + // the customization; its slots {0,1,2,3,7} are disjoint from every equipment branch. if let Some(rows) = equip.helm_vis { let row = rows[usize::from(sex == 1)]; if let Some(masks) = self.helmet_vis.get(&row) { @@ -104,40 +83,32 @@ impl CharacterGeosets { } } } - // The equipment branches. `g(slot, sub)` = the slot's geosetGroup[sub], gated non-zero. - // EquipGeosets bodyslot indices: 0 shirt · 1 chest · 2 belt · 3 pants · 4 boots · 5 wrist · - // 6 gloves · 7 tabard. + // The equipment branches; `g(slot, sub)` is a non-zero geosetGroup, slots 0 shirt, 1 chest, + // 2 belt, 3 pants, 4 boots, 5 wrist, 6 gloves, 7 tabard. let g = |slot: usize, sub: usize| { equip.bodyslots[slot] .map(|groups| groups[sub]) .filter(|v| *v != 0) }; let disable = |set: &mut Vec, lo: u16, hi: u16| set.retain(|id| *id < lo || *id > hi); - // The robe bit: chest or pants geosetGroup[2] (B4's guard) — it also suppresses B5's tabard - // flap and B6's preview. B7 is NOT gated on it: that branch reads the chest's bit alone. + // The robe bit (chest or pants geosetGroup[2]) gates B4, B5 and B6; B7 reads the chest's. let robe = g(1, 2).or_else(|| g(3, 2)); - // B1/B2 (`0x477564`): gloves replace the glove group (401+v, own range disabled); else the - // chest's sleeves (the `else` arm, B2). + // B1/B2 (`0x477564`): gloves replace the glove group, else the chest's sleeves show. if let Some(v) = g(6, 0) { disable(&mut set, 401, 499); set.push(401 + v as u16); } else if let Some(v) = g(1, 0) { set.push(801 + v as u16); } - // B3 (`0x4775bd`): the shirt's sleeve cuff — only when nothing ELSE dresses the forearm. - // The reference's gate is the ArmLower equip row's cells 1..6, not "is a chest equipped": - // most chest pieces leave that tile empty and the cuff survives them, while a bracer or a - // glove takes it and the cuff goes (decision 1864; [`EquipGeosets::forearm_dressed`]). + // B3 (`0x4775bd`): the shirt's cuff, unless something else dresses the forearm. if !equip.forearm_dressed { if let Some(v) = g(0, 0) { set.push(801 + v as u16); } } - // B4 (`0x4775fb`): a robe hides boots/kneepads/pant-legs/trousers and shows its skirt - // (1301+v); else the boots replace the boot group; else the pants' kneepads. The bare `901` - // enables are the reference's own (`0x477648`, `0x477685`) and are no-ops here — the 16-base - // loop already carries 901 and nothing on this path disables group 9 — kept so the sequence - // stays the reference's rather than resting on that invariant. + // B4 (`0x4775fb`): a robe hides the leg groups and shows its skirt, else boots replace the + // boot group, else the pants' kneepads. The bare 901 enables are the reference's own + // (`0x477648`, `0x477685`), redundant with the bases. if let Some(v) = robe { disable(&mut set, 501, 599); disable(&mut set, 902, 999); @@ -145,10 +116,8 @@ impl CharacterGeosets { disable(&mut set, 1300, 1399); set.push(1301 + v as u16); } else if let Some(v) = g(4, 0) { - // `0x477639` disables the WHOLE boot group before enabling the boot's own geoset — the - // naked foot comes off with the boot on. 501 is a real submesh on 11 of the 18 character - // models (64 vertices on Human male), so leaving it on — as benilla did until decision - // 1864, on a misread of this same note — put a bare ankle inside every boot. + // `0x477639` disables the whole boot group first: the bare foot, 501, is a real submesh + // on 11 of the 18 character models. disable(&mut set, 501, 599); set.push(901); set.push(501 + v as u16); @@ -157,30 +126,22 @@ impl CharacterGeosets { } else { set.push(901); } - // B5 (`0x477706`): the tabard flap (robes hide it). + // B5 (`0x477706`): the tabard flap, hidden by a robe. if robe.is_none() { if let Some(v) = g(7, 0) { set.push(1201 + v as u16); } } - // B6 (`0x477752`): the tabard-designer preview. **No ItemDisplayInfo column feeds this - // branch** — its gate is the flag `[cc+0xc]`, set only while the guild registrar's tabard - // designer is open (setter `0x5e07fb`; the branch's old "helm-skirt" label was a mislabel, - // and no shipped head display carries a geoset group at all). It forces the tabard group on - // with an EMPTY tabard slot: 1201, plus 1202 when neither the chest nor the legs carries the - // robe bit. On shipped character models 1201 has no submesh (it is the group's "no tabard" - // base) and 1202 is the flap — so the observable effect is exactly "wear the tabard so the - // design can be previewed" (decision 1864). + // B6 (`0x477752`): the designer preview wears 1201, the group's empty base, and without a + // robe 1202, the flap. if equip.tabard_preview { - set.push(1201); // already on from the opening loop; the reference enables it anyway + set.push(1201); // redundant with the bases; the reference enables it too if robe.is_none() { set.push(1202); } } - // B7 (`0x477799`): the shirt's doublet + the pants' leg geoset (the recorded 1102 base — not - // 1101). Skipped ENTIRELY when the CHEST is a robe (`0x4777a4` — the legs' own robe bit does - // NOT gate here, unlike B4/B5/B6) or when a TABARD is worn (`0x4777b8`, bodyslot 9 sub 0; - // the guard is the tabard, not the glove). Decision 1864. + // B7 (`0x477799`): the shirt's doublet and the pants' legs (base 1102, not 1101); skipped + // under a chest robe (`0x4777a4`, not a legs robe) or a tabard (`0x4777b8`). if g(1, 2).is_none() && g(7, 0).is_none() { if let Some(v) = g(0, 1) { set.push(1001 + v as u16); @@ -189,36 +150,20 @@ impl CharacterGeosets { set.push(1102 + v as u16); } } - // B8 (`0x47780a`): a cloak replaces the cloak group (1501+v, range disabled). + // B8 (`0x47780a`): a cloak replaces the cloak group. if let Some(v) = equip.cloak.filter(|v| *v != 0) { disable(&mut set, 1500, 1599); set.push(1501 + v as u16); } - // The client's `0x7110d0` writes a flag per submesh, so enabling an already-enabled geoset - // is idempotent there; ours is a list, and several branches re-enable a base the opening - // loop already added. Normalise once at the end so the result is a canonical set. + // The client sets a flag per submesh (`0x7110d0`), so a repeated enable is a no-op there. set.sort_unstable(); set.dedup(); set } - /// Load both customization DBCs from the patch chain. - /// - /// **A duplicated `(race, sex, variation)` key resolves to the FIRST row** (decision 0682). Both - /// consumers are *linear scans* of the loaded table — `0x478540` for the hair geoset, `0x478740` - /// for the facial-hair record (`0x478540` is a loaded-DBC linear scan, not a pool) — so the - /// earliest matching record is the one the reference returns, where a last-write - /// `HashMap::insert` would take the latest. - /// - /// It matters exactly once in the shipped data, and it was B11: `CharHairGeosets` carries **four** - /// rows for **(race 9 goblin, sex 0, variation 0)** — ids 241–244, geosets 1 / 2 / 1 / 2, the only - /// duplicated key in the file (243/244 read as the goblin-*female* pair with a zeroed sex column, - /// which is why goblin females, having no row at all, were always right). Geoset 1 is the bare - /// scalp; geoset 2 is that scalp **plus** a 38-vertex topknot whose texture slot is `Monster1` — - /// a slot no character-model goblin display fills, so it drew flat white (decision 0157). All 314 - /// character-model goblin displays select variation 0, so last-write put a white tuft on every one - /// of them. `CharacterFacialHairStyles` has no duplicate keys today; it takes the same rule - /// because the same scan law governs it. + /// Load the customization DBCs from the patch chain. A repeated `(race, sex, variation)` key + /// takes its first row, as the client's linear scans (`0x478540`, `0x478740`) do: the shipped + /// CharHairGeosets lists goblin male variation 0 four times (geosets 1, 2, 1, 2). pub fn load(chain: &mut Chain) -> Result { let hair = { let bytes = chain @@ -231,7 +176,7 @@ impl CharacterGeosets { if let (Some(race), Some(sex), Some(var), Some(geoset)) = (u32_at(r, 1), u32_at(r, 2), u32_at(r, 3), u32_at(r, 4)) { - // FIRST row wins — see the duplicate-key note on [`CharacterGeosets::load`]. + // The first row wins. m.entry((race as u8, sex as u8, var as u8)) .or_insert(geoset); } @@ -249,7 +194,7 @@ impl CharacterGeosets { )?; let mut m = HashMap::with_capacity(rs.records().len()); for r in rs.records() { - // fields: RaceID, SexID, VariationID(facialHair), 3×unused, gA, gB, gC (no ID column). + // fields: RaceID, SexID, VariationID(facialHair), 3×unused, Geoset100/300/200. if let (Some(race), Some(sex), Some(var)) = (u32_at(r, 0), u32_at(r, 1), u32_at(r, 2)) { @@ -258,15 +203,13 @@ impl CharacterGeosets { u32_at(r, 7).unwrap_or(0), u32_at(r, 8).unwrap_or(0), ]; - // FIRST row wins — see the duplicate-key note on [`CharacterGeosets::load`]. + // The first row wins. m.entry((race as u8, sex as u8, var as u8)).or_insert(g); } } m }; - // HelmetGeosetVisData — the helm hide-masks (read by `0x4799a0`). Soft-optional: a - // missing/undecodable table just means helms never hide hair (the pre-helm-vis behavior), - // like the other soft-fails. + // HelmetGeosetVisData, the helm hide-masks: without the file, helms hide nothing. let helmet_vis = match chain.read_file(HELMET_GEOSET_VIS) { Ok(bytes) => { let rs = parse(&bytes, helmet_vis_schema(), "HelmetGeosetVisData")?; @@ -288,7 +231,7 @@ impl CharacterGeosets { } } -/// CharHairGeosets.dbc — 6 fields in build 5875 (verified against the file header). +/// CharHairGeosets.dbc: 6 fields. pub(crate) fn char_hair_geosets_schema() -> Schema { let mut s = Schema::new("CharHairGeosets"); for (name, ty) in [ @@ -304,9 +247,8 @@ pub(crate) fn char_hair_geosets_schema() -> Schema { s } -/// HelmetGeosetVisData.dbc — 6 fields in build 5875 (verified: the loader `0x546f00` asserts -/// fieldCount 6 / recordSize 0x18; the 5 mask columns are **race** bitmasks, as `0x4799a0` tests -/// them). +/// HelmetGeosetVisData.dbc: 6 fields, 0x18-byte records (loader `0x546f00`); the five masks are +/// race bitmasks (`0x4799a0`). pub(crate) fn helmet_vis_schema() -> Schema { let mut s = Schema::new("HelmetGeosetVisData"); for (name, ty) in [ @@ -322,7 +264,7 @@ pub(crate) fn helmet_vis_schema() -> Schema { s } -/// CharacterFacialHairStyles.dbc — 9 fields in build 5875 (verified); no ID column (keyed race/sex/var). +/// CharacterFacialHairStyles.dbc: 9 fields, no ID column. pub(crate) fn char_facial_hair_schema() -> Schema { let mut s = Schema::new("CharacterFacialHairStyles"); for (name, ty) in [ @@ -332,9 +274,9 @@ pub(crate) fn char_facial_hair_schema() -> Schema { ("Unused3", FieldType::UInt32), ("Unused4", FieldType::UInt32), ("Unused5", FieldType::UInt32), - ("Geoset100", FieldType::UInt32), // gA → group 1 (+100) - ("Geoset300", FieldType::UInt32), // gB → group 3 (+300) - ("Geoset200", FieldType::UInt32), // gC → group 2 (+200) + ("Geoset100", FieldType::UInt32), + ("Geoset300", FieldType::UInt32), + ("Geoset200", FieldType::UInt32), ] { s.add_field(SchemaField::new(name, ty)); } @@ -345,8 +287,7 @@ pub(crate) fn char_facial_hair_schema() -> Schema { mod tests { use super::*; - /// The equipment geoset branches (decisions 0074/1864, from the arithmetic of `0x477520`): - /// gloves/boots/robe/cloak **replace** their groups, and a robe suppresses the tabard branch. + /// Gloves, boots, a robe and a cloak replace their groups; a robe hides the tabard flap. #[test] fn equipment_geoset_branches() { let cg = CharacterGeosets { @@ -357,21 +298,18 @@ mod tests { let naked = cg.visible_geosets(1, 0, 0, 0, &EquipGeosets::default()); assert!(naked.contains(&401) && naked.contains(&1101) && naked.contains(&1501)); - // Gloves v=1: the glove group is replaced (401 out, 402 in). let mut eq = EquipGeosets::default(); eq.bodyslots[6] = Some([1, 0, 0]); let set = cg.visible_geosets(1, 0, 0, 0, &eq); assert!(set.contains(&402) && !set.contains(&401)); - // Boots v=2: the boot group is replaced — 503 in, the naked foot 501 OUT (`0x477639` - // disables [501,599] first). benilla stacked them until decision 1864. + // Boots take the bare foot, 501, off (`0x477639`). let mut eq = EquipGeosets::default(); eq.bodyslots[4] = Some([2, 0, 0]); let set = cg.visible_geosets(1, 0, 0, 0, &eq); assert!(set.contains(&503) && !set.contains(&501)); - // A robe (chest group[2]=1) shows its skirt (1302) and hides boots/pant-legs/trousers — - // including a worn tabard's flap and the pants' own branches. + // A robe shows its skirt and hides the leg groups and the tabard flap. let mut eq = EquipGeosets::default(); eq.bodyslots[1] = Some([1, 0, 1]); eq.bodyslots[3] = Some([2, 1, 0]); @@ -387,7 +325,6 @@ mod tests { assert!(!set.contains(&1202), "tabard suppressed under a robe"); assert!(set.contains(&802), "the chest's sleeves still show"); - // Cloak v=4: the cloak group is replaced (1501 out, 1505 in). let eq = EquipGeosets { cloak: Some(4), ..Default::default() @@ -396,10 +333,7 @@ mod tests { assert!(set.contains(&1505) && !set.contains(&1501)); } - /// **B3's real gate** (`0x4775bd`, decision 1864): the shirt's sleeve cuff survives or dies by - /// what dresses the *forearm tile*, not by whether a chest is equipped. Both directions matter — - /// 533 of the 637 shipped chest displays leave `ArmLowerTexture` empty, and 566 of 570 wrist - /// displays fill it — and benilla had both backwards until this record. + /// Branch B3 (`0x4775bd`) keys the shirt's cuff on the forearm tile, not the chest slot. #[test] fn b3_shirt_sleeve_follows_the_forearm_tile_not_the_chest_slot() { let cg = CharacterGeosets { @@ -420,9 +354,7 @@ mod tests { !cg.visible_geosets(1, 0, 0, 0, &shirt(true)).contains(&802), "something else paints the forearm: the cuff goes" ); - // And a chest in the slot is NOT the gate: a chest whose ArmLower art is empty leaves the - // tile free, and the cuff stays. (The chest's own sleeve geoset is B1's `else`, group 8 too, - // so give this one none.) + // A chest with no sleeve geoset (B2 would add one to group 8 too) leaves the cuff. let mut eq = shirt(false); eq.bodyslots[1] = Some([0, 0, 0]); assert!( @@ -431,10 +363,7 @@ mod tests { ); } - /// **B6 — the tabard-designer preview** (`0x477752`; decision 1864). The one - /// branch of the eight with no ItemDisplayInfo column behind it: its gate is `[cc+0xc]`, "the - /// guild registrar's tabard designer is open", and it wears the tabard flap (1202) with an - /// **empty** tabard slot — unless a robe is on, which suppresses it exactly as it suppresses B5. + /// Branch B6 (`0x477752`): the open designer wears the flap over an empty slot, unless robed. #[test] fn b6_tabard_preview_wears_the_flap_with_an_empty_slot() { let cg = CharacterGeosets { @@ -442,10 +371,8 @@ mod tests { facial: HashMap::new(), helmet_vis: HashMap::new(), }; - // Off: an empty tabard slot shows no flap. let set = cg.visible_geosets(1, 0, 0, 0, &EquipGeosets::default()); assert!(!set.contains(&1202), "no tabard, no designer: no flap"); - // On: the flap appears with nothing in the slot. let preview = EquipGeosets { tabard_preview: true, ..Default::default() @@ -453,7 +380,6 @@ mod tests { let set = cg.visible_geosets(1, 0, 0, 0, &preview); assert!(set.contains(&1202), "the designer forces the flap on"); assert!(set.contains(&1201), "and the group base with it"); - // A robe suppresses it, from either the chest's or the legs' robe bit. for slot in [1usize, 3] { let mut eq = preview; eq.bodyslots[slot] = Some([0, 0, 1]); @@ -464,9 +390,7 @@ mod tests { } } - /// **B7's two guards** (`0x4777a4` / `0x4777b8`, decision 1864): the shirt-doublet + pant-leg arm - /// is skipped whole when the CHEST is a robe or when a TABARD is worn — and, unlike B4/B5/B6, - /// the *legs'* own robe bit does not gate it. + /// Branch B7 is off under a chest robe or a tabard (`0x4777a4`, `0x4777b8`), not a legs robe. #[test] fn b7_is_gated_by_the_chest_robe_and_the_tabard() { let cg = CharacterGeosets { @@ -494,7 +418,6 @@ mod tests { "ungated: both on" ); - // A worn tabard skips the whole branch (`0x4777b8` guards on the tabard, not the glove). let mut eq = base; eq.bodyslots[7] = Some([1, 0, 0]); let set = cg.visible_geosets(1, 0, 0, 0, &eq); @@ -504,7 +427,6 @@ mod tests { "a tabard skips B7 whole" ); - // A chest robe skips it too — and takes the doublet with it, which the pre-1864 code left on. let mut eq = base; eq.bodyslots[1] = Some([0, 0, 1]); let set = cg.visible_geosets(1, 0, 0, 0, &eq); @@ -513,8 +435,7 @@ mod tests { "a chest robe skips B7 whole" ); - // The LEGS' robe bit does not: B4-long still hides the pant-leg group, but the doublet — - // enabled after that disable — stays on. This is the asymmetry the old `robe` shortcut lost. + // A legs robe: B4 hides the pant-leg group, then B7 enables its geosets anyway. let mut eq = base; eq.bodyslots[3] = Some([1, 0, 1]); let set = cg.visible_geosets(1, 0, 0, 0, &eq); @@ -525,10 +446,7 @@ mod tests { ); } - /// The helm-vis force (`0x4799a0`): each vis column is a **race** bitmask; a set - /// `1 << race` bit forces the slot back to its group base — hair to the bare scalp (1), facial - /// groups to 101/201/301, ears to 701 — dropping the styled geosets. The row is sex-selected - /// (ItemDisplayInfo col 12 male / 13 female); a mask without our race bit leaves the style. + /// The helm vis row (`0x4799a0`) is picked by sex, and a race bit forces the group bases. #[test] fn helm_vis_forces_group_bases_by_race() { let mut hair = HashMap::new(); @@ -536,8 +454,8 @@ mod tests { let mut facial = HashMap::new(); facial.insert((1, 0, 1), [2u32, 3, 4]); // styled facial → 102/303/204 let mut helmet_vis = HashMap::new(); - helmet_vis.insert(368, [u32::MAX; 5]); // hide-everything row (real id, dumped) - helmet_vis.insert(245, [0u32; 5]); // hide-nothing row (real id, dumped) + helmet_vis.insert(368, [u32::MAX; 5]); // a shipped row id, hiding everything + helmet_vis.insert(245, [0u32; 5]); // a shipped row id, hiding nothing let cg = CharacterGeosets { hair, facial, @@ -547,14 +465,14 @@ mod tests { helm_vis: Some([368, 245]), ..Default::default() }; - // Male (sex 0) picks row 368: everything forced to base. + // Male picks row 368. let set = cg.visible_geosets(1, 0, 1, 1, &helm); assert!(set.contains(&1) && !set.contains(&5), "hair → bare scalp"); assert!(set.contains(&101) && !set.contains(&102)); assert!(set.contains(&201) && !set.contains(&204)); assert!(set.contains(&301) && !set.contains(&303)); assert!(set.contains(&701) && !set.contains(&702), "ears → 701"); - // The female column of this pair is the hide-nothing row: styles survive. + // Female picks row 245. let mut hair = HashMap::new(); hair.insert((1, 1, 1), 5u32); let cg2 = CharacterGeosets { @@ -583,9 +501,7 @@ mod tests { assert!(set.contains(&5), "race bit miss → style kept"); } - /// The naked selection for a known Human-male appearance (rows dumped from the build-5875 files): - /// hairStyle 1 → GeosetID 2; facialHair 1 → gA/gB/gC = 1/2/1. So the set carries the body (0), the - /// hair geoset (2), the three facial geosets (101 / 201 / 302), and the default group bases. + /// A Human male's naked set, shipped rows: hairStyle 1 → geoset 2, facialHair 1 → 1/2/1. #[test] fn naked_human_male_selection() { let mut hair = HashMap::new(); @@ -604,13 +520,12 @@ mod tests { assert!(set.contains(&101), "facial group 1: gA 1 + 100"); assert!(set.contains(&302), "facial group 3: gB 2 + 300"); assert!(set.contains(&201), "facial group 2: gC 1 + 200"); - // Default equipment-group bases (entries 4–15) stand for a naked body. assert!(set.contains(&401) && set.contains(&702) && set.contains(&1501)); - // A non-selected hair variant is hidden (geoset 5 is hairStyle 4, not chosen). + // Geoset 5 is hairStyle 4, not chosen. assert!(!set.contains(&5), "unselected hair variant hidden"); } - /// A bald hairstyle (GeosetID 0) still resolves to geoset 1 (the scalp), never 0-the-body-collision. + /// A bald style (GeosetID 0) resolves to geoset 1, the scalp, not 0, the body. #[test] fn bald_resolves_to_scalp_geoset_one() { let mut hair = HashMap::new(); @@ -624,20 +539,14 @@ mod tests { assert!(set.contains(&1), "max(1, 0) = scalp geoset 1"); } - /// **B11, against the shipped DBC** (decision 0682): `CharHairGeosets` carries four rows for the - /// goblin male's only hairstyle, and the reference's linear scan takes the first — geoset **1**, - /// the bare scalp. Last-write took id 244's geoset **2**, which adds an untextured topknot. - /// - /// Three things are pinned here because each on its own could rot: that the shipped file really - /// does duplicate that key (the defect only exists if it does), that we resolve it to 1, and that - /// **no other key in the table is duplicated** — which is what bounds this change to goblins. + /// The shipped CharHairGeosets repeats only the goblin male's one style, and the first row, + /// geoset 1, the bare scalp, wins; geoset 2 adds an untextured topknot. #[test] fn goblin_male_hair_takes_the_first_of_four_duplicate_rows() { let data = crate::wow_data_or_skip!(); let mut chain = crate::open_chain(&data).expect("open chain"); - // The file's own rows, read independently of the loader, so the test states the premise it - // rests on rather than assuming it. + // The file's rows, read without the loader. let bytes = chain.read_file(CHAR_HAIR_GEOSETS).expect("CharHairGeosets"); let rs = parse(&bytes, char_hair_geosets_schema(), "CharHairGeosets").expect("parse"); let mut keys: HashMap<(u32, u32, u32), Vec> = HashMap::new(); @@ -660,7 +569,6 @@ mod tests { "the goblin-male block is the duplicate, in file order" ); - // And the loader resolves it the reference's way. let cg = CharacterGeosets::load(&mut chain).expect("load customization tables"); let set = cg.visible_geosets(9, 0, 0, 0, &EquipGeosets::default()); assert!( @@ -671,9 +579,7 @@ mod tests { !set.contains(&2), "goblin male does NOT show geoset 2 — the white topknot of B11" ); - // The goblin female has no row at all (race 9 ships only sex-0 rows), so she falls to the - // region base, which is the same scalp. She was never affected; assert it so a future - // "fix" to the sex column has to notice her. + // Race 9 ships only sex-0 rows, so the goblin female keeps the region base, the same scalp. let f = cg.visible_geosets(9, 1, 0, 0, &EquipGeosets::default()); assert!(f.contains(&1) && !f.contains(&2), "goblin female unchanged"); } diff --git a/crates/benilla-formats/src/characters/mod.rs b/crates/benilla-formats/src/characters/mod.rs index ca017ff3..4f5ee29c 100644 --- a/crates/benilla-formats/src/characters/mod.rs +++ b/crates/benilla-formats/src/characters/mod.rs @@ -1,24 +1,10 @@ -//! Character customization render data (decision 0041, Milestone B) — what a player's appearance -//! selects: which **geosets** the body shows ([`CharacterGeosets`]) and which **skin textures** the -//! body wears ([`CharSections`]). +//! Character appearance render data: the geosets an appearance shows ([`CharacterGeosets`], the +//! client's geoset dispatch `0x477520`) and the body skin it wears: body-skin batches +//! (`M2TextureType::Other(1)`) have no texture of their own, and [`CharSections::composite_body`] +//! builds one on the 256² partition `0x475c50` and the section→cell map `0x4782e0`. //! -//! **Geosets.** A character model contains *every* hairstyle, facial-hair piece, and body-option geoset; -//! the real client renders only the selected ones. The selection is the compositor's geoset dispatch -//! `0x477520`. For a character with **no equipment** the 8 per-item -//! branches all no-op (their `ItemDisplayInfo` records are null), leaving only the unconditional opening -//! block: disable every geoset, enable geoset 0, then enable the 16 region-base entries of `cc+0x144` — -//! entries 0–3 overwritten by the customization DBCs (the chosen hair + 3 facial-hair geosets), entries -//! 4–15 the default group bases. We render an M2 submesh iff its `skinSectionId` is in that set. -//! -//! **Skin textures.** A character body's `M2TextureType::Other(1)` (body skin) batches have no embedded -//! texture — the client supplies a runtime composite keyed on the appearance ([`CharSections::composite_body`], -//! decision 0044). The **base skin** (`sectionType 0`) is a single full 256² body-layout BLP per (race, -//! sex, skinColor); the face / facial-hair / hair / underwear overlays (`sectionTypes 1–4`) are region -//! BLPs blended on top at fixed atlas tiles (the static partition `0x475c50` writes + the -//! section→cell map of `0x4782e0`). -//! -//! DBC field maps from the client's record readers (CharSections `0x575540`, the geoset tables -//! `0x5753b0`/`0x575a80`); field counts + row semantics cross-checked against the build-5875 files. +//! DBC layouts follow the client's record readers: CharSections `0x575540`, the geoset tables +//! `0x5753b0`/`0x575a80`. mod customization; mod geosets; @@ -31,7 +17,6 @@ pub use sections::{ BlitSource, CharSections, EmblemLayer, EquipBlit, GuildEmblem, }; -// The appearance tables' schemas, for the `benilla-extract dbc` CSV dump (`crate::schema_for`) — -// the same constructors the loaders above use, never a second transcription of the same layout. +// The loaders' own schemas, for the `benilla-extract dbc` CSV dump (`crate::schema_for`). pub(crate) use geosets::{char_facial_hair_schema, char_hair_geosets_schema, helmet_vis_schema}; pub(crate) use sections::char_sections_schema; diff --git a/crates/benilla-formats/src/characters/sections.rs b/crates/benilla-formats/src/characters/sections.rs index 445beb1c..83fee743 100644 --- a/crates/benilla-formats/src/characters/sections.rs +++ b/crates/benilla-formats/src/characters/sections.rs @@ -9,64 +9,37 @@ use crate::{read_texture_mip_chain, BlpMipChain, Chain}; const CHAR_SECTIONS: &str = "DBFilesClient\\CharSections.dbc"; -/// CharSections `sectionType` values (facial hair is 2 at `0x478660`, hair 3 at `0x4784c0`): the -/// layer a row supplies. `skin` is the full base atlas; the rest are region overlays the body -/// composite blends on top (decision 0044). +/// CharSections `sectionType` values (facial hair 2 at `0x478660`, hair 3 at `0x4784c0`). const SECTION_SKIN: u8 = 0; const SECTION_FACE: u8 = 1; const SECTION_FACIAL_HAIR: u8 = 2; const SECTION_HAIR: u8 = 3; const SECTION_UNDERWEAR: u8 = 4; -/// The hair variation the client's type-6 binder substitutes when the selected style resolves no -/// texture — a **literal 1** in the binary at two of its three call sites (`0x478445`, `0x4786f2`), -/// not a search. See [`CharSections::hair_mesh_texture`]; decision 0536. +/// The hair variation the type-6 binder falls back to: a literal 1 at `0x478445` and `0x4786f2`. const HAIR_SUBSTITUTE_VARIATION: u8 = 1; -/// An atlas rect `(x, y, w, h)` in pixels — a composite destination tile. +/// An atlas rect `(x, y, w, h)` in pixels. type Tile = (u32, u32, u32, u32); -/// The body-atlas tiles the head + underwear overlays composite into — four of the 10 rects of the -/// static 256² partition `0x475c50` writes. g8/g9 are the head strip (left column, Y 160–256: g8 -/// the 32-tall upper band, g9 the 64-tall lower band); g3 (torso upper) and g5 (pelvis) are the -/// right column's Y 0–64 and Y 96–160 — the two tiles the underwear section dresses, one per -/// texture column (the group→cell map: the group-3 handler `0x4772f0` takes underwear cell -/// `cc+0x20c`, the group-5 handler `0x4773a0` takes `cc+0x208`, and `0x478790` builds those two -/// from the sectionType-4 row's `TextureName[1]`/`[0]`). +/// The head strip of the 256² partition `0x475c50` writes: g8 the upper band, g9 the lower. const TILE_G8: Tile = (0, 160, 128, 32); const TILE_G9: Tile = (0, 192, 128, 64); -// The underwear's two tiles ARE two of the equipment tiles — the composite reaches them through -// `EQUIP_TILES` so there is one transcription of the rects, and these names exist for the tests, -// which read far better naming the group than indexing the array. +// The underwear's torso and pelvis tiles are equipment tiles g3 and g5, named for the tests. #[cfg(test)] const TILE_G3: Tile = EQUIP_TILES[3]; #[cfg(test)] const TILE_G5: Tile = EQUIP_TILES[5]; -/// The underwear section's two blits: `(textureColumn, equipLayer, columnsTested)` — which -/// `TextureName` column dresses which equipment tile, and **how far into that tile's equipment -/// columns the client looks before it draws at all** (read from the two handlers below). -/// -/// The underwear is not an under-layer — it is the tile's **fallback**. `0x4772f0` (TorsoUpper) -/// tests `cc+0x2e8/0x2ec/0x2f0` = columns 0–2 and `0x4773a0` (LegUpper) tests `cc+0x368/0x36c` = -/// columns 0–1, each `jne` straight past the underwear blit; only with every tested cell null does -/// the handler blit `cc+0x20c`/`cc+0x208`. So a shirt, a chest/robe or a guild tabard's background -/// hides the bra, and legs or a robe hide the panties — but the tested set is a strict **prefix**, -/// and the columns past it are not consulted: **a belt never hides the panties, and a plain tabard -/// never hides the bra**, even though both repaint their tile afterwards. -/// -/// The base-skin blit is unconditional in both branches, so a suppressed tile shows *skin*, not a -/// hole — and the underwear itself is a REPLACE (alphaDepth 0, an opaque full-tile paste), never a -/// blend. +/// The underwear's blits, `(TextureName column, layer, cells tested)`: the tile's fallback, drawn +/// by `0x4772f0` (TorsoUpper, `cc+0x20c`) only with cells 0–2 empty and by `0x4773a0` (LegUpper, +/// `cc+0x208`) only with cells 0–1 empty; `0x478790` fills both from the sectionType-4 row. const UNDERWEAR_TILES: [(usize, usize, i8); 2] = [ - (0, 5, 2), // TextureName[0] → LegUpper: {Legs, Chest/Robe} tested; the belt (col 2) is not - (1, 3, 3), // TextureName[1] → TorsoUpper: {Shirt, Chest/Robe, guild-tabard Background} tested + (0, 5, 2), // pelvis into LegUpper: pants and chest tested, not the belt + (1, 3, 3), // torso into TorsoUpper: shirt, chest and a guild tabard's background tested ]; -/// The eight equipment tiles g0–g7 (written by `0x475c50`), in **layer order** — the load-bearing -/// identity of decision 0074: ItemDisplayInfo texture column *i* = compositor layer *i* = tile -/// g*i*. Note g3 == [`TILE_G3`] and g5 == [`TILE_G5`] — equipment TorsoUpper/LegUpper share the -/// underwear's two tiles; underwear blits first, so it sits under. +/// The equipment tiles g0–g7 of `0x475c50`: ItemDisplayInfo texture column i is layer i, tile gi. const EQUIP_TILES: [Tile; 8] = [ (0, 0, 128, 64), // g0 ArmUpper (0, 64, 128, 64), // g1 ArmLower @@ -78,8 +51,7 @@ const EQUIP_TILES: [Tile; 8] = [ (128, 224, 128, 32), // g7 Foot ]; -/// The `Item\TextureComponents\` region directory per layer/column (decision 0074; empirically -/// suffix-matched — the on-disk dirs are exactly these eight). +/// The `Item\TextureComponents\` directory per layer, the eight the data ships. const EQUIP_TEX_DIRS: [&str; 8] = [ "ArmUpperTexture", "ArmLowerTexture", @@ -91,17 +63,9 @@ const EQUIP_TEX_DIRS: [&str; 8] = [ "FootTexture", ]; -/// The `[0x803bf8]` bodyslot×layer table, rows = bodyslots 2–9 (shirt, chest, belt, -/// pants, boots, wrist, gloves, tabard), columns = layers 0–7: the **cell** within the layer's row -/// that this slot's contribution occupies, `-1` = this slot never touches the layer. -/// -/// It is a **default, not a fixed priority** — that was decision 0074's mistake and B326/B327's -/// cause. `0x478ad0` tests the cell for `-1` and for a non-empty texture name, then hands the slot to -/// the layer's own **chooser** `[0xb42424 + layer*4]`, and two of the eight choosers overrule the -/// table from the item's `geosetGroup` (see [`equip_column`]). Layers 0/2/3/4/5/7 take it verbatim. -/// -/// The row is 16 cells and holds **one record per cell**; the composite handler fans out over it by -/// ascending cell index, so a higher cell is blitted later and covers a lower one. +/// The `[0x803bf8]` table: per bodyslot 2–9 and layer 0–7, the default cell of the slot's art, +/// `-1` for never; two layer choosers (`[0xb42424 + layer*4]`) overrule it ([`equip_column`]). +/// A row holds one record per cell, blitted by ascending cell. const EQUIP_LAYER_COLUMN: [[i8; 8]; 8] = [ [0, 0, -1, 0, 0, -1, -1, -1], // shirt [1, 1, -1, 1, 1, 1, 1, -1], // chest (robes reach the legs) @@ -110,101 +74,55 @@ const EQUIP_LAYER_COLUMN: [[i8; 8]; 8] = [ [-1, -1, -1, -1, -1, -1, 2, 0], // boots [-1, 2, -1, -1, -1, -1, -1, -1], // wrist [-1, 3, 0, -1, -1, -1, -1, -1], // gloves - [-1, -1, -1, 4, 4, -1, -1, -1], // tabard (TorsoUpper is cell 4, not 3 — 0074 mis-read it) + [-1, -1, -1, 4, 4, -1, -1, -1], // tabard ]; -/// The worn-slot indices the two overruling choosers name, in `equipment` order (bodyslot − 2). +/// Worn-slot indices in `equipment` order, bodyslot − 2. const SLOT_CHEST: usize = 1; const SLOT_PANTS: usize = 3; const SLOT_BOOTS: usize = 4; const SLOT_GLOVES: usize = 6; -/// The tabard's worn-slot index (bodyslot 9 − 2) — the slot whose display decides whether the -/// guild-emblem layers install at all ([`ItemDisplay::takes_guild_emblem`]). +/// The tabard's worn slot, whose display decides whether the guild emblem installs. const SLOT_TABARD: usize = 7; /// Where the guild tabard's art lives. const GUILD_EMBLEM_DIR: &str = "Textures\\GuildEmblems"; -/// The two compositor layers the guild tabard paints, and the filename half each names: TorsoUpper -/// takes the `_TU_` files, TorsoLower the `_TL_` ones (`0x47a610` stores the `_TU_` -/// trio into group 3's cells and the `_TL_` twins into group 4's). No other layer is touched — the -/// emblem is a torso garment, and the arms/legs keep whatever the rest of the outfit painted. +/// The guild tabard's layers and name halves (`0x47a610`): TorsoUpper `_TU_`, TorsoLower `_TL_`. const EMBLEM_LAYERS: [(usize, &str); 2] = [(3, "TU"), (4, "TL")]; -/// The filename half layer `layer` reads its guild-emblem art from, or `None` for a layer the -/// tabard never reaches. fn emblem_half(layer: usize) -> Option<&'static str> { EMBLEM_LAYERS .iter() .find_map(|&(l, half)| (l == layer).then_some(half)) } -/// CharSections texture lookup: (race, sex, sectionType, variation, colorIndex) → that row's up-to-3 -/// `TextureName` columns (empty strings for absent columns). Feeds both the base body skin -/// (`sectionType 0`) and the head/pelvis region overlays the body composite blends on top (decision 0044). +/// CharSections: (race, sex, sectionType, variation, colorIndex) → the row's `TextureName`s. pub struct CharSections { sections: HashMap<(u8, u8, u8, u8, u8), [String; 3]>, } impl CharSections { - /// The base body-skin BLP for an appearance — CharSections `sectionType 0`, the single full 256² - /// body-layout texture keyed by skinColor. `None` if the row is absent. + /// The 256² base body skin for a skin colour (`sectionType 0`). pub fn skin_texture(&self, race: u8, sex: u8, skin_color: u8) -> Option<&str> { self.tex(race, sex, SECTION_SKIN, 0, skin_color, 0) } - /// The standalone **extra-skin** BLP for an appearance — CharSections `sectionType 0`, - /// `TextureName[1]`, keyed by skinColor (`…Skin00_NN_Extra.blp`). The texture the client binds to a - /// character body's M2 texture type 8 batches, loaded plain — never composited (the client's extra - /// loader is a bare TextureCreate). Only fur races author the column: tauren bind their head/leg fur - /// to it; every other race's rows leave it empty (and their models carry no type-8 batch). + /// The extra skin (`sectionType 0`, `TextureName[1]`), bound uncomposited to M2 texture type 8; + /// only the fur races author it (tauren). pub fn skin_extra_texture(&self, race: u8, sex: u8, skin_color: u8) -> Option<&str> { self.tex(race, sex, SECTION_SKIN, 0, skin_color, 1) } - /// The hair-**mesh** texture for an appearance — CharSections `sectionType 3`, `TextureName[0]`, - /// keyed by hairStyle (variation) + hairColor. The single BLP the client binds to the hair geometry's - /// M2 texture type 6 (decision 0045); the colour is baked into the chosen file, not a runtime tint. - /// `None` when the row/column is empty (e.g. a bald style, variation 0 — its columns are blank). - /// (`TextureName[1]/[2]` of the same row are the scalp-on-skin overlays the body composite blends into - /// the head atlas — those go through [`Self::composite_body`], not here.) + /// The hair row's `TextureName[0]`, colour baked in; meshes use [`Self::hair_mesh_texture`]. pub fn hair_texture(&self, race: u8, sex: u8, hair_style: u8, hair_color: u8) -> Option<&str> { self.tex(race, sex, SECTION_HAIR, hair_style, hair_color, 0) } - /// The texture to bind to a character body's M2 **type 6** batches — the hair sheet, which on - /// several races dresses the *facial* hair geometry (beard/mustache geosets) as well as the - /// scalp hair. Prefer this over [`Self::hair_texture`] anywhere a mesh is being textured; - /// `hair_texture` is the raw row accessor, and a bald row is genuinely blank. - /// - /// **The mechanism** (decision 0536). The client has no fallback *lookup*. - /// `0x478220(cc, variationIdx)` is the sole type-6 binder, it always reads `TextureName[0]` - /// (never column-indexed), and an **empty name is a no-op that leaves the slot untouched** - /// (`0x47827d cmp BYTE PTR [ecx],0x0` → `je 0x4782d8`) — not a null bind. Three sites call it, - /// in build order skin → hairStyle → facialHair: - /// - /// 1. `0x478445` — variation **literal 1**, gated `sex==0 && hairStyle==0 && ChrRaces.Flags & 8` - /// 2. `0x478450` — variation `hairStyle`, unconditional - /// 3. `0x4786f2` — variation **literal 1**, gated on the selected beard having geoset geometry - /// and the hairStyle row missing a detail column; no sex/race/hairStyle test - /// - /// Because no site ever clears the slot, the result is simply the **last site that resolved a - /// non-empty name** — which reduces to: take the hairStyle row, and when it is blank take - /// **variation 1** at the same colour. That is the fixpoint of the client's incremental apply - /// pipeline, and it is what this implements; we resolve a whole look at once rather than - /// re-applying per dial, so emulating the three sites separately would add machinery without - /// changing an output. - /// - /// The literal 1 is load-bearing where a race authors several sheets per colour: Human male - /// resolves `Hair03_.blp` specifically, not an arbitrary non-blank row. - /// - /// **Where this can diverge** (verified inert on 1.12.1 data, kept honest rather than dropped): - /// site 3's trigger is broader than "the hairStyle row is blank" — it also fires for Dwarf M - /// {0,2,4,9}, Scourge M {1,3,4,8} and Orc F {7}, i.e. 14 of 145 variation groups rather than the - /// 5 blank ones. In every one of those the variation-1 sheet is byte-identical to what the real - /// hairStyle already bound, so the reduction above is exact on the shipped build. It would only - /// part company with the client on data where a site-3 group's variation-1 sheet differs from - /// its own — which build 5875 does not contain. + /// The sheet for M2 texture type 6, the hair and on several races the beard: the style's row, + /// else variation 1 at the same colour. The client's binder `0x478220` ignores an empty name + /// (`0x47827d`) and runs for variation 1 (`0x478445`, `0x4786f2`) and the style (`0x478450`); + /// on the shipped data its last non-empty bind is always this. pub fn hair_mesh_texture( &self, race: u8, @@ -216,7 +134,7 @@ impl CharSections { .or_else(|| self.hair_texture(race, sex, HAIR_SUBSTITUTE_VARIATION, hair_color)) } - /// One `TextureName` column of a section row, or `None` if the row/column is absent or empty. + /// One `TextureName` column of a row; an empty name is `None`. fn tex(&self, race: u8, sex: u8, ty: u8, var: u8, color: u8, col: usize) -> Option<&str> { self.sections .get(&(race, sex, ty, var, color)) @@ -224,27 +142,12 @@ impl CharSections { .filter(|s| !s.is_empty()) } - /// Composite a character's full body-skin atlas: the base 256² skin with the face / facial-hair / - /// hair / underwear region overlays blended in at their fixed atlas tiles, returned as one mip - /// pyramid ready to upload (decision 0044). `Ok(None)` if the base skin row is absent; a missing or - /// undecodable overlay is skipped (best-effort, like the rest of the asset pipeline). + /// The body-skin atlas as one mip pyramid: the 256² base skin, the head overlays, the + /// underwear, the equipment by bodyslot − 2 and the guild emblem ([`equip_blits`]), each at its + /// tile per authored mip level (`0x475c50`, `0x4770f0`); `Ok(None)` without a base skin row. /// - /// Each overlay is read from its own origin and source-over blitted onto the base at the verified - /// tile (`0x475c50`'s bbox table + `0x4770f0`'s src rect + the head-section map). The overlay - /// BLP's own alpha is the blend control, so an opaque overlay (`alphaDepth 0` → the real client's - /// REPLACE: face, pelvis) overwrites the base and an alpha one (facial hair) blends — at full 8-bit - /// precision, not the client's 16-bit RGB565 / 2-bit-coverage memory format (the modern-client - /// choice; the decision has the why). Composited per **authored** mip level — no gamma-byte CPU - /// downsample, the same C2 rule [`read_texture_mip_chain`]/the world-art upload follow. - /// - /// `equipment` is the dressed extension (decision 0074): the worn ItemDisplayInfo rows by - /// **bodyslot − 2** (shirt, chest, belt, pants, boots, wrist, gloves, tabard; `None` = the slot - /// is empty). Their region textures blit into the eight equipment tiles after the skin sections, - /// stacked per layer by the client's priority table — and they also **gate** the underwear, which - /// is the tile's fallback rather than an under-layer ([`UNDERWEAR_TILES`]). - /// - /// `emblem` is the wearer's guild tabard (decision 1704), which paints the torso layers' cells - /// 2/3/4 over the garment's own — see [`equip_blits`] for when it installs and when it does not. + /// Deviation: blends in 8-bit RGBA, not the client's RGB565 with 2-bit coverage, because that + /// format only saves texture memory and loses precision. pub fn composite_body( &self, chain: &mut Chain, @@ -264,13 +167,8 @@ impl CharSections { }; let mut atlas = read_texture_mip_chain(chain, base_path) .with_context(|| format!("reading base skin '{base_path}'"))?; - // The head overlays: (sectionType, variation, color, texColumn, destTile) — the verified fan-out - // (the section → cell core `0x4782e0` + the head map). Within a tile, order matters (later - // overwrites/blends over earlier): base skin (already the canvas) → face → facial hair → hair. - // Note the columns differ by section: face/facial-hair use TextureName[0]/[1] (lower/upper), - // hair uses [1]/[2]. Hair is blank for e.g. Human male (its texid columns are empty), so those - // reads no-op there; included so the path is correct for races whose hairline does composite - // into the head. + // The head overlays `(sectionType, variation, color, column, tile)` of `0x4782e0`, in order + // face, facial hair, hair; face and facial hair use columns 0/1, hair 1/2. let overlays: [(u8, u8, u8, usize, Tile); 6] = [ (SECTION_FACE, face, skin, 0, TILE_G9), (SECTION_FACE, face, skin, 1, TILE_G8), @@ -287,13 +185,10 @@ impl CharSections { blit_over(&mut atlas, &overlay, tile); } } - // The equipment plan, built once: the blits below consume it, and the underwear reads it as its - // gate. One plan, so "what dresses this tile?" has a single answer (decision 0074's `equip_blits`). + // One plan gates the underwear and drives the equipment blits. let plan = equip_blits(&equipment, emblem, tabard_preview); - // The underwear ([`UNDERWEAR_TILES`]) — the tile's fallback, not an under-layer: a contribution - // in any of the group's TESTED columns and the client draws no underwear there at all, leaving - // the base skin it already blitted. Drawn before the equipment because the untested columns (the - // belt, the tabard) still stack on top of it. + // The underwear, skipped when a tested cell is taken (`UNDERWEAR_TILES`); drawn first, so + // the untested cells (belt, tabard) stack on top. for (col, layer, tested) in UNDERWEAR_TILES { if plan.iter().any(|s| s.layer == layer && s.column < tested) { continue; @@ -305,11 +200,7 @@ impl CharSections { blit_over(&mut atlas, &overlay, EQUIP_TILES[layer]); } } - // The equipment layers (decision 0074) and the guild tabard's three (decision 1704), in the - // one order [`equip_blits`] decides, each taking the first of its own - // [`EquipBlit::candidates`] that decodes. A name that resolves to nothing is skipped — - // best-effort, like the skin overlays; on the model it reads as a garment that stops early, - // which is what the instrument's `MISSING` line is for. + // The equipment and emblem layers in plan order, each from its first decodable candidate. for step in &plan { if let Some(overlay) = step .candidates(sex) @@ -344,12 +235,8 @@ impl CharSections { str_at(&rs, r, 8).unwrap_or_default(), ]; let key = (race as u8, sex as u8, ty as u8, var as u8, color as u8); - // The standard player sections are `flags == 0`. Some (race,sex,type,color) keys ALSO have - // an `EXTRA`-flagged row (`flags & 0x1`) sharing the same colorIndex — e.g. human-male - // skinColor 0/1 carry both `HumanMaleSkin00_00` (flags 0) and the late `…_100` (flags 1). - // Those are not the chargen-selectable skins; a player with skinColor 0/1 must get `…_00`. - // So a standard row always wins; an extra row only fills a key no standard row provides - // (order-independent — robust to the extras appearing before or after the standards). + // A row flagged `0x1` can share its key with a standard row (Human male skin 0 has + // `…Skin00_00` and `…_100`); the standard row wins in either order. let extra = u32_at(r, 9).unwrap_or(0) & 0x1 != 0; if extra { sections.entry(key).or_insert(texs); @@ -362,37 +249,25 @@ impl CharSections { } } -/// One equipment contribution the body composite blits, carrying the worn bodyslot it came from, -/// the layer/tile it lands in, and the `ItemDisplayInfo` region-texture name it draws. -/// -/// This is the *plan* [`CharSections::composite_body`] executes, exposed so the instrument -/// (`benilla-extract charatlas`) reads the composite off the **same law the composite runs** rather -/// than a second transcription that can drift. "Which worn slot repainted this tile?" is the -/// question both reported outfit bugs turned on, and it was unanswerable without this. +/// One step of the equipment plan [`CharSections::composite_body`] runs, public so +/// `benilla-extract charatlas` reports the same plan. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct EquipBlit<'a> { - /// The compositor layer — `ItemDisplayInfo` texture column *i* = layer *i* = atlas tile g*i*. + /// The compositor layer: ItemDisplayInfo texture column i, atlas tile gi. pub layer: usize, - /// The cell this contribution occupies in the layer's row — [`equip_column`]'s answer for a worn - /// garment, or the guild tabard's fixed 2/3/4. Ascending = blitted later. + /// The cell in the layer's row ([`equip_column`], or the emblem's 2/3/4); higher blits later. pub column: i8, /// Where the art comes from. pub source: BlitSource<'a>, } -/// What one [`EquipBlit`] paints with — a worn garment's own region texture, or one of the three -/// guild-tabard layers. Two different filename laws, which is why this is an enum and not a path: -/// a garment resolves `Item\TextureComponents\\_.blp` (`_U` first, the -/// wearer's gender letter as the fallback), the tabard `Textures\GuildEmblems\<…>__U.blp` -/// with no gender variant at all. +/// What an [`EquipBlit`] paints: a garment's `Item\TextureComponents\\_.blp`, +/// or a guild-tabard layer's `Textures\GuildEmblems\<…>__U.blp`, which is never gendered. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum BlitSource<'a> { - /// A worn garment's `ItemDisplayInfo` region texture: the bodyslot it came from, indexed as - /// [`CharSections::composite_body`]'s `equipment` is (bodyslot − 2: 0 shirt · 1 chest · 2 belt · - /// 3 pants · 4 boots · 5 wrist · 6 gloves · 7 tabard), and the row's name for this layer (no - /// directory, no gender suffix). + /// A worn garment: its slot (bodyslot − 2) and its bare region-texture name for this layer. Worn { slot: usize, texture: &'a str }, - /// One of the wearer's guild-tabard layers — see [`EmblemLayer`]. + /// One of the wearer's guild-tabard layers. Emblem { part: EmblemLayer, emblem: GuildEmblem, @@ -400,13 +275,8 @@ pub enum BlitSource<'a> { } impl EquipBlit<'_> { - /// The chain paths this contribution is looked up under, **in the order the composite tries - /// them**, first hit wins. Two for a worn garment (`_U`, then the wearer's gender letter — the - /// order `0x476e20` builds them in); one for a guild-tabard layer, which ships a single - /// ungendered name. - /// - /// The composite runs this and the instrument reports it, so "which file did that cell take?" - /// has one answer and cannot drift into two transcriptions. + /// The chain paths to try, first hit wins: `_U` then the gender letter for a garment + /// (`0x476e20`), the one ungendered name for an emblem layer. pub fn candidates(&self, sex: u8) -> Vec { match self.source { BlitSource::Worn { texture, .. } => { @@ -419,50 +289,28 @@ impl EquipBlit<'_> { } } -/// A guild's tabard — the five indices `SMSG_GUILD_QUERY_RESPONSE` carries, and the whole input to -/// the guild-emblem composite. They are file indices, not colours: each names a shipped BLP under -/// `Textures\GuildEmblems\`, and an index with no file simply paints nothing (the client's -/// `**** Unable to Load Texture %s` leaves the cell null), so nothing here is range-checked. -/// -/// **Signed on purpose.** A guild that has never bought a tabard carries `-1` in all five (vmangos -/// `Guild/Guild.cpp:86`; `int32` on the wire), and `{:02}` renders that as `-1` — the same -/// `Emblem_-1_-1_TU_U` the client's own `%02d` builds, which resolves to no file and so paints -/// nothing. Clamping, or casting through unsigned, would invent a crest for a guild that has none. +/// A guild's tabard: the five `SMSG_GUILD_QUERY_RESPONSE` indices, each naming a BLP under +/// `Textures\GuildEmblems\` (the designer's counts `[0x808220]` = {170, 17, 6, 17, 51}); an index +/// with no file paints nothing. Signed, never clamped: a guild with no tabard sends `-1` in all +/// five (vmangos `Guild/Guild.cpp:86`, `int32`). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub struct GuildEmblem { - /// The emblem symbol — `Emblem_