Audit save slots and simulate corruption recovery
verifiedAgainst 971c93c · verifiedOn 2026-09-10.
What you will build
Section titled “What you will build”In Voxamine, world persistence is built to withstand sudden power outages, process crashes, and storage corruption without sacrificing player data (MASTERPLAN.md §40). Instead of dumping unbuffered streams directly into active files, the game uses a dual-file architecture (SaveGameService.cs): lightweight, human-readable JSON for identity and session metadata (WorldMetadata.cs), and high-density, binary-chunk data for block edits, vessels, and entities (WorldSaveData.cs).
You will manage the atomic, crash-consistent save pipeline using AtomicJsonFile.cs and AtomicBinaryWorldSaveFile.cs, enforce safe slot slug validation, test automated backup fallback (.bak) when a primary file is damaged, and audit slot health interactively using the Persistence Lab Voxel Workshop module (PersistenceLabModule.cs).
// Load a world slot safely with automated backup recovery:var saveService = new SaveGameService(saveRootDirectory);if (saveService.TryLoadSlot("alpha-base", out WorldMetadata metadata, out WorldSaveData saveData, out string warning, out string error)){ // If primary file was corrupted, SaveGameService transparently falls back to .bak // and emits a non-fatal warning so the player is alerted! if (!string.IsNullOrEmpty(warning)) { Debug.LogWarning($"Slot loaded with backup recovery: {warning}"); }}Where this sits
Section titled “Where this sits”The persistence architecture isolates file I/O from scene rendering and gameplay systems:
- World Persistence Service (
SaveGameService) acts as the high-level coordinator: validating slot names, listing saves, creating slots, and rotating backups. - Atomic File Helpers (
AtomicJsonFile&AtomicBinaryWorldSaveFile) guarantee crash consistency by writing to temporary.tmpfiles, flushing to disk, and executing atomic file replacements. - Data Models (
WorldMetadata&WorldSaveData) represent the immutable schema for metadata (seed, format version, elapsed time, difficulty) and binary world state (chunk edits, vessel inventories, player vitals). - Voxel Workshop (
PersistenceLabModule) provides an editor dashboard to inspect local save slots, check backup availability, and deliberately simulate primary-file corruption without hand-editing files.
flowchart TD
subgraph GAMEPLAY["Game Runtime / Autosave"]
SESSION["GameSessionRuntime / AutosaveScheduler"]
META["WorldMetadata (JSON)\nSlotId, DisplayName, Seed, FormatVersion"]
DATA["WorldSaveData (Binary)\nVoxel Edits, Vessels, Entities"]
end
subgraph SERVICE["VoxelSandbox.World.Persistence"]
SAVE_SVC["SaveGameService\n(Slot management & backup policy)"]
ATOMIC_JSON["AtomicJsonFile\n(Write .tmp -> flush -> replace)"]
ATOMIC_BIN["AtomicBinaryWorldSaveFile\n(Write .tmp -> flush -> replace)"]
end
subgraph STORAGE["Disk Storage (persistentDataPath/Worlds/<slotId>/)"]
JSON_FILE["WorldMetadata.json (primary)"]
JSON_BAK["WorldMetadata.json.bak (backup)"]
BIN_FILE["WorldSaveData.bin (primary)"]
BIN_BAK["WorldSaveData.bin.bak (backup)"]
end
subgraph WORKSHOP["Voxel Workshop (Editor)"]
LAB["PersistenceLabModule\n(Slot inspection, health status, corrupt simulator)"]
end
SESSION --> SAVE_SVC
META --> SAVE_SVC
DATA --> SAVE_SVC
SAVE_SVC --> ATOMIC_JSON
SAVE_SVC --> ATOMIC_BIN
ATOMIC_JSON --> JSON_FILE
ATOMIC_JSON -.->|rotates to| JSON_BAK
ATOMIC_BIN --> BIN_FILE
ATOMIC_BIN -.->|rotates to| BIN_BAK
STORAGE --> LAB
LAB -->|simulates corruption| BIN_FILE
Before you start
Section titled “Before you start”Read Docs/MASTERPLAN.md §40 (“Persistence and save architecture”) and the Persistence.md Codebase orientation page, then review:
SaveGameService, the main entry point for slot creation and loading.WorldMetadata, the lightweight human-readable JSON schema.WorldSaveData, binary payload containing voxel edits and vessel state.PersistenceLabModule, the editor tool for inspecting and testing save slots.SaveGameServiceTests, test suite verifying atomic saves and backup recoveries.
Review the storage invariants:
| Constraint | Rule | Purpose |
|---|---|---|
| Slot ID Validation | Must match ^[a-z0-9][a-z0-9_-]{2,31}$. |
Prevents path traversal attacks (../) and cross-platform filesystem incompatibilities. |
| Dual-File Split | Metadata in JSON; world state in Binary. | Allows fast UI slot listing without deserializing megabytes of voxel chunk geometry. |
| Crash Consistency | Write to .tmp, flush to disk, then replace. |
Guarantees that a power cut during a save never leaves a half-written, unreadable primary file. |
| Automated Fallback | If primary fails, load .bak and issue warning. |
Ensures player never loses world progress due to a single corrupted write block. |
| 64-bit Seed Preservation | DisplaySeed ($long$) and worldSeed ($int$) stored together. |
Preserves Minecraft-compatible signed 64-bit seed strings while providing internal 32-bit hash seeds. |
The build, step by step
Section titled “The build, step by step”1. Validating Slot Identity and Directory Layout
Section titled “1. Validating Slot Identity and Directory Layout”In SaveGameService.cs, each save lives in its own directory under persistentDataPath/Worlds/<slotId>/:
public bool TryCreateSlot( string slotId, string displayName, long displaySeed, int worldSeed, int difficulty, out WorldMetadata metadata, out string error){ if (!IsValidSlotId(slotId)) { error = "Slot id must be 3-32 characters using lowercase letters, numbers, hyphens, or underscores."; return false; }
string slotDirectory = GetSlotDirectory(slotId); if (Directory.Exists(slotDirectory)) { error = $"A save slot with id '{slotId}' already exists."; return false; }
Directory.CreateDirectory(slotDirectory); metadata = new WorldMetadata { slotId = slotId, displayName = displayName, displaySeed = displaySeed, worldSeed = worldSeed, difficulty = difficulty, formatVersion = WorldMetadata.CurrentFormatVersion, createdAtUtc = DateTime.UtcNow.ToString("O"), lastPlayedAtUtc = DateTime.UtcNow.ToString("O") };
return AtomicJsonFile.TryWrite(GetMetadataPath(slotId), metadata, out error);}2. Atomic Crash-Consistent Writes
Section titled “2. Atomic Crash-Consistent Writes”In AtomicBinaryWorldSaveFile.cs, files are written using a three-phase commit:
public static bool TryWrite(string targetPath, WorldSaveData data, out string error){ string tempPath = targetPath + ".tmp"; string backupPath = targetPath + ".bak";
try { // 1. Write full payload to temporary file: using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None)) using (var writer = new BinaryWriter(stream)) { data.Serialize(writer); stream.Flush(true); // Force physical flush to storage media }
// 2. Rotate existing primary file to backup: if (File.Exists(targetPath)) { File.Copy(targetPath, backupPath, overwrite: true); }
// 3. Atomically replace target file with temp file: File.Move(tempPath, targetPath, overwrite: true); error = null; return true; } catch (Exception ex) { if (File.Exists(tempPath)) File.Delete(tempPath); error = ex.Message; return false; }}If power fails during step 1, the primary file is completely untouched. If power fails during step 3, the filesystem guarantees atomic rename on modern filesystems or fallback to .bak.
3. Automated Backup Recovery
Section titled “3. Automated Backup Recovery”In SaveGameService.TryLoadSlot, the loader attempts to load primary files first, falling back to backup files when corruption is detected:
public bool TryLoadSlot( string slotId, out WorldMetadata metadata, out WorldSaveData saveData, out string warning, out string error){ metadata = null; saveData = null; warning = null;
string metaPath = GetMetadataPath(slotId); string dataPath = GetDataPath(slotId);
// 1. Load Metadata (JSON): if (!AtomicJsonFile.TryRead(metaPath, out metadata, out string metaError)) { // Attempt metadata backup: string metaBak = metaPath + ".bak"; if (File.Exists(metaBak) && AtomicJsonFile.TryRead(metaBak, out metadata, out _)) { warning = "Primary metadata was corrupt; recovered from backup."; } else { error = $"Could not load metadata: {metaError}"; return false; } }
// 2. Load World State (Binary): if (!AtomicBinaryWorldSaveFile.TryRead(dataPath, out saveData, out string dataError)) { // Attempt binary backup: string dataBak = dataPath + ".bak"; if (File.Exists(dataBak) && AtomicBinaryWorldSaveFile.TryRead(dataBak, out saveData, out _)) { warning = (warning == null ? "" : warning + " ") + "Primary world data was corrupt; recovered from backup."; } else { error = $"Could not load world data: {dataError}"; return false; } }
error = null; return true;}4. Interactive Testing with Persistence Lab
Section titled “4. Interactive Testing with Persistence Lab”Open PersistenceLabModule.cs. The module provides:
- Slot Registry View: Lists all local slots, showing seed, creation time, and format version.
- Health Indicator: Green checkmark when primary files are intact; warning icon when running on
.bak. - Simulate Corrupt Primary: A safe QA button that corrupts
WorldSaveData.binwith random junk bytes while preservingWorldSaveData.bin.bak. Clicking refresh immediately confirms thatSaveGameServicecatches the corruption and reports backup recovery.
Verify
Section titled “Verify”1. Execute Headless EditMode Tests
Section titled “1. Execute Headless EditMode Tests”Run the dedicated persistence tests:
/opt/unity/Editor/Unity -batchmode -nographics \ -projectPath /home/soulwax/workspace/engines/unity/minecraft/Minecraft-HD \ -runTests -testPlatform EditMode \ -testFilter VoxelSandbox.Tests.SaveGameServiceTestsVerify that all assertions pass:
CreateSlot_ValidatesItsPublicIdentityAndListsTheNewWorld: verifies regex validation and duplicate rejection.CreateSlot_PreservesTheSignedInt64SeedBesideItsInternalHash: confirms 64-bit seed round-tripping.LoadSlot_FallsBackToBackupWhenPrimaryFileIsCorrupt: proves automated.bakfallback with non-fatal warning reporting.SaveSlot_OverwritesAtomicallyWithoutLeavingTempFiles: confirms clean.tmpcleanup.
2. Inspect in Persistence Lab
Section titled “2. Inspect in Persistence Lab”- Open Tools → Voxel Sandbox → Voxel Workshop → Persistence Lab.
- Verify existing slots appear in the list with
Format Version: 3and healthy status. - Click Simulate Corrupt Primary on a test slot:
- Verify the slot status changes to
Recovered from backup. - Confirm the game can still load and enter Play Mode from that slot without crashing.
- Verify the slot status changes to
Now do your own
Section titled “Now do your own”To add a new persisted domain (e.g. Atmospheric Enclosures or Player Achievements):
- Declare Save Payload Structure: Define serializable state in a dedicated struct implementing
ISavePayload. - Add Binary Serialization: Wire
BinaryWriter.WriteandBinaryReader.ReadinWorldSaveData.Serialize/Deserialize. - Bump Format Version: If breaking backwards compatibility, increment
WorldMetadata.CurrentFormatVersionand provide migration logic inSaveGameService. - Add Regression Tests: Assert round-trip fidelity in
SaveGameServiceTests.cs.
Pitfalls
Section titled “Pitfalls”| Symptom | Cause | Fix |
|---|---|---|
| Save file becomes 0 bytes or corrupt after a game crash. | File was written directly via FileStream(path, FileMode.Create) without atomic replacement. |
Always write to <path>.tmp, flush with stream.Flush(true), and atomically replace via File.Move(..., overwrite: true). |
| Slot list takes several seconds to display on main menu. | Menu loaded entire binary chunk data just to display the world name and seed. | Separate metadata into lightweight WorldMetadata.json. Only load WorldSaveData.bin when entering gameplay. |
| Player seed changes when creating a world with a large negative seed. | Seed was cast directly to int, truncating 64-bit values. |
Store DisplaySeed as long and derive worldSeed via deterministic hashing (WorldSeedMath.ResolveSeed). |
| World fails to load after a crash and no backup is found. | Backup rotation was performed after the write instead of before. | Always copy existing targetPath to <targetPath>.bak before replacing it with the new .tmp file. |
| Path traversal vulnerability when loading custom slot IDs. | Raw user input was joined to saveRoot without validation. |
Validate slot IDs strictly using ^[a-z0-9][a-z0-9_-]{2,31}$. Reject dots and slashes. |
User-contributed notes
Corrections, clarifications, and practical tips for this page. Anonymous is fine — a name is optional. Basic Markdown works:
**bold**,*italic*,`code`, and links.Notes policy
Notes are lightly filtered for spam and may be edited or removed. Keep them about this page — no support requests, no personal data, nothing you would not publish. Links are limited and marked
nofollow.