Synvael/docs/save_format.md
Serkyo cae434d227
Some checks are pending
CI / Rust Check & Lint (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Lua Lint & Format (push) Waiting to run
CI / LFS Pointer Guard (push) Waiting to run
docs(workspace): rewrite the subsystem notes and ADRs
2026-08-06 22:52:57 +02:00

5.9 KiB
Raw Blame History

Save format

This document explains how modified chunks are framed, stored, and read back from disk.

The purely in-memory framing logic (handling the SYNR region index and SYNC chunk records) lives inside crates/shared/src/save/. The filesystem operations (reading a region file, mutating its chunks, and flushing it back crash-safely) live in crates/server/src/save/. The actual runtime load path that turns a ChunkPos into a resident chunk is in crates/server/src/world_server.rs, driven by the streaming reconcile loop we documented in chunk_streaming.md.

What is persisted

We only store chunks that diverge from their deterministic worldgen baseline. (The full rationale for this is recorded in ADR-0009). A modified chunk is saved as a ChunkData, which contains the chunk position, the worldgen_version its baseline is pinned to, and a sparse map of edits (local_index → BlockId). If a chunk hasn't been modified, it stores absolutely no voxel data and won't even appear in its region file.

On-disk layout

We partition voxel storage into region files. Each file covers a 32×32 grid of chunk columns in the XZ plane (this grid is 2D; we don't partition the Y axis). You can calculate a chunk's region using (cx.div_euclid(32), cz.div_euclid(32)). Notice we use div_euclid instead of standard truncating division; this ensures negative columns correctly floor toward negative infinity rather than snapping toward zero. We store all multi-byte integers as little-endian.

A region file consists of a single SYNR index, followed by all the SYNC records that the index points to.

  • SYNR region index (region.rs): This contains a magic tag, the framing version, and three side tables. The header table maps ChunkPos to offset + length + flags for every resident record. The free list tracks reclaimable file spans left behind by removed or shrunken records. The stamp table tracks per-chunk worldgen_version exceptions (the region pins a base_worldgen_version globally and only stores exceptions for chunks that differ from it). Both the header and stamp tables are BTreeMaps, ensuring their serialization order is perfectly deterministic.
  • SYNC chunk record (record.rs): This starts with a fixed header (magic, chunk-format version, flags, a last_modified Unix-ms timestamp, and the compressed/uncompressed payload lengths). Following the header is a zstd-compressed, postcard-serialized ChunkData payload. We intentionally never compress the header itself, so repair tools can parse the framing without having to decompress the entire file. We use zstd level 3 for compression to heavily favor speed.

Durability layer

The RegionFile struct (region_file.rs) reads a region file into memory, applies chunk mutations (write_chunk, remove_chunk), and flushes it back to disk.

Right now, our flush strategy is a whole-file atomic rewrite. We serialize the complete file image, write it to a .tmp sibling file, fsync it, rename it directly over the target file, and then fsync the containing directory. This is vastly simpler than trying to build an incremental append-plus-header-rewrite scheme. We've noted this deviation right at the serialize call site and logged it for future revision. Because the on-disk format itself remains unchanged (the free list and absolute record offsets already fully support incremental appends), switching to an incremental strategy later will require absolutely no save migrations.

Concurrency and the save actor

Region files are owned by a single dedicated thread: the save actor (region_actor.rs). It holds the map of all open RegionFiles and acts as their sole owner, meaning individual region files don't need their own locks.

Worker threads never touch a region file directly. Instead, they hold cloned senders for the actor's request channel and communicate purely by message passing. This directly applies the "message-passing over shared-state" rule from DEVELOPMENT.md to our persistence layer. Having one queue thread serialize all region I/O keeps that heavy lifting entirely off both the simulation tick and the main worker pool. A region file is opened lazily upon first access, and its contents are served directly from memory after that.

Load pipeline

When we need to load a ChunkPos, the work runs on the worker pool (safely off the tick thread) via load_chunk in world_server.rs:

  1. The worker asks the save actor for the stored record at the requested position.
  2. Hit (Some(ChunkData)): We regenerate the chunk's baseline (using the LRU baseline cache) and materialize the stored diff straight over it.
  3. Miss (None): The chunk was never modified, so the freshly regenerated baseline is the final chunk.
  4. Save-layer error: Streaming must never wedge the game, so if reading fails, the chunk safely falls back to a fresh baseline and we log the error.

Right now, the regenerated baseline uses the current worldgen version rather than the record's specific stored worldgen_version. This is fine while only a single version exists, but honoring the stored version on both load and write-back is tracked as follow-on work.

  • ADR-0003: Covers seed-deterministic worldgen, which is the foundational invariant that makes regeneration-on-load mathematically sound.
  • ADR-0009: Covers baseline-relative sparse persistence and explains exactly why we only store diffs.