M45 Save/Load Subsystem Audit

VerifiedCaution

Deep audit of the M45 save/load subsystem: full-ECS-snapshot capture, type-erased registry, format-version discipline, atomic disk writes, and save-corruption detection framed as CRITICAL data loss.

Sby Skills Guide Bot
SecurityAdvanced
11/9/2026
Claude Code
#rust#save-load#code-audit#data-integrity#ecs

Recommended for

Automated summary

Deep audit of a Rust game engine's M45 save/load subsystem: full ECS snapshot capture, type-erased registry, format-version discipline, atomic disk writes, and the M45.1 live load-apply path.

Strengths

  • Very tight scope: exact files, roles and sizes (~3.2k LOC in crates/save) are enumerated, leaving few blind spots.
  • Orchestrator plus per-dimension task agents (max 3 concurrent), with mandatory reading of shared audit methodology and severity docs.
  • Correctly frames the stakes by ranking any data loss or corruption as CRITICAL/HIGH, keeping findings prioritised around player progress.
  • Surfaces non-obvious traps, such as registration order feeding `schema_fingerprint` and silently breaking every existing save with a `SchemaMismatch`.

Limitations

  • Heavily bound to one specific private repository (crates/save, byroredux) and to commit hashes; not reusable as-is elsewhere.
  • Relies on companion files that are not included (`.claude/commands/_audit-common.md`, `_audit-severity.md`) and on an external round-trip doc.
  • The provided text is cut off mid-sentence in the « Ownership split » section, so the responsibility breakdown is incomplete.
When to use it

Use it when reviewing a change that touches crates/save, save_io or the save format, before merge, to catch progress-loss and corruption risks.

When not to use it

Avoid it for auditing another project's save system, or when the `_audit-common.md` and `_audit-severity.md` docs are unavailable.

Security analysis

Caution
Quality score84/100

The skill is a read-and-report audit workflow: it greps, reads git history, runs the project's own test suites, files no credentials anywhere, and writes a markdown report to a fixed temp path. There is no exfiltration, no curl|sh, no credential handling, and the only deletion is a scoped temp directory. Risk is 'caution' rather than 'safe' because it drives bash, git history, GitHub API calls with ambient credentials, and executes compiled project code, and because it pulls in transitive instruction files that were not supplied for review.

Findings
  • •Runs `cargo test` across workspace crates, which compiles and executes repository code (build scripts, test binaries) — code-execution surface if the repo is untrusted or compromised.
  • •Invokes `gh issue list --repo matiaszanolli/ByroRedux`, i.e. authenticated network access to GitHub using the operator's CLI credentials.
  • •Uses shell filesystem mutation including `mkdir -p /tmp/audit/save` and a terminal `rm -rf /tmp/audit/save` (scoped, but a destructive verb).
  • •Ships no `allowed-tools` declaration, so the required bash/git/gh/cargo capabilities are implicit rather than bounded by the skill itself.
  • •Heavily delegates to instructions in other files (`.claude/commands/_audit-common.md`, `_audit-severity.md`) whose contents are not included here and are therefore unaudited.
  • •Prescribes running shell commands (`cargo test`, `git log`, `mkdir -p`, `rm -rf /tmp/audit/save`) and network access via `gh issue list --repo matiaszanolli/ByroRedux`; all are legitimate audit operations and the `rm -rf` is scoped to a `/tmp` scratch dir, but the file grants no tool allowlist, so capabilities are implicit rather than bounded.
  • •Instructs writing report files into `docs/audits/` and reading repo-internal paths; write target is confined to audit docs, and no instruction asks to transmit repository contents, credentials, tokens, or environment data anywhere.
  • •No obfuscation, base64 payloads, curl|sh pipelines, safety-disablement, or attempts to alter the auditor's role were found; the dense imperative prose is normal skill-authoring style, not prompt injection.
How this analysis is made

Examples

Audit complet du format de sauvegarde
Audite le sous-système save/load M45 dans crates/save et byroredux/src/save_io en profondeur : format, registre, écriture disque atomique et refus côté sauvegarde. Classe tout risque de perte de données en CRITIQUE.
Vérifier l'impact d'un nouveau composant enregistré
J'ai ajouté un appel à register_saved dans crates/scripting/src/skills. Vérifie si sa position dans build_save_registry casse schema_fingerprint pour les sauvegardes existantes, puis propose le correctif.
Contrôle du chargement à chaud
Fais un audit ciblé du live load-apply M45.1 : reload de cellule, deltas indexés par FormId et restauration de la pose du joueur. Signale toute divergence entre MUTABLE_DELTA_COLUMNS et les composants réellement enregistrés.

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"

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 ~107-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.2k 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 — since #5802 bde3478db it also calls per-subsystem register_saved(&mut registry) delegates in crates/scripting/src/ (ownership, crime_gold, crime_faction, obscript_state, obscript_ledger, challenges, magic_effects, speechcraft, loc_ref_types, destruction, skills — the last four e619dbbaf, which splits the builder chain at each), each at its rows' former position, because schema_fingerprint hashes rows in registration order — moving a delegate call is a SchemaMismatch for every existing save), 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 seven explicit nested-payload files (since #5059 945be7dc6: lighting.rs, crates/sdk identity.rs / script_function.rs); 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) | | 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) | | 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 → reseat_ambient_packages_after_restore → build_form_id_remap → apply_deltas, each called exactly once (#5060 02fa8358c; 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 | | *_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; the NOT_PARKED rows of the eviction-row partition still marked "UNAUDITED" (seeded from the delta list by #6085 — each owes a park-or-reason decision; 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_DESIGN list, 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 and PickedUp are correctly transient — the durable half of a pickup is the PersistentReferenceStates tombstone). Recent false reasons, since fixed: StoryEventAliasFill ("quests restart through fresh events" — now registered, #5394 03e51dfad), EatSleepState ("resolve_destination is idempotent" — restated per PLDT anchor, #5391 42aab4c09), CinematicReAdoption ("the teardown handles them" — the purge now despawns them, #5379 203be9ed4); plus the Eat/Sleep rows' "registered Seated restore" (Seated is not overlaid — restore_world-only; restated and pinned by the seated_without_path check at the tail of the completeness guard, #5458 0aa844dbb) and StoryLocationCursor ("a load legitimately re-fires" — execute_pending_save_loads now resets it on every in-process load, #5435 557ead21d, pinned in live_reload_tests.rs).
  • Removed-from-allowlist stays registered: CharacterController (fractional breath/drowning carry), Material, RumbleOnActivate, FragmentExecutionQueue, the cinematic trio — verify none regressed out of build_save_registry, and no re-added NOT_SAVED_BY_DESIGN entry 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 its register_component site: Material (blast radius), AnimationPlayer/AnimationStack (stale root_entity/clip_handle), FollowState/EscortState/Seated/cinematic pair (EntityId, registry handles), ActorVitals (write-once FormID key). A column with an EntityId/handle must also be covered by validate_saved_entity_references (Dim 4).
  • Replacing columns: Perks, TimedRestorations, Dead, ActorControlState use register_replacing_component (the last two since #5027/#5052 7aa1d7741 — the process-lifetime player outlives the reload, so an additive Dead/restraint row survived a load into an alive save; pinned by dead_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 session EntityIds takes the StoryEventAliasFill shape (#5394): StoryEventFill serde(skip)s its EventDataSlots and saves the R1/R2 reference FormIds, which the alias refresh re-resolves. The overlay emits no EquipmentEventBatch: presentation derived from an overlaid column is re-derived by reconcile_worn_gear (byroredux/src/npc_spawn/loot_appearance.rs, #5034 20717d5b7) — run on the player after apply_deltas and on NPCs from reference_state::restore; it diffs live roots against restored slots (reveal / hide / queue PendingGearImport) and synthesizes no event batch. A new presentation fact derived from an overlaid column needs the same treatment. Re-derived-not-saved player state: FactionRanks is reset to the record by reset_player_factions_to_record after the overlay (#5058 be3cd9468 — 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 by clear_player_gear_handoff_scratch, #5255 6b494d002, PendingInventoryActions) and NPC dialogue (NpcDialogueTopic, DialogueSurfaceState) are all NOT_SAVED_BY_DESIGN; ObjectiveHudCache is an App field, not a Resource. Verify each reason (body subtree entities have no FormIdComponent: captured by save_world, never remapped). RigidBodyData is NOT_SAVED_BY_DESIGN since v116 (#6237): every carrier is a PhysicsSourceForm collision entity the live overlay can never resolve, so #2379's column only ever round-tripped through restore_world; a script's SetMotionType lives in the ReferenceMotionTypeState ledger (PRE_RELOAD_RESOURCES), applied by cell_loader::spawn::scripted_motion_type to each collider. A re-registered RigidBodyData column is the regression.
  • Determinism (the Snapshot doc claims reproducible CRCs at equal state): Snapshot maps are BTreeMap and component rows are sorted by entity id (registry.rs), but saved resources serialize as-is — at 2026-09-19 Globals(HashMap<u32, f32>), QuestStageState, ReferenceEnableState (HashSet), ReferenceLockState and PersistentReferenceStates::pair_rows all emit hash-iteration order (since joined by DialogueSpokenInfoForms (HashSet) and StoryEventAliasFill (HashMap)), so two saves of equal state can differ in bytes/CRC. #4748 is closed as a doc fix: the Snapshot doc in snapshot.rs now says the payload "is not guaranteed deterministic for equal logical state" (Globals is still a HashMap, 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_entity is saved verbatim and replayed via set_next_entity before inserts; insert_batch's entity < next_entity is a debug_assert only (release inserts at an unspawned id silently — MEDIUM); StringPool::dump/from_dump preserves symbol order (a reordered dump = every Name wrong = 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_MAJOR doc comment in snapshot.rs: 115 as of 2026-10-21, with one vN -> vN+1 entry per bump from v22 on; the ledger still lacks the v32→v33 entry — #5367 14cff35ae, new saved resource DialogueSpokenInfoForms — known-open #5459. Recent anchors: v36 Wave 4 RigidBodyData.surface_material, v37 #5542 widened it u8→u32, v38 #5300 re-keyed DialogueSpokenInfoForms to a (speaker ref, INFO) pair, and v39–v104 are almost entirely M47.2 scripting state — new saved resource columns, Effect / FragmentGuard / ValueExpr variants and fields reachable through queued fragment continuations (FragmentExecutionQueue), CharacterLevel v46 (kill XP ended #2947's exemption), Bleedout v51, LevelUpPoints v53; v105–v114 are gameplay/ObScript fields and columns (Bleedout.remaining_secs → Option v105, parked ReferenceState.bleedout v106, M47.3 ObScript columns v109–v112, Material.source_shader_type v113 #4256, parked ReferenceState script package / corpse pose + PersistentReferenceStates script rows v114 #6071–#6073 611769b16, parked ReferenceState magic effects / weapon_drawn / ignore_crime + ScriptState timer / rumble v115 #6085 3b9060701; RigidBodyData out, ReferenceMotionTypeState in v116 #6237); do not hardcode the number elsewhere). Precedent: v112 (#6050 5f8bbf454) — register_saved columns 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 because schema_fingerprint hashes only column keys (+ replacing policy). A new required field, retyped field, or new Option in 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-tagged serde_json enum (keyed by variant name, never ordinal — #4140 corrected the old "discriminant shift" rationale), removing a field (no deny_unknown_fields on any saved type), renaming with serde(alias). Changing an existing variant's field shape is not compatible. Adding/removing a registered column changes the fingerprint and rejects old saves via SchemaMismatch 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_FINGERPRINT change 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, including NOT_SAVED_BY_DESIGN types sharing it (e.g. AnimatedTextureFlip; inspect-only files such as WaterMaterial's left the scan in 573170e1c) — those move the hash with no snapshot impact; (b) a tuple struct sweeps the following impl block 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 and FORMAT_MAJOR were 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 (the byroredux/src pattern) defined in another file is outside save_type_sources() unless added to its explicit list (#5059 fixed in 945be7dc6 for LightSource.emitter and the crates/sdk FormRef / ScriptValue payloads — the next such nested payload is the residue; the extension-manifest declaration types sharing script_function.rs are exempt from the serde(default) scan, with a staleness test); the shape scan once skipped a save derive that was not the last #[…] attribute (#5403 fixed in e19cb0491, coverage markers assert StoryManagerNodeState / SmNodeRuntime contribute shapes); a new Option<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 by extensions::capture_extension_state, type ExtensionStateSnapshot in crates/sdk/src/component.rs): it is outside the registry, the completeness guard, and both serde guards (sdk types carry a plain derive, so save_type_sources() drops them), and follows its own policy — a format_version range (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 in preflight_extension_state (before teardown); every shape change to its rows bumps EXTENSION_STATE_FORMAT_VERSION (nothing enforces it — no shape baseline); rows without stable authored identity refuse the save rather than dropping; decode_saved_state treats a missing key as empty but a present-and-malformed one as an abort.
  • ValidateFn parity: every register_* variant builds a validate closure that decodes the SAME type load does (Vec<(u32, T)> for columns, bare T for resources) — a missing/wrong one exempts a column from the preflight so it fails only after teardown.
  • Feature chain: crates/save → byroredux-core features=["save"] (→ inspect) must still hold, or a non-default build compiles away serde impls and columns serialize to null.
  • FormId handle vs pair: register_form_id_component saves the stable FormIdPair (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 explicit is_form_id flag, never "first apply: None entry".
  • FnvHasher uses 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 with crates/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 inside stage_and_rename) → parent-directory fsync. Any failure up to the rename removes the temp (#5163 69fd54fb2; 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 (#5143 2e95f0bbf). The three non-save writers pin that with assert_no_clobber_fallback (production text only, via core::source_scan); write_slot is pinned by write_slot_has_no_clobber_fallback and stages through the unique atomic_temp_path (#5247 eb08e14fd; the old fixed save_<slot>.ess.tmp raced two processes sharing a save dir) after create_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::new must build the ring via SaveRing::resume (not new), and the cursor advances only after a committed write (quicksave_ring_cursor_does_not_advance_on_validation_abort).
  • Minor-version + default-fill: decode accepts 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.SetInChargen abDisableSaving; plus the context-invariant refusal #5253 27562f8ae — LoadedCellIndex present but neither CurrentCellContext nor CurrentExteriorContext means an unloadable snapshot even when the synced flag still reads false, as in the console ingresses that dispatch outside the drain), then validate_world + validate_form_ids + validate_cinematic_entity_refs (abort, up to 20 lines, nothing written), then save_world → capture_extension_state → encode → write_slot; ring advance and the SaveComplete session event only after the commit. Any alternate save path that bypasses the gates (console, player action, SDK) is HIGH; input adapters must enqueue through queue_player_save_action (post-scheduler drain — save_world takes ~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 debug input.press quicksave|quickload, which resolves through ActionBindings to 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_world currently runs hierarchy, equipment, saved-entity references (session-local EntityId fields of columns excluded from the overlay — grep the function for the live column list), animation (AnimationPlayer, AnimationStack root + layer clips, Seated.animation_restore), inventory instances vs ItemInstancePool, and Material finiteness (the progression check validate_progression_state retired in dbe562c1f when CharacterLevel became a saved replacing column, v46); the binary adds FormId resolvability and cinematic EntityId refs. 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 both restore_world and execute_pending_save_loads; the typed preflight aborts (before teardown).
  • Transient state: saves must not depend on transients — PlayerNotifications, pending action queues, CellTransitionInFlight, parked StreamStateSnapshots are 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 — #5919 aa14d3e89, replayed by stamp_quest_reference as NPCs respawn; pre_reload_restore_installs_the_scripted_faction_ledger; never ItemInstancePool — teardown's release_victim_item_instances needs the live pool, #4135; the live values are captured first as LiveReloadLedgers and 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 — #6081 b5a205715) → without_parked_state wraps the reload (outgoing session's PersistentReferenceStates and StreamStateSnapshots are set aside and restored on failure) → teardown + reload (validate_cell_loadable preflight first, so a missing ESM/cell keeps the live session; then purge_cinematic_retention_state drops every ActorCinematicState/HorseTetherState row so the convoy is not retained across the teardown as a ghost twin sharing a FormIdPair — #5056 a197e8563, both reload arms + both debug-load paths, never ordinary cell transitions (purge_wiring_tests) — and despawns + clears the unsaved CinematicReAdoption pending list, whose un-rooted entities the CellRootIndex teardown cannot enumerate (#5379 203be9ed4). Since #5384 that list holds parentless roots only, so the purge (now taking the VulkanContext) collects each root's Children closure and hands it to release_entities — subtrees no longer orphan (which made validate_world refuse every later save) and GPU/Rapier handles are released, #5490 99cd9fd6c. The teardown never despawns the player rig: unload_cell_inner refuses player_owned_entities, #5483 5a68e983e) → reset_story_location_cursor + discard_pending_story_events (the unsaved CLOC cursor and any straddling StoryEvent markers describe the outgoing session; every load behaves like a fresh boot, #5435 557ead21d) → discard_package_reports (outgoing actors' package transitions must not run PF_ Begin/Change fragments in the loaded session, #5864 001de8741) → discard_pending_unload_deliveries (the teardown's own OnCellDetach/ OnUnload queue; Bethesda raises no unload event for a load, #6083 4b8e62d87) → restore_extension_state → wholesale restore_resources again (idempotent; re-asserts CurrentCellContext/PlayerPose; the second call is not redundant) → restore_resident (parked rows for resident placements, #4695) → drop_entity_bound_continuations on PapyrusProviderContinuationQueue (session-local EntityRef handles must not resume, #4139) → reseat_ambient_packages_after_restore (re-pick packages against the restored clock before the overlay, #4815; its order against restore_resident and the overlay is pinned by post_reload_drain_steps_keep_their_order, #5060 02fa8358c) → build_form_id_remap → park the snapshot rows whose FormIdPair did not resolve (unresolved_form_id_pairs → park_unresolved_snapshot_rows into PersistentReferenceStates, #5054 17e720430 — the exterior reload streams only radius_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 in a8b3d1902 and the count pinned by #5460 bda5a1167), 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) → diagnostic validate_world + validate_form_ids + validate_cinematic_entity_refs → apply_player_pose LAST (after the overlay of CharacterController, 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 from execute_pending_save_loads (id collision = CRITICAL). clear_entities does not free GPU/physics handles — the live path's unload_current_interior/drain_streaming_state must.
  • Remap: build_form_id_remap matches saved FormIdPair → 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 carries PLAYER_FORM_ID_PAIR from 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_actions drains a FIFO after Scheduler::run; remote console runs in the exclusive DebugDrainSystem); capture_player_pose reads post-propagation Transform before the drain; PlayerNotifications drain in render_one_frame is unconditional (bounded ring of 8, oldest dropped). The load drain lives in step_save_loads where the App owns &mut World + &mut VulkanContext and is skipped while an interior transition or foreign loading screen is active; the single PendingSaveLoadSlot is 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 #5591 946310eb9: body Transform + 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_pose returns bool and 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 (#5530 d28545728, a_corrupt_saved_pose_is_refused_and_leaves_the_player_where_the_cell_spawned_them).
  • Context guards: LoadCommand accepts interior OR exterior context and rejects only neither; SaveInfoCommand mirrors the three arms (#3500); quickload_latest walks slots_by_recency and falls back past a corrupt newest slot; command_output_is_failure scans 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).

From the file to a first result

The download contains exactly the content displayed on this page. This copy may differ from the latest version in the source repository.

View the source and supporting files

This download contains SKILL.md only. Any scripts, references, templates and dependencies are not included. Get the required files from the source, preserving their directory structure, before continuing.

1. Download the file

Download SKILL.md

From the root of a test project, save the file as .skill-downloads/m45-save-load-subsystem-audit-mvj1ocrv/SKILL.md, or use the command below. This folder is for inspecting the skill before installation.

Command for macOS/Linux or WSL: requires a POSIX shell and curl. On Windows without WSL, use the download button and create the folder manually.

mkdir -p '.skill-downloads/m45-save-load-subsystem-audit-mvj1ocrv' && curl --fail --show-error --location 'https://skills-guides.com/api/skills/m45-save-load-subsystem-audit-mvj1ocrv/download' --output '.skill-downloads/m45-save-load-subsystem-audit-mvj1ocrv/SKILL.md.part' && mv '.skill-downloads/m45-save-load-subsystem-audit-mvj1ocrv/SKILL.md.part' '.skill-downloads/m45-save-load-subsystem-audit-mvj1ocrv/SKILL.md'

2. Check prerequisites

Read SKILL.md before using it. If it references scripts or external services, inspect them and prepare the required access in your test environment.

No recognized prerequisites section in this file. This does not mean it is self-contained: libraries, tools and access requirements may be mentioned elsewhere in the content or source repository.

The platforms on this page are catalog indications. The paths below follow tool documentation; they do not prove this skill has been run in those tools. No minimum tool version is confirmed for this listing.

3. Load the skill

This file does not provide a usable name/description header for this native procedure. It remains available to download and inspect; use the manual method below.

Manual method or another tool: attach SKILL.md to a conversation, or open it in an agent that can read files. Include any required supporting resources. No universal folder is recognized by every tool.

4. Try a first task

Use a short, synthetic input: an API endpoint, a small document or an interface brief. In a new conversation, attach the file or give the agent its path, then adapt this request:

Read .skill-downloads/m45-save-load-subsystem-audit-mvj1ocrv/SKILL.md and apply its instructions to this example: [describe a test input]. Identify any missing files, dependencies or access. Produce the result requested by the skill without inventing data or running unreviewed scripts.

Expected result: output matching the skill instructions, or a precise list of missing prerequisites. A read confirmation alone does not validate a first use. If an external resource is unavailable, that path remains untested.

Related skills