audit-save
Deep audit of the M45 save/load subsystem — full-ECS-snapshot capture, type-erased registry, format-version discipline, atomic disk write + ring, save-side refusal/validation gates, and the M45.1 live load-apply (cell reload + FormId-keyed deltas + player-pose restore)
- License 许可
- MIT
- 最近更新
- 2026-11-12
- Stars
- ★ 36
- Forks
- 7
01 前言 Front Matter
- description
- Deep audit of the M45 save/load subsystem — full-ECS-snapshot capture, type-erased registry, format-version discipline, atomic disk write + ring, save-side refusal/validation gates, and the M45.1 live load-apply (cell reload + FormId-keyed deltas + player-pose restore)
- argument-hint
- --focus <dimensions> --depth shallow|deep
03 SKILL.md 正文
Save / Load Subsystem Audit (M45 + M45.1)
Audit crates/save (full-ECS-snapshot format) and its sole live caller byroredux/src/save_io/ (save_io.rs is a ~108-LOC re-export hub over registry / player / commands / live_load, split in d7c47d741)
(+ save_io/*_tests.rs) for data loss and save corruption. The subsystem exists to remove Bethesda's
slow-corruption tail by making the live ECS the single source of truth; verify the CODE delivers that.
A silently dropped column, a stale schema guard, a torn capture, or a botched FormId remap loses player
progress — frame as CRITICAL/HIGH per _audit-severity.md (data loss is CRITICAL).
Architecture: Orchestrator; each dimension is a Task agent (max 3 concurrent). Read
.claude/commands/_audit-common.md (layout, methodology, dedup, finding format) and
.claude/commands/_audit-severity.md first; do not duplicate them here.
Scope
Crate crates/save/src/ (~3.3k LOC — read all): lib.rs (design intent docstring, SaveError),
snapshot.rs (Snapshot, container header, FORMAT_MAJOR doc = the bump ledger, encode/decode),
registry.rs (SaveRegistry; register_component / register_replacing_component /
register_component_preserving / register_resource / register_form_id_component; ValidateFn; schema_fingerprint), driver.rs (save_world,
restore_world, validate_snapshot_types, restore_resources, restore_resources_subset,
build_form_id_remap, apply_deltas), disk.rs (write_slot, slots_by_recency, SaveRing),
validate.rs (validate_world + 6 sub-checks). Tests: crates/save/tests/round_trip.rs.
Engine side (byroredux/src/save_io/): build_save_registry (the curated type set — a scripting subsystem's
saved columns live in its own module's register_saved(&mut registry) (crates/scripting/src/*, 45 modules, ~51 call
sites in the hub; extracted in slices from #5802 bde3478db through 93bcda9bc, then #6705 15e498420 moved 47 more rows
out of the hub; umbrella #5802 closed 2026-11-07). The hub keeps 39 rows, each with a stated reason: core types and the FormId column (byroredux-core sits
below byroredux-save; FactionReputation #6759 8fc1a9ef9 and DismemberRecord d50fc1153 are the two newest), the physics CharacterController, and the binary crate's own state. Each delegate is called at its
rows' former position, because schema_fingerprint hashes rows in registration order — moving a delegate call is a
SchemaMismatch for every existing save, and the_schema_fingerprint_is_pinned now fails it; where a module's columns sit
in separate runs of the order it exports register_saved_<column> too),
MUTABLE_DELTA_COLUMNS (second hardcoded list driving the live overlay), PRE_RELOAD_RESOURCES,
SaveCommand/SaveInfoCommand/LoadCommand, PendingPlayerSaveActions (F5/F9/pause-menu ingress),
quickload_latest, execute_pending_save_loads (+ reload_interior_session/reload_exterior_session),
capture_player_pose/apply_player_pose, validate_form_ids, validate_cinematic_entity_refs.
Cross-cut: byroredux/src/boot/registries.rs (installs registry/state), app_events.rs (per-frame
order: capture_player_pose → step_player_save_actions → step_save_loads), app_step.rs,
byroredux/src/notifications.rs (PlayerNotifications: bounded, transient, unsaved; drained in
app_frame.rs::render_one_frame), byroredux/src/extensions/ (SDK extension-state capture/preflight/
restore layered around the save/reload — the extension API is /audit-tooling),
byroredux/src/cell_loader/reference_state.rs (PersistentReferenceStates, without_parked_state),
cell_loader/{transition,spawn,unload}.rs (unload.rs: purge_cinematic_retention_state, the
player_owned_entities teardown refusal), crates/core/src/{atomic_file.rs,string/mod.rs,ecs/world.rs},
crates/physics/src/sync.rs. Companion doc: docs/engine/save-load-roundtrip.md (numbers the extension preflight/restore
steps as 1b/3b).
Ownership split: this skill owns schema, atomicity, validation, and load-apply mechanics. What loot /
inventory / consumable state should persist (gameplay semantics) → /audit-gameplay; reconcile-after-
overlay code lives in gameplay files but its existence per removable column is Dim 1's.
Guard ledger (E2) — run these first; aim dimensions at what they cannot see
cargo test -p byroredux-save and cargo test -p byroredux --bin byroredux save_io (plus
app_step / boot::schedule sources named below). Only the four real-master tests in
save_io/consumable_tests.rs are #[ignore]d. Confirm each guard is live: not #[ignore]d, its scan
finds >0 items, and it fails when the invariant is broken.
| Guard | Pins |
|---|---|
registry_completeness_tests::every_component_or_resource_impl_is_saved_or_explicitly_allowlisted |
every Component/Resource impl in any crates/*/src (roots discovered, not listed) + byroredux/src is registered XOR in NOT_SAVED_BY_DESIGN with a reason; PlayerNotifications, PickedUp etc. are classified there |
serde_default_guard_tests::serde_default_on_saved_struct_requires_format_major_bump |
any #[serde(default)] on a save-participating type (skip-only fields exempt) |
serde_default_guard_tests::saved_type_shape_changes_require_format_major_bump |
hash of every #[derive(..Serialize..)] struct/enum body in save_type_sources() — discovered files that carry a cfg_attr(feature = "save" derive or define a registered type, plus eight explicit non-turbofish / nested-payload files (form_id.rs ×2, string/mod.rs, script_instance.rs; since #5059 945be7dc6: lighting.rs, crates/sdk identity.rs / script_function.rs; since #6509 c72ecafad: collision.rs, whose MotionType is the payload of the saved ReferenceMotionTypeState and had silently left the scan when #6237 unregistered RigidBodyData); registered names are read from save_io/registry.rs and from every scanned file that defines fn register_saved( (#5802) — vs BASELINE_SHAPE_FINGERPRINT/BASELINE_MAJOR; the attribute scan reads the whole contiguous attribute block, so a save derive above a plain #[derive(..)] is seen (#5403 e19cb0491) |
serde_default_guard_tests::set_in_chargen_renames_still_decode_v23_keys |
serde(alias) rename path stays decodable |
round_trip_tests::delta_columns_carry_only_session_stable_fields |
no FixedString/EntityId/session handle in a MUTABLE_DELTA_COLUMNS type |
round_trip_tests::delta_columns_removed_at_runtime_have_a_load_reconciler |
every delta column with a production removal site has a declared reconciler or "NoReconcilerNeeded" reason |
round_trip_tests::npc_spawn_stamped_components_are_saved_or_intentionally_rederived |
spawn-stamped components registered or in REDERIVED_NOT_SAVED |
crates/save snapshot.rs/disk.rs/registry.rs unit tests |
header gates (rejects_bad_magic/_truncated/_payload_truncation/detects_crc_corruption/rejects_schema_mismatch/rejects_major_version_skew), atomic write, slot parsing, ring resume, recency tie-break, form_id_column_resolves_the_flagged_entry, replacing-column semantics |
tests/round_trip.rs::typed_snapshot_preflight_rejects_bad_column_without_world_mutation |
typed preflight precedes clear_entities |
live_reload_tests::{saved_resources_are_restored_before_the_cell_reload, pre_reload_restore_must_not_install_saved_item_instance_pool_early} |
pre-reload resource subset, #4135 |
live_reload_tests::{an_aborted_load_hands_the_live_ledgers_back, the_drain_captures_before_the_pre_reload_restore_and_reinstates_on_abort} |
LiveReloadLedgers (one field per PRE_RELOAD_RESOURCES entry, kept in step by a source guard) captured before the pre-reload restore and reinstated on every abort (#6081 b5a205715) |
live_reload_tests::live_load_drain_discards_the_outgoing_sessions_script_events |
the teardown's queued OnCellDetach/OnUnload deliveries are discarded and ActorWatch re-baselined after the session replacement (#6083 4b8e62d87) |
live_reload_tests::{live_load_drain_discards_the_cell_resets_its_reload_raised, live_load_drain_discards_the_outgoing_sessions_requests} |
the reload's own note_player_entered_cell must not leave PendingCellResets queued against the outgoing clock (#6238 078df54e1, discard_pending_cell_resets), and the value-keyed request queues (PlayerMoveRequest, PendingPlayerXp, PendingSkillAdvances, PendingSleep, PendingMusic, PendingScriptMessages, MessageBoxQueue) straddling the drain are cleared (#6239 337bf25c8, discard_outgoing_session_requests); the same sweep now also clears PendingEvidenceDeposits (#6573 81332fc38) and PendingMineHits (#6540 bdaf75c66), ends VatsState mode via end_vats_for_world_change (systems/vats.rs: exit_all() keeping roll_state; #6508 5a423316f — an F9 inside VATS left the loaded world frozen on despawned targets; #6625 6186ab77e made it the one helper shared with the player-death and cell-transition exits) and drops the crafting session, CraftingRequest and PendingDroppedItems (#6574 375d48c6d) |
registry_completeness_tests::every_component_or_resource_impl_is_saved_or_explicitly_allowlisted (tail) |
#6574: a NOT_SAVED_BY_DESIGN reason that says "a load discards" / "The load drain ends" its type must be named in the body of discard_outgoing_session_requests — a claim the sweep does not back fails (the generic guard the earlier audits asked for) |
registry_completeness_tests::the_schema_fingerprint_is_pinned |
BASELINE_SCHEMA_FINGERPRINT vs build_save_registry().schema_fingerprint(): a registration-row reorder (the #5802 slices' risk) or a column add/remove/rename fails CI instead of rejecting every save (#6296 5a3f571f4) |
registry_completeness_tests::every_delta_column_is_parked_or_explicitly_not |
the third list: every MUTABLE_DELTA_COLUMNS entry is parked in a named ReferenceState/ScriptState field (cell_loader/reference_state.rs), carried by ActorStreamSnapshot, or on NOT_PARKED with a reason; every parked field maps to a column or FIELDS_WITHOUT_A_COLUMN (#6085 3b9060701); no NOT_PARKED reason may read UNAUDITED — the marker's last row, Escorted, is parked now (#6294 f3ab49f55) |
command_queue_tests::{a_save_taken_mid_cell_transition_is_refused_not_written, a_save_taken_while_chargen_disables_saving_is_refused_not_written, quicksave_ring_cursor_does_not_advance_on_validation_abort, player_save_actions_wait_for_the_quiescent_fifo_drain, quickload_empty_errors_and_corrupt_newest_falls_back} |
save-side refusal gates, ring, quiescent drain |
live_reload_tests::post_reload_drain_steps_keep_their_order |
source-order pin inside execute_pending_save_loads: restore_resources → restore_resident → build_form_id_remap → the ScriptPackage pre-seat apply_deltas → reseat_ambient_packages_after_restore → the full apply_deltas(.. MUTABLE_DELTA_COLUMNS) (#5060 02fa8358c; the remap and pre-seat moved ahead of the re-seat in #6087 c7463c04d; the reconcile_worn_gear single-call pin joined it, #5460 bda5a1167) |
app_step.rs::the_save_drain_publishes_the_transition_flag_before_draining |
CellTransitionInFlight published before the drain |
crates/save/src/snapshot.rs the_bump_ledger_is_contiguous_and_current |
the FORMAT_MAJOR doc ledger has every vN -> vN+1 step with no hole and ends at the constant — a bump that edits only the constant fails (#5459 a4ccf3b27) |
*_survives_save_load* (round_trip_tests.rs, consumable_tests.rs) |
per-type round trips incl. lock/enable ledgers, fragment/provider queues, cinematic trio, Perks/timed restorations, SM node state + StoryEventAliasFill reference FormIds |
Not covered by any guard (the audit's real work): the two-list drift between registry and
MUTABLE_DELTA_COLUMNS; staleness of NOT_SAVED_BY_DESIGN reasons; whether the NOT_PARKED reasons of the eviction-row
partition are true (seeded from the delta list by #6085; #6294 8fc521650 / f3ab49f55 retired the UNAUDITED marker —
the guard now rejects it, and Escorted is parked; a delta column reverting on every cell exit is the GAME-SD-01/02 class); whether a baseline refresh
without a bump was justified; manual impl Serialize types (invisible to both serde guards);
the extension-state payload's own versioning (Dim 2); semantic correctness of load-apply ordering beyond
the two pinned pre-reload cases. (Path-qualified impl byroredux_core::ecs::Resource for X impls were
invisible to impl_target_type until #5255 6b494d002: it now parses the trait path's final segment, the nine
formerly unclassified types are allowlisted, and the guard also asserts every NOT_SAVED_BY_DESIGN row matches a
discovered production impl — a renamed/deleted type can no longer keep a stale row. Still judge the reasons.)
Parameters / Extra Fields
--focus <dims> (default all 5) · --depth shallow|deep (shallow = container/API contracts; deep =
trace capture → encode → disk → decode → reload → delta-apply). Finding fields: Dimension: Snapshot
Completeness | Format & Schema Discipline | Container & Disk | Save-Side Gates | Live Load-Apply ·
Data-Loss Class: silent-drop | corruption-on-load | irrecoverable-write | reference-break | none
(required for any finding that can lose progress).
Phase 1: Setup
mkdir -p /tmp/audit/save; dedup: gh issue list --repo matiaszanolli/ByroRedux --limit 200 --json number,title,state,labels > /tmp/audit/save/issues.json;
read the newest docs/audits/AUDIT_SAVE_*.md (fixed findings are regression checks — verify the fix is
in place); read the crates/save/src/lib.rs docstring and snapshot.rs container doc — each design claim
must be CODE-CONFIRMED or DRIFTED; run the guard ledger. Delta-first: for each dimension
git log --since=<last report> --format='%h %cs %s' -- <Paths>, skim unchanged ones.
Phase 2: Dimensions
Dimension 1: Snapshot Completeness & the Two Lists (highest risk — silent-drop)
Paths: byroredux/src/save_io/registry.rs (build_save_registry, MUTABLE_DELTA_COLUMNS), byroredux/src/save_io/live_load.rs (PRE_RELOAD_RESOURCES), save_io/registry_completeness_tests.rs, crates/save/src/{registry,driver}.rs
First step: git log -p -S'register_' --since=<last report> -- byroredux/src/save_io; then git log --since=<last report> --format='%h %cs %s' -- 'crates/*/src' byroredux/src | grep -i 'component\|resource\|state' to find new persistent state.
- The registry IS the completeness contract; start from the guard's
NOT_SAVED_BY_DESIGNlist, not from a hand enumeration. A persistent, player-mutable component/resource that is neither registered nor allowlisted is a HIGH silent-drop; a stale allowlist reason (the guard checks a reason exists, not that it is true) is the auditable residue — spot-check a sample every run, and re-check any reason that names a "no production mutator" claim after new gameplay commits land (P3 loot/consumables/pickups, perks, timed restorations, hardcore mode, persistent reference state were all new saved state;PlayerNotificationsandPickedUpare correctly transient — the durable half of a pickup is thePersistentReferenceStatestombstone). Recent false reasons, since fixed:StoryEventAliasFill("quests restart through fresh events" — now registered, #539403e51dfad),EatSleepState("resolve_destinationis idempotent" — restated per PLDT anchor, #539142aab4c09),CinematicReAdoption("the teardown handles them" — the purge now despawns them, #5379203be9ed4); plus the Eat/Sleep rows' "registered Seated restore" (Seatedis not overlaid —restore_world-only; restated and pinned by theseated_without_pathcheck at the tail of the completeness guard, #54580aa844dbb) andStoryLocationCursor("a load legitimately re-fires" —execute_pending_save_loadsnow resets it on every in-process load, #5435557ead21d, pinned inlive_reload_tests.rs). - Removed-from-allowlist stays registered:
CharacterController(fractional breath/drowning carry),Material,RumbleOnActivate,FragmentExecutionQueue, the cinematic trio — verify none regressed out ofbuild_save_registry, and no re-addedNOT_SAVED_BY_DESIGNentry re-excludes them. - Two lists, one truth: the live load overlays only columns in BOTH the registry and
MUTABLE_DELTA_COLUMNS. A registered mutable column absent from the overlay is captured but never replayed → HIGH silent-drop, unless deliberately excluded and documented at itsregister_componentsite:Material(blast radius),AnimationPlayer/AnimationStack(staleroot_entity/clip_handle),FollowState/EscortState/Seated/cinematic pair (EntityId, registry handles),ActorVitals(write-once FormID key). A column with anEntityId/handle must also be covered byvalidate_saved_entity_references(Dim 4). - Replacing columns:
Perks,CharacterLevel,LevelUpPoints,FactionReputation(#6759),TimedRestorations,Dead,ActorControlState,DismemberRecorduseregister_replacing_component(Dead/ActorControlStatesince #5027/#50527aa1d7741— the process-lifetime player outlives the reload, so an additiveDead/restraint row survived a load into an alive save; pinned bydead_and_restraint_cleared_on_live_player_by_saved_absence) — saved absence is authoritative for FormID-matched entities (empty column kept in the snapshot; a missing column is not a tombstone). Ordinary columns are additive-only, so every runtime removal of an overlaid component needs a reconciler (reconcile_dead_actor_runtime_state,reconcile_player_equipped_weapon); the guard enforces disposition, this dimension judges whether "NoReconcilerNeeded" reasons are true (carrier destroyed by the cell reload?) and whether a new persisted fact (disable, pickup, lock) chose the marker-plus- reconciler or FormID-keyed-ledger model (ReferenceEnableState,ReferenceLockState,PersistentReferenceStates— resources keyed by FormID survive cell unload; a component would not). A saved resource that needs sessionEntityIds takes theStoryEventAliasFillshape (#5394):StoryEventFillserde(skip)s itsEventDataSlotsand saves the R1/R2 reference FormIds, which the alias refresh re-resolves. The overlay emits noEquipmentEventBatch: presentation derived from an overlaid column is re-derived byreconcile_worn_gear(byroredux/src/npc_spawn/loot_appearance.rs, #503420717d5b7) — run on the player afterapply_deltasand on NPCs fromreference_state::restore; it diffs live roots against restored slots (reveal / hide / queuePendingGearImport) and synthesizes no event batch. A new presentation fact derived from an overlaid column needs the same treatment. Re-derived-not-saved player state:FactionRanksis reset to the record byreset_player_factions_to_recordafter the overlay (#5058be3cd9468— alias-injected ranks otherwise outlived the load). - P3/P4 runtime state (2026-09-29): player body (
PlayerBodyRoot,PlayerBodyRootEntity,HiddenFirstPerson,PlayerCameraView), mid-life gear import (NpcSkeletonBones,ActorBodyClass,PendingGearImport,PendingGearRelease— both dropped together on load byclear_player_gear_handoff_scratch, #52556b494d002,PendingInventoryActions) and NPC dialogue (NpcDialogueTopic,DialogueSurfaceState) are allNOT_SAVED_BY_DESIGN;ObjectiveHudCacheis anAppfield, not a Resource. Verify each reason (body subtree entities have noFormIdComponent: captured bysave_world, never remapped).RigidBodyDataisNOT_SAVED_BY_DESIGNsince v116 (#62376765e60eb): every carrier is aPhysicsSourceFormcollision entity the live overlay can never resolve, so #2379's column only ever round-tripped throughrestore_world; a script'sSetMotionTypelives in theReferenceMotionTypeStateledger (PRE_RELOAD_RESOURCES), applied bycell_loader::spawn::scripted_motion_typeto each collider. A re-registeredRigidBodyDatacolumn is the regression. - Determinism (the
Snapshotdoc claims reproducible CRCs at equal state):Snapshotmaps areBTreeMapand component rows are sorted by entity id (registry.rs), but saved resources serialize as-is — at 2026-09-19Globals(HashMap<u32, f32>),QuestStageState,ReferenceEnableState(HashSet),ReferenceLockStateandPersistentReferenceStates::pair_rowsall emit hash-iteration order (since joined byDialogueSpokenInfoForms(HashSet) andStoryEventAliasFill(HashMap)), so two saves of equal state can differ in bytes/CRC. #4748 is closed as a doc fix: theSnapshotdoc insnapshot.rsnow says the payload "is not guaranteed deterministic for equal logical state" (Globalsis still aHashMap, by design of that resolution). A new claim of reproducible CRCs, or a consumer that diffs/hashes saves, reopens it (MEDIUM doc/contract mismatch, not data loss). Do not claim determinism at the row level without checking this. next_entityis saved verbatim and replayed viaset_next_entitybefore inserts;insert_batch'sentity < next_entityis adebug_assertonly (release inserts at an unspawned id silently — MEDIUM);StringPool::dump/from_dumppreserves symbol order (a reordered dump = everyNamewrong = CRITICAL). Output:/tmp/audit/save/dim_1.md
Dimension 2: Format & Schema Discipline (registry fidelity + FORMAT_MAJOR)
Paths: crates/save/src/{snapshot,registry}.rs, save_io/serde_default_guard_tests.rs, crates/save/Cargo.toml, crates/core/Cargo.toml
First step: cargo test -p byroredux --bin byroredux serde_default_guard ; git log --since=<last report> --format='%h %cs %s' -- crates/save/src/snapshot.rs
- Bump rule (read the
FORMAT_MAJORdoc comment insnapshot.rs: 124 as of 2026-11-09, with onevN -> vN+1entry per bump from v22 on, contiguous since #5459a4ccf3b27restored the v32→v33 entry (#536714cff35ae, new saved resourceDialogueSpokenInfoForms) and pinned it withthe_bump_ledger_is_contiguous_and_current. Recent anchors: v36 Wave 4RigidBodyData.surface_material, v37 #5542 widened itu8→u32, v38 #5300 re-keyedDialogueSpokenInfoFormsto a (speaker ref, INFO) pair, and v39–v104 are almost entirely M47.2 scripting state — new saved resource columns,Effect/FragmentGuard/ValueExprvariants and fields reachable through queued fragment continuations (FragmentExecutionQueue),CharacterLevelv46 (kill XP ended #2947's exemption),Bleedoutv51,LevelUpPointsv53; v105–v124 are gameplay/ObScript fields and columns (Bleedout.remaining_secs→Optionv105, parkedReferenceState.bleedoutv106, M47.3 ObScript columns v109–v112,Material.source_shader_typev113 #4256, parkedReferenceStatescript package / corpse pose +PersistentReferenceStatesscript rows v114 #6071–#6073611769b16, parkedReferenceStatemagic effects /weapon_drawn/ignore_crime+ScriptStatetimer / rumble v115 #60853b9060701;RigidBodyDataout,ReferenceMotionTypeStatein v116 #62376765e60eb; v117 #6190ObScriptLedgerlost four unread columns; v118 Oblivion jail —ObScriptLedger.stolen_goods/evidence/jail; v119 theCrimeKnowledgeresource joined the registry; v120 theKnownWordsresource joined it andEffectgainedObtainWord; v121SmNodeRuntime.last_node_fire_hours; v122ItemInstance.quality; v123 #67598fc1a9ef9theFactionReputationreplacing column (the stale "no mutation site" exemption); v124 d50fc1153 theDismemberRecordreplacing column plus a parkedReferenceState.dismemberfield, VATS-LIMB-008/-009); do not hardcode the number elsewhere). Precedent: v112 (#60505f8bbf454) —register_savedcolumns landed after a baseline refresh with no bump and the shape guard sat red on main; a new registered column's defining file joins the shape scan, so it is a bump here too. Intra-type shape changes need a bump becauseschema_fingerprinthashes only column keys (+ replacing policy). A new required field, retyped field, or newOptionin a saved type bumps;#[serde(default)]is forbidden as a compatibility mechanism (guard) — even where the default would be correct for every old save. A saved map keyed by a tuple/struct cannot be a JSON object (serde_jsonrefuses the key once it holds a row, failing the whole snapshot — the FNVSetAllyquicksave failure): such maps go throughbyroredux_scripting::serde_pairsas[key, value]lists, which also read an empty object as the empty map;4156e68cdmoved the shape baseline with no bump on that ground (a non-empty map never wrote, so no old save can hold one) — judge any similar refresh against it. Read-compatible changes move the shape baseline without a bump: adding an enum variant to an externally-taggedserde_jsonenum (keyed by variant name, never ordinal — #4140 corrected the old "discriminant shift" rationale), removing a field (nodeny_unknown_fieldson any saved type), renaming withserde(alias). Changing an existing variant's field shape is not compatible. Adding/removing a registered column changes the fingerprint and rejects old saves viaSchemaMismatchwith no bump. An audit proposing to relax the blanket rule for a "safe" default reopens #1714. - Baseline refresh triage (the guard cannot judge this): every
BASELINE_SHAPE_FINGERPRINTchange must carry a justification comment stating whether a registered column's shape changed. Known false-positive classes: (a) the guard hashes every serialized-derive type in a scanned file, includingNOT_SAVED_BY_DESIGNtypes sharing it (e.g.AnimatedTextureFlip; inspect-only files such asWaterMaterial's left the scan in 573170e1c) — those move the hash with no snapshot impact; (b) a tuple struct sweeps the followingimplblock into its span (VisibilityMask) — method edits move the hash; (c) the hash includes the file-relative path, so moving a save-participating type between files moves it. Verify baseline andFORMAT_MAJORwere updated in the same commit, and that a refresh-without-bump was not hiding a real field change to a registered type. - Guard blind spots: manual
impl Serialize(no derive) types; a nested payload type of a plain-deriveregistered type (thebyroredux/srcpattern) defined in another file is outsidesave_type_sources()unless added to its explicit list (#5059 fixed in945be7dc6forLightSource.emitterand thecrates/sdkFormRef/ScriptValuepayloads — the next such nested payload is the residue; the extension-manifest declaration types sharingscript_function.rsare exempt from theserde(default)scan, with a staleness test); the shape scan once skipped a save derive that was not the last#[…]attribute (#5403 fixed ine19cb0491, coverage markers assertStoryManagerNodeState/SmNodeRuntimecontribute shapes); a newOption<T>field is hashed but looks like a routine baseline bump — confirm it got the bump;serde(alias)must never coexist with a dropped value semantic change. - Second payload — extension state (
Snapshot.resources["ByroExtensionState"], written byextensions::capture_extension_state, typeExtensionStateSnapshotincrates/sdk/src/component.rs): it is outside the registry, the completeness guard, and both serde guards (sdk types carry a plainderive, sosave_type_sources()drops them), and follows its own policy — aformat_versionrange (MIN_EXTENSION_STATE_FORMAT_VERSION..=EXTENSION_STATE_FORMAT_VERSION) with#[serde(default)]fields, which the blanket rule forbids for registry types. Verify: the range gate runs inpreflight_extension_state(before teardown); every shape change to its rows bumpsEXTENSION_STATE_FORMAT_VERSION(nothing enforces it — no shape baseline); rows without stable authored identity refuse the save rather than dropping;decode_saved_statetreats a missing key as empty but a present-and-malformed one as an abort. ValidateFnparity: everyregister_*variant builds avalidateclosure that decodes the SAME typeloaddoes (Vec<(u32, T)>for columns, bareTfor resources) — a missing/wrong one exempts a column from the preflight so it fails only after teardown.- Feature chain:
crates/save→byroredux-corefeatures=["save"](→inspect) must still hold, or a non-default build compiles away serde impls and columns serialize tonull. - FormId handle vs pair:
register_form_id_componentsaves the stableFormIdPair(skips unresolvable handles with WARN, never panics); load re-interns to fresh handles and errors cleanly without a pool.form_id_column()is keyed by the explicitis_form_idflag, never "firstapply: Noneentry". FnvHasheruses canonical FNV-1a 64 constants and hashes only names/order/policy (no TypeId/address). Output:/tmp/audit/save/dim_2.md
Dimension 3: Container & Disk Durability
Paths: crates/save/src/{snapshot,disk}.rs, crates/core/src/atomic_file.rs, crates/save/tests/round_trip.rs
First step: cargo test -p byroredux-save disk:: snapshot::
Guarded (see ledger): header gate order (length → magic → major → schema fingerprint → payload_len
checked bounds → CRC over the payload only → serde_json::from_slice), advisory minor, slot-name strictness
(save_42.ess.tmp rejected), slots_by_recency newest-first with slot-number tie-break, ring wrap/resume.
Residual checks:
atomic_write(crates/core/src/atomic_file.rs; shared withcrates/settings-io,crates/boot-request,crates/game-detect/src/overrides.rs) order is exactly: create tmp →write_all→flush→sync_all→ byte-exact read-back →rename(all insidestage_and_rename) → parent-directory fsync. Any failure up to the rename removes the temp (#516369fd54fb2;failed_rename_removes_the_temp), and no caller may fall back to copying/renaming the temp over the target on error — the old "Windows rename" fallback clobbered good files on disk-full (#51432e95f0bbf). The three non-save writers pin that withassert_no_clobber_fallback(production text only, viacore::source_scan);write_slotis pinned bywrite_slot_has_no_clobber_fallbackand stages through the uniqueatomic_temp_path(#5247eb08e14fd; the old fixedsave_<slot>.ess.tmpraced two processes sharing a save dir) aftercreate_dir_all. Rename-before-fsync, a length-only read-back, or a new fallback branch is a HIGH durability hole.- Ring never clobbers the last good save:
SaveState::newmust build the ring viaSaveRing::resume(notnew), and the cursor advances only after a committed write (quicksave_ring_cursor_does_not_advance_on_validation_abort). - Minor-version + default-fill:
decodeaccepts newer minors and serde default-fills; that is the exact path#[serde(default)]would exploit — cross-check Dim 2's guard is live. SAVE_DIR_ENV(BYROREDUX_SAVE_DIR) redirects the ring; smoke gates depend on the spelling. Output:/tmp/audit/save/dim_3.md
Dimension 4: Save-Side Gates (refusal + validation)
Paths: byroredux/src/save_io/commands.rs (SaveCommand::execute, validate_form_ids, validate_cinematic_entity_refs), crates/save/src/validate.rs, byroredux/src/app_step.rs (step_player_save_actions)
First step: grep -n 'fn validate_\|validate_[a-z_]*(world' crates/save/src/validate.rs byroredux/src/save_io/commands.rs
- Order in
SaveCommand::execute: refusals first (CellTransitionInFlight— a mid-transition save would be written and permanently unloadable;CinematicPresentationState.disable_saving—Game.SetInChargenabDisableSaving; plus the context-invariant refusal #525327562f8ae—LoadedCellIndexpresent but neitherCurrentCellContextnorCurrentExteriorContextmeans an unloadable snapshot even when the synced flag still readsfalse, as in the console ingresses that dispatch outside the drain), thenvalidate_world+validate_form_ids+validate_cinematic_entity_refs(abort, up to 20 lines, nothing written), thensave_world→capture_extension_state→encode→write_slot; ring advance and theSaveCompletesession event only after the commit. Any alternate save path that bypasses the gates (console, player action, SDK) is HIGH; input adapters must enqueue throughqueue_player_save_action(post-scheduler drain —save_worldtakes ~30 storage + resource read locks, so a live scheduler lane needs the ABBA analysis re-derived;/audit-concurrency). Current ingresses: the winit F5/F9 intercept, the pause menu, and the debuginput.press quicksave|quickload, which resolves throughActionBindingsto the same F5/F9 edges (queue_debug_action_press,byroredux/src/interaction.rs,bae84e54b;quicksave_press_joins_the_player_save_queue_through_the_f5_binding). - Coverage:
validate_worldcurrently runs hierarchy, equipment, saved-entity references (session-localEntityIdfields of columns excluded from the overlay — grep the function for the live column list), animation (AnimationPlayer,AnimationStackroot + layer clips,Seated.animation_restore), inventory instances vsItemInstancePool, andMaterialfiniteness (the progression checkvalidate_progression_stateretired indbe562c1fwhenCharacterLevelbecame a saved replacing column, v46); the binary adds FormId resolvability and cinematicEntityIdrefs. Do not restate the list elsewhere — enumerate inter-entity reference fields in any newly registered type not covered (MEDIUM defense-in-depth gap) and any check that flags a legitimately sparse-but-spawned id (dangling =>= next_entity, not "no components"). - Post-load validation is diagnostic-only (
log_validation_warnings, WARN, no abort) in bothrestore_worldandexecute_pending_save_loads; the typed preflight aborts (before teardown). - Transient state: saves must not depend on transients —
PlayerNotifications, pending action queues,CellTransitionInFlight, parkedStreamStateSnapshotsare unsaved by design; a save taken while one is non-empty must still round-trip (check new transient resources land on the allowlist, not the registry). Output:/tmp/audit/save/dim_4.md
Dimension 5: Live Load-Apply & Frame Boundary
Paths: byroredux/src/save_io/live_load.rs (execute_pending_save_loads, reload_*_session), byroredux/src/save_io/player.rs (apply_player_pose), crates/save/src/driver.rs, byroredux/src/{app_events,app_step,app_frame}.rs, byroredux/src/cell_loader/{transition,reference_state,unload}.rs
First step: read execute_pending_save_loads top to bottom against the sequence below; git log --since=<last report> --format='%h %cs %s' -- byroredux/src/save_io
- Strict apply sequence: drain slot →
validate_snapshot_types(abort, session kept) →preflight_extension_state(abort) →restore_resources_subset(PRE_RELOAD_RESOURCES)(only resources read by spawn-time code during the reload:ReferenceEnableState,ReferenceLockState,ReferenceScriptState,ScriptedFactionChanges— #5919aa14d3e89, replayed bystamp_quest_referenceas NPCs respawn — andReferenceMotionTypeState, #6237, read byscripted_motion_typeas each collider is built;pre_reload_restore_installs_the_scripted_faction_ledger; neverItemInstancePool— teardown'srelease_victim_item_instancesneeds the live pool, #4135; the live values are captured first asLiveReloadLedgersand reinstated if this restore or the reload below fails/refuses, so a "keeping the current session" abort does not leave the other timeline's ledgers live — #6081b5a205715) →without_parked_statewraps the reload (outgoing session'sPersistentReferenceStatesandStreamStateSnapshotsare set aside and restored on failure) → teardown + reload (validate_cell_loadablepreflight first, so a missing ESM/cell keeps the live session; thenpurge_cinematic_retention_statedrops everyActorCinematicState/HorseTetherStaterow so the convoy is not retained across the teardown as a ghost twin sharing aFormIdPair— #5056a197e8563, both reload arms + both debug-load paths, never ordinary cell transitions (purge_wiring_tests) — and despawns + clears the unsavedCinematicReAdoptionpending list, whose un-rooted entities theCellRootIndexteardown cannot enumerate (#5379203be9ed4). Since #5384 that list holds parentless roots only, so the purge (now taking theVulkanContext) collects each root'sChildrenclosure and hands it torelease_entities— subtrees no longer orphan (which madevalidate_worldrefuse every later save) and GPU/Rapier handles are released, #549099cd9fd6c. The teardown never despawns the player rig:unload_cell_innerrefusesplayer_owned_entities, #54835a68e983e) →reset_story_location_cursor+discard_pending_story_events(the unsaved CLOC cursor and any straddlingStoryEventmarkers describe the outgoing session; every load behaves like a fresh boot, #5435557ead21d) →discard_package_reports(outgoing actors' package transitions must not run PF_ Begin/Change fragments in the loaded session, #5864001de8741) →discard_pending_unload_deliveries(the teardown's ownOnCellDetach/OnUnloadqueue; Bethesda raises no unload event for a load, #60834b8e62d87) →discard_pending_cell_resets(the reload's cell-rootnote_player_entered_cellcalls ran against the outgoingCellVisits/clock; the restoredCellVisitsis authoritative, #6238078df54e1) →discard_outgoing_session_requests(value-keyedPlayerMoveRequest/ XP / skill / sleep / music / message-box queues that would otherwise apply in the loaded world and be kept by the next save, #6239337bf25c8; plus jail-evidence deposits, mine hits, VATS mode (end_vats_for_world_change), the crafting session and pending dropped items, #6573 / #6540 / #6508 / #6574; the test pins all but the mine queue) →restore_extension_state→ wholesalerestore_resourcesagain (idempotent; re-assertsCurrentCellContext/PlayerPose; the second call is not redundant) →restore_resident(parked rows for resident placements, #4695; followed bycombat::release_held_dead_actor_reconciliations, which hands a corpse's held reconciliation to the Late-stage sink now that its placement is resident, #61331e2c1d30e) →drop_entity_bound_continuationsonPapyrusProviderContinuationQueue(session-localEntityRefhandles must not resume, #4139) →build_form_id_remap→ aScriptPackage-onlyapply_deltaspre-seat (the ambient selection honours a running script package over the schedule, so re-seating without it stamped the scheduled winner and the next minute's tick cleared the freshly overlaidTravelState/Traveledmarkers and reported a spurious package-change fragment; the column is replacing and idempotent, so the full overlay re-applies it harmlessly — #6087c7463c04d) →reseat_ambient_packages_after_restore(re-pick packages against the restored clock before the overlay, #4815; its order againstrestore_resident, the remap, the pre-seat and the overlay is pinned bypost_reload_drain_steps_keep_their_order, #506002fa8358c) → park the snapshot rows whoseFormIdPairdid not resolve (unresolved_form_id_pairs→park_unresolved_snapshot_rowsintoPersistentReferenceStates, #505417e720430— the exterior reload streams onlyradius_load, so the save's hysteresis-band rows were dropped and their loot duplicated;unresolved_snapshot_rows_are_parked_for_the_next_respawn) →apply_deltas(MUTABLE_DELTA_COLUMNS)→ dead/equipped-weapon reconcilers,reconcile_worn_gear(called once; the duplicate was dropped ina8b3d1902and the count pinned by #5460bda5a1167),reset_player_factions_to_record→reapply_scripted_faction_changes(restored scripted memberships on top) →rebaseline_actor_watch(outgoing bleedout/combat sets and player location must not deliver across timelines, #6083) → diagnosticvalidate_world+validate_form_ids+validate_cinematic_entity_refs→apply_player_poseLAST (after the overlay ofCharacterController, whose motion fields pose-restore then zeroes; a new field on either side needs an explicit decision). Any failure after the reload returns immediately, never falling through into pose-restore on a partial overlay. Idempotency: teardown is unconditional, so a second load of the same slot does not stack deltas. - Two restore paths: live load uses
restore_resources+apply_deltas(saved ids remapped to the reloaded cell's fresh ids);restore_world(clear + repopulate at saved ids) is test/loose only and must never be reachable fromexecute_pending_save_loads(id collision = CRITICAL).clear_entitiesdoes not free GPU/physics handles — the live path'sunload_current_interior/drain_streaming_statemust. - Remap:
build_form_id_remapmatches savedFormIdPair→ live entity; rows without a form id are skipped (respawned by the loader); unresolved pairs are logged (WARN, first 20) — a moved object vanishing must stay diagnosable; the player body carriesPLAYER_FORM_ID_PAIRfrom spawn so it participates. Persistent-reference rows: resident rows arrive via the component overlay, nonresident via the resource restore; rows are consumed on respawn (semantics →/audit-gameplay). - Frame boundary: capture runs post-scheduler (
step_player_save_actionsdrains a FIFO afterScheduler::run; remote console runs in the exclusiveDebugDrainSystem);capture_player_posereads post-propagationTransformbefore the drain;PlayerNotificationsdrain inrender_one_frameis unconditional (bounded ring of 8, oldest dropped). The load drain lives instep_save_loadswhere the App owns&mut World+&mut VulkanContextand is skipped while an interior transition or foreign loading screen is active; the singlePendingSaveLoadSlotis last-writer-wins and reports supersession. - Pose: yaw/pitch always to
InputState; Character mode + live body →place_character_body(byroredux/src/systems/character.rs, the one teleport helper since #5591946310eb9: bodyTransform+GlobalTransform+CharacterController::clear_momentum—vertical_velocity/is_grounded/wants_jump—- kinematic Rapier sync, no-op without a handle); restore branches on the live mode (#2018), so
FlyCam-saved/Character-loaded relocates the body and the reverse repositions the camera.
apply_player_posereturnsbooland refuses the whole pose (non-finite or out-of-world position —PhysicsWorld::translation_is_sane, the keyframe bound — or non-finite yaw/pitch), leaving the reloaded cell's spawn (#5530d28545728,a_corrupt_saved_pose_is_refused_and_leaves_the_player_where_the_cell_spawned_them).
- kinematic Rapier sync, no-op without a handle); restore branches on the live mode (#2018), so
FlyCam-saved/Character-loaded relocates the body and the reverse repositions the camera.
- Context guards:
LoadCommandaccepts interior OR exterior context and rejects only neither;SaveInfoCommandmirrors the three arms (#3500);quickload_latestwalksslots_by_recencyand falls back past a corrupt newest slot;command_output_is_failurescans every output line. Output:/tmp/audit/save/dim_5.md
Phase 3: Merge
Combine /tmp/audit/save/dim_*.md into docs/audits/AUDIT_SAVE_<TODAY>.md: Executive Summary (each
lib.rs design claim CODE-CONFIRMED/DRIFTED; findings by severity and Data-Loss Class) · Data-Loss
Class Matrix · Completeness Ledger (registry × MUTABLE_DELTA_COLUMNS: SAVED-only / SAVED+OVERLAID /
structural; cross-check against the guard's allowlist rather than re-deriving) · Findings (CRITICAL
first, deduplicated; two-list drift owned by Dim 1, GPU/physics teardown by Dim 5) · Guards
Verified (which ledger guards were confirmed live, which reasons were spot-checked).
Phase 4: Cleanup
rm -rf /tmp/audit/save; tell the user the report is ready; suggest
/audit-publish docs/audits/AUDIT_SAVE_<TODAY>.md (domain label save-load; test-gap for coverage
findings, doc-rot for drifted save/load docs).