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, thengit 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
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.- Read the newest
docs/audits/AUDIT_PARSERS_*.mdif any. 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. NeverComponentDatabaseFile::parsethe vanilla CDB (~9.19 GiB tree, #4274);real_cdb.rsstreams.
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_COUNT10 M,MAX_CHUNK_BYTES1 GiB,MAX_RECORD_TOTAL_BYTES2 GiB,inflate_bounded/inflate_bounded_zlib. A reader passing a file-controlled length toVec::with_capacity, orread_to_endon a decoder with nodeclared + 1cap, 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)boundsread_general_records,read_dx10_recordsand 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 throughchecked_chunk_size; BA2 sums rawunpacked_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) ischecked_*or bounded by a preceding cap (hkxbone_count > 4096precedes its multiplies). - Nesting: menuxml (tiles
depth > 48, expressions> 64,MAX_READ_DEPTH32, include-cycle set;include_cycles_terminate;MenuFileSourceproviders must stay archive-backed); bgsm templates (DEPTH_LIMIT16 + 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 nestingMAX_NESTING_DEPTH64 →Error::NestingTooDeep(#4657,self_referential_struct_is_rejected_instead_of_overflowing_the_stack);vdf::parse_entriesMAX_VDF_DEPTH32 (#4673,pathological_nesting_hits_the_depth_cap); menuxml includes re-enter atdepth + 1and are capped byMAX_INCLUDE_SPLICES256 (#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_BYTES256 KiB,MAX_INCLUDE_FRAGMENT_BYTES64 KiB checked before the per-document cache,MAX_DOCUMENT_TILES16,384); a fragment that cannot fit the splice budget is refused before it is cached (cachesNone), and every fetch, spliced or refused, is charged againstMAX_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:MenuRendererkeys decodes by resolved archive path (one fetch + decode + charge for every spelling of a file) and charges each againstMAX_DECODED_TEXTURE_BYTES(1 GiB, from the DDS header viaRgba8::decoded_lenbefore 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_cstrcapped at 256 B, and the absolute sample capMAX_TRANSFORM_SAMPLESis 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) withmax_frames_per_blockcapped atMAX_FRAMES_PER_BLOCK256 (fixed in a65595815, #5006 — the one-block bypass;skyrim_se_spline_dimensions_census_stays_under_the_gate_ceilingsis 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 bycheck_reference_samples, and a carried-but-unusable frame is reported inHkxAnimation::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 samenormalize_quaternionpolicy as the spline decoder: a zero-length / overflowing quaternion drops the track intodropped_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 atMAX_STATIC_CLIP_SAMPLES65,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_EVENTS4096,MAX_STATES1024,MAX_TRANSITIONS4096,MAX_TRIGGERS1024,MAX_VARIABLES4096,MAX_BONES1024, generator recursionMAX_GENERATOR_DEPTH32, modifier nestingMAX_MODIFIER_DEPTH16, reference chasingMAX_REFERENCE_HOPS8 /MAX_INLINED_REFERENCES64, inlined machine heightMAX_MACHINE_NESTING33 (event/variable name merge is hashed, 3ce34ed28 #5893).inline_referencesspends oneInlineBudgetshared by the whole recursion: the 64 resolves plus aMAX_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 wrapu32is left out (5696d0947, #6212;mutually_referencing_behaviors_are_each_loaded_once,inlined_volume_is_bounded_by_one_shared_weight_budget; the shared weight isgraph_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 bya_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: aWalkguard carries an on-stack ancestor set (a pointer back to an ancestor is refused) and a per-decodeMAX_GRAPH_STEPS2,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 chargewalkis the regression. File-driven integer arithmetic useschecked_*—bone_of_binding'soffsetInArrayPlusOne - 1overflowed on0x80000000(49895b2df, #5895).expression.rsis 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 Bcstrcap 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 chargedmax(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::buildparses the schema withParseLimits::unlimited()and streams theDBFileIndexcollections element-by-element — its bound is the per-instance decode, notparse_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; plusNoKeyedObjects, a0da96c0f #5899); the skip path walksClass::read_orderlike the read path (#5323, 258ba2b90);cdb_material_indexwarns before memoizing a failed archive fetch (#5319, 8be8c3d3a). - MenuXml's own DDS decoder (
tex.rs) rejects dimensions > 8192 and sizes the uncompressed payload againstwidth × height × bppbefore allocating the RGBA canvas; every BC path (BC1–5, incl. via the DX10 header) checks the full block payload with checked arithmetic inbc_outputbefore allocating (0b7501e70, #5667); a zero channel mask is an absent channel and sub-8-bit masks replicate by shift-OR (the old2*bits - 8underflowed — dev panic, release mis-decode), L8 keys offDDPF_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-inmenu_texture_header_census_decodespins zero undecodable vanilla menu headers). &strbyte-range slicing of disk-derived names panics mid-char (#3391); useas_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 isErr(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 anErrnaming the entry (#4662,extract_dx10_rejects_a_short_non_final_chunk_naming_the_entry); only the final chunk stays lenient. A DX10chunk_hdr_len != 24is a hardInvalidDatain every build, and nodebug_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.txtplaceholders: 35 Oblivion, 1 FO3, 1 FNV) is decided inBsaArchive::extractbefore 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 →InvalidDatanaming the entry — because flate2 1.1.10 reports an empty zlib stream asUnexpectedEofwhere 1.1.9 returnedOk(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.rspins the decoder semantics the readers rely on). "Short decode staysOk" 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 throughextract_first/Archive::extract_or_warn(asset_provider/archive.rs, #4658): a non-NotFounderror 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/--treeNIF paths (byroredux/src/debug_load.rs,scene/nif_loader.rs) now resolve throughTextureProvider::extract_mesh(BA2 dispatch, last-listed-wins,open_shared), not a raw first-winsBsaArchive::open(fixed in 44219e881, #5009; pinned by the hygiene testno_production_code_opens_archives_outside_the_provider_layer— bin-crate production code may not openBsaArchive/Ba2Archivedirectly).Archive::open_shared(asset_provider/archive.rs, 5402b2530 #5288) is the process-wide opened-handle registry (validated by anArchiveStamp— 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_unwindinbyroredux/src/streaming/pre_parse.rs; archive extract, hkx, bgsm, facegen and menuxml callers may run unguarded on the main thread.crates/facegen/src/egm.rsindexesbytes[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 andu64offsets on v105; embed-name flag 0x100 only for >= 104; codec zlib v103/v104, LZ4 frame v105 — neverlz4_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
matchover {1,2,3,7,8} (unknown_version_rejected); v2 header +8 B, v3 +12 B carryingcompression_method(0 zlib, 3 LZ4 block, elseErr:v3_unknown_compression_method_rejected); v1/v2/v7/v8 always zlib; per-chunkpacked_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) andextract_dx10_mips(path, first_mip)(inflates only chunks reaching the range;first_mip0 equalsextract; 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 aboveNEWEST_KNOWN_VERSION(22) still decodes with the newest layout but surfacesversion_over_ceiling+unconsumed_bytes(#4664); strings decode lossily (#4672).material/provider.rslogs those diagnostics on the BGEM path, and the BGSM template resolver (crates/bgsm/src/template.rs) now parses every leaf and template throughparse_bgsm_diag, reporting each via theTemplateResolver::parsedhook that the provider routes to the samewarn_parse_diagnostics(746e0858e, #5010, pinned byresolve_reports_per_file_parse_diagnostics; cache hits are not re-reported). A new parse site that calls the non-diagnosticparse_bgsm/parse_bgemis the regression. - CDB: header errors (
BadMagic,UnsupportedVersion,BigEndianUnsupported); vocabularies pinned bychunk_type_recognized_set_is_pinned,builtin_type_recognized_set_is_pinned,class_flags_known_mask_is_pinned;probe_headertolerates 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_sizeto the file's own track counts aspad(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::parsegates magic, pointer width 4|8, little-endian,fileVersion8 (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 them_predicateArraySizePlusPaddingarea and the splineanimation_typeis 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 inconvert_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 arescale × 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=1makes an absent corpus panic and a setBYROREDUX_<GAME>_DATAoverride binding. Implemented incrates/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 throughtests/common(binding override, strict on a missing install/archive, panic on an open failure); hkx'sanimations_archive, bgsm'sopen_archive_at, facegen'sGame::extract, the bsarequire_archivehelpers and the in-cratearchive_missinggate all fail underREQUIREinstead of skipping, and treat a present-but-unopenable archive as a failure. All six parser crates'--ignoredsuites run inreal-data-gates.yml'sparsersjob (#4659), which targets abyroredux-game-dataself-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::unlessBYROREDUX_SKYRIMLE_DATAis set. - Assertion strength:
real_cdb.rspins exact counts (97 classes / 1,438,780 values); bgsm holdsMIN_SUCCESS_RATE1.0 over FO4 and FO76Materials.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 throughdecode_spline_animationagainst a reject baseline (the first run measured 288 vanilla rejects — a real decoder bug, then fixed: #5659mask_sizeis Havok's 4-byte-padded size, eca0238e7, and #5662 a non-finite prop-bone track is dropped intoHkxAnimation::dropped_bonesrather than failing the clip, 9707cb367; the census now rejects 0 of 6,126), andmaterial_index_dlc_cdb_resolves_the_base_corpusasserts 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 throughrequire_game_data/require_archiveand pins its exact byte, vertex and morph counts, and the FO4 BGSM/BGEM and FO76 sweeps parse throughparse_*_diagand 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.rspinsfallout_4_animations_archive_census_is_exact(14,370 clips — 13,514 spline / 856 lossless, 848 additive),fallout_4_behavior_graph_census_is_exactandskyrim_se_behavior_graph_census_is_exact(three named non-decoders), and the default lane now builds version-11 packfiles (PackfileBuilderv11 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 behindBYROREDUX_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/FNVtextures\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 paddedmask_sizeand 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.mdordocs/feature-matrix.mdneeds 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 →MRTextureFileslots (SLOT_COLOR..SLOT_TRANSMISSIVE) plus per-slotTextureReplacement, alpha test,IsGlass,UseSSS, and (9bb6b7dce, #5277/#5283)EffectSettingsComponent.BlendingModeandEmissiveSettingsComponent(Enabled + the inline nestedEmittanceSettingstint / luminous emittance).apply_cdb_material(material/merge.rs, 978d25c19) forwards colour / normal / emissive slots, theSLOT_COLORreplacement only when no colour texture landed (never a tint), alpha test only whenHasOpacity = trueis authored on theParentchain (an absent field is the measured class defaultfalse; regression guard: fixed in 0d698d16c, #5661, closed),IsGlass→bgem_glass,"AlphaBlend"→has_alpha, (enabled emissive settings are captured but parked, 4667b884b #5503 — noemissive_color/emissive_mult); it stampsPbrMaterial::NO_SIGNAL_NEUTRALscalars 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 offTranslucencySettingsComponent.Settingssince 595110e3c, #5673 — the old arm keyed on the nested struct's class name, which never occurs top-level). TheObjectsdecode counts rows whoseDBID/Parent/PersistentIDshape drifted and warns once per build (fa6666053, #5672;PersistentIDmisses counted and an all-miss table is the typedNoKeyedObjectserror, a0da96c0f, #5899); parent-graph defects (cycles, objects inheriting through one, chains pastMAX_PARENT_DEPTH64) come from one O(n) census at build and warn once — the per-mergeParentwalk (ancestry, bounded + cycle-safe) is silent (a0da96c0f, #5900);MaterialIndexreads thePersistentIDcolumns by name, not map order (30bdbcc3b, #5465).ObjectInfo.Parentinheritance 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 andTextureReplacementflat colours too (nearest-set-wins down the chain; an authoredEnabled = falseon a nearer set stops inheritance; real-data no-texture count 2,049 → 217 of 10,152, pinned bymaterial_index_resolves_inherited_texture_slots). #5502 is closed (5c14ebdf8 + 1d7e2c6bd; the glass-coverage decision is recorded innifal.md:IsGlassroutes tobgem_glassdirectly, theAlphaBlendforward only passes the classifier's coverage early-return);Additive/Multiplyblend modes are forwarded (8e30e66e1,cdb_blend_mode_tests); the soft variants andTakeSmallerstay parked by name innifal.md(dbfae5a34; #5509 open).MergeOutcome::PresenceOnlynow means an index miss (apply_cdb_pbr_fallback,mat_path_lookup_miss_keeps_presence_only_and_no_textures). A loose.matJSON file that decodes wins over the CDB (material/loose_mat.rs, #4277, c2f28e06c); the decoder reads the installed layout —Objects[].Components[]with properties underData, string-typed bools and scalars,Data\stripped fromFileName,Indexviau8::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 recordImportedTextureSource::Mat.parse_loose_matis a secondCdbMaterialproducer, but the Alpha / Effect (BlendingMode) / Emissive settings rule lives once incapture_settings(crates/sfmaterial/src/settings.rs, over aFieldSourcetrait both decoders implement; loose folds first-writer-wins) — fixed in 442901646, #5525, pinned bythe_compiled_and_loose_representations_capture_identically; the loose decoder still walks noParentchain, andTranslucencySettingsComponentstays 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/.trihave no consumer — documented incrates/facegen/src/lib.rs(#3544, closed by doc correction); only EGM reachesapply_morphs. Re-file only if that claim changes. - HKX partial results reach
convert_hkx_clipas data, not silence:HkxAnimation::dropped_bones(#5662),reference_frame_dropped(#5894) andadditive(Havok 2014blendHint, #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_headerhas no production caller;cell_loader/precombined.rsowns 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(Unicodeto_lowercase().replace('/', "\\"); the BSA module's duplicate is gone, f26eb657f #6308); consumer normalisersnormalize_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; guardssiblings_*inarchive_siblings.rs;open_with_numeric_siblingsde-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--esmplugin'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 throughnormalize_pathlike 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 inread_dirorder (f26eb657f, #6308;loose_case_variant_duplicates_resolve_to_the_last_path);every_content_provider_resolves_collisions_last_winsscans providers for a first-wins loop;count_shadowed_entrieslogs 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 viaread_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.vdffromsteamapps/+config/is unioned; ACFinstalldiris contained understeamapps/common/ininstalls_in_libraryvia the host-independent string predicateis_bare_install_dir(#4673; widened in aad5dfd75, #5016 —/,\,:,.,..rejected so Windows-rooted\X/D:Xcannot 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).