matiaszanolli

NIF Parser Audit

"Deep audit of NIF parser — stream position, version gating, block dispatch coverage, geometry handoff"

matiaszanolli 28 3 Updated 1w ago
GitHub

Install

npx skillscat add matiaszanolli/byroredux/claude-commands-audit-nif

Install via the SkillsCat registry.

SKILL.md

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 under crates/nif/tests/. They run
    nightly 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.
    Still true 2026-10-10: the workflow parses since d98769436 (#4619, closed), but no
    byroredux-game-data self-hosted runner is registered (repo runner count 0) — every scheduled
    run queues and is cancelled at 24 h, so no nightly result exists (the corpus jobs now 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).
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 matches
    (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). Known-open harness gaps (dedup, do not re-file): the #5260 total_blocks pin
    measures parser output, so a truncation regression reads as "corpus drift" (#5438);
    open_mesh_archive and the Oblivion drift corpus still treat present-but-unopenable as
    absent (#5439); the #5259 scan only matches the ends_with(".nif") spelling, so nif_stats'
    directory mode still walks .nif only (#5440).
  • 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 BlockRefs 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
    tangent::synthesize_tangents (Mikkelsen). 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. 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; drift must be a known motor-tail value).
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. By-design
    residuals are pinned by corpus::is_known_constraint_motor_tail_drift (1/18/19/26;
    malleable +4; the three no-motor types exactly 0). 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. 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. 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). Known-open #5442:
    the #5092 streaming.rs split dropped #1171's compile-time PartialNifImport: Send
    assertion (still enforced at the channel send).
  • 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