From cae434d227938256c21501624a61e820a634c6e0 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Thu, 6 Aug 2026 22:52:57 +0200 Subject: [PATCH] docs(workspace): rewrite the subsystem notes and ADRs --- .../adr/0001-record-architecture-decisions.md | 24 ++--- docs/adr/0002-half-scale-voxel-grid.md | 14 +-- docs/adr/0003-seed-deterministic-worldgen.md | 16 +-- .../0004-server-authoritative-simulation.md | 16 +-- docs/adr/0005-namespaced-content-ids.md | 22 ++--- docs/adr/0006-base-game-on-modding-api.md | 26 ++--- ...007-declarative-content-via-modding-api.md | 28 +++--- .../0008-split-coordinate-entity-positions.md | 22 +++-- ...eline-relative-sparse-chunk-persistence.md | 22 ++--- docs/adr/0010-net-crate-async-runtime.md | 24 ++--- ...uthority-stream-for-server-pushed-state.md | 34 +++---- docs/adr/template.md | 6 +- docs/chunk_streaming.md | 74 +++++++------- docs/diagnostics.md | 74 +++++++------- docs/meshing.md | 98 ++++++++++--------- docs/packs.md | 30 +++--- docs/rendering.md | 20 ++-- docs/save_format.md | 40 ++++---- 18 files changed, 312 insertions(+), 278 deletions(-) diff --git a/docs/adr/0001-record-architecture-decisions.md b/docs/adr/0001-record-architecture-decisions.md index 842269f..def6220 100644 --- a/docs/adr/0001-record-architecture-decisions.md +++ b/docs/adr/0001-record-architecture-decisions.md @@ -5,22 +5,24 @@ ## Context -The engine makes a number of architectural choices that are hard to reverse and non-obvious from the code alone: the crate boundaries, the determinism stance for worldgen, the server-authoritative simulation model, the content-ID namespacing scheme, and similar. The reasoning behind such choices is valuable to future contributors but does not belong inline in the source, where it would either be lost or bloat a single guidance file. +The engine makes quite a few architectural choices that are hard to reverse and practically impossible to deduce just by reading the code. This includes our crate boundaries, our strict determinism stance for worldgen, the server-authoritative simulation model, the content-ID namespacing scheme, and similar foundational concepts. -A durable, low-ceremony place is required to record *why* significant choices were made, kept separate from the cross-cutting rules in `AGENTS.md` (which records *what* to follow) and from subsystem implementation docs (which record *how* a system works). +The reasoning behind these choices is incredibly valuable to future contributors, but it doesn't belong inline in the source code. If we put it there, it would either get lost in the noise or bloat a single guidance file until it became unreadable. + +We need a durable, low-ceremony place to record exactly *why* significant choices were made. This needs to be kept entirely separate from the cross-cutting rules in `DEVELOPMENT.md` (which simply records *what* to follow) and from subsystem implementation docs (which record *how* a system works). ## Decision -Architecture Decision Records (ADRs), in the lightweight format popularised by Michael Nygard, are used to capture significant, hard-to-reverse decisions. +We will use Architecture Decision Records (ADRs) to capture all significant, hard-to-reverse decisions. We are adopting the lightweight format popularized by Michael Nygard. -- Each ADR is a single Markdown file in `docs/adr/`, numbered sequentially (`0001-...`, `0002-...`). -- Each record carries a status (`Proposed`, `Accepted`, `Deprecated`, or `Superseded by ...`) and a date. -- Records are **append-only**: once accepted, an ADR is not edited to reflect a later change of mind. A new ADR is written instead and the old one is marked `Superseded`. -- New records are started from [`template.md`](template.md). +- Each ADR lives as a single Markdown file inside `docs/adr/`, numbered sequentially (e.g., `0001-...`, `0002-...`). +- Each record must carry a clear status (`Proposed`, `Accepted`, `Deprecated`, or `Superseded by ...`) and a date. +- Records are strictly **append-only**. Once we accept an ADR, we do not edit it to reflect a later change of mind. Instead, we write a brand new ADR and mark the old one as `Superseded`. +- We start all new records by copying [`template.md`](template.md). ## Consequences -- The rationale behind significant choices is preserved with its historical context, independent of how the code later evolves. -- `AGENTS.md` stays lean: it can state a rule and link to the ADR that explains it, rather than carrying the justification inline. -- A small, ongoing discipline is required : a contributor making a significant architectural choice is expected to add an ADR for it. Trivial or easily reversible choices do not warrant one. -- Because records are numbered and append-only, the directory grows monotonically without any single file becoming a bottleneck. +- The actual rationale behind significant choices is permanently preserved with its historical context, completely independent of how the codebase evolves later. +- `DEVELOPMENT.md` stays lean. It can simply state a rule and link to the relevant ADR to explain it, rather than carrying a massive justification inline. +- This requires a small but ongoing discipline from the team: if you make a significant architectural choice, you are expected to write an ADR for it. (Trivial or easily reversible choices don't need one). +- Because the records are numbered and append-only, the directory just grows monotonically over time without any single file turning into a bottleneck. diff --git a/docs/adr/0002-half-scale-voxel-grid.md b/docs/adr/0002-half-scale-voxel-grid.md index 0a379c7..adcbe21 100644 --- a/docs/adr/0002-half-scale-voxel-grid.md +++ b/docs/adr/0002-half-scale-voxel-grid.md @@ -5,17 +5,17 @@ ## Context -The world is built from voxels. The physical edge length chosen for a voxel propagates into nearly every system: collision resolution, mesh chunking, level-of-detail thresholds, network bandwidth, and in-memory data layout all scale with voxel density. A finer grid yields more expressive terrain and building at a direct cost in voxel count per unit volume. +The world is entirely built from voxels. The physical edge length we choose for a voxel directly impacts nearly every system: collision resolution, mesh chunking, level-of-detail thresholds, network bandwidth, and in-memory data layout all scale directly with voxel density. While a finer grid allows for much more expressive terrain and building, it comes at a steep cost in total voxel count per unit volume. -Relative to a conventional coarse voxel grid with a 1 m edge, halving the edge to 0.5 m doubles the linear resolution and multiplies the voxel count per unit volume by roughly 8×. +Compared to a conventional coarse voxel grid with a 1-meter edge, halving the edge to 0.5 meters doubles the linear resolution but multiplies the voxel count per unit volume by roughly 8x. ## Decision -A voxel edge is **0.5 m**. Inside the engine the world unit is the block itself: **1 unit = 1 block**, counted in blocks rather than metres. A player therefore occupies **3 units tall × 2 units wide** (3 blocks × 2 blocks). +We are setting the voxel edge to exactly **0.5 meters**. Inside the engine, the base world unit is the block itself (**1 unit = 1 block**). We count in blocks rather than meters. Because of this, a standard player occupies **3 units tall by 2 units wide** (3 blocks by 2 blocks). ## Consequences -- Density is ~8× that of a 1 m-grid world per unit volume. Chunk dimensions and voxel data layouts must be chosen with that multiplier in mind; a layout that is comfortable on a coarse grid may not be here. -- Collision, meshing, LOD selection, and network bandwidth budgets all inherit the 8× factor and must be designed against it from the start. -- Finer terrain and construction detail become possible, this is the motivating benefit. -- The choice is load-bearing and expensive to revisit later, since persisted worlds and save formats encode the block scale. +- The density is roughly 8x that of a 1-meter grid world per unit volume. We absolutely must design chunk dimensions and voxel data layouts with this multiplier in mind; a data layout that feels comfortable on a coarse grid might be completely unviable here. +- Collision, meshing, LOD selection, and network bandwidth budgets all inherit this 8x factor and have to be designed against it from day one. +- The motivating benefit is that we can support much finer terrain and construction detail. +- This is a load-bearing choice that will be incredibly expensive to revisit later, mainly because persisted worlds and save formats fundamentally encode the block scale. diff --git a/docs/adr/0003-seed-deterministic-worldgen.md b/docs/adr/0003-seed-deterministic-worldgen.md index 11472b4..8abac1a 100644 --- a/docs/adr/0003-seed-deterministic-worldgen.md +++ b/docs/adr/0003-seed-deterministic-worldgen.md @@ -5,19 +5,19 @@ ## Context -Procedural world generation must be reproducible: given the same seed, the same world is expected on any platform and at any time. Reproducibility enables shared seeds, reliable bug reproduction, and consistent behaviour between a server and any client that regenerates terrain locally. +Procedural world generation has to be reproducible. Given the exact same seed, we expect the exact same world to generate on any platform and at any time. Reproducibility lets players share seeds, helps us reliably reproduce bugs, and ensures consistent behavior between a server and any client that tries to regenerate terrain locally. -Reproducibility is fragile. Sources of nondeterminism include OS-seeded random number generators, the randomised iteration order of the standard hasher, and platform-specific arithmetic. +Unfortunately, reproducibility is incredibly fragile. Common sources of nondeterminism include OS-seeded random number generators, the randomized iteration order of Rust's standard hasher, and platform-specific floating-point arithmetic. ## Decision -Worldgen is **seed-deterministic** and is held to bit-for-bit reproducibility across platforms. +Worldgen is strictly **seed-deterministic** and we hold it to a standard of bit-for-bit reproducibility across all supported platforms. -- A fixed RNG algorithm (e.g. `wyrand`, `xoshiro`) is used, seeded only from the world seed. `rand::thread_rng()` and any OS-seeded source are prohibited in worldgen. -- Iteration order that feeds RNG draws or content placement must be deterministic. The default randomised-hash `HashMap` iteration order must not be relied upon; use `BTreeMap`, `IndexMap`, or an explicit sort. +- We must use a fixed RNG algorithm (like `wyrand` or `xoshiro`) that is seeded *only* from the world seed. Using `rand::thread_rng()` or any other OS-seeded source is strictly prohibited anywhere in worldgen. +- Any iteration order that feeds into RNG draws or content placement must be completely deterministic. You cannot rely on the default randomized-hash `HashMap` iteration order; you must use `BTreeMap`, `IndexMap`, or apply an explicit sort. ## Consequences -- Worldgen code is constrained in its choice of RNG and collection types, and reviewers must watch for nondeterministic iteration order. -- Identical worlds are guaranteed from identical seeds, on any supported platform. -- This guarantee is scoped to worldgen only; see [ADR-0004](0004-server-authoritative-simulation.md) for why the rest of the simulation is deliberately not held to the same standard. +- Worldgen code is heavily constrained in its choice of RNG and collection types. Reviewers have to actively watch out for nondeterministic iteration order creeping in. +- In exchange, we guarantee identical worlds from identical seeds on any supported platform. +- This strict guarantee is scoped *only* to worldgen. See [ADR-0004](0004-server-authoritative-simulation.md) for a detailed explanation of why we intentionally do not hold the rest of the simulation to this same standard. diff --git a/docs/adr/0004-server-authoritative-simulation.md b/docs/adr/0004-server-authoritative-simulation.md index e050572..5ffc5d4 100644 --- a/docs/adr/0004-server-authoritative-simulation.md +++ b/docs/adr/0004-server-authoritative-simulation.md @@ -5,19 +5,19 @@ ## Context -The game supports single-player and multiplayer through one dedicated `server` crate; single-player runs that same server logic rather than a separate offline path. A networked simulation must decide where authority lives and how much determinism the simulation is held to. +The game supports both single-player and multiplayer through a single dedicated `server` crate. Single-player literally just runs the server logic locally rather than using a separate offline path. Because of this, the networked simulation has to clearly define where authority lives and how strictly deterministic the simulation needs to be. -One option is full simulation determinism (lockstep, rollback, or replay-from-inputs), which permits clients to advance the simulation in agreement and exchange only inputs. It is powerful but imposes a heavy, ongoing cost: every float, every hash-map iteration, and all platform-specific math must be made cross-platform reproducible. +One option is full simulation determinism (using lockstep, rollback, or replay-from-inputs). This allows clients to advance the simulation in perfect agreement by only exchanging inputs. While powerful, it imposes a massive, ongoing maintenance cost: every single float, every hash-map iteration, and all platform-specific math would have to be perfectly cross-platform reproducible forever. ## Decision -The simulation is **server-authoritative**. The server computes the truth; clients send inputs and receive state snapshots, predicting locally for responsiveness and reconciling on disagreement. Combat, physics, mob AI, and item drops are computed once, on the server. +The simulation is strictly **server-authoritative**. The server computes the absolute truth; clients simply send their inputs and receive state snapshots in return. Clients predict locally to stay responsive, and reconcile when they disagree with the server. Combat, physics, mob AI, and item drops are all computed exactly once, exclusively on the server. -**Full simulation determinism (lockstep / rollback / replay-from-inputs) is an explicit non-goal.** Outside of worldgen (see [ADR-0003](0003-seed-deterministic-worldgen.md)), floats, hash-map iteration order, and platform-specific math are all permitted. +**Full simulation determinism (lockstep, rollback, or replay-from-inputs) is an explicit non-goal.** Outside of worldgen (which has its own strict rules in [ADR-0003](0003-seed-deterministic-worldgen.md)), you are free to use floats, hash-map iteration, and platform-specific math. ## Consequences -- The engine does not pay the cost of cross-platform float reproducibility for the simulation, only worldgen carries that burden. -- Clients require prediction and reconciliation logic to stay responsive against an authoritative server. -- Cheat resistance follows from authority residing on the server. -- Features that would require deterministic replay of the full simulation are out of scope by this decision; reopening them would mean revisiting the determinism cost deliberately avoided here. +- The engine avoids the massive ongoing cost of cross-platform float reproducibility for the main simulation. Only worldgen carries that specific burden. +- Clients have to implement prediction and reconciliation logic to actually feel responsive while playing against an authoritative server. +- We get cheat resistance practically for free since authority strictly resides on the server. +- Any features that would inherently require deterministic replay of the full simulation are completely out of scope due to this decision. If we ever want to revisit them, we would have to accept the determinism cost we are deliberately avoiding here. diff --git a/docs/adr/0005-namespaced-content-ids.md b/docs/adr/0005-namespaced-content-ids.md index 1aa26d5..5b2680b 100644 --- a/docs/adr/0005-namespaced-content-ids.md +++ b/docs/adr/0005-namespaced-content-ids.md @@ -5,22 +5,22 @@ ## Context -All registered content (blocks, items, recipes, biomes, entities, and so on) needs a stable identifier that is unambiguous across the engine, data packs, Lua mods, recipe references, and save files. First-party and third-party content must coexist without collision, and identifiers must survive being written to disk and read back. +Every single piece of registered content (blocks, items, recipes, biomes, entities, etc.) needs a perfectly stable identifier. This identifier has to be completely unambiguous across the engine, data packs, Lua mods, recipe references, and save files. First-party content and third-party content must be able to coexist without ever colliding, and these identifiers must be able to survive being written to disk and read back later. ## Decision -Content is identified by a **namespaced string** of the form `"namespace:id"`. +We identify all content using a **namespaced string** in the exact format `"namespace:id"`. -- The namespace `core:` is reserved for first-party content (`"core:stone"`, `"core:iron_sword"`). Mods choose their own short namespace (`"mymod:weird_dirt"`). -- The strict form is mandatory. A bare id with no `:` is an **error at registration / parse time**, never silently coerced to `core:`. The rule is identical everywhere: engine scripts, data packs, Lua mods, recipe references, save files. -- Charset: namespace and id are each `[a-z0-9_-]+` with exactly one `:` between them. Lowercase ASCII only, no uppercase, Unicode, spaces, dots, or slashes. -- At registration time each id string is interned into a small integer handle (e.g. `BlockId(u32)`). Hot paths compare handles; the original string is kept for display, save/load, and the Lua API. +- We strictly reserve the `core:` namespace for first-party content (e.g., `"core:stone"`, `"core:iron_sword"`). Modders must choose their own short namespace (e.g., `"mymod:weird_dirt"`). +- This format is absolutely mandatory. A bare ID with no `:` is an **immediate error at registration or parse time**. We will never silently coerce it to `core:`. This exact rule applies everywhere: engine scripts, data packs, Lua mods, recipe references, and save files. +- **Charset rules:** Both the namespace and the ID must match `[a-z0-9_-]+` and have exactly one `:` sitting between them. We only allow lowercase ASCII. No uppercase letters, no Unicode, no spaces, no dots, and no slashes. +- At registration time, we intern each ID string into a small integer handle (for example, `BlockId(u32)`). All hot paths compare these integer handles for speed. We only keep the original string around for display purposes, save/load routines, and the Lua API. -The reserved namespace is deliberately `core:` rather than the project name, so it remains stable independent of branding. +Notice that the reserved namespace is deliberately called `core:` rather than naming it after the project itself. This ensures the namespace stays completely stable regardless of any future rebranding. ## Consequences -- Identifiers are greppable, filesystem-safe, and unambiguous in logs and save files. -- First-party and mod content cannot collide, and the absence of a silent default means mistakes surface immediately rather than corrupting data. -- A small runtime cost is paid at registration to intern strings, in exchange for handle comparison on hot paths. -- The strict form is a permanent contract: relaxing it later (e.g. defaulting bare ids) would change the meaning of existing save files and is therefore effectively irreversible. +- Identifiers are highly greppable, inherently filesystem-safe, and totally unambiguous in both logs and save files. +- First-party and mod content simply cannot collide. By refusing to provide a silent default namespace, we ensure mistakes blow up immediately rather than silently corrupting data. +- We pay a tiny runtime cost at registration to intern the strings, but in exchange, we get lightning-fast handle comparisons on all hot paths. +- This strict formatting is a permanent contract. If we relaxed it later (for example, by defaulting bare IDs), we would fundamentally change the meaning of existing save files, making such a change effectively irreversible. diff --git a/docs/adr/0006-base-game-on-modding-api.md b/docs/adr/0006-base-game-on-modding-api.md index 1cf6980..536a864 100644 --- a/docs/adr/0006-base-game-on-modding-api.md +++ b/docs/adr/0006-base-game-on-modding-api.md @@ -5,23 +5,25 @@ ## Context -The engine exposes a Lua modding API. A modding API can be treated as a bolt-on layer over a separate, privileged engine path, or the engine's own content can be defined through the same API that mod authors use. The former tends to let the engine drift ahead of the API, leaving mod authors with second-class capabilities and no working reference. +The engine exposes a Lua modding API. When building a game engine, you generally have two options: treat the modding API as a bolt-on layer over a separate, highly privileged internal engine path, or force the engine to define its own content through the exact same API that mod authors use. -The API is also exposed to two execution contexts, a client-side Lua VM and a server-side Lua VM, with different trust levels. Authoritative operations (world mutation, combat resolution) must not be invocable from the client VM. +The first option usually leads to the engine drifting far ahead of the API. Mod authors end up with second-class capabilities and lack any working reference material to look at. + +We also have to expose this API to two different execution contexts: a client-side Lua VM and a server-side Lua VM. These have completely different trust levels. Authoritative operations (like mutating the world or resolving combat) absolutely must not be invocable from the client VM. ## Decision -The base game is built on top of the modding API; the shipped content (blocks, items, entities, recipes, ...) is defined through the same API mod authors use, so it doubles as reference material. +The base game is built entirely on top of the modding API. All shipped content (blocks, items, entities, recipes, etc.) is defined through the exact same API that mod authors use, allowing it to double as living reference material. -- Any new gameplay primitive must be reachable through the Lua API, not only through a Rust-internal path. Adding a Rust-side concept with no API surface breaks the dogfooding contract. -- The API and its bindings live in the `scripting` crate, which owns the `mlua` dependency, the API table registration, and the mod loader. Both `client` and `server` depend on it; `shared` does not. -- Authoritative APIs are defined once but **gated by execution context**: the client VM is restricted to read-only / UI / effects, while the server VM holds authority. One API surface, two contexts. -- `scripting` wraps `shared` types in newtypes rather than implementing `UserData` for them in `shared`, keeping the protocol/data crate free of `mlua`. +- If we add a new gameplay primitive, it must be reachable through the Lua API. We cannot add a Rust-internal path that skips the API. Adding a Rust-side concept with no API surface fundamentally breaks our dogfooding contract. +- The API and all of its bindings live in the `scripting` crate. This crate owns the `mlua` dependency, the API table registration, and the mod loader. Both `client` and `server` depend on it, but crucially, `shared` does not. +- We define authoritative APIs exactly once, but they are strictly **gated by execution context**. The client VM is locked down to read-only state, UI, and effects, while the server VM holds true authority. It is a single API surface running in two distinct contexts. +- The `scripting` crate wraps `shared` types in newtypes rather than implementing `UserData` for them directly inside `shared`. This keeps our core protocol/data crate completely free of `mlua`. ## Consequences -- Mod authors can read first-party content as a faithful example of what the API allows, because it uses no privileged path they lack. -- The API must stay stable and discoverable, since it is both the engine's and the modder's surface; engine internals must not leak through it. -- The client/server trust boundary is enforced at the API layer rather than re-checked ad hoc. -- A feature cannot be "added to the engine" and exposed to mods later as an afterthought, the API surface is part of the definition of done. -- Declarative content loading follows the same single-path rule; see [ADR-0007](0007-declarative-content-via-modding-api.md). +- Mod authors can confidently read our first-party content as a faithful example of what the API allows, simply because it doesn't use any privileged paths that they lack access to. +- The API must remain highly stable and easily discoverable because it is simultaneously the engine's surface and the modder's surface. Engine internals must never leak through it. +- We enforce the client/server trust boundary structurally at the API layer rather than relying on ad hoc checks everywhere. +- You simply cannot "add a feature to the engine" and then expose it to mods later as an afterthought. Creating the API surface is a mandatory part of the definition of done. +- Our declarative content loading strictly follows this same single-path rule; you can read more about that in [ADR-0007](0007-declarative-content-via-modding-api.md). diff --git a/docs/adr/0007-declarative-content-via-modding-api.md b/docs/adr/0007-declarative-content-via-modding-api.md index 0e9a5b4..1b35ba4 100644 --- a/docs/adr/0007-declarative-content-via-modding-api.md +++ b/docs/adr/0007-declarative-content-via-modding-api.md @@ -5,24 +5,30 @@ ## Context -Content can be defined declaratively in data files (JSON, or another format) rather than in Lua: blocks, items, recipes, loot tables, biomes, tags. A naive implementation gives data packs their own registration code path straight into the engine's registries. That produces two parallel ways to register the same content, which drift apart and double the surface that must be kept correct. +We can define content declaratively in data files (like JSON) rather than writing it out in Lua. This is perfect for things like blocks, items, recipes, loot tables, biomes, and tags. -This decision concerns **data packs** (declarative content). It is distinct from **resource packs**, which are client-side asset overlays carrying no logic; the two systems are orthogonal and must not be merged into one "pack" concept. +A naive way to build this would be to give data packs their own dedicated registration code path straight into the engine's registries. However, that creates two parallel ways to register the exact same content. Over time, they inevitably drift apart, and we end up having to maintain double the surface area just to keep everything correct. + +Note that this decision is specifically about **data packs** (declarative content). This is completely distinct from **resource packs**, which are just client-side asset overlays that carry no logic whatsoever. The two systems are orthogonal and we must not merge them into a single generic "pack" concept. ## Decision -The data-pack loader reads the declarative files and **calls the same Lua API** the engine and Lua mods use. There is one registration path: `data/blocks/stone.json` → loader → `blocks.register{ id = "stone", ... }`. No parallel registration system is built. The loader belongs in `scripting` (or a sibling crate if it grows). First-party content may use either JSON or Lua, whichever fits. +The data-pack loader parses the declarative files and then **calls the exact same Lua API** that the engine and Lua mods use. There is only one registration path: +`data/blocks/stone.json` → loader → `blocks.register{ id = "stone", ... }`. -Each data-pack schema is treated as a stable contract, versioned as deliberately as the Lua API. +We will not build a parallel registration system. The loader logic belongs in the `scripting` crate (or a sibling crate if it gets too large). Because they flow through the same pipeline, first-party content is free to use either JSON or Lua, depending on whichever fits best. We treat every data-pack schema as a completely stable contract, versioned just as deliberately as the Lua API itself. -**Amendment (declarative-first):** the choice between JSON and Lua is not free per content item. Anything expressible as data — the static fields of a block, item, recipe, loot table, biome, or tag — is authored as data and lives in `data/`; Lua is reserved for behavior (logic that runs on an event or tick). A pure-data block therefore needs no Lua at all. Consequently a data pack *can* register a block, item, or other primitive on its own, provided that primitive is purely declarative; the moment it needs behavior, that behavior half comes from a Lua mod. To avoid hand-authoring large volumes of near-identical files, modders may use **datagen**: code that emits `data/` files at build time, on the author's machine, before the pack ships. Datagen output (not its code) is the shipped artifact, and never runs at load time, so the single runtime load path is preserved. +**Amendment (declarative-first):** The choice between JSON and Lua is not a free-for-all. Anything that can be expressed as raw data (like the static fields of a block, item, recipe, loot table, biome, or tag) must be authored as data inside the `data/` directory. Lua is strictly reserved for *behavior* (logic that actually runs on an event or a tick). -The canonical load order, later layers overriding earlier ones, is: base game → data packs → Lua mods → resource packs (resource packs last so client visuals win). +This means a purely static block needs absolutely zero Lua. A data pack can register a block, item, or other primitive entirely on its own, as long as it's purely declarative. The moment that primitive needs behavior, the behavior half must come from a Lua mod. To save modders from hand-authoring massive volumes of near-identical files, they can use **datagen**. Datagen is code that emits `data/` files at build time on the author's machine before the pack ships. The actual output of datagen (not the generator code itself) is the shipped artifact, and the generator never runs at game load time. This perfectly preserves our single runtime load path. + +The canonical load order (where later layers override earlier ones) is: +Base game → Data packs → Lua mods → Resource packs (resource packs go last so the client visuals always win). ## Consequences -- One source of truth for registration; declarative content and scripted content cannot diverge in behaviour because they end at the same API. -- Declarative-first means data is the default and code the exception: a new primitive gets a data schema first, and a Lua-only registration path signals a gap in that schema. The base game dogfoods the datapack path, keeping pure-data `core` content in `data/` and only behavioural systems in `scripts/`. -- Accepting a schema is a long-lived commitment, since data packs in the wild depend on it. -- Resource packs remain entirely client-side with no server involvement, and are kept conceptually separate from data packs. -- Full subsystem detail (load order, repo and user-data layout, resolution semantics) lives in [`docs/packs.md`](../packs.md). +- We maintain one single source of truth for all registration. Declarative content and scripted content simply cannot diverge in behavior because they funnel into the exact same API. +- The declarative-first rule means data is the default and code is the exception. If a new primitive is added, it gets a data schema first. A Lua-only registration path is a red flag that the schema is missing something. The base game aggressively dogfoods the datapack path, keeping pure-data `core` content in `data/` and strictly placing behavioral systems in `scripts/`. +- Accepting a schema is a major, long-lived commitment because data packs in the wild will immediately depend on it. +- Resource packs remain entirely client-side, require zero server involvement, and are kept conceptually isolated from data packs. +- You can find the full subsystem details (load order, repo layout, user-data layout, and resolution semantics) in [`docs/packs.md`](../packs.md). diff --git a/docs/adr/0008-split-coordinate-entity-positions.md b/docs/adr/0008-split-coordinate-entity-positions.md index 4d21a44..2ae945f 100644 --- a/docs/adr/0008-split-coordinate-entity-positions.md +++ b/docs/adr/0008-split-coordinate-entity-positions.md @@ -5,22 +5,24 @@ ## Context -In a procedurally generated voxel world, an entity's position cannot be robustly represented by a single global single-precision floating-point vector (`f32` or `Vec3`). At large distances from the origin, the spacing between representable floating-point numbers increases, leading to spatial jitter, physics instability, and rendering artifacts. +In a procedurally generated voxel world, you simply cannot represent an entity's position using a single, global, single-precision floating-point vector (`f32` or `Vec3`). As you move further away from the origin, the physical spacing between representable floating-point numbers inherently increases. Eventually, this leads to aggressive spatial jitter, physics instability, and horrible rendering artifacts. -While double-precision floats (`f64`) postpone this issue, they double the data size and are not uniformly or natively supported on GPUs, which expect `f32` vertices and transforms. The engine requires a spatial representation that maintains sub-millimeter precision universally across a theoretically unbounded world, without coupling the simulation state directly to GPU limitations or paying the cost of `f64` everywhere. +We could postpone this issue by switching to double-precision floats (`f64`), but that comes with serious downsides. It doubles our data size and isn't uniformly or natively supported on GPUs, which overwhelmingly expect `f32` for vertices and transforms. + +We need a spatial representation that perfectly maintains sub-millimeter precision universally across a theoretically unbounded world, without coupling our core simulation state directly to GPU limitations or paying the heavy cost of `f64` everywhere. ## Decision -The engine uses a split-coordinate representation for entity positions, encapsulated by the `EntityPos` type. An entity's absolute position is defined by two discrete components: +The engine strictly uses a split-coordinate representation for entity positions, entirely encapsulated by the `EntityPos` type. An entity's absolute position is defined by two discrete components: -1. A `chunk` anchor (`ChunkPos`): The integer coordinates of the chunk containing the entity. -2. A `local` offset (`Vec3`): A single-precision floating-point vector describing the entity's exact position relative to the chunk's minimum corner. +1. A `chunk` anchor (`ChunkPos`): The integer coordinates of the exact chunk that currently contains the entity. +2. A `local` offset (`Vec3`): A standard single-precision floating-point vector that describes the entity's exact position relative to the chunk's minimum corner. -When an entity moves, the movement is applied to the `local` offset. A normalization step (`EntityPos::renormalize`) then carries any overflow beyond the chunk boundaries into the integer `chunk` anchor, ensuring the `local` offset always remains strictly within the bounding box of a single chunk (`[0.0, CHUNK_SIZE)`). +When an entity moves, we apply that movement strictly to the `local` offset. Afterward, a normalization step (`EntityPos::renormalize`) checks if the offset overflowed beyond the chunk's boundaries. If it did, it carries that overflow directly into the integer `chunk` anchor, guaranteeing that the `local` offset always remains strictly within the bounding box of a single chunk (`[0.0, CHUNK_SIZE)`). ## Consequences -- **Uniform Precision:** Entities maintain exact `f32` precision regardless of how far they travel from the world origin, as the active floating-point magnitude is strictly bounded by the size of a single chunk. -- **Rendering Stability:** The renderer can compute relative matrices by defining the camera's current chunk as the origin. This allows the GPU to process all vertex data and transforms in standard `f32` without any spatial jitter. -- **Math Complexity:** Code manipulating spatial positions (like physics integration and distance checks) becomes more complex. It is no longer possible to simply subtract two global vectors; logic must handle both the chunk offset and the local offset simultaneously. -- **Serialization:** `EntityPos` serializes as a compound struct, ensuring save files do not lose coordinate precision for distant entities. +- **Uniform Precision:** Entities maintain exact `f32` precision regardless of how far they travel from the world origin, purely because the active floating-point magnitude is strictly bounded by the size of a single chunk. +- **Rendering Stability:** The renderer can safely compute relative matrices by temporarily defining the camera's current chunk as the absolute origin. This allows the GPU to process all vertex data and transforms in standard `f32` without any spatial jitter whatsoever. +- **Math Complexity:** Code that manipulates spatial positions (like physics integration and distance checks) inherently becomes more complex. You can no longer just subtract two global vectors to get a distance; your logic must handle both the chunk offset and the local offset simultaneously. +- **Serialization:** `EntityPos` safely serializes as a compound struct, ensuring that our save files never lose coordinate precision for extremely distant entities. diff --git a/docs/adr/0009-baseline-relative-sparse-chunk-persistence.md b/docs/adr/0009-baseline-relative-sparse-chunk-persistence.md index 324ed27..767d542 100644 --- a/docs/adr/0009-baseline-relative-sparse-chunk-persistence.md +++ b/docs/adr/0009-baseline-relative-sparse-chunk-persistence.md @@ -5,23 +5,23 @@ ## Context -Worldgen is seed-deterministic ([ADR-0003](0003-seed-deterministic-worldgen.md)): any unmodified chunk is reproducible bit-for-bit from `(seed, chunk_coord, worldgen_version)`. The world is procedurally generated, unbounded in Y, and viewed at large horizontal distance in both single-player and multiplayer, so the set of chunks a session *visits* is effectively unbounded. +Because our worldgen is strictly seed-deterministic (see [ADR-0003](0003-seed-deterministic-worldgen.md)), any completely unmodified chunk is reproducible bit-for-bit from just `(seed, chunk_coord, worldgen_version)`. Our world is procedurally generated, entirely unbounded on the Y axis, and viewed at massive horizontal distances in both single-player and multiplayer. Because of this, the total set of chunks a session simply *visits* is effectively unbounded. -Persisting the full voxel contents of every visited chunk, the naive model, makes save size scale with the volume *explored* rather than the volume *changed*. In a half-scale voxel grid ([ADR-0002](0002-half-scale-voxel-grid.md)), where a unit volume holds roughly eight times the voxels of a 1 m grid, that cost is compounded. The overwhelming majority of visited chunks are never modified, so storing them at all duplicates data the generator can reproduce on demand. +If we persisted the full voxel contents of every single visited chunk (the naive model), the save file size would scale directly with the volume *explored* rather than the volume *actually changed*. Since we use a half-scale voxel grid ([ADR-0002](0002-half-scale-voxel-grid.md)) where a unit volume holds roughly eight times the voxels of a standard 1-meter grid, that storage cost would compound aggressively. The overwhelming majority of visited chunks are never modified by the player, so storing them on disk just duplicates data that our generator can reproduce perfectly on demand. -The decision that is hard to reverse is the *on-disk representation* of a chunk: the `SYNC` record format and the `ChunkData` type are both shaped by it, and changing the representation later requires a save-format migration. +The part of this decision that is hardest to reverse is the *on-disk representation* of a chunk. Both the `SYNC` record format and the `ChunkData` type are strictly shaped by it, and changing this representation later will require a heavy save-format migration. ## Decision -A chunk is persisted only when its contents diverge from its deterministic baseline. +We only persist a chunk when its contents actually diverge from its deterministic baseline. -- **Representation.** Both on disk (the `SYNC` record) and in memory (`ChunkData`), a modified chunk is stored as a sparse `local_index → BlockId` edit map layered over the regenerated baseline, together with the `worldgen_version` the baseline is pinned to. An unmodified chunk stores no voxel data and is omitted from its region file entirely. -- **Load.** A load resolves the baseline by regenerating it from the seed, then applies the stored diff when a record exists (a hit). A miss means the chunk was never modified, so the regenerated baseline *is* the chunk. -- **Version pinning.** Each persisted chunk records the `worldgen_version` its baseline was generated under, so a later generator update does not silently shift the baseline beneath an already-modified chunk. A region pins a `base_worldgen_version` and stores only per-chunk exceptions. +- **Representation:** Both on disk (in the `SYNC` record) and in memory (as `ChunkData`), a modified chunk is stored purely as a sparse `local_index → BlockId` edit map layered directly over the regenerated baseline, alongside the `worldgen_version` that the baseline is pinned to. If a chunk is totally unmodified, it stores absolutely no voxel data and is omitted from its region file entirely. +- **Load:** When we load a chunk, we resolve the baseline by regenerating it from the seed, and then we just apply the stored diff on top if a record exists. If there is no record (a miss), it means the chunk was never modified, so the freshly regenerated baseline *is* the chunk. +- **Version pinning:** Each persisted chunk explicitly records the `worldgen_version` its baseline was generated under. This guarantees that a future generator update won't silently shift the baseline beneath an already-modified chunk and corrupt the edits. A region file pins a `base_worldgen_version` globally and only stores per-chunk exceptions to save space. ## Consequences -- Save size scales with the volume *modified*, not the volume explored. A session that walks across untouched terrain writes nothing. -- Revisiting an unmodified chunk re-runs worldgen instead of reading it back. This CPU cost is mitigated by an LRU cache of regenerated baselines, which is a pure performance layer and does not affect authority or determinism. -- Worldgen determinism is promoted from a worldgen-local property to a hard invariant of the persistence layer: if the generator ceased to be reproducible, every unmodified chunk and every stored diff's baseline would be corrupted. Determinism regressions are therefore guarded aggressively by tests. -- A per-chunk `worldgen_version` stamp is mandatory metadata, and the load and write-back paths must both honour it once more than one worldgen version exists. Until then a single version is assumed, tracked as follow-on work. +- Save file size explicitly scales with the volume *modified*, not the volume explored. If a player walks across untouched terrain for miles, it writes absolutely nothing to disk. +- When you revisit an unmodified chunk, it inherently re-runs worldgen instead of reading anything back from disk. We mitigate this CPU cost heavily using an LRU cache of regenerated baselines. This cache is purely a performance layer and doesn't affect authority or determinism at all. +- Worldgen determinism is aggressively promoted from a mere worldgen-local property to a rock-solid, load-bearing invariant of the entire persistence layer. If the generator ever ceased to be reproducible, every unmodified chunk and every stored diff's baseline would be instantly corrupted. Because of this, determinism regressions are guarded aggressively by tests. +- A per-chunk `worldgen_version` stamp is mandatory metadata. The load and write-back paths must strictly honor it once we introduce more than one worldgen version. (Right now we assume a single version, and honoring it fully is tracked as follow-on work). diff --git a/docs/adr/0010-net-crate-async-runtime.md b/docs/adr/0010-net-crate-async-runtime.md index 593d7b1..60531be 100644 --- a/docs/adr/0010-net-crate-async-runtime.md +++ b/docs/adr/0010-net-crate-async-runtime.md @@ -5,23 +5,25 @@ ## Context -The network transport is QUIC via `quinn` which is an asynchronous library built on the `tokio` runtime and requires TLS 1.3 through `rustls`. These are heavy dependencies that pull an entire async ecosystem into the build. +Our network transport uses QUIC via `quinn`, which is an asynchronous library built squarely on the `tokio` runtime and inherently requires TLS 1.3 through `rustls`. These are heavy dependencies that drag an entire async ecosystem into the build tree. -The `shared` crate is mandated to stay lean and dependency-light: it is the protocol/data layer, holding pure serde message types with no async, rendering, or engine internals. Placing transport code in `shared` would violate that mandate and force every consumer of the protocol types to compile `tokio` and `rustls`. At the same time, the simulation is synchronous: the `server` runs a synchronous `bevy_ecs` loop and the `client` runs a synchronous `winit` event loop. Introducing an async runtime must not turn those loops async or leak `tokio` throughout the workspace. +We have a strict mandate that the `shared` crate must stay incredibly lean and dependency-light. It acts as our core protocol and data layer, holding pure `serde` message types with absolutely no async, rendering, or engine internals. Dropping transport code straight into `shared` would brutally violate that mandate, forcing every single consumer of our protocol types to compile both `tokio` and `rustls`. + +At the same time, our main simulation is completely synchronous. The `server` runs a synchronous `bevy_ecs` loop, and the `client` runs a synchronous `winit` event loop. Introducing an async runtime must absolutely not force those loops to become async or let `tokio` leak throughout the entire workspace. ## Decision -Transport lives in a dedicated `net` crate, separate from `shared`, and the `tokio` runtime is confined to it. +Transport logic lives completely isolated in a dedicated `net` crate, entirely separate from `shared`, and the `tokio` runtime is strictly confined to it. -- `net` owns the `quinn`, `tokio`, and `rustls` dependencies, plus the QUIC endpoints, connection lifecycle, and wire framing. -- `shared` continues to hold only the protocol message *types* (serde, no async). +- `net` completely owns the `quinn`, `tokio`, and `rustls` dependencies. It handles the QUIC endpoints, the full connection lifecycle, and all wire framing. +- `shared` remains perfectly clean, holding only the raw protocol message *types* (using `serde`, with zero async logic). - Both `client` and `server` depend on `net`. -- The async runtime is bridged to the synchronous simulation over channels (`crossbeam-channel`), consistent with the message-passing concurrency model in `AGENTS.md`. The synchronous loops never `.await`; they send and receive protocol messages across the boundary. +- We bridge the async runtime to the synchronous simulation using `crossbeam-channel`, which aligns perfectly with the message-passing concurrency model defined in `DEVELOPMENT.md`. The synchronous loops never ever call `.await`; they simply send and receive protocol messages across the channel boundary. ## Consequences -- `shared` stays lean: consumers of the protocol types do not compile the async stack. -- The async surface is quarantined. Only `net` deals with `tokio`, keeping the `server` and `client` loops synchronous and unchanged. -- The workspace now has six crates. `net` sits between `shared` (types it carries) and the two binaries (which drive it). -- The channel bridge is an explicit boundary that must be maintained: work crossing between the async runtime and the sync simulation flows through channels, never through shared async state or by making the sim async. -- A crypto provider backend is required by `rustls`; the transport code must install one before building QUIC configuration. +- `shared` stays extremely lean. Consumers that only need the protocol types do not have to compile the massive async stack. +- The async surface is perfectly quarantined. Only `net` actually deals with `tokio`, keeping both the `server` and `client` loops happily synchronous and completely unchanged. +- The workspace now contains six crates, with `net` sitting neatly between `shared` (which provides the types it carries) and the two binaries (which actively drive it). +- The channel bridge acts as an explicit, hard boundary that must be maintained. Any work crossing between the async runtime and the synchronous simulation must flow purely through channels. We never pass shared async state or force the simulation to become async. +- Because `rustls` requires a crypto provider backend, the transport code has to manually install one before building any QUIC configuration. diff --git a/docs/adr/0011-authority-stream-for-server-pushed-state.md b/docs/adr/0011-authority-stream-for-server-pushed-state.md index f4cf689..ee6adfa 100644 --- a/docs/adr/0011-authority-stream-for-server-pushed-state.md +++ b/docs/adr/0011-authority-stream-for-server-pushed-state.md @@ -5,30 +5,30 @@ ## Context -The simulation is server-authoritative ([ADR-0004](0004-server-authoritative-simulation.md)), so a category of traffic exists that the client never asks for: state the server pushes on its own cadence. Simulation snapshots are the eventual bulk of it; periodic server diagnostics were the first concrete instance. +Because our simulation is strictly server-authoritative (see [ADR-0004](0004-server-authoritative-simulation.md)), there is an entire category of network traffic that the client never actually asks for: state the server just decides to push on its own cadence. Simulation snapshots will eventually make up the bulk of this, but periodic server diagnostics were the first concrete instance we hit. -Two existing streams could have absorbed that traffic, and both are a poor fit: +We could have crammed this traffic into two existing streams, but both were a terrible fit: -- The **control stream** (stream 0) carries the handshake and disconnect. It is request/response and effectively one-shot per connection. Adding a recurring push to it mixes lifecycle negotiation with steady-state traffic, and a burst of pushed state would sit in the same ordered stream as a disconnect notice that should arrive promptly. -- The **chunk stream** (stream 3) is bidirectional and carries large frames (a 1 MiB cap). Head-of-line blocking is per-stream in QUIC, so a small, time-sensitive state push queued behind a multi-hundred-kilobyte chunk delivery would inherit that chunk's latency. That is precisely the coupling separate streams exist to avoid. +- The **control stream** (stream 0) handles handshakes and disconnects. It is strictly request/response and effectively one-shot per connection. Shoving a recurring push onto it mixes one-time lifecycle negotiation with steady-state spam, meaning a burst of pushed state could delay a time-sensitive disconnect notice sitting in the exact same ordered stream. +- The **chunk stream** (stream 3) is bidirectional and handles massive frames (up to a 1 MiB cap). QUIC handles head-of-line blocking on a per-stream basis. If a tiny, highly time-sensitive state push gets queued directly behind a 500 KB chunk delivery, it completely inherits that chunk's awful latency. That is precisely the coupling that separate streams are designed to avoid. -Stream assignment is a wire contract shared by both peers: `StreamLayout` fixes the ids, and changing one is a protocol break. The decision is therefore made once, ahead of the snapshot work that will depend on it, rather than discovered later. +Stream assignment acts as a hard wire contract shared by both peers (`StreamLayout` rigidly fixes the IDs), and changing one is a protocol break. Because of this, we need to make this decision exactly once, ahead of the massive snapshot work that will rely on it, rather than discovering we need it later and breaking the protocol. ## Decision -Server-pushed authoritative state travels on its own unidirectional-in-practice stream, `StreamLayout::authority` (stream 2), carrying `shared::protocol::authority::AuthorityMessage`. +Server-pushed authoritative state travels on its very own, unidirectional-in-practice stream: `StreamLayout::authority` (stream 2). It carries `shared::protocol::authority::AuthorityMessage`. -- The stream is **server => client only**. Nothing the client sends belongs on it; client input gets its own stream when it lands. -- `AuthorityMessage` is an enum, so new pushed payloads are added as variants rather than as new streams. `ServerStats` is the first variant; simulation snapshots will join it. -- Frames use the existing length-prefixed `postcard` codec with `MAX_AUTHORITY_FRAME_LEN` (64 KiB), well above a fixed-shape diagnostics record, and set to bound what a malformed length prefix can make a peer allocate. -- The async/sync bridge follows the pattern established for chunk delivery ([ADR-0010](0010-net-crate-async-runtime.md)): the simulation loop holds an `AuthoritySink`, a synchronous non-blocking handle wrapping a `tokio` MPSC sender, so neither `server` nor `client` names a `tokio` type. -- A send on a departed connection is logged at debug and dropped. The simulation loop cannot act on a disconnected client, and pushed state is by definition unsolicited, so failure to deliver it is not an error condition for the sender. +- The stream is strictly **server => client only**. Absolutely nothing the client sends belongs on it. When client input arrives, it will get its own dedicated stream. +- `AuthorityMessage` is an enum. Because of this, any new pushed payloads are simply added as variants rather than requiring brand new streams. `ServerStats` is the first variant, and simulation snapshots will eventually join it. +- Frames use our existing length-prefixed `postcard` codec, capped safely at `MAX_AUTHORITY_FRAME_LEN` (64 KiB). This is well above what a fixed-shape diagnostics record needs, and it safely limits how much memory a malformed length prefix can trick a peer into allocating. +- The async/sync bridge strictly follows the pattern we established for chunk delivery in [ADR-0010](0010-net-crate-async-runtime.md). The simulation loop holds an `AuthoritySink`, which is a synchronous, non-blocking handle wrapping a `tokio` MPSC sender. This ensures neither the `server` nor `client` crate ever has to explicitly name a `tokio` type. +- If we attempt a send on a departed connection, it simply logs at debug level and drops. The simulation loop cannot act on a disconnected client anyway, and since pushed state is by definition unsolicited, failing to deliver it is never an actual error condition for the sender. ## Consequences -- Latency of pushed state is independent of chunk delivery volume. A client pulling its initial region at full rate still receives snapshots on time. -- Adding a pushed payload is one enum variant, with no new stream to negotiate on either peer and no `StreamLayout` change. -- The stream layout now commits four ids (control 0, reserved 1, authority 2, chunk LOD0 3). Reassigning any of them is a `PROTOCOL_VERSION` bump. -- Loss and ordering semantics are per-stream: authority messages are ordered relative to each other and unordered relative to chunk deliveries. Anything requiring a snapshot to be interpreted against a specific delivered chunk must carry its own correlation (a tick number or chunk version), rather than relying on arrival order across streams. -- The sink is fire-and-forget and unbounded. That is appropriate for a low-rate diagnostics push, but snapshots at tick rate will need a bound and a drop policy: a slow client must not be allowed to grow the server's queue without limit. This is the known follow-up before snapshots ship. -- Diagnostics being *on* the authority stream rather than beside it means they are subject to the same server-authoritative framing: the client reports what the server measured, never what it inferred. See [`docs/diagnostics.md`](../diagnostics.md). +- The latency of pushed state is now completely independent of chunk delivery volume. Even if a client is pulling its initial region at maximum bandwidth, it still receives its state snapshots perfectly on time. +- Adding a brand new pushed payload is as simple as adding an enum variant. It requires zero new streams to negotiate on either peer and zero `StreamLayout` changes. +- The stream layout now explicitly commits four IDs (control 0, reserved 1, authority 2, chunk LOD0 3). Trying to reassign any of these will demand a full `PROTOCOL_VERSION` bump. +- Loss and ordering semantics are strictly per-stream. Authority messages are perfectly ordered relative to each other, but completely unordered relative to chunk deliveries. If something requires a snapshot to be interpreted against a specific delivered chunk, it must carry its own correlation data (like a tick number or chunk version) rather than lazily relying on arrival order across streams. +- The current sink is fire-and-forget and unbounded. While this is perfectly fine for a low-rate diagnostics push, sending massive snapshots at tick rate will absolutely require a bound and a drop policy. We cannot allow a slow client to grow the server's queue without limit. This is a known follow-up requirement before snapshots officially ship. +- Because diagnostics are *on* the authority stream rather than beside it, they are subject to the exact same server-authoritative framing: the client strictly reports what the server measured, never what it inferred locally. See [`docs/diagnostics.md`](../diagnostics.md) for more details. diff --git a/docs/adr/template.md b/docs/adr/template.md index 44a87b7..8fe7ad7 100644 --- a/docs/adr/template.md +++ b/docs/adr/template.md @@ -5,12 +5,12 @@ ## Context -The forces at play: the technical situation, the constraints, and the problem that requires a decision. State the facts neutrally. What makes this choice non-obvious or hard to reverse later? +Describe the forces at play. Outline the technical situation, the constraints we are working under, and the specific problem that requires a decision. Try to state the facts neutrally. What exactly makes this choice non-obvious, or why would it be hard to reverse later? ## Decision -The choice that was made, stated in the active, present tense ("The engine uses ...", "Worldgen seeds from ..."). One decision per record. +State the choice that we actually made. Use active, present tense ("The engine uses ...", "Worldgen seeds from ..."). Keep it to exactly one decision per record. ## Consequences -What becomes easier and what becomes harder as a result. Include the trade-offs accepted, the follow-on work implied, and anything a future contributor must keep true for the decision to remain valid. +Explain what becomes easier and what becomes harder as a result of this decision. You should include the trade-offs we accepted, any follow-on work this implies, and anything a future contributor must keep true in order for this decision to remain valid. diff --git a/docs/chunk_streaming.md b/docs/chunk_streaming.md index ecfa5c2..c4ab50d 100644 --- a/docs/chunk_streaming.md +++ b/docs/chunk_streaming.md @@ -1,91 +1,95 @@ # Chunk streaming -How the server keeps the set of resident chunks in sync with where players are, and how chunk generation is kept off the simulation tick. The generating side lives in [`crates/shared/src/generator.rs`](../crates/shared/src/generator.rs); the streaming and storage side in [`crates/server/src/world_server.rs`](../crates/server/src/world_server.rs), driven from [`crates/server/src/main.rs`](../crates/server/src/main.rs). +This covers how the server keeps resident chunks in sync with player positions and how we keep chunk generation completely off the main simulation tick. The generation code lives in [`crates/shared/src/generator.rs`](../crates/shared/src/generator.rs), while the streaming and storage logic is in [`crates/server/src/world_server.rs`](../crates/server/src/world_server.rs), driven from [`crates/server/src/main.rs`](../crates/server/src/main.rs). ## Overview -Chunk residency is *reconciled* every tick against a **desired set**: the union of a cylinder of chunks around every player anchor. Chunks inside the desired set are made resident; chunks outside it are evicted. Generation of a missing chunk is expensive, so it is performed on a dedicated worker-thread pool rather than inline on the tick. Reconciliation is therefore non-blocking: each pass *drains* whatever chunks the pool has finished, *evicts* what is no longer wanted, and *dispatches* what is still missing, without ever waiting for a chunk to be generated. +Every tick, the server reconciles chunk residency against a **desired set**, which is the union of cylinders around every player's anchor. If a chunk is in the desired set, we make it resident; if it falls outside, we evict it. Because generating a missing chunk is expensive, we hand that work off to a dedicated thread pool rather than blocking the tick. This makes the reconciliation process entirely non-blocking: each pass drains whatever the pool has finished, evicts chunks that are no longer needed, and dispatches requests for anything still missing, all without ever waiting for a chunk to finish generating. ## Desired set -`cylinder_chunks(center, radius, out)` inserts every chunk position within the streaming cylinder around `center` into `out`. The shape is a disc in XZ (`dx² + dz² ≤ radius²`) extruded vertically to `±radius/2`, reflecting the fact that horizontal view distance exceeds vertical. +The `cylinder_chunks(center, radius, out)` function takes every chunk position within the streaming cylinder around the `center` and inserts it into `out`. This shape is a horizontal disc (`dx² + dz² ≤ radius²`) extruded vertically to `±radius/2`. We use this shape because horizontal view distance matters much more than vertical. -Two producers build desired sets. During the **startup loading gate**, the ECS streaming system `stream_chunks` (in `main.rs`) queries every entity carrying `Player`, `Position`, and `ViewDistance` and unions each anchor's cylinder, pre-warming the origin region before the network is up. During **steady-state play**, the desired set is instead the union of every connected client's subscription (each a `cylinder_chunks(center, radius)` around its camera), assembled from the `ClientStream` map in the main loop — see [Network delivery](#network-delivery-client--server). In both cases the sets are unioned, so overlapping cylinders deduplicate automatically and a chunk is evicted only when *no* subscriber wants it. See [Multiplayer](#multiplayer) below. +We build these desired sets in two places. During the **startup loading gate**, the `stream_chunks` ECS system queries every entity that has `Player`, `Position`, and `ViewDistance` components, unioning each anchor's cylinder to pre-warm the origin region before the network is even up. During **steady-state play**, the desired set becomes the union of every connected client's active subscription (calculated as `cylinder_chunks(center, radius)` around its camera), which we assemble from the `ClientStream` map in the main loop. In both cases, overlapping cylinders deduplicate automatically because we union the sets, meaning a chunk only gets evicted when absolutely *no* subscriber wants it anymore. See the [Multiplayer](#multiplayer) section for more on this. ## The worker pool -`ServerWorld::new` builds the pool once. Its structure: +The `ServerWorld::new` function builds the worker pool once. Here is how it is structured: -- **Generator sharing:** the `VoxelGenerator` is wrapped in an `Arc` and a handle is cloned into every worker. `generate_chunk(&self)` is read-only, so no `Mutex` is required; the workers share one immutable generator. -- **Job channel (main → workers):** a `crossbeam-channel` carrying `ChunkPos`. `crossbeam-channel` is used rather than `std::sync::mpsc` because the pool needs **multiple consumers**: every worker clones the `Receiver` and pulls from the one shared queue, and each job is delivered to exactly one worker. `std::sync::mpsc` permits only a single consumer. -- **Result channel (workers → main):** a `crossbeam-channel` carrying `(ChunkPos, Chunk)`. Each worker clones the `Sender`; the main thread holds the single `Receiver`. -- **Worker loop:** each worker blocks on `job_rx.recv()`, generates the chunk, and sends `(pos, chunk)` back. A blocking `recv` on a worker thread is acceptable because it is not the simulation thread. +- **Generator sharing:** We wrap the `VoxelGenerator` in an `Arc` and clone a handle into every worker. Because `generate_chunk(&self)` is read-only, we don't need a `Mutex`; all workers share a single immutable generator safely. +- **Job channel (main to workers):** This is a `crossbeam-channel` that carries `ChunkPos`. We specifically use `crossbeam` instead of `std::sync::mpsc` because the pool requires multiple consumers. Every worker clones the `Receiver` to pull from a single shared queue, and each job goes to exactly one worker. The standard library's `mpsc` only allows a single consumer. +- **Result channel (workers to main):** Another `crossbeam-channel` carrying `(ChunkPos, Chunk)`. Every worker clones the `Sender`, and the main thread holds the single `Receiver`. +- **Worker loop:** Each worker blocks on `job_rx.recv()`, generates the requested chunk, and sends the result back. Blocking on `recv` here is perfectly fine since this runs on a worker thread, not the main simulation thread. ### Channel disconnection and shutdown -A `crossbeam-channel` reports disconnection (its `recv` returns `Err`) only once *all* senders — or, symmetrically, all receivers — have been dropped. After the spawn loop, the template `job_rx` and `result_tx` that were cloned from are dropped immediately. Retaining either would keep its channel open forever: workers would never observe job-channel shutdown, and the main thread would never observe the result channel closing. Worker `JoinHandle`s are retained on `ServerWorld` for a future graceful-stop path that drops `job_tx` and joins the threads; the process currently relies on OS teardown at exit. +A `crossbeam-channel` will only report disconnection (returning an `Err` on `recv`) when all senders or receivers have been completely dropped. Right after spawning the pool, we immediately drop the original `job_rx` and `result_tx` templates. If we held onto them, the channels would stay open forever, meaning the workers would never realize the job channel shut down and the main thread would never see the result channel close. We do keep the worker `JoinHandle`s on `ServerWorld` for a future graceful shutdown path, but right now the process just relies on the OS tearing down threads on exit. -## Reconcile: drain → evict → dispatch +## Reconcile: drain, evict, dispatch -`ServerWorld::reconcile(&mut self, desired)` runs three non-blocking phases per pass: +The `ServerWorld::reconcile(&mut self, desired)` function runs three non-blocking phases during each pass: -1. **Drain.** `result_rx.try_recv()` is pulled in a loop until empty (`try_recv` never blocks). Each returned position is removed from `in_flight`. A returned chunk is inserted into the resident map **only if it is still in `desired`** — see [the eviction race](#the-eviction-race). -2. **Evict.** Resident chunks absent from `desired` are removed. In-flight chunks that are no longer wanted need no handling here; the drain guard discards them when they arrive. -3. **Dispatch.** For every position in `desired` that is neither resident nor already in `in_flight`, the position is inserted into `in_flight` and sent on `job_tx`. The `in_flight` set is what prevents the same position being re-queued on every pass while a worker is still generating it. +1. **Drain:** We pull from `result_rx.try_recv()` in a loop until it is empty (which never blocks). We remove each returned position from the `in_flight` set. A returned chunk only gets inserted into the resident map if it is still present in the `desired` set (see [the eviction race](#the-eviction-race)). +2. **Evict:** Any resident chunks that are no longer in the `desired` set are removed. If an in-flight chunk is no longer wanted, we don't need to handle it here; the drain phase will naturally discard it when it finally arrives. +3. **Dispatch:** For every position in the `desired` set that isn't resident and isn't already `in_flight`, we insert it into `in_flight` and send it down `job_tx`. The `in_flight` set ensures we don't spam the same position into the queue on every single pass while a worker is busy generating it. -`in_flight` therefore tracks positions dispatched but not yet returned, and is the single source of truth for "work outstanding." +This makes `in_flight` our single source of truth for tracking work that has been dispatched but hasn't returned yet. ### The eviction race -Between a chunk being dispatched and the worker returning it, the anchor may move so that the chunk is no longer wanted. Without a guard, the drain phase would insert the now-unwanted chunk, resurrecting a chunk that the evict phase had already discarded (or would never be asked to discard, since it was never resident). The guard in phase 1 — insert only if `desired.contains(&pos)` — makes a late arrival harmless: an unwanted chunk is dropped on arrival rather than made resident. +While a chunk is off being generated by a worker, the player might move away, meaning the chunk is no longer wanted. If we weren't careful, the drain phase would insert this unwanted chunk into the map, effectively resurrecting a chunk that the evict phase had already tossed out. By guarding the drain phase (only inserting if `desired.contains(&pos)`), a late arrival is totally harmless. The unwanted chunk just gets dropped on arrival instead of becoming resident. ## Startup loading gate -Startup reuses the *same* worker pool and schedule; there is no separate synchronous loading path. Before granting player control, `main` runs the streaming schedule in a loop and polls `ServerWorld::streaming_idle()` (true when `in_flight` is empty). Once the initial region has at least one resident chunk and no work in flight, the region is ready. Waiting here is acceptable because no gameplay is running yet. During play the same reconcile runs every tick but is **never** waited on. A loading progress fraction is available as `resident / (resident + in_flight)`. +Startup uses the exact same worker pool and schedule; we intentionally avoid building a separate synchronous loading path. Before handing control over to the player, `main` runs the streaming schedule in a tight loop and polls `ServerWorld::streaming_idle()` (which returns true when `in_flight` is empty). Once the starting region has at least one resident chunk and zero work in flight, it is ready to go. It is perfectly fine to wait here because actual gameplay hasn't started yet. Once the game is running, this exact same reconcile logic runs every tick but is *never* waited on. We calculate loading progress as a simple fraction: `resident / (resident + in_flight)`. -## Network delivery (client ↔ server) +## Network delivery (client and server) -Residency (above) keeps chunks in the server's memory; **delivery** streams them to each client. The two are decoupled: the reconcile pool does not know about clients, and delivery does not generate. Delivery is implemented in [`crates/net/src/chunk.rs`](../crates/net/src/chunk.rs) (transport) and [`crates/server/src/client_stream.rs`](../crates/server/src/client_stream.rs) (per-client bookkeeping), driven from `main.rs`; the client side lives in [`crates/client/src/chunks.rs`](../crates/client/src/chunks.rs). +While residency keeps chunks loaded in the server's memory, **delivery** is responsible for streaming those chunks to clients. These concepts are strictly decoupled. The reconcile pool knows nothing about clients, and the delivery system never generates chunks. Delivery logic lives in [`crates/net/src/chunk.rs`](../crates/net/src/chunk.rs) for transport and [`crates/server/src/client_stream.rs`](../crates/server/src/client_stream.rs) for server-side bookkeeping. The client counterpart is in [`crates/client/src/chunks.rs`](../crates/client/src/chunks.rs). ### The chunk stream -After the handshake, the client opens one **bidirectional** QUIC stream (the canonical `StreamLayout::chunk_lod0`, stream 3) and the server accepts it, mirroring the control-stream convention. Both directions ride this one stream: client → server carries `ChunkSubscribe { center, radius }`, server → client carries `ChunkMessage::{Chunk { pos, data }, Drop { pos }}`. Frames use the existing length-prefixed `postcard` codec with a dedicated `MAX_CHUNK_FRAME_LEN` (1 MiB) cap, larger than the 64 KiB control cap. +Once the handshake finishes, the client opens a single bidirectional QUIC stream (specifically stream 3, `StreamLayout::chunk_lod0`) and the server accepts it. Both directions ride on this one stream. The client sends `ChunkSubscribe { center, radius }` to the server, and the server replies with `ChunkMessage::{Chunk { pos, data }, Drop { pos }}`. These frames use our length-prefixed `postcard` codec, capped at a dedicated 1 MiB limit (`MAX_CHUNK_FRAME_LEN`), which is much larger than the 64 KiB control limit. ### The async/sync bridge -The QUIC pump is async on the network thread; the simulation loop (server) and winit loop (client) are synchronous. Two channels cross the boundary per connection, in opposite directions, and use different primitives for that reason: +The QUIC network pump is completely async, but the server simulation and client `winit` loops are totally synchronous. To bridge this gap, we use two channels per connection (one for each direction) and pick different primitives based on the direction: -- **Inbound** (`ChunkSubscribe` arriving async, consumed by the sync loop) reuses the `crossbeam` `ServerEvent` channel, surfaced as `ServerEvent::ChunkSubscribe { id, request }`. The async `send` is non-blocking; the sync loop drains with `try_iter`. -- **Outbound** (a `ChunkMessage` produced by the sync loop, consumed async) uses a **`tokio` unbounded MPSC**. Its `send` is synchronous, so the non-async loop pushes without a runtime, while the pump's `recv().await` composes into its `tokio::select!`. A blocking `crossbeam` receiver would freeze the current-thread runtime and cannot appear in a `select!` arm. The tokio sender is wrapped so neither `server` nor `client` names a tokio type: `ChunkSink` (server → client deliveries) and `ChunkSubscriber` (client → server subscriptions). See [ADR-0010](adr/0010-net-crate-async-runtime.md). +- **Inbound:** A `ChunkSubscribe` arrives via async and is consumed by the sync loop. We route this through the `crossbeam` `ServerEvent` channel, surfacing it as `ServerEvent::ChunkSubscribe { id, request }`. The async `send` is non-blocking, and the sync loop simply drains it with `try_iter`. +- **Outbound:** A `ChunkMessage` is produced by the sync loop and consumed via async. We use an unbounded MPSC from `tokio` for this. The synchronous send works without needing an async runtime, while the pump can `await` the `recv()` cleanly inside a `tokio::select!`. If we used a blocking `crossbeam` receiver here, it would freeze the current-thread runtime and wouldn't work inside a `select!`. We wrap the tokio sender in `ChunkSink` (server to client) and `ChunkSubscriber` (client to server) so neither the client nor server code ever explicitly names a `tokio` type (refer to [ADR-0010](adr/0010-net-crate-async-runtime.md)). -The server-side pump is `chunk_stream_task`; its client mirror is `client_chunk_task`. Each is one `select!` loop over "a frame arrived to read" and "a message is queued to write." The client's `ClientLink` bundles the handshake outcome, the `ChunkSubscriber`, and a `crossbeam` `ChunkStream` receiver of deliveries. +The server runs this in `chunk_stream_task` while the client runs it in `client_chunk_task`. Each one is just a `select!` loop evaluating whether a frame arrived to read or a message is queued to write. The client bundles this into a `ClientLink` containing the handshake outcome, the `ChunkSubscriber`, and the `crossbeam` receiver for incoming deliveries. ### Per-client state and the diff -Each connected client is tracked by a `ClientStream` holding its `ChunkSink`, its current desired set (radius-clamped to `SERVER_MAX_RADIUS`), and its `sent` set. On each subscription, `desired_diff(previous, new)` yields the load list (`new − previous`) and drop list (`previous − new`); a `ChunkMessage::Drop` is emitted for every already-**sent** chunk that left the set. Newly-desired chunks are **not** sent immediately — chunk loads are async, so `ClientStream::flush` runs each tick and delivers every desired-but-unsent chunk that has since become resident, retrying on later ticks until the pool returns it. +Every connected client gets a `ClientStream` tracker holding its `ChunkSink`, its current desired set (clamped to `SERVER_MAX_RADIUS`), and a record of what it has already been sent. When a subscription updates, we calculate the diff (`new - previous` for loads, `previous - new` for drops). For every chunk in the drop list that was previously sent, we emit a `ChunkMessage::Drop`. -Delivery is bounded by `MAX_DELIVERIES_PER_TICK` (32 chunks per client per tick). Encoding a chunk is the expensive part of `flush`, and a client whose subscription has just jumped can have hundreds of chunks pending at once; without a cap that backlog is encoded in a single tick and shows up directly as a tick overrun. The budget counts chunks **actually encoded**, so a tick where most of the desired set is still in flight is not charged for work it did not do. The fixed count is a placeholder for a time budget, which becomes necessary once per-chunk cost varies with LOD. +However, we do *not* send newly desired chunks immediately. Chunk generation is async, so `ClientStream::flush` checks each tick and delivers any newly resident chunks that the client wants but hasn't received yet. If the chunk isn't ready, it simply waits and tries again on a future tick. + +We bound this delivery to `MAX_DELIVERIES_PER_TICK` (currently 32 chunks per client per tick) to prevent lag spikes. Encoding chunks is expensive. If a client teleports and suddenly needs hundreds of chunks, trying to encode them all at once would immediately blow out the tick budget. Importantly, this limit only applies to chunks we *actually encode*. If most of the desired set is still generating in the worker pool, we don't penalize the tick budget for work that hasn't happened yet. This fixed chunk count is a stopgap until we implement a proper time-based budget, which will be necessary once chunk costs start varying by LOD. ### Self-contained payloads (all-air diff) -`ChunkMessage::Chunk` carries a `ChunkData` (the sparse, baseline-relative form; see [ADR-0009](adr/0009-baseline-relative-sparse-chunk-persistence.md)). Because the client runs **no** worldgen (the server owns world content; worldgen never runs client-side), it cannot reconstruct a worldgen baseline to diff against. So delivered chunks are diffed against an **all-air baseline** (`Chunk::default()`): the edits become the chunk's full non-air content, and the client materializes each payload against its own all-air `Chunk::default()`. This makes every delivery self-contained, at the cost of not exploiting the deterministic baseline for compression — a compression concern deferred to the LOD/compression pass. +The `ChunkMessage::Chunk` payload carries a `ChunkData` in a sparse, baseline-relative format (detailed in [ADR-0009](adr/0009-baseline-relative-sparse-chunk-persistence.md)). Because the client does absolutely zero worldgen (the server is strictly authoritative), it has no way to reconstruct the worldgen baseline to diff against. Instead, we diff all delivered chunks against a completely empty all-air baseline (`Chunk::default()`). The edits effectively become the chunk's entire non-air content, and the client reconstructs the chunk by applying those edits to its own empty baseline. This keeps every delivery completely self-contained. While we sacrifice the compression benefits of diffing against the true worldgen baseline, we're deferring heavy compression work to the future LOD pass. ### Client application -The client subscribes with its own `LOAD_RADIUS` (so the server's per-client resident set matches what the client keeps) whenever its center chunk changes. Deliveries are drained under per-frame budgets: a `ChunkMessage::Chunk` is materialized and the position (plus its six neighbours) is queued for meshing, while a `ChunkMessage::Drop` removes the mesh. Meshing itself runs on a worker pool rather than inline, so the client retains chunk voxels after upload; that pipeline is described in [`meshing.md`](meshing.md). The client **also** evicts chunks outside `LOAD_RADIUS` locally, independent of the server `Drop`, so memory stays bounded even if the server is slow. +Whenever the client's center chunk changes, it subscribes using its own `LOAD_RADIUS`. This ensures the server's per-client resident set perfectly matches what the client intends to keep. The client drains incoming deliveries under its own per-frame budgets. When a `ChunkMessage::Chunk` arrives, it materializes the data and queues the position (along with its six neighbors) for meshing. When a `ChunkMessage::Drop` arrives, it discards the mesh. Since meshing runs on a separate worker pool, the client safely holds onto the chunk voxels even after the mesh is uploaded (this pipeline is covered in [`meshing.md`](meshing.md)). Crucially, the client proactively evicts chunks outside its `LOAD_RADIUS` on its own. It doesn't strictly wait for the server's `Drop` message, ensuring memory usage stays strictly bounded even if the server lags behind. ## Multiplayer -Residency is a single shared pipeline: every client's subscription cylinder is unioned into one desired set, reconciled against one chunk store served by one worker pool, so a chunk is generated once no matter how many clients want it. **Delivery**, by contrast, is per-client: each `ClientStream` independently tracks what that client has been sent and diffs its own subscription (see [Network delivery](#network-delivery-client--server)). A client joining or leaving is a `ClientStream` entering or leaving the map on the connect/disconnect events. Backpressure and fairness across clients (a bounded job channel, nearest-first priority, per-chunk ack/flow-control) remain deferred. +Residency operates as a single, shared pipeline. We union every client's subscription cylinder into one massive desired set, which is then reconciled against a single chunk store and a single worker pool. This guarantees a chunk is only generated once, regardless of how many clients requested it. + +**Delivery**, however, is strictly per-client. Each `ClientStream` independently tracks what that specific client has received and diffs against its personal subscription. When a client connects or disconnects, its `ClientStream` is simply added to or removed from the map. Features like backpressure, fairness across clients, and per-chunk flow control are deferred for now. ## Level of detail -Each job is currently a full-detail (LOD0) chunk. When LOD is introduced, the job payload grows from `ChunkPos` to `(ChunkPos, Lod)`; the worker-pool plumbing is LOD-agnostic and does not change. +Currently, every job processes a full-detail LOD0 chunk. When we eventually introduce LODs, the job payload will just grow from `ChunkPos` to `(ChunkPos, Lod)`. The entire worker-pool plumbing is already LOD-agnostic and won't need to change. ## Testing -The pure cylinder math and the async reconcile behaviour are unit-tested in `world_server.rs`: +We unit-test the pure cylinder math and async reconcile behavior inside `world_server.rs`: -- `cylinder_chunks` symmetry, boundary inclusion, and translation invariance. -- `reconcile_converges_over_multiple_passes`: an initial pass dispatches work and leaves nothing resident; repeated passes drain the pool until every desired position is resident. -- `evicted_chunk_is_not_repopulated_on_arrival`: a dispatched chunk that stops being wanted is discarded on arrival and never becomes resident. This test is timing-independent because every pass after dispatch reconciles against an empty desired set. +- `cylinder_chunks` is tested for symmetry, boundary inclusion, and translation invariance. +- `reconcile_converges_over_multiple_passes` verifies that an initial pass dispatches work but leaves nothing resident, while subsequent passes drain the pool until everything is resident. +- `evicted_chunk_is_not_repopulated_on_arrival` confirms our eviction race guard works. If a chunk stops being wanted while it's in flight, it gets discarded upon arrival and never enters the resident set. This test works without any timing hacks because subsequent passes simply reconcile against an empty desired set. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 4acb19f..adea8bd 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1,83 +1,83 @@ # Runtime diagnostics -How the engine reports on itself: what each crate measures, how those measurements are aggregated into the client's statistics panel, and how the server's own figures reach the client. The panel lives in [`crates/client/src/stats.rs`](../crates/client/src/stats.rs); the sources are spread across `renderer`, `net`, `server`, and `shared`. +This document explains how the engine monitors itself, what each crate measures, how we aggregate those measurements into the client's statistics panel, and how the server sends its own figures to the client. The panel implementation lives in [`crates/client/src/stats.rs`](../crates/client/src/stats.rs), but the data sources are spread across `renderer`, `net`, `server`, and `shared`. ## Why this exists -Every figure here is **measured, not declared**. The nominal tick rate advertised in the handshake is a constant: it states what the server intends to run at and can never reveal that it is falling behind. The same holds throughout, since a configured frame cap says nothing about achieved frame time and a load radius says nothing about how many chunks are actually resident. Diagnostics exist to close that gap, so the answer to "is this slow, and where" comes from observation rather than from configuration. +Every metric here is **measured, not declared**. The nominal tick rate we advertise during the handshake is just a constant; it states what the server *wants* to run at, so it can never reveal if the server is actually falling behind. The same logic applies everywhere else. A configured frame cap says nothing about achieved frame times, and a configured load radius says nothing about how many chunks are actually resident in memory. Diagnostics exist to bridge that gap. If you want to know if the game is running slowly (and exactly where), the answer must come from live observation rather than configuration. -The immediate motivation is that the client is now doing enough work per frame (materialising deliveries, dispatching mesh jobs, ingesting meshes, culling, submitting) that a frame-time regression has several plausible causes and no way to distinguish them by inspection. +The immediate motivation for this is that the client is doing a lot of work per frame (materializing deliveries, dispatching mesh jobs, ingesting meshes, culling, and submitting). If a frame-time regression happens, there are several plausible culprits, and it's impossible to distinguish them just by looking at the code. ## The measurement layers -Each crate measures what only it can see, and exposes a plain snapshot type. No crate formats, and no crate reaches into another's internals. +Each crate measures only what it can see and exposes a plain snapshot type. Crate boundaries are strictly respected; no crate formats the data itself, and no crate reaches into another's internals. | Source | Type | What it observes | |--------|------|------------------| -| `renderer` | `RenderStats` | Uploaded / visible / culled meshes, draw calls, triangles, vertices, geometry bytes, active render mode, projection, swapchain, frames presented and skipped | -| `renderer` | `GpuInfo` | Device name and class, vendor and device ids, driver and API versions, total device-local memory. Queried once, since every field is immutable for the renderer's lifetime | -| `renderer` | `MemoryUsage` | Two independent views of GPU memory: the driver's heap accounting and the renderer's own allocator | -| `net` | `NetStats` | Application counters (chunks received, drops received, subscribes sent) plus QUIC path state (RTT, lost packets, congestion window, path MTU, bytes and datagrams) | -| `client` | `ChunkStats` | Resident chunks, uploaded meshes, in-flight mesh jobs, pending re-meshes, desired-set size | -| `client` | `FrameStats` | Frame count, mean / min / max frame time, achieved FPS over the window | -| `client` | `HostInfo` / `HostUsage` | CPU brand and core count, OS and kernel, then process CPU and memory against system totals | -| `server` | `ServerStats` | Measured TPS, mean and max tick body, tick-budget utilisation, resident and in-flight chunks, connected clients, entities, players, uptime | +| `renderer` | `RenderStats` | Uploaded/visible/culled meshes, draw calls, triangles, vertices, geometry bytes, active render mode, projection, swapchain, frames presented, and frames skipped | +| `renderer` | `GpuInfo` | Device name and class, vendor and device IDs, driver and API versions, and total device-local memory. We only query this once since these fields are immutable for the renderer's lifetime | +| `renderer` | `MemoryUsage` | Two independent views of GPU memory: the driver's heap accounting and the renderer's own internal allocator | +| `net` | `NetStats` | Application counters (chunks received, drops received, subscribes sent) plus QUIC path state (RTT, lost packets, congestion window, path MTU, bytes, and datagrams) | +| `client` | `ChunkStats` | Resident chunks, uploaded meshes, in-flight mesh jobs, pending re-meshes, and desired-set size | +| `client` | `FrameStats` | Frame count, mean/min/max frame time, and achieved FPS over the window | +| `client` | `HostInfo` / `HostUsage` | CPU brand and core count, OS and kernel, followed by process CPU and memory usage against system totals | +| `server` | `ServerStats` | Measured TPS, mean and max tick body, tick-budget utilization, resident and in-flight chunks, connected clients, entities, players, and total uptime | ### Separating the immutable from the live -`GpuInfo` and `HostInfo` are queried once; `MemoryUsage` and `HostUsage` are read per window. The split is deliberate: device name and driver version cannot change while the renderer lives, and re-querying them each window would pay for a string allocation to learn nothing. Live figures are read on demand precisely because they are not cacheable. +We query `GpuInfo` and `HostInfo` exactly once, while `MemoryUsage` and `HostUsage` are read per window. This split is deliberate. The device name and driver version cannot change while the renderer is alive, so re-querying them every window would waste string allocations just to learn nothing new. On the flip side, live figures are read on demand precisely because they cannot be cached. -`MemoryUsage` reports the driver's heap figures as `Option`, because they require `VK_EXT_memory_budget`. Where the extension is unavailable the allocator's own figures still report, since this process's suballocations are always knowable even when the driver's total is not. The panel must therefore render a missing driver figure as missing rather than substituting zero, which would read as "no memory in use". +`MemoryUsage` reports the driver's heap figures as an `Option` because they rely on the `VK_EXT_memory_budget` extension. If that extension isn't available, the allocator's own figures still report accurately (since our process's suballocations are always known even if the driver's total isn't). Because of this, the panel must render a missing driver figure as genuinely missing, rather than substituting a zero which would imply "no memory in use". -`decode_driver_version` exists because `VkPhysicalDeviceProperties::driverVersion` is documented as vendor-specific and two vendors deviate from the standard packing: NVIDIA uses a 10/8/8/6-bit layout, and Intel's *Windows* driver uses a 14/18-bit split while its Mesa driver follows the Vulkan convention. The decode is unit-tested per vendor, since a mis-decoded driver version is the kind of wrong-but-plausible output nobody notices. +The `decode_driver_version` function exists because `VkPhysicalDeviceProperties::driverVersion` is documented as vendor-specific, and two major vendors deviate from the standard Vulkan packing. NVIDIA uses a 10/8/8/6-bit layout, and Intel's Windows driver uses a 14/18-bit split (though its Mesa driver follows the standard Vulkan convention). We unit-test this decoding per vendor, because a mis-decoded driver version is exactly the kind of wrong-but-plausible bug that nobody notices until it causes a problem. ## Windowed measurement -Everything is reported over a **window**, not instantaneously. Both `client::stats::STATS_INTERVAL` and `server::tick_stats::REPORT_INTERVAL` are one second: short enough to surface a stall promptly, long enough that producing a report costs nothing next to the work it summarises. +Everything is reported over a **window**, not instantaneously. Both `client::stats::STATS_INTERVAL` and `server::tick_stats::REPORT_INTERVAL` are set to one second. This is short enough to surface stalls promptly, but long enough that producing the report costs effectively nothing compared to the actual work it summarizes. -A window carries a mean *and* a maximum for exactly one reason: they answer different questions. A mean comfortably inside budget alongside a spiking maximum indicates intermittent stalls, a hitch, whereas a mean at budget indicates sustained overload. Reporting only the mean hides the first case, which is the one users actually feel. +A window carries both a mean and a maximum for one very specific reason: they answer different questions. A mean that sits comfortably inside budget alongside a spiking maximum indicates an intermittent stall (a "hitch"), whereas a mean sitting at budget indicates sustained overload. If we only reported the mean, we would completely hide the first case, which is what users actually feel while playing. -The server additionally reports `tick_budget_percent`, the share of the nominal tick period consumed by the mean tick body. It is derived rather than measured, but it is the figure that says whether headroom exists; values at or above 100 mean the loop no longer has any. The tick body is timed *excluding* the sleep that pads a tick out to its period, so the number reflects work rather than pacing. +The server additionally reports `tick_budget_percent`, which is the share of the nominal tick period consumed by the mean tick body. We derive this rather than measuring it directly, but it is the crucial figure that tells us whether we have any headroom left. A value at or above 100 means the loop is completely tapped out. We time the tick body *excluding* the sleep that pads a tick out to its period, ensuring the number reflects actual engine work rather than forced pacing. -`TickMeter` computes this with no division-by-zero hazard: a zero period means no budget exists to consume, so utilisation is undefined and reported as zero rather than as infinity. +`TickMeter` computes this safely without any division-by-zero hazards. A zero period simply means no budget exists to consume, so utilization is undefined and reported as zero rather than causing a crash or returning infinity. -### Collection is unconditional; emission is gated +### Collection is unconditional, emission is gated -The panel is toggled with the **F1 + I** chord (see `crates/client/src/debug.rs`), but the toggle gates *emission only*. Accumulation runs whether or not the panel is on, and the window closes on schedule either way. +You can toggle the panel using the **F1 + I** chord (defined in `crates/client/src/debug.rs`), but that toggle *only* gates emission. Data accumulation runs continuously whether the panel is visible or not, and the window closes on its normal schedule either way. -This matters more than it sounds. Gating collection on the toggle would make the first window after enabling the panel partial, reporting a fraction of a second of frames as though it were a full window, and the first thing anyone does when something feels wrong is turn the panel on. The figures must already be correct at that moment. +This is much more important than it sounds. If we gated collection on the toggle, the first window after enabling the panel would be partial. It would report a fraction of a second of frames as though it were a full window. Since the first thing anyone does when the game feels wrong is turn the panel on, the figures must already be perfectly accurate at that exact moment. -The panel is emitted through `tracing` at `info` as a multi-line block, consistent with the project-wide prohibition on `println!` for diagnostics. The server formats its own figures the same way, so a dedicated server's log and a client's panel present the same numbers identically. +When enabled, the panel emits through `tracing` at the `info` level as a multi-line block. This aligns with our project-wide ban on using `println!` for diagnostics. The server formats its own figures the exact same way, ensuring that a dedicated server's log and a client's panel present identical numbers in identical formats. ## Getting the server's figures to the client -`ServerStats` is a `shared` protocol type pushed on the authority stream (stream 2) once per window; the stream's design is [ADR-0011](adr/0011-authority-stream-for-server-pushed-state.md). The client drains it non-blockingly each frame and retains the most recent snapshot, so the panel always has a value even though server and client windows are not aligned. +`ServerStats` is a `shared` protocol type that the server pushes down the authority stream (stream 2) once per window. The design for this stream is documented in [ADR-0011](adr/0011-authority-stream-for-server-pushed-state.md). The client drains this stream non-blockingly every frame and simply holds onto the most recent snapshot. This ensures the panel always has a value to display, even though the server and client windows are naturally misaligned. -The retention is intentional: aligning the two cadences would require synchronisation for a display figure. A snapshot up to a second old is the correct trade, and the server's own `uptime_secs` makes staleness visible if it ever matters. +This retention strategy is fully intentional. Trying to align the two cadences perfectly would require complex synchronization just for a display figure. Trading that complexity for a snapshot that might be up to a second old is the correct choice. If the staleness ever matters, the server's own `uptime_secs` field makes it completely visible. -The server also formats and logs the same `ServerStats` locally, so a dedicated host is diagnosable without a client attached. +The server also formats and logs the same `ServerStats` locally, which means you can fully diagnose a dedicated host without ever needing a client attached. ### What is *not* on the wire -`ServerKind` (integrated, dedicated local, or dedicated remote) is deliberately **not** a protocol field. The client already knows the answer without asking: it either spawned a server in-process or dialled a socket, and a loopback address distinguishes a locally hosted process from a remote one. A server-declared field would be redundant at best and spoofable at worst, so the value is constructed client-side from facts the client already holds. +You might notice that `ServerKind` (integrated, dedicated local, or dedicated remote) is deliberately **not** a protocol field. The client already knows the answer without asking; it either spawned a server in-process, or it dialed a socket. If it dialed a socket, checking for a loopback address perfectly distinguishes a locally hosted process from a remote one. Adding a server-declared field for this would be redundant at best and spoofable at worst, so we construct the value client-side using facts the client already holds. -The general rule this instances: a diagnostic should be sourced from whichever side *observes* it. The server reports its own tick health because only it can measure that; the client classifies the session because only it knows how the session was established. +This demonstrates a general rule: a diagnostic should be sourced from whichever side *observes* it. The server reports its own tick health because only it can measure that, but the client classifies the session because only the client knows how the session was established. ## Concurrency -Two boundaries are crossed, with a different primitive for each. +We cross two concurrency boundaries here, and we use a different primitive for each. -**Client net counters** (`NetCounters`) are incremented on the async chunk task and read on the winit thread, held behind an `Arc` and mutated with **relaxed atomics**. Relaxed is correct here rather than merely cheap: each counter is independent, nothing else is ordered against them, and a reader observing a slightly stale value is reporting a diagnostic figure, not making a decision. Paying for stronger ordering would buy precision nobody consumes. +**Client net counters** (`NetCounters`) are incremented on the async chunk task and read on the winit thread. They are held behind an `Arc` and mutated using **relaxed atomics**. Using relaxed ordering here isn't just about performance; it is strictly correct. Each counter is independent, nothing else is ordered against them, and if a reader observes a slightly stale value, it's just reporting a diagnostic figure rather than making a critical gameplay decision. Paying for stronger memory ordering would just buy precision that nobody consumes. -**Renderer frame stats** are populated at the end of every successful `draw_frame` and **retained whole** until the next frame replaces them. A reader on the panel's one-second cadence therefore observes a complete, self-consistent frame rather than a half-updated struct, a snapshot at a point rather than field-by-field sampling. That property is what makes it safe for the panel to run on a cadence unrelated to the render loop. +**Renderer frame stats** are populated at the very end of every successful `draw_frame` and **retained whole** until the next frame replaces them. Because of this, a reader polling on the panel's one-second cadence will always observe a complete, self-consistent frame snapshot rather than a half-updated struct or a field-by-field mix. This property is exactly what makes it safe for the panel to run on a completely independent cadence from the render loop. ## Testing -Formatting and derivation are pure and are tested; live capture is not. +Formatting and derivation logic are pure, so we test them extensively. Live capture, however, is not unit-tested. -- `crates/server/src/tests/tick_stats.rs`: window closing, mean and max derivation, budget utilisation including the zero-period case. -- `crates/renderer/src/tests/stats.rs`: `decode_driver_version` per vendor, and `cull_ratio_percent` including the nothing-uploaded case. -- `crates/client/src/tests/stats.rs`: frame accumulation and panel formatting, including absent optional sources. -- `crates/shared/src/tests/session.rs`: `ServerKind` classification from loopback and non-loopback addresses. +- `crates/server/src/tests/tick_stats.rs`: Tests window closing, mean and max derivation, and budget utilization (including the zero-period edge case). +- `crates/renderer/src/tests/stats.rs`: Tests `decode_driver_version` for each vendor, and `cull_ratio_percent` (including the case where nothing is uploaded). +- `crates/client/src/tests/stats.rs`: Tests frame accumulation and panel formatting, making sure it handles absent optional sources correctly. +- `crates/shared/src/tests/session.rs`: Tests `ServerKind` classification from both loopback and non-loopback addresses. -Vulkan device queries, `sysinfo` host readings, and live QUIC path statistics depend on real hardware and a live connection, and are verified by running the client. +Vulkan device queries, `sysinfo` host readings, and live QUIC path statistics all fundamentally depend on real hardware and a live network connection, so they are verified manually by running the client. diff --git a/docs/meshing.md b/docs/meshing.md index 7b1b336..5fac1e0 100644 --- a/docs/meshing.md +++ b/docs/meshing.md @@ -1,104 +1,108 @@ -# Chunk meshing & visibility +# Chunk meshing and visibility -How a dense voxel chunk becomes drawn triangles: the greedy mesher, the neighbour-awareness it requires, the background worker pool that keeps it off the winit thread, and the frustum cull that decides what is submitted. The mesher and cull live in [`crates/renderer/src/meshing.rs`](../crates/renderer/src/meshing.rs) and [`crates/renderer/src/frustum.rs`](../crates/renderer/src/frustum.rs); the orchestration in [`crates/client/src/mesh_pool.rs`](../crates/client/src/mesh_pool.rs) and [`crates/client/src/chunks.rs`](../crates/client/src/chunks.rs). +This covers how we turn a dense voxel chunk into drawn triangles. It explains the greedy mesher, the neighbour-awareness it needs, the background worker pool that keeps it off the main thread, and the frustum culling that decides what actually gets submitted. -Where chunks *come from* is [`chunk_streaming.md`](chunk_streaming.md); this note picks up once a chunk is resident on the client. +The mesher and cull live in [`crates/renderer/src/meshing.rs`](../crates/renderer/src/meshing.rs) and [`crates/renderer/src/frustum.rs`](../crates/renderer/src/frustum.rs). The orchestration that glues it all together is in [`crates/client/src/mesh_pool.rs`](../crates/client/src/mesh_pool.rs) and [`crates/client/src/chunks.rs`](../crates/client/src/chunks.rs). + +If you want to know where chunks *come from*, check out [`chunk_streaming.md`](chunk_streaming.md). This note picks up the story right after a chunk becomes resident on the client. ## Crate ownership -The mesher lives in `renderer`, not in `client` and not in `shared`. +The mesher lives in the `renderer` crate, not `client` and definitely not `shared`. -It sits next to `renderer::vertex::Vertex` (the module was renamed from `mesh` to free the name) because the mesher's output format *is* the renderer's vertex format. Keeping them in one crate means that format never has to become a cross-crate contract, and it keeps geometry generation out of the crate that `AGENTS.md` wants confined to input, windowing, and presentation glue. The client previously held its own copy; that copy is gone. +We put it right next to `renderer::vertex::Vertex` because the mesher's output format is literally the renderer's vertex format (the module was renamed from `mesh` specifically to free up the name). Keeping them in the same crate means we never have to treat that vertex format as a cross-crate contract. It also keeps geometry generation entirely out of the `client` crate, honoring the architectural rule that `client` should only handle input, windowing, and presentation glue. We used to keep a copy of the mesher in the client, but we deleted it. -Nothing about this makes `renderer` a dependency for drawing alone: `generate_mesh` is a pure CPU function, with no device handles and no GPU state, and it is unit-testable as such. +This setup does not turn `renderer` into a dependency solely for drawing. The `generate_mesh` function is completely pure CPU code; it has no device handles, no GPU state, and we can unit test it exactly as is. ## The greedy mesher -`generate_mesh(chunk, neighbors)` returns `(Vec, Vec)` and is a pure function: no GPU handles, no device state, no I/O. Naively, a solid voxel emits six quads and a chunk emits up to `6 x 32³` of them, almost all interior and immediately hidden. The mesher instead emits the *visible surface*, merged. +The `generate_mesh(chunk, neighbors)` function takes a chunk and returns `(Vec, Vec)`. It is a pure function with no GPU handles, no device state, and no I/O. -Each of the three axes is swept slice by slice. For one slice, a 2D mask of exposed faces is built over the two perpendicular axes, and the mask is then merged into rectangles: a run is extended along the first axis while the key matches, then the run is extended along the second axis while every cell of the candidate row matches. The result is the largest axis-aligned quad available for that key, and a flat plane of one material collapses from thousands of quads to one. +Naively, a solid voxel would emit six quads, meaning a single chunk could emit up to `6 x 32³` quads, almost all of which would be hidden inside the interior. Our mesher avoids this entirely by only emitting the *visible surface*, and merging the quads together. -The half-scale voxel grid ([ADR-0002](adr/0002-half-scale-voxel-grid.md)) makes this load-bearing rather than an optimisation: flat terrain costs roughly 8x the faces of a metre-grid world, and merging collapses exactly the runs that scaling creates. +It sweeps through each of the three axes slice by slice. For a single slice, it builds a 2D mask of exposed faces over the two perpendicular axes, and then merges that mask into rectangles. It extends a run along the first axis as long as the key matches, and then extends along the second axis as long as every single cell of the candidate row matches. The result is the largest axis-aligned quad possible for that specific key. This turns a flat plane of a single material from thousands of individual quads into just one. -**The merge key** (`FaceKey`) is the block id plus the *signed* face direction. The sign is not decoration: a top face and the bottom face of the voxel directly above it are coplanar and share a material, and merging them would fuse two surfaces that face opposite ways and shade differently. `FaceDir` therefore distinguishes `PosY` from `NegY`, and likewise on the other axes. +Because we use a half-scale voxel grid (see [ADR-0002](adr/0002-half-scale-voxel-grid.md)), this greedy merging is absolutely load-bearing, not just a neat optimization. Flat terrain on a half-scale grid costs roughly 8x the faces of a standard metre-grid world, and this merging completely collapses the exact runs that scaling creates. -**Face colour** is currently a function of face direction alone, standing in for lighting until materials land. It is part of the key only implicitly, since direction already is. +**The merge key:** We use a `FaceKey` that combines the block ID with the *signed* face direction. The sign isn't just decoration here. Consider a top face, and the bottom face of the voxel directly above it; they are perfectly coplanar and share a material, but if we merged them, we would fuse two surfaces that face opposite directions and need to shade differently. Therefore, `FaceDir` explicitly distinguishes `PosY` from `NegY`, and does the same for the other axes. -### Vertex extents: a shared convention +**Face colour:** Currently, face colour is determined solely by the face direction, acting as a stand-in for real lighting until our materials system lands. It is implicitly part of the merge key since the direction is already there. -The mesher places block `i` on the interval `[i, i + 1)`: its near face sits at `coord(i)` and its far face at `coord(i + 1)`. This matches the `floor()`-based coordinate-to-block mapping used by the rest of the engine (`position.floor()` yields the block index), so a raycast or cursor highlight that floors a hit point resolves to the same cell the mesher drew. +### Vertex extents (a shared convention) -A chunk's geometry therefore spans `[offset, offset + CHUNK_SIZE]`. The frustum cull builds each chunk's bounding box from the same origin; if the mesher's extents ever change, the cull's box must change with them, or chunks will be culled while still partially on screen (or drawn while fully off it). The coupling is noted at both sites; treat it as an invariant of this file pair. +The mesher places block `i` on the exact interval `[i, i + 1)`. The near face sits exactly at `coord(i)` and the far face at `coord(i + 1)`. This perfectly matches the `floor()`-based coordinate-to-block mapping we use everywhere else in the engine (where `position.floor()` yields the block index). This ensures that a raycast or cursor highlight resolves to the exact same physical cell that the mesher drew. + +Because of this, a chunk's geometry spans exactly `[offset, offset + CHUNK_SIZE]`. The frustum culler builds each chunk's bounding box from this exact same origin. If the mesher's extents ever change, the culler's box must change with them, otherwise chunks will get culled while still partially on screen, or drawn when completely hidden. We've noted this tight coupling at both call sites, so treat it as a hard invariant between these two files. ## Neighbour-aware boundary culling -A face is emitted only when the voxel adjoining it is air. For interior voxels that test is local, but for the `32²` faces on each of a chunk's six sides the adjoining voxel lives in another chunk. `Neighbors<'a>` carries borrowed handles to the six face-adjacent chunks for exactly this test. +We only emit a face when the voxel directly next to it is air. For interior voxels, this test is purely local. But for the `32²` faces on each of the chunk's six boundary sides, the adjoining voxel technically lives in another chunk entirely. This is exactly why we pass `Neighbors<'a>` into the function; it carries borrowed handles to the six face-adjacent chunks so we can check those edges. -An **absent** neighbour (`None`) means "not resident", and the boundary is treated as **exposed**, so its faces are emitted. The alternative, treating absence as solid, would cull those faces and leave visible holes along the load frontier as the player moves. Emitting them costs geometry that will be re-meshed away once the neighbour arrives, which is the correct trade: a transient over-draw beats a transient hole. +If a neighbour is **absent** (`None`), it means the chunk isn't resident yet. When this happens, we treat the boundary as **exposed** and emit the faces. If we treated an absent neighbour as solid, we would cull those faces, leaving massive, visible holes along the chunk border as the player moves around. Yes, emitting them costs extra geometry that will eventually be re-meshed away once the neighbour arrives, but that is the correct trade-off. A transient over-draw is always better than a transient hole in the world. -The consequence is that **a chunk's mesh is a function of seven chunks, not one**. Any change to residency invalidates the meshes of everything adjacent to it, which is what makes the staleness protocol below necessary. It is also why the client retains chunk voxels after uploading geometry: a neighbour arriving later needs this chunk's boundary voxels to re-mesh against. That retention is a real memory cost, recorded as a follow-up in `crates/client/src/chunks.rs`. +The direct consequence of this is that **a chunk's mesh is a function of seven chunks, not one**. Any change to a chunk's residency invalidates the meshes of everything adjacent to it, which is why we need the staleness protocol discussed below. This is also why the client holds onto chunk voxels even after uploading geometry; when a neighbour finally arrives, it needs this chunk's boundary voxels to re-mesh against. That retention is a real memory cost, and we've logged it as a follow-up inside `crates/client/src/chunks.rs`. ## The mesh worker pool -Meshing a chunk is far too expensive to run on the winit thread, so `MeshPool` owns a set of worker threads fed by `crossbeam-channel` (multi-consumer, unlike `std::sync::mpsc`), mirroring the server's generation pool. +Meshing a chunk is far too computationally expensive to run on the winit thread. Instead, `MeshPool` owns a dedicated set of worker threads fed by a `crossbeam-channel`. We use `crossbeam` because it supports multiple consumers, unlike `std::sync::mpsc`, mirroring how the server's generation pool works. -Jobs are **owned snapshots**: a `MeshJob` carries the chunk and its six neighbours as `Arc` handles, so dispatch is a refcount bump rather than a copy of a 64 KiB volume, and the worker borrows nothing from the manager. Neighbours are snapshotted *at dispatch time*, which is precisely the state the resulting mesh will be correct for. +Jobs are submitted as **owned snapshots**. A `MeshJob` carries the chunk and its six neighbours as `Arc` handles, so dispatching a job is just a quick refcount bump rather than copying a full 64 KiB volume. The worker borrows absolutely nothing from the manager. We snapshot the neighbours *at dispatch time*, providing the exact, stable state that the resulting mesh will be correct for. -### Staleness: the generation protocol +### Staleness and the generation protocol -Between dispatching a job for a position and the worker returning it, the world can have moved on: the chunk may have been evicted, or a neighbour may have loaded or dropped, making the in-flight mesh wrong before it arrives. Applying it would upload geometry that does not match the resident voxels. +Between dispatching a job and the worker finally returning the mesh, the world can change. The chunk itself might have been evicted, or a neighbour might have loaded or dropped. If that happens, the in-flight mesh is instantly wrong before it even arrives, and applying it would upload geometry that doesn't match the resident voxels. -Every dispatch is therefore stamped with a `JobGen`, a monotonic token drawn from a single global counter (not one counter per position, so no two dispatches ever share a token). The manager records the latest generation per in-flight position, and a returned mesh is applied only when **both** hold: +To fix this, we stamp every single dispatch with a `JobGen`, which is a monotonic token drawn from a single global counter. We use one global counter, not one per position, to ensure no two dispatches ever share the same token. The manager records the latest generation for every in-flight position, and a returned mesh is only applied if **both** of these conditions hold: -1. the position is still wanted (still resident), and -2. the generation recorded as in-flight for it still equals the mesh's own generation. +1. The position is still wanted (it is still resident). +2. The generation recorded as in-flight for this position perfectly matches the mesh's own generation token. -A missing entry means the position was evicted; a mismatch means a newer job superseded this one. Either way the result is discarded rather than uploaded. This is the same shape as the eviction race in server-side chunk residency: a late arrival is made *harmless* rather than prevented. It generalises, too, because re-dispatch is then free, costing nothing but the superseded worker's wasted effort. +If the entry is missing entirely, the position was evicted. If the tokens mismatch, a newer job has already superseded this one. In both cases, we just discard the result instead of uploading it. This follows the exact same logic as the eviction race in server-side chunk residency: we make late arrivals *harmless* rather than trying to prevent them entirely. This is great because re-dispatching becomes practically free, costing nothing but the superseded worker's wasted effort. -`JobGen::next` wraps rather than panics on overflow. Wrapping requires 2⁶⁴ dispatches in one session, and a collision would additionally require the wrapped-to job to still be outstanding. +`JobGen::next` simply wraps around instead of panicking on overflow. To actually trigger a collision, you would need exactly 2⁶⁴ dispatches in a single play session, and the wrapped-to job would still need to be outstanding when the collision happened. ### Per-frame budgets -`ChunkManager::update` runs three bounded phases per frame, so a burst of deliveries degrades frame *pacing* rather than causing a stall: +The `ChunkManager::update` function runs three bounded phases every frame. This ensures that a massive burst of chunk deliveries only degrades frame *pacing* rather than causing a complete stall: -- **`LOADS_PER_UPDATE`** (4) bounds chunk deliveries materialised per frame. Excess stays queued in the transport and is picked up next frame. Drops are not charged against this budget, since removing a mesh is cheap and delaying it only wastes memory. -- **`MESHES_PER_UPDATE`** (16) bounds mesh jobs dispatched per frame, drained from a pending re-mesh *set*. The set deduplicates: a burst of deliveries re-meshes each affected neighbour once, not once per delivery. The budget exceeds `LOADS_PER_UPDATE` because one delivery can enqueue up to seven jobs, itself plus six neighbours. -- **Ingesting finished meshes is unbounded.** Uploading already-computed geometry is cheap next to computing it, and throttling it would only let completed work pile up. +- **`LOADS_PER_UPDATE` (4):** Bounds how many chunk deliveries we materialize per frame. Any excess just stays queued in the transport layer and gets picked up on the next frame. Drops do not count against this budget because removing a mesh is extremely cheap, and delaying it just wastes memory unnecessarily. +- **`MESHES_PER_UPDATE` (16):** Bounds how many mesh jobs we dispatch per frame. We drain these from a pending re-mesh *set*, which inherently deduplicates work. A burst of deliveries will re-mesh an affected neighbour exactly once, not once per delivery. This budget is much higher than `LOADS_PER_UPDATE` because a single delivery can enqueue up to seven jobs (the chunk itself, plus its six neighbours). +- **Ingesting finished meshes is unbounded.** Uploading geometry that has already been computed is incredibly cheap compared to computing it in the first place, and artificially throttling it would just cause completed work to pile up for no reason. ### The `MeshSink` boundary -The manager never names `Renderer`. It uploads through a `MeshSink` trait (insert, remove), implemented for `renderer::Renderer` in the client. The chunk manager is thus a pure orchestration state machine, testable against a recording fake with no Vulkan device involved, which is what makes the residency, budget, and staleness logic unit-testable at all. See `crates/client/src/tests/chunks.rs`. +The chunk manager never actually names the `Renderer`. Instead, it uploads everything through a `MeshSink` trait (which just requires `insert` and `remove`), implemented for `renderer::Renderer` on the client side. Because of this, the chunk manager is a pure orchestration state machine that we can test against a recording fake without ever touching a Vulkan device. This is precisely what makes our residency, budget, and staleness logic easily unit-testable. Check out `crates/client/src/tests/chunks.rs` to see this in action. ## Frustum culling -Uploaded geometry is not unconditionally drawn. Each frame, `Frustum::from_view_proj` extracts six world-space planes from the combined view-projection matrix (Gribb-Hartmann), and every chunk mesh is tested with `intersects_aabb` before its draw call is recorded. +Just because geometry is uploaded doesn't mean we draw it unconditionally. Every frame, `Frustum::from_view_proj` extracts six world-space planes from the combined view-projection matrix (using the Gribb-Hartmann method), and we test every chunk mesh with `intersects_aabb` before recording its draw call. -Two details are easy to get wrong and are pinned by tests: +There are two specific details here that are easy to mess up, so we pin them tightly with tests: -- **Vulkan depth range.** Clip space here is `[0, 1]`, so the near plane is the third matrix row alone (`r2`), not `r3 + r2` as in OpenGL's `[-1, 1]` convention. The OpenGL form culls geometry directly ahead of the camera. See [`rendering.md`](rendering.md) for the broader clip-space conventions. -- **Row versus column.** `glam` stores matrices column-major while the derivation operates on rows of the combined matrix, so rows are read explicitly. +- **Vulkan depth range:** Clip space in Vulkan is `[0, 1]`. Because of this, the near plane is calculated from the third matrix row alone (`r2`), instead of `r3 + r2` which is the OpenGL `[-1, 1]` convention. If you use the OpenGL form, you will cull geometry directly in front of the camera. We document the broader clip-space conventions in [`rendering.md`](rendering.md). +- **Row versus column:** `glam` stores matrices in column-major order, but our derivation operates on the *rows* of the combined matrix, so we have to read the rows explicitly. -The box test uses the **positive vertex**: for each plane, the box corner farthest along that plane's normal is selected per axis. If even that corner lies behind the plane, the whole box does. The test is conservative, since a box straddling two planes' outsides without being inside the frustum can pass, which is the correct bias for culling: a false *visible* costs a wasted draw, a false *hidden* costs a visible artefact. +For the bounding box test, we use the **positive vertex**. For each plane, we select the box corner that sits farthest along that specific plane's normal per axis. If even that farthest corner sits behind the plane, we know the entire box is behind it. This test is intentionally conservative. A box that straddles the outside of two planes without actually being inside the frustum can sometimes pass the test. This is exactly the bias we want for culling: a false *visible* just costs a slightly wasted draw call, but a false *hidden* results in a glaring visual glitch. -Planes are normalised at construction so plane evaluation returns true signed distances, which keeps the test usable for distance-based decisions (LOD selection) later. +We normalize all planes during construction so that plane evaluation returns true signed distances. This ensures the test remains usable for distance-based decisions (like LOD selection) down the line. ## Debug render modes -The mesher's output is inspected through render modes, layered over two concepts: +We inspect the mesher's output using render modes, which are layered over two distinct concepts: -- **`RasterPass`** is the GPU-level primitive and maps one-to-one onto a compiled pipeline, because polygon mode and depth-compare state are baked into a pipeline and cannot be set by a command. All passes share one pipeline layout and differ only in that state. -- **`RenderMode`** composes passes into what is presented, as an ordered list. `Filled` is one pass; `FilledWireframe` draws the terrain and then overlays edges, keeping the surface readable while showing the size and shape of the quads the greedy mesher actually emitted. +- **`RasterPass`** is a GPU-level primitive that maps exactly one-to-one onto a compiled pipeline. We have to do this because polygon mode and depth-compare state are baked directly into Vulkan pipelines and cannot be set dynamically via a command. All our passes share a single pipeline layout and only differ in that specific baked state. +- **`RenderMode`** composes these passes into an ordered list for presentation. `Filled` is a single pass, whereas `FilledWireframe` draws the solid terrain and then draws the edges over it. This keeps the surface readable while letting us see the exact size and shape of the quads the greedy mesher emitted. -Adding a mode is one variant plus one arm in `RenderMode::passes`, and needs a new pass only if it requires rasterisation state no existing pass provides. `RasterPass::ALL` has its length pinned to `RasterPass::COUNT` at compile time, so a variant that is not listed fails to build rather than silently indexing the wrong pipeline. +To add a new mode, you just add one variant and one arm in `RenderMode::passes`. You only need to create a brand new pass if the mode requires rasterization state that no existing pass currently provides. `RasterPass::ALL` has its length strictly pinned to `RasterPass::COUNT` at compile time, meaning if you forget to list a variant, the code will fail to build rather than silently indexing into the wrong pipeline at runtime. -Debug passes are sized and tinted in the vertex shader rather than through separate geometry, so no extra vertex data is uploaded to support them. The chords that select these modes are documented in `crates/client/src/debug.rs`; they sit behind an F1 modifier so they cannot collide with movement keys. +We size and tint debug passes entirely in the vertex shader rather than generating separate geometry, so we don't need to upload any extra vertex data to support them. You can find the chords that select these modes inside `crates/client/src/debug.rs`; we put them behind an F1 modifier so you don't accidentally trigger them while moving around. ## Testing -The mesher and the frustum are pure algorithmic code, which is where the testing policy in `AGENTS.md` directs effort: +The mesher and the frustum are pure algorithmic code, which fits exactly with the testing policy laid out in `DEVELOPMENT.md`. -- `crates/renderer/src/tests/meshing.rs`: merge behaviour, face-direction keying (opposing coplanar faces must not merge), boundary culling against present and absent neighbours, empty and full chunks. -- `crates/renderer/src/tests/frustum.rs`: plane extraction under the Vulkan depth range, and the AABB test on inside, outside, and straddling boxes. -- `crates/client/src/tests/chunks.rs`: residency, per-frame budgets, and the generation protocol, driven against a `MeshSink` fake. +- `crates/renderer/src/tests/meshing.rs`: Tests merging behavior, face-direction keying (ensuring opposing coplanar faces don't merge), boundary culling against both present and absent neighbours, and handles both empty and full chunks. +- `crates/renderer/src/tests/frustum.rs`: Tests plane extraction under the Vulkan depth range, and verifies the AABB test against inside, outside, and straddling boxes. +- `crates/client/src/tests/chunks.rs`: Tests residency, per-frame budgets, and the generation protocol by driving everything against a `MeshSink` fake. -The Vulkan submission path itself (pipeline creation, command recording, presentation) is verified by running the client, not by unit tests. +We verify the actual Vulkan submission path (pipeline creation, command recording, presentation) by running the client manually, rather than writing brittle unit tests for it. diff --git a/docs/packs.md b/docs/packs.md index b2a104d..6af3cec 100644 --- a/docs/packs.md +++ b/docs/packs.md @@ -1,38 +1,40 @@ -# Data packs & resource packs +# Data packs and resource packs -Two distinct, orthogonal systems. They are kept separate and are not collapsed into one "pack" concept. The decision that data packs register through the modding API rather than a parallel path is recorded in [ADR-0007](adr/0007-declarative-content-via-modding-api.md). +These are two completely separate, orthogonal systems. We deliberately keep them apart rather than collapsing them into a single generic "pack" concept. (The decision to have data packs register through the modding API instead of building a parallel system is documented in [ADR-0007](adr/0007-declarative-content-via-modding-api.md)). ## Resource packs -Client-side asset overlays: textures, sounds, models, fonts, language files. No logic. +Resource packs are strictly client-side asset overlays. They contain textures, sounds, models, fonts, and language files, but absolutely zero logic. -A pack is a directory tree mirroring `/assets/` that overrides files by path. The renderer/asset loader resolves logical asset IDs against a stack of pack roots (base game → installed packs by priority) and the topmost hit wins. Ownership sits with the asset pipeline (in `client`, or a sibling `assets` crate if it grows). Pack authors never touch Lua. +A pack is just a directory tree that mirrors the structure of `/assets/` and overrides files based on their path. When the renderer or asset loader looks for an asset ID, it resolves it against a stack of pack roots (starting from the base game, up through installed packs ordered by priority). The topmost hit wins. Ownership of this system sits entirely with the asset pipeline (currently in `client`, but it might move to a sibling `assets` crate if it grows). Pack authors never need to touch Lua. -A client's own resource packs are a purely local choice; the server has no say over them and they are never part of gameplay modlist matching. The **one** exception is a **server resource pack**: a server may push a single cosmetic overlay of its own (a themed / total-conversion server) to connecting clients. It is a one-way server → client push, applied on top of the client's local stack, and enforced per the server's choice — *optional* packs the client may decline and keep playing, a *required* pack the client declines or fails to fetch rejects the connection. It is still `assets/`-only (no `data/`, no `scripts/`), so it can never affect authoritative state. +A client's choice of resource packs is a purely local decision. The server has no say over them, and they are never checked during multiplayer modlist matching. The **one** exception to this is a **server resource pack**. A server can push a single cosmetic overlay (like a themed or total-conversion server) to connecting clients. This is a one-way push from server to client, applied squarely on top of the client's local stack. The server decides if it's optional (the client can decline and keep playing) or required (if the client declines or the fetch fails, the connection is rejected). Crucially, a server resource pack is still strictly `assets/`-only; it cannot contain `data/` or `scripts/`, ensuring it can never accidentally affect authoritative gameplay state. ## Data packs -Declarative content definitions in JSON (or TOML/RON, TBD): blocks, items, recipes, loot tables, biomes, tags. +Data packs handle declarative content definitions using JSON (though we might evaluate TOML or RON later). They define things like blocks, items, recipes, loot tables, biomes, and tags. -No parallel registration system is built. The loader reads the declarative files and calls the same Lua API the engine and Lua mods use. One source of truth: +We intentionally did not build a parallel registration system for this. The loader simply parses the declarative files and calls the exact same Lua API that the engine and Lua mods use. This gives us one single source of truth: ``` data/blocks/stone.json → loader → blocks.register{ id = "stone", ... } ``` -The loader belongs in `scripting` (or a sibling crate if it grows). Every data-pack schema is a stable contract, the same as the Lua API, version it deliberately. +The loader logic belongs in `scripting` (or a sibling crate if it gets too large). Because they interface with the API, every data-pack schema is treated as a stable contract and versioned deliberately. -**Declarative-first (ADR-0007):** JSON and Lua are not free alternatives. Anything expressible as data — the static fields of a block, item, recipe, loot table, biome, or tag — is authored as data in `data/`; Lua is reserved for behavior (logic that runs on an event or tick). A pure-data block therefore needs no Lua, and a data pack can register such a primitive on its own; only its behavior half (if any) comes from a Lua mod. Engine first-party content follows the same rule, keeping pure-data `core` content in `data/` and only behavioral systems in `scripts/`. To avoid hand-authoring large volumes of near-identical files, modders may use **datagen**: code that emits `data/` files at build time, before the pack ships — its output, not its code, is the shipped artifact, and it never runs at load time. +**Declarative-first approach (ADR-0007):** JSON and Lua are not meant to be interchangeable options. Anything that can be expressed purely as data (like the static fields of a block, item, recipe, loot table, biome, or tag) must be authored as data inside `data/`. We reserve Lua strictly for behavior, meaning logic that runs on an event or a tick. Because of this, a pure-data block needs absolutely zero Lua, and a data pack can register it entirely on its own. Only the behavioral half (if the block actually has any) comes from a Lua mod. + +Our first-party engine content follows this exact same rule: we keep all pure-data `core` content in `data/`, and only behavioral systems live in `scripts/`. If a modder wants to avoid hand-authoring a massive amount of near-identical JSON files, they can use **datagen**. Datagen is code that emits `data/` files at build time before the pack ships. The final generated files are the shipped artifact, while the generator code itself never runs at game load time. ## Canonical load order -Later layers override earlier ones: +Later layers always override earlier ones: ``` base game (assets/scripts + assets/data) - → data packs (declarative content add/override) - → Lua mods (full API access) - → resource packs (client-only, asset overlay, always last so visuals win) + → data packs (adds/overrides declarative content) + → Lua mods (has full API access) + → resource packs (client-only asset overlays, loaded last so they dictate the final visuals) ``` ## Repo layout @@ -47,7 +49,7 @@ base game (assets/scripts + assets/data) ## User-data layout -Runtime, resolved via the `directories` / `dirs` crate: +This is resolved at runtime using the `directories` (or `dirs`) crate: ``` / diff --git a/docs/rendering.md b/docs/rendering.md index ba8ccb7..de39739 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -1,21 +1,27 @@ -# Rendering & coordinate conventions +# Rendering and coordinate conventions -Implementation notes for the `renderer` crate and for code that imports geometry. The project-wide coordinate convention itself (+Y up, right-handed, 1 unit = 1 block) is stated in [`AGENTS.md`](../AGENTS.md#coordinate-system--units); this note collects the gotchas that arise because neighbouring systems use different conventions. These are not convention changes, only mismatches to handle in one agreed place. +This document covers implementation notes for the `renderer` crate and any code that imports geometry. The project-wide coordinate convention (+Y up, right-handed, 1 unit = 1 block) is strictly defined in [`DEVELOPMENT.md`](../DEVELOPMENT.md#coordinate-system--units). This note just collects the common gotchas that pop up because neighboring systems and tools use different conventions. To be clear, these are not convention changes for our engine, just necessary translations we have to handle in one agreed-upon place. ## Shader compilation -GLSL sources under `assets/shaders/` are compiled to SPIR-V by the `renderer` crate's build script and embedded from `OUT_DIR`; no compiled module is committed. Building the crate therefore requires `libshaderc`, either as a distribution package (`libshaderc-dev` on Debian and Ubuntu, `shaderc` on Arch, the Vulkan SDK on Windows) or, failing that, a C++ toolchain with cmake and ninja so `shaderc-sys` can build the library from source. +We compile GLSL sources located under `assets/shaders/` to SPIR-V using the `renderer` crate's build script. The compiled output is embedded directly from the `OUT_DIR`; we intentionally do not commit any compiled modules to the repository. -A shader that fails to compile aborts the build, naming the source file and the offending line. +Because of this, building the crate requires `libshaderc`. You can get this as a distribution package (`libshaderc-dev` on Debian and Ubuntu, `shaderc` on Arch, or via the Vulkan SDK on Windows). If you don't have it installed, the build script will fall back to using a C++ toolchain with cmake and ninja so `shaderc-sys` can build the library from source. + +If a shader fails to compile, the build aborts immediately, and the error will explicitly name the source file and the offending line. ## Vulkan clip space -Vulkan clip space is **Y-down** by default, and its depth range is `[0, 1]` (not `[-1, 1]` as in OpenGL). The projection matrix must flip Y, or the viewport height is set negative, both are common idioms in `ash` examples. World and view space stay Y-up; only clip space differs. +Vulkan clip space is **Y-down** by default, and its depth range is strictly `[0, 1]`. This is completely different from OpenGL, which uses `[-1, 1]`. + +Because our world space and view space stay Y-up, you have to flip Y when moving into clip space. You can do this by having the projection matrix flip Y, or by setting the viewport height to a negative value. Both are common idioms in `ash` examples. ## Blender import -Blender is **Z-up, right-handed**. Models exported from Blender need a coordinate swap on import: rotate −90° around X, or swap Y/Z with a sign change. Decide once where that swap happens, at export, at import, or never (by adopting the source convention), and keep it in a single place. Performing it in two places eventually produces a model that is mirrored or upside-down. +Blender natively uses a **Z-up, right-handed** coordinate system. + +When you export models from Blender, they require a coordinate swap on import: you have to either rotate them −90° around the X-axis, or swap the Y and Z axes while applying a sign change. The hard rule here is to decide *exactly once* where that swap happens (at export, at import, or never, by simply adopting the source convention) and keep it in a single place. If you accidentally perform the swap in two places, you will eventually end up with a model that is mirrored or rendered completely upside-down. ## glTF import -glTF is **Y-up, right-handed**, which matches the engine convention. It is therefore the most friction-free model format when there is a choice. +The glTF format is natively **Y-up, right-handed**, which perfectly matches our engine's convention. Because of this, it is easily the most friction-free model format to use whenever you have a choice. diff --git a/docs/save_format.md b/docs/save_format.md index 6464ff5..cdc502d 100644 --- a/docs/save_format.md +++ b/docs/save_format.md @@ -1,42 +1,46 @@ # Save format -How modified chunks are framed, stored, and read back from disk. This note describes the implementation. +This document explains how modified chunks are framed, stored, and read back from disk. -The pure, in-memory framing (the `SYNR` region index and `SYNC` chunk records) lives in [`crates/shared/src/save/`](../crates/shared/src/save/). The filesystem side — reading a region file, mutating its chunks, and flushing it back crash-safely — lives in [`crates/server/src/save/`](../crates/server/src/save/). The runtime load path that turns a `ChunkPos` into a resident chunk lives in [`crates/server/src/world_server.rs`](../crates/server/src/world_server.rs) and is driven by the streaming reconcile loop documented in [`chunk_streaming.md`](chunk_streaming.md). +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 -Only chunks that diverge from their deterministic worldgen baseline are stored; the rationale is recorded in [ADR-0009](adr/0009-baseline-relative-sparse-chunk-persistence.md). A modified chunk is a [`ChunkData`](../crates/shared/src/world/chunk_data.rs): the chunk position, the `worldgen_version` its baseline is pinned to, and a sparse `local_index → BlockId` edit map. An unmodified chunk stores no voxel data and is absent from its region file. +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 -Voxel storage is partitioned into **region files**, each covering a 32×32 grid of chunk columns in the XZ plane (the grid is 2D; Y is not partitioned). A chunk's region is `(cx.div_euclid(32), cz.div_euclid(32))` — `div_euclid`, not truncating division, so negative columns floor toward negative infinity rather than toward zero. All multi-byte integers are little-endian. +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 is a `SYNR` index followed by the `SYNC` records the index points at. +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)): magic tag, framing version, and three side tables — a **header table** (`ChunkPos → offset+length+flags` for every resident record), a **free list** (reclaimable spans left by removed or shrunken records), and a **stamp table** (per-chunk `worldgen_version` exceptions; the region pins a `base_worldgen_version` and stores only chunks that differ from it). The header and stamp tables are `BTreeMap`s so their serialization order is deterministic. -- **`SYNC` chunk record** ([`record.rs`](../crates/shared/src/save/record.rs)): a fixed header (magic, chunk-format version, flags, `last_modified` timestamp in unix-ms, and the compressed and uncompressed payload lengths) followed by a zstd-compressed, postcard-serialized `ChunkData`. The header is never compressed, so a repair tool can read framing without decompressing. Compression is zstd level 3, favouring speed. +- **`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 -[`RegionFile`](../crates/server/src/save/region_file.rs) reads a region file into memory, mutates its chunks (`write_chunk`, `remove_chunk`), and flushes it back. The flush strategy is a **whole-file atomic rewrite**: the complete file image is serialized, written to a `.tmp` sibling, fsynced, renamed over the target, and the containing directory is fsynced. This is a simpler alternative to an incremental append-plus-header-rewrite scheme; the deviation is noted at the `serialize` site and tracked for revision as follow-on work. The on-disk format is unchanged, so the switch requires no migration (the free list and absolute record offsets already support it). +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. -## Concurrency: the save actor +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. -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 open `RegionFile`s and is their sole owner, so no region file needs a lock of its own. Worker threads never touch a region file directly; they hold cloned senders on the actor's request channel and communicate by message. This is the message-passing-over-shared-state concurrency stance from `AGENTS.md` applied to persistence: one queue thread serializes all region I/O, keeping it off both the simulation tick and the worker pool. A region file is opened on first access and its contents are served from memory thereafter. +## 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 -A load of `ChunkPos` runs on the worker pool (off the tick thread), in [`world_server.rs`](../crates/server/src/world_server.rs) `load_chunk`: +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 position. -2. **Hit** (`Some(ChunkData)`): the baseline is regenerated (via the LRU baseline cache) and the stored diff is materialized over it. -3. **Miss** (`None`): the chunk was never modified, so the regenerated baseline is the chunk. -4. **Save-layer error**: streaming must not wedge, so the chunk falls back to a fresh baseline and the error is logged. +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. -The regenerated baseline currently uses the current worldgen version rather than the record's stored `worldgen_version`; while a single version exists these coincide. Honouring the stored version on both load and write-back is follow-on work. +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): seed-deterministic worldgen — the invariant that makes regen-on-load sound. -- [ADR-0009](adr/0009-baseline-relative-sparse-chunk-persistence.md): baseline-relative sparse persistence — why only diffs are stored. +- [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.