Skip to content
Edit on GitHub

Audit save slots and simulate corruption recovery

verifiedAgainst 971c93c · verifiedOn 2026-09-10.

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}");
}
}

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 .tmp files, 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

Read Docs/MASTERPLAN.md §40 (“Persistence and save architecture”) and the Persistence.md Codebase orientation page, then review:

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.

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);
}

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.

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:

  1. Slot Registry View: Lists all local slots, showing seed, creation time, and format version.
  2. Health Indicator: Green checkmark when primary files are intact; warning icon when running on .bak.
  3. Simulate Corrupt Primary: A safe QA button that corrupts WorldSaveData.bin with random junk bytes while preserving WorldSaveData.bin.bak. Clicking refresh immediately confirms that SaveGameService catches the corruption and reports backup recovery.

Run the dedicated persistence tests:

Terminal window
/opt/unity/Editor/Unity -batchmode -nographics \
-projectPath /home/soulwax/workspace/engines/unity/minecraft/Minecraft-HD \
-runTests -testPlatform EditMode \
-testFilter VoxelSandbox.Tests.SaveGameServiceTests

Verify that all assertions pass:

  • CreateSlot_ValidatesItsPublicIdentityAndListsTheNewWorld: verifies regex validation and duplicate rejection.
  • CreateSlot_PreservesTheSignedInt64SeedBesideItsInternalHash: confirms 64-bit seed round-tripping.
  • LoadSlot_FallsBackToBackupWhenPrimaryFileIsCorrupt: proves automated .bak fallback with non-fatal warning reporting.
  • SaveSlot_OverwritesAtomicallyWithoutLeavingTempFiles: confirms clean .tmp cleanup.
  1. Open Tools → Voxel Sandbox → Voxel Workshop → Persistence Lab.
  2. Verify existing slots appear in the list with Format Version: 3 and healthy status.
  3. 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.

To add a new persisted domain (e.g. Atmospheric Enclosures or Player Achievements):

  1. Declare Save Payload Structure: Define serializable state in a dedicated struct implementing ISavePayload.
  2. Add Binary Serialization: Wire BinaryWriter.Write and BinaryReader.Read in WorldSaveData.Serialize / Deserialize.
  3. Bump Format Version: If breaking backwards compatibility, increment WorldMetadata.CurrentFormatVersion and provide migration logic in SaveGameService.
  4. Add Regression Tests: Assert round-trip fidelity in SaveGameServiceTests.cs.
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.

  1. Loading notes…