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

47 lines
5.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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/`](../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/`](../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`](../crates/server/src/world_server.rs), driven by the streaming reconcile loop we documented in [`chunk_streaming.md`](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](adr/0009-baseline-relative-sparse-chunk-persistence.md)). A modified chunk is saved as a [`ChunkData`](../crates/shared/src/world/chunk_data.rs), 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`](../crates/shared/src/save/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 `BTreeMap`s, ensuring their serialization order is perfectly deterministic.
- **`SYNC` chunk record** ([`record.rs`](../crates/shared/src/save/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`](../crates/server/src/save/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`](../crates/server/src/save/region_actor.rs)). It holds the map of all open `RegionFile`s 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`](../crates/server/src/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.
## Related decisions
- [ADR-0003](adr/0003-seed-deterministic-worldgen.md): Covers seed-deterministic worldgen, which is the foundational invariant that makes regeneration-on-load mathematically sound.
- [ADR-0009](adr/0009-baseline-relative-sparse-chunk-persistence.md): Covers baseline-relative sparse persistence and explains exactly why we only store diffs.