matiaszanolli

Parser Discipline Audit (archives, materials, packfiles, sidecars)

"Parser-discipline audit of the non-NIF, non-ESM readers of untrusted game-archive input — BSA/BA2/CSG archives, BGSM/BGEM, Starfield CDB, Havok HKX packfiles, FaceGen sidecars, MenuXml parse side, Steam VDF/ACF"

matiaszanolli 28 3 Actualitzat fa 1 setm.
GitHub

Instal·la

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

Instal·la des del registre de SkillsCat.

SKILL.md

Parser Discipline Audit (archives, materials, packfiles, sidecars)

Read _audit-common.md and _audit-severity.md for shared protocol.

Archives and mod files are attacker-controllable. This skill audits reader discipline for formats no other audit owns; per-game content claims (parse rates, rendering) stay with the per-game audits; NIF blocks /audit-nif, ESM /audit-esm, .spt /audit-speedtree, .pex/.psc /audit-papyrus; HKX playback (cinematic systems, root motion, completion events) stays /audit-scripting Dim 5 — this skill owns the HKX decode.

Crate Reads Consumer
crates/bsa/src/ (archive/, ba2.rs, csg.rs, uvd.rs, safety.rs, naming.rs, read_at.rs) BSA v103/v104 zlib, v105 LZ4 frame; BA2 v1/2/3/7/8 GNRL + DX10 (DDS header reconstructed; v3 LZ4 block); FO4 .csg, .uvd header (docs/engine/archives.md, docs/engine/fo4-csg-format.md) byroredux/src/asset_provider/archive.rs, byroredux/src/cell_loader/precombined.rs
crates/bgsm/src/ BGSM/BGEM v1–v22 + template chain byroredux/src/asset_provider/material/{provider,merge}.rs
crates/sfmaterial/src/ (reader.rs, index.rs) Starfield materialsbeta.cdb — generic object tree + the streaming per-material MaterialIndex byroredux/src/asset_provider/material/cdb.rs (header probe + MaterialIndex::build/lookup; each valid CDB's index builds on a named worker thread started at discover_starfield_cdbs, single-flight OnceLock slot, prefetch builds serialised — dd759b762, #5291), material/merge.rs (apply_cdb_material)
crates/hkx/src/ Havok 2010 packfile, 32-bit LE (Skyrim LE) + 64-bit LE (SE) byroredux/src/asset_provider/animation.rs (sole caller)
crates/facegen/src/ .egm / .egt / .tri byroredux/src/npc_spawn/resumable/runtime.rs (EGM only)
crates/menuxml/src/{parse,tex,font}.rs Oblivion/FO3/FNV menu XML (lenient scanner), its own DDS decoder (BC1/2/3 + masked 8/16/24/32-bpp) and .fnt fonts — parse robustness only, the rest is /audit-ui crates/menuxml/src/menu.rs
crates/game-detect/src/{vdf,steam,catalog}.rs Steam libraryfolders.vdf / appmanifest_*.acf (policy: /audit-tooling Dim 5) launcher, byro-detect

Contract every reader must meet (a finding violates one): (a) a file-controlled size is bounded before memory is reserved; (b) malformed bytes give Err with field context — never a panic, abort or unbounded loop; (c) a short/degraded decode is a documented Ok + warning or an error, never silent; (d) a real-data sweep exists and cannot go green vacuously.

Parameters (from $ARGUMENTS)

  • --focus <dimensions>: dimensions to run (default all 6). --crate <name>: one crate.
  • Delta-first: run each First step, then git log --since=<last AUDIT_PARSERS date> -- <Paths>; deep-audit only dimensions whose Paths changed.

Extra Per-Finding Fields

  • Dimension: Size Discipline | Error Semantics | Version Gating | Corpus Gates | Decode-Consumer Wiring | I/O & Paths
  • Trigger Input: the byte-level condition (field, value, file). Severity per _audit-severity.md: abort/OOM/stack overflow reachable from an archive or mod file = HIGH (CRITICAL if UB); vanilla content failing to load = HIGH; silently wrong bytes = MEDIUM–HIGH; missing gate/test = LOW–MEDIUM.

Phase 1: Setup

  1. mkdir -p /tmp/audit/parsers; gh issue list --repo matiaszanolli/ByroRedux --limit 200 --json number,title,state,labels > /tmp/audit/parsers/issues.json; dedup per _audit-common.md.
  2. Read the newest docs/audits/AUDIT_PARSERS_*.md if any.
  3. cargo test -p byroredux-bsa -p byroredux-bgsm -p byroredux-sfmaterial -p byroredux-hkx -p byroredux-facegen -p byroredux-menuxml -p byroredux-game-detect; record counts. Real-data suites, data present, one crate at a time: BYROREDUX_REQUIRE_GAME_DATA=1 cargo test -p <crate> -- --ignored. Never ComponentDatabaseFile::parse the vanilla CDB (~9.19 GiB tree, #4274); real_cdb.rs streams.

Phase 2: Dimensions (max 3 concurrent agents)

Dimension 1: Untrusted-size discipline

Paths: crates/bsa/src/, crates/hkx/src/, crates/facegen/src/, crates/sfmaterial/src/{reader,index}.rs, crates/bgsm/src/reader.rs, crates/menuxml/src/{parse,tex,font}.rs, crates/game-detect/src/vdf.rs.
First step: grep -n 'with_capacity\|vec!\[0\|read_to_end\|as usize' <Paths>; trace each hit to the bound before it.
Guards (confirm live, not #[ignore]d): entry_count_rejects_attacker_u32_max, over_ratio_payload_is_rejected_at_the_ceiling (crates/bsa/src/safety.rs); malicious_bsa_folder_count_u32_max_rejected (crates/bsa/src/archive/tests.rs); malicious_file_count_u32_max_rejected_before_allocation, lz4_flex_is_pinned_to_the_safe_decoder (workspace lz4_flex 0.14 keeps safe-decode — feature list unchanged by the 20ad630a6 bump; off = heap overflow no catch_unwind catches, HIGH), lz4_decompress_is_panic_guarded (crates/bsa/src/ba2.rs); oversized_read_len_is_rejected_before_it_is_reserved (crates/bsa/src/csg.rs); decode_spline_animation_rejects_a_sample_count_bomb, decode_spline_animation_rejects_a_block_claiming_its_whole_clip (crates/hkx/src/animation.rs); rejects_morph_count_over_cap (crates/facegen/src/egm.rs); parse_with_limits_rejects_object_tree_before_materialising_it (sfmaterial).
Beyond the guards:

  • One implementation of the ceilings — MAX_ENTRY_COUNT 10 M, MAX_CHUNK_BYTES 1 GiB, MAX_RECORD_TOTAL_BYTES 2 GiB, inflate_bounded / inflate_bounded_zlib. A reader passing a file-controlled length to Vec::with_capacity, or read_to_end on a decoder with no declared + 1 cap, is the regression; so is a second copy of the constants.
  • Record reservations are also file-relative (#4661): capacity_hint(count, remaining_bytes, min_record_bytes) bounds read_general_records, read_dx10_records and the BSA folder/file tables by the bytes left. A new table reader reserving on the absolute cap alone is the regression.
  • declared_size (BSA, BA2, asset_provider/archive.rs) hands file-controlled sizes to the streaming budget: BSA runs them through checked_chunk_size; BA2 sums raw unpacked_sizes with no ceiling — check no consumer allocates from it.
  • Sizes computed from data outside the archive (NIF-carried counts into CsgArchive::read_psg, #3758) need their own overflow-checked bound.
  • File-value arithmetic (count * stride, offset + len) is checked_* or bounded by a preceding cap (hkx bone_count > 4096 precedes its multiplies).
  • Nesting: menuxml (tiles depth > 48, expressions > 64, MAX_READ_DEPTH 32, include-cycle set; include_cycles_terminate; MenuFileSource providers must stay archive-backed); bgsm templates (DEPTH_LIMIT 16 + visited set, resolve_breaks_self_reference_cycle). sfmaterial value nesting MAX_NESTING_DEPTH 64 → Error::NestingTooDeep (#4657, self_referential_struct_is_rejected_instead_of_overflowing_the_stack); vdf::parse_entries MAX_VDF_DEPTH 32 (#4673, pathological_nesting_hits_the_depth_cap); menuxml includes re-enter at depth + 1 and are capped by MAX_INCLUDE_SPLICES 256 (#4650) plus document-wide byte/tile budgets, since splice count alone let a 64 KiB fragment x 256 splices materialise 2.4 M tiles (#5314, 23fd95055: MAX_INCLUDE_BYTES 256 KiB, MAX_INCLUDE_FRAGMENT_BYTES 64 KiB checked before the per-document cache, MAX_DOCUMENT_TILES 16,384); a fragment that cannot fit the splice budget is refused before it is cached (caches None), and every fetch, spliced or refused, is charged against MAX_INCLUDE_FETCHED_BYTES (2x the splice budget) before any probe (fixed in 6a132a967, #5399: refused_includes_are_bounded_by_the_fetch_budget). Decoded menu textures are a second unbounded-retention site: MenuRenderer keys decodes by resolved archive path (one fetch + decode + charge for every spelling of a file) and charges each against MAX_DECODED_TEXTURE_BYTES (1 GiB, from the DDS header via Rgba8::decoded_len before allocating); over budget the filename resolves to nothing, cached, with one warning (5dce486b6, #5658 — vanilla totals 980 MiB Oblivion all-resolution-sets, so it never trips on shipped content). hkx strings are borrowed, read_cstr capped at 256 B, and the absolute spline sample cap MAX_TRANSFORM_SAMPLES is 1 M — 8x the vanilla census max of 124,821 (#5316, d8295d77c; the old 16 M admitted a 597 MiB decode) — and the spline frame count is bounded relative to the file's blocks (#4648/#4655) with max_frames_per_block capped at MAX_FRAMES_PER_BLOCK 256 (fixed in a65595815, #5006 — the one-block bypass; skyrim_se_spline_dimensions_census_stays_under_the_gate_ceilings is the real-data guard) — a new per-entry owned copy of a shared string is the regression. MenuXml include budget + cycle checks run before the archive fetch and probes are memoised per resolved path (fixed in 2465740cc, #5007: self_including_fragment_costs_one_fetch, budget_exhausted_includes_never_fetch; known-open #5329 — that cycle check tests all three candidate spellings, so a lower-priority spelling on the stack rejects a distinct higher-priority include). MaterialIndex::build parses the schema with ParseLimits::unlimited() and streams the DBFileIndex collections element-by-element — its bound is the per-instance decode, not parse_with_limits. A degraded join on a non-vanilla CDB now errors instead of yielding an empty/wrong index (#5320, 6489ae225: MissingDbFileIndex, RowInstanceMismatch, DuplicateDbFileIndex, UnsupportedDbFileIndexChunk); the skip path walks Class::read_order like the read path (#5323, 258ba2b90); cdb_material_index warns before memoizing a failed archive fetch (#5319, 8be8c3d3a).
  • MenuXml's own DDS decoder (tex.rs) rejects dimensions > 8192 and sizes the uncompressed payload against width × height × bpp before allocating the RGBA canvas; a zero channel mask is an absent channel and sub-8-bit masks replicate by shift-OR (the old 2*bits - 8 underflowed — dev panic, release mis-decode), L8 keys off DDPF_LUMINANCE (fixed in 015e10549, #5377: truncated_payload_is_rejected_before_allocation, l8_grey_decodes_as_luminance_not_red). 16-bpp A4R4G4B4 / R5G6B5 decode through the same masks and the loader names "present but undecodable" separately from "not found" (fixed in 5c53ad9c7, #5400; the opt-in menu_texture_header_census_decodes pins zero undecodable vanilla menu headers).
  • &str byte-range slicing of disk-derived names panics mid-char (#3391); use as_bytes().

Dimension 2: Truncation, EOF and error semantics

Paths: Dimension 1's, plus byroredux/src/asset_provider/{archive,animation,material/provider}.rs, byroredux/src/npc_spawn/resumable/ (consumer Err arms).
First step: list non-test unwrap() / expect( / [idx] on file-derived indices in Paths; read each consumer's Err arm.

  • Short decode is a deliberate Ok + log::warn! (shipped padding deltas #622/#812); over-run is Err(InvalidData). Guards: short_decode_stays_ok_for_the_shipped_padding_deltas, decompress_chunk_zlib_short_stream_returns_actual_length, decompress_chunk_lz4_under_run_returns_actual_length_not_declared. A short non-final DX10 chunk is an Err naming the entry (#4662, extract_dx10_rejects_a_short_non_final_chunk_naming_the_entry); only the final chunk stays lenient. A DX10 chunk_hdr_len != 24 is a hard InvalidData in every build, and no debug_assert! sits on a file-controlled field (fixed in cf2a36920, #5008: dx10_open_rejects_a_non_24_chunk_hdr_len_as_invalid_data, dx10_open_tolerates_non_monotonic_start_mip_without_panicking). An empty compressed BSA record (vanilla zero-byte .txt placeholders: 35 Oblivion, 1 FO3, 1 FNV) is decided in BsaArchive::extract before any decoder — size header 0 with no stream → Ok(empty) on the zlib and LZ4-frame arms, a non-zero declared size with no stream → InvalidData naming the entry — because flate2 1.1.10 reports an empty zlib stream as UnexpectedEof where 1.1.9 returned Ok(0) (fixed in fa2af0e89, #5073/#5660; extract_returns_empty_for_a_zero_size_compressed_placeholder, extract_rejects_a_nonzero_size_with_no_compressed_stream; safety.rs pins the decoder semantics the readers rely on). "Short decode stays Ok" holds for complete streams only; a stream cut mid-block is an error.
  • A checksum-only zlib failure retries as raw DEFLATE; a corrupt body must still error (corrupt_adler32_trailer_recovers_via_raw_deflate, corrupt_deflate_body_still_errors).
  • Errors carry context (bgsm Error::UnexpectedEof { offset }, hkx labels, checked_* label). Consumer fall-through loops go through extract_first / Archive::extract_or_warn (asset_provider/archive.rs, #4658): a non-NotFound error warns once per (archive, entry) instead of reading as "missing" and silently falling back to the vanilla copy — a raw .extract(..).ok() loop is the regression (known-open #5009: byroredux/src/debug_load.rs's resolve_nif_bytes keeps one, BSA-only and first-wins; owner /audit-tooling). Archive::open_shared (asset_provider/archive.rs, 5402b2530 #5288) is the process-wide opened-handle registry (validated by file length + mtime, capped at 1024) that the texture / material pools and the CDB index build now open through — a replaced archive must be re-read, not served stale (open_shared_reuses_an_unchanged_archive_and_rereads_a_replaced_one).
  • A bad asset costs the asset, not the cell, and a partial result must be signalled: hkx skips one bad annotation and keeps the clip (read_annotations_skips_an_out_of_range_time_and_keeps_the_rest); menuxml truncates an over-deep subtree with a warning. Flag any silent partial result.
  • File-byte-reachable panics: NIF parse runs under catch_unwind in byroredux/src/streaming/pre_parse.rs; archive extract, hkx, bgsm, facegen and menuxml callers may run unguarded on the main thread. crates/facegen/src/egm.rs indexes bytes[offset] after its exact-size check — verify that precondition is unconditional.

Dimension 3: Version and generation gating

Paths: crates/bsa/src/archive/open.rs, crates/bsa/src/ba2.rs, crates/bgsm/src/{base,bgsm,bgem}.rs, crates/sfmaterial/src/reader.rs, crates/hkx/src/packfile.rs, crates/facegen/src/.
First step: grep -n 'version' <Paths>; every gate keys off the file's own version field, never a GameKind sniff.

  • BSA: allowlist {103,104,105}, unknown = Err; folder record 16 vs 24 B and u64 offsets on v105; embed-name flag 0x100 only for >= 104; codec zlib v103/v104, LZ4 frame v105 — never lz4_flex::block (synthetic_v105_block_codec_payload_is_rejected_by_frame_reader); size-word bit 31 has no sourced meaning and is not acted on (#3367).
  • BA2: exhaustive match over {1,2,3,7,8} (unknown_version_rejected); v2 header +8 B, v3 +12 B carrying compression_method (0 zlib, 3 LZ4 block, else Err: v3_unknown_compression_method_rejected); v1/v2/v7/v8 always zlib; per-chunk packed_size == 0 = stored raw regardless of codec; DX10 DDS header 148 B (build_dds_header_is_148_bytes) with cubemap, mip clamp and per-DXGI pitch/linear-size flag.
  • BGSM/BGEM: per-field version >= / < gates; a version above NEWEST_KNOWN_VERSION (22) still decodes with the newest layout but surfaces version_over_ceiling + unconsumed_bytes (#4664); strings decode lossily (#4672). material/provider.rs logs those diagnostics on the BGEM path, but the BGSM template resolver (crates/bgsm/src/template.rs) parses with the non-diagnostic parse_bgsm, so the BGSM warnings fire only in the depth-limit recovery arm (known-open #5010).
  • CDB: header errors (BadMagic, UnsupportedVersion, BigEndianUnsupported); vocabularies pinned by chunk_type_recognized_set_is_pinned, builtin_type_recognized_set_is_pinned, class_flags_known_mask_is_pinned; probe_header tolerates unknown chunk FourCCs (#4273), the object-tree parse must reject them. User-class inline bytes are read in field-offset order (Class::read_order; XMCOLOR declares r,g,b,a at offsets 2,1,0,3 — fixed in 224a19372, read_order_reorders_xmcolor_shape); a declaration-order read is the regression.
  • HKX: the spline-clip dimension gate ties mask_size to the file's own track counts as pad(4*transform_tracks + float_tracks, 4) (Havok pads the mask table to 4 B; the unpadded equality rejected 229 SE / 167 LE vanilla clips, #5659, decode_spline_animation_rejects_a_mask_size_off_the_padded_value). Packfile::parse gates magic, pointer width 4|8, little-endian, fileVersion == 8, reuse-padding 0, hk_2010* (#4332, rejects_packfiles_that_are_not_havok_2010_msvc_layout); offsets derive from pointer size (layout_walk_reproduces_the_skyrim_se_offsets); a track binding a bone the skeleton lacks is dropped with a warning in convert_hkx_clip (#3013), never zip-truncated.
  • FaceGen: magics FREGM002 / FREGT003 / FRTRI003; EGM demands exact file size (rejects_size_count_mismatch), so trailing bytes on a modded head are refused — intended? EGM deltas are scale × i16, not half-floats (#4653); EGT is planar signed chars (#4668); EGT header offset 8 is R = rows = height, 12 is C = width (fixed in 8f7acf3a7, #5014: non_square_header_maps_rows_to_height_and_columns_to_width).

Dimension 4: Corpus and baseline gates

Paths: crates/*/tests/*real*.rs, crates/bgsm/tests/parse_all.rs, crates/sfmaterial/tests/, crates/menuxml/tests/, crates/hkx/src/animation.rs (test module), .github/workflows/real-data-gates.yml.
First step: grep -rn '#\[ignore' crates/{bsa,bgsm,sfmaterial,hkx,facegen,menuxml} vs grep -n 'cargo test' .github/workflows/*.yml; diff the two.

  • Strict lane (#3850): BYROREDUX_REQUIRE_GAME_DATA=1 makes an absent corpus panic and a set BYROREDUX_<GAME>_DATA override binding. Implemented in crates/bsa/tests/{bsa_real,ba2_real,csg_real}.rs, crates/bgsm/tests/parse_all.rs, crates/sfmaterial/tests/real_cdb.rs, crates/facegen/tests/parse_real_facegen.rs.
  • Vacuous-green sites (#4660 closed the known ones; verify no regression): the menuxml corpora (crates/menuxml/tests/{vanilla,fnv,fo3}_corpus.rs) are #[ignore]d and resolve through tests/common (binding override, strict on a missing install/archive, panic on an open failure); hkx's animations_archive, bgsm's open_archive_at, facegen's Game::extract, the bsa require_archive helpers and the in-crate archive_missing gate all fail under REQUIRE instead of skipping, and treat a present-but-unopenable archive as a failure. All six parser crates' --ignored suites are scheduled nightly in real-data-gates.yml's parsers job (#4659) — but it targets a byroredux-game-data self-hosted runner and none is registered (runner count 0, 2026-10-10), so every run is cancelled unstarted and no nightly result exists; run the suites locally; the Skyrim LE hkx parity test is --skipped there with a ::warning:: unless BYROREDUX_SKYRIMLE_DATA is set.
  • Assertion strength: real_cdb.rs pins exact counts (97 classes / 1,438,780 values); bgsm holds MIN_SUCCESS_RATE 1.0 over FO4 and FO76 Materials.ba2 (FO76's two JSON exports are a named, counted bucket). #4665 added FO76 BA2/DX10, FO3 BSA, FNV MenuXml and Oblivion EGM sweeps. Tabulate (format x game) sweeps and list what is still missing. The #5326 hardening landed (d4352d477): the HKX census pins the SE clip count (6,126), bounds skips, and runs every clip through decode_spline_animation against a reject baseline (the first run measured 288 vanilla rejects — a real decoder bug, then fixed: #5659 mask_size is Havok's 4-byte-padded size, eca0238e7, and #5662 a non-finite prop-bone track is dropped into HkxAnimation::dropped_bones rather than failing the clip, 9707cb367; the census now rejects 0 of 6,126), and material_index_dlc_cdb_resolves_the_base_corpus asserts the five Nightmare texture slots under the strict lane. Known-open gate weaknesses (dedup, do not re-file): the Oblivion EGM test resolves its data dir by hand and skips green under REQUIRE, and the BGSM sweeps count Ok only (#5011); three of the four HKX dimension-gate fixtures leave mask_size 0 and so cannot fail on their own clause (#5446).
  • ROADMAP compat-matrix rates are NIF-level; any BGSM/BA2/CDB rate claim in ROADMAP.md or docs/feature-matrix.md needs a dated measurement + sweep command. CI has no game data, so each format branch needs an in-memory fixture (build_v105_archive, PackfileBuilder, synth_egm, bgsm::tests::minimal_v2_bytes).

Dimension 5: Decode <-> consumer wiring

Paths: crates/bgsm/src/, byroredux/src/asset_provider/material/{merge,cdb}.rs, crates/facegen/src/lib.rs, byroredux/src/npc_spawn/resumable/runtime.rs, crates/hkx/src/animation.rs, byroredux/src/asset_provider/animation.rs, crates/bsa/src/uvd.rs.
First step: list pub fields of BgsmFile / BgemFile / HkxAnimation / EgmFile; grep each in its consumer.

  • BGSM/BGEM fields vs merge_external_material (byroredux/src/asset_provider/tests/bgsm_merge.rs): classify unconsumed fields renderer-relevant (finding) vs editor-only. The merge takes &mut ImportedMaterial; a widened signature is a NIFAL violation (/audit-nifal).
  • CDB (#3398 still OPEN for the rest; spike docs/audits/SF_CDB_PHASE2_SPIKE_2026-08-29.md): MaterialIndex (224a19372) joins path → material → layer → texture set → MRTextureFile slots (SLOT_COLOR..SLOT_TRANSMISSIVE) plus per-slot TextureReplacement, alpha test, IsGlass, UseSSS, and (9bb6b7dce, #5277/#5283) EffectSettingsComponent.BlendingMode and EmissiveSettingsComponent (Enabled + the inline nested EmittanceSettings tint / luminous emittance). apply_cdb_material (material/merge.rs, 978d25c19) forwards colour / normal / emissive slots, the SLOT_COLOR replacement only when no colour texture landed (never a tint), alpha test only when HasOpacity = true is authored on the Parent chain (an absent field is the measured class default false, 0d698d16c, #5661 — issue still open), IsGlass → bgem_glass, "AlphaBlend" → has_alpha, (enabled emissive settings are captured but parked, 4667b884b #5503 — no emissive_color / emissive_mult); it stamps PbrMaterial::NO_SIGNAL_NEUTRAL scalars on a hit (#5197). Parked by design: opacity / metal / rough / AO / transmissive slots, slot 6 _height (a layer height-blend input, not parallax — 46dfdcc6c, #5381), MaterialParamFloat, non-colour replacements, UseSSS. ObjectInfo.Parent inheritance of the settings components is resolved (5c14ebdf8, #5502; the decode is pinned in the default lane since 7ceda787d, #5578); known-open: texture-set slots and flat colours are not Parent-inherited (2,051 of 10,158 referenced materials resolve with no texture) — the remaining half of #5502; additive-family blend modes stay unforwarded but are parked by name in nifal.md (dbfae5a34; #5509 open). MergeOutcome::PresenceOnly now means an index miss (apply_cdb_pbr_fallback, mat_path_lookup_miss_keeps_presence_only_and_no_textures). A loose .mat JSON file that decodes wins over the CDB (material/loose_mat.rs, #4277, c2f28e06c); the decoder reads the installed layout — Objects[].Components[] with properties under Data, string-typed bools and scalars, Data\ stripped from FileName, Index via u8::try_from, all four colour channels required (fixed in faf09c6e6, #5396; vanilla ships none, four Creation archives carry 20). An undecodable file falls through to the CDB lookup instead of pre-empting it (#5436); both arms record ImportedTextureSource::Mat. Known-open #5525: parse_loose_mat is a second CdbMaterial producer that reads no Parent inheritance and no BlendingMode. Do not file the parked fields as new; a parked field leaking into a live slot is the finding.
  • FaceGen .egt / .tri have no consumer — documented in crates/facegen/src/lib.rs (#3544, closed by doc correction); only EGM reaches apply_morphs. Re-file only if that claim changes.
  • UVD: envelope only, no consumer yet (parse_uvd_header has no production caller; cell_loader/precombined.rs owns the future previs consumer; #3810, the envelope spike, is closed); payload undecoded by design.
  • Consumed-but-unparsed: a consumer unwrap_or(default) standing in for an unexposed field. ESM/NIF cases (TXST DecalData #3638, SkinAttach) go to /audit-esm / /audit-nif.

Dimension 6: I/O and path robustness

Paths: crates/bsa/src/{naming.rs,read_at.rs,archive/mod.rs,ba2.rs,csg.rs}, byroredux/src/asset_provider/{archive,texture,material/provider}.rs, byroredux/src/asset_provider/tests/, crates/game-detect/src/{vdf,steam}.rs, crates/menuxml/src/parse.rs.
First step: cargo test -p byroredux --bin byroredux asset_provider; read archive_precedence.rs and archive_siblings.rs.

  • Key: both readers use to_lowercase().replace('/', "\\") (Unicode fold); consumer normalisers normalize_mesh_path, normalize_texture_path, normalize_material_path, strip_build_prefix, canonical_texture_key — check open-time and lookup keys agree and non-ASCII names do not diverge from ASCII-only folds elsewhere.
  • Siblings: numeric_sibling_paths (pure; shared with the launcher validator): Foo.bsa -> 2..9, Foo0.bsa -> 1..9, Foo01.ba2 -> 02..09, mid-series none; guards siblings_* in archive_siblings.rs; open_with_numeric_siblings de-dups by case-fold.
  • Provider order is last-listed wins across a chain (#3637); every_content_provider_resolves_collisions_last_wins scans providers for a first-wins loop; count_shadowed_entries logs shadowing. Within one archive a duplicate normalised name is counted at open and warned, last record wins (#4671, BSA and BA2).
  • File model: no mmap, no Mutex<File> — positional reads via read_at.rs::ReadAt (pread / seek_read), so extracts from one archive run concurrently on Unix (Windows serialises in the kernel, #4999); CSG's mutex guards only its chunk cache. A shared-cursor read reintroduced anywhere is the regression (concurrent_extracts_read_their_own_entries, synthetic_v105_concurrent_extracts_read_their_own_bytes, concurrent_reads_get_their_own_chunk_bytes, #5000). Bounds checks must fail on a replaced archive, not return garbage (extract_rejects_compressed_payload_too_short).
  • VDF/ACF: malformed input rejected, not half-read (malformed_documents_are_rejected_rather_than_half_read); libraryfolders.vdf from steamapps/ + config/ is unioned; ACF installdir is contained under steamapps/common/ in installs_in_library via the host-independent string predicate is_bare_install_dir (#4673; widened in aad5dfd75, #5016 — /, \, :, ., .. rejected so Windows-rooted \X / D:X cannot escape; only_a_bare_directory_name_is_a_valid_installdir); unreadable manifests are logged.

Phase 3: Merge and Cleanup

Write docs/audits/AUDIT_PARSERS_<TODAY>.md: Executive Summary (findings by severity; crates swept; real-data suites run vs skipped), deduplicated Findings, and a Gate Matrix (crate x {size caps, error policy, version gate, corpus sweep, CI lane}). Cross-audit dedup: NIF/ESM sizes /audit-nif Dim 1 / /audit-esm Dim 1; unsafe /audit-safety; MenuXml rendering /audit-ui. Then rm -rf /tmp/audit/parsers and suggest /audit-publish docs/audits/AUDIT_PARSERS_<TODAY>.md (labels: import-pipeline BSA/BA2/CSG/FaceGen, nifal BGSM/CDB, animation HKX, ui MenuXml, test-gap gates, tech-debt game-detect; game:* when title-specific).