From 653fd24e152c25fc86360ed5e96408f3d3a1a1f1 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Wed, 22 Jul 2026 00:17:18 +0200 Subject: [PATCH] docs(server): document client-server chunk delivery transport --- docs/chunk_streaming.md | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/chunk_streaming.md b/docs/chunk_streaming.md index 2d20e8a..5d0fa8e 100644 --- a/docs/chunk_streaming.md +++ b/docs/chunk_streaming.md @@ -10,7 +10,7 @@ Chunk residency is *reconciled* every tick against a **desired set**: the union `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 ECS streaming system `stream_chunks` (in `main.rs`) queries every entity carrying `Player`, `Position`, and `ViewDistance`, and unions each anchor's cylinder into one `HashSet`. Because the sets are unioned, overlapping cylinders deduplicate automatically and a chunk is evicted only when *no* player wants it. See [Multiplayer](#multiplayer) below. +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. ## The worker pool @@ -43,9 +43,38 @@ Between a chunk being dispatched and the worker returning it, the anchor may mov 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)`. +## Network delivery (client ↔ 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). + +### 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. + +### 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: + +- **Inbound** (`ChunkSubscribe` arriving async, consumed by the sync loop) reuses the `crossbeam` `ServerEvent` channel, surfaced as `ServerEvent::ChunkSubscribe { id, request }`. The async `send` is non-blocking; the sync loop drains with `try_iter`. +- **Outbound** (a `ChunkMessage` produced by the sync loop, consumed async) uses a **`tokio` unbounded MPSC**. Its `send` is synchronous, so the non-async loop pushes without a runtime, while the pump's `recv().await` composes into its `tokio::select!`. A blocking `crossbeam` receiver would freeze the current-thread runtime and cannot appear in a `select!` arm. The tokio sender is wrapped so neither `server` nor `client` names a tokio type: `ChunkSink` (server → client deliveries) and `ChunkSubscriber` (client → server subscriptions). See [ADR-0010](adr/0010-net-crate-async-runtime.md). + +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. + +### 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. + +### 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. + +### 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. It drains deliveries under a per-frame meshing budget: `ChunkMessage::Chunk` → materialize → `generate_mesh` → `insert_mesh` (skipping empty meshes); `ChunkMessage::Drop` → `remove_mesh`. It **also** evicts chunks outside `LOAD_RADIUS` locally, independent of the server `Drop`, so memory stays bounded even if the server is slow. + ## Multiplayer -No per-player streaming pipeline exists. Every player anchor's cylinder is unioned into one desired set, reconciled against one chunk store served by one worker pool. A player joining or leaving is simply an entity entering or leaving the ECS query; it requires no streaming-specific code. The only per-player concern is the loading gate, which for a joining player checks that player's cylinder against the resident set rather than the global set. Backpressure and fairness across players (a bounded job channel, nearest-first priority) are shared-pipeline concerns deferred for later. +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. ## Level of detail