"Deep audit of NIFAL — the NIF Abstraction Layer (canonical translation tier): Imported* → translate() → Canonical, single-boundary / no-fabrication / no-render-time-fallback"
Install
npx skillscat add matiaszanolli/byroredux/claude-commands-audit-nifal Install via the SkillsCat registry.
NIFAL Audit — Canonical Translation Layer
Audit NIFAL (spec: docs/engine/nifal.md): the discipline that every per-game NIF data
category is folded into one game-agnostic representation through a single explicittranslate() boundary — no Option "resolve-later" leaks, no render-time heuristics.
NIF bytes ──parse──▶ Imported* ──translate()──▶ Canonical ──consume──▶ ECS / GPU
(per-game, raw, messy) (one site per category) (the ECS component that already
serves the role; no third type)Canonical-type rule (spec §1): where an ECS component already serves the game-agnostic
role, that component IS the canonical type. Do NOT flag the absence of a third Canonical*
struct.
Architecture: Orchestrator. Each dimension runs as a Task agent (max 3 concurrent).
Read .claude/commands/_audit-common.md and .claude/commands/_audit-severity.md (it has
dedicated NIFAL rows: wrong translate_material output = HIGH; translatable block silently
dropped = MEDIUM, HIGH if it removes visible content). Do not re-derive severities.
Scope vs /audit-nif: NIF owns the parse side (bytes read wrong). NIFAL owns the
translate side: bytes read fine but data dropped, duplicated, or resolved per-game
downstream. Not here: mesh-water and env translation (water_material_from_mesh,attach_mesh_water in material_translate.rs, env_translate/) → /audit-exterior;GpuMaterial layout/shader contract → /audit-renderer; Material save shape →/audit-save; BGSM/BGEM/CDB parse → /audit-parsers (the merge boundary is Dim 8 here).
The four tier invariants (every dimension is a lens on these)
- single-boundary — exactly one
translate()site per category; a second construction
site filling a canonical type field-by-field is a violation (caller count is the detector). - no-fabrication — no invented value or guessed normalization; a new constant must cite a
measurement or source (feedback_no_guessing). Canonical "measured, then deliberately NOT
normalized": emissive scale (Dim 1), particle colour/size-curve deferrals (Dim 5). - no-leak — no
Option"resolve-later" field or raw enum/block-type discriminator on a
canonical type reaches a consumer that re-resolves it (raw-tierImported*may carry them). - no-render-time-fallback — no classification deferred to a per-draw heuristic (the deleted
classify_pbr/ render-side glass heuristics; re-introduction is a regression).
Risk order: (1) wrong/divergent canonical Material from translate_material (all games, no
fallback to mask it); (2) a translatable block silently dropped; (3) a render-time fallback;
(4) a second construction site.
Guard confidence is limited. The text-scan / kitchen-sink completeness guards below were
hardened (#4404 particles, #4405 animation, #4411 material + Lights/Collision resolve scans:
comments, string literals and test modules are now stripped; the material pin scan reads only#[test]-registered fn bodies; value fixtures no longer equal the preset/default) but remain
structural. A green guard is not proof a field is asserted: open the test and confirm a real
assertion exists for the field you are auditing.
Parameters (from $ARGUMENTS)
--focus <dimensions>: comma-separated numbers (e.g.1,8). Default: all 9.--game <name>:fnv,fo3,skyrim,oblivion,fo4,fo76,starfield. Default: all detected.
Extra Per-Finding Fields
- Dimension: Material | Geometry/Transform | Skinning/Lights | Nodes | Particles | Collision | Animation | Shader-flags/Effects | Completeness
- Tier Violated:
single-boundary|no-fabrication|no-leak|no-render-time-fallback|parked-not-leak(verify a "deferred" field is genuinely unconsumed) |harness-gap - Game Affected: variant(s) where the divergence manifests
Phase 1: Setup
- Parse
$ARGUMENTS;mkdir -p /tmp/audit/nifal. gh issue list --repo matiaszanolli/ByroRedux --limit 200 --json number,title,state,labels > /tmp/audit/issues.json;
scandocs/audits/forAUDIT_NIFAL_*/AUDIT_NIF_*. NIFAL has many open findings —
dedup carefully (gh issue list --search "NIFAL in:title").- Read
docs/engine/nifal.md(§2 per-category inventory — Skinning/Particles prose was
re-synced by #4410/#4409; §3 lists the drawn-surface exemptions) anddocs/engine/material-abstraction.md(Leak A/B are
recorded closed — do not re-report). cargo test -p byroredux-nif -p byroredux material_translatefor the default-lane guards.
Phase 2: Dimensions
Dimension 1: Material — the reference realisation
Paths: byroredux/src/material_translate.rs, crates/core/src/ecs/components/material.rs, byroredux/src/helpers.rs, byroredux/src/cell_loader/{spawn/mesh_instance,placement_lod,object_lod}.rs, byroredux/src/scene/nif_loader.rs
First step: git diff <last-report>..HEAD -- crates/core/src/ecs/components/material.rs byroredux/src/material_translate.rs | grep '^[+-].*pub '
The boundary: translate_material(source: &ImportedMaterial, mesh_name: Option<&str>, paths: ResolvedPaths, extra_material_flags: u32) -> Material — the only raw-material → Material producer (calls Material::resolve_pbr() then helpers::classify_glass_into_material_with_provenance). Its narrow input type is what makes "material translation cannot depend on geometry" checkable: widening it back to &ImportedMesh is a regression even if nothing misuses it yet. Callers must all route through it: loose path nif_loader.rs, cell path spawn/mesh_instance.rs, LOD paths placement_lod.rs + object_lod.rs; translate_texture_only_material serves draws with no NIF material (terrain, water, .btr) — each such spawner still has to get a Material.
Guards (byroredux crate tests): every_exterior_spawner_inserts_a_boundary_material (scans SPAWNER_ROOTS for , MeshHandle( inserts; file granularity only), both_spawn_sites_derive_markers_through_this_boundary, translate_material_copies_every_canonical_field + every_source_derived_material_field_is_pinned_by_a_test (kitchen sink; pins counted only inside #[test] fn bodies, #4411), nif_importer_material_kind_literals_match_renderer_constants — all in material_translate.rs; workspace_hygiene_tests::no_source_file_frames_the_deleted_classify_pbr_as_live (docs only — nothing forbids a render-time classifier in code).
Checklist (what the guards cannot see):
- Only
translate_materialbuilds aMaterialfrom import data (a second field-by-field literal issingle-boundary). Phase-2 resolver scope: it runs only on the two paths carrying a sourceImportedMaterial(nif_loader,mesh_instance); the three terrain spawners deliberately skip it (#4264, documented ontranslate_texture_only_material;terrain_lod_btr.rscalls onlyresolve_msn_z_source, #4632). The module doc andnifal.mdnow list all three resolvers incl. the BGSM-onlyresolve_unresolved_gloss_neutral_roughness, withplacement_lod.rs+object_lod.rsexempt because "all three early-return withoutMaterialTextureHandles" — still false for that third one (it readssmooth_specwithunwrap_or(0)and proceeds), so #4246 stays OPEN on that half; both tables also state that resolver's trigger backwards (#5437, open). Material.metalness/roughnessare plainf32, clamped[0,1]/[0.04,1]inresolve_pbr; noOptionoverride field on the canonical type; no per-drawclassify_pbrin the renderer.resolve_pbrfills onlyNaNsentinels and reads the threadedspecular_authored, never a hardcodedfalse; it must not overwrite an authored BGSM override. The NaN arm is a live classifier input, not a backstop: the BGEM merge and Starfield material-reference stubs deliberately leave both overrides NaN (#4441, c2b67d81e). A Starfield CDB hit instead stampsPbrMaterial::NO_SIGNAL_NEUTRALscalars so the keyword classifier never runs over the CDB-resolved colour path (#5197, 978d25c19); only a lookup miss reaches the classifier.- Glass is classified once, alpha-aware, inside
translate_materialafterresolve_pbr(forced glass roughness wins); engine-synthesized kinds (material_kind >= 100: 100 glass, 101 effect, 102 no-lighting, 103 fire-refraction) are never demoted; conductors, non-alpha and decals are gated out. The authored-dispatch guard protects kinds2..=20: Environment Map (1) is a glass authoring and must still classify (#4392; the mirror-pane guard deliberately keeps1..=20). Production callsclassify_glass_into_material_with_provenance(classify_glass_into_materialis a#[cfg(test)]shim) with two distinct provenance signals (#5099):external_material_resolvedgates effect-carrier keyword promotion (set by the BGSM/BGEM merge arms and both CDB arms, #4283),from_bgsmgates the authored lit-dispatch override (#4855) — re-conflating them strands Starfield CDB glass. CDBIsGlassroutes tobgem_glass(the classifier's authored positive input) and never forcesthin_glass(#5196); because Starfield shapes almost never carryNiAlphaProperty, that input is only live through the CDB's authoredEffectSettingsComponent.BlendingMode—"AlphaBlend"forwardshas_alphaso the classifier's transparent-coverage gate can pass (9bb6b7dce, #5277).ObjectInfo.Parentinheritance of the settings components is resolved (5c14ebdf8, #5502):IsGlassnow reads on 217 referenced vanilla materials, but none of them authors a coverage signal (BlendingMode,HasOpacity,AlphaTestThreshold), so the classifier stays inert for want of coverage — measured, not a defect; the remaining half of #5502 is texture-set slot inheritance (below). TheAlphaTestThreshold→alpha_testtranslation requiresHasOpacity = trueauthored somewhere on theParentchain; an absent field is the measured class defaultfalse(0d698d16c, #5661 — issue still open; pinalpha_test_requires_an_authored_has_opacity_true). Additive-family blend modes (Additive,SourceSoftAdditive, …) are not forwarded but are now parked by name with audit counts innifal.md(dbfae5a34); #5509 stays open until a capture lands the carry-over.translate_material_with_provenancealso returns whether the mirror-pane branch forced its state, and the spawn-timeresolve_unresolved_gloss_neutral_roughnesspass (#3905) skips such panes (#5230, 9f0a8a7cc) — it must not rewrite the classifier's roughness 0.04 / metalness 1.0.material_kind: u32is the GPU shader-dispatch contract — itsu32-ness is not a leak. Known-open: the shader-type discriminator never reaches the canonicalMaterial(#4256). - Carried, not re-derived:
texture_clamp_mode/src_blend_mode/dst_blend_modecopy the import fields (defaults 0 / 6 / 7); Oblivionparallax_height_in_alphais decided once fromNiTexturingProperty.apply_mode(a downstreamif game == oblivion, or an invented Oblivion height scale, is the violation); the detail-combine neutral is declared by the producer (Material.detail_neutral: 128/255 classic, 65/255 Skyrim FaceTint viaslot_role::detail_neutral_for; detail view is raw UNORM, the shader divides in encoded space with no per-game branch — #4422). A shader-side per-game constant for any of these isno-render-time-fallback. - Every
f32field added toMaterialmust be added toMaterial::sanitize_finite's macro list in the same commit (silently missed once — the BGEM glass-optics fields, #3373;detail_neutraldid it right); a nested struct gets its ownsanitize_finitedescent (WetnessShading/LuminanceShading, #4282 — Starfield wetness/luminance, capture-only, noGpuMaterialconsumer yet). Neither the size pin nor the offset pin catches an omission;Materialis a saved column (shape change →/audit-save). - Emissive scale = no-op (spec §4): the three
EmissiveSourcevariants were measured across four games and share ~1.0 scale; an "emissive normalization constant" isno-fabrication. Open question Q2 inmaterial-abstraction.mdis resolved — do not re-open. Tool:crates/nif/examples/material_dump.rs.
Output:/tmp/audit/nifal/dim_1.md
Dimension 2: Geometry / Transform — the template the others match
Paths: crates/nif/src/import/{coord,transform,mod,units}.rs, crates/nif/src/rotation.rs, crates/nif/src/import/mesh/{ni_tri_shape,bs_tri_shape,bs_geometry,tangent}.rs
First step: git log --since=<last report> --format='%h %s' -- crates/nif/src/import/mesh crates/nif/src/rotation.rs
Guards: tangent_convention_tests.rs, crates/nif/src/import/mesh/*_tests.rs per-game extractors.
Checklist:
- Every per-game vertex decode (classic, packed-half
BSTriShape, StarfieldBSGeometryUDEC3) converges to renderer-space positions +u32indices; noOption-gated decode-later geometry reaches the consumer (MeshRegistry::uploadincrates/renderer/src/mesh.rsis format-agnostic). - Z-up→Y-up once at the import boundary (
zup_point_to_yup,zup_matrix_to_yup_quat), never per consumer. One resolved tangent array (authored or Mikkelsensynthesize_tangents) reaches the vertex buffer. - Spatial units converge once: Starfield meters → 70 units/m in
import/units.rs(length_scale, gatedbsver >= FO76_STARFIELD_BOUNDARY) for meshes, hierarchy, emitters, force fields, lights and animation distances;BSGeometryvertices also divide out the 69.969HAVOK_SCALEtooling factor. A consumer re-scaling Starfield units, or a scaled rotation/weight/timing, issingle-boundary/no-fabrication(pins:import/tests/units.rs). - Degenerate-rotation SVD repair (
is_degenerate_rotation,repair_rotation_svd_or_identity,sanitize_rotationinrotation.rs) fires once at parse; a consumer re-validating rotations is a raw-tier leak.local_bound_radiusis derived at extraction (extract_local_bound), Y-up; no render-time recomputation.
Output:/tmp/audit/nifal/dim_2.md
Dimension 3: Skinning & Lights
Paths: crates/nif/src/import/mesh/{skin,sse_recon}.rs, crates/nif/src/import/walk/lights.rs, crates/nif/src/import/types.rs
First step: grep -rn 'skin_attach_bone_names\|widen_packed_bone_indices' crates/nif/src
Guards: walk::lights::light_dispatch_coverage_tests (all four NiLight arms present; anti-vacuity assert); walk::tests::the_scene_graph_walkers_never_call_a_satellite_walker; sse_skin_index_space_tests.rs (#[ignore], Skyrim data).
Checklist:
ImportedSkinemits global bone indices. Skyrim SE packed indices already address the skin bone list and are only widened (widen_packed_bone_indices); a partition-palette remap on that channel is the regression (see/audit-nifDim 4). Palette skinning is game-agnostic downstream — no consumer re-derives partition layout.- Starfield bone names read both authored channels,
SkinAttachfirst (skin_attach_bone_names), then node refs, then the geometric solver, then aBone{i}placeholder; a count mismatch between channels declines the list rather than zipping (#3930). Reverting tobone_refs-only fabricates names. - Lights:
ImportedLightresolves toLightKind+ effectiveradius; the renderer never inspects the source block type.NiSpotLight/NiDirectionalLightdirection is world column 0 (Gamebryo model direction (1,0,0), #4395), with the directional kind negated once at the boundary ("toward the light"). Affected-node scoping is untranslated for everyNiLightkind —NiNode.effectsis parsed but walked only for texture effects, soImportedLightcarries no scope (parked, recorded innifal.md"Lights" by dd8c1a1f1, #5282); the only mitigation isspawn_nif_lights'__MAX_Default_Lightname skip (is_known_exporter_artifact_light_name, #5189), a census-backed drop, not a scoping translation. Light-bearing model NIFs (barrel fires, torches) place the light at their exact-nameAttachLightnode viafind_attach_light_offset(crates/nif/src/import/attach_light.rs, composed root→node chain, switch nodes following the active child; the earlierflame/firesubstring match picked the wrong node in ~24 vanilla files and was reverted) — one resolver, consumed by the cell-loader spawn path only.NiAmbientLightis further parked by census (0 live vanilla population;crates/nif/examples/ambient_light_census.rs; the spawn gate's colour-sum predicate drops black ones).
Output:/tmp/audit/nifal/dim_3.md
Dimension 4: Nodes — raw-tier-parked passthroughs (verify parked, not silently dropped)
Paths: crates/nif/src/import/types.rs, spawn sites byroredux/src/scene/nif_loader.rs, byroredux/src/cell_loader/spawn.rs
First step: for f in bs_value_node bs_ordered_node tree_bones range_kind lod_group bs_lod_cutoffs bs_sub_index; do grep -rn --include='*.rs' "\.$f\b" byroredux/src crates/renderer/src crates/core/src | grep -v test; done — expected empty (zero canonical consumers).
- Live node data (name, flags →
SceneFlags, collision,billboard_mode→Billboard) IS consumed at the spawn sites. There is deliberately no single translate_node (loose-NIF spawns the full hierarchy; the cell loader a flattened placement root) — do not flag its absence. - Parked with deferred translation (blocker in parens):
bs_value_node(M35 LOD selector),bs_ordered_node(render-order hint),tree_bones(SpeedTree wind),range_kind(destructible/blast/debris systems),lod_group(per-frame distance switch; content-absent),bs_lod_cutoffs(in-cell LOD draw count),bs_sub_index(dismemberment). Any of them feeding a canonical component without a translate step is a leak. The deeper passthrough inventory (NiTextureEffect, NiSwitchNode identity, BSFurnitureMarker, BSBound) isnifal.md§2 "Passthroughs" — cross-check before reporting.
Output:/tmp/audit/nifal/dim_4.md
Dimension 5: Particles — one overlay boundary folds every authored emitter override
Paths: byroredux/src/systems/particle.rs, crates/nif/src/import/walk/emitter.rs, crates/nif/src/blocks/particle.rs
First step: grep -rn 'apply_emitter_overlays(' byroredux/src | grep -v 'systems/particle.rs' (expect the two spawn sites: cell_loader/spawn.rs, scene/nif_loader.rs)
The boundary: apply_emitter_overlays folds colour curve, base params (apply_emitter_params), birth rate, force fields, texture/blend, max particles, effect shader and greyscale LUT onto a name-heuristic preset; both load paths route through it (#1513).
Guards: every_overlay_parameter_reaches_the_preset (value test, each overlay set distinct from the preset) + every_declared_overlay_parameter_is_read_by_the_body (source scan) — hardened by #4404 (05fc45943: comment/substring matches stripped, value fixtures differ from the preset) but still structural; see "Guard confidence" above.
Checklist (no-fabrication / single-boundary):
- A second inline overlay at a spawn site is a
single-boundaryviolation. Authored kinematics + lifetime + size override the preset;initial_coloris intentionally NOT applied (the colour curve owns colour — starting to apply it is a reverseno-fabricationregression). Size =initial_radius × base_scale(base_scaleessential: FNV oasis smoke 50 × 0.15); the grow→steady→fade bell cannot map to linear start/end — only magnitude translates. - Birth rate is authored (
extract_emitter_rate; FLT_MAX sentinel rejected; four tiers in order — constant/keyed curve recovered as the time-weighted mean, notkeys.first()or the peak (#3754), legacyNiPSysEmitterCtlrDatafirst key, blend-interpolator sub-target (#2548), embedded-sequence scan for manager-driven blends (#3329,Idle-family preferred); sibling-controller resolution bytarget_ref, #4467 — see/audit-nifDim 4). LegacyNiParticleSystemControllerkeeps the preset rate. - Planar spread is applied (#4240): NIF authors a half-spread, the canonical field is full width — the translate doubles it (
× 2.0); fog-volume sizing must use the doubled value. Attribution: base params, colour, budget and the rate tiers are per-instance (#4261; the legacyNiPSysEmitterCtlrDatatier via the ctlr's owndata_ref, #4560; an own budget block that resolves is decisive even when it authors none, #4550); only the unresolvable-ref budget scan remains whole-scene (the #3329 sequence tier went per-instance with #4620: a controlled block with a non-nullcontroller_refmust name the system's own ctlr). - Force fields are Z-up→Y-up converted once at overlay time (
convert_force_fields_zup_to_yup) and stay world-space. Emitter orientation is carried (#4398): the flat import holds the composed host × block rotation, the cell spawn composes REFR × NIF onto the billboard and fog-volume entities, andparticle_systemrotates the shape offset and cone byGlobalTransformrotation — an identity stamp at spawn is the regression. Tool:crates/nif/examples/emitter_dump.rs.
Output:/tmp/audit/nifal/dim_5.md
Dimension 6: Collision — every parsed bhk*Shape resolves (no silent drop)
Paths: crates/nif/src/import/collision/{mod,shape,ragdoll}.rs
First step: cargo test -p byroredux-nif dispatch_coverage_tests
Guard: import::collision::dispatch_coverage_tests::every_dispatched_bhk_shape_has_resolve_arm (every Bhk*Shape with a dispatch arm has a downcast_ref arm in the resolve_shape_inner body; the source scan slices to that fn since #5573/06755dc5a — a sibling downcast_ref chain cannot mark a shape resolved — and a missing fn fails loudly; still structural per #4411). Count shape arms fresh; do not quote a number.
Checklist (no-leak — "parsed for byte-correctness then dropped at the unsupported-shape fallback" is the prime leak class):
- Every parsed
bhk*Shapeis resolved, delegated, folded into aCompound, or explicitly parked with a documented reason.BhkPlaneShape → Noneis the documented exception — no collider is produced for its placement: the one vanilla instance's alpha-tested render mesh is rejected by the trimesh fallback's!alpha_testgate (#4407; the drop is counted inplane_shapesand surfaced by the spawn census). Havok→engine transform + per-gamehavok_scaleare applied uniformly incollision/; recursion depth is bounded and non-finite floats guarded. - The raw
hkMotionTypebyte collapses to canonicalMotionTypeat translate (havok_motion_type); a consumer reading the raw byte isno-leak. CollisionAuthoringSummary(summarize_collision_authoring) crosses the boundary carrying only fouru32counts (classic,new_physics,phantom,plane_shapes) — nobsver/block-type string/per-game enum (a game-specific field reachingCachedNifImportisno-leak). Its consumer (cell_loader/spawn.rs::spawn_packed_collision_proxy) stays renderer-free:CollisionShape+RigidBodyData+ transforms, and noMeshHandle(a blob-derived guess must not enter the BLAS/TLAS as authored geometry).- Havok surface material is raw and parked (#5574, 993d52f3a;
nifal.md"Havok surface material"):RigidBodyData::surface_material/part_surface_materials(leaf-aligned withcollision_shape_to_parts, pushed byresolve_shape_intoat the exact site that emits each leaf — the separate second-traversal walker was deleted, daddf52f5 #5550, so a new leaf kind must push its material there; packed-tri-stripsub_partsresolved, edb57dd9f #5541) carry the loaded game's own id space (Oblivion 0–31, FO3/FNV 0–127, Skyrim CRC32;0= STONE vs NONE). Consumers (FootstepConfig/ImpactConfigsurface_sounds) must not branch on the game; a canonical surface id moves the saved column (FORMAT_MAJOR,/audit-save). - Documented limitations (NOT leaks; confirm they stay in the table at the top of
import/collision/mod.rs):BhkNPCollisionObjectblob does not resolve to a shape (container decoded,hknpCompressedMeshShapeDatalayout not —docs/engine/physal.md);BhkPCollisionObjectphantoms need a trigger-volume path, not a rigid body; decoded-but-not-imported constraint kinds (ball-and-socket / spring / chain).
Output:/tmp/audit/nifal/dim_6.md
Dimension 7: Animation / controllers — single NIF→AnimationClip boundary
Paths: byroredux/src/anim_convert.rs, byroredux/src/asset_provider/animation.rs, crates/nif/src/anim/
First step: cargo test -p byroredux -- canonical_animation_completeness_harness every_hkx_sample_field
Boundary: convert_nif_clip (many callers, one function — correct). It is not the only producer of the canonical AnimationClip: convert_hkx_clip builds it from Havok behaviour-graph idles; a field added to AnimationClip usually needs both producers. convert_hkx_clip takes the caller's one_shot / accum_root policy as arguments (#4563) — a caller overriding cycle_type or root binding after conversion is single-boundary.
Guards: anim_convert::canonical_animation_completeness_harness::{every_clip_scalar_survives_convert_nif_clip, every_transform_channel_field_survives_convert_nif_clip, every_non_transform_channel_survives_convert_nif_clip}; asset_provider::animation::tests::every_hkx_sample_field_survives_convert_hkx_clip — value fixtures chosen so a dropped field can't fake a pass (#4405: two keys per channel, non-default enums).
Checklist:
- Per-game variation resolved at import: B-spline interpolators (FO3/FNV and Skyrim+ — feedback_bspline_not_skyrim_only) sampled to linear keys over the owning sequence's span, under an aggregate key budget whose exhaustion takes the static pose (#5486 — allocation side is
/audit-nifDim 6); XYZ-Euler composed to quaternions; TBC/Hermite decoded; Z-up→Y-up once. Consumers see only game-agnostic quaternion keys. NiPathInterpolatoris composed, not copied (#5571):extract_transform_channel_pathevaluatespath(percent(t))— theNiFloatDatapercent keys map animation time onto theNiPosDatapath keys (time domain 0..1) — and resamples under the B-spline span/ceiling/budget bounds; rotation/scale stay empty (never identity); a path without percent data is skipped. Follow-axis / banking / constant-velocity / closed-curve wrap are parked innifal.md§Animation, not leaks. B-spline sampling keeps the interpolator's own span as the curve parameter domain and the window only trims emitted times (BsplineWindow, #5572); a sequence span clamps only when finite and forward (usable_sequence_span, #5575).- Text-key events wired end to end (
text_keys_ref→AnimationClip.text_keys→AnimationTextKeyEvents); embedded controllers' emptytext_keysis deliberate. Parked, not leaks: per-light ambient colour channels and morph-weight channels (confirm no canonical consumer). - One rotation-key sanitizer: every rotation entering a canonical
RotationKey(KF keys, static poses, B-spline samples/fallbacks) routes throughnormalized_rotation_sample(anim/keys.rs), which skips non-finite / FLT_MAX / overflowing / near-zero samples — never substitutes identity (#4396/#4406); static-pose gates useis_key_value_sane, not bareis_flt_max(NaN-inclusive, #4397). A second sanitizer or an identity fallback is the regression.
Output:/tmp/audit/nifal/dim_7.md
Dimension 8: Shader flags / texture roles / effect shaders (highest report yield)
Paths: crates/nif/src/shader_flags.rs, crates/nif/src/import/material/{slot_role,dedicated_shader,walker,mod}.rs, crates/nif/src/import/types.rs (MaterialTextureSet), byroredux/src/asset_provider/material/merge.rs, byroredux/src/cell_loader/refr.rs, byroredux/src/asset_provider/texture.rs
First step: git log --since=<last report> --format='%h %s' -- crates/nif/src/import/material byroredux/src/asset_provider/material byroredux/src/cell_loader/refr.rs
Guards: crates/nif/src/import/types.rs tests roles_covers_every_field_in_the_set + values_covers_every_field_in_the_set (count-based: catch an omission, not which role); material_translate::tests::documented_texture_role_list_matches_the_struct (struct ↔ nifal.md); asset_provider::texture::tests::common_material_texture_walk_covers_every_secondary_role_once (all 25 secondary roles pinned to a colour space both directions; a data map flipping to sRGB fails); slot_role.rs confinement tests (non-Skyrim layouts never take the Skyrim co-location arms, #4431); render/tint_alpha_gate_tests.rs; fo3nv_and_skyrim_decal_bits_agree.
Checklist (no-render-time-fallback / no-leak):
- Vocabulary:
MaterialTextureSet<T>— 22 named roles +decals: [T; 4];values()yields 26,secondary_values()(=values().skip(1), hard-codesbase_colorfirst — reordering is load-bearing) yields 25. Every source populates the same roles (NiTexturingProperty,BSShaderTextureSetFO3→Skyrim, BGSM/BGEM,BSEffectShaderProperty). A per-game slot index surviving past the NIF import boundary is the cardinal violation.slot_to_roleis the one table used by both the importer and the REFR overlay; Starfield/FO76 do not enter it (roles come from BGSM/CDB). Starfield.mat/CDB is now a live texture-role source:apply_cdb_material(merge.rs; 18fce7e43, 978d25c19) fillsSLOT_COLOR→base_color,SLOT_NORMAL→normal,SLOT_EMISSIVE→emissiveand parks the rest — slot 6_heightincluded, since it is a layer height-blend input and the canonicalheightrole arms parallax occlusion (fixed in 46dfdcc6c, #5381); theSLOT_COLORTextureReplacementlands indiffuse_coloronly when no colour texture did (#5190).EmissiveSettingsComponentis parked, not forwarded (4667b884b, #5503 closed):apply_cdb_materialreads the enabled/tint/luminance values but writes nothing toemissive_color/emissive_mult, because the raw physicalLuminousEmittance(50–5,199 measured) has no §4 scale measurement; the pin iscdb_emissive_settings_are_parked_not_forwarded_raw. Known-open: texture-set slots and flat-colour replacements are not Parent-inherited, so about 20 % of vanilla-referenced materials (2,051 of 10,158) resolve with zero textures — the key-hash hit rate (97.9 %) is not texture coverage (remaining half of #5502). Both the CDB hit and a decodable loose.matrecordImportedTextureSource::Matprovenance viarecord_external_texture_sources(c2f28e06c, #4277 — theMatarm #3906 deleted as unproduced is produced again). The loose file (material/loose_mat.rs) decodes the installedObjects[].Components[]/Datalayout with string-typed bools/scalars, and only a file that decodes pre-empts the CDB — an undecodable one falls through tolookup_cdb_materialand its miss fallback (fixed in faf09c6e6, #5396 / #5436). Known-open:parse_loose_matis a secondCdbMaterialproducer that reads noParentinheritance and noBlendingMode(#5525); the CDB resolve tail is copied at fourmerge.rssites (#5284). Starfield's five single-channel kinds (_rough/_metal/_ao/_opacity/_transmissive) are parked by name innifal.md, never routed tosmooth_spec/specular(#4429; XOR guardstarfield_single_channel_kinds_are_parked_by_name_or_canonical, scanning only the anchored list span since #5003). - Role checklist (enforced by
documented_texture_role_list_matches_the_struct, #3465): the 22 roles are (base_color,normal,emissive,detail,smooth_spec,dark,height,environment,environment_mask,tint,inner_layer,specular,lighting_mask,back_lighting,lighting,flow,wrinkle,greyscale_lut,reflectance,emittance_gradient,glass_roughness_scratch,glass_dirt_overlay) plus ordereddecals: [T; 4]— 26 entries fromvalues(). This list is the checklist an auditor diffsvalues()against. Tconverts withmap_refonly (Option<FixedString>→Option<String>→ bindless index); a consumer rebuilding a path from a role it was handed isno-leak.- Likeliest mis-merges:
smooth_spec(specular-strength/gloss) vsspecular(specular-colour) — FO4 slot 7 issmooth_spec(its_s.ddsis BC5; feeding it to the colour multiply tinted every FO4 highlight, #4424);environmentvsenvironment_mask. Deliberate, closed: the FO4 slot-2 arm ignores theGlow_Mapgate (#4430, closed by 1a12670ae as docs-only — the census inslot_role.rsshows the NIF bit is unreliable both ways, so routing is unchanged until an FO4 source for the real rule exists; don't re-file without that source). - Tint role: the Skyrim skin
_sk.ddsis a subsurface input, not an albedo multiplier; the tint multiply fires only when the tint DDS carries a real alpha (TINT_ALPHA_WEIGHT_BIT, #4423). FaceGen per-NPC tint override keys on canonical FaceTint heads (kind 4), not SkinTint (select_facegen_diffuse,scene/nif_loader.rs, #4421). - BGSM/BGEM merge boundary (
asset_provider/material/merge.rs): the overlay carries named roles (bgsm_emissive,bgsm_height,bgsm_greyscale_lut,bgsm_inner_layeronRefrTextureOverlay), never re-resolved through NIF wire slots (#4434); the BGEM arm updates the NIF's effect payload in place and ORs enable bits, never assigns (#4425); greyscale-to-palette bits are captured on the lit path too; BGSM's enable is one-way OR'd only when the NIF supplied the LUT, while a BGSM that wins the LUT slot is authoritative including OFF (#3897/#3898, #4402 restored #2108's rule) — the winner is decided once, after the fill, so a live BGSM LUT displacing a dead NIF one takes the slot's enable bit too (#5106, 51c80099e). A disabled BGSM specular block forwards no specular, derives no metalness (#4654/#4836) and leaves roughness to the NIF side — the #3639 near-mirror fallback is gated on the enabled arm (#5012, e80f7e854); the BGSM envmap fill is gated on the chain'senvironment_mappingbit (#4428). Regression pins: MSWP re-merge seeds from the swapped material (#4400); a shader texture displacingNiTexturingPropertyre-latches clamp mode and the parallax pair (#4401). - Per-game flag vocabularies dispatch by block type, never a runtime
if game ==:grep -n 'if game' crates/renderer/shaders/triangle.frag crates/renderer/shaders/include/*.glslmust be empty. All nineBSLightingShaderPropertyshader types forward trailing data.BSEffectShaderPropertyis captured and routed (material_kind == 101); itsbase_color_scalediffuse-tint-vs-emissive path is deferred viaEmissiveSource::Effect. FO4Model_Space_Normals/Alpha_Testbits reachMaterialInfo(/audit-nifDim 4 owns the parse side); Skyrim effect-shaderenv_map_scaleunauthored stays out ofMaterialInfo(#4393);BSShaderTextureSetoutranksNiTexturingProperty(#4235). - Flipbooks:
NiFlipControllerslots resolve to canonical roles (#3901); frames go throughresolve_flip_texture_for_role(per-role colour space + authored clamp, #4426); frame textures are released on cell unload viaAnimatedTextureFlip::all_handles(#4427) — check any new texture-owning component joins the unload walk.
Output:/tmp/audit/nifal/dim_8.md
Dimension 9: Translation-completeness signal + cross-cutting invariants
Paths: crates/nif/tests/translation_completeness.rs, crates/nif/examples/
First step: cargo test -p byroredux-nif --test translation_completeness -- --ignored (opt-in, needs game data; one game at a time)
cross_game_translation_completenessreports per-game fill rate over the raw pre-mergeImportedMaterialtier (before BGSM/BGEM/CDB merge, #2214), stratified sample. Each game row has a PBR-override ceiling as well as a floor (assert_pbr_override_ceiling, #4566) — upward drift is how a parser placeholder fabricates a classifier signal (#4393). Oblivion's fill is 100% by construction (specular_authoredon every mesh), so it pins that and ceilings each other signal class instead (assert_pbr_signal_ceiling, #4965). A near-zerotex/nrmfill on FO76/Starfield is a documented structural zero, not a leak. Large per-game fill divergence is a lead, not proof — verify the extractor. A category that converges on FNV but drops to ~0 on Starfield is an unverified-game leak.- Boundary inventory to keep true: Material ✓
translate_material; Particles ✓apply_emitter_overlays; Animation ✓convert_nif_clip(+convert_hkx_clip); Nodes ✗ by design (Dim 4); exterior →/audit-exterior. A new category must declare a boundary.nifal.md§3 records five drawn-surface exemptions (#4304; reconciled against the spawner guard, which owns the count, in 7d1de4ad5, #5210): Cornell fixtures (cornell/builders.rs),crates/savereconstructions, ground-cover blades (GroundCoverPalette; the authored-model tier resolves a NIFALMateriallike a placed reference), mesh → participating medium (beam/fog cards spawned asFogVolumes, cell path only, #5102; known-open #5285: the declared boundary lists six beam converters,CachedNifImport::beam_volumeschains seven), andscene.rs's four material-free demo primitives — plus two scan-level (not boundary) exemptions: the LOD-waterplane.entityre-insert (#5243) andnpc_spawn/resumable/mod.rs's test-only mocks.
Output:/tmp/audit/nifal/dim_9.md
Phase 3: Merge
- Read all
/tmp/audit/nifal/dim_*.md; combine intodocs/audits/AUDIT_NIFAL_<TODAY>.md:- Executive Summary — per-category status (converged / triaged / pending) vs the spec §2 inventory; violation counts per invariant.
- Per-Category Tier Matrix — category × invariant (pass / fail / N-A) with the boundary fn cited.
- Findings — by severity (NIFAL rows in
_audit-severity.md), base format + Extra Fields above. - Documented-limitation ledger — restate parked-not-leak items (node/mesh passthroughs, FO4+ NP blob, phantoms, size-over-life curve, ambient/morph anim channels, affected-node light scoping incl. NiAmbientLight, CDB parked slots) so they are not re-reported.
- Remove cross-dimension duplicates.
Run .claude/commands/_audit-validate.sh before finalizing. Suggest /audit-publish docs/audits/AUDIT_NIFAL_<TODAY>.md (domain label nifal, plus nif-parser for parse-side or renderer/shaders for the consuming end; add game:* when title-specific).