---
description: "Deep audit of NIF parser — stream position, version gating, block dispatch coverage, geometry handoff"
argument-hint: "--focus <dimensions> --game <fnv|fo3|skyrim|oblivion|fo4|fo76|starfield> --corpus <path>"
---

# NIF Parser Audit

Audit the NIF binary-format parser (`crates/nif/src/`) for byte-accurate correctness
across the Oblivion → Starfield version span. Real game data is used when available.

The recurring failure mode is **stream-position drift**: a block over/under-reads its
payload, the consumed-byte count diverges from the header `block_sizes` entry, and either the
reconciliation masks it (parse "succeeds" with silent corruption) or — on Oblivion-era files
with *no* `block_sizes` table — every later block is misaligned and the scene truncates.
Dimensions are ordered by that risk.

**Architecture**: Orchestrator. Each dimension runs as a Task agent (max 3 concurrent).

Read `.claude/commands/_audit-common.md` and `.claude/commands/_audit-severity.md` for
shared protocol (layout, game-data locations, dedup, finding format). Do not duplicate them.
`docs/engine/nif-parser.md` is the code-verified reference (module map, version handling,
per-game coverage matrix, block coverage) — prefer it over re-deriving. The authoritative
block spec is `/mnt/data/src/reference/nifxml/nif.xml` (*reference_nifxml*); Gamebryo 2.3
source is the tiebreaker.

**Not in scope**: BSA/BA2/CSG archive-reader discipline and the FO4 previs header
(`/audit-parsers`); per-game *material* translation and the canonical tier (`/audit-nifal`);
SpeedTree `.spt` (`/audit-speedtree`); Havok→Rapier behaviour (`/audit-physics` — only the
constraint CInfo decode is a NIF seam).

## Parameters (from $ARGUMENTS)

- `--focus <dimensions>`: comma-separated numbers (e.g. `1,2`). Default: all 6.
- `--game <name>`: `fnv`, `fo3`, `skyrim`, `oblivion`, `fo4`, `fo76`, `starfield`. Default: all detected.
- `--corpus <path>`: directory of extracted `.nif` files for bulk testing.

## Extra Per-Finding Fields

- **Dimension**: Stream Position | Version Gating | Block Dispatch Coverage | Geometry Handoff | Collision/Shader Parsing | Allocation Hygiene
- **Game Affected**: which `NifVariant`(s), citing the `bsver` band.

## Phase 1: Setup

1. Parse `$ARGUMENTS`; `mkdir -p /tmp/audit/nif`; fetch the dedup baseline (`_audit-common.md`).
2. Note which game-data directories exist. Skim `docs/engine/nif-parser.md` § "Per-game NIF
   coverage" so findings are measured against current clean/recoverable rates.
3. `cargo test -p byroredux-nif` (default lane); record the pass count.
4. **Corpus gates are opt-in** (`#[ignore]`, need installed data): `parse_real_nifs`,
   `per_block_baselines`, `block_coverage_baselines`, `oblivion_stream_drift_corpus`,
   `constraint_drift_corpus`, `normal_synthesis_corpus`, `np_ragdoll_corpus` under `crates/nif/tests/`. They are
   wired per title in `.github/workflows/real-data-gates.yml` (`BYROREDUX_REQUIRE_GAME_DATA=1`,
   so an absent corpus is a failed job). Locally: one title at a time,
   `BYROREDUX_<GAME>_DATA=<Data dir> cargo test -p byroredux-nif --test per_block_baselines <game> -- --ignored --nocapture`.
   The workflow parses since d98769436 (#4619, closed), but no `byroredux-game-data`
   self-hosted runner is registered, so the lane is **manual-only** (*workflow_dispatch*; the
   nightly schedule was removed in fde8feec2, #5905) — **no automated result exists** (the corpus
   jobs run `--no-fail-fast`, fa2af0e89, #5660, so one red test binary no longer hides the ones
   after it). A green default `cargo test` says nothing about parse rates; run the gate locally.

## Phase 2: Dimensions

### Dimension 1: Stream Position Integrity (PRIMARY)
Paths: `crates/nif/src/{lib,stream,header,scene}.rs`, every `blocks/**/*.rs` `parse()`
First step: `git log --since=<last report> --format='%h %s' -- crates/nif/src/blocks crates/nif/src/lib.rs`
Flow: `parse_nif` → `parse_nif_with_options` → `dispatch_blocks` (block loop, `no_block_sizes`
branch) with `parse_header` / `finalize_scene` as sibling phases.
**Guards**: the `NifScene::recovered_by_guess` zero-assert in the corpus walk
(`tests/common`, surfaced by `parse_real_nifs`: a size-cache/`oblivion_skip_sizes` skip is
*inferred*, not file-declared — absolute zero, not a rate; a size-cache skip that lands
inside the failed block's own consumed bytes is refused, #3926);
`every_tail_capturing_block_reports_it_and_parse_nif_records_it` (`blocks/shader_tests/starfield.rs`;
source-shape scan of `blocks/shader/*.rs` + `blocks/node.rs`: every struct declaring a
`starfield_tail` field overrides `opaque_tail_len`; a tail-capturing block in a file not in
its list is invisible to it); `blocks/controller/sequence_pre_10_1_0_106_tests.rs`;
`tests/oblivion_stream_drift_corpus.rs` (no-`block_sizes` detector has zero false positives;
every sizeless Oblivion NIF must land on its root footer via `parse_nif_with_options_and_consumed`,
eight measured stale-tail LOD files allowlisted — 6ad0c0214, #5782; extraction / parse failures
are counted and fail the sweep, 7869a93fd #5781; a present-but-unopenable Oblivion DLC archive
fails, c2d3914bf #5780).
**Checklist** (what the guards cannot see):
- Each block consumes exactly its `block_sizes` entry. A finding is a block whose drift the
  reconciliation is *silently absorbing*, not the reconciliation itself.
- Oblivion-era files (v20.0.0.4/5 and the v10.x NetImmerse family) ship **no** `block_sizes`:
  one wrong field cascades. Audit those parsers for unconditional reads that assume a
  later-game layout; version-gated trailing fields must be guarded, never always-read.
- Bool width: `read_bool` (version-dependent 1 or 4 bytes) vs `read_byte_bool` (always 1) must
  match the nif.xml type per field — a phantom/missing bool is the classic 1-/3-byte drift.
- **An opaque tail captured to `block_size` is invisible to `drift_histogram`** (consumed ==
  declared by construction). It needs its own telemetry: `NiObject::opaque_tail_len` feeding
  `NifScene::opaque_tail_histogram` (#2625). Regression = a new tail-capturing block that
  doesn't wire it. Also confirm capture-to-`block_size` is used, never a hardcoded length
  (Starfield `BSLightingShaderProperty` tail is 0 B on most content — 480,757
  of 483,837 blocks — with a 30 B tail on the remaining 3,080).
- Pre-10.1.0.106 `NiSequence` / `ControlledBlock` / `NiControllerSequence`: exactly one of
  each `until="10.1.0.103"` / `since="10.1.0.106"` pair is present for any version (a read
  that fires both, or drops the `until` half, loses text keys and binds no channel);
  below-band `cycle_type` default is `CYCLE_CLAMP = 2`, and `import_sequence`
  (`crates/nif/src/anim/sequence.rs`) collapses a non-finite/non-positive duration to `0.0`.
- `NiDynamicEffect` carries two affected-nodes groups (`until="4.0.0.2"` and
  `since="10.1.0.0"`); both must be read (`NiLight`/`NiTextureEffect`, no anchor on files
  that old).
- Starfield `BSGeometryMeshData`'s meshlet/cull trailer is `MeshTrailer::OptionalAtEnd` only for a
  standalone `.mesh` body (`parse_from_bytes`); an inline (flag 0x200) body reads it `Required`
  (#4269) — `remaining() == 0` says nothing inside a NIF stream.
- Starfield `BSFaceGenNiNode` has its own type with an opaque `starfield_tail` (aliasing it
  to plain `NiNode::parse` under-reads 2 B on every instance); `BSWeakReferenceNode` declines
  an unfittable payload into `starfield_tail` rather than overrunning.
- Regression pins (verify still fixed, do not re-report): Oblivion v10.x family (#1509,
  #1337, #1329, #1310/#1301/#1302 — method: extract → `trace_block` → byte-decode; memory
  *nif_v10x_stride_drift_resolved*); Starfield shader over-read (#1510: gate FO76-tail fields
  on `bsver < STARFIELD`, since `#BS_F76#` is stream 155 only); root-NiNode / inline-type-name
  truncation recovers rather than hard-`Err` (#688/#698).
- If a corpus is available: report consumed-vs-`block_size` mismatches by block type with
  counts; any non-zero on a *known* type is drift; separately any `no_block_sizes` truncation.
**Output**: `/tmp/audit/nif/dim_1.md`

### Dimension 2: Version Gating (highest report yield)
Paths: `crates/nif/src/{version,shader_flags}.rs`, all `stream.bsver()` / `stream.version()` sites in `blocks/`
First step: `git log --since=<last report> --format='%h %s' -- crates/nif/src/version.rs crates/nif/src/shader_flags.rs`, then for every parser changed since, compare each `since=`/`until=`/`vercond` in nif.xml with the gate written; `grep -rn 'V10_1_0_106\|V10_1_0_103' crates/nif/src/blocks` shows raw uses of constants a `NifVersion` helper already encodes.
**Guard**: the `detect_*` tests in `version.rs`; `crates/nif/src/version_literal_tests.rs` (72769b95a, #5119) fails on any bare `NifVersion(0x…)` literal or bare decimal `bsver` comparison in production `src/` outside `version.rs`. The scan blanks each `#[cfg(test)]` module individually (a first-occurrence cut once hid every later production item, #5258). Nothing guards a *named* constant used where a `NifVersion` helper already encodes the gate.
**Checklist**:
- `NifVariant::detect` covers every `(version, user_version, user_version_2)` combination of
  the seven titles + the FO3-dev edge case. `impl NifVariant` is minimal (`detect`, `bsver`);
  the live named-helper surface is on **`NifVersion`** (`has_mopp_offset`,
  `has_havok_strips_scale`, `has_object_group_id`, `has_skin_data_partition_ref`,
  `has_skin_data_vertex_weights_flag`, `has_keyframe_controller_data`,
  `has_quat_transform_trs_valid`, `has_interp_controller_manager_controlled`,
  `uses_old_rigid_body_layout`, `has_ni_sequence_prologue`, `has_controller_sequence_fields`) —
  verify against `version.rs` before citing (it has been pruned and grown). **A new parser
  that hardcodes a raw version literal for a feature a helper covers is the regression**; a
  new helper needs a consumer in the same change (dead helpers were pruned). Doctrine:
  `docs/engine/nif-parser.md` "Version handling".
- `bsver` bands are named constants (`OBLIVION = 11`, `FO3_FNV = 34`, `RIGID_BODY_FLAGS16 = 76`,
  `SKYRIM_LE = 83`, `SKYRIM_SE = 100`, `FALLOUT4 = 130`, `FO4_DLC_UPPER = 140`, `FO76 = 155`,
  `STARFIELD = 172`, …): check operator and constant (off-by-one band membership silently
  corrupts a neighbouring game). FO76 (155) and Starfield (172+) differ — keep apart.
- `shader_flags.rs`: FO3/FNV `BSShaderFlags` + `BSShaderFlags2` u32 *pair* (`fo3nv_f1/f2`) vs
  Skyrim+ single-word storage (`skyrim_slsf1/2`, `fo4_slsf1/2`) and the FO76/Starfield
  `bs_shader_crc32` arrays; each game reads its own storage shape. Import reads them via
  `is_decal_from_legacy_shader_flags` / `is_decal_from_modern_shader_flags` /
  `is_two_sided_from_modern_shader_flags` (`crates/nif/src/import/material/mod.rs`).
- Oblivion specifics: u16 flags below `FLAGS_U32_THRESHOLD = 26`; inline strings below
  `STRING_TABLE_THRESHOLD`; `NiPSysEmitter` routed through the nif.xml gate (`bsver < 26`);
  `NiTextureEffect`'s embedded `NiDynamicEffect` base gated `bsver < FALLOUT4`.
**Output**: `/tmp/audit/nif/dim_2.md`

### Dimension 3: Block Dispatch Coverage
Paths: `crates/nif/src/blocks/mod.rs`, `crates/nif/tests/{per_block,block_coverage}_baselines.rs`, `crates/nif/tests/data/`, `crates/nif/src/corpus.rs`, `crates/nif/src/kfm.rs`
First step: `cargo run -p byroredux-nif --release --example nif_stats -- <archive-or-dir> --tsv` — but do **not** diff it directly against `tests/data/per_block_baselines/<game>.tsv`: the two TSVs use different keys (#4265 — `nif_stats` keys `parsed` on the dispatched Rust struct name, the baselines on the header-advertised wire name), so they legitimately disagree on alias-collapsed families. Use `nif_stats --tsv` for the unknown/parsed split per struct, and the `per_block_baselines` gate itself (`--unknown-only` for the short view) for wire-name comparison against the checked-in baselines
**Checklist**:
- Dispatch is a hand-written `match type_name` in `parse_block_inner` (the `impl_ni_object!`
  macro only generates trait impls). Count top-level arms fresh — nested `match`es
  (`type_name_static` RTTI arms, `type_name_arc_hint`) inflate a naive `grep -c '=>'`; never
  quote a stale number or copy one from `docs/engine/nif-parser.md` § "Block coverage".
- From a corpus or archive listing, enumerate block types that fall through to `NiUnknown`;
  count per game; flag any that cascade (a missing block on Oblivion truncates the scene → Dim 1).
- A block that parses but is dropped downstream is a Dim 4/5 finding, not coverage.
- **Baseline harness invariants**: baselines key on **wire RTTI** via
  `header.block_type_indices` (`record_scene_blocks`, `tests/common/mod.rs`), not the parsed
  struct name (several arms parse multiple wire types into one struct — `BhkRigidBody.is_t`,
  `NiPSysBlock.original_type`); the corpus definition is shared in `corpus.rs`
  (`NIF_ENTRY_EXTENSIONS` includes `.bto`/`.btr` — renamed NIFs; a second private copy of
  the rule is the regression; every corpus filter routes through `corpus::is_nif_entry`, pinned by
  a source scan, #5259); the gates walk **every** mesh-bearing archive
  (`open_all_mesh_archives` all-or-nothing, `open_optional_mesh_archives` present-only — a
  present-but-unopenable archive panics and an all-absent corpus fails, #5232;
  `run_all_meshes_gate` with a *per-archive* `limit`); `parse_real_nifs` asserts a per-archive
  clean-rate floor (`min_clean`, 0.995) plus recoverable 100% — truncation counts as
  recoverable, so the floor is what catches silent clean-rate collapse. New coverage
  findings should land as a baseline-test extension; `BYROREDUX_REGEN_BASELINES=1`
  regenerates after an intentional change. `run_baseline` compares each TSV's own `total=` header
  (`baseline_corpus_total`) against the walked corpus *before* the per-type comparator, so an
  install that grew or shrank fails naming the corpus drift rather than reading as parser loss
  (#4628). The #5260 file-count pin: `diagnose_corpus` (`tests/block_coverage_baselines.rs`)
  compares the NIF file count against that same `total=` header, so a `total_blocks` move on a
  *matching* install reports as a **parser delta** (fewer / more — investigate before
  regenerating), and only a file-count mismatch reads as corpus drift (92ad8247d, #5438).
  `open_present_archive` (`tests/common/mod.rs`) owns the present-but-unopenable panic for
  `open_mesh_archive`, `open_all_mesh_archives` and the Oblivion drift corpus, which now resolves
  its directory through `game_data_dir` (5f414d681, #5439; `open_optional_mesh_archives` alone
  still logs and continues, by design). `nif_stats`' directory mode routes through
  `corpus::is_nif_entry` and the #5259 scan also rejects `eq_ignore_ascii_case("nif")` and
  `== / != "nif"` compares (ffa85a0b4, #5440; pinned by
  `the_corpus_scan_knows_both_hand_rolled_spellings`). Regression guards, not open gaps.
- `kfm.rs` (KFM binary catalog, v1.2.0.0–2.2.0.0, transcribed from `NiKFMTool::ReadBinary`)
  has no engine consumer today — audit for version-gate fidelity and `allocate_vec` bounds
  only (its `read_cstring` bounds the `i32` length against remaining bytes and
  `MAX_SINGLE_ALLOC_BYTES`, #4631); "unused" is not a finding.
**Output**: `/tmp/audit/nif/dim_3.md`

### Dimension 4: Geometry Extraction & Import Handoff
Paths: `crates/nif/src/import/{mod,types,transform,coord,precombine,units}.rs`, `import/mesh/`, `import/walk/`, `import/material/{walker,dedicated_shader}.rs`
First step: `git log --since=<last report> --format='%h %s' -- crates/nif/src/import/mesh crates/nif/src/import/walk`
This is the *parse → ECS* handoff; per-game material classification is `/audit-nifal`.
**Guards**: `import/mesh/sse_skin_index_space_tests.rs` (`#[ignore]`, needs Skyrim SE data),
`bs_tri_shape_partition_remap_tests.rs`, `sse_skin_geometry_reconstruction_tests.rs`,
`tangent_convention_tests.rs`, `tests/normal_synthesis_corpus.rs` (ignored), `import/walk/tests.rs`.
**Checklist**:
- All `NiAVObject` fields via the `.av.*` sub-struct. Coordinate conversion (Z-up → Y-up,
  `coord.rs`) applied consistently to positions, normals, rotations. `compose_transforms`
  neutralises an overflowed (finite-input, non-finite-product) result through the #4549
  sanitiser (#4633, `compose_transforms_overflowing_products_are_neutralized`).
- **Walker bounds**: `NiNode.children` holds raw `BlockRef`s never verified to form a tree, so
  all five scene-graph walkers (hierarchical, flat, and the lights / texture-effect /
  particle-emitter satellites) thread one per-walk visited bitset (`new_visited`) through
  `revisit_guard` (`import/walk/mod.rs`): a repeat reference is skipped and depth is capped at
  `MAX_NIF_NODE_DEPTH` (128). #1269's depth cap alone left self-reference / diamond fan-out
  exponential (fixed in cf8730adc, #5485; pinned by the `*_walker_survives_a_self_reference`
  family + `walkers_survive_a_mutual_cycle` in `import/walk/tests.rs`). A new walker that
  recurses without the guard is the regression; vanilla has zero multi-parent children.
- Per-game geometry path: classic `NiTriShape` (Oblivion/FO3/FNV) vs Skyrim SE+ packed-half
  `BSTriShape` vs Starfield `BSGeometry` (bulk-read via `read_u16_array` + unpack; import
  extractor in `import/mesh/bs_geometry.rs`). Each decodes its own stride and index format.
- **Tangents**: FO4+ `BSTriShape` inline when `VF_TANGENTS | VF_NORMALS` set;
  Oblivion/FO3/FNV `NiBinaryExtraData` "Tangent space (binormal & tangent vectors)" — must
  honor the `[tangents…, bitangents…]` swap (the field holds ∂P/∂V, #786); Starfield UDEC3
  `tangents_raw` with W normalized to ±1 at import (#2246); otherwise synthesis — Lengyel
  accumulation + Gram-Schmidt (nifly `CalcTangentSpace`, **not** MikkTSpace, d81f7501f #5963)
  in the single producer `synthesize_tangents_yup`; the Z-up `synthesize_tangents` is a wrapper
  that converts its inputs and calls it (89a0b6ccd, #5793 — a second accumulation copy is the
  regression). Distinct paths — don't cross-wire.
- **Skyrim SE skinning index spaces** (#3355/#3360, then the 2026-09-17 correction): the
  `NiSkinPartition::Triangles` are already **global** vertex indices on the `Stream() == 100`
  band (do not push them through `vertex_map`); the packed `BSTriShape` / SSE global-buffer
  bone indices already address the skin's bone list and are only *widened*
  (`widen_packed_bone_indices`); only the separate `NiSkinPartition.bone_indices` channel is
  partition-local. Regression = re-applying a partition palette to the packed channel (the
  deleted *remap_bs_tri_shape_bone_indices* behaviour) or `vertex_map` to triangles.
  `sse_recon::try_reconstruct_sse_geometry` and `skin::*` consume the right counts.
  Every skin producer declines the whole weight set (bind pose, never a clamp) when any bone
  index >= the skin's own bone count: Starfield `BSGeometry` in `convert_bs_geometry_skin_weights`
  (#4268, 0abc86c8b), and the `BSTriShape` inline + SSE global-buffer packed channel and classic
  `NiSkinData` via `decline_unbounded_packed_indices` (`import/mesh/skin.rs`; fixed in cf9dec258,
  #5389 — #4268's "siblings already bound" premise was false). The palette bound in
  `render/skinned.rs` is a producer-upheld invariant, so a new producer without the decline is
  the regression. #5524 (fixed in 656e2bdc3) gave the FO4 `BsSkinInstance` arm its decline +
  `bone_count - 1` boundary tests (`mesh/bs_tri_shape_partition_remap_tests.rs`), made the decline
  warning arm-neutral, and restored `widen_packed_bone_indices`' doc comment.
- **Particle emitters**: `NiPSysEmitter`/`NiPSysEmitterCtlr`/`NiPSysEmitterCtlrData`/
  `NiPSysGrowFadeModifier` are typed (`blocks/particle.rs`); params flow
  `extract_emitter_params` / `extract_emitter_rate` (`import/walk/emitter.rs`) →
  `apply_emitter_params` (`byroredux/src/systems/particle.rs`, overrides kinematics + size,
  not colour). The rate walk is scoped to the system's own controller chain; when a sibling
  `NiPSys*` controller parses to the opaque `NiPSysBlock` marker (which discards
  `next_controller_ref`) the fallback resolves by `base.target_ref` to the ctlr targeting the
  system whose `controller_ref` equals the chain head — per-instance exact, never a
  whole-scene first-match, and gated on a non-NULL chain head (#4467; opt-in gate
  `real_archive_torch_meshes_surface_particle_emitters`). The legacy tier follows the ctlr's own
  `NiPSysEmitterCtlr.data_ref` (until 10.1.0.103, #4560); an unlinked data block is attributed
  to nobody. The geometry APP_CULLED + live-`NiVisController` gate lives once in
  `shape_is_culled` (node arms: `node_is_culled`; ef78a0eec, #5792, source-count guard in
  `import/tests/app_culled_visibility.rs` — a per-arm copy is the regression). Both walkers skip APP_CULLED / editor-marker particle systems
  (`particle_system_is_culled`, #4561) unless a `NiVisController` — found on the chain *or* by
  `target_ref` — un-hides them. Regression = a hardcoded preset replacing an authored birth
  rate, or the fallback claiming another system's ctlr.
- **Starfield units** (`import/units.rs`): `bsver >= FO76_STARFIELD_BOUNDARY` content is authored
  in meters and scaled once to 70 units/m (`length_scale`) across meshes, hierarchy, emitters,
  force fields and animation; `BSGeometry` vertices also divide out `HAVOK_SCALE` (69.969).
  Rotations, weights and authored scales stay dimensionless; pre-Starfield imports are
  bit-identical (`import/tests/units.rs`). A second scale site downstream is the regression.
- FO4 precombined geometry (`import/precombine.rs`, M49) reuses `decode_bs_vertex_stream`
  with `full_precision = false` (PSG positions are half even when the descriptor sets
  full-precision); container read is `byroredux_bsa::CsgArchive` (`/audit-parsers`); spec
  `docs/engine/fo4-csg-format.md`.
- FO4 model-space normals + alpha-test: `dedicated_shader.rs` ORs `Model_Space_Normals`
  (F4SF1 bit 12, or the `MODELSPACENORMALS` CRC for `bsver >= 132`) and `Alpha_Test`
  (F4SF2 bit 25) into `MaterialInfo`; parsed-but-dropped bits render object-space normals as
  tangent-space. The NIF flag ranks below the later BGSM merge.
**Output**: `/tmp/audit/nif/dim_4.md`

### Dimension 5: Collision & Shader Block Parsing
Paths: `crates/nif/src/blocks/collision/`, `crates/nif/src/blocks/shader/`, `crates/nif/src/import/collision/`
First step: `cargo test -p byroredux-nif -- dispatch_coverage_tests bhk_ hk_packed`
**Guards**: `import::collision::dispatch_coverage_tests::every_dispatched_bhk_shape_has_resolve_arm`
(a new `bhk*Shape` dispatch arm needs a `resolve_shape_inner` `downcast_ref` arm — else it
parses then silently drops collision; *nif_shape_dispatch_resolve_parity*; the scan reads only the
`resolve_shape_inner` body, #5573; a body's per-leaf Havok surface materials are pushed by
`resolve_shape_into` in the same walk as the shape, #5550, and a packed-strips table is chained
from the data block's `sub_parts`, #5541);
`import::collision::dispatch_tests::havok_motion_type_maps_full_enum`;
`blocks/collision/hk_packed_ni_tri_strips_data_tests.rs`;
`tests/constraint_drift_corpus.rs` (opt-in; `decoded_constraints_consume_their_whole_block` —
drift must be exactly 0 on Oblivion, FO3, FNV, Skyrim LE and SE);
`tests/np_ragdoll_corpus.rs` (opt-in FO4: every ragdoll blob decodes with consistent joint
pivots, every body's shape volume × density equals its mass).
**Checklist**:
- **`bhk*` field-for-field**: rigid-body flag width (`uses_old_rigid_body_layout`,
  `RIGID_BODY_FLAGS16 = 76`, `RIGID_BODY_EXTRA_FLOATS = 9`); MOPP offset / strips scale gated
  (`has_mopp_offset`, `has_havok_strips_scale`); FO3+ `hkSubPartData` is *decoded* (filter +
  material), never `skip(12)`. `bhkRigidBody` takes the CInfo2014 layout at `bsver >= FALLOUT4`,
  a deliberate widening of nif.xml's `== 130` (which sends FO76/Starfield to CInfo2010); no
  vanilla content decides it (0 `bhkRigidBody` on FO4/FO76/Starfield) — a `bsver > 130` body in
  the wild is the evidence to re-check (#4627). The PHYSAL per-game seam is only the constraint CInfo decode.
- **Constraint CInfo**: typed decoders exist for hinge (into `LimitedHingeCInfo`), limited
  hinge (the Oblivion layout authors no `Perp Axis In B1`; it is derived as `PerpB2 × AxisB` from
  nif.xml's own identity, never zeroed — 526cb07d9, #5537, pinned by
  `oblivion_limited_hinge_derives_perp_b1_from_perp_b2_cross_axis_b`), prismatic, ragdoll, ball-and-socket, stiff-spring and the ball-socket chain
  (`BhkConstraintData`; `BhkBreakableConstraint` and malleable wrappers decode the inner
  CInfo). `bhkGenericConstraint` has no dispatch arm (nif.xml gives only its name), so it
  lands as `NiUnknown` with an exact `block_size` skip. There is no drift-suppression list
  any more (#4626 removed *is_havok_constraint_stub* / *stubbed_drift_histogram*); any
  constraint drift lands in the real `drift_histogram` — suppression is the mechanism that
  hid `bhkHingeConstraint`'s +128, so do not reintroduce it. The trailing
  `bhkConstraintMotorCInfo` (type byte + 0/25/18/17 B) of FO3+ Ragdoll / LimitedHinge /
  Prismatic is now consumed — in the bare dispatch and the Malleable wrapper (motor, then
  `Strength` f32), leniently, via the one `BhkBreakableConstraint::consume_motor` table; the
  breakable wrapper consumes it exactly once (a071ed217, #5960). The old motor-tail allowlist
  (*is_known_constraint_motor_tail_drift*) is deleted — any non-zero constraint drift is a
  finding. *Decoded is not imported*: the ragdoll
  importer (`import/collision/ragdoll.rs`) still declines ball-and-socket / spring / chain
  until a canonical joint kind exists (`docs/engine/physal.md`).
- **`BSLightingShaderProperty::parse`** is a thin `bsver` dispatcher → `parse_skyrim`
  (83–129) / `parse_fo4` (130–154) / `parse_fo76_plus` (≥155); each reads only its own field
  set; per-`shader_type` trailing count (0–7) matches nif.xml (`parse_shader_type_data_fo4`
  handles only the FO4-specific types and delegates every "same as Skyrim" arm to
  `parse_shader_type_data`, b99db0f11 #5737 — a duplicated arm is the regression). Starfield captures
  `starfield_tail` to `block_size`. The material-reference stub gate is `!name.is_empty()`
  for `bsver >= STARFIELD` (stubs are `.mat` names; the only suffix-less ones are the bare
  `Materials\` directory, not content hashes — #4439) but the suffix-aware
  `is_material_reference` for FO76 (155..171 — the stopcond is `bsver >= FO76`, not the 152
  `FO76_SF2_CRCS` shader-flag threshold; the `effect.rs` comment was corrected in 2be7c7c0b,
  #5082); `BSEffectShaderProperty` and
  `BSLightingShaderProperty::parse_fo76_plus` must stay in lockstep (`parse_bs_effect_starfield_suffixless_name_stubs`).
- Starfield shader-type translation is keyed at the **parser** boundary
  (`parse_with_size` routes every `bsver >= 155` through `parse_fo76_plus`), not the
  slot-table layout tag; `normalize_shader_type` masks types 4/5 — a Starfield FaceTint (3)
  must not reach the slot table as Skyrim Parallax (#3364).
- **`BhkNPCollisionObject` is "approximated", not "decoded"**: the FO4/FO76/Starfield
  `BhkSystemBinary` blob does not resolve to a `CollisionShape`.
  `blocks::collision::havok_packfile::parse_havok_packfile` decodes the container (header,
  sections, three fixup tables → typed object graph); what stays opaque is
  `hknpCompressedMeshShapeData`'s bit-packed layout (`docs/engine/physal.md`). Do not report
  it as decoded. The FO4 skeleton **ragdoll** blob is decoded (`blocks/collision/np_ragdoll.rs`,
  `decode_np_ragdoll`: `hknpRagdollData` capsule / sphere / `hknpConvexPolytopeShape` /
  `hknpDynamicCompoundShape` bodies, ragdoll + limited-hinge joints, `hknpBreakableConstraintData`
  unwrapped — e1bd40bcf, #5015 (closed); fc4a15104, #6067 (open: 64 joints whose bind-pose pivots disagree are only characterised)) into the classic
  `RagdollCInfo` / `LimitedHingeCInfo` records; its offsets were recovered from the corpus (no
  SDK) and are pinned by independent physical checks, so a layout claim needs a corpus
  measurement, not nif.xml. `summarize_collision_authoring` scans every collision block and feeds
  `CachedNifImport.collision_authoring` (`byroredux/src/cell_loader/nif_import_registry.rs`);
  `examine_collision_kind` keeps `None / Classic / NewPhysicsStub / Phantom / Unrecognised`
  distinct (a Skyrim+ phantom must not be force-translated as a rigid body). A per-game
  detail (raw `bsver`, block-type string) escaping `import/collision/` is a finding →
  cross-link `/audit-nifal`.
- `hkMotionType` byte → canonical `MotionType` (`havok_motion_type`): `1..=5 | 8` Dynamic,
  `6` Keyframed, `7` Static, `9` CharacterKinematic, else Static; the zero-mass "Dynamic"
  reclassification lives beside it — see `/audit-physics`, do not re-derive.
**Output**: `/tmp/audit/nif/dim_5.md`

### Dimension 6: Allocation Hygiene (PERF)
Paths: `crates/nif/src/stream.rs`, `blocks/**/*.rs` callers, `crates/nif/src/anim/bspline.rs`, `crates/nif/tests/heap_allocation_bounds*.rs`, `byroredux/src/streaming/pre_parse.rs` (`pre_parse_cell`)
First step: `cargo test -p byroredux-nif --features dhat-heap --test heap_allocation_bounds` (CI job `nif-heap-allocation-bounds` runs the three heap files, each its own process)
**Guards**: the dhat-gated `heap_allocation_bounds.rs` (a `harness = false` sequential main since
#5050 — libtest's own threads polluted its exact `total_blocks == 1` pin; single node, FO4 packed vertices,
SSE geometry+particle, skin blocks), `heap_allocation_bounds_geometry.rs`,
`heap_allocation_bounds_import.rs`; `stream.rs` unit tests
(`allocate_vec_sized_*`, `allocate_vec_min_bytes_uses_the_supplied_minimum_not_size_of`);
`blocks/tri_shape_tangent_presize_tests.rs` + the SSE-recon sibling (tangent `Vec` pre-sized
exactly for a `VF_TANGENTS | VF_NORMALS` descriptor and not otherwise, both packed decoders, #4617).
**Checklist**:
- `allocate_vec` / `allocate_vec_sized` / `allocate_vec_min_bytes` are `#[must_use]`; a call
  that only bound-checks and drops the Vec is a no-op. Bulk arrays go through `read_pod_vec<T>`
  (single allocation); allocate-then-loop-fill is the regression. `read_pod_vec` is bounded by
  the crate-local `unsafe trait AnyBitPattern` with an explicit per-type impl list
  (`stream.rs`; `bytemuck` is *not* a dependency — check before reporting it) and a
  big-endian `compile_error!` gate.
- **`allocate_vec_min_bytes`'s minimum must be the smallest legitimate on-disk encoding of one
  *emitted* element, not `size_of` of the in-memory type** (#3918): a generated element (e.g.
  de-stripped triangles, ≥ 2 B each on disk, 6 B in memory) with a `size_of`-derived bound
  rejects valid data near EOF and fails silently downstream (a demoted `NiUnknown`
  `NiSkinPartition` un-hides skin partitions → NPCs render bare skin through armor). Known
  siblings over-demand by 12–18% (`InterpBlendItem` 20 vs 17, `NiAgdDataStream` 28 vs 25).
- Per-block loop counters use the `entry().get_mut() / insert` split, not
  `or_insert(name.to_string())`; block names are interned `Arc<str>`; `ragdoll.rs` uses
  `allocate_vec` (not the old `check_alloc`).
- `pre_parse_cell`: each stream-pool task extracts (archive read + inflate) and parses its own
  input, admitted first against `STREAM_PARSE_INPUT_BYTES` (64 MiB) by the archive-declared size
  (`mesh_declared_size`) so nothing is allocated before admission; cells with < 8 fresh inputs
  keep a serial fast path. A coordinator-side extract-all barrier returning is the regression
  (its doc comment was corrected to the per-task extract in 3deda6bb7, #5063). Regression guard:
  #1171's compile-time `PartialNifImport: Send` assertion (restored beside the struct in
  `byroredux/src/streaming/mod.rs`, 7de61c2c2, #5442, after the #5092 split dropped it).
- `NifStream` caps any single file-driven allocation (256 MB); confirm new readers route
  through the capped helpers, not raw `vec![0; n]`.
- **B-spline resampling** (`anim/bspline.rs`) is import-side allocation the stream cap never
  sees: each interpolator span is intersected with the owning `NiControllerSequence`'s span,
  capped per channel at `BSPLINE_MAX_SAMPLES_PER_CHANNEL` (108 k = 30 Hz × 1 h), and charged
  against one `BsplineSampling` budget (`BSPLINE_IMPORT_KEY_BUDGET`, 1 M keys) per `import_kf`
  pass (`import_embedded_animations` gets its own, with no sequence span); a spent budget takes
  the static-pose fallback with a warn, the degraded channels counted and logged once per pass
  (`BsplineSampling::degraded` / `log_degraded`, a076ca7fb #5577) (fixed in 0c4d4af08, #5486; pins in
  `anim/tests/bspline.rs`). The sequence clamp only decides which times are emitted: `BsplineWindow`
  (`reserve()`) keeps the interpolator's own span as the `u` domain (6fef6bd57, #5572), and only a
  finite, positive-width sequence span clamps (`usable_sequence_span`, 356a74b0f, #5575 — the
  pre-10.1.0.106 `f32::MAX`/`f32::MIN` defaults must not). Sampling from the interpolator's own
  `stop_time` unclamped, or a per-channel `BsplineSampling::new()`, is the regression — the allocation abort it allows is not caught by
  `parse_one_nif`'s `catch_unwind`.
**Output**: `/tmp/audit/nif/dim_6.md`

## Phase 3: Merge

1. Read all `/tmp/audit/nif/dim_*.md`; combine into `docs/audits/AUDIT_NIF_<TODAY>.md`:
   - **Executive Summary** — clean/recoverable rate per game (vs the `nif-parser.md` matrix),
     stream-position mismatches, critical coverage gaps.
   - **Block Type Coverage Matrix** — block types × games (parsed / skipped / NiUnknown).
   - **Findings** — by severity (`_audit-severity.md` NIF rows: hard parse failure = HIGH;
     a mismatch the `block_size` reconciliation covers = MEDIUM).
   - **Prioritized Fix Order** — rendering-blocking blocks, then animation, then collision.
2. Remove cross-dimension duplicates; material translation → `/audit-nifal`.

Suggest: `/audit-publish docs/audits/AUDIT_NIF_<TODAY>.md`
