AGENT SKILL

audit-parsers

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

License 许可
MIT
最近更新
2026-11-11
Stars
★ 36
Forks
7

01 前言 Front Matter

description
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
argument-hint
--focus <dimensions> [--crate bsa|bgsm|sfmaterial|hkx|facegen|menuxml|game-detect]

03 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, lod_family.rs — pure path classifier, no byte parse) 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, settings.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 installed synchronously before the worker is queued, and every build — prefetch or lazy — serialised behind one CDB_BUILD_GATE — dd759b762, #5291; e16a624e9, #5666, lookup_waits_for_a_queued_prefetch_slot), material/merge.rs (apply_cdb_material)
crates/hkx/src/ (packfile.rs, animation.rs, behavior.rs, expression.rs) Havok 2010 packfile (v8), 32-bit LE (Skyrim LE) + 64-bit LE (SE); Havok 2014 packfile (v11, Fallout 4: 0x40-byte section headers after a predicate area, spline type 3, THREECOMP48 rotations, hkaDefaultAnimatedReferenceFrame, hkaLosslessCompressedAnimation — layout measured from 856 FO4 clips, not sourced, decode_animation dispatches both kinds, 2649febc7 #5897); spline + lossless clips, skeletons, behavior graphs / character / project files and their condition-expression text byroredux/src/asset_provider/{animation,actor_graph,archetype_idle,object_graph}.rs, byroredux/src/systems/graph_locomotion.rs
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/4/5 + DX10-header BC1–5 / RGBA8 / BGRA8 / R8 + masked 8/16/24/32-bpp; BC4/BC5/DX10 added in 5017a64c2 for terrain height maps) and .fnt fonts — parse robustness only, the rest is /audit-ui crates/menuxml/src/menu.rs; Rgba8::decode_dds also feeds byroredux/src/asset_provider/terrain_height.rs and tex.dump (commands/assets.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; a self-reference reached through a path alias is canonicalised and cached, multi-node cycles reparse — de034a53d, #5907). 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); refusals are remembered per resolved path and consulted before the fetch, and a refused candidate falls through to a smaller resolution-set sibling (4c979297b, #5693); a lower-priority set's copy substituting for an undecodable preferred texture warns once per spelling (64bca63af, #5670). hkx strings are borrowed, read_cstr capped at 256 B, and the absolute sample cap MAX_TRANSFORM_SAMPLES is 1 M (spline and lossless paths) — 8x Skyrim's census max of 124,821 but only 1.7x FO4's (voice lip-sync clips reach 584,155, pinned since a3574d65e #5896) (#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. Spline scalar bounds whose finite span overflows f32 flag the track like NaN bounds instead of pushing ±inf through the sampler (c3b578ae5, #5691, overflowing_finite_span_drops_only_that_track); the FO4 reference-frame twin — neighbouring finite samples whose difference overflows, non-finite / non-positive duration — is refused by check_reference_samples, and a carried-but-unusable frame is reported in HkxAnimation::reference_frame_dropped (warned with the clip path) rather than reading as no frame (74733c655, #5894); its field offsets derive from the packfile pointer width (3674a045a, #5901). Lossless-clip rotations (decode_lossless_in) go through the same normalize_quaternion policy as the spline decoder: a zero-length / overflowing quaternion drops the track into dropped_bones (like a non-finite value, #5662) and a non-unit one is normalised in the decoder (18ff2c3a0, #6242; a_lossless_rotation_the_spline_policy_refuses_drops_its_track_and_others_are_normalised) — a second quaternion policy per decoder is the regression. A lossless clip with no dynamic series (every channel absent or static) ties its declared frame count to nothing in the file, so it is capped at MAX_STATIC_CLIP_SAMPLES 65,536 (33x the FO4 census maximum of 1,974 samples across 11 such clips) and a zero duration over more than one frame is refused, as the spline path does (e4f72f3e5, #6306; a_lossless_clip_with_no_dynamic_data_cannot_claim_a_sample_budget_of_frames, a_lossless_clip_cannot_stretch_many_frames_over_zero_duration). The behavior-graph decoder (behavior.rs) bounds every file-driven table: MAX_EVENTS 4096, MAX_STATES 1024, MAX_TRANSITIONS 4096, MAX_TRIGGERS 1024, MAX_VARIABLES 4096, MAX_BONES 1024, generator recursion MAX_GENERATOR_DEPTH 32, modifier nesting MAX_MODIFIER_DEPTH 16, reference chasing MAX_REFERENCE_HOPS 8 / MAX_INLINED_REFERENCES 64, inlined machine height MAX_MACHINE_NESTING 33 (event/variable name merge is hashed, 3ce34ed28 #5893). inline_references spends one InlineBudget shared by the whole recursion: the 64 resolves plus a MAX_INLINED_WEIGHT (= MAX_GRAPH_STEPS) weight budget (machine_weight: states, transitions, clips, sequences, modifiers, weighed by what each holds — a clip's triggers and every modifier's leaf tables (assign bindings, event ranges, expressions) counted recursively through lists / modify-once / event-driven wrappers, the same entries the decode charges a step each; a list of 1,024 expression modifiers once weighed 3 while owning ~a million strings, so 64 references inlined 64 deep copies, +1.2 GiB, ec8f8af6f #6479, a_modifier_list_of_expression_tables_is_weighed_by_what_it_holds) charged as soon as a sub-behavior resolves and before anything recurses into it, and a reference already on the inlining stack (self or a cycle) is cut before it is resolved — a self-reference loads once, where it used to stack up to 64 live deep clones (1,990 MiB peak in the probe); the nesting height is checked before the recursion as well as after, and a sub-behavior whose modifier ids would wrap u32 is left out (5696d0947, #6212; mutually_referencing_behaviors_are_each_loaded_once, inlined_volume_is_bounded_by_one_shared_weight_budget; the shared weight is graph_weight, which counts every entry a resolve deep-copies — bone-binding tables and bare reference trees included, each walked field named with no .. so a new table does not compile until weighed — fixed in 9a6dd51dd, #6593, pinned by a_blender_childs_bone_bindings_are_weighed_by_what_they_hold, a_tree_of_references_to_itself_is_weighed_by_what_it_holds, a_graphs_weight_counts_every_table_a_resolve_copies), and the FO4 reference-frame sample table 65,536 — a new table reader without a cap is the regression. Depth caps alone do not bound work: a Walk guard carries an on-stack ancestor set (a pointer back to an ancestor is refused) and a per-decode MAX_GRAPH_STEPS 2,000,000 budget charged per object entered, per child-array entry walked (e730f9a77, #5891) and per leaf-table entry read inside an object — clip triggers, variable bindings (once per member looked up), bone weights, transitions, event ranges, expressions, assign bindings (#6211; a_fan_out_onto_a_clip_with_full_tables_is_refused_by_the_step_budget). FO4's shipped max is 245,831 steps (Skyrim SE 3,329), asserted ≥ 8x by the corpus census; a new table reader that does not charge walk is the regression. File-driven integer arithmetic uses checked_* — bone_of_binding's offsetInArrayPlusOne - 1 overflowed on 0x80000000 (49895b2df, #5895). expression.rs is a recursive-descent parser over graph-authored condition text (!/-/parenthesis recursion has no depth counter of its own; it is bounded only by the 256 B cstr cap that feeds it — a longer string source would make it a stack-overflow surface). 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; fixed in ba252c482, #5329 — the cycle check now tests only the resolved key, after the cached fetch, so a lower-priority spelling on the stack no longer rejects a distinct higher-priority include; a real cycle still resolves from the cache and never re-fetches). Every fetch is charged max(len, INCLUDE_FETCH_FLOOR_BYTES) (1 KiB) so the per-document extract count is bounded at 512, and the exhausted-budget warnings (budget / bytes / fetched / tiles) fire once per document rather than once per path (547d3c385, #5668). 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; plus NoKeyedObjects, a0da96c0f #5899); 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; every BC path (BC1–5, incl. via the DX10 header) checks the full block payload with checked arithmetic in bc_output before allocating (0b7501e70, #5667); 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. The debug-load and --mesh/--tree NIF paths (byroredux/src/debug_load.rs, scene/nif_loader.rs) now resolve through TextureProvider::extract_mesh (BA2 dispatch, last-listed-wins, open_shared), not a raw first-wins BsaArchive::open (fixed in 44219e881, #5009; pinned by the hygiene test no_production_code_opens_archives_outside_the_provider_layer — bin-crate production code may not open BsaArchive/Ba2Archive directly). Archive::open_shared (asset_provider/archive.rs, 5402b2530 #5288) is the process-wide opened-handle registry (validated by an ArchiveStamp — length + mtime everywhere, plus (dev, ino, ctime) on Unix so a same-length in-place rewrite that restores the mtime is caught, 04036a6fb #5663 — 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, open_shared_rereads_a_same_length_rewrite_that_restores_the_mtime); the path-keyed Starfield CDB caches are documented process-lifetime and never re-stamped.
  • 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. Ba2Archive::dx10_layout (record-table read, no extract) and extract_dx10_mips(path, first_mip) (inflates only chunks reaching the range; first_mip 0 equals extract; GNRL refused; M39 TXS-SRC-001, 36b55c728 / f70d041de) are file-controlled-range readers: dx10_layout_reports_header_and_chunk_mip_ranges, dx10_extract_mip_range_reads_only_tail_chunks.
  • 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, and the BGSM template resolver (crates/bgsm/src/template.rs) now parses every leaf and template through parse_bgsm_diag, reporting each via the TemplateResolver::parsed hook that the provider routes to the same warn_parse_diagnostics (746e0858e, #5010, pinned by resolve_reports_per_file_parse_diagnostics; cache hits are not re-reported). A new parse site that calls the non-diagnostic parse_bgsm / parse_bgem is the regression.
  • 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 (Havok 2010, hk_2010*) or 11 (Havok 2014, hk_2014*; the contents string must match the version), reuse-padding 0 (#4332, rejects_packfiles_that_are_not_havok_2010_msvc_layout); a v11 file's section table starts after the m_predicateArraySizePlusPadding area and the spline animation_type is 3 (v8: 5), both keyed off the file's own version; 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), crates/hkx/src/corpus_tests.rs, .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 run in real-data-gates.yml's parsers job (#4659), which targets a byroredux-game-data self-hosted runner that is not registered — the workflow is now manual-only (workflow_dispatch; the nightly schedule was removed in fde8feec2, #5905 closed), so no automated parser real-data result exists and the closed-HIGH pin list (.github/real-data-pins.txt, 9ee74b6e7 #6183) carries no parser-crate lane; run the suites locally (the job runs --no-fail-fast, fa2af0e89, #5660, so one red binary no longer hides the rest of the crate); 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. #5671 (390241376) made the census exact — 6,126 clips / 1,573 skipped / 59 dropped-track clips / 1,471 max frames / 15 max blocks, rejects must be the empty set. The #5011 hardening landed (e8676b097): the Oblivion EGM test goes through require_game_data / require_archive and pins its exact byte, vertex and morph counts, and the FO4 BGSM/BGEM and FO76 sweeps parse through parse_*_diag and fail on unconsumed bytes or an over-ceiling version (the other (format x game) gaps the issue listed are not addressed). Havok 2014 is gated too (a3574d65e, #5896): corpus_tests.rs pins fallout_4_animations_archive_census_is_exact (14,370 clips — 13,514 spline / 856 lossless, 848 additive), fallout_4_behavior_graph_census_is_exact and skyrim_se_behavior_graph_census_is_exact (three named non-decoders), and the default lane now builds version-11 packfiles (PackfileBuilder v11 mode: header, spline type gate, THREECOMP48, reference frame, modifier cycle/fan-out guards). Archive and FaceGen sweeps pin counts and extract every vanilla entry across FO3/FNV/Skyrim SE/FO4/FO76, Skyrim LE optional behind BYROREDUX_SKYRIMLE_DATA (ce008af1c, #5903: fo3_fnv_and_skyrimse_bsa_corpus_census, fo4_ba2_vanilla_corpus_full_extract_zero_errors, vanilla_facegen_sidecar_census_is_exact); the bgsm sweep pins vanilla corpus sizes (c5a0b98c6, #5904) — except FO76, a live-service corpus: its BGSM material count and BA2 archive/entry census are floors (29,991 materials, 40 archives, 679,240 entries) asserted after the clean-parse / drift / extract-everything checks, with a re-measure line on change (regression guard: fixed in a2fb1bb12, #6482, closed; last measured 2026-10-24: 30,078 materials, 43 archives, 688,096 entries). The menu DDS census is exact per resolution set (textures\menus* 1,975 / 1,712 / 1,789) and covers the FO3/FNV textures\interface\ art (1,157 / 1,767; 065c9d31d, #5669). The HKX dimension-gate fixtures isolate their own clauses (regression guard: fixed in c78e9241e, #5446 — every fixture carries the exact padded mask_size and a sample product that only its named clause can refuse, so deleting any one of the three clauses reddens exactly its test; probe by mutation, not by reading).
  • 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; regression guard: fixed in 0d698d16c, #5661, closed), 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 / TransmissiveScale (captured off TranslucencySettingsComponent.Settings since 595110e3c, #5673 — the old arm keyed on the nested struct's class name, which never occurs top-level). The Objects decode counts rows whose DBID / Parent / PersistentID shape drifted and warns once per build (fa6666053, #5672; PersistentID misses counted and an all-miss table is the typed NoKeyedObjects error, a0da96c0f, #5899); parent-graph defects (cycles, objects inheriting through one, chains past MAX_PARENT_DEPTH 64) come from one O(n) census at build and warn once — the per-merge Parent walk (ancestry, bounded + cycle-safe) is silent (a0da96c0f, #5900); MaterialIndex reads the PersistentID columns by name, not map order (30bdbcc3b, #5465). ObjectInfo.Parent inheritance is resolved for the settings components (5c14ebdf8; the decode is pinned in the default lane since 7ceda787d, #5578) and, since 1d7e2c6bd, for texture-set slots and TextureReplacement flat colours too (nearest-set-wins down the chain; an authored Enabled = false on a nearer set stops inheritance; real-data no-texture count 2,049 → 217 of 10,152, pinned by material_index_resolves_inherited_texture_slots). #5502 is closed (5c14ebdf8 + 1d7e2c6bd; the glass-coverage decision is recorded in nifal.md: IsGlass routes to bgem_glass directly, the AlphaBlend forward only passes the classifier's coverage early-return); Additive / Multiply blend modes are forwarded (8e30e66e1, cdb_blend_mode_tests); the soft variants and TakeSmaller stay 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. parse_loose_mat is a second CdbMaterial producer, but the Alpha / Effect (BlendingMode) / Emissive settings rule lives once in capture_settings (crates/sfmaterial/src/settings.rs, over a FieldSource trait both decoders implement; loose folds first-writer-wins) — fixed in 442901646, #5525, pinned by the_compiled_and_loose_representations_capture_identically; the loose decoder still walks no Parent chain, and TranslucencySettingsComponent stays a per-decoder arm (no installed loose file authors it). 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.
  • HKX partial results reach convert_hkx_clip as data, not silence: HkxAnimation::dropped_bones (#5662), reference_frame_dropped (#5894) and additive (Havok 2014 blendHint, #5897) — the engine has no additive layering for Havok clips, so an additive clip (848 of FO4's 14,370 vanilla clips per the census) is converted as an absolute pose with a warning; it is a known gap, not a new finding, unless the warning goes missing.
  • 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 and the loose tier share one exported byroredux_bsa::normalize_path (Unicode to_lowercase().replace('/', "\\"); the BSA module's duplicate is gone, f26eb657f #6308); 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); a loose Data\ tier (LooseDir, indexed once per directory per process from the --esm plugin's directory, loose_data_dir) is appended after every archive in the texture, mesh and material pools, so loose mod/user files win (48e422a71, #6200; asset_provider/tests/loose_override.rs); loose keys fold through normalize_path like the archives (loose_keys_fold_like_the_archives), and two loose files folding onto one key resolve to the lexicographically last path with the collision count warned once, not in read_dir order (f26eb657f, #6308; loose_case_variant_duplicates_resolve_to_the_last_path); 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).

04 同仓库更多 Skills