"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)"
Instal·la
npx skillscat add matiaszanolli/byroredux/claude-commands-audit-save Instal·la des del registre de SkillsCat.
Save / Load Subsystem Audit (M45 + M45.1)
Audit crates/save (full-ECS-snapshot format) and its sole live caller byroredux/src/save_io.rs
(+ 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/ (~2.9k 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_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 + 7 sub-checks). Tests: crates/save/tests/round_trip.rs.
Engine side (byroredux/src/save_io.rs): build_save_registry (the curated type set),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 inapp_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, theplayer_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 (plusapp_step / boot::schedule sources named below). Only the four real-master tests insave_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 four explicit nested-payload files — vs BASELINE_SHAPE_FINGERPRINT/BASELINE_MAJOR |
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 |
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 |
app_step.rs::the_save_drain_publishes_the_transition_flag_before_draining |
CellTransitionInFlight published before the drain |
*_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 andMUTABLE_DELTA_COLUMNS; staleness of NOT_SAVED_BY_DESIGN reasons; 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 dimensiongit 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.rs (build_save_registry, MUTABLE_DELTA_COLUMNS, 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.rs; 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;PlayerNotifications
andPickedUpare correctly transient — the durable half of a pickup is thePersistentReferenceStates
tombstone). 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),RigidBodyData,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_component
site: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,TimedRestorations,Dead,ActorControlStateuseregister_replacing_component(the last two since #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 — the basis ofRigidBodyData's
"NoReconcilerNeeded" row). - 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, 49 as of 2026-10-12 — v31 #5042ActorValue.base_authored, v32 #5017ActorControlState.unconscious+ the parkedReferenceStatecontrol
state, v33 #536714cff35aenew saved resourceDialogueSpokenInfoForms(Say-Once ledger; the doc still has no
v32→v33 entry, known-open #5459), v34 #5412a614eb273ActorValue.set_override, v35 #539403e51dfadnew
saved resourceStoryEventAliasFill, v36 Wave 4RigidBodyData.surface_materialrequired byte, v37 #5542surface_materialwidenedu8→u32(Skyrim CRC32 materials), v38 #5300DialogueSpokenInfoFormselementu32→(speaker ref FormID, INFO FormID)pair — Say Once is per actor, v39 M47.2 fragmentEffect::AddItem
containerObjectRef→ActorRef+ newEffect::RemoveItem(queued fragment tails are saved), v40 M47.2 new saved resourceQuestScriptVariables(+ActorRef::Speaker,Effect::SetQuestVariable), v41 M47.2Effect::Conditional
guardsStageDoneGuard→FragmentGuard, v42 M47.2 new savedScriptedFactionChanges+Effect::ChangeFaction, v43 M47.2 new savedLocationAliasBindings(quest location aliases), v44 M47.2AddItem/RemoveItemcountu32→ItemCount+ parkedReferenceState.perks, v45 M47.2 new savedCreatedReferences, v46 M47.2CharacterLevel
column (kill XP ended #2947's exemption), v47 M47.2 new savedAliasOverrides(ForceRefTo / Clear), v48 M47.2 new savedReferenceActivationBlocks(BlockActivation), v49 M47.2 new savedMapMarkers(AddToMap), v50 M47.2
new savedLocationClearState(cleared locations), v51 M47.2 new savedBleedoutcomponent (essential actors' bleedout), v52 M47.2 new savedActorBaseProtectionOverrides(SetEssential / SetProtected), v53 M47.2 new
savedLevelUpPointscomponent (unspent skill points / perks), v54 M47.2 new
savedScriptedRelationships(SetRelationshipRank);
do not hardcode the number elsewhere): 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. 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 viaSchemaMismatch
with 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_DESIGN
types 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-derive
registered type (thebyroredux/srcpattern) defined in another file is outsidesave_type_sources()unless
added to its explicit list (known-open #5059:LightSource.emitter, thecrates/sdkpayloads); a save derive
that is not the last#[…]attribute before the declaration is skipped (known-open #5403:StoryManagerNodeState,SmNodeRuntime); 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.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.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, progression (CharacterLevel.xp != 0aborts becauseCharacterLevelis unsaved; the player now carries one from itsNPC_record (#4678), so the first XP
writer makes every save refuse untilCharacterLevelis registered), andMaterialfiniteness; 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.rs (execute_pending_save_loads, reload_*_session, 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.rs
- 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; neverItemInstancePool— teardown'srelease_victim_item_instancesneeds the live pool, #4135) →without_parked_statewraps the reload (outgoing session'sPersistentReferenceStatesandStreamStateSnapshotsare set aside and restored on failure) → teardown + reload (validate_cell_loadable
preflight first, so a missing ESM/cell keeps the live session; thenpurge_cinematic_retention_state
drops 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 unsavedCinematicReAdoption
pending 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)
→restore_extension_state→ wholesalerestore_resourcesagain (idempotent; re-assertsCurrentCellContext/PlayerPose; the second call is
not redundant) →restore_resident(parked rows for resident placements, #4695) →drop_entity_bound_continuationsonPapyrusProviderContinuationQueue(session-localEntityRefhandles must not resume, #4139) →reseat_ambient_packages_after_restore(re-pick packages
against the restored clock before the overlay, #4815; neither it norrestore_residenthas a source-order
pin, known-open #5060) →build_form_id_remap→ 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
twice back-to-back, known-open #5460),reset_player_factions_to_record→ diagnosticvalidate_world→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 → bodyTransform+GlobalTransform+set_kinematic_translation(returnsfalsewithout a Rapier handle) and clearsvertical_velocity/is_grounded/wants_jump; 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 (eachlib.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).