Compare commits
10 commits
4b712f2a70
...
cae434d227
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cae434d227 | ||
|
|
7d7c1cfed4 | ||
|
|
17517ec715 | ||
|
|
5a494f03fd | ||
|
|
7cc43ca05e | ||
|
|
213a113fc9 | ||
|
|
7ab2488778 | ||
|
|
10b035504c | ||
|
|
51b6b1e5e8 | ||
|
|
d68cb966f7 |
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -25,6 +25,9 @@ graphify-out/
|
||||||
# tokensave MCP local tooling state (per-machine code-graph database and config)
|
# tokensave MCP local tooling state (per-machine code-graph database and config)
|
||||||
.tokensave/
|
.tokensave/
|
||||||
|
|
||||||
|
# headroom MCP local tooling state (per-machine context-compression marker)
|
||||||
|
.claude/.headroom_wrap_marker.json
|
||||||
|
|
||||||
# Machine-local files
|
# Machine-local files
|
||||||
*.local.*
|
*.local.*
|
||||||
tmp/
|
tmp/
|
||||||
|
|
|
||||||
328
AGENTS.md
328
AGENTS.md
|
|
@ -1,223 +1,109 @@
|
||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
Guidance for AI coding agents (and human contributors) working in this repository. This file is the single source of truth for architecture, conventions, and workflow. Tool-specific entry points (e.g. `CLAUDE.md`) import it rather than duplicating it.
|
**CRITICAL:** This file contains the engineering context for AI coding agents.
|
||||||
|
|
||||||
## Project goal
|
## 🚨 Absolute Rules (Never Break These) / Do Not
|
||||||
|
- **DO NOT** bypass the Lua scripting layer for gameplay logic.
|
||||||
Voxel-based game with souls-like combat. Built in Rust; rendering targets Vulkan via [`ash`](https://github.com/ash-rs/ash) (raw Vulkan bindings, not a higher-level wrapper like `wgpu` or `vulkano`).
|
- **DO NOT** use `unwrap()` or `expect()` outside `main` or tests.
|
||||||
|
- **DO NOT** use `println!`; use `tracing`.
|
||||||
World is procedurally generated. Voxel edge length is 0.5 m, so the player occupies **3 blocks tall × 2 blocks wide**. This finer grid is load-bearing for design decisions: collision, mesh chunking, LOD thresholds, and network bandwidth all need to assume ~8× the voxel count of a 1 m-grid world per unit volume, so pick chunk sizes and data layouts accordingly. The rationale and trade-offs of this scale are recorded in [ADR-0002](docs/adr/0002-half-scale-voxel-grid.md). Supports both single-player and multiplayer via a dedicated server. That dual mode is why `server` exists as its own crate even for solo play (the single-player path is expected to run the server logic in-process or invoke the same crate, rather than having a separate offline code path).
|
- **DO NOT** introduce non-deterministic logic into worldgen (no `thread_rng`, no `HashMap` iteration).
|
||||||
|
- **DO NOT** build parallel registration systems or duplicate registries.
|
||||||
## Documentation map
|
|
||||||
|
## 📖 What to Read
|
||||||
Documentation is layered by altitude; keep content at the layer it belongs to so no single file accretes everything.
|
- **Read `AGENTS.md` (this file)** first for every task.
|
||||||
|
- **Read `DEVELOPMENT.md`** before making any architectural or cross-cutting changes.
|
||||||
- **This file (`AGENTS.md`)**: cross-cutting engineering conventions and architecture invariants, i.e. the rules that apply regardless of which feature is being touched. There is a finite set of these, so this file should grow slowly. Subsystem-specific specs do **not** belong here.
|
- **Read subsystem docs (`docs/` and rustdoc)** only for the specific crates you are modifying.
|
||||||
- **[`docs/`](docs/) + Rust module docs (`//!`, `///`)**: per-subsystem technical implementation docs. How an individual system (meshing, networking, worldgen, ...) is built. Prefer module docs next to the code; promote to a `docs/<subsystem>.md` note when the design spans multiple files.
|
|
||||||
- **[`docs/adr/`](docs/adr/)**: Architecture Decision Records capturing the *why* behind significant, hard-to-reverse choices, one append-only file per decision. See [`docs/README.md`](docs/README.md) for the full scheme and [`docs/adr/0001-record-architecture-decisions.md`](docs/adr/0001-record-architecture-decisions.md) for the practice.
|
## ✅ Before Changing Code
|
||||||
|
- Does this require updates to documentation or ADRs (`docs/adr/`)?
|
||||||
The canonical game-*design* specification (intent, world rules, gameplay behaviour) is maintained separately and is not part of this repository; this repo documents how that design is implemented.
|
- Which crate does this belong to? (Maintain strict boundaries).
|
||||||
|
- Is this a new gameplay feature? If so, it must be exposed via the Lua API.
|
||||||
## Workspace layout
|
|
||||||
|
## 🛠️ Modification Priorities
|
||||||
Cargo workspace (resolver = "3", edition 2024) with six crates under `crates/`:
|
1. **Implement the feature in Lua** if possible, using existing APIs.
|
||||||
|
2. **Extend the Lua API** if it lacks the required capability.
|
||||||
- `client`: binary. Windowed application using `winit` 0.30 (`ApplicationHandler` pattern, `ControlFlow::Poll`). Also pulls in `image`. Player-facing app titled "Synvael"; handles input, windowing, and drives the renderer.
|
3. **Modify Rust internals** only as a last resort to support the Lua API.
|
||||||
- `server`: binary. Authoritative game simulation (voxel world, combat, players). Used both for dedicated multiplayer hosts and as the simulation backend for single-player.
|
Avoid bypassing the scripting layer entirely for gameplay features.
|
||||||
- `renderer`: library. Voxel/scene rendering on Vulkan via `ash`, decoupled from windowing so it can be driven by `client`.
|
|
||||||
- `shared`: library. Types and protocol shared between `client` and `server` (world/voxel data, network messages, combat primitives). Stays lean and dep-light; no `mlua`, no rendering, no engine internals.
|
## 🏗️ Code Style & Edits
|
||||||
- `scripting`: library. Lua modding API and bindings (owns the `mlua` dependency, `UserData` wrappers around `shared` types, API table registration, mod loader). Both `client` and `server` depend on it.
|
- Prefer modifying existing systems over creating new abstractions.
|
||||||
- `net`: library. QUIC transport, connection lifecycle, and wire framing for the client↔server protocol; owns the async runtime (`tokio`) and the `quinn`/`rustls` dependencies. Both `client` and `server` depend on it. See [ADR-0010](docs/adr/0010-net-crate-async-runtime.md).
|
- Avoid duplicate registries, parallel APIs, unnecessary traits, and premature generic abstractions.
|
||||||
|
- Keep changes local to the relevant module unless the architecture requires otherwise.
|
||||||
When adding code, keep the boundary tight: protocol/data types and game-rule primitives go in `shared`; Lua API surface and `mlua` integration in `scripting`; GPU/draw code in `renderer`; transport and connection code in `net` (protocol message *types* stay in `shared`); only input, windowing, and presentation glue live in `client`. Avoid growing `client` with simulation logic since it must work identically against either a local or remote `server`.
|
|
||||||
|
## ❓ When Unsure
|
||||||
## Modding API (Lua): dogfooded
|
If an implementation conflicts with these rules, **prefer preserving the architecture over minimizing code changes.**
|
||||||
|
|
||||||
The game exposes a Lua modding API, and **the base game itself is built on top of that same API** rather than treating it as a separate add-on layer. Built-in content (blocks, items, entities, recipes, etc.) is defined through the modding API so that mod authors can read the shipped code as reference for what's possible and how to do it.
|
## 📦 Architectural Dependency Rules
|
||||||
|
- `shared` stays lean and dependency-light (no `mlua`, no rendering).
|
||||||
This has hard implications when adding features:
|
- `scripting` depends on `shared`, but `shared` does **not** depend on `scripting`.
|
||||||
|
- `client` and `server` depend on `shared`, `scripting`, and `net`.
|
||||||
- Any new gameplay primitive (a new block type, item, entity, ability, ...) needs to be reachable through the Lua API, not just a Rust-only path. If you add a Rust-side concept without an API surface, you've broken the dogfooding contract.
|
- `client` depends on `renderer`, but `server` does not.
|
||||||
- Prefer extending the API and then *using* it from the engine over adding a parallel Rust-only entry point.
|
- **Do not** put simulation logic in `client`.
|
||||||
- Keep the API stable and discoverable, since mod authors will be reading it. Avoid leaking engine internals through it.
|
|
||||||
- The API and its bindings live in the **`scripting`** crate. It owns the `mlua` dependency, the API table registration, and the mod loader. Both `client` and `server` depend on it; `shared` does **not**, since `shared` stays the lean protocol/data layer.
|
## 🔍 File Location Hints
|
||||||
- Authoritative APIs (world mutation, combat resolution) are defined in `scripting` but gated so the client-side Lua VM can't invoke them. One API surface, two execution contexts: client VM = read-only/UI/effects, server VM = authoritative.
|
- `/assets/scripts/`: Shipped base game Lua scripts.
|
||||||
- Prefer wrapper newtypes inside `scripting` over `impl UserData for SharedType` in `shared`, to avoid coupling the protocol crate to `mlua`.
|
- `/mods/`: In-repo example mods/test fixtures.
|
||||||
|
- `<user-data-dir>/mods/`: Player-installed mods (resolved at runtime).
|
||||||
The decision to build the base game on top of the modding API, and the client/server VM gating that follows from it, are recorded in [ADR-0006](docs/adr/0006-base-game-on-modding-api.md).
|
- `crates/client/`: Windowing, input, presentation.
|
||||||
|
- `crates/server/`: Authoritative simulation.
|
||||||
## Assets
|
- `crates/shared/`: Core types, network protocols.
|
||||||
|
- `crates/scripting/`: Lua API bindings.
|
||||||
All game assets live under `/assets` at the repo root, organised into subfolders by kind: `icons/`, `models/`, `shaders/`, `sounds/`, `textures/`, `scripts/`. New assets must be placed in the matching subfolder; do not drop loose files into `/assets` itself, and do not scatter assets inside crate directories.
|
- `crates/renderer/`: Vulkan graphics.
|
||||||
|
- `crates/net/`: QUIC networking.
|
||||||
Assets are published openly under CC-BY-NC-SA 4.0 (see `LICENSE.md`). Binary assets (textures, models, sounds, compiled shaders) are tracked via **Git LFS**; keep `.gitattributes` up to date when adding a new binary file type. Lua scripts and JSON data live in plain Git (text files).
|
|
||||||
|
## ⚙️ Basic Verification Commands
|
||||||
## Script locations
|
- Check compilation: `cargo check -p <crate>`
|
||||||
|
- Lint code: `cargo clippy --all-targets --all-features -- -D warnings`
|
||||||
Three distinct locations, do not mix them:
|
- Format Rust: `cargo fmt --all -- --check`
|
||||||
|
- Format Lua: `stylua .` and `selene .`
|
||||||
- **`/assets/scripts/`**: the base game's own Lua, shipped with the binary. This is the dogfooded "first-party mod" the engine loads through the same API mod authors use. Mirror the structure modders will use (e.g. `scripts/blocks/`, `scripts/items/`, `scripts/entities/`) so it serves as a working reference.
|
- Run tests: `cargo test -p <crate>`
|
||||||
- **`/mods/`** (top-level): in-repo example mods or test fixtures. Kept out of `/assets/` because they're not engine-shipped content, and out of `crates/` because they're not Rust source.
|
|
||||||
- **`<user-data-dir>/mods/`**: player-installed mods, loaded at runtime only. Resolved via the `directories` / `dirs` crate (Linux: `~/.local/share/synvael/mods/`, with platform equivalents elsewhere). Never read from a hard-coded path.
|
## 🚀 Quick Start Task Checklist
|
||||||
|
- [ ] Review "What to Read" and "Before Changing Code".
|
||||||
## Data packs & resource packs
|
- [ ] Check modification priorities (Lua vs Rust).
|
||||||
|
- [ ] Make edits following code style guidelines.
|
||||||
Two distinct, orthogonal systems; keep them separate, do not collapse them into one "pack" concept. **Resource packs** are client-side asset overlays (textures, sounds, models, fonts, language files; no logic). **Data packs** are declarative content definitions (JSON/TOML/RON): blocks, items, recipes, loot tables, biomes, tags.
|
- [ ] Run basic verification commands.
|
||||||
|
- [ ] Commit using Conventional Commits.
|
||||||
The load-bearing rule: **do not build a parallel registration system.** The data-pack loader reads the declarative files and calls the same Lua API the engine and Lua mods use, giving one source of truth (`data/blocks/stone.json` → loader → `blocks.register{ ... }`). Each schema is a stable contract; version it deliberately. The decision is recorded in [ADR-0007](docs/adr/0007-declarative-content-via-modding-api.md).
|
|
||||||
|
---
|
||||||
Full subsystem detail (load order, repo and user-data layout, resolution semantics) lives in [`docs/packs.md`](docs/packs.md).
|
|
||||||
|
### Additional Subsystem Context
|
||||||
## Contributing workflow
|
|
||||||
|
**Concurrency & State**
|
||||||
Before committing a change, verify it against the actual repo state rather than assuming it is correct: read the files, inspect `git diff`, and run `cargo check` / `cargo clippy` / `cargo test` as appropriate. Then follow this loop for each change:
|
- **Multithreaded:** Prefer message-passing and per-thread ownership over shared mutable state. Avoid large `Mutex` wrappers.
|
||||||
|
- **Vulkan queues:** Not free-threaded.
|
||||||
1. **Verify** the change is actually present and correct in the working tree.
|
- **Lua VMs:** Not thread-safe. Treat each VM as owned by a single thread.
|
||||||
2. **Run the linter and formatter** to ensure no regressions or style issues were introduced: `cargo clippy --all-targets --all-features -- -D warnings`, `cargo fmt --all -- --check`, `selene .`, and `stylua .`.
|
|
||||||
3. **Ensure useful comments are present** before committing: function doc comments (`///`) and inline comments above non-obvious logic, following the documentation style below.
|
**Determinism**
|
||||||
4. **Create a focused git commit** following the commit conventions below.
|
- **Worldgen:** Strictly seed-deterministic. Use fixed RNG algorithms (`wyrand`, `xoshiro`). Never use `rand::thread_rng()`. Do not rely on `HashMap` iteration order (use `BTreeMap` or `IndexMap`).
|
||||||
|
- **Simulation:** Server-authoritative but not lockstep. Platform-specific math and floats are permitted outside of worldgen.
|
||||||
Keep commits scoped to a single concept. Do not batch multiple unrelated changes into one commit, and do not leave a verified change uncommitted before moving on to the next.
|
|
||||||
|
**Logging & Error Handling**
|
||||||
## Concurrency model
|
- **Logging:** Use `tracing` and spans (`#[tracing::instrument]`). No `println!`.
|
||||||
|
- **Libraries (`shared`, `renderer`, `scripting`):** Use `thiserror`.
|
||||||
The game is **multithreaded by design**: single-threaded would not meet the perf budget for voxel meshing, worldgen, rendering, networking, and simulation running together. Code should assume multiple threads and design data ownership accordingly:
|
- **Binaries (`client`, `server`):** Use `anyhow`.
|
||||||
|
|
||||||
- Prefer message-passing (channels: `crossbeam-channel`, `flume`, or `std::sync::mpsc`) and per-thread ownership over shared mutable state.
|
**Testing Expectations**
|
||||||
- When sharing is unavoidable, use the right primitive for the access pattern: `Arc<Mutex<_>>` for low-contention shared state, `Arc<RwLock<_>>` for read-heavy, atomics (`AtomicU32`, `AtomicBool`, ...) for counters and flags, lock-free structures (`crossbeam`, `dashmap`) for hot paths. Avoid wrapping large hot data in a single `Mutex` "just in case", which is how you accidentally serialise the whole engine.
|
- Test pure algorithmic logic, correctness traps (e.g. integer overflow, div_euclid), and determinism (worldgen).
|
||||||
- Worldgen and chunk meshing are the obvious parallelism wins. A thread pool (e.g. `rayon`, or a hand-rolled one) feeding meshing/generation jobs is expected.
|
- I/O and GPU code are tested via integration/visual verification.
|
||||||
- Vulkan command-buffer recording can be parallelised too, but Vulkan **queues** are not free-threaded: only one thread submits to a given queue at a time. Plan ownership of `vk::Queue` accordingly.
|
- Ensure new tests run successfully and do not break existing ones.
|
||||||
- The Lua VMs (one per execution context: client, server) are **not** thread-safe in `mlua`'s default config; treat each VM as owned by a single thread, and dispatch work to/from it via channels.
|
|
||||||
|
**Linting**
|
||||||
## Logging & error handling
|
- The workspace uses strict lints (including banning `unwrap`, `expect`, `print`).
|
||||||
|
- Prefer `#[expect(...)]` over `#[allow(...)]`. Suppress narrowly and justify non-obvious suppressions. Never suppress `correctness` lints.
|
||||||
- **Logging:** [`tracing`](https://docs.rs/tracing/) (with `tracing-subscriber` as the output backend). Use `info!` / `warn!` / `error!` / `debug!` / `trace!` macros at appropriate levels, and use **spans** (`#[tracing::instrument]`, `info_span!`) to scope work, which is how you keep multithreaded log output legible. Don't reach for `println!`/`eprintln!` for diagnostics; if it's worth printing, it's worth a `tracing` event.
|
|
||||||
- **Errors in libraries** (`shared`, `renderer`, `scripting`): typed error enums via [`thiserror`](https://docs.rs/thiserror/) (`#[derive(Error)]`). Each variant is a distinct, matchable failure mode. Don't expose `anyhow::Error` from a library API.
|
**Documentation Style**
|
||||||
- **Errors in binaries** (`client`, `server`): [`anyhow`](https://docs.rs/anyhow/) at the top level, with `.context("...")` for human-readable layering. Library errors compose into `anyhow::Error` cleanly via `?`.
|
- Formal, objective tone. No "we" or "you".
|
||||||
- **Never `.unwrap()` or `.expect()` outside `main` / setup / tests**, except where the invariant is genuinely impossible to violate. In the hot path, propagate with `?` and let the caller decide.
|
- All public/internal struct fields need `///` docs.
|
||||||
|
- Functions require `# Errors`, `# Panics`, and `# Safety` sections in that order.
|
||||||
## Testing policy
|
|
||||||
|
**Branching Strategy**
|
||||||
Tests are prioritised by risk, not by a coverage percentage. Effort is directed where "compiles and appears correct" does not guarantee correctness. The following categories require accompanying unit tests, written in the same change that introduces or modifies the logic:
|
- **Development branch:** `dev`.
|
||||||
|
- **Releases branch:** `main`.
|
||||||
- **Pure, algorithmic logic** — values in, values out, no I/O, no GPU, no windowing: coordinate and index math, packing/unpacking, meshing math, and similar self-contained computation. These are cheap to test and their edges are easy to get subtly wrong.
|
- **Large features:** Feature branch off `dev` (e.g., `feat/new-worldgen`).
|
||||||
- **Correctness traps** — behaviour where a plausible implementation is silently wrong on an edge case: sign handling, off-by-one, integer overflow or truncation, bit-packing boundaries. As a canonical example, world-to-chunk conversion floors via `div_euclid` rather than truncating via `/`; a test on negative inputs pins that contract and prevents a regression to `/`.
|
|
||||||
- **Load-bearing invariants, especially determinism** — per the determinism stance below, worldgen is seed-deterministic and bit-for-bit reproducible. That contract cannot be verified by inspection and is guarded by tests (for example, generating a chunk twice from one seed and asserting equality). Determinism is guarded aggressively.
|
**General Coding Standards**
|
||||||
|
- **Content IDs:** Strict `"namespace:id"` format (e.g. `"core:stone"`). Interned to handles at runtime.
|
||||||
Subsystems that are I/O- or hardware-bound — the `renderer`/Vulkan GPU paths, `client` windowing and input, and top-level binary wiring — are validated through integration and manual/visual verification rather than unit tests, since their behaviour depends on a live device, window, or process rather than on pure logic. The appropriate mechanism differs; the expectation of verification does not.
|
- **Coordinate system:** +Y up, right-handed. 1 unit = 1 block (0.5m).
|
||||||
|
- **Paths:** Linux/Windows only. Use `std::path::Path` and `directories` crate. No hard-coded `/home`.
|
||||||
Unit tests live beside the code (`#[cfg(test)] mod tests`) and run with `cargo test -p <crate>`.
|
- **Commits:** Conventional Commits with crate scope (e.g., `feat(scripting): ...`).
|
||||||
|
|
||||||
## Lint suppressions
|
|
||||||
|
|
||||||
The workspace opts into strict linting: Clippy's `pedantic` group plus restriction lints that ban `unwrap`, `expect`, and `print` outside the permitted contexts (see `[workspace.lints]` in the root `Cargo.toml`). Suppressions are therefore expected at specific sites, and are governed by these rules:
|
|
||||||
|
|
||||||
- **Prefer `#[expect(...)]` over `#[allow(...)]`** for a localised suppression. An `#[expect]` becomes a warning (`unfulfilled_lint_expectations`) if the lint it names no longer fires, so an obsolete suppression surfaces and is removed instead of lingering silently. `#[allow]` never self-reports and accumulates into dead noise.
|
|
||||||
- **Suppress narrowly.** Name the exact lint(s), and attach the attribute to the smallest scope that covers the site — a statement, expression, or item — never a broad crate-level `#![allow]`. The sole standing exception is a deliberate crate-wide policy, such as `#![allow(unsafe_code)]` in `renderer`, where the suppression is the architectural intent rather than a local waiver.
|
|
||||||
- **Justify non-obvious suppressions.** Where the reason a lint is safe to suppress is not self-evident from the surrounding code, precede the attribute with a brief comment stating why (for example, that a cast is provably in range).
|
|
||||||
- **Never suppress `correctness`-tier lints.** Those indicate real defects; fix the code instead.
|
|
||||||
|
|
||||||
## Documentation style
|
|
||||||
|
|
||||||
- **Objective Tone:** All comments (both doc comments `///` and inline `//`) must be written in a formal, objective, and neutral tone.
|
|
||||||
- **No Personal Pronouns:** Avoid first-person ("we", "our", "us") or second-person ("you", "your") pronouns.
|
|
||||||
- **Voice:** Use the passive voice or neutral descriptive language. Instead of "We initialize the buffer," use "The buffer is initialized." Instead of "Your vertex shader needs this," use "The vertex shader requires this."
|
|
||||||
- **Focus:** Describe the code's behavior, the system's state, or technical invariants.
|
|
||||||
- **Struct Documentation:** Every field in a public or internal struct must have a doc comment (`///`) explaining its purpose and any invariants.
|
|
||||||
- **Function documentation sections:** Function doc comments follow the [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/documentation.html) standard sections, in this fixed order after the summary and any extended description: `# Errors`, then `# Panics`, then `# Safety`. The sections apply to **all** functions, public and private (clippy only enforces the public ones; the same standard is expected on private helpers by hand).
|
|
||||||
- **`# Errors`** is mandatory on every function returning `Result`, and states the conditions under which each error variant is returned. `fn main` is exempt.
|
|
||||||
- **`# Panics`** is mandatory on any function that can panic (an `expect`/`unwrap`/`panic!`/`assert!`/indexing/arithmetic that can trip), and states the condition that triggers the panic.
|
|
||||||
- **`# Safety`** is mandatory on every `unsafe fn`, and states the invariants the caller must uphold.
|
|
||||||
- Test functions (`#[test]`, and helpers inside `#[cfg(test)]`) are exempt from all three; they are not part of the documented surface.
|
|
||||||
- Enforcement: `missing_errors_doc`, `missing_panics_doc`, and `missing_safety_doc` are warnings in the workspace lint set, so a missing section on a public item fails CI.
|
|
||||||
- **Stability:** Treat the documentation as a technical specification for the engine.
|
|
||||||
- **Line breaks:** Do not insert line returns inside a comment unless necessary. A comment that fits on a single line stays on a single line; do not pre-wrap at ~80 chars for aesthetics. Only break across lines when the comment is genuinely long (multi-sentence prose, enumerated invariants) or when a hard break carries meaning (separating an intro line from a bullet list, for instance).
|
|
||||||
|
|
||||||
## Target platforms
|
|
||||||
|
|
||||||
**Linux and Windows only.** No macOS, no mobile, no console, no web/WASM.
|
|
||||||
|
|
||||||
- Both platforms have native Vulkan via vendor ICDs (NVIDIA / AMD / Intel). No translation layer (no MoltenVK story), so modern Vulkan extensions can be adopted freely without consulting a portability matrix.
|
|
||||||
- **File paths:** always use `std::path::Path` / `PathBuf` and the `directories` (or `dirs`) crate for user-data lookup. Never hard-code `/home/...` or `~`. Linux follows XDG (`$XDG_DATA_HOME` etc.); Windows uses `%APPDATA%`.
|
|
||||||
- **Line endings:** repo is LF-only. Set `core.autocrlf = false` and/or a `.gitattributes` with `* text eol=lf` to keep diffs clean across the two OSes.
|
|
||||||
- **Filename casing:** never have two files differing only in case. Linux is case-sensitive; Windows isn't; the mismatch produces confusing "works on my machine" bugs.
|
|
||||||
|
|
||||||
## Determinism stance
|
|
||||||
|
|
||||||
- **Worldgen is seed-deterministic.** Given the same seed, worldgen must produce bit-for-bit the same world on any platform, any time. This constrains worldgen code: use a fixed RNG algorithm (e.g. `wyrand`, `xoshiro`), **never** `rand::thread_rng()` or anything seeded from the OS. Do not depend on `HashMap` iteration order (Rust's default hasher is randomised); use `BTreeMap`, `IndexMap`, or sort explicitly when iteration order feeds into RNG draws or content placement. See [ADR-0003](docs/adr/0003-seed-deterministic-worldgen.md).
|
|
||||||
- **Simulation is server-authoritative.** The server runs 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.
|
|
||||||
- **Full simulation determinism (lockstep / rollback / replay-from-inputs) is a non-goal.** This means floats, hash-map iteration, and platform-specific math are all fair game *outside of worldgen*. Don't pay the cost of cross-platform float reproducibility for a feature we're not building. See [ADR-0004](docs/adr/0004-server-authoritative-simulation.md).
|
|
||||||
|
|
||||||
## Content IDs & namespacing
|
|
||||||
|
|
||||||
All registered content (blocks, items, recipes, biomes, entities, ...) is identified by a **namespaced string** of the form `"namespace:id"`. The full rationale is in [ADR-0005](docs/adr/0005-namespaced-content-ids.md).
|
|
||||||
|
|
||||||
- **Engine's reserved namespace:** `core:`. All first-party content registered by the base game uses it (`"core:stone"`, `"core:iron_sword"`). Mods pick their own short namespace (`"mymod:weird_dirt"`).
|
|
||||||
- **Strict form required.** A bare ID with no `:` is an **error at registration / parse time**, not silently coerced to `core:`. Same rule everywhere: engine scripts, data packs, Lua mods, recipe references, save files. No exceptions; the symmetry is the point.
|
|
||||||
- **Charset:** namespace and id are each `[a-z0-9_-]+`, exactly one `:` between them. Lowercase ASCII only. No uppercase, no Unicode, no spaces, no dots, no slashes. Keeps IDs greppable, filesystem-safe, and unambiguous in logs and save files.
|
|
||||||
- **Runtime representation:** intern each ID string into a small integer handle (e.g. `BlockId(u32)`) at registration time. Hot paths compare handles, not strings. Keep the original string for display, save/load, and the Lua API surface.
|
|
||||||
|
|
||||||
> *Project name note:* The project is named **Synvael** ("Catalyst" was the working codename). The engine namespace is deliberately `core:` (not the project name), so it stays stable independent of branding.
|
|
||||||
|
|
||||||
## Coordinate system & units
|
|
||||||
|
|
||||||
- **Up axis:** **+Y**.
|
|
||||||
- **Handedness:** **right-handed** (default math convention; +X right, +Y up, +Z toward the viewer / out of the screen).
|
|
||||||
- **World unit:** **1 unit = 1 block.** Blocks are simply 0.5 m in physical scale, but inside the engine everything is counted in *blocks*, not metres. A player is therefore 3 units tall × 2 units wide in world coordinates.
|
|
||||||
|
|
||||||
Implementation gotchas that arise because neighbouring tools use different conventions (Vulkan clip space, Blender import, glTF) are collected in [`docs/rendering.md`](docs/rendering.md). They are not convention changes, only mismatches to handle in one agreed place.
|
|
||||||
|
|
||||||
## Branching Strategy & Workflow
|
|
||||||
|
|
||||||
- **`main` vs `dev`**: The repository follows a strict workflow. The `main` branch is reserved purely for stable releases. All active development happens on the **`dev`** branch.
|
|
||||||
- **Feature Branches**: For any large feature, always create a new branch off of `dev` (e.g., `feat/new-worldgen`). Do not commit large, work-in-progress features directly to `dev`. Once the feature is complete and verified, merge it back into `dev`.
|
|
||||||
- **Continuous Integration**: The project enforces strict linting and formatting via GitHub Actions. This includes workspace-level `clippy` rules (banning `unwrap` and `println!`), `cargo fmt`, `selene` for Lua, and `stylua`. Always ensure your code passes these tools locally before pushing.
|
|
||||||
|
|
||||||
## Commit conventions
|
|
||||||
|
|
||||||
[**Conventional Commits**](https://www.conventionalcommits.org/) with **mandatory crate-name scope**.
|
|
||||||
|
|
||||||
Format:
|
|
||||||
|
|
||||||
```
|
|
||||||
<type>(<crate>): <imperative subject>
|
|
||||||
|
|
||||||
[optional body]
|
|
||||||
|
|
||||||
[optional footer(s)]
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Type** (required, exactly one): `feat` (new feature), `fix` (bug fix), `refactor` (no behaviour change), `perf`, `docs`, `test`, `chore` (build/tooling/deps), `build`, `ci`. Breaking changes append `!` before the colon: `feat(scripting)!: ...`.
|
|
||||||
- **Scope** (required): the crate the change primarily affects, one of `client`, `server`, `renderer`, `shared`, `scripting`. For changes that genuinely span the whole workspace (e.g. workspace-level Cargo config, repo-wide `.gitattributes`), use `workspace`. For changes confined to non-Rust assets, use `assets`. Avoid omitting the scope, and avoid inventing per-commit scopes.
|
|
||||||
- **Subject:** imperative mood ("add", not "added" / "adds"), lowercase, no trailing period, ≤ ~72 chars.
|
|
||||||
- **Body:** keep commit messages short and simple, usually subject only, no body. The exception is `fix(...)` commits for non-trivial bugs, where a body explaining the root cause and why the fix works is valuable. Don't pad routine `feat`/`refactor`/`chore`/`docs` commits with bodies.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```
|
|
||||||
feat(scripting): expose blocks.register to lua
|
|
||||||
fix(renderer): clamp swapchain extent to surface caps
|
|
||||||
refactor(shared): split network message types into submodule
|
|
||||||
chore(workspace): bump ash to 0.39
|
|
||||||
docs(assets): document texture-pack overlay layout
|
|
||||||
feat(server)!: change tick rate from 20 to 30 Hz
|
|
||||||
```
|
|
||||||
|
|
||||||
If a single commit truly touches multiple crates and can't be reasonably split, that's a signal to split it. Only fall back to `workspace` scope when the change is intrinsically workspace-wide.
|
|
||||||
|
|
||||||
**Do not add any AI assistant as a co-author on commits.** No `Co-Authored-By: ...` trailers for assistants, no "Generated with ..." footers. Commits are authored by the human running the work.
|
|
||||||
|
|
||||||
## Common commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build # build all crates
|
|
||||||
cargo run -p client # run the windowed client
|
|
||||||
cargo run -p server # run the server
|
|
||||||
cargo test # run all tests
|
|
||||||
cargo test -p renderer it_works # run a single test by name
|
|
||||||
cargo check -p <crate> # fast type-check one crate
|
|
||||||
cargo clippy --all-targets --all-features -- -D warnings
|
|
||||||
cargo fmt
|
|
||||||
selene .
|
|
||||||
stylua .
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
Project guidance for this repository is maintained in **[AGENTS.md](AGENTS.md)**, the tool-agnostic single source of truth for architecture, conventions, and workflow. It is imported below so Claude Code loads it directly; edit `AGENTS.md`, not this file.
|
See `AGENTS.md` for project instructions. Do not edit this file.
|
||||||
|
|
||||||
@AGENTS.md
|
@AGENTS.md
|
||||||
|
|
|
||||||
151
CONTRIBUTING.md
151
CONTRIBUTING.md
|
|
@ -1,167 +1,132 @@
|
||||||
# Contributing to Synvael
|
# Contributing to Synvael
|
||||||
|
|
||||||
Thank you for your interest in contributing to Synvael. This document explains how contributions work, what is expected, and what contributors receive in return.
|
Thanks for your interest in contributing to Synvael. This guide covers how contributions work, our expectations, and what you get out of it as a contributor.
|
||||||
|
|
||||||
## Philosophy
|
## Philosophy
|
||||||
|
|
||||||
Synvael is **open-source and commercially developed**. The engine source code is licensed under AGPLv3, official game assets under CC-BY-NC-SA 4.0 (see [LICENSE.md](LICENSE.md) for full terms), and the project is built by Cryoforge Nexus, a commercial entity that intends to monetize the finished game.
|
Synvael is **open-source and commercially developed**. The engine code uses the AGPLv3 license, and official game assets use CC-BY-NC-SA 4.0 (check [LICENSE.md](LICENSE.md) for the details). The project is built by Cryoforge Nexus, a commercial company that plans to monetize the finished game.
|
||||||
|
|
||||||
Outside contributions are welcome and credited, but they do not carry equity or revenue share. If that trade does not work for you, please do not contribute. The project would rather be upfront about these terms than have anyone feel misled later.
|
We welcome and credit outside contributions, but they don't include equity or revenue share. If that arrangement doesn't work for you, it's best not to contribute. We prefer being upfront about this rather than risking anyone feeling misled later on.
|
||||||
|
|
||||||
## Contributor Tiers
|
## Contributor Tiers
|
||||||
|
|
||||||
### Core team
|
### Core team
|
||||||
|
|
||||||
The core team makes architectural and roadmap decisions and is eligible for an ownership stake in Cryoforge Nexus, subject to the company's internal agreements.
|
The core team handles architectural and roadmap decisions. They are eligible for an ownership stake in Cryoforge Nexus based on the company's internal agreements.
|
||||||
|
|
||||||
Membership is not open by default, but there is a path in. A contributor becomes **eligible for consideration** when all of the following are met:
|
Membership isn't open by default, but there is a way in. A contributor becomes **eligible for consideration** when they meet all these criteria:
|
||||||
|
|
||||||
1. **Sustained contribution.** At least six months of active, merged contributions. Consistency and quality matter more than volume.
|
1. **Sustained contribution:** At least six months of active, merged contributions. Consistency and quality are more important than sheer volume.
|
||||||
2. **Architectural ownership.** The contributor has taken responsibility for at least one subsystem (meshing, networking, worldgen, a major Lua API surface, etc.) and has demonstrated the ability to make sound design decisions within it, not just implement assigned tasks.
|
2. **Architectural ownership:** The contributor has taken charge of at least one subsystem (like meshing, networking, worldgen, or a major Lua API surface) and has shown they can make good design decisions there, rather than just completing assigned tasks.
|
||||||
3. **Community trust.** A track record of constructive code reviews, issue discussions, and collaboration. No pattern of conflict or disregard for project norms.
|
3. **Community trust:** A solid track record of constructive code reviews, issue discussions, and teamwork. No history of conflict or ignoring project norms.
|
||||||
|
|
||||||
When these criteria are met, an existing core member may **nominate** the contributor. Admission requires a **supermajority vote** (two-thirds or more) of current core members. The nominee may decline.
|
Once these criteria are met, an existing core member can **nominate** the contributor. Joining requires a **supermajority vote** (two-thirds or more) from the current core members, and the nominee is free to decline.
|
||||||
|
|
||||||
Meeting the criteria makes someone eligible for consideration, not entitled to membership. The final decision remains a human judgement about long-term fit with the team.
|
Meeting the criteria just makes someone eligible, not entitled to membership. The final call is a human judgement about long-term fit with the team.
|
||||||
|
|
||||||
### Outside contributors
|
### Outside contributors
|
||||||
|
|
||||||
Anyone who submits a pull request, asset, bug report, translation, or documentation change. Outside contributors receive public credit, authorship on their work under the project's open-source licenses, code review, mentorship where useful, and a voice in technical discussion. They do not receive equity, revenue share, or a guarantee of future paid work.
|
This includes anyone who submits a pull request, asset, bug report, translation, or documentation change. Outside contributors get public credit, authorship on their work under our open-source licenses, code reviews, mentorship when helpful, and a voice in technical discussions. They do not get equity, revenue share, or any guarantee of future paid work.
|
||||||
|
|
||||||
## Contributor License Agreement (CLA)
|
## Contributor License Agreement (CLA)
|
||||||
|
|
||||||
Every contribution, code and assets alike, requires signing a [Contributor License Agreement](CLA.md) before a pull request can be merged. The CLA does **not** transfer copyright, contributors retain it. It grants Cryoforge Nexus a perpetual, irrevocable license to use, modify, sublicense, and relicense the contribution, including under commercial terms.
|
Every contribution (both code and assets) requires signing a [Contributor License Agreement](CLA.md) before we can merge a pull request. The CLA does **not** transfer your copyright; you keep it. It simply grants Cryoforge Nexus a perpetual, irrevocable license to use, modify, sublicense, and relicense your contribution, including for commercial purposes.
|
||||||
|
|
||||||
This is necessary because the public licenses (AGPLv3 + CC-BY-NC-SA 4.0) would otherwise prevent the planned monetization model. Without the CLA, contributed assets in particular would lock the project out of any commercial release.
|
This is necessary because the public licenses (AGPLv3 and CC-BY-NC-SA 4.0) would otherwise block our monetization plan. Without the CLA, contributed assets in particular would prevent the project from having a commercial release.
|
||||||
|
|
||||||
### How to sign
|
### How to sign
|
||||||
|
|
||||||
When a pull request is opened, an automated check verifies whether all commit authors have signed the CLA. If not, it posts a comment with instructions. To sign, leave a comment on the pull request containing:
|
When you open a pull request, an automated check looks to see if all commit authors have signed the CLA. If not, it drops a comment with instructions. To sign, just leave a comment on the pull request saying:
|
||||||
|
|
||||||
```
|
```
|
||||||
I have read the CLA and I agree
|
I have read the CLA and I agree
|
||||||
```
|
```
|
||||||
|
|
||||||
The bot records the signature and updates the check status automatically. Signing is a one-time action; once recorded, all future pull requests from the same GitHub account are accepted without re-signing.
|
The bot records your signature and updates the check status automatically. You only have to do this once. After that, all future pull requests from your GitHub account are accepted without needing to sign again.
|
||||||
|
|
||||||
## What is accepted
|
## What is accepted
|
||||||
|
|
||||||
- **Code** in Rust (for engine crates) or Lua (for `assets/scripts/` and `mods/`), licensed under AGPLv3.
|
- **Code** in Rust (for engine crates) or Lua (for `assets/scripts/` and `mods/`), licensed under AGPLv3.
|
||||||
- **Original assets** authored by the contributor: textures, models, sounds, icons, shaders. Licensed under CC-BY-NC-SA 4.0.
|
- **Original assets** you created yourself: textures, models, sounds, icons, shaders. Licensed under CC-BY-NC-SA 4.0.
|
||||||
- **Translations, documentation, bug reports, and design feedback.**
|
- **Translations, documentation, bug reports, and design feedback.**
|
||||||
|
|
||||||
## What is not accepted
|
## What is not accepted
|
||||||
|
|
||||||
- **Assets derived from copyrighted third-party material** (other games, films, copyrighted art). All submitted assets must be original work.
|
- **Assets derived from copyrighted third-party material** (like other games, films, or copyrighted art). Everything submitted must be your original work.
|
||||||
- **AI-generated assets.** Textures, models, sounds, icons, and other non-code assets must be original human-authored work. See [AI-Assisted Contributions](#ai-assisted-contributions) below.
|
- **AI-generated assets.** Textures, models, sounds, icons, and any other non-code assets must be originally made by humans. Check the [AI-Assisted Contributions](#ai-assisted-contributions) section below.
|
||||||
- **Contributions without a signed CLA.** The bot enforces this; unsigned pull requests cannot be merged.
|
- **Contributions without a signed CLA.** The bot enforces this strictly, so unsigned pull requests won't be merged.
|
||||||
- **Native (Rust) mods submitted as pull requests.** Native mods that link against engine internals are derivative works under AGPLv3 and belong in their own repositories. Lua mods are welcome in `mods/`.
|
- **Native (Rust) mods submitted as pull requests.** Native mods that link against engine internals are considered derivative works under AGPLv3 and belong in their own separate repositories. Lua mods are completely welcome in `mods/`.
|
||||||
|
|
||||||
## Development Setup
|
## Coding Standards & Architecture
|
||||||
|
|
||||||
### Prerequisites
|
For the complete technical engineering manual, including architecture boundaries, naming, documentation style, and lint rules, please refer to [DEVELOPMENT.md](DEVELOPMENT.md).
|
||||||
|
|
||||||
- [Rust](https://www.rust-lang.org/tools/install) (stable toolchain, edition 2024)
|
## Pull Request Process
|
||||||
- [Git LFS](https://git-lfs.com/) (binary assets are tracked via LFS)
|
|
||||||
- A Vulkan-capable GPU with up-to-date drivers (Linux or Windows)
|
|
||||||
|
|
||||||
### Building
|
1. **Branch from `dev`.** The `main` branch is kept strictly for stable releases. All active development happens on the **`dev`** branch. If you are working on a large feature, always create a new feature branch off `dev` (for example, `feat/new-worldgen`). Don't commit large, work-in-progress features directly to `dev`.
|
||||||
|
2. **One concept per pull request.** Try to keep your changes focused. If a pull request touches multiple unrelated systems, please split it up.
|
||||||
|
3. **Ensure CI passes.** Our pipeline runs `cargo fmt`, `cargo clippy`, `selene`, and `stylua`. Pull requests with lint failures won't be reviewed. Always make sure your code passes these tools locally before pushing.
|
||||||
|
4. **Sign the CLA.** The CLA bot must show a passing status before we start reviewing.
|
||||||
|
5. **Describe the change.** Explain what your pull request does, why it's needed, and any design decisions you made. If it relates to any open issues, link them.
|
||||||
|
6. **Respond to review feedback.** Maintainers might request changes. Please address them or discuss alternative approaches with us.
|
||||||
|
|
||||||
```bash
|
## Contributing workflow
|
||||||
git clone https://github.com/Cryoforge-Nexus/Synvael.git
|
|
||||||
cd Synvael
|
|
||||||
git lfs pull
|
|
||||||
cargo build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running
|
Before you commit a change, verify it against the actual repo state instead of assuming it's correct. Read the files, inspect `git diff`, and run `cargo check`, `cargo clippy`, or `cargo test` as needed. Then just follow this loop for each change:
|
||||||
|
|
||||||
```bash
|
1. **Verify** that the change is actually present and correct in your working tree.
|
||||||
cargo run -p client # windowed client
|
2. **Run the linter and formatter** to ensure no regressions or style issues sneaked in: `cargo clippy --all-targets --all-features -- -D warnings`, `cargo fmt --all -- --check`, `selene .`, and `stylua .`.
|
||||||
cargo run -p server # dedicated server
|
3. **Ensure useful comments are present** before committing: add function doc comments (`///`) and inline comments above non-obvious logic, sticking to the project's documentation style.
|
||||||
```
|
4. **Create a focused git commit** using the commit conventions listed below.
|
||||||
|
|
||||||
### Testing
|
Keep commits scoped to a single concept. Don't bundle multiple unrelated changes into one commit, and try not to leave a verified change uncommitted before moving on to the next thing.
|
||||||
|
|
||||||
```bash
|
## Commit conventions
|
||||||
cargo test # all tests
|
|
||||||
cargo test -p shared # tests for a single crate
|
|
||||||
```
|
|
||||||
|
|
||||||
### Linting
|
We use [**Conventional Commits**](https://www.conventionalcommits.org/) with a **mandatory crate-name scope**.
|
||||||
|
|
||||||
The CI pipeline enforces strict linting. Run these locally before pushing:
|
Format:
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo fmt --all -- --check
|
|
||||||
cargo clippy --all-targets --all-features -- -D warnings
|
|
||||||
selene .
|
|
||||||
stylua .
|
|
||||||
```
|
|
||||||
|
|
||||||
Lua linting requires [Selene](https://kampfkarren.github.io/selene/) and [StyLua](https://github.com/JohnnyMorganz/StyLua). Install them via `cargo install selene` and `cargo install stylua`, or use the pre-built binaries from their release pages.
|
|
||||||
|
|
||||||
## Coding Standards
|
|
||||||
|
|
||||||
All conventions, commit format, documentation style, naming, architecture boundaries and lint rules are documented in [AGENTS.md](AGENTS.md). That file is the single source of truth; this section highlights the most relevant points for contributors.
|
|
||||||
|
|
||||||
### Commit format
|
|
||||||
|
|
||||||
[Conventional Commits](https://www.conventionalcommits.org/) with a mandatory crate-name scope:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
<type>(<crate>): <imperative subject>
|
<type>(<crate>): <imperative subject>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer(s)]
|
||||||
```
|
```
|
||||||
|
|
||||||
Types: `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `chore`, `build`, `ci`. Scope is the primary crate affected (`client`, `server`, `renderer`, `shared`, `scripting`) or `workspace` / `assets` for cross-cutting changes.
|
- **Type** (required, exactly one): `feat` (new feature), `fix` (bug fix), `refactor` (no behaviour change), `perf`, `docs`, `test`, `chore` (build/tooling/deps), `build`, `ci`. If there are breaking changes, append a `!` before the colon: `feat(scripting)!: ...`.
|
||||||
|
- **Scope** (required): the crate the change primarily affects, choosing from `client`, `server`, `renderer`, `shared`, or `scripting`. If the change spans the whole workspace (like a Cargo config change or `.gitattributes`), use `workspace`. For changes isolated to non-Rust assets, use `assets`. Please avoid omitting the scope and don't make up new scopes per commit.
|
||||||
|
- **Subject:** imperative mood (use "add", not "added" or "adds"), lowercase, no trailing period, and keep it under 72 characters.
|
||||||
|
- **Body:** keep commit messages short and sweet. Usually just a subject is fine. The main exception is `fix(...)` commits for non-trivial bugs, where a body explaining the root cause and why the fix actually works is super helpful. There's no need to pad routine commits with bodies.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
```
|
```
|
||||||
feat(scripting): expose blocks.register to lua
|
feat(scripting): expose blocks.register to lua
|
||||||
fix(renderer): clamp swapchain extent to surface caps
|
fix(renderer): clamp swapchain extent to surface caps
|
||||||
|
refactor(shared): split network message types into submodule
|
||||||
|
chore(workspace): bump ash to 0.39
|
||||||
docs(assets): document texture-pack overlay layout
|
docs(assets): document texture-pack overlay layout
|
||||||
|
feat(server)!: change tick rate from 20 to 30 Hz
|
||||||
```
|
```
|
||||||
|
|
||||||
### Documentation
|
If a single commit touches multiple crates and honestly can't be reasonably split, that's usually a sign that it should be split anyway. Only fall back to the `workspace` scope when the change is fundamentally workspace-wide.
|
||||||
|
|
||||||
- Formal, objective tone. No first-person or second-person pronouns.
|
|
||||||
- Every public struct field requires a `///` doc comment.
|
|
||||||
- Inline comments (`//`) above non-obvious logic.
|
|
||||||
|
|
||||||
### Architecture boundaries
|
|
||||||
|
|
||||||
- Protocol/data types → `shared`
|
|
||||||
- Lua API and `mlua` integration → `scripting`
|
|
||||||
- GPU/draw code → `renderer`
|
|
||||||
- Input, windowing, presentation → `client`
|
|
||||||
- Simulation, authoritative logic → `server`
|
|
||||||
|
|
||||||
Do not place simulation logic in `client`. See [AGENTS.md](AGENTS.md) for the full rationale.
|
|
||||||
|
|
||||||
## Pull Request Process
|
|
||||||
|
|
||||||
1. **Branch from `dev`.** The `main` branch is reserved for stable releases. All development happens on `dev`. For any feature of non-trivial scope, create a feature branch off `dev` (e.g., `feat/new-worldgen`).
|
|
||||||
2. **One concept per pull request.** Keep changes focused. If a pull request touches multiple unrelated systems, split it.
|
|
||||||
3. **Ensure CI passes.** The pipeline runs `cargo fmt`, `cargo clippy`, `selene`, and `stylua`. Pull requests with lint failures are not reviewed.
|
|
||||||
4. **Sign the CLA.** The CLA bot must report a passing status before review begins.
|
|
||||||
5. **Describe the change.** Explain what the pull request does, why it is needed, and any design decisions made. Link to relevant issues if applicable.
|
|
||||||
6. **Respond to review feedback.** Maintainers may request changes. Please address them or discuss alternatives.
|
|
||||||
|
|
||||||
## Recognition
|
## Recognition
|
||||||
|
|
||||||
Every merged contribution earns an entry in `CREDITS.md`. Substantial or sustained contributions are highlighted on the project website. Standout contributors may, once the project has revenue, be offered paid bounties for specific scoped work, this is a transactional arrangement, not equity or an ongoing revenue share.
|
Every merged contribution gets an entry in `CREDITS.md`. Major or sustained contributions are highlighted on the project website. Standout contributors might be offered paid bounties for specific scoped work once the project starts generating revenue. This is a transactional setup, not equity or an ongoing revenue share.
|
||||||
|
|
||||||
This is the honest ceiling of what outside contribution earns. If the goal is co-ownership of a game studio, this project is not the right fit.
|
This is the hard limit of what outside contribution earns. If your goal is co-ownership of a game studio, this project probably isn't the right fit.
|
||||||
|
|
||||||
## AI-Assisted Contributions
|
## AI-Assisted Contributions
|
||||||
|
|
||||||
AI tools (code completion, generation, refactoring assistants) may be used as aids when writing code. The following rules apply:
|
You are welcome to use AI tools like code completion, generation, or refactoring assistants when writing code. Just keep these rules in mind:
|
||||||
|
|
||||||
- **AI-assisted code is accepted, with conditions.** The contributor must have genuinely reviewed every line of the submitted code and be able to explain what it does and why. Contributions where the author cannot answer questions about their own code during review will be rejected. The contributor, not the AI tool, is the responsible author.
|
- **AI-assisted code is accepted, with conditions.** You must genuinely review every line of the submitted code and be able to explain what it does and why. If you can't answer questions about your own code during review, the contribution will be rejected. You are the responsible author, not the AI tool.
|
||||||
- **AI-generated assets are not accepted.** Textures, models, sounds, icons, and other non-code assets must be original human-authored work. This applies regardless of the AI tool or its training-data provenance.
|
- **AI-generated assets are not accepted.** Textures, models, sounds, icons, and other non-code assets must be original human-authored work. This rule applies regardless of the tool used or where its training data came from.
|
||||||
- **Disclosure is required.** If AI tools were used in a material way during the creation of a code contribution, this must be stated in the pull request description. A brief note (e.g., "AI-assisted: used Copilot for boilerplate generation") is sufficient.
|
- **Disclosure is required.** If you used AI tools in a meaningful way to create a code contribution, state this in the pull request description. A quick note like "AI-assisted: used Copilot for boilerplate generation" is completely fine.
|
||||||
|
|
||||||
## Questions?
|
## Questions?
|
||||||
|
|
||||||
Open an issue or start a discussion on the repository. Maintainers are happy to help with setup, answer questions about the architecture, or discuss proposed changes before work begins.
|
Feel free to open an issue or start a discussion on the repository. Maintainers are always happy to help with setup, answer questions about the architecture, or chat about proposed changes before you start working on them.
|
||||||
|
|
|
||||||
195
DEVELOPMENT.md
Normal file
195
DEVELOPMENT.md
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
# Development Guidelines
|
||||||
|
|
||||||
|
This file is the single source of truth for architecture, conventions, and workflow for human contributors working on Synvael. Tool-specific entry points (like `CLAUDE.md`) and AI instructions (like `AGENTS.md`) import or summarize this file rather than duplicating it.
|
||||||
|
|
||||||
|
## Documentation map
|
||||||
|
|
||||||
|
Our documentation is layered by altitude. Try to keep content at the layer it belongs to so no single file ends up hoarding everything.
|
||||||
|
|
||||||
|
- **This file (`DEVELOPMENT.md`)**: covers cross-cutting engineering conventions and architecture invariants. These are the rules that apply no matter what feature you're touching. There is a finite set of these, so this file should grow pretty slowly. Subsystem-specific specs do not belong here.
|
||||||
|
- **[`docs/`](docs/) and Rust module docs (`//!`, `///`)**: per-subsystem technical implementation docs explaining how an individual system (meshing, networking, worldgen, etc.) is actually built. We prefer module docs right next to the code. If a design spans multiple files, promote it to a `docs/<subsystem>.md` note.
|
||||||
|
- **[`docs/adr/`](docs/adr/)**: Architecture Decision Records capturing the "why" behind significant, hard-to-reverse choices, with one append-only file per decision. Check out [`docs/README.md`](docs/README.md) for the full structure and [`docs/adr/0001-record-architecture-decisions.md`](docs/adr/0001-record-architecture-decisions.md) for the practice itself.
|
||||||
|
|
||||||
|
The canonical game design specification covering intent, world rules, and gameplay behaviour is maintained separately and isn't part of this repository. This repo only documents how that design gets implemented.
|
||||||
|
|
||||||
|
## Workspace layout
|
||||||
|
|
||||||
|
We use a Cargo workspace (resolver = "3", edition 2024) containing six crates under `crates/`:
|
||||||
|
|
||||||
|
- `client`: binary. This is the windowed application using `winit` 0.30 (`ApplicationHandler` pattern, `ControlFlow::Poll`). It also pulls in `image`. This is the player-facing app titled "Synvael". It handles input, windowing, and drives the renderer.
|
||||||
|
- `server`: binary. The authoritative game simulation covering the voxel world, combat, and players. It is used for dedicated multiplayer hosts and also acts as the simulation backend for single-player.
|
||||||
|
- `renderer`: library. Voxel and scene rendering on Vulkan using `ash`. It is deliberately decoupled from windowing so the `client` can drive it.
|
||||||
|
- `shared`: library. Types and protocols shared between `client` and `server` like world and voxel data, network messages, and combat primitives. This crate stays lean and dependency-light. It has no `mlua`, no rendering, and no engine internals.
|
||||||
|
- `scripting`: library. The Lua modding API and bindings. This crate owns the `mlua` dependency, `UserData` wrappers around `shared` types, API table registration, and the mod loader. Both `client` and `server` depend on it.
|
||||||
|
- `net`: library. QUIC transport, connection lifecycle, and wire framing for the client-server protocol. It owns the async runtime (`tokio`) and the `quinn` and `rustls` dependencies. Both `client` and `server` depend on it. See [ADR-0010](docs/adr/0010-net-crate-async-runtime.md) for more details.
|
||||||
|
|
||||||
|
When adding code, please keep these boundaries tight. Protocol and data types plus game-rule primitives go in `shared`. Lua API surfaces and `mlua` integration live in `scripting`. GPU and drawing code goes in `renderer`. Transport and connection code belongs in `net` (but protocol message types stay in `shared`). Only input, windowing, and presentation glue should live in `client`. Try to avoid growing `client` with simulation logic, since it needs to work identically whether it's talking to a local or remote `server`.
|
||||||
|
|
||||||
|
## Modding API (Lua): dogfooded
|
||||||
|
|
||||||
|
The game exposes a Lua modding API, and **the base game itself is built directly on top of that same API** rather than treating it as a separate add-on layer. Built-in content like blocks, items, entities, and recipes are defined through the modding API so mod authors can read the shipped code as a reference for what's possible and how to do it.
|
||||||
|
|
||||||
|
This has some strict implications when adding new features:
|
||||||
|
|
||||||
|
- Any new gameplay primitive (a new block type, item, entity, ability, etc.) needs to be accessible through the Lua API, not just as a Rust-only path. If you add a Rust-side concept without an API surface, you've broken our dogfooding rule.
|
||||||
|
- Prefer extending the API and then *using* it from the engine over adding a parallel Rust-only entry point.
|
||||||
|
- Keep the API stable and easy to discover, since mod authors will be reading it. Avoid leaking engine internals through it.
|
||||||
|
- The API and its bindings live strictly in the **`scripting`** crate. It owns the `mlua` dependency, the API table registration, and the mod loader. Both `client` and `server` depend on it. `shared` does **not**, since it needs to stay as a lean protocol layer.
|
||||||
|
- Authoritative APIs like world mutation and combat resolution are defined in `scripting` but gated so the client-side Lua VM cannot invoke them. We use one API surface across two execution contexts: the client VM is for read-only UI and effects, while the server VM is authoritative.
|
||||||
|
- Use wrapper newtypes inside `scripting` rather than `impl UserData for SharedType` in `shared`. This prevents coupling the protocol crate to `mlua`.
|
||||||
|
|
||||||
|
The decision to build the base game on top of the modding API and the client/server VM gating that follows are recorded in [ADR-0006](docs/adr/0006-base-game-on-modding-api.md).
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
All game assets live under `/assets` at the repo root, organized into subfolders by kind: `icons/`, `models/`, `shaders/`, `sounds/`, `textures/`, and `scripts/`. New assets must go in the matching subfolder. Don't drop loose files directly into `/assets`, and don't scatter assets inside crate directories.
|
||||||
|
|
||||||
|
Assets are published openly under CC-BY-NC-SA 4.0 (check `LICENSE.md`). Binary assets like textures, models, sounds, and compiled shaders are tracked using **Git LFS**. Keep `.gitattributes` up to date when adding a new binary file type. Lua scripts and JSON data are plain text files and live in standard Git.
|
||||||
|
|
||||||
|
## Script locations
|
||||||
|
|
||||||
|
We use three distinct locations for scripts. Please do not mix them:
|
||||||
|
|
||||||
|
- **`/assets/scripts/`**: the base game's own Lua, shipped with the binary. This is the dogfooded "first-party mod" the engine loads through the same API mod authors use. We mirror the structure modders will use (like `scripts/blocks/`, `scripts/items/`, `scripts/entities/`) so it serves as a working reference.
|
||||||
|
- **`/mods/`** (top-level): in-repo example mods or test fixtures. We keep these out of `/assets/` because they aren't engine-shipped content, and out of `crates/` because they aren't Rust source code.
|
||||||
|
- **`<user-data-dir>/mods/`**: player-installed mods, loaded only at runtime. This path is resolved via the `directories` or `dirs` crate (on Linux, it's `~/.local/share/synvael/mods/`, with platform equivalents elsewhere). Never read from a hard-coded path.
|
||||||
|
|
||||||
|
## Data packs & resource packs
|
||||||
|
|
||||||
|
These are two distinct, orthogonal systems. Keep them separate, and don't merge them into one "pack" concept. **Resource packs** are client-side asset overlays covering textures, sounds, models, fonts, and language files. They contain no logic. **Data packs** are declarative content definitions (using JSON, TOML, or RON) covering blocks, items, recipes, loot tables, biomes, and tags.
|
||||||
|
|
||||||
|
Our strict rule here: **do not build a parallel registration system.** The data-pack loader reads declarative files and calls the exact same Lua API that the engine and Lua mods use, ensuring one single source of truth (e.g. `data/blocks/stone.json` is read by the loader, which calls `blocks.register{ ... }`). Each schema is a stable contract that we version deliberately. This decision is recorded in [ADR-0007](docs/adr/0007-declarative-content-via-modding-api.md).
|
||||||
|
|
||||||
|
Full subsystem details regarding load order, repo and user-data layouts, and resolution semantics can be found in [`docs/packs.md`](docs/packs.md).
|
||||||
|
|
||||||
|
## Concurrency model
|
||||||
|
|
||||||
|
The game is **multithreaded by design**. A single-threaded approach simply wouldn't meet our performance budget for running voxel meshing, worldgen, rendering, networking, and simulation all at once. Assume multiple threads when writing code and design data ownership accordingly:
|
||||||
|
|
||||||
|
- Prefer message-passing using channels (`crossbeam-channel`, `flume`, or `std::sync::mpsc`) and per-thread ownership rather than shared mutable state.
|
||||||
|
- When sharing is completely unavoidable, use the right primitive for your access pattern. Use `Arc<Mutex<_>>` for low-contention shared state, `Arc<RwLock<_>>` for read-heavy state, atomics like `AtomicU32` or `AtomicBool` for counters and flags, and lock-free structures from `crossbeam` or `dashmap` for hot paths. Try to avoid wrapping large hot data in a single `Mutex` "just in case", as this can easily accidentally serialize the entire engine.
|
||||||
|
- Worldgen and chunk meshing are massive parallelism wins. We expect a thread pool like `rayon` or a hand-rolled one to feed meshing and generation jobs.
|
||||||
|
- Vulkan command-buffer recording can also be parallelized, but Vulkan **queues** are not free-threaded. Only one thread can submit to a given queue at a time, so plan ownership of `vk::Queue` accordingly.
|
||||||
|
- The Lua VMs (one per execution context for client and server) are **not** thread-safe in `mlua`'s default configuration. Treat each VM as owned by a single thread, and dispatch work to and from it using channels.
|
||||||
|
|
||||||
|
## Logging & error handling
|
||||||
|
|
||||||
|
- **Logging:** We use [`tracing`](https://docs.rs/tracing/) with `tracing-subscriber` as the output backend. Use `info!`, `warn!`, `error!`, `debug!`, and `trace!` macros at appropriate levels. It's crucial to use **spans** (`#[tracing::instrument]`, `info_span!`) to scope work, as this is how we keep multithreaded log output readable. Avoid using `println!` or `eprintln!` for diagnostics. If it's worth printing, it's worth a proper `tracing` event.
|
||||||
|
- **Errors in libraries** (`shared`, `renderer`, `scripting`): Use typed error enums via [`thiserror`](https://docs.rs/thiserror/) using `#[derive(Error)]`. Each variant should be a distinct, matchable failure mode. Do not expose `anyhow::Error` from a library API.
|
||||||
|
- **Errors in binaries** (`client`, `server`): Use [`anyhow`](https://docs.rs/anyhow/) at the top level, leaning on `.context("...")` to provide human-readable layers. Library errors compose smoothly into `anyhow::Error` using the `?` operator.
|
||||||
|
- **Never use `.unwrap()` or `.expect()` outside of `main`, setup logic, or tests.** The only exception is when an invariant is genuinely impossible to violate. On hot paths, propagate errors with `?` and let the caller decide what to do.
|
||||||
|
|
||||||
|
## Testing policy
|
||||||
|
|
||||||
|
We prioritize tests based on risk, not raw coverage percentages. We direct our testing effort toward areas where code that compiles and appears correct isn't guaranteed to actually be correct. You must write accompanying unit tests for these categories in the same change that introduces or modifies the logic:
|
||||||
|
|
||||||
|
- **Pure algorithmic logic.** Things with values in, values out, no I/O, no GPU, and no windowing. This includes coordinate and index math, packing and unpacking, meshing math, and similar self-contained computations. These are cheap to test and their edges are notoriously easy to get subtly wrong.
|
||||||
|
- **Correctness traps.** Behaviors where a totally plausible implementation is silently wrong on an edge case. Examples include sign handling, off-by-one errors, integer overflow or truncation, and bit-packing boundaries. As a classic example, world-to-chunk conversion needs to floor via `div_euclid` rather than truncating via `/`. A test on negative inputs locks in that contract and prevents someone from accidentally regressing to `/`.
|
||||||
|
- **Load-bearing invariants (especially determinism).** As noted in our determinism stance below, worldgen is seed-deterministic and bit-for-bit reproducible. That contract can't be verified just by looking at the code, so it is strictly guarded by tests (for example, generating a chunk twice from one seed and asserting they are equal). We guard determinism aggressively.
|
||||||
|
|
||||||
|
Subsystems that are bound by I/O or hardware (like the `renderer` and Vulkan GPU paths, `client` windowing and input, and top-level binary wiring) are validated through integration tests and manual visual verification rather than strict unit tests. Their behavior relies on a live device, window, or process rather than pure logic. While the mechanism differs, the expectation that they are properly verified does not.
|
||||||
|
|
||||||
|
Unit tests live right next to the code as `#[cfg(test)] mod tests` and are run using `cargo test -p <crate>`.
|
||||||
|
|
||||||
|
## Lint suppressions
|
||||||
|
|
||||||
|
The workspace opts into a strict set of lints. This includes Clippy's `pedantic` group along with restriction lints that ban `unwrap`, `expect`, and `print` outside permitted contexts (you can check `[workspace.lints]` in the root `Cargo.toml`). Suppressions are expected at specific sites and are governed by these rules:
|
||||||
|
|
||||||
|
- **Always prefer `#[expect(...)]` over `#[allow(...)]`** for a localized suppression. An `#[expect]` turns into a warning (`unfulfilled_lint_expectations`) if the lint it targets no longer fires, meaning obsolete suppressions surface automatically and can be cleaned up instead of lingering silently. `#[allow]` never self-reports and just accumulates as dead noise.
|
||||||
|
- **Suppress narrowly.** Name the exact lint or lints, and attach the attribute to the absolute smallest scope that covers the site (like a statement, expression, or item). Never use a broad crate-level `#![allow]`. The only exception is a deliberate crate-wide policy, such as `#![allow(unsafe_code)]` in the `renderer`, where the suppression represents an architectural intent rather than a local waiver.
|
||||||
|
- **Justify non-obvious suppressions.** If the reason a lint is safe to suppress isn't totally obvious from the surrounding code, leave a brief comment above the attribute explaining why (for instance, noting that a specific cast is mathematically provably in range).
|
||||||
|
- **Never suppress `correctness`-tier lints.** These indicate real defects. Fix the code instead.
|
||||||
|
|
||||||
|
## Documentation style
|
||||||
|
|
||||||
|
- **Objective Tone:** All comments (both doc comments `///` and inline `//`) must be written in a formal, objective, and neutral tone.
|
||||||
|
- **No Personal Pronouns:** Avoid first-person ("we", "our", "us") and second-person ("you", "your") pronouns.
|
||||||
|
- **Voice:** Try to use the passive voice or neutral descriptive language. Instead of "We initialize the buffer," try "The buffer is initialized." Instead of "Your vertex shader needs this," write "The vertex shader requires this."
|
||||||
|
- **Focus:** Describe the code's behavior, the system's state, or technical invariants.
|
||||||
|
- **Struct Documentation:** Every single field in a public or internal struct needs a doc comment (`///`) explaining what it's for and any invariants it holds.
|
||||||
|
- **Function documentation sections:** Function doc comments should follow the standard sections from the [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/documentation.html). They should appear in this fixed order after the summary and description: `# Errors`, then `# Panics`, then `# Safety`. These sections apply to **all** functions, whether public or private. Clippy only enforces public ones, but we expect the same standard on private helpers by hand.
|
||||||
|
- **`# Errors`** is mandatory on every function returning a `Result`. It needs to state the exact conditions under which each error variant is returned. `fn main` is exempt from this.
|
||||||
|
- **`# Panics`** is mandatory on any function that can panic. This includes `expect`, `unwrap`, `panic!`, `assert!`, array indexing, or arithmetic that can trip. State the condition that triggers the panic.
|
||||||
|
- **`# Safety`** is mandatory on every `unsafe fn`, stating the exact invariants the caller must uphold.
|
||||||
|
- Test functions (`#[test]`, and helpers inside `#[cfg(test)]`) are fully exempt from all three sections since they aren't part of the documented surface.
|
||||||
|
- We use `missing_errors_doc`, `missing_panics_doc`, and `missing_safety_doc` as warnings in our workspace lint set, so missing a section on a public item will fail CI.
|
||||||
|
- **Stability:** Treat the documentation as a technical specification for the engine.
|
||||||
|
- **Line breaks:** Do not insert line returns inside a comment unless it's genuinely necessary. If a comment fits on a single line, leave it on a single line. Don't artificially wrap text at 80 characters just for aesthetics. Only break across lines when the comment is actually long (like multi-sentence prose or enumerated invariants) or when a hard break carries meaning (like separating an intro line from a bulleted list).
|
||||||
|
|
||||||
|
## Target platforms
|
||||||
|
|
||||||
|
**Linux and Windows only.** We do not support macOS, mobile, consoles, or web/WASM.
|
||||||
|
|
||||||
|
- Both platforms feature native Vulkan support via vendor ICDs (NVIDIA, AMD, Intel). There is no translation layer like MoltenVK, meaning we can adopt modern Vulkan extensions freely without checking a portability matrix.
|
||||||
|
- **File paths:** Always use `std::path::Path` or `PathBuf` along with the `directories` (or `dirs`) crate for looking up user data. Never hard-code paths like `/home/...` or `~`. Linux properly follows XDG standards (`$XDG_DATA_HOME`, etc.), while Windows correctly uses `%APPDATA%`.
|
||||||
|
- **Line endings:** The repository is strictly LF-only. Make sure to set `core.autocrlf = false` and rely on our `.gitattributes` setting `* text eol=lf` to keep diffs completely clean across both operating systems.
|
||||||
|
- **Filename casing:** Never create two files that differ only in casing. Linux is case-sensitive and Windows isn't, so mismatches create incredibly confusing "works on my machine" bugs.
|
||||||
|
|
||||||
|
## Determinism stance
|
||||||
|
|
||||||
|
- **Worldgen is seed-deterministic.** Given the exact same seed, worldgen must produce bit-for-bit the same world on any platform, at any time. This strongly constrains our worldgen code: you must use a fixed RNG algorithm like `wyrand` or `xoshiro`. **Never** use `rand::thread_rng()` or anything seeded directly from the OS. Do not depend on `HashMap` iteration order, as Rust's default hasher is randomized. Use `BTreeMap`, `IndexMap`, or explicitly sort your data when iteration order feeds into RNG draws or content placement. For more detail, check [ADR-0003](docs/adr/0003-seed-deterministic-worldgen.md).
|
||||||
|
- **Simulation is server-authoritative.** The server runs the absolute truth. Clients send their inputs and receive state snapshots back, predicting locally for responsiveness and reconciling whenever they disagree with the server. Combat, physics, mob AI, and item drops are computed exactly once, on the server.
|
||||||
|
- **Full simulation determinism (lockstep, rollback, replay-from-inputs) is a non-goal.** Because of this, floats, hash-map iteration, and platform-specific math are all totally fair game *outside of worldgen*. We don't want to pay the massive performance cost of cross-platform float reproducibility for a feature we aren't even building. See [ADR-0004](docs/adr/0004-server-authoritative-simulation.md).
|
||||||
|
|
||||||
|
## Content IDs & namespacing
|
||||||
|
|
||||||
|
All registered content (like blocks, items, recipes, biomes, and entities) is identified using a **namespaced string** in the exact format `"namespace:id"`. The full rationale for this is in [ADR-0005](docs/adr/0005-namespaced-content-ids.md).
|
||||||
|
|
||||||
|
- **Engine's reserved namespace:** `core:`. All first-party content registered directly by the base game uses this namespace (e.g. `"core:stone"`, `"core:iron_sword"`). Mods pick their own short namespace (e.g. `"mymod:weird_dirt"`).
|
||||||
|
- **Strict form required.** A bare ID with no `:` is considered an **error at registration and parse time**. It will not be silently coerced to `core:`. This same rule applies absolutely everywhere: engine scripts, data packs, Lua mods, recipe references, and save files. There are no exceptions. The symmetry is entirely the point.
|
||||||
|
- **Charset:** The namespace and id must each match `[a-z0-9_-]+`, separated by exactly one `:`. Stick to lowercase ASCII only. No uppercase letters, no Unicode, no spaces, no dots, and no slashes. This keeps IDs easy to grep, completely filesystem-safe, and unambiguous in logs and save data.
|
||||||
|
- **Runtime representation:** We intern each ID string into a small integer handle (like `BlockId(u32)`) when it gets registered. Hot paths should always compare handles, never strings. We keep the original string around purely for display, saving and loading, and the Lua API surface.
|
||||||
|
|
||||||
|
> *Project name note:* The project is named **Synvael** ("Catalyst" was our old working codename). The engine namespace is deliberately kept as `core:` rather than the project name, ensuring it stays stable even if branding changes.
|
||||||
|
|
||||||
|
## Coordinate system & units
|
||||||
|
|
||||||
|
- **Up axis:** **+Y**.
|
||||||
|
- **Handedness:** **right-handed** (this is the default math convention where +X is right, +Y is up, and +Z points toward the viewer or out of the screen).
|
||||||
|
- **World unit:** **1 unit = 1 block.** Blocks are exactly 0.5 meters in physical scale, but inside the engine, everything is counted in *blocks*, not meters. A player is therefore exactly 3 units tall and 2 units wide in world coordinates.
|
||||||
|
|
||||||
|
We've collected implementation gotchas that pop up because neighboring tools use different conventions (like Vulkan clip space, Blender import, or glTF) in [`docs/rendering.md`](docs/rendering.md). Note that these are not convention changes for the engine, just mismatches that we handle in one agreed-upon place.
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Rust](https://www.rust-lang.org/tools/install) (stable toolchain, edition 2024)
|
||||||
|
- [Git LFS](https://git-lfs.com/) (binary assets are tracked via LFS)
|
||||||
|
- A Vulkan-capable GPU with up-to-date drivers (Linux or Windows)
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Cryoforge-Nexus/Synvael.git
|
||||||
|
cd Synvael
|
||||||
|
git lfs pull
|
||||||
|
cargo build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p client # windowed client
|
||||||
|
cargo run -p server # dedicated server
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test # all tests
|
||||||
|
cargo test -p shared # tests for a single crate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linting
|
||||||
|
|
||||||
|
The CI pipeline enforces strict linting. Run these locally before pushing your code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
selene .
|
||||||
|
stylua .
|
||||||
|
```
|
||||||
|
|
||||||
|
Lua linting requires [Selene](https://kampfkarren.github.io/selene/) and [StyLua](https://github.com/JohnnyMorganz/StyLua). You can install them by running `cargo install selene` and `cargo install stylua`, or by using the pre-built binaries from their GitHub release pages.
|
||||||
|
|
@ -1,9 +1,66 @@
|
||||||
#version 450
|
#version 450
|
||||||
|
|
||||||
layout(location = 0) in vec3 frag_color;
|
layout(location = 0) in vec3 frag_color;
|
||||||
|
layout(location = 1) in vec3 frag_normal;
|
||||||
|
layout(location = 2) in float frag_debug_tint;
|
||||||
|
layout(location = 3) in vec3 frag_world_position;
|
||||||
|
|
||||||
layout(location = 0) out vec4 out_color;
|
layout(location = 0) out vec4 out_color;
|
||||||
|
|
||||||
void main () {
|
// The block is declared identically in cube.vert. A push-constant block is a single object shared by every stage of the pipeline, so the two declarations must agree exactly even where a stage reads only part of it.
|
||||||
out_color = vec4(frag_color, 1.0);
|
layout(push_constant) uniform PushConstants {
|
||||||
}
|
mat4 mvp;
|
||||||
|
// xyz is the chunk's world offset; w is the debug-tint weight, 0.0 for normal rendering and 1.0 for a debug raster pass.
|
||||||
|
vec4 chunk_offset;
|
||||||
|
// xyz is the camera's world position; w is the horizontal distance at which fog reaches full opacity.
|
||||||
|
vec4 fog;
|
||||||
|
// rgb is the sky colour distant geometry fades into, matching the colour attachment's clear value; w is the vertical distance at which fog reaches full opacity.
|
||||||
|
vec4 sky_color;
|
||||||
|
} push_constants;
|
||||||
|
|
||||||
|
// Direction the light travels, pointing downward and across both horizontal axes so that no cubic face orientation receives exactly the same amount of light as another. A light aligned with an axis would leave two of the three visible faces of a cube indistinguishable.
|
||||||
|
const vec3 LIGHT_DIRECTION = vec3(-0.4, -1.0, -0.3);
|
||||||
|
|
||||||
|
// Fraction of the albedo retained by a fully unlit face, standing in for bounced light until a global-illumination term exists. Without it, faces turned away from the light collapse to black and their silhouettes disappear against one another.
|
||||||
|
const float AMBIENT = 0.25;
|
||||||
|
|
||||||
|
// Colour applied to debug raster passes, chosen to contrast with terrain and to remain legible when overlaid on filled geometry.
|
||||||
|
const vec3 DEBUG_COLOR = vec3(1.0, 0.0, 1.0);
|
||||||
|
|
||||||
|
// Fraction of the fog end distance at which the fade begins. Below it geometry is drawn unfogged, which keeps the fog out of the region the player is actually looking at while leaving enough depth for the ramp to read as gradual rather than as a band.
|
||||||
|
const float FOG_START_FRACTION = 0.6;
|
||||||
|
|
||||||
|
// Floor on the width of the fade band, guarding the division below against a caller that supplies a fog end distance of zero.
|
||||||
|
const float MIN_FOG_RANGE = 1e-3;
|
||||||
|
|
||||||
|
// Returns the fog opacity for a surface `distance` from the camera along one axis, given the distance at which that axis reaches full opacity.
|
||||||
|
//
|
||||||
|
// The ramp is linear rather than exponential. Exponential fog approaches full opacity asymptotically without ever reaching it, so geometry stays faintly visible right up to the moment its chunk is unloaded, which is the pop the fog exists to conceal.
|
||||||
|
float fog_ramp(float distance, float end) {
|
||||||
|
float start = end * FOG_START_FRACTION;
|
||||||
|
float range = max(end - start, MIN_FOG_RANGE);
|
||||||
|
return clamp((distance - start) / range, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// The interpolated normal is renormalised: the six face normals are unit length and constant across a quad, but interpolation across a triangle is not guaranteed to preserve that.
|
||||||
|
vec3 normal = normalize(frag_normal);
|
||||||
|
|
||||||
|
// Lambertian term. The light vector is negated because LIGHT_DIRECTION points along the light's travel, whereas the dot product requires the direction from the surface toward the light. The clamp discards the negative half, where the face points away from the light.
|
||||||
|
float diffuse = max(dot(normal, normalize(-LIGHT_DIRECTION)), 0.0);
|
||||||
|
vec3 lit = frag_color * (AMBIENT + (1.0 - AMBIENT) * diffuse);
|
||||||
|
|
||||||
|
// The debug tint is applied after shading so debug passes draw flat and stay legible over the shaded geometry beneath them.
|
||||||
|
vec3 shaded = mix(lit, DEBUG_COLOR, frag_debug_tint);
|
||||||
|
|
||||||
|
// The horizontal and vertical extents of the streaming region are ramped independently, because the region is a cylinder rather than a sphere and therefore reaches one frontier well before the other. Fading both against a single distance leaves the nearer frontier unfogged and fully visible.
|
||||||
|
vec3 to_camera = frag_world_position - push_constants.fog.xyz;
|
||||||
|
float fog_horizontal = fog_ramp(length(to_camera.xz), push_constants.fog.w);
|
||||||
|
float fog_vertical = fog_ramp(abs(to_camera.y), push_constants.sky_color.a);
|
||||||
|
|
||||||
|
// Whichever frontier the surface is closer to determines the fade, so geometry is fully obscured before it crosses either one.
|
||||||
|
float fog_factor = max(fog_horizontal, fog_vertical);
|
||||||
|
|
||||||
|
// Fog is applied after the debug tint so an overlay recedes together with the geometry it annotates instead of punching through the fade.
|
||||||
|
out_color = vec4(mix(shaded, push_constants.sky_color.rgb, fog_factor), 1.0);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,37 @@
|
||||||
|
|
||||||
layout(location = 0) in vec3 in_position;
|
layout(location = 0) in vec3 in_position;
|
||||||
layout(location = 1) in vec3 in_color;
|
layout(location = 1) in vec3 in_color;
|
||||||
|
layout(location = 2) in uint in_face;
|
||||||
|
|
||||||
layout(location = 0) out vec3 frag_color;
|
layout(location = 0) out vec3 frag_color;
|
||||||
|
layout(location = 1) out vec3 frag_normal;
|
||||||
|
layout(location = 2) out float frag_debug_tint;
|
||||||
|
layout(location = 3) out vec3 frag_world_position;
|
||||||
|
|
||||||
|
// The block is declared identically in cube.frag. A push-constant block is a single object shared by every stage of the pipeline, so the two declarations must agree exactly even where a stage reads only part of it.
|
||||||
layout(push_constant) uniform PushConstants {
|
layout(push_constant) uniform PushConstants {
|
||||||
mat4 mvp;
|
mat4 mvp;
|
||||||
// xyz is the chunk's world offset; w is the debug-tint weight, 0.0 for normal rendering and 1.0 for a debug raster pass.
|
// xyz is the chunk's world offset; w is the debug-tint weight, 0.0 for normal rendering and 1.0 for a debug raster pass.
|
||||||
vec4 chunk_offset;
|
vec4 chunk_offset;
|
||||||
|
// xyz is the camera's world position; w is the horizontal distance at which fog reaches full opacity.
|
||||||
|
vec4 fog;
|
||||||
|
// rgb is the sky colour distant geometry fades into, matching the colour attachment's clear value; w is the vertical distance at which fog reaches full opacity.
|
||||||
|
vec4 sky_color;
|
||||||
} push_constants;
|
} push_constants;
|
||||||
|
|
||||||
// Colour applied to debug raster passes, chosen to contrast with terrain and to remain legible when overlaid on filled geometry.
|
|
||||||
const vec3 DEBUG_COLOR = vec3(1.0, 0.0, 1.0);
|
|
||||||
|
|
||||||
// Size, in pixels, of the points emitted under VK_POLYGON_MODE_POINT. Sizes above 1.0 require the largePoints device feature.
|
// Size, in pixels, of the points emitted under VK_POLYGON_MODE_POINT. Sizes above 1.0 require the largePoints device feature.
|
||||||
const float DEBUG_POINT_SIZE = 5.0;
|
const float DEBUG_POINT_SIZE = 5.0;
|
||||||
|
|
||||||
|
// Outward normals of the six cubic face directions, indexed by the packed face attribute.
|
||||||
|
const vec3 FACE_NORMALS[6] = vec3[6](
|
||||||
|
vec3( 1.0, 0.0, 0.0),
|
||||||
|
vec3(-1.0, 0.0, 0.0),
|
||||||
|
vec3( 0.0, 1.0, 0.0),
|
||||||
|
vec3( 0.0, -1.0, 0.0),
|
||||||
|
vec3( 0.0, 0.0, 1.0),
|
||||||
|
vec3( 0.0, 0.0, -1.0)
|
||||||
|
);
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
// The chunk-local vertex is shifted into world space by the per-chunk offset before projection.
|
// The chunk-local vertex is shifted into world space by the per-chunk offset before projection.
|
||||||
vec3 world_position = in_position + push_constants.chunk_offset.xyz;
|
vec3 world_position = in_position + push_constants.chunk_offset.xyz;
|
||||||
|
|
@ -26,6 +42,14 @@ void main() {
|
||||||
// Point size is consulted whenever the polygon mode is POINT; leaving it unwritten renders points of undefined size. It is ignored by the FILL and LINE pipelines, so it is written unconditionally.
|
// Point size is consulted whenever the polygon mode is POINT; leaving it unwritten renders points of undefined size. It is ignored by the FILL and LINE pipelines, so it is written unconditionally.
|
||||||
gl_PointSize = DEBUG_POINT_SIZE;
|
gl_PointSize = DEBUG_POINT_SIZE;
|
||||||
|
|
||||||
float debug_tint = push_constants.chunk_offset.w;
|
frag_color = in_color;
|
||||||
frag_color = mix(in_color, DEBUG_COLOR, debug_tint);
|
|
||||||
|
// Chunk placement is a pure translation, so a chunk-local face normal is already a world-space normal and no normal matrix is required.
|
||||||
|
frag_normal = FACE_NORMALS[in_face];
|
||||||
|
|
||||||
|
// Shading and the debug tint both resolve in the fragment stage, so the weight is forwarded rather than applied here.
|
||||||
|
frag_debug_tint = push_constants.chunk_offset.w;
|
||||||
|
|
||||||
|
// Forwarded for the fog term, which needs the distance from the camera to the shaded surface. Interpolating the world position is correct here because it is an affine function of the vertex positions.
|
||||||
|
frag_world_position = world_position;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,23 @@ use crate::mesh_pool::{JobGen, MeshJob, MeshPool, MeshResult};
|
||||||
|
|
||||||
/// Radius, in chunks, of the region kept resident around the camera center. Also the radius the client subscribes with, so the server's resident set matches the client's.
|
/// Radius, in chunks, of the region kept resident around the camera center. Also the radius the client subscribes with, so the server's resident set matches the client's.
|
||||||
// TODO: make configurable / drive from view-distance setting.
|
// TODO: make configurable / drive from view-distance setting.
|
||||||
pub const LOAD_RADIUS: i32 = 8;
|
pub const LOAD_RADIUS: i32 = 16;
|
||||||
|
|
||||||
|
/// Horizontal extent, in blocks, of the resident region around the camera.
|
||||||
|
#[expect(
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
reason = "the radius and chunk size are small compile-time constants, exact as f32"
|
||||||
|
)]
|
||||||
|
pub const LOAD_DISTANCE: f32 = LOAD_RADIUS as f32 * CHUNK_SIZE as f32;
|
||||||
|
|
||||||
|
/// Vertical extent, in blocks, of the resident region around the camera.
|
||||||
|
///
|
||||||
|
/// The streaming region is a cylinder half as tall as it is wide (see [`desired_chunks`]), so it reaches its vertical frontier at half the horizontal distance. Fading both extents against [`LOAD_DISTANCE`] leaves the cylinder's caps unfogged and their unloaded edge plainly visible from above or below, so the renderer ramps the two independently. The halving uses integer division to track [`desired_chunks`] exactly, including for odd radii.
|
||||||
|
#[expect(
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
reason = "the radius and chunk size are small compile-time constants, exact as f32"
|
||||||
|
)]
|
||||||
|
pub const LOAD_DISTANCE_VERTICAL: f32 = (LOAD_RADIUS / 2) as f32 * CHUNK_SIZE as f32;
|
||||||
|
|
||||||
/// Maximum number of chunk deliveries materialized in a single call to [`ChunkManager::update`], bounding per-frame materialization work. Deliveries beyond the budget remain queued in the transport for the next frame.
|
/// Maximum number of chunk deliveries materialized in a single call to [`ChunkManager::update`], bounding per-frame materialization work. Deliveries beyond the budget remain queued in the transport for the next frame.
|
||||||
const LOADS_PER_UPDATE: usize = 4;
|
const LOADS_PER_UPDATE: usize = 4;
|
||||||
|
|
|
||||||
|
|
@ -232,8 +232,13 @@ impl App {
|
||||||
self.report_statistics(frame, center, travelled);
|
self.report_statistics(frame, center, travelled);
|
||||||
}
|
}
|
||||||
|
|
||||||
let view = self.camera.view_matrix();
|
let frame = renderer::FrameParams {
|
||||||
if let Some(Err(e)) = self.renderer.as_mut().map(|r| r.draw_frame(view)) {
|
view: self.camera.view_matrix(),
|
||||||
|
camera_position: pos,
|
||||||
|
fog_end_horizontal: chunks::LOAD_DISTANCE,
|
||||||
|
fog_end_vertical: chunks::LOAD_DISTANCE_VERTICAL,
|
||||||
|
};
|
||||||
|
if let Some(Err(e)) = self.renderer.as_mut().map(|r| r.draw_frame(frame)) {
|
||||||
error!("Failed to draw frame: {e}");
|
error!("Failed to draw frame: {e}");
|
||||||
event_loop.exit();
|
event_loop.exit();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
|
||||||
use std::ffi::c_char;
|
use std::ffi::c_char;
|
||||||
|
|
||||||
pub use error::RendererError;
|
pub use error::RendererError;
|
||||||
pub use renderer::{MeshKey, RasterPass, RenderMode, Renderer};
|
pub use renderer::{FrameParams, MeshKey, RasterPass, RenderMode, Renderer};
|
||||||
pub use stats::{GpuInfo, MemoryUsage, ProjectionInfo, RenderStats, SwapchainInfo};
|
pub use stats::{GpuInfo, MemoryUsage, ProjectionInfo, RenderStats, SwapchainInfo};
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
|
||||||
|
|
@ -24,26 +24,33 @@ enum FaceDir {
|
||||||
NegZ,
|
NegZ,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FaceDir {
|
||||||
|
/// Returns the index identifying this direction's outward normal to the shader.
|
||||||
|
///
|
||||||
|
/// The six values are a contract with the `FACE_NORMALS` table in `assets/shaders/cube.vert`, which is indexed by them directly.
|
||||||
|
const fn to_index(self) -> u32 {
|
||||||
|
match self {
|
||||||
|
Self::PosX => 0,
|
||||||
|
Self::NegX => 1,
|
||||||
|
Self::PosY => 2,
|
||||||
|
Self::NegY => 3,
|
||||||
|
Self::PosZ => 4,
|
||||||
|
Self::NegZ => 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Identifies whether two faces are mergeable.
|
/// Identifies whether two faces are mergeable.
|
||||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||||
struct FaceKey {
|
struct FaceKey {
|
||||||
/// The material of the voxel owning the face.
|
/// The material of the voxel owning the face.
|
||||||
block: BlockId,
|
block: BlockId,
|
||||||
/// The face's signed axis direction, which selects its colour.
|
/// The face's signed axis direction, which selects its outward normal.
|
||||||
dir: FaceDir,
|
dir: FaceDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the flat RGB colour for a face pointing in `dir`.
|
/// The flat RGB albedo emitted for every face.
|
||||||
///
|
const MATERIAL_COLOR: [f32; 3] = [0.2, 0.8, 0.2];
|
||||||
/// The values reproduce the previous per-face emitter exactly so the rendered output is unchanged.
|
|
||||||
const fn color_of(dir: FaceDir) -> [f32; 3] {
|
|
||||||
match dir {
|
|
||||||
FaceDir::PosY => [0.2, 0.8, 0.2],
|
|
||||||
FaceDir::NegY => [0.1, 0.4, 0.1],
|
|
||||||
FaceDir::PosX | FaceDir::NegX => [0.15, 0.6, 0.15],
|
|
||||||
FaceDir::PosZ | FaceDir::NegZ => [0.18, 0.7, 0.18],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Converts a chunk-local integer coordinate to its floating-point value.
|
/// Converts a chunk-local integer coordinate to its floating-point value.
|
||||||
#[expect(
|
#[expect(
|
||||||
|
|
@ -160,9 +167,9 @@ pub fn generate_mesh(chunk: &Chunk, neighbors: &Neighbors) -> (Vec<Vertex>, Vec<
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|y, x0, z0, w, h| {
|
|y, x0, z0, w, h| {
|
||||||
let (xmin, xmax) = (coord(x0) - 0.5, coord(x0 + w) - 0.5);
|
let (xmin, xmax) = (coord(x0), coord(x0 + w));
|
||||||
let (zmin, zmax) = (coord(z0) - 0.5, coord(z0 + h) - 0.5);
|
let (zmin, zmax) = (coord(z0), coord(z0 + h));
|
||||||
let yp = coord(y) + 0.5;
|
let yp = coord(y + 1);
|
||||||
[
|
[
|
||||||
[xmin, yp, zmax],
|
[xmin, yp, zmax],
|
||||||
[xmax, yp, zmax],
|
[xmax, yp, zmax],
|
||||||
|
|
@ -187,9 +194,9 @@ pub fn generate_mesh(chunk: &Chunk, neighbors: &Neighbors) -> (Vec<Vertex>, Vec<
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|y, x0, z0, w, h| {
|
|y, x0, z0, w, h| {
|
||||||
let (xmin, xmax) = (coord(x0) - 0.5, coord(x0 + w) - 0.5);
|
let (xmin, xmax) = (coord(x0), coord(x0 + w));
|
||||||
let (zmin, zmax) = (coord(z0) - 0.5, coord(z0 + h) - 0.5);
|
let (zmin, zmax) = (coord(z0), coord(z0 + h));
|
||||||
let yp = coord(y) - 0.5;
|
let yp = coord(y);
|
||||||
[
|
[
|
||||||
[xmin, yp, zmin],
|
[xmin, yp, zmin],
|
||||||
[xmax, yp, zmin],
|
[xmax, yp, zmin],
|
||||||
|
|
@ -214,9 +221,9 @@ pub fn generate_mesh(chunk: &Chunk, neighbors: &Neighbors) -> (Vec<Vertex>, Vec<
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|x, z0, y0, w, h| {
|
|x, z0, y0, w, h| {
|
||||||
let (zmin, zmax) = (coord(z0) - 0.5, coord(z0 + w) - 0.5);
|
let (zmin, zmax) = (coord(z0), coord(z0 + w));
|
||||||
let (ymin, ymax) = (coord(y0) - 0.5, coord(y0 + h) - 0.5);
|
let (ymin, ymax) = (coord(y0), coord(y0 + h));
|
||||||
let xp = coord(x) + 0.5;
|
let xp = coord(x + 1);
|
||||||
[
|
[
|
||||||
[xp, ymin, zmax],
|
[xp, ymin, zmax],
|
||||||
[xp, ymin, zmin],
|
[xp, ymin, zmin],
|
||||||
|
|
@ -241,9 +248,9 @@ pub fn generate_mesh(chunk: &Chunk, neighbors: &Neighbors) -> (Vec<Vertex>, Vec<
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|x, z0, y0, w, h| {
|
|x, z0, y0, w, h| {
|
||||||
let (zmin, zmax) = (coord(z0) - 0.5, coord(z0 + w) - 0.5);
|
let (zmin, zmax) = (coord(z0), coord(z0 + w));
|
||||||
let (ymin, ymax) = (coord(y0) - 0.5, coord(y0 + h) - 0.5);
|
let (ymin, ymax) = (coord(y0), coord(y0 + h));
|
||||||
let xp = coord(x) - 0.5;
|
let xp = coord(x);
|
||||||
[
|
[
|
||||||
[xp, ymin, zmin],
|
[xp, ymin, zmin],
|
||||||
[xp, ymin, zmax],
|
[xp, ymin, zmax],
|
||||||
|
|
@ -268,9 +275,9 @@ pub fn generate_mesh(chunk: &Chunk, neighbors: &Neighbors) -> (Vec<Vertex>, Vec<
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|z, x0, y0, w, h| {
|
|z, x0, y0, w, h| {
|
||||||
let (xmin, xmax) = (coord(x0) - 0.5, coord(x0 + w) - 0.5);
|
let (xmin, xmax) = (coord(x0), coord(x0 + w));
|
||||||
let (ymin, ymax) = (coord(y0) - 0.5, coord(y0 + h) - 0.5);
|
let (ymin, ymax) = (coord(y0), coord(y0 + h));
|
||||||
let zp = coord(z) + 0.5;
|
let zp = coord(z + 1);
|
||||||
[
|
[
|
||||||
[xmin, ymin, zp],
|
[xmin, ymin, zp],
|
||||||
[xmax, ymin, zp],
|
[xmax, ymin, zp],
|
||||||
|
|
@ -295,9 +302,9 @@ pub fn generate_mesh(chunk: &Chunk, neighbors: &Neighbors) -> (Vec<Vertex>, Vec<
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|z, x0, y0, w, h| {
|
|z, x0, y0, w, h| {
|
||||||
let (xmin, xmax) = (coord(x0) - 0.5, coord(x0 + w) - 0.5);
|
let (xmin, xmax) = (coord(x0), coord(x0 + w));
|
||||||
let (ymin, ymax) = (coord(y0) - 0.5, coord(y0 + h) - 0.5);
|
let (ymin, ymax) = (coord(y0), coord(y0 + h));
|
||||||
let zp = coord(z) - 0.5;
|
let zp = coord(z);
|
||||||
[
|
[
|
||||||
[xmax, ymin, zp],
|
[xmax, ymin, zp],
|
||||||
[xmin, ymin, zp],
|
[xmin, ymin, zp],
|
||||||
|
|
@ -332,7 +339,7 @@ fn run_pass(
|
||||||
vertices,
|
vertices,
|
||||||
indices,
|
indices,
|
||||||
corners(slice, u0, v0, w, h),
|
corners(slice, u0, v0, w, h),
|
||||||
color_of(key.dir),
|
key.dir.to_index(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -380,14 +387,14 @@ fn merge_mask(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Appends one quad (four vertices, six indices) with the given corners and colour.
|
/// Appends one quad (four vertices, six indices) with the given corners and packed face normal.
|
||||||
///
|
///
|
||||||
/// Indices wind the two triangles as `[base, base+1, base+2, base+2, base+3, base]`, matching the corner ordering supplied by the caller.
|
/// A quad is planar, so all four vertices share `normal`. Indices wind the two triangles as `[base, base+1, base+2, base+2, base+3, base]`, matching the corner ordering supplied by the caller.
|
||||||
fn push_quad(
|
fn push_quad(
|
||||||
vertices: &mut Vec<Vertex>,
|
vertices: &mut Vec<Vertex>,
|
||||||
indices: &mut Vec<u32>,
|
indices: &mut Vec<u32>,
|
||||||
corners: [[f32; 3]; 4],
|
corners: [[f32; 3]; 4],
|
||||||
color: [f32; 3],
|
normal: u32,
|
||||||
) {
|
) {
|
||||||
#[expect(
|
#[expect(
|
||||||
clippy::cast_possible_truncation,
|
clippy::cast_possible_truncation,
|
||||||
|
|
@ -395,7 +402,11 @@ fn push_quad(
|
||||||
)]
|
)]
|
||||||
let base = vertices.len() as u32;
|
let base = vertices.len() as u32;
|
||||||
for position in corners {
|
for position in corners {
|
||||||
vertices.push(Vertex { position, color });
|
vertices.push(Vertex {
|
||||||
|
position,
|
||||||
|
color: MATERIAL_COLOR,
|
||||||
|
normal,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
indices.extend_from_slice(&[base, base + 1, base + 2, base + 2, base + 3, base]);
|
indices.extend_from_slice(&[base, base + 1, base + 2, base + 2, base + 3, base]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,19 @@ pub fn create_shader_module(
|
||||||
Ok(module)
|
Ok(module)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Size, in bytes, of one `vec4` slot of the push-constant block.
|
||||||
|
pub const VEC4_BYTES: u32 = 16;
|
||||||
|
|
||||||
|
/// Number of `vec4` slots following the MVP matrix in the push-constant block: the per-chunk offset, the fog parameters, and the sky colour.
|
||||||
|
const PUSH_CONSTANT_VEC4S: u32 = 3;
|
||||||
|
|
||||||
|
/// Shader stages that read the push-constant block.
|
||||||
|
///
|
||||||
|
/// Both stages are declared across the entire range: the vertex stage consumes the MVP and the per-chunk offset, the fragment stage the fog and sky slots. Vulkan requires the `stage_flags` given to every `cmd_push_constants` call to cover exactly the stages the layout declares for the bytes being written, so the layout and every update read this one value rather than restating the flags.
|
||||||
|
pub const PUSH_CONSTANT_STAGES: vk::ShaderStageFlags = vk::ShaderStageFlags::from_raw(
|
||||||
|
vk::ShaderStageFlags::VERTEX.as_raw() | vk::ShaderStageFlags::FRAGMENT.as_raw(),
|
||||||
|
);
|
||||||
|
|
||||||
/// Defines the 'interface' of the pipeline (what data we can pass to the shaders).
|
/// Defines the 'interface' of the pipeline (what data we can pass to the shaders).
|
||||||
///
|
///
|
||||||
/// This layout defines any push constants or descriptor sets (textures/UBOs) accessed by the shaders during execution.
|
/// This layout defines any push constants or descriptor sets (textures/UBOs) accessed by the shaders during execution.
|
||||||
|
|
@ -36,16 +49,17 @@ pub fn create_shader_module(
|
||||||
///
|
///
|
||||||
/// Returns [`RendererError::VulkanError`] if the device fails to create the pipeline layout.
|
/// Returns [`RendererError::VulkanError`] if the device fails to create the pipeline layout.
|
||||||
pub fn create_pipeline_layout(device: &Device) -> Result<vk::PipelineLayout, RendererError> {
|
pub fn create_pipeline_layout(device: &Device) -> Result<vk::PipelineLayout, RendererError> {
|
||||||
// The push-constant range covers the 64-byte MVP matrix followed by a 16-byte vec4 per-chunk world offset (80 bytes total, within the 128-byte guaranteed minimum).
|
// The push-constant range covers the 64-byte MVP matrix followed by three 16-byte vec4 slots (112 bytes total, within the 128-byte guaranteed minimum).
|
||||||
#[expect(
|
#[expect(
|
||||||
clippy::expect_used,
|
clippy::expect_used,
|
||||||
reason = "80 bytes (Mat4 + vec4) is well within u32 range"
|
reason = "112 bytes (Mat4 + three vec4s) is well within u32 range"
|
||||||
)]
|
)]
|
||||||
let push_constant_range = vk::PushConstantRange::default()
|
let push_constant_range = vk::PushConstantRange::default()
|
||||||
.stage_flags(vk::ShaderStageFlags::VERTEX)
|
.stage_flags(PUSH_CONSTANT_STAGES)
|
||||||
.offset(0)
|
.offset(0)
|
||||||
.size(
|
.size(
|
||||||
u32::try_from(std::mem::size_of::<glam::Mat4>() + std::mem::size_of::<[f32; 4]>())
|
u32::try_from(std::mem::size_of::<glam::Mat4>())
|
||||||
|
.map(|mvp| mvp + PUSH_CONSTANT_VEC4S * VEC4_BYTES)
|
||||||
.expect("push-constant size exceeds u32 range"),
|
.expect("push-constant size exceeds u32 range"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
use crate::pipeline::{PUSH_CONSTANT_STAGES, VEC4_BYTES};
|
||||||
use crate::stats::{GpuInfo, MemoryUsage, ProjectionInfo, RenderStats, SwapchainInfo};
|
use crate::stats::{GpuInfo, MemoryUsage, ProjectionInfo, RenderStats, SwapchainInfo};
|
||||||
use crate::sync::SyncPrimitives;
|
use crate::sync::SyncPrimitives;
|
||||||
use crate::{create_depth_resources, create_gpu_buffer, swapchain};
|
use crate::{create_depth_resources, create_gpu_buffer, swapchain};
|
||||||
|
|
@ -17,8 +18,43 @@ const FOV_Y_DEGREES: f32 = 45.0;
|
||||||
/// Distance to the near clip plane, in blocks.
|
/// Distance to the near clip plane, in blocks.
|
||||||
const NEAR_PLANE: f32 = 0.1;
|
const NEAR_PLANE: f32 = 0.1;
|
||||||
|
|
||||||
/// Distance to the far clip plane, in blocks.
|
/// Lower bound on the distance to the far clip plane, in blocks.
|
||||||
const FAR_PLANE: f32 = 500.0;
|
///
|
||||||
|
/// The far plane is extended past this whenever the frame's fog reaches further (see [`far_plane_for`]). The floor applies when the fog is nearer, and keeps the projection well-formed for a caller that supplies no fog distance at all.
|
||||||
|
const MIN_FAR_PLANE: f32 = 500.0;
|
||||||
|
|
||||||
|
/// Linear RGB colour of the empty sky.
|
||||||
|
const SKY_COLOR: [f32; 3] = [0.1, 0.2, 0.4];
|
||||||
|
|
||||||
|
/// Per-frame parameters supplied by the caller to [`Renderer::draw_frame`].
|
||||||
|
///
|
||||||
|
/// The renderer owns the projection, which derives from the swapchain it manages; everything here is state only the caller knows.
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub struct FrameParams {
|
||||||
|
/// Right-handed world-to-view matrix for this frame.
|
||||||
|
pub view: glam::Mat4,
|
||||||
|
/// World-space position of the camera eye, in blocks. Distance from this point drives the fog term.
|
||||||
|
pub camera_position: glam::Vec3,
|
||||||
|
/// Horizontal distance, in blocks, at which fog reaches full opacity.
|
||||||
|
///
|
||||||
|
/// The caller derives this from its own streaming radius so that geometry has already faded out completely by the time the chunk holding it is unloaded, which is what keeps the unload from reading as a pop.
|
||||||
|
pub fog_end_horizontal: f32,
|
||||||
|
/// Vertical distance, in blocks, at which fog reaches full opacity.
|
||||||
|
///
|
||||||
|
/// Supplied separately because a streaming region is not required to be a sphere.
|
||||||
|
pub fog_end_vertical: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the far clip distance covering the fog reach implied by the two extents.
|
||||||
|
///
|
||||||
|
/// The far plane must sit beyond every fragment the fog has not yet fully obscured, or the clip plane becomes the visible boundary and replaces the intended fade with a hard edge. Fog opacity saturates as soon as *either* axis passes its own end distance, so a fragment that is still partially visible lies strictly inside the box those two extents bound; the box's diagonal is therefore the furthest such a fragment can be, and covering it is exactly sufficient rather than merely conservative.
|
||||||
|
///
|
||||||
|
/// [`MIN_FAR_PLANE`] applies as a floor, so a caller supplying no fog distance still receives a usable projection.
|
||||||
|
fn far_plane_for(fog_end_horizontal: f32, fog_end_vertical: f32) -> f32 {
|
||||||
|
fog_end_horizontal
|
||||||
|
.hypot(fog_end_vertical)
|
||||||
|
.max(MIN_FAR_PLANE)
|
||||||
|
}
|
||||||
|
|
||||||
/// One rasterisation pass over the visible chunk meshes.
|
/// One rasterisation pass over the visible chunk meshes.
|
||||||
///
|
///
|
||||||
|
|
@ -237,7 +273,7 @@ impl Renderer {
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns [`RendererError::SyncPrimitivesMissing`] if the synchronization primitives have been torn down, or [`RendererError::VulkanError`] if any device operation (fence wait, image acquire, command recording, submit, or present) fails.
|
/// Returns [`RendererError::SyncPrimitivesMissing`] if the synchronization primitives have been torn down, or [`RendererError::VulkanError`] if any device operation (fence wait, image acquire, command recording, submit, or present) fails.
|
||||||
pub fn draw_frame(&mut self, camera_view: glam::Mat4) -> Result<(), RendererError> {
|
pub fn draw_frame(&mut self, frame: FrameParams) -> Result<(), RendererError> {
|
||||||
let sync = self
|
let sync = self
|
||||||
.sync
|
.sync
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -296,7 +332,7 @@ impl Renderer {
|
||||||
let view = self.swapchain_image_views[image_index as usize];
|
let view = self.swapchain_image_views[image_index as usize];
|
||||||
|
|
||||||
// 4. Record the actual rendering commands, retaining what they submitted.
|
// 4. Record the actual rendering commands, retaining what they submitted.
|
||||||
let submission = self.record_commands(cmd, view, image, camera_view)?;
|
let submission = self.record_commands(cmd, view, image, frame)?;
|
||||||
|
|
||||||
// 5. Submit the work to the GPU
|
// 5. Submit the work to the GPU
|
||||||
let submit_info = vk::SubmitInfo::default()
|
let submit_info = vk::SubmitInfo::default()
|
||||||
|
|
@ -511,7 +547,7 @@ impl Renderer {
|
||||||
cmd: vk::CommandBuffer,
|
cmd: vk::CommandBuffer,
|
||||||
view: vk::ImageView,
|
view: vk::ImageView,
|
||||||
image: vk::Image,
|
image: vk::Image,
|
||||||
camera_view: glam::Mat4,
|
frame: FrameParams,
|
||||||
) -> Result<Submission, RendererError> {
|
) -> Result<Submission, RendererError> {
|
||||||
// Transition layouts for drawing
|
// Transition layouts for drawing
|
||||||
self.transition_to_draw_layout(cmd, image);
|
self.transition_to_draw_layout(cmd, image);
|
||||||
|
|
@ -524,7 +560,7 @@ impl Renderer {
|
||||||
.store_op(vk::AttachmentStoreOp::STORE)
|
.store_op(vk::AttachmentStoreOp::STORE)
|
||||||
.clear_value(vk::ClearValue {
|
.clear_value(vk::ClearValue {
|
||||||
color: vk::ClearColorValue {
|
color: vk::ClearColorValue {
|
||||||
float32: [0.1, 0.2, 0.4, 1.0],
|
float32: [SKY_COLOR[0], SKY_COLOR[1], SKY_COLOR[2], 1.0],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -552,7 +588,7 @@ impl Renderer {
|
||||||
unsafe {
|
unsafe {
|
||||||
self.device.cmd_begin_rendering(cmd, &rendering_info);
|
self.device.cmd_begin_rendering(cmd, &rendering_info);
|
||||||
}
|
}
|
||||||
let submission = self.issue_draw_calls(cmd, camera_view);
|
let submission = self.issue_draw_calls(cmd, frame);
|
||||||
unsafe {
|
unsafe {
|
||||||
self.device.cmd_end_rendering(cmd);
|
self.device.cmd_end_rendering(cmd);
|
||||||
}
|
}
|
||||||
|
|
@ -608,8 +644,8 @@ impl Renderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Issues the actual draw calls for the frame, returning what was submitted.
|
/// Issues the actual draw calls for the frame, returning what was submitted.
|
||||||
fn issue_draw_calls(&self, cmd: vk::CommandBuffer, camera_view: glam::Mat4) -> Submission {
|
fn issue_draw_calls(&self, cmd: vk::CommandBuffer, frame: FrameParams) -> Submission {
|
||||||
let projection = self.projection_info();
|
let projection = self.projection_info(frame);
|
||||||
|
|
||||||
// The view matrix is supplied by the caller (the client's camera); the renderer owns only the projection, which depends on the swapchain aspect ratio it manages.
|
// The view matrix is supplied by the caller (the client's camera); the renderer owns only the projection, which depends on the swapchain aspect ratio it manages.
|
||||||
let mvp = glam::camera::rh::proj::vulkan::perspective(
|
let mvp = glam::camera::rh::proj::vulkan::perspective(
|
||||||
|
|
@ -617,19 +653,19 @@ impl Renderer {
|
||||||
projection.aspect,
|
projection.aspect,
|
||||||
projection.near,
|
projection.near,
|
||||||
projection.far,
|
projection.far,
|
||||||
) * camera_view;
|
) * frame.view;
|
||||||
|
|
||||||
let (visible, culled) = self.cull_to_frustum(mvp);
|
let (visible, culled) = self.cull_to_frustum(mvp);
|
||||||
let submission = self.summarise_submission(&visible, culled, projection);
|
let submission = self.summarise_submission(&visible, culled, projection);
|
||||||
|
|
||||||
self.set_dynamic_state(cmd);
|
self.set_dynamic_state(cmd);
|
||||||
self.record_passes(cmd, mvp, &visible);
|
self.record_passes(cmd, mvp, frame, &visible);
|
||||||
|
|
||||||
submission
|
submission
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Derives this frame's projection parameters from the swapchain extent.
|
/// Derives this frame's projection parameters from the swapchain extent and the frame's fog distances.
|
||||||
fn projection_info(&self) -> ProjectionInfo {
|
fn projection_info(&self, frame: FrameParams) -> ProjectionInfo {
|
||||||
let aspect =
|
let aspect =
|
||||||
f64::from(self.swapchain_extent.width) / f64::from(self.swapchain_extent.height);
|
f64::from(self.swapchain_extent.width) / f64::from(self.swapchain_extent.height);
|
||||||
|
|
||||||
|
|
@ -641,7 +677,7 @@ impl Renderer {
|
||||||
fov_y_radians: FOV_Y_DEGREES.to_radians(),
|
fov_y_radians: FOV_Y_DEGREES.to_radians(),
|
||||||
aspect: aspect as f32,
|
aspect: aspect as f32,
|
||||||
near: NEAR_PLANE,
|
near: NEAR_PLANE,
|
||||||
far: FAR_PLANE,
|
far: far_plane_for(frame.fog_end_horizontal, frame.fog_end_vertical),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -651,7 +687,7 @@ impl Renderer {
|
||||||
fn cull_to_frustum(&self, mvp: glam::Mat4) -> (Vec<&GpuMesh>, usize) {
|
fn cull_to_frustum(&self, mvp: glam::Mat4) -> (Vec<&GpuMesh>, usize) {
|
||||||
let frustum = Frustum::from_view_proj(mvp);
|
let frustum = Frustum::from_view_proj(mvp);
|
||||||
|
|
||||||
// A chunk spans CHUNK_SIZE blocks on each axis. The mesher centres block i on [i - 0.5, i + 0.5], so a chunk's box runs [offset - 0.5, offset + CHUNK_SIZE - 0.5]; the extent below is added to that shifted minimum corner.
|
// A chunk spans CHUNK_SIZE blocks on each axis. Block i spans [i, i+1), so a chunk's geometry runs [offset, offset + CHUNK_SIZE].
|
||||||
#[expect(
|
#[expect(
|
||||||
clippy::cast_precision_loss,
|
clippy::cast_precision_loss,
|
||||||
reason = "CHUNK_SIZE is 32, exactly representable as f32"
|
reason = "CHUNK_SIZE is 32, exactly representable as f32"
|
||||||
|
|
@ -664,7 +700,7 @@ impl Renderer {
|
||||||
.values()
|
.values()
|
||||||
.filter(|mesh| {
|
.filter(|mesh| {
|
||||||
// Reject the chunk when its world-space bounding box falls entirely outside the frustum.
|
// Reject the chunk when its world-space bounding box falls entirely outside the frustum.
|
||||||
let box_min = glam::Vec3::from(mesh.world_offset) - glam::Vec3::splat(0.5);
|
let box_min = glam::Vec3::from(mesh.world_offset);
|
||||||
let visible = frustum.intersects_aabb(box_min, box_min + chunk_extent);
|
let visible = frustum.intersects_aabb(box_min, box_min + chunk_extent);
|
||||||
if !visible {
|
if !visible {
|
||||||
culled += 1;
|
culled += 1;
|
||||||
|
|
@ -730,14 +766,20 @@ impl Renderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records one indexed draw per visible mesh, for every pass the active render mode composes.
|
/// Records one indexed draw per visible mesh, for every pass the active render mode composes.
|
||||||
fn record_passes(&self, cmd: vk::CommandBuffer, mvp: glam::Mat4, visible: &[&GpuMesh]) {
|
fn record_passes(
|
||||||
|
&self,
|
||||||
|
cmd: vk::CommandBuffer,
|
||||||
|
mvp: glam::Mat4,
|
||||||
|
frame: FrameParams,
|
||||||
|
visible: &[&GpuMesh],
|
||||||
|
) {
|
||||||
unsafe {
|
unsafe {
|
||||||
// The MVP is identical for every chunk and every pass this frame, so it is pushed once before the loops.
|
// The MVP is identical for every chunk and every pass this frame, so it is pushed once before the loops.
|
||||||
let mvp_bytes = bytemuck::cast_slice(mvp.as_ref());
|
let mvp_bytes = bytemuck::cast_slice(mvp.as_ref());
|
||||||
self.device.cmd_push_constants(
|
self.device.cmd_push_constants(
|
||||||
cmd,
|
cmd,
|
||||||
self.pipeline_layout,
|
self.pipeline_layout,
|
||||||
vk::ShaderStageFlags::VERTEX,
|
PUSH_CONSTANT_STAGES,
|
||||||
0,
|
0,
|
||||||
mvp_bytes,
|
mvp_bytes,
|
||||||
);
|
);
|
||||||
|
|
@ -749,6 +791,25 @@ impl Renderer {
|
||||||
)]
|
)]
|
||||||
let chunk_offset_byte = size_of::<glam::Mat4>() as u32;
|
let chunk_offset_byte = size_of::<glam::Mat4>() as u32;
|
||||||
|
|
||||||
|
// The fog and sky blocks are adjacent and both frame-constant, so the two vec4s are pushed together in a single command after the per-chunk offset slot. The two fog distances are packed into the spare `w` component of each slot rather than claiming a fourth vec4, which would take the block to exactly the 128-byte guaranteed minimum and leave no headroom.
|
||||||
|
let fog_and_sky = [
|
||||||
|
frame.camera_position.x,
|
||||||
|
frame.camera_position.y,
|
||||||
|
frame.camera_position.z,
|
||||||
|
frame.fog_end_horizontal,
|
||||||
|
SKY_COLOR[0],
|
||||||
|
SKY_COLOR[1],
|
||||||
|
SKY_COLOR[2],
|
||||||
|
frame.fog_end_vertical,
|
||||||
|
];
|
||||||
|
self.device.cmd_push_constants(
|
||||||
|
cmd,
|
||||||
|
self.pipeline_layout,
|
||||||
|
PUSH_CONSTANT_STAGES,
|
||||||
|
chunk_offset_byte + VEC4_BYTES,
|
||||||
|
bytemuck::cast_slice(&fog_and_sky),
|
||||||
|
);
|
||||||
|
|
||||||
// Overlay modes submit the same geometry more than once, each pass binding a pipeline whose rasterisation state differs. Later passes draw over earlier ones.
|
// Overlay modes submit the same geometry more than once, each pass binding a pipeline whose rasterisation state differs. Later passes draw over earlier ones.
|
||||||
for pass in self.render_mode.passes() {
|
for pass in self.render_mode.passes() {
|
||||||
self.device.cmd_bind_pipeline(
|
self.device.cmd_bind_pipeline(
|
||||||
|
|
@ -768,7 +829,7 @@ impl Renderer {
|
||||||
self.device.cmd_push_constants(
|
self.device.cmd_push_constants(
|
||||||
cmd,
|
cmd,
|
||||||
self.pipeline_layout,
|
self.pipeline_layout,
|
||||||
vk::ShaderStageFlags::VERTEX,
|
PUSH_CONSTANT_STAGES,
|
||||||
chunk_offset_byte,
|
chunk_offset_byte,
|
||||||
bytemuck::cast_slice(&offset),
|
bytemuck::cast_slice(&offset),
|
||||||
);
|
);
|
||||||
|
|
@ -988,3 +1049,7 @@ impl Drop for Renderer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/renderer.rs"]
|
||||||
|
mod tests;
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,50 @@ fn single_block_emits_six_quads() {
|
||||||
assert_eq!(indices.len(), 36);
|
assert_eq!(indices.len(), 36);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn face_direction_indices_match_the_shader_normal_table() {
|
||||||
|
// Pins the numeric contract with the FACE_NORMALS table in assets/shaders/cube.vert, which is indexed by these values. Nothing else connects the two, and a silent reordering would mis-light every face rather than fail to build.
|
||||||
|
assert_eq!(FaceDir::PosX.to_index(), 0);
|
||||||
|
assert_eq!(FaceDir::NegX.to_index(), 1);
|
||||||
|
assert_eq!(FaceDir::PosY.to_index(), 2);
|
||||||
|
assert_eq!(FaceDir::NegY.to_index(), 3);
|
||||||
|
assert_eq!(FaceDir::PosZ.to_index(), 4);
|
||||||
|
assert_eq!(FaceDir::NegZ.to_index(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_block_quads_carry_their_own_face_normal() {
|
||||||
|
let mut chunk = Chunk::default();
|
||||||
|
chunk.set(5, 5, 5, BlockId(1));
|
||||||
|
let (vertices, _) = generate_mesh(&chunk, &Neighbors::default());
|
||||||
|
|
||||||
|
// The constant axis and plane coordinate of each face of a block at (5, 5, 5), indexed by packed normal. Deducing the expected direction from the geometry rather than from the emission order keeps the assertion valid if the passes are reordered.
|
||||||
|
let expected: [(usize, f32); 6] = [(0, 6.0), (0, 5.0), (1, 6.0), (1, 5.0), (2, 6.0), (2, 5.0)];
|
||||||
|
|
||||||
|
let mut seen = [false; 6];
|
||||||
|
for quad in vertices.chunks_exact(4) {
|
||||||
|
let normal = quad[0].normal;
|
||||||
|
assert!(
|
||||||
|
quad.iter().all(|v| v.normal == normal),
|
||||||
|
"a planar quad carries more than one normal index"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (axis, plane) = expected[normal as usize];
|
||||||
|
assert!(
|
||||||
|
quad.iter()
|
||||||
|
.all(|v| (v.position[axis] - plane).abs() < f32::EPSILON),
|
||||||
|
"the quad tagged with normal index {normal} does not lie on that face's plane"
|
||||||
|
);
|
||||||
|
|
||||||
|
seen[normal as usize] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
seen.iter().all(|&s| s),
|
||||||
|
"an isolated block must emit one quad per face direction"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn full_chunk_merges_each_face_into_one_quad() {
|
fn full_chunk_merges_each_face_into_one_quad() {
|
||||||
let mut chunk = Chunk::default();
|
let mut chunk = Chunk::default();
|
||||||
|
|
|
||||||
37
crates/renderer/src/tests/renderer.rs
Normal file
37
crates/renderer/src/tests/renderer.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
//! Unit tests for the pure helpers in [`crate::renderer`].
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn far_plane_floors_at_the_minimum_for_near_fog() {
|
||||||
|
// Fog that saturates well inside the minimum leaves the far plane at the floor; shrinking it to match would clip geometry for no gain.
|
||||||
|
assert!((far_plane_for(128.0, 64.0) - MIN_FAR_PLANE).abs() < f32::EPSILON);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn far_plane_covers_the_diagonal_of_the_two_extents() {
|
||||||
|
// 768 horizontal and 384 vertical (a radius-24 cylinder) reach 858.6 at the corner, past the 500-block floor.
|
||||||
|
let far = far_plane_for(768.0, 384.0);
|
||||||
|
assert!(far > MIN_FAR_PLANE);
|
||||||
|
assert!(
|
||||||
|
(far - 858.65_f32).abs() < 0.01,
|
||||||
|
"unexpected far plane {far}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn far_plane_reaches_past_each_extent_taken_alone() {
|
||||||
|
// The corner of the box is further than either edge, so covering only the larger extent would still clip partially-visible fragments near the diagonal.
|
||||||
|
let (horizontal, vertical) = (768.0_f32, 384.0_f32);
|
||||||
|
let far = far_plane_for(horizontal, vertical);
|
||||||
|
assert!(far > horizontal);
|
||||||
|
assert!(far > vertical);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn far_plane_is_well_formed_without_fog() {
|
||||||
|
// A caller that supplies no fog distance must still receive a usable projection rather than a degenerate zero-depth one.
|
||||||
|
assert!(far_plane_for(0.0, 0.0) > 0.0);
|
||||||
|
}
|
||||||
|
|
@ -4,10 +4,10 @@
|
||||||
|
|
||||||
use bytemuck::{Pod, Zeroable};
|
use bytemuck::{Pod, Zeroable};
|
||||||
|
|
||||||
/// Represents a single vertex in 3D space with position and texture coordinates.
|
/// Represents a single vertex in 3D space with position, colour, and face orientation.
|
||||||
///
|
///
|
||||||
/// Uses `repr(C)` to ensure the memory layout matches what the GPU expects (no Rust-specific reordering).
|
/// Uses `repr(C)` to ensure the memory layout matches what the GPU expects (no Rust-specific reordering).
|
||||||
/// `Pod` and `Zeroable` allows safely casting this struct to a raw byte slice.
|
/// `Pod` and `Zeroable` allows safely casting this struct to a raw byte slice. Every field is 4-byte aligned and the struct is 28 bytes, so no implicit padding exists for `Pod` to expose.
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
#[derive(Copy, Clone, Debug, PartialEq, Pod, Zeroable)]
|
#[derive(Copy, Clone, Debug, PartialEq, Pod, Zeroable)]
|
||||||
pub struct Vertex {
|
pub struct Vertex {
|
||||||
|
|
@ -15,6 +15,10 @@ pub struct Vertex {
|
||||||
pub position: [f32; 3],
|
pub position: [f32; 3],
|
||||||
/// The RGB color of the vertex [r, g, b].
|
/// The RGB color of the vertex [r, g, b].
|
||||||
pub color: [f32; 3],
|
pub color: [f32; 3],
|
||||||
|
/// Index of the face's outward normal into the shader's normal table.
|
||||||
|
///
|
||||||
|
/// Cubic geometry admits only six distinct normals, so the direction is packed as an index rather than a `vec3`, saving 8 bytes per vertex. The vertex shader decodes it; the index ordering is defined by `FaceDir::to_index` in `meshing.rs` and must stay in step with the `FACE_NORMALS` table in `cube.vert`.
|
||||||
|
pub normal: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Vertex {
|
impl Vertex {
|
||||||
|
|
@ -40,7 +44,7 @@ impl Vertex {
|
||||||
/// Describes the layout of individual fields (attributes) within a single vertex.
|
/// Describes the layout of individual fields (attributes) within a single vertex.
|
||||||
///
|
///
|
||||||
/// These 'locations' must match the `layout(location = X)` qualifiers in the vertex shader.
|
/// These 'locations' must match the `layout(location = X)` qualifiers in the vertex shader.
|
||||||
pub fn get_attribute_descriptions() -> [ash::vk::VertexInputAttributeDescription; 2] {
|
pub fn get_attribute_descriptions() -> [ash::vk::VertexInputAttributeDescription; 3] {
|
||||||
[
|
[
|
||||||
// Location 0: position (vec3 -> R32G32B32_SFLOAT)
|
// Location 0: position (vec3 -> R32G32B32_SFLOAT)
|
||||||
ash::vk::VertexInputAttributeDescription::default()
|
ash::vk::VertexInputAttributeDescription::default()
|
||||||
|
|
@ -53,6 +57,12 @@ impl Vertex {
|
||||||
.location(1)
|
.location(1)
|
||||||
.format(ash::vk::Format::R32G32B32_SFLOAT)
|
.format(ash::vk::Format::R32G32B32_SFLOAT)
|
||||||
.offset(12),
|
.offset(12),
|
||||||
|
// Location 2: packed face normal index (uint -> R32_UINT). The shader input must be declared `uint`; reading an integer-formatted attribute through a float declaration is undefined and silently produces garbage on some drivers.
|
||||||
|
ash::vk::VertexInputAttributeDescription::default()
|
||||||
|
.binding(0)
|
||||||
|
.location(2)
|
||||||
|
.format(ash::vk::Format::R32_UINT)
|
||||||
|
.offset(24),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ use crate::world_server::{ServerWorld, cylinder_chunks};
|
||||||
|
|
||||||
/// Upper bound, in chunks, on a client's requested load radius. A larger request is clamped to this, bounding the per-client resident set and the reconcile cost the server performs on the client's behalf.
|
/// Upper bound, in chunks, on a client's requested load radius. A larger request is clamped to this, bounding the per-client resident set and the reconcile cost the server performs on the client's behalf.
|
||||||
// TODO: derive from server configuration and per-tier LOD limits.
|
// TODO: derive from server configuration and per-tier LOD limits.
|
||||||
pub const SERVER_MAX_RADIUS: u16 = 12;
|
pub const SERVER_MAX_RADIUS: u16 = 24;
|
||||||
|
|
||||||
/// Worldgen version stamped on delivered chunk diffs. A single version exists today; this becomes the chunk's stored version once worldgen versioning lands.
|
/// Worldgen version stamped on delivered chunk diffs. A single version exists today; this becomes the chunk's stored version once worldgen versioning lands.
|
||||||
const WORLDGEN_VERSION: u32 = 0;
|
const WORLDGEN_VERSION: u32 = 0;
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,57 @@
|
||||||
# Documentation
|
# Documentation
|
||||||
|
|
||||||
Technical, implementation-facing documentation for the Synvael engine. This directory is the **code-side** counterpart to the game-design specification; it describes how subsystems are built, not what the game should feel like.
|
This is the technical, implementation-facing documentation for Synvael. You can think of this directory as the **code-side** counterpart to the game-design specification. It describes exactly how subsystems are built under the hood, not what the game should feel like to play.
|
||||||
|
|
||||||
## How documentation is layered
|
## How documentation is layered
|
||||||
|
|
||||||
Documentation lives at three altitudes. Each layer answers a different question, and content is kept at the layer it belongs to so that no single file accretes everything.
|
Our documentation lives at three different altitudes. Each layer answers a distinct type of question, and we keep content strictly at the layer it belongs to. This ensures no single file turns into an unreadable monolith.
|
||||||
|
|
||||||
| Layer | Location | Answers | Churn |
|
| Layer | Location | Answers | Churn |
|
||||||
|-------|----------|---------|-------|
|
|-------|----------|---------|-------|
|
||||||
| **Project conventions** | [`AGENTS.md`](../AGENTS.md) | "What rules apply no matter which feature I touch?" | Slow, a finite set of cross-cutting invariants |
|
| **Project conventions** | [`DEVELOPMENT.md`](../DEVELOPMENT.md) | "What rules apply no matter which feature I touch?" | Slow, a finite set of cross-cutting invariants |
|
||||||
| **Subsystem technical docs** | this `docs/` tree + Rust module docs (`//!`, `///`) | "How does *this* subsystem work?" | Grows with features, distributed across files |
|
| **Subsystem technical docs** | this `docs/` tree + Rust module docs (`//!`, `///`) | "How does *this* subsystem work?" | Grows with features, distributed across files |
|
||||||
| **Architecture decisions** | [`docs/adr/`](adr/) | "*Why* was this chosen over the alternatives?" | Append-only, one file per decision |
|
| **Architecture decisions** | [`docs/adr/`](adr/) | "*Why* was this chosen over the alternatives?" | Append-only, one file per decision |
|
||||||
|
|
||||||
The rule that keeps `AGENTS.md` lean: if a piece of documentation is specific to one subsystem, it does **not** go in `AGENTS.md`. It goes in that subsystem's module docs or a `docs/<subsystem>.md` note, and `AGENTS.md` only links to it. `AGENTS.md` is an index and a rulebook, not a container for feature specs.
|
We use a strict rule to keep `DEVELOPMENT.md` lean: if a piece of documentation is specific to only one subsystem, it does **not** go in `DEVELOPMENT.md`. It belongs in that subsystem's module docs or a `docs/<subsystem>.md` note, and `DEVELOPMENT.md` only links to it. `DEVELOPMENT.md` is an index and a rulebook, not a dumping ground for feature specs.
|
||||||
|
|
||||||
### Where to put a new piece of documentation
|
### Where to put a new piece of documentation
|
||||||
|
|
||||||
- A rule true across the whole project (a convention, an invariant) → `AGENTS.md`.
|
Not every subsystem gets a dedicated document. You should default to using module documentation unless the technical design spans across multiple modules.
|
||||||
- How one subsystem is implemented → prefer Rust **module docs** next to the code (`//!` at the top of the module). They cannot drift far from the code and render with `cargo doc`.
|
|
||||||
- Cross-file technical design too large for a doc comment (e.g. the rendering frame graph, the network protocol, the worldgen pipeline) → a `docs/<subsystem>.md` note.
|
- A rule that is true across the whole project (like a convention or an invariant) goes in `DEVELOPMENT.md`.
|
||||||
- The reasoning behind a specific, hard-to-reverse choice → an **ADR** in [`docs/adr/`](adr/).
|
- How a specific subsystem is implemented belongs in Rust **module docs** right next to the code (`//!` at the top of the module). These docs can't drift far from the code and they render cleanly with `cargo doc`.
|
||||||
|
- Cross-file technical designs that are just too large for a doc comment (like the rendering frame graph, the network protocol, or the worldgen pipeline) get a dedicated `docs/<subsystem>.md` note.
|
||||||
|
- The reasoning behind a specific, hard-to-reverse choice becomes an **ADR** in [`docs/adr/`](adr/).
|
||||||
|
|
||||||
## Relationship to the design specification
|
## Relationship to the design specification
|
||||||
|
|
||||||
The canonical **design** specification (intent, world rules, gameplay-system behaviour, and unresolved questions) is maintained separately and is **not** part of this repository. This `docs/` tree records how the engine *implements* those designs.
|
The canonical **design** specification (which covers intent, world rules, gameplay-system behavior, and unresolved questions) is maintained entirely separately and is **not** part of this repository. This `docs/` tree exists strictly to record how the engine *implements* those designs.
|
||||||
|
|
||||||
Each subsystem note should name the design topic it implements (by title, e.g. "Design source: *Worldgen*"), so the trail from intent to implementation exists without coupling the repository to an external location. When the implementation and the design disagree, surface the disagreement rather than silently resolving it in code.
|
To maintain the link between intent and implementation, each subsystem note should explicitly name the design topic it implements (by title, like "Design source: *Worldgen*"). This lets us trace the path from design to code without tightly coupling the repository to an external location. If the implementation and the design ever diverge, make sure to document that discrepancy clearly in the relevant subsystem note or ADR, rather than just silently resolving it in code.
|
||||||
|
|
||||||
## Index
|
## Index
|
||||||
|
|
||||||
- [`adr/`](adr/): Architecture Decision Records.
|
- [`adr/`](adr/): Architecture Decision Records.
|
||||||
- [`adr/0001-record-architecture-decisions.md`](adr/0001-record-architecture-decisions.md): establishes the ADR practice.
|
- [`adr/0001-record-architecture-decisions.md`](adr/0001-record-architecture-decisions.md): Establishes the ADR practice.
|
||||||
- [`adr/0002-half-scale-voxel-grid.md`](adr/0002-half-scale-voxel-grid.md): the half-scale voxel grid.
|
- [`adr/0002-half-scale-voxel-grid.md`](adr/0002-half-scale-voxel-grid.md): The half-scale voxel grid.
|
||||||
- [`adr/0003-seed-deterministic-worldgen.md`](adr/0003-seed-deterministic-worldgen.md): seed-deterministic worldgen.
|
- [`adr/0003-seed-deterministic-worldgen.md`](adr/0003-seed-deterministic-worldgen.md): Seed-deterministic worldgen.
|
||||||
- [`adr/0004-server-authoritative-simulation.md`](adr/0004-server-authoritative-simulation.md): server-authoritative simulation.
|
- [`adr/0004-server-authoritative-simulation.md`](adr/0004-server-authoritative-simulation.md): Server-authoritative simulation.
|
||||||
- [`adr/0005-namespaced-content-ids.md`](adr/0005-namespaced-content-ids.md): namespaced content IDs.
|
- [`adr/0005-namespaced-content-ids.md`](adr/0005-namespaced-content-ids.md): Namespaced content IDs.
|
||||||
- [`adr/0006-base-game-on-modding-api.md`](adr/0006-base-game-on-modding-api.md): base game built on the modding API.
|
- [`adr/0006-base-game-on-modding-api.md`](adr/0006-base-game-on-modding-api.md): Base game built directly on the modding API.
|
||||||
- [`adr/0007-declarative-content-via-modding-api.md`](adr/0007-declarative-content-via-modding-api.md): declarative content loads through the modding API.
|
- [`adr/0007-declarative-content-via-modding-api.md`](adr/0007-declarative-content-via-modding-api.md): Declarative content loads through the modding API.
|
||||||
- [`adr/0008-split-coordinate-entity-positions.md`](adr/0008-split-coordinate-entity-positions.md): split-coordinate entity positions.
|
- [`adr/0008-split-coordinate-entity-positions.md`](adr/0008-split-coordinate-entity-positions.md): Split-coordinate entity positions.
|
||||||
- [`adr/0009-baseline-relative-sparse-chunk-persistence.md`](adr/0009-baseline-relative-sparse-chunk-persistence.md): baseline-relative sparse chunk persistence.
|
- [`adr/0009-baseline-relative-sparse-chunk-persistence.md`](adr/0009-baseline-relative-sparse-chunk-persistence.md): Baseline-relative sparse chunk persistence.
|
||||||
- [`adr/0010-net-crate-async-runtime.md`](adr/0010-net-crate-async-runtime.md): dedicated `net` crate with a confined async runtime.
|
- [`adr/0010-net-crate-async-runtime.md`](adr/0010-net-crate-async-runtime.md): Dedicated `net` crate with a confined async runtime.
|
||||||
- [`adr/0011-authority-stream-for-server-pushed-state.md`](adr/0011-authority-stream-for-server-pushed-state.md): a dedicated authority stream for server-pushed state.
|
- [`adr/0011-authority-stream-for-server-pushed-state.md`](adr/0011-authority-stream-for-server-pushed-state.md): A dedicated authority stream for server-pushed state.
|
||||||
- [`adr/template.md`](adr/template.md): template for new decisions.
|
- [`adr/template.md`](adr/template.md): The template for logging new decisions.
|
||||||
|
|
||||||
Subsystem notes:
|
Subsystem notes:
|
||||||
|
|
||||||
- [`packs.md`](packs.md): data packs & resource packs (load order, layout, resolution).
|
- [`packs.md`](packs.md): Data packs and resource packs (load order, layout, resolution).
|
||||||
- [`rendering.md`](rendering.md): rendering & coordinate gotchas (Vulkan clip space, Blender/glTF import).
|
- [`rendering.md`](rendering.md): Rendering and coordinate gotchas (Vulkan clip space, Blender/glTF import).
|
||||||
- [`chunk_streaming.md`](chunk_streaming.md): chunk streaming and async worker pipeline.
|
- [`chunk_streaming.md`](chunk_streaming.md): Chunk streaming and the async worker pipeline.
|
||||||
- [`meshing.md`](meshing.md): greedy meshing, the mesh worker pool, and frustum culling.
|
- [`meshing.md`](meshing.md): Greedy meshing, the mesh worker pool, and frustum culling.
|
||||||
- [`diagnostics.md`](diagnostics.md): runtime statistics collection and the debug panel.
|
- [`diagnostics.md`](diagnostics.md): Runtime statistics collection and the debug panel.
|
||||||
- [`save_format.md`](save_format.md): chunk persistence, region-file layout, save actor, and load pipeline.
|
- [`save_format.md`](save_format.md): Chunk persistence, region-file layout, save actor, and load pipeline.
|
||||||
|
|
||||||
Further subsystem notes are added here as systems are implemented and locked.
|
We will add further subsystem notes here as those systems are implemented and locked down.
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,24 @@
|
||||||
|
|
||||||
## Context
|
## 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
|
## 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 ADR lives as a single Markdown file inside `docs/adr/`, numbered sequentially (e.g., `0001-...`, `0002-...`).
|
||||||
- Each record carries a status (`Proposed`, `Accepted`, `Deprecated`, or `Superseded by ...`) and a date.
|
- Each record must carry a clear 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`.
|
- 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`.
|
||||||
- New records are started from [`template.md`](template.md).
|
- We start all new records by copying [`template.md`](template.md).
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
- The rationale behind significant choices is preserved with its historical context, independent of how the code later evolves.
|
- The actual rationale behind significant choices is permanently preserved with its historical context, completely independent of how the codebase evolves later.
|
||||||
- `AGENTS.md` stays lean: it can state a rule and link to the ADR that explains it, rather than carrying the justification inline.
|
- `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.
|
||||||
- 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.
|
- 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 records are numbered and append-only, the directory grows monotonically without any single file becoming a bottleneck.
|
- Because the records are numbered and append-only, the directory just grows monotonically over time without any single file turning into a bottleneck.
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,17 @@
|
||||||
|
|
||||||
## Context
|
## 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
|
## 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
|
## 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.
|
- 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 the 8× factor and must be designed against it from the start.
|
- Collision, meshing, LOD selection, and network bandwidth budgets all inherit this 8x factor and have to be designed against it from day one.
|
||||||
- Finer terrain and construction detail become possible, this is the motivating benefit.
|
- The motivating benefit is that we can support much finer terrain and construction detail.
|
||||||
- The choice is load-bearing and expensive to revisit later, since persisted worlds and save formats encode the block scale.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,19 @@
|
||||||
|
|
||||||
## Context
|
## 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
|
## 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.
|
- 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.
|
||||||
- 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.
|
- 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
|
## Consequences
|
||||||
|
|
||||||
- Worldgen code is constrained in its choice of RNG and collection types, and reviewers must watch for nondeterministic iteration order.
|
- 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.
|
||||||
- Identical worlds are guaranteed from identical seeds, on any supported platform.
|
- In exchange, we guarantee identical worlds 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.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,19 @@
|
||||||
|
|
||||||
## Context
|
## 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
|
## 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
|
## Consequences
|
||||||
|
|
||||||
- The engine does not pay the cost of cross-platform float reproducibility for the simulation, only worldgen carries that burden.
|
- The engine avoids the massive ongoing cost of cross-platform float reproducibility for the main simulation. Only worldgen carries that specific burden.
|
||||||
- Clients require prediction and reconciliation logic to stay responsive against an authoritative server.
|
- Clients have to implement prediction and reconciliation logic to actually feel responsive while playing against an authoritative server.
|
||||||
- Cheat resistance follows from authority residing on the server.
|
- We get cheat resistance practically for free since authority strictly resides 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.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,22 @@
|
||||||
|
|
||||||
## Context
|
## 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
|
## 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"`).
|
- 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"`).
|
||||||
- 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.
|
- 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: namespace and id are each `[a-z0-9_-]+` with exactly one `:` between them. Lowercase ASCII only, no uppercase, Unicode, spaces, dots, or slashes.
|
- **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 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.
|
- 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
|
## Consequences
|
||||||
|
|
||||||
- Identifiers are greppable, filesystem-safe, and unambiguous in logs and save files.
|
- Identifiers are highly greppable, inherently filesystem-safe, and totally unambiguous in both 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.
|
- 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.
|
||||||
- A small runtime cost is paid at registration to intern strings, in exchange for handle comparison on hot paths.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,25 @@
|
||||||
|
|
||||||
## Context
|
## 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
|
## 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.
|
- 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 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- `scripting` wraps `shared` types in newtypes rather than implementing `UserData` for them in `shared`, keeping the protocol/data crate free of `mlua`.
|
- 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
|
## 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.
|
- 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 stay stable and discoverable, since it is both the engine's and the modder's surface; engine internals must not leak through it.
|
- 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.
|
||||||
- The client/server trust boundary is enforced at the API layer rather than re-checked ad hoc.
|
- We enforce the client/server trust boundary structurally at the API layer rather than relying on ad hoc checks everywhere.
|
||||||
- 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.
|
- 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.
|
||||||
- Declarative content loading follows the same single-path rule; see [ADR-0007](0007-declarative-content-via-modding-api.md).
|
- 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).
|
||||||
|
|
|
||||||
|
|
@ -5,24 +5,30 @@
|
||||||
|
|
||||||
## Context
|
## 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
|
## 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
|
## Consequences
|
||||||
|
|
||||||
- One source of truth for registration; declarative content and scripted content cannot diverge in behaviour because they end at the same API.
|
- 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.
|
||||||
- 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/`.
|
- 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 long-lived commitment, since data packs in the wild depend on it.
|
- 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 with no server involvement, and are kept conceptually separate from data packs.
|
- Resource packs remain entirely client-side, require zero server involvement, and are kept conceptually isolated from data packs.
|
||||||
- Full subsystem detail (load order, repo and user-data layout, resolution semantics) lives in [`docs/packs.md`](../packs.md).
|
- You can find the full subsystem details (load order, repo layout, user-data layout, and resolution semantics) in [`docs/packs.md`](../packs.md).
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,24 @@
|
||||||
|
|
||||||
## Context
|
## 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
|
## 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.
|
1. A `chunk` anchor (`ChunkPos`): The integer coordinates of the exact chunk that currently contains 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.
|
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
|
## 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.
|
- **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 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.
|
- **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 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.
|
- **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` serializes as a compound struct, ensuring save files do not lose coordinate precision for distant entities.
|
- **Serialization:** `EntityPos` safely serializes as a compound struct, ensuring that our save files never lose coordinate precision for extremely distant entities.
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,23 @@
|
||||||
|
|
||||||
## Context
|
## 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
|
## 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.
|
- **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.** 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.
|
- **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 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.
|
- **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
|
## Consequences
|
||||||
|
|
||||||
- Save size scales with the volume *modified*, not the volume explored. A session that walks across untouched terrain writes nothing.
|
- 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.
|
||||||
- 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.
|
- 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 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.
|
- 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, 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.
|
- 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).
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,25 @@
|
||||||
|
|
||||||
## Context
|
## 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
|
## 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.
|
- `net` completely owns the `quinn`, `tokio`, and `rustls` dependencies. It handles the QUIC endpoints, the full connection lifecycle, and all wire framing.
|
||||||
- `shared` continues to hold only the protocol message *types* (serde, no async).
|
- `shared` remains perfectly clean, holding only the raw protocol message *types* (using `serde`, with zero async logic).
|
||||||
- Both `client` and `server` depend on `net`.
|
- 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
|
## Consequences
|
||||||
|
|
||||||
- `shared` stays lean: consumers of the protocol types do not compile the async stack.
|
- `shared` stays extremely lean. Consumers that only need the protocol types do not have to compile the massive async stack.
|
||||||
- The async surface is quarantined. Only `net` deals with `tokio`, keeping the `server` and `client` loops synchronous and unchanged.
|
- 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 has six crates. `net` sits between `shared` (types it carries) and the two binaries (which drive it).
|
- 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 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.
|
- 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.
|
||||||
- A crypto provider backend is required by `rustls`; the transport code must install one before building QUIC configuration.
|
- Because `rustls` requires a crypto provider backend, the transport code has to manually install one before building any QUIC configuration.
|
||||||
|
|
|
||||||
|
|
@ -5,30 +5,30 @@
|
||||||
|
|
||||||
## Context
|
## 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 **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 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 **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
|
## 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.
|
- 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, so new pushed payloads are added as variants rather than as new streams. `ServerStats` is the first variant; simulation snapshots will join it.
|
- `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 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.
|
- 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 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.
|
- 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.
|
||||||
- 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.
|
- 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
|
## 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.
|
- 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 pushed payload is one enum variant, with no new stream to negotiate on either peer and no `StreamLayout` change.
|
- 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 commits four ids (control 0, reserved 1, authority 2, chunk LOD0 3). Reassigning any of them is a `PROTOCOL_VERSION` bump.
|
- 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 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.
|
- 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 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.
|
- 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.
|
||||||
- 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).
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@
|
||||||
|
|
||||||
## Context
|
## 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
|
## 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
|
## 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.
|
||||||
|
|
|
||||||
|
|
@ -1,91 +1,95 @@
|
||||||
# Chunk streaming
|
# 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
|
## 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
|
## 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
|
## 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.
|
- **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 → 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.
|
- **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 → main):** a `crossbeam-channel` carrying `(ChunkPos, Chunk)`. Each worker clones the `Sender`; the main thread holds the single `Receiver`.
|
- **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 chunk, and sends `(pos, chunk)` back. A blocking `recv` on a worker thread is acceptable because it is not the simulation thread.
|
- **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
|
### 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).
|
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.** 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.
|
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 `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.
|
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
|
### 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 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
|
### 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 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`.
|
- **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` 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).
|
- **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
|
### 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)
|
### 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
|
### 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
|
## 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
|
## 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
|
## 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.
|
- `cylinder_chunks` is tested for 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.
|
- `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`: 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.
|
- `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.
|
||||||
|
|
|
||||||
|
|
@ -1,83 +1,83 @@
|
||||||
# Runtime diagnostics
|
# 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
|
## 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
|
## 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 |
|
| 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` | `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, total device-local memory. Queried once, since every field is immutable for the renderer's lifetime |
|
| `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 allocator |
|
| `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) |
|
| `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` | `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, achieved FPS over the window |
|
| `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, then process CPU and memory against system totals |
|
| `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 utilisation, resident and in-flight chunks, connected clients, entities, players, uptime |
|
| `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
|
### 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
|
## 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
|
## 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
|
### 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
|
## 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
|
## 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/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`: `decode_driver_version` per vendor, and `cull_ratio_percent` including the nothing-uploaded 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`: frame accumulation and panel formatting, including absent optional sources.
|
- `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`: `ServerKind` classification from loopback and non-loopback addresses.
|
- `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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
## 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
|
## The greedy mesher
|
||||||
|
|
||||||
`generate_mesh(chunk, neighbors)` returns `(Vec<Vertex>, Vec<u32>)` 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<Vertex>, Vec<u32>)`. 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 centres block `i` on the interval `[i - 0.5, i + 0.5]`, hence the `± 0.5` offsets throughout the quad emitters. A chunk's geometry therefore spans `[offset - 0.5, offset + CHUNK_SIZE - 0.5]`, **not** `[offset, offset + CHUNK_SIZE]`.
|
### Vertex extents (a shared convention)
|
||||||
|
|
||||||
That half-block shift is duplicated in the frustum cull, which builds each chunk's bounding box from the same shifted minimum corner. Nothing in the type system ties the two together: 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
|
## 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
|
## 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
|
1. The position is still wanted (it is still resident).
|
||||||
2. the generation recorded as in-flight for it still equals the mesh's own generation.
|
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
|
### 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.
|
- **`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 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.
|
- **`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 already-computed geometry is cheap next to computing it, and throttling it would only let completed work pile up.
|
- **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 `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
|
## 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.
|
- **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 column-major while the derivation operates on rows of the combined matrix, so rows are read explicitly.
|
- **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
|
## 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.
|
- **`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 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.
|
- **`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
|
## 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/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`: plane extraction under the Vulkan depth range, and the AABB test on inside, outside, and straddling boxes.
|
- `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`: residency, per-frame budgets, and the generation protocol, driven against a `MeshSink` fake.
|
- `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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
## 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
|
## 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", ... }
|
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
|
## Canonical load order
|
||||||
|
|
||||||
Later layers override earlier ones:
|
Later layers always override earlier ones:
|
||||||
|
|
||||||
```
|
```
|
||||||
base game (assets/scripts + assets/data)
|
base game (assets/scripts + assets/data)
|
||||||
→ data packs (declarative content add/override)
|
→ data packs (adds/overrides declarative content)
|
||||||
→ Lua mods (full API access)
|
→ Lua mods (has full API access)
|
||||||
→ resource packs (client-only, asset overlay, always last so visuals win)
|
→ resource packs (client-only asset overlays, loaded last so they dictate the final visuals)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Repo layout
|
## Repo layout
|
||||||
|
|
@ -47,7 +49,7 @@ base game (assets/scripts + assets/data)
|
||||||
|
|
||||||
## User-data layout
|
## User-data layout
|
||||||
|
|
||||||
Runtime, resolved via the `directories` / `dirs` crate:
|
This is resolved at runtime using the `directories` (or `dirs`) crate:
|
||||||
|
|
||||||
```
|
```
|
||||||
<user-data>/
|
<user-data>/
|
||||||
|
|
|
||||||
|
|
@ -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
|
## 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
|
||||||
|
|
||||||
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 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 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.
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,46 @@
|
||||||
# Save format
|
# 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
|
## 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
|
## 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.
|
- **`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)): 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.
|
- **`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
|
## 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
|
## 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.
|
1. The worker asks the save actor for the stored record at the requested position.
|
||||||
2. **Hit** (`Some(ChunkData)`): the baseline is regenerated (via the LRU baseline cache) and the stored diff is materialized over it.
|
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 regenerated baseline is the chunk.
|
3. **Miss (`None`):** The chunk was never modified, so the freshly regenerated baseline *is* the final chunk.
|
||||||
4. **Save-layer error**: streaming must not wedge, so the chunk falls back to a fresh baseline and the error is logged.
|
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
|
## Related decisions
|
||||||
|
|
||||||
- [ADR-0003](adr/0003-seed-deterministic-worldgen.md): seed-deterministic worldgen — the invariant that makes regen-on-load sound.
|
- [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): baseline-relative sparse persistence — why only diffs are stored.
|
- [ADR-0009](adr/0009-baseline-relative-sparse-chunk-persistence.md): Covers baseline-relative sparse persistence and explains exactly why we only store diffs.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue