---
description: "Deep audit of NIFAL — the NIF Abstraction Layer (canonical translation tier): Imported* → translate() → Canonical, single-boundary / no-fabrication / no-render-time-fallback"
argument-hint: "--focus <dimensions> --game <fnv|fo3|skyrim|oblivion|fo4|fo76|starfield>"
---

# NIFAL Audit — Canonical Translation Layer

Deep audit of **NIFAL** (the NIF Abstraction Layer; spec: `docs/engine/nifal.md`).
NIFAL is the engine's canonical translation tier — the cornerstone of cross-game
compatibility. It is not a crate; it is the **discipline** that every per-game NIF
data category is folded into one game-agnostic representation through a single
explicit `translate()` boundary, with no `Option` "resolve-later" leaks and no
render-time heuristics downstream.

The three-tier model this audit enforces (per `docs/engine/nifal.md` §1):

```
  NIF bytes ──parse──▶  Imported*  ──translate()──▶  Canonical  ──consume──▶  ECS / GPU
            (per-game,             (one site per       (the ECS               (no per-game
             raw, messy)           category, folds     component when         branches, no
                                   in every quirk)     one already            Option fallback)
                                                       serves the role)
```

**The canonical-type rule** (spec §1): *where an ECS component already serves the
game-agnostic, engine-facing role, that component IS the canonical type.* Do NOT
flag the absence of a third `Canonical*` struct as a leak — that is deliberate
(ceremony with no new capability). The canonical tier is reached by (a) making the
`translate()` boundary the sole producer and (b) removing residual `Option`/raw
leaks from the component itself.

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

See `.claude/commands/_audit-common.md` for project layout, game data locations,
methodology, deduplication, context rules, and base finding format.
See `.claude/commands/_audit-severity.md` for the severity scale — it carries
**dedicated NIFAL rows** (wrong `translate_material` output = HIGH all-game blast
radius; translatable block silently dropped = MEDIUM, escalate to HIGH if it
removes visible content) and the matching decision-tree branches. Apply those rows;
do not re-derive severities here.

**Scope vs `/audit-nif`**: `/audit-nif` owns the *parse* side (block field
correctness, version handling, stream position, coverage). NIFAL owns the
*translate* side (does each parsed category reach one canonical representation
through one boundary, with no leak/fabrication/fallback?). When a finding is "the
bytes are read wrong," it belongs to `/audit-nif`; when it is "the bytes are read
fine but the data is dropped, duplicated, or resolved per-game downstream," it
belongs here.

## The four tier invariants (every dimension is a lens on these)

- **single-boundary** — exactly one `translate()` site per category that needs one.
  A second construction site that fills a canonical type field-by-field is a
  violation (caller count is the cheap detector).
- **no-fabrication** — no invented value / guessed normalization. A new constant
  must cite a measurement or source (`feedback_no_guessing`). The emissive no-op
  (Dim 1) and particle colour/size-curve deferrals (Dim 5) are the canonical
  "measured, then deliberately NOT normalized" examples.
- **no-leak** — no `Option` "resolve-later" field or raw enum/block-type
  discriminator on a *canonical* type reaches a consumer that has to re-resolve it.
  (Raw-tier `Imported*` carrying `Option`s is fine — the leak is the crossing into
  the canonical/consumer tier.)
- **no-render-time-fallback** — no classification deferred to a per-draw heuristic.
  The deleted `classify_pbr` / render-side glass heuristics are the cautionary tale;
  a re-introduction is a regression, not a new design choice.

**Highest blast radius first.** Order findings by NIFAL risk: (1) a wrong/divergent
canonical `Material` out of `translate_material` (HIGH, silently wrong on *every*
game, no per-draw fallback to mask it); (2) a *translatable* parsed block silently
dropped at an unsupported-shape/skip fallback (removes authored content); (3) a
no-render-time-fallback violation (classification leaked into shader/render); (4) a
single-boundary violation (a second construction site that can diverge the paths).

## Parameters (from $ARGUMENTS)

- `--focus <dimensions>`: Comma-separated dimension numbers (e.g., `1,6`). Default: all 9.
- `--game <name>`: Focus on a specific variant: `fnv`, `fo3`, `skyrim`, `oblivion`,
  `fo4`, `fo76`, `starfield`. Default: all detected (from `_audit-common.md` game
  data locations).

## Extra Per-Finding Fields

- **Dimension**: Material | Geometry/Transform | Skinning/Lights | Nodes | Particles
  | Collision | Animation | Shader-flags/Effects | Completeness
- **Tier Violated**: which tier invariant broke — `single-boundary` | `no-fabrication`
  | `no-leak` | `no-render-time-fallback` | `parked-not-leak` (verify a "deferred"
  field is genuinely unconsumed, not a silent drop)
- **Game Affected**: which variant(s) the divergence manifests on

## Phase 1: Setup

1. Parse `$ARGUMENTS`.
2. `mkdir -p /tmp/audit/nifal`.
3. Fetch dedup baseline:
   `gh issue list --repo matiaszanolli/ByroRedux --limit 200 --json number,title,state,labels > /tmp/audit/issues.json`,
   and scan `docs/audits/` for prior `AUDIT_NIFAL_*` / `AUDIT_NIF_*` reports.
4. Read `docs/engine/nifal.md` (the spec — per-category leak inventory in §2,
   surveyed/converged status per category) and `docs/engine/material-abstraction.md`
   (the material-slice predecessor; its §2 "Leak A"/"Leak B" are recorded **closed**
   in `nifal.md` §3 — do not re-report them as open).
5. Check which game data directories exist.

## Phase 2: Launch Dimension Agents

### Dimension 1: Material — the reference realisation (single boundary, no PBR fallback, glass-once)
**Entry points**:
- `byroredux/src/material_translate.rs::translate_material(source: &ImportedMaterial, mesh_name: Option<&str>, paths: ResolvedPaths, extra_material_flags: u32) -> Material` — the **single** raw-material → `Material` boundary (`ResolvedPaths { textures: MaterialTextureSet<Option<String>>, material_path: Option<String> }` also defined here). It calls `Material::resolve_pbr()` then `helpers::classify_glass_into_material` internally.
  **Signature narrowed 2026-07-27 (`05d68926`)** — it used to take `&ImportedMesh`. It now takes `&ImportedMaterial` + an explicit `mesh_name`, so the boundary provably cannot read geometry, skinning, transforms, or scene ownership. **Any future widening back to `&ImportedMesh` is a Dimension-1 regression** — flag it even if nothing currently misuses it, because the narrow type is what makes "material translation cannot depend on geometry" checkable rather than merely intended.
- Callers (MUST be exactly two, both routing through the boundary): `byroredux/src/scene/nif_loader.rs` (loose-NIF path) and `byroredux/src/cell_loader/spawn.rs` (cell path).
- `crates/core/src/ecs/components/material.rs` — the canonical `Material`; `Material::resolve_pbr()`, `classify_pbr_keyword()` (the `NaN`-sentinel backstop classifier), `EmissiveSource` enum (`None`/`Material`/`Lighting`/`Effect`).
- `byroredux/src/helpers.rs::classify_glass_into_material` — the single glass classifier (defined once; the many call sites in that file are its unit tests).
- `byroredux/src/render/static_meshes.rs` — the renderer consumer (reads `m.metalness` / `m.roughness` directly).

**Checklist**:
- `translate_material` is the ONLY site that fills a `Material` from an `ImportedMaterial`. Any second site building a `Material` field-by-field from import data is a `single-boundary` violation (the pre-converged state had two ~110-line literals in `spawn.rs` + `nif_loader.rs` — regression pattern).
- `Material.metalness` / `Material.roughness` are plain `f32` (resolved, clamped `metalness ∈ [0,1]`, `roughness ∈ [0.04,1]` — verify the clamp in `resolve_pbr`). No `metalness_override: Option<f32>` / `roughness_override: Option<f32>` field on the canonical `Material`, and no per-draw `classify_pbr` fallback in the renderer (`no-leak` + `no-render-time-fallback`).
- `resolve_pbr()` only fills `NaN` sentinels (via `classify_pbr_keyword`) then clamps; for NIF/BGSM content the override is already `Some(…)` at import so the classifier arm is a backstop. It must not overwrite an authored BGSM/BGEM override.
- Glass is classified **once**, alpha-aware, inside `translate_material` via `classify_glass_into_material`, AFTER `resolve_pbr` so the forced glass roughness wins. Engine-synthesized kinds (`material_kind >= 100`) are never demoted; conductors (`metalness >= 0.3`), non-alpha, and decals are gated out. No glass heuristic at render time (`material-abstraction.md` §2 "Leak A" is closed — confirm it stays deleted).
- `material_kind: u32` is intentionally kept as-is (it is the GPU shader-dispatch contract — the `material_kind == N` ladder in `triangle.frag`; 0–20 vanilla `shader_type`, 100 GLASS, 101 EFFECT_SHADER). Do NOT flag its `u32`-ness as a leak. **Future-slice invariant**: any `SurfaceClass` enum MUST lower to the exact `triangle.frag` ladder (drift risk vs the shader — a shader-adjacent change).
- `effect_shader_flags` packs the union of BSEffect SLSF bits (`cell_loader::pack_effect_shader_flags`) + BGSM v>2 bits (`cell_loader::pack_imported_material_flags`) + the caller's `extra_material_flags` (REFR-overlay model-space-normals on the cell path; `0` on the loose path).

**Regression pins**:
- **material_translate dedup**: the two duplicate `Material` construction literals were collapsed into `translate_material`. A field added in one load path that doesn't go through the boundary can silently diverge the two paths — the regression this dedup prevents.
- **Emissive scale = no-op** (spec §4): `Material.emissive_mult` is fed by three `EmissiveSource` variants (`Material` legacy / `Lighting` Skyrim+ / `Effect` FO4+). All three were **measured** across Oblivion/FNV/Skyrim/FO4 and already share a ~1.0 scale — **no normalization is applied or wanted**. A future "emissive normalization constant" is a `no-fabrication` violation (inventing a correction for a divergence the ground truth shows does not exist). The one genuine distinction (`BSEffectShaderProperty.base_color_scale` is a diffuse-tint, not emissive) is captured by the `EmissiveSource::Effect` discriminator and left for a future BSEffect render path (#166 rename note in `import/material/dedicated_shader.rs`). Open question Q2 in `material-abstraction.md` is resolved no-op — do not re-open it. Tooling: `crates/nif/examples/material_dump.rs` (the `emisM` + `emSrc` columns).
**Output**: `/tmp/audit/nifal/dim_1.md`

### Dimension 2: Geometry / Transform — the cleanest category (the template the others match)
**Entry points**:
- `crates/nif/src/import/coord.rs` — Z-up (Gamebryo) → Y-up (renderer): `zup_point_to_yup`, `zup_matrix_to_yup_quat` (thin wrappers over `byroredux_core::math::coord`).
- `crates/nif/src/import/mesh/tangent.rs` — `synthesize_tangents` / `synthesize_tangents_yup` (Mikkelsen synthesis fallback).
- `crates/nif/src/import/mesh/` per-game extractors — `ni_tri_shape.rs`, `bs_tri_shape.rs`, `bs_geometry.rs`; each feeds `ImportedMesh.local_bound_radius` (field on `crates/nif/src/import/types.rs`, derived via `mesh::extract_local_bound`).
- `crates/nif/src/rotation.rs` — degenerate-rotation SVD repair: `is_degenerate_rotation`, `repair_rotation_svd_or_identity`, `sanitize_rotation` (done ONCE at parse time — see #277; NOTE the repair lives in `rotation.rs`, not `transform.rs`).
- `crates/nif/src/import/transform.rs::compose_transforms` — parent×child composition (assumes rotations already sanitized).
- Consumer: `crates/renderer/src/mesh.rs::MeshRegistry::upload` (format-agnostic).

**Checklist**:
- Every per-game vertex decode (classic `NiTriShape`, Skyrim packed-half `BSTriShape`, Starfield `BSGeometry` UDEC3) converges to a single `Vec<[f32;3]>` positions + `Vec<u32>` indices in renderer space. No `Option`-gated "decode-later" geometry reaches the consumer.
- Z-up→Y-up is applied consistently at the import boundary, not duplicated per-consumer; the renderer never re-handles coordinate frames.
- Tangents either come from authored extra-data OR Mikkelsen synthesis — one resolved tangent array reaches the vertex buffer (no per-game tangent branch in the shader).
- SVD rotation repair fires once at parse; `compose_transforms` / `zup_matrix_to_yup_quat` assume valid rotations (don't re-check per composition). A consumer re-validating rotations is a leak of raw-tier messiness.
- `local_bound_radius` is derived in renderer (Y-up) space at extraction. No render-time bound recomputation.
**Output**: `/tmp/audit/nifal/dim_2.md`

### Dimension 3: Skinning & Lights
**Entry points**:
- Skinning: `crates/nif/src/import/mesh/skin.rs` — `ImportedSkin` (struct on `crates/nif/src/import/types.rs`, with `global_skin_transform`), the #613 partition-local→global bone-index remap (done at extraction).
- Lights: `crates/nif/src/import/types.rs::LightKind` (`Ambient`/`Directional`/`Point`/`Spot`) + `ImportedLight.radius`; populated in `crates/nif/src/import/walk/mod.rs`.

**Checklist**:
- **Skinning** (`no-leak` / converged): `ImportedSkin` emits **global** bone indices — partition-local remap done at extraction (#613 / SK-D1-01: pre-#613 silently aliased every vertex past partition 0). The defensive u16-range warning in `skin.rs` (the `bone_refs_slice.len() > u16::MAX` guard) must stay. `global_skin_transform` carried through. Palette skinning is game-agnostic downstream — no consumer should re-derive partition layout.
- **Lights** (`no-leak` / converged): `ImportedLight` resolves to the `LightKind` enum with a derived effective `radius` (Bethesda units, from attenuation). The renderer must NEVER inspect the source NIF block type (NiAmbientLight / NiDirectionalLight / NiPointLight / NiSpotLight) — that is the raw-tier discriminator collapsed at translate. A downstream `match` on source block type is a leak.
**Output**: `/tmp/audit/nifal/dim_3.md`

### Dimension 4: Nodes — raw-tier-parked passthroughs (verify parked, not silently dropped)
**Entry points**:
- `crates/nif/src/import/types.rs` — parked `ImportedNode` fields: `bs_value_node: Option<BsValueNodeData>`, `bs_ordered_node: Option<BsOrderedNodeData>`, `tree_bones: Option<TreeBones>`, `range_kind: Option<BsRangeKind>`, `lod_group: Option<LodGroupData>`; parked `ImportedMesh` fields: `bs_lod_cutoffs: Option<[u32;3]>`, `bs_sub_index: Option<BsSubIndexTriShapeData>`.
- Live (canonical) node data consumers: spawn sites in `byroredux/src/scene/nif_loader.rs` + `byroredux/src/cell_loader/spawn.rs` (`name`, `flags`→`SceneFlags`, `collision`→`CollisionShape`/`RigidBodyData`, `billboard_mode`→`Billboard`).

**Checklist**:
- The live node data (name, flags, collision, billboard_mode) IS consumed at the spawn sites — confirm no canonical node field is dropped.
- The `ImportedNode → ECS` step is deliberately NOT a single *translate_node* boundary (no such fn exists, and none should): the two load paths handle nodes structurally differently (loose-NIF spawns the full NiNode hierarchy as entities; cell loader uses a flattened placement-root). Do NOT flag the absence of one boundary as a `single-boundary` violation for nodes — it is documented (spec §2 Nodes).
- The fields below are **raw-tier-parked with deferred translation** — verify (per-game) they have **zero canonical ECS consumers** (`parked-not-leak`). They sit on the raw `ImportedMesh`/`ImportedNode`, which the tier model permits to carry per-game data, and reach no canonical component. If you find ANY of them feeding a canonical ECS component without a translate step, THAT is a leak finding. (Grep `\.field` / `field:` outside `types.rs`, the parser, and `_tests` — the expected hit count is zero.)

  | Field | Source block | Authored data | Blocked on |
  |---|---|---|---|
  | `bs_value_node` | `BSValueNode` | LOD-distance / billboard-mode hint (FO3/FNV) | M35 LOD selector |
  | `bs_ordered_node` | `BSOrderedNode` | alpha-sort bound + draw-order hint | `RenderOrderHint` + `build_render_data` sort key |
  | `tree_bones` | `BSTreeNode` | SpeedTree branch/trunk bone names | SpeedTree wind/bend sim |
  | `range_kind` | `BSRangeNode`/`BSDamageStage`/`BSBlastNode`/`BSDebrisNode` | destructible/blast/debris discriminator | destructible-switching / blast / debris systems |
  | `lod_group` | `NiLODNode` → `NiRangeLODData` | center + per-level near/far (Y-up); foundation parsed, import walks child 0 only; **content-absent** in shipped archives | per-frame distance-switch system |
  | `bs_lod_cutoffs` | `BSLODTriShape` | mesh-level LOD0/1/2 triangle-count cutoffs (Skyrim ~43 meshes — the content-bearing in-cell LOD) | in-cell LOD draw-count consumer |
  | `bs_sub_index` | `BSSubIndexTriShape` | dismemberment / locational-damage segment ids | dismemberment system |

  When a consumer feature lands, its slice must translate the parked field (data already captured — no parser change). Until then, this table is the bounded-gap record. The deeper passthrough inventory (NiTextureEffect, NiSwitchNode identity, BSFurnitureMarker/BSInvMarker, BSBound cell-path) lives in `nifal.md` §2 "Passthroughs" — cross-check against it; do not re-report a documented passthrough as a leak.
**Output**: `/tmp/audit/nifal/dim_4.md`

### Dimension 5: Particles — one shared overlay boundary folds every authored emitter override
**Entry points**:
- Parser (typed blocks): `crates/nif/src/blocks/particle.rs` — `NiPSysEmitter { params: EmitterBaseParams }` (box/sphere/cylinder/array/mesh variants via `read_emitter_base`/`read_volume_emitter_base`), `NiPSysEmitterCtlr { interpolator_ref }`, `NiPSysEmitterCtlrData` (legacy birth-rate), `NiPSysGrowFadeModifier { base_scale }`.
- Import: `crates/nif/src/import/walk/mod.rs::extract_emitter_params` → `ImportedEmitterParams` (surfaced on `ImportedParticleEmitter(+Flat)`); `extract_emitter_rate` (controller → `NiFloatInterpolator` constant / `NiFloatData` first key; legacy fallback `NiPSysEmitterCtlrData`).
- **The boundary**: `byroredux/src/systems/particle.rs::apply_emitter_overlays` — the **single overlay site** (#1513) that folds colour curve + base params + birth rate + force fields onto a name-heuristic preset in place. Called from BOTH `byroredux/src/scene/nif_loader.rs` (~line 513) and `byroredux/src/cell_loader/spawn.rs` (~line 642) via `crate::systems::apply_emitter_overlays` (re-exported by `pub(crate) use particle::*` in `systems.rs`). `apply_emitter_params` is the sub-helper it delegates to for the kinematic/lifetime/size subset — not itself the boundary.

**Checklist** (`no-fabrication` / `single-boundary`):
- `apply_emitter_overlays` is the single site overlaying authored data onto the preset. Both load paths route through it — a second inline overlay (colour, base params, rate, or force fields written field-by-field at a spawn site) is a `single-boundary` violation. This is the #1513 dedup: before it, the four overlays were copy-pasted inline at both sites.
- Authored **kinematic + lifetime** fields (speed, speed_variation, declination, declination_variation, life, life_variation) override the name-heuristic preset guesses (via `apply_emitter_params`).
- `initial_color` is **intentionally NOT applied** — colour stays owned by the `color_curve` override (white nif.xml default would wash out tuned presets). Flag a future change that starts applying it as a `no-fabrication` regression (in reverse).
- Spawn **rate** is authored: `extract_emitter_rate` follows `NiPSysEmitterCtlr.interpolator_ref`; the overlay sets `preset.rate` when present (FLT_MAX sentinel rejected — #1363/#1364). Legacy `NiParticleSystemController` content has no controller → keeps preset rate.
- Particle **size**: `apply_emitter_params` sets constant `start_size = end_size = initial_radius × base_scale` (`base_scale None → 1.0`). `base_scale` is essential (FNV oasis smoke `radius 50 × 0.15 = 7.5`; raw radius alone would be ~7× oversized). The grow→steady→fade bell shape canNOT map to the linear `start_size→end_size` — only the authored *magnitude* is translated (size-over-life curve is documented future work, not a leak).
- **Force fields** are Z-up→Y-up converted at overlay time (`convert_force_fields_zup_to_yup`, #984), not per-particle per-frame.

**Regression pins**:
- **Typed particle blocks**: the box/sphere/cylinder/array/mesh parsers read the base via `read_emitter_base` instead of skipping it (byte advancement unchanged, `Radius Variation` interleaved before `Life Span` per nif.xml). A parser reverting to skipping the base, or a per-game hardcoded layout without the BSVER gate, is the regression.
- Tooling: `crates/nif/examples/emitter_dump.rs` (`rate / radius / bscale / speed / spdVar / decl / declVar / life / lifeVar / initColor`).
**Output**: `/tmp/audit/nifal/dim_5.md`

### Dimension 6: Collision — every parsed bhk*Shape resolves to a CollisionShape (no silent drop)
**Entry points**:
- `crates/nif/src/import/collision/shape.rs` — `resolve_shape` / `resolve_shape_inner` (recursive bhk-shape → `CollisionShape`; split out of the sibling `crates/nif/src/import/collision/mod.rs` by #1876, which keeps `extract_collision` / `examine_collision_kind` / the documented-limitation table); `CollisionShape` / `RigidBodyData` / `MotionType` are `byroredux_core::ecs::components::collision` types (the canonical tier). Havok→engine transform + per-game `havok_scale` (`scene.havok_scale`, ×7.0 TES4/FO3/FNV, ×69.99 Skyrim+/FO4) applied uniformly. Recursion depth is bounded (#1385) and non-finite floats guarded (#1409).

**Checklist** (`no-leak` — "parsed for byte-correctness then dropped at the unsupported-shape fallback" is the prime leak class):
- Every parsed `bhk*Shape` variant is handled (resolved to a `CollisionShape`, delegated, folded into a `Compound`, or explicitly parked with a documented reason) in `resolve_shape_inner`. **As of #1334 there are 16 shape arms** (count `downcast_ref::<Bhk*Shape>` arms in `import/collision/shape.rs`; post-#1876 that file holds shapes only — the `BhkCollisionObject`/`BhkNPCollisionObject`/`BhkPCollisionObject` object downcasts live in `mod.rs`): `BhkSphereShape`, `BhkPlaneShape`, `BhkMultiSphereShape`, `BhkBoxShape`, `BhkCapsuleShape`, `BhkCylinderShape`, `BhkConvexVerticesShape`, `BhkMoppBvTreeShape`, `BhkConvexSweepShape`, `BhkListShape`, `BhkConvexListShape`, `BhkTransformShape`, `BhkNiTriStripsShape`, `BhkMeshShape`, `BhkPackedNiTriStripsShape`, `BhkCompressedMeshShape`. `BhkPlaneShape` (`#1334`) is the one deliberate exception — it returns `None` (no half-space `CollisionShape` variant yet; the trimesh fallback renders the correct ground surface anyway), documented at its arm, not a leak. A parsed `*Shape` block type with NO resolve arm at all (falls through to the unsupported-shape fallback) silently vanishes the authored collision — that is a leak finding. (The `dispatch_coverage_tests` module in `import/collision/mod.rs` already automates this diff — it scans `blocks/mod.rs` for dispatched `bhk…Shape` structs and `shape.rs` for resolve arms. Extend it rather than re-deriving the cross-check by hand.)
- Havok→engine transform + `havok_scale` are applied uniformly inside `collision.rs` (Z-up→Y-up `(x, z, -y)`, quaternion swap). No consumer re-applies the scale.
- **`hkMotionType` byte collapses to the canonical `MotionType` at translate** (`#1652`, `extract_from_classic`): the raw Havok byte resolves to `Dynamic` / `Keyframed` / `Static` / `CharacterKinematic` per the canonical `hkMotionType` enum — the per-game raw byte is the raw-tier discriminator and must NOT leak past this decode. A downstream consumer inspecting the raw motion byte (instead of `RigidBodyData.motion_type`) is a `no-leak` violation; the old `4 => Keyframed / _ => Static` collapse is a `no-fabrication` regression (invents the wrong canonical value).
- **`CollisionAuthoringSummary` — the scene-level census that disambiguates an empty shape array** (added 2026-08-07): `summarize_collision_authoring` (`crates/nif/src/import/collision/mod.rs`) scans **every** parsed collision-object block, not just the ones `resolve_shape_inner` successfully resolves, and tallies `classic` / `new_physics` / `phantom` counts. This exists because `resolve_shape`'s output is ambiguous on its own — zero decoded `CollisionShape`s on a placement means either "nothing was authored" or "FO4+/FO76/Starfield packed Havok (`BhkNPCollisionObject` → `BhkSystemBinary`) was authored but the blob is still opaque," and only the census tells them apart (`needs_packed_havok_fallback()` = `new_physics > 0`). It rides `CachedNifImport.collision_authoring` (`byroredux/src/cell_loader/nif_import_registry.rs`) into the cell loader. Two invariants to check: (1) the summary crosses the NIFAL boundary carrying only the three `u32` counts — no `bsver`, raw block-type string, or per-game enum; a game-specific field reaching `CachedNifImport` or the spawn call site is a `no-leak` violation. (2) the consumer it feeds (`cell_loader/spawn.rs::spawn_packed_havok_proxy` / `synthesize_packed_havok_proxy`) stays renderer-free — the synthesized ghost entity gets `CollisionShape` + `RigidBodyData` + `Transform`/`GlobalTransform` + `Parent(placement_root)` and explicitly **no** `MeshHandle`; a future change that attaches one would put a blob-derived guess into the BLAS/TLAS as if it were authored geometry.

**Regression pins** (do NOT re-report these as open leaks — verify they stay resolved):
- **`BhkMultiSphereShape`** → `Compound` of `Ball` children at each sphere's scaled center (single centred sphere unwraps to a plain `Ball`). Pre-fix fell through the fallback (#9c6096aa).
- **`BhkConvexListShape`** → `Compound` of resolved convex sub-shapes (mirrors `BhkListShape`; FO3/FNV/Skyrim destructibles + debris). Pre-fix dropped silently (#9c6096aa).
- **`BhkConvexSweepShape`** (delegates to its inner `shape_ref`) and **`BhkMeshShape`** (resolves tri-strip data with per-axis scale) → added #1360/#1361. A revert is a `no-leak` regression.
- **Documented limitations (NOT leaks)** — confirm they stay documented in the table at the top of `import/collision/mod.rs`, and do NOT report them as leaks:
  - `BhkNPCollisionObject` (FO4/FO76/Starfield Havok-serialised `BhkSystemBinary` blob) — the blob decoder is still a separate, undone project. As of 2026-08-07 the fallback is authoring-aware rather than blind: Architecture meshes still get `cell_loader/spawn.rs::synthesize_static_trimesh` (precise per-submesh static trimesh), while Clutter/Actor placements flagged by `CollisionAuthoringSummary.needs_packed_havok_fallback()` get a single conservative placement-following `Cuboid` proxy instead (`synthesize_packed_havok_proxy` / `spawn_packed_havok_proxy`) — an approximation of presence and rough extent, not a claim the payload was decoded.
  - `BhkPCollisionObject` phantoms (Skyrim+ trigger volumes) — need a `TriggerVolume` ECS path, not a rigid body. The `is::<BhkNPCollisionObject>` / `is::<BhkPCollisionObject>` discriminators let the trimesh fallback distinguish the two — verify they're intact.
**Output**: `/tmp/audit/nifal/dim_6.md`

### Dimension 7: Animation / controllers — single NIF→AnimationClip boundary (surveyed converged 2026-06-02)
**Entry points**:
- Parser/import: `crates/nif/src/anim/entry.rs::import_kf` (KF sequences) + `import_embedded_animations` (mesh-embedded controllers); both funnel through one set of `extract_*_channel_at` cores in `crates/nif/src/anim/`.
- **The boundary**: `byroredux/src/anim_convert.rs::convert_nif_clip` — the single NIF→core `AnimationClip` translation (multiple callers — `npc_spawn.rs`, `cell_loader/references/mod.rs` + `partial.rs`, `scene.rs` + `scene/nif_loader.rs`, `systems/animation.rs` — all route through this one fn; multiple callers of one boundary is correct, not a single-boundary violation).
- Canonical type: ECS `AnimationClip` (`crates/core/src/animation/`).

**Checklist** (`no-leak` / `no-fabrication`):
- Every per-game variation is resolved at import: B-spline compressed interpolators (FO3/FNV + Skyrim+ — *not* Skyrim-only, per `feedback_bspline_not_skyrim_only`) sampled to linear keys; XYZ-Euler rotation keys composed to quaternions; TBC/Hermite tangents decoded; Z-up→Y-up once. The player/stack consumers must see only game-agnostic quaternion keys — no `Option`/era branch downstream.
- Text-key events wired: `NiControllerSequence.text_keys_ref` → `AnimationClip.text_keys` → `AnimationTextKeyEvents` ECS → scripting. Embedded controllers set `text_keys: Vec::new()` by design (mesh-local controllers carry no event keys) — verify that's a deliberate empty, not a drop.
- Intentionally **parked** (captured, no renderer consumer yet, NOT leaks): per-light **ambient** colour channels and **morph-weight** channels. Confirm they reach no canonical consumer.
**Output**: `/tmp/audit/nifal/dim_7.md`

### Dimension 8: Shader flags / texture sets / effect shaders — per-game vocabularies collapse at parse (surveyed converged 2026-06-02)
**Entry points**:
- `crates/nif/src/shader_flags.rs` — namespaced per-game flag vocabularies (`fo3nv_f1`, `skyrim_slsf1`, `fo4_slsf1`, + FO76/Starfield CRC32 arrays), and the unit-test equivalence asserts on bits 26/27 (`fo3nv_and_skyrim_decal_bits_agree`) guarding bit-meaning collisions. The `ShaderFlags<'a>` typed view and its `is_decal()` / `is_two_sided()` were deleted as transitively dead in *#1897*; the live decal / two-sided helpers are `is_decal_from_legacy_shader_flags` / `is_decal_from_modern_shader_flags` / `is_two_sided_from_modern_shader_flags` in `crates/nif/src/import/material/mod.rs`.
- `MaterialInfo` (`crates/nif/src/import/material/`) — decal / two-sided read once per property type; `BSShaderTextureSet` slot→role mapping keyed on `shader_type`.
- `EmissiveSource::Effect` + `material_kind == 101` — `BSEffectShaderProperty` capture/route.

**Checklist** (`no-render-time-fallback` / `no-leak`):
- Per-game flag vocabularies are dispatched by **block type** (the wire format already discriminates the game), NOT by a runtime `if game ==`. Verify `triangle.frag` **and its `#include`d `include/*.glsl` headers** have **zero** `if game ==` branches — the renderer reads `material.is_decal` / `two_sided` with no per-game branch. A per-game branch leaking into the shader is the cardinal `no-render-time-fallback`/leak violation for this dimension.
- All 9 `BSLightingShaderProperty` shader-type variants forward their trailing data (SkinTint/HairTint/Parallax/MultiLayer/Eye/Sparkle — the pre-#343 8-of-9 drop is closed). A variant dropping its trailing data is a regression.
- `BSEffectShaderProperty` captured + routed (EFFECT_* flags, `material_kind == 101`). The one *deferred* item is the `base_color_scale` diffuse-tint-vs-emissive render path — tagged via `EmissiveSource::Effect`, not dropped (don't re-report as a leak).
- **FO4 render-affecting flags reach `MaterialInfo`** (`#1592`, `import/material/dedicated_shader.rs` — split out of *walker.rs* by #2059): `Model_Space_Normals` (F4SF1 bit 12) + `Alpha_Test` (F4SF2 bit 25) are ORed into `MaterialInfo` (plus the FO76+ `MODELSPACENORMALS` CRC for `bsver >= 132`) so the per-game flag vocabulary collapses into the canonical fields (`model_space_normals` / `alpha_test`), not a render-time `if game == fo4`. The NIF flag is a strictly lower-priority source than the later BGSM merge (which OR-upgrades). Regression = the walker parsing the F4SF pair but dropping these bits (a `no-leak` violation — the data is read fine but never reaches the canonical material).

**Texture roles — the 2026-07-27 unification (`1d94eb24` + `05d68926`). This is now the primary subject of this dimension:**
- `MaterialTextureSet<T>` (`crates/nif/src/import/types.rs`) is the canonical texture vocabulary: 18 named roles (`base_color`, `normal`, `emissive`, `detail`, `smooth_spec`, `dark`, `height`, `environment`, `environment_mask`, `tint`, `inner_layer`, `specular`, `lighting`, `flow`, `wrinkle`, `greyscale_lut`, `reflectance`, `emittance_gradient`) plus ordered `decals: [T; 4]`.
- **Every** source populates the same roles: `NiTexturingProperty` (legacy), `BSShaderTextureSet` (FO3→Skyrim), BGSM/BGEM (FO4+), Starfield `.mat`/CDB, `BSEffectShaderProperty`. **A per-game slot index surviving past the NIF import boundary is the cardinal violation for this dimension** — game-specific slot numbers and container formats must stop there.
- `T` is generic over pipeline stage and must be converted with `map_ref`, never re-parsed: `Option<FixedString>` (imported) → `Option<String>` (archive-resolved, `ResolvedPaths`) → bindless index (renderer). A consumer that reconstructs a path string from a role it was already handed is a `no-leak` violation.
- Two roles are deliberately distinct and are the likeliest mis-merge: `smooth_spec` (smoothness / specular-**strength** mask — legacy gloss map, BGSM smooth-spec) vs `specular` (standalone specular-**colour** map). Collapsing them silently changes shading on FO4 content. Same care for `environment` vs `environment_mask`.
- **Three hand-written role walks, only one of which the compiler protects.** `map_ref` builds a full struct literal, so a forgotten role there *is* a compile error — it is safe. `values()` returns a fixed-size array `.chain(self.decals.iter())` and **is not protected**: add a role to the struct, forget it in `values()`, and every lifecycle consumer (texture release, validation, any exhaustive visit) silently skips it — no compile error, no test failure unless one asserts the count. Diff `values()`'s array against the struct field list every time this type changes; that is the actual regression surface, not `map_ref`.
- `secondary_values()` is `values().skip(1)`, which hard-codes "base_color is element 0". Reordering `values()` so base_color isn't first silently reclassifies a role as secondary. Flag any reordering of that array as load-bearing.
**Output**: `/tmp/audit/nifal/dim_8.md`

### Dimension 9: Translation-completeness signal + cross-cutting tier invariants
**Entry points**:
- `crates/nif/tests/translation_completeness.rs` — `cross_game_translation_completeness` (`#[ignore]`-gated; run with `cargo test -p byroredux-nif --test translation_completeness -- --ignored`), `collect_stats`, `MaterialStats::record/print_row`. Per-game (`Oblivion`/`FNV`/`Skyrim`/…) aggregate fill-rate over the canonical `Material` slots.

**Checklist** (the four tier invariants stated up top, applied across every dimension):
- **single-boundary**: each category that needs one declares its boundary, not scattered construction — Material ✓ `translate_material`; Particles ✓ `apply_emitter_overlays`; Animation ✓ `convert_nif_clip`; EXAL exterior ✓ `env_translate.rs::translate_*`; Nodes ✗ by design (Dim 4). New categories must declare a boundary.
- **no-fabrication**: the emissive no-op (Dim 1) and particle colour/size-curve deferrals (Dim 5) are the canonical "measured, then deliberately NOT normalized" examples. Any new constant must cite a measurement or source.
- **no-leak**: no `Option`/raw discriminator on a canonical type reaches a consumer that re-resolves it.
- **no-render-time-fallback**: no classification deferred to a per-draw heuristic (the deleted `classify_pbr` / render-side glass heuristics are the cautionary tale; `triangle.frag` `if game ==` count must be zero — Dim 8).
- The completeness harness is the **per-game coverage signal**: a category that converges on FNV but drops to ~0 fill on Starfield is an unverified-game leak even if no single-game audit flagged it. Treat large per-game fill-rate divergence (in `print_row` output) as a lead, not gospel — verify the underlying extractor.
**Output**: `/tmp/audit/nifal/dim_9.md`

## Phase 3: Merge

1. Read all `/tmp/audit/nifal/dim_*.md` files.
2. Combine into `docs/audits/AUDIT_NIFAL_<TODAY>.md` (YYYY-MM-DD) with structure:
   - **Executive Summary** — per-category convergence status (converged / triaged /
     pending) vs the spec §2 leak inventory; count of single-boundary / no-fabrication
     / no-leak / no-render-time-fallback violations found.
   - **Per-Category Tier Matrix** — table of category × tier-invariant (single-boundary,
     no-fabrication, no-leak, no-render-time-fallback) marked pass / fail / N-A, with the
     boundary fn cited for each.
   - **Findings** — grouped by severity (apply the NIFAL severity rows in
     `_audit-severity.md`), using the base finding format plus the Extra Per-Finding
     Fields above.
   - **Documented-limitation ledger** — restate the parked-not-leak items (node/mesh
     passthroughs, FO4+ NP blob, phantoms, size-over-life curve, ambient/morph anim
     channels) so they are not re-reported next sweep.
3. Remove cross-dimension duplicates.

Run `.claude/commands/_audit-validate.sh` before finalizing (backticked paths must
resolve against the live tree — Path-Reference Convention in `_audit-common.md`).

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