Merge pull request #5 from Cryoforge-Nexus/feat/networking-foundation

feat(net): QUIC transport foundation — endpoints, codec, and handshake
This commit is contained in:
Serkyo 2026-07-15 18:52:24 +02:00 committed by GitHub
commit 2ca9ae801d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 3346 additions and 956 deletions

View file

@ -20,15 +20,16 @@ The canonical game-*design* specification (intent, world rules, gameplay behavio
## Workspace layout
Cargo workspace (resolver = "3", edition 2024) with four crates under `crates/`:
Cargo workspace (resolver = "3", edition 2024) with six crates under `crates/`:
- `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.
- `server`: binary. Authoritative game simulation (voxel world, combat, players). Used both for dedicated multiplayer hosts and as the simulation backend for single-player.
- `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.
- `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.
- `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).
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`; 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 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`.
## Modding API (Lua): dogfooded
@ -123,6 +124,12 @@ The workspace opts into strict linting: Clippy's `pedantic` group plus restricti
- **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).

971
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -41,5 +41,4 @@ unimplemented = "warn"
# Pedantic exceptions (too noisy)
module_name_repetitions = "allow"
must_use_candidate = "allow"
missing_errors_doc = "allow"
must_use_candidate = "allow"

View file

@ -19,3 +19,4 @@ raw-window-handle.workspace = true
ash-window.workspace = true
serde_json.workspace = true
shared = { path = "../shared" }
net = { version = "0.1.0", path = "../net" }

View file

@ -59,7 +59,10 @@ impl Camera {
/// Advances the camera by a single frame, applying `input` accumulated over `dt` seconds.
pub fn update(&mut self, input: &InputState, dt: f32) {
// Apply accumulated mouse motion to the orientation. A downward mouse delta (positive y) lowers the pitch, so the vertical term is subtracted.
#[expect(clippy::cast_possible_truncation)]
#[expect(
clippy::cast_possible_truncation,
reason = "mouse deltas are small; f32 precision is sufficient for camera input"
)]
{
self.yaw += input.mouse_delta.0 as f32 * self.sensitivity;
self.pitch -= input.mouse_delta.1 as f32 * self.sensitivity;

View file

@ -22,8 +22,10 @@ use winit::window::{CursorGrabMode, Window, WindowId};
/// Transient per-frame input state sampled from window and device events.
///
/// Keyboard fields hold whether a movement key is currently pressed. `mouse_delta` accumulates raw pointer motion between frames and is consumed (reset to zero) once applied to the camera.
// The bools are independent per-key held states, for which a flat struct is the clearest form.
#[expect(clippy::struct_excessive_bools)]
#[expect(
clippy::struct_excessive_bools,
reason = "per-key held states are independent; a flat bool struct is the clearest representation"
)]
#[derive(Default)]
struct InputState {
/// Whether the "move forward" key (W) is held.
@ -54,6 +56,8 @@ struct App {
input: InputState,
/// Timestamp of the previous frame, used to derive delta-time. `None` before the first frame.
last_frame: Option<Instant>,
/// Receives the outcome of the background connect and handshake, drained non-blocking from the event loop. `None` before the connection is started and once the outcome has been observed.
handshake_rx: Option<net::ConnectOutcome>,
}
impl Default for App {
@ -69,6 +73,7 @@ impl Default for App {
),
input: InputState::default(),
last_frame: None,
handshake_rx: None,
}
}
}
@ -141,10 +146,16 @@ impl ApplicationHandler for App {
self.window = Some(window);
self.renderer = Some(renderer);
#[expect(clippy::expect_used)]
#[expect(
clippy::expect_used,
reason = "startup asset load; a missing worldgen config is unrecoverable at launch"
)]
let config_str = std::fs::read_to_string("assets/data/worldgen/default.json")
.expect("Failed to read worldgen config");
#[expect(clippy::expect_used)]
#[expect(
clippy::expect_used,
reason = "startup config parse; a malformed worldgen config is unrecoverable at launch"
)]
let worldgen_config: shared::generator::WorldGenConfig =
serde_json::from_str(&config_str).expect("Failed to parse worldgen config");
@ -160,12 +171,30 @@ impl ApplicationHandler for App {
indices.len()
);
#[expect(clippy::expect_used)]
#[expect(
clippy::expect_used,
reason = "the renderer is assigned earlier in this function"
)]
self.renderer
.as_mut()
.expect("Renderer initialized")
.update_mesh(&vertices, &indices)
.expect("Failed to upload terrain to GPU");
// Kick off a background connect + handshake to the local server.
let hello = shared::protocol::ClientHello {
protocol_version: shared::protocol::PROTOCOL_VERSION,
client_build: env!("CARGO_PKG_VERSION").to_owned(),
player_identity: shared::protocol::PlayerIdentity {
display_name: "Player".to_owned(),
},
installed_packs: Vec::new(),
requested_features: shared::protocol::FeatureFlags(0),
};
let server_addr =
std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, net::DEFAULT_PORT));
info!("Connecting to server at {server_addr}");
self.handshake_rx = Some(net::connect_in_background(server_addr, hello));
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
@ -189,6 +218,29 @@ impl ApplicationHandler for App {
}
}
WindowEvent::RedrawRequested => {
// Non-blocking check for the handshake outcome.
let mut handshake_done = false;
if let Some(rx) = self.handshake_rx.as_ref() {
match rx.try_recv() {
Ok(Ok(ack)) => {
info!(
protocol_version = ack.protocol_version,
"handshake complete"
);
handshake_done = true;
}
Ok(Err(reason)) => {
warn!("handshake failed: {reason}");
handshake_done = true;
}
// Empty: not ready yet. Disconnected: the network thread ended.
Err(_) => {}
}
}
if handshake_done {
self.handshake_rx = None;
}
// Derive delta-time from the previous frame so movement is framerate-independent. The first frame has no predecessor and therefore advances by zero seconds.
let now = Instant::now();
let dt = self

View file

@ -6,7 +6,8 @@ use shared::world::{BlockId, CHUNK_SIZE, Chunk};
#[expect(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::too_many_lines
clippy::too_many_lines,
reason = "voxel coordinates and vertex counts are small and lossless as f32/u32; the per-face unrolling is intentionally long"
)]
pub fn generate_mesh(chunk: &Chunk) -> (Vec<Vertex>, Vec<u32>) {
let mut vertices = Vec::new();

21
crates/net/Cargo.toml Normal file
View file

@ -0,0 +1,21 @@
[package]
name = "net"
license.workspace = true
authors.workspace = true
edition.workspace = true
version.workspace = true
[dependencies]
crossbeam-channel = "0.5.16"
postcard.workspace = true
quinn = "0.11.11"
rcgen = "0.14.8"
rustls = "0.23.41"
serde.workspace = true
thiserror.workspace = true
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "net", "sync", "io-util", "time"] }
tracing.workspace = true
shared = { path = "../shared" }
[lints]
workspace = true

134
crates/net/src/codec.rs Normal file
View file

@ -0,0 +1,134 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Length-prefixed `postcard` frame codec.
//!
//! Encodes and decodes one logical protocol message per record on a QUIC stream, using a length prefix so a reader can recover record boundaries from a byte stream.
use crate::error::NetError;
/// The maximum payload length, in bytes, accepted on the control stream (64 KiB), matching the mod-payload cap. Higher-bandwidth tiers such as chunk streaming define their own caps.
pub const MAX_CONTROL_FRAME_LEN: usize = 64 * 1024;
/// The maximum number of bytes an unsigned LEB128 varint may occupy for a `u64` value (`ceil(64 / 7)`).
const MAX_VARINT_LEN: usize = 10;
/// Appends `value` to `buf` as an unsigned LEB128 varint.
///
/// Each byte carries seven value bits in little-endian group order; the high bit (`0x80`) is a continuation flag set on every byte except the last.
fn write_varint(value: u64, buf: &mut Vec<u8>) {
let mut remaining = value;
loop {
// Extract the low seven bits of the remaining value.
let mut byte = (remaining & 0x7f) as u8;
remaining >>= 7;
if remaining != 0 {
// Further bytes follow, so mark the continuation bit.
byte |= 0x80;
}
buf.push(byte);
if remaining == 0 {
break;
}
}
}
/// Reads an unsigned LEB128 varint from the front of `bytes`, returning the decoded value and the number of bytes consumed.
///
/// # Errors
///
/// Returns [`NetError::MalformedVarint`] if the encoding exceeds the ten bytes a `u64` may occupy, or [`NetError::UnexpectedEof`] if the buffer ends while the continuation bit is still set.
fn read_varint(bytes: &[u8]) -> Result<(u64, usize), NetError> {
let mut value: u64 = 0;
let mut shift: u32 = 0;
for (index, &byte) in bytes.iter().enumerate() {
if index >= MAX_VARINT_LEN {
return Err(NetError::MalformedVarint);
}
// Accumulate the seven payload bits at their little-endian position.
value |= u64::from(byte & 0x7f) << shift;
if byte & 0x80 == 0 {
return Ok((value, index + 1));
}
shift += 7;
}
// The continuation bit was still set when the buffer ran out.
Err(NetError::UnexpectedEof)
}
/// Encodes `msg` as a single length-prefixed `postcard` frame into a freshly allocated buffer.
///
/// # Errors
///
/// Returns [`NetError::Postcard`] if `msg` fails to serialize.
fn encode_frame<T: serde::Serialize>(msg: &T) -> Result<Vec<u8>, NetError> {
let payload = postcard::to_stdvec(msg)?;
let mut frame = Vec::new();
write_varint(payload.len() as u64, &mut frame);
frame.extend_from_slice(&payload);
Ok(frame)
}
/// Writes one length-prefixed `postcard` frame to a quinn send stream.
///
/// # Errors
///
/// Returns [`NetError::Postcard`] if `msg` fails to serialize, or [`NetError::Write`] if the send stream rejects the bytes.
pub async fn write_frame<T: serde::Serialize>(
stream: &mut quinn::SendStream,
msg: &T,
) -> Result<(), NetError> {
let frame = encode_frame(msg)?;
stream.write_all(&frame).await?;
Ok(())
}
/// Reads one length-prefixed `postcard` frame from a quinn recv stream and decodes it.
///
/// # Errors
///
/// Returns [`NetError::MalformedVarint`] if the length prefix is overlong, [`NetError::FrameTooLarge`] if the declared length exceeds `max_len`, [`NetError::Read`] if the stream ends before the frame is complete, or [`NetError::Postcard`] if the payload fails to deserialize.
pub async fn read_frame<T: serde::de::DeserializeOwned>(
stream: &mut quinn::RecvStream,
max_len: usize,
) -> Result<T, NetError> {
// The prefix length is not known in advance, so bytes are pulled one at a time until a byte without the continuation flag is read, then decoded by the shared pure helper.
let mut prefix = Vec::with_capacity(MAX_VARINT_LEN);
loop {
let mut byte = [0u8; 1];
stream.read_exact(&mut byte).await?;
prefix.push(byte[0]);
if byte[0] & 0x80 == 0 {
break;
}
if prefix.len() > MAX_VARINT_LEN {
return Err(NetError::MalformedVarint);
}
}
let (len, _consumed) = read_varint(&prefix)?;
// The declared length is validated before allocating the payload buffer.
let checked_len = check_frame_len(len, max_len)?;
let mut payload = vec![0u8; checked_len];
stream.read_exact(&mut payload).await?;
Ok(postcard::from_bytes(&payload)?)
}
/// Validates a declared frame length against `max_len`.
///
/// # Errors
///
/// Returns [`NetError::FrameTooLarge`] if `len` exceeds `max_len`.
fn check_frame_len(len: u64, max_len: usize) -> Result<usize, NetError> {
if len > max_len as u64 {
return Err(NetError::FrameTooLarge { len, max: max_len });
}
#[expect(
clippy::cast_possible_truncation,
reason = "len <= max_len (usize) checked above"
)]
Ok(len as usize)
}
#[cfg(test)]
#[path = "tests/codec.rs"]
mod tests;

125
crates/net/src/endpoint.rs Normal file
View file

@ -0,0 +1,125 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! QUIC endpoint construction for client and server.
//!
//! Builds the `quinn` endpoints and configures ALPN and TLS 1.3. Both endpoints negotiate the `synvael` application protocol; a peer advertising any other ALPN identifier is rejected during the TLS handshake.
use std::net::SocketAddr;
use std::sync::Arc;
use quinn::crypto::rustls::{QuicClientConfig, QuicServerConfig};
use quinn::{ClientConfig, Endpoint, ServerConfig};
use rustls::DigitallySignedStruct;
use rustls::SignatureScheme;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName, UnixTime};
use tracing::debug;
use crate::error::NetError;
/// The Application-Layer Protocol Negotiation identifier for the Synvael protocol.
pub const ALPN: &[u8] = b"synvael";
/// Installs the process-wide default `rustls` `CryptoProvider` if one is not already installed.
fn ensure_crypto_provider() {
if rustls::crypto::ring::default_provider()
.install_default()
.is_err()
{
debug!("rustls crypto provider already installed; reusing existing default");
}
}
/// Builds a QUIC server endpoint bound to `bind`, using a freshly generated self-signed certificate and the `synvael` ALPN.
///
/// # Errors
///
/// Returns [`NetError::Rcgen`] if certificate generation fails, [`NetError::Rustls`] if the TLS configuration cannot be built, [`NetError::NoInitialCipherSuite`] if the configuration lacks a TLS 1.3 cipher suite, or [`NetError::Io`] if the UDP socket cannot be bound.
pub fn server_endpoint(bind: SocketAddr) -> Result<Endpoint, NetError> {
ensure_crypto_provider();
// Generate a self-signed certificate for the "localhost" subject. The subject is not validated by the current client verifier and exists only to satisfy certificate structure.
let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()])?;
let cert_der = cert.cert.der().clone();
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der()));
let mut tls_config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![cert_der], key_der)?;
tls_config.alpn_protocols = vec![ALPN.to_vec()];
let quic_config = QuicServerConfig::try_from(tls_config)?;
let server_config = ServerConfig::with_crypto(Arc::new(quic_config));
Ok(Endpoint::server(server_config, bind)?)
}
/// Builds a QUIC client endpoint bound to an ephemeral local address, configured with the `synvael` ALPN and a permissive certificate verifier.
///
/// # Errors
///
/// Returns [`NetError::NoInitialCipherSuite`] if the TLS configuration lacks a TLS 1.3 cipher suite, or [`NetError::Io`] if the local UDP socket cannot be bound.
pub fn client_endpoint() -> Result<Endpoint, NetError> {
ensure_crypto_provider();
let mut tls_config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCert))
.with_no_client_auth();
tls_config.alpn_protocols = vec![ALPN.to_vec()];
let quic_config = QuicClientConfig::try_from(tls_config)?;
let client_config = ClientConfig::new(Arc::new(quic_config));
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().map_err(std::io::Error::other)?)?;
endpoint.set_default_client_config(client_config);
Ok(endpoint)
}
/// A certificate verifier that unconditionally accepts any server certificate, disabling server authentication.
// TODO: replace with trust-on-first-use + certificate pinning.
#[derive(Debug)]
struct AcceptAnyServerCert;
impl ServerCertVerifier for AcceptAnyServerCert {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
// Advertise the schemes the installed provider can verify, so the shim does not artificially restrict handshake negotiation.
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
#[cfg(test)]
#[path = "tests/endpoint.rs"]
mod tests;

73
crates/net/src/error.rs Normal file
View file

@ -0,0 +1,73 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Error types for the net crate.
use thiserror::Error;
/// Errors produced by the framing codec, endpoint construction, and stream I/O helpers.
#[derive(Debug, Error)]
pub enum NetError {
/// A `postcard` serialization or deserialization operation failed.
#[error("postcard codec error: {0}")]
Postcard(#[from] postcard::Error),
/// An underlying byte-stream I/O operation failed.
#[error("i/o error: {0}")]
Io(#[from] std::io::Error),
/// A declared frame length exceeded the caller-supplied maximum, indicating a malicious or corrupt peer.
#[error("frame length {len} exceeds maximum {max}")]
FrameTooLarge {
/// The frame length declared by the length prefix, in bytes.
len: u64,
/// The maximum payload length accepted by the reader, in bytes.
max: usize,
},
/// The stream ended before a complete frame (prefix or payload) had been read.
#[error("unexpected end of stream while reading a frame")]
UnexpectedEof,
/// A varint length prefix was malformed: either overlong or otherwise invalid.
#[error("malformed varint length prefix")]
MalformedVarint,
/// Writing bytes to a quinn send stream failed.
#[error("quinn write error: {0}")]
Write(#[from] quinn::WriteError),
/// Reading an exact number of bytes from a quinn recv stream failed.
#[error("quinn read error: {0}")]
Read(#[from] quinn::ReadExactError),
/// Generation of the self-signed server certificate failed.
#[error("certificate generation error: {0}")]
Rcgen(#[from] rcgen::Error),
/// Construction of the `rustls` TLS configuration failed.
#[error("rustls configuration error: {0}")]
Rustls(#[from] rustls::Error),
/// The `rustls` configuration lacked a TLS 1.3 cipher suite, which QUIC requires.
#[error("no initial cipher suite for quic: {0}")]
NoInitialCipherSuite(#[from] quinn::crypto::rustls::NoInitialCipherSuite),
}
/// Errors produced while performing the Synvael application handshake.
#[derive(Debug, Error)]
pub enum HandshakeError {
/// The synchronous `quinn` connect call failed before the connection attempt began.
#[error("quic connect error: {0}")]
Connect(#[from] quinn::ConnectError),
/// The QUIC connection failed to establish or was lost during the handshake.
#[error("quic connection error: {0}")]
Connection(#[from] quinn::ConnectionError),
/// A control-stream frame failed to encode, decode, or transfer.
#[error("control frame codec error: {0}")]
Codec(#[from] NetError),
/// The server refused the handshake. Carries the structured reason received (client side) or sent (server side) over the wire.
#[error("handshake rejected: {0:?}")]
Rejected(shared::protocol::HandshakeReject),
/// A control message other than the one expected for this handshake step arrived.
#[error("unexpected control message during handshake")]
UnexpectedMessage,
/// The client's protocol version did not match the server's. Returned locally by the server after it has sent a [`shared::protocol::HandshakeReject`] to the client.
#[error("protocol version mismatch: client {client}, server {server}")]
VersionMismatch {
/// Protocol version advertised by the client in its `ClientHello`.
client: u32,
/// Protocol version the server was built against (`shared::protocol::PROTOCOL_VERSION`).
server: u32,
},
}

174
crates/net/src/handshake.rs Normal file
View file

@ -0,0 +1,174 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Synvael application handshake over an established QUIC connection.
use quinn::{Connection, Incoming, RecvStream, SendStream, VarInt};
use shared::protocol::{
ClientHello, ControlMessage, Disconnect, HandshakeAck, HandshakeReject, PROTOCOL_VERSION,
RejectReason, StreamLayout,
};
use tracing::{info, warn};
use crate::codec::{MAX_CONTROL_FRAME_LEN, read_frame, write_frame};
use crate::error::HandshakeError;
/// Application close code used when a peer is rejected during the handshake.
const CLOSE_CODE_REJECTED: u32 = 1;
/// Application close code used for an orderly, graceful disconnect.
const CLOSE_CODE_GRACEFUL: u32 = 0;
/// Upper bound on the wait for a rejected client to read the `HandshakeReject` and close, before the server tears the connection down anyway.
const REJECT_DELIVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
/// A completed client-side handshake: an established connection, the retained control stream, and the server's acceptance parameters.
#[derive(Debug)]
pub struct Connected {
/// The established QUIC connection. Additional streams are opened from it.
pub connection: Connection,
/// The control stream (stream 0), retained so it can carry chat, commands, and disconnect. Never finished after the handshake.
pub control: (SendStream, RecvStream),
/// The negotiated session parameters returned by the server.
pub ack: HandshakeAck,
}
/// A completed server-side handshake for one connection: the established connection, the retained control stream, and the client's presented identity.
#[derive(Debug)]
pub struct ServerConnection {
/// The established QUIC connection. Additional streams are accepted from it.
pub connection: Connection,
/// The control stream (stream 0), retained so it can carry chat, commands, and disconnect. Never finished after the handshake.
pub control: (SendStream, RecvStream),
/// The `ClientHello` the accepted client presented.
pub hello: ClientHello,
}
/// Connects to a Synvael server: establishes the QUIC connection, opens the control stream, sends `ClientHello`, and awaits the server's response.
///
/// # Errors
///
/// Returns [`HandshakeError::Connect`] if the connection attempt cannot be initiated, [`HandshakeError::Connection`] if the QUIC connection fails to establish, [`HandshakeError::Codec`] if the `ClientHello` cannot be written or the reply cannot be read, [`HandshakeError::Rejected`] if the server refuses the handshake, and [`HandshakeError::UnexpectedMessage`] if the server replies with a control message other than `HandshakeAck` or `HandshakeReject`.
pub async fn connect(
endpoint: &quinn::Endpoint,
server_addr: std::net::SocketAddr,
server_name: &str,
hello: ClientHello,
) -> Result<Connected, HandshakeError> {
let connection = endpoint.connect(server_addr, server_name)?.await?;
// The client opens the control stream; the server accepts it. The stream first appears on the server once the `ClientHello` bytes are written below.
let (mut send, mut recv) = connection.open_bi().await?;
write_frame(&mut send, &ControlMessage::ClientHello(hello)).await?;
match read_frame::<ControlMessage>(&mut recv, MAX_CONTROL_FRAME_LEN).await? {
ControlMessage::HandshakeAck(ack) => {
info!(
protocol_version = ack.protocol_version,
server_build = %ack.server_build,
"handshake accepted by server"
);
Ok(Connected {
connection,
control: (send, recv),
ack,
})
}
ControlMessage::HandshakeReject(rej) => {
warn!(reason = ?rej.reason, detail = %rej.detail, "handshake rejected by server");
Err(HandshakeError::Rejected(rej))
}
_ => Err(HandshakeError::UnexpectedMessage),
}
}
/// Accepts one incoming connection: completes the QUIC handshake, reads the client's `ClientHello`, validates it, and replies with `HandshakeAck` or `HandshakeReject`.
///
/// On a protocol-version mismatch a [`HandshakeReject`] is sent to the client, the connection is closed with [`CLOSE_CODE_REJECTED`], and [`HandshakeError::VersionMismatch`] is returned locally.
///
/// # Errors
///
/// Returns [`HandshakeError::Connection`] if the QUIC connection fails to establish or the control stream cannot be accepted, [`HandshakeError::Codec`] if the `ClientHello` cannot be read or a reply cannot be written, [`HandshakeError::UnexpectedMessage`] if the first control message is not a `ClientHello`, and [`HandshakeError::VersionMismatch`] if the client's protocol version does not match the server's.
pub async fn accept_connection(
incoming: Incoming,
server_build: String,
tick_rate_hint: u16,
) -> Result<ServerConnection, HandshakeError> {
let connection = incoming.await?;
// The client opened the control stream; the server accepts it here.
let (mut send, mut recv) = connection.accept_bi().await?;
let ControlMessage::ClientHello(hello) =
read_frame::<ControlMessage>(&mut recv, MAX_CONTROL_FRAME_LEN).await?
else {
return Err(HandshakeError::UnexpectedMessage);
};
if hello.protocol_version != PROTOCOL_VERSION {
let reject = HandshakeReject {
reason: RejectReason::ProtocolMismatch,
detail: format!(
"client protocol version {}, server protocol version {PROTOCOL_VERSION}",
hello.protocol_version
),
upgrade_url: None,
};
warn!(
client = hello.protocol_version,
server = PROTOCOL_VERSION,
"rejecting client on protocol mismatch"
);
// Send the rejection, then keep the connection alive until the client has read it and closed. `Connection::close` (and dropping the connection) discards buffered stream data, so an immediate close would race the client's read and lose the reject frame. The wait is bounded so a misbehaving client cannot park the accept task indefinitely.
write_frame(&mut send, &ControlMessage::HandshakeReject(reject)).await?;
let _ = send.finish();
let _ = tokio::time::timeout(REJECT_DELIVERY_TIMEOUT, connection.closed()).await;
connection.close(
VarInt::from_u32(CLOSE_CODE_REJECTED),
b"protocol version mismatch",
);
return Err(HandshakeError::VersionMismatch {
client: hello.protocol_version,
server: PROTOCOL_VERSION,
});
}
// TODO: validate installed_packs against the world's required packs once the modlist-matching concept lands.
let ack = HandshakeAck {
protocol_version: PROTOCOL_VERSION,
server_build,
world_packs: vec![],
missing_packs: vec![],
stream_layout: StreamLayout::default(),
tick_rate_hint,
};
write_frame(&mut send, &ControlMessage::HandshakeAck(ack)).await?;
info!(
display_name = %hello.player_identity.display_name,
"handshake accepted"
);
Ok(ServerConnection {
connection,
control: (send, recv),
hello,
})
}
/// Sends a `Disconnect` control message and then closes the connection cleanly.
///
/// The control stream's send half is borrowed mutably to write the final frame; the connection is then closed with [`CLOSE_CODE_GRACEFUL`]. A failure to write the `Disconnect` frame is logged rather than propagated, since the connection is closed unconditionally afterwards. Both [`Connected`] and [`ServerConnection`] expose their parts as `connection` and `control`, so either can call this with `(&conn.connection, &mut conn.control.0, reason)`.
pub async fn graceful_disconnect(connection: &Connection, send: &mut SendStream, reason: &str) {
if let Err(error) = write_frame(
send,
&ControlMessage::Disconnect(Disconnect {
reason: reason.to_owned(),
}),
)
.await
{
warn!(%error, "failed to send disconnect frame; closing connection regardless");
}
connection.close(VarInt::from_u32(CLOSE_CODE_GRACEFUL), reason.as_bytes());
}

23
crates/net/src/lib.rs Normal file
View file

@ -0,0 +1,23 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! QUIC transport, connection lifecycle, and wire framing for the Synvael client-server protocol.
//!
//! This crate owns the asynchronous runtime and the transport dependencies, keeping them out of the lean `shared` protocol crate. Protocol message types live in `shared`; this crate is responsible only for carrying them over the wire.
//!
//! The synchronous simulation loop (`server`) and windowing loop (`client`) never touch the async runtime directly. They exchange messages with the network over channels, so the async runtime stays confined to this crate.
pub mod codec;
pub mod endpoint;
pub mod error;
pub mod handshake;
pub mod runtime;
pub use runtime::{ConnectOutcome, NetworkServer, ServerEvent, connect_in_background};
/// Default UDP port the server binds and the client connects to when none is configured.
// TODO: make the bind address and port configurable through server/client configuration.
pub const DEFAULT_PORT: u16 = 25565;
#[cfg(test)]
#[path = "tests/handshake.rs"]
mod handshake_tests;

261
crates/net/src/runtime.rs Normal file
View file

@ -0,0 +1,261 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Threaded `tokio` runtime bridge between the async network and the synchronous simulation.
use std::net::SocketAddr;
use std::thread;
use shared::protocol::{ClientHello, HandshakeAck};
use tracing::{info, warn};
use crate::endpoint::{client_endpoint, server_endpoint};
use crate::error::NetError;
use crate::handshake::{ServerConnection, accept_connection, connect};
/// Channel receiver delivering the outcome of a background client connect: the negotiated [`HandshakeAck`] on success, or a human-readable error string on failure.
pub type ConnectOutcome = crossbeam_channel::Receiver<Result<HandshakeAck, String>>;
/// An event surfaced by the network thread to the synchronous server loop.
#[derive(Debug)]
pub enum ServerEvent {
/// A client completed the Synvael handshake. Carries the stable per-session id and the `ClientHello` it presented.
ClientConnected {
/// Stable identifier assigned to this session for the lifetime of the connection.
id: u64,
/// The identity and build parameters the client advertised.
hello: ClientHello,
},
/// A previously connected client's session ended.
ClientDisconnected {
/// Identifier of the session that ended, matching the earlier [`ServerEvent::ClientConnected`].
id: u64,
/// Human-readable description of why the connection closed.
reason: String,
},
}
/// Handle to the background networking thread and its owned `tokio` runtime.
#[derive(Debug)]
pub struct NetworkServer {
/// Events produced by the accept loop, drained by the synchronous simulation thread.
events: crossbeam_channel::Receiver<ServerEvent>,
/// Shutdown signal. Dropping this sender resolves the accept loop's receiver and breaks the loop.
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
/// Join handle for the network thread, awaited on drop for an orderly shutdown.
thread: Option<thread::JoinHandle<()>>,
}
impl NetworkServer {
/// Spawns a dedicated network thread, builds a current-thread `tokio` runtime on it, binds a QUIC server endpoint on `bind`, and runs the accept loop.
///
/// # Errors
///
/// Returns [`NetError::Io`] if the runtime cannot be built, the network thread cannot be spawned, or the thread exits before reporting a bound address, and any error from [`server_endpoint`] if the endpoint cannot be constructed or bound.
pub fn spawn(
bind: SocketAddr,
server_build: String,
tick_rate_hint: u16,
) -> Result<(Self, SocketAddr), NetError> {
let (events_tx, events_rx) = crossbeam_channel::unbounded();
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
// Reports the bound address (or the error that prevented binding) back to this thread, so `spawn` can surface it synchronously.
let (ready_tx, ready_rx) = crossbeam_channel::bounded::<Result<SocketAddr, NetError>>(1);
let thread = thread::Builder::new()
.name("net-server".to_owned())
.spawn(move || {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
let _ = ready_tx.send(Err(NetError::Io(error)));
return;
}
};
runtime.block_on(async move {
// The endpoint is constructed inside the runtime context because `quinn` binds its driver task onto the current runtime.
let endpoint = match server_endpoint(bind) {
Ok(endpoint) => endpoint,
Err(error) => {
let _ = ready_tx.send(Err(error));
return;
}
};
let local_addr = match endpoint.local_addr() {
Ok(addr) => addr,
Err(error) => {
let _ = ready_tx.send(Err(NetError::Io(error)));
return;
}
};
// If the caller has already given up, there is nothing to serve.
if ready_tx.send(Ok(local_addr)).is_err() {
return;
}
accept_loop(
endpoint,
events_tx,
shutdown_rx,
server_build,
tick_rate_hint,
)
.await;
});
})
.map_err(NetError::Io)?;
let local_addr = match ready_rx.recv() {
Ok(Ok(addr)) => addr,
Ok(Err(error)) => return Err(error),
Err(_) => {
return Err(NetError::Io(std::io::Error::other(
"network thread exited before reporting a bound address",
)));
}
};
Ok((
Self {
events: events_rx,
shutdown: Some(shutdown_tx),
thread: Some(thread),
},
local_addr,
))
}
/// Drains every [`ServerEvent`] currently queued, without blocking.
pub fn poll_events(&self) -> impl Iterator<Item = ServerEvent> + '_ {
self.events.try_iter()
}
}
impl Drop for NetworkServer {
fn drop(&mut self) {
// Dropping the sender resolves the accept loop's shutdown receiver, breaking the loop and letting `block_on` return so the thread unwinds and the runtime is dropped.
self.shutdown.take();
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
/// Accepts incoming QUIC connections until the endpoint stops yielding them or a shutdown is signalled, spawning one handshake task per connection.
async fn accept_loop(
endpoint: quinn::Endpoint,
events: crossbeam_channel::Sender<ServerEvent>,
mut shutdown: tokio::sync::oneshot::Receiver<()>,
server_build: String,
tick_rate_hint: u16,
) {
// Session ids are handed out sequentially; the accept loop is the sole assigner, so a plain counter suffices.
let mut next_id: u64 = 0;
loop {
tokio::select! {
incoming = endpoint.accept() => {
let Some(incoming) = incoming else { break };
let id = next_id;
next_id += 1;
tokio::spawn(handle_connection(
incoming,
id,
events.clone(),
server_build.clone(),
tick_rate_hint,
));
}
// Resolves when the `NetworkServer` handle is dropped (sender gone) or an explicit signal is sent.
_ = &mut shutdown => break,
}
}
info!("network accept loop shutting down");
}
/// Performs the handshake for one incoming connection and, on success, reports connect and disconnect events for its session.
async fn handle_connection(
incoming: quinn::Incoming,
id: u64,
events: crossbeam_channel::Sender<ServerEvent>,
server_build: String,
tick_rate_hint: u16,
) {
match accept_connection(incoming, server_build, tick_rate_hint).await {
Ok(ServerConnection {
connection, hello, ..
}) => {
// If the receiver is gone the server is shutting down; drop the connection silently.
if events
.send(ServerEvent::ClientConnected { id, hello })
.is_err()
{
return;
}
let reason = connection.closed().await;
let _ = events.send(ServerEvent::ClientDisconnected {
id,
reason: reason.to_string(),
});
}
Err(error) => {
warn!(%error, id, "connection handshake failed");
}
}
}
/// Runs a one-shot connect and Synvael handshake against `server_addr` on a background `tokio` thread, reporting the outcome to the returned receiver.
#[must_use]
pub fn connect_in_background(server_addr: SocketAddr, hello: ClientHello) -> ConnectOutcome {
let (outcome_tx, outcome_rx) = crossbeam_channel::bounded(1);
// Retained so a failure to spawn the thread can still be reported to the caller.
let spawn_err_tx = outcome_tx.clone();
let spawned = thread::Builder::new()
.name("net-client".to_owned())
.spawn(move || {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
let _ = outcome_tx.send(Err(format!("failed to build tokio runtime: {error}")));
return;
}
};
runtime.block_on(async move {
let endpoint = match client_endpoint() {
Ok(endpoint) => endpoint,
Err(error) => {
let _ = outcome_tx.send(Err(error.to_string()));
return;
}
};
// The server name matches the self-signed certificate's subject; the current verifier accepts any certificate regardless.
match connect(&endpoint, server_addr, "localhost", hello).await {
Ok(connected) => {
if outcome_tx.send(Ok(connected.ack.clone())).is_err() {
return;
}
// Keep the connection alive on the network thread until the server closes it. A full client session pump is a later concept.
let reason = connected.connection.closed().await;
info!(%reason, "server connection closed");
}
Err(error) => {
let _ = outcome_tx.send(Err(error.to_string()));
}
}
});
});
if let Err(error) = spawned {
let _ = spawn_err_tx.send(Err(format!("failed to spawn network thread: {error}")));
}
outcome_rx
}

View file

@ -0,0 +1,118 @@
// SPDX-License-Identifier: AGPL-3.0-only
use super::*;
use shared::protocol::{
ClientHello, ControlMessage, FeatureFlags, PROTOCOL_VERSION, PlayerIdentity,
};
/// Round-trips a set of boundary values through the varint codec, asserting both the decoded value and the exact byte length consumed.
#[test]
fn varint_round_trip_boundaries() -> Result<(), NetError> {
let cases = [
0,
1,
127,
128,
16_383,
16_384,
u64::from(u32::MAX),
u64::MAX,
];
for value in cases {
let mut buf = Vec::new();
write_varint(value, &mut buf);
let (decoded, consumed) = read_varint(&buf)?;
assert_eq!(decoded, value, "decoded value mismatch");
assert_eq!(
consumed,
buf.len(),
"consumed length must equal encoded length"
);
}
Ok(())
}
/// A frame encodes as `[varint payload length][payload]`, and decoding the prefix recovers exactly the payload byte count.
#[test]
fn encode_frame_prefixes_payload_length() -> Result<(), NetError> {
let msg = ControlMessage::ClientHello(ClientHello {
protocol_version: PROTOCOL_VERSION,
client_build: "synvael-client-0.1.0".to_string(),
player_identity: PlayerIdentity {
display_name: "Player1".to_string(),
},
installed_packs: Vec::new(),
requested_features: FeatureFlags(0),
});
let payload = postcard::to_stdvec(&msg)?;
let frame = encode_frame(&msg)?;
let (declared_len, prefix_bytes) = read_varint(&frame)?;
assert_eq!(
declared_len,
payload.len() as u64,
"prefix must equal payload length"
);
assert_eq!(
&frame[prefix_bytes..],
payload.as_slice(),
"payload bytes must follow the prefix unchanged"
);
Ok(())
}
/// Confirms the documented one/two-byte boundary encodings so a regression in the continuation logic is caught directly.
#[test]
fn varint_boundary_lengths() {
let mut buf = Vec::new();
write_varint(127, &mut buf);
assert_eq!(buf.len(), 1, "127 must encode in a single byte");
buf.clear();
write_varint(128, &mut buf);
assert_eq!(buf.len(), 2, "128 must encode in two bytes");
}
/// A varint whose final byte still sets the continuation bit is a truncated buffer and must error rather than panic.
#[test]
fn varint_truncated_is_error() {
// Two bytes both flagged as "continued", with no terminating byte.
let truncated = [0x80u8, 0x80u8];
assert!(
read_varint(&truncated).is_err(),
"truncated varint must return an error"
);
}
/// An encoding longer than the ten bytes a `u64` can occupy is rejected as overlong rather than silently accepted.
#[test]
fn varint_overlong_is_error() {
// Eleven continuation bytes followed by a terminator exceeds the u64 limit.
let overlong = [0x80u8; 11];
assert!(
read_varint(&overlong).is_err(),
"overlong varint must return an error"
);
}
/// A declared length within the cap is accepted; one exceeding it is rejected as `FrameTooLarge` before any allocation.
#[test]
fn frame_len_bound_is_enforced() {
assert_eq!(
check_frame_len(64, 128).ok(),
Some(64),
"a length within the cap is accepted"
);
assert_eq!(
check_frame_len(128, 128).ok(),
Some(128),
"a length equal to the cap is accepted"
);
assert!(
matches!(
check_frame_len(129, 128),
Err(NetError::FrameTooLarge { len: 129, max: 128 })
),
"a length over the cap must be rejected",
);
}

View file

@ -0,0 +1,24 @@
// SPDX-License-Identifier: AGPL-3.0-only
use super::*;
#[test]
fn alpn_identifier_is_synvael() {
assert_eq!(ALPN, b"synvael");
}
// A tokio runtime is required because `quinn::Endpoint` spawns its driver task on construction.
#[tokio::test]
async fn server_endpoint_constructs_and_binds() -> Result<(), NetError> {
let bind = "127.0.0.1:0".parse().map_err(std::io::Error::other)?;
let endpoint = server_endpoint(bind)?;
// A concrete port is assigned once the UDP socket is bound.
assert_ne!(endpoint.local_addr()?.port(), 0, "socket must bind a port");
Ok(())
}
#[tokio::test]
async fn client_endpoint_constructs_and_binds() -> Result<(), NetError> {
client_endpoint()?;
Ok(())
}

View file

@ -0,0 +1,102 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Loopback integration tests for the Synvael application handshake.
//!
//! Each test binds a real QUIC server endpoint on `127.0.0.1:0`, reads the OS-assigned port, and drives a client through the full `ClientHello` -> `HandshakeAck` / `HandshakeReject` exchange, exercising the async `read_frame`/`write_frame` path end-to-end.
use crate::endpoint::{client_endpoint, server_endpoint};
use crate::error::HandshakeError;
use crate::handshake::{accept_connection, connect};
use shared::protocol::{ClientHello, FeatureFlags, PROTOCOL_VERSION, PlayerIdentity, RejectReason};
/// Builds a `ClientHello` for `display_name` advertising `protocol_version`.
fn hello(display_name: &str, protocol_version: u32) -> ClientHello {
ClientHello {
protocol_version,
client_build: "synvael-client-test".to_owned(),
player_identity: PlayerIdentity {
display_name: display_name.to_owned(),
},
installed_packs: vec![],
requested_features: FeatureFlags(0),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn happy_path_completes_handshake() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let server = server_endpoint("127.0.0.1:0".parse()?)?;
let server_addr = server.local_addr()?;
// Accept exactly one connection on the server, returning the observed identity. The connection is held open until the client closes it, so the ack frame is reliably delivered before teardown.
let server_task = tokio::spawn(async move {
let incoming = server.accept().await.ok_or("server endpoint closed")?;
let conn = accept_connection(incoming, "synvael-server-test".to_owned(), 20).await?;
let name = conn.hello.player_identity.display_name.clone();
conn.connection.closed().await;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(name)
});
let client = client_endpoint()?;
let connected = connect(
&client,
server_addr,
"localhost",
hello("Tester", PROTOCOL_VERSION),
)
.await?;
assert_eq!(
connected.ack.protocol_version, PROTOCOL_VERSION,
"server must ack with the matching protocol version"
);
// Close the client connection so the server's `closed()` wait resolves.
drop(connected);
let observed_name = server_task.await??;
assert_eq!(
observed_name, "Tester",
"server must observe the client's display name"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn version_mismatch_is_rejected() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let server = server_endpoint("127.0.0.1:0".parse()?)?;
let server_addr = server.local_addr()?;
let server_task = tokio::spawn(async move {
let incoming = server.accept().await.ok_or("server endpoint closed")?;
// The server is expected to return VersionMismatch after sending the reject.
let result = accept_connection(incoming, "synvael-server-test".to_owned(), 20).await;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(result.is_err())
});
let client = client_endpoint()?;
let result = connect(
&client,
server_addr,
"localhost",
hello("Tester", PROTOCOL_VERSION + 1),
)
.await;
match result {
Err(HandshakeError::Rejected(rej)) => {
assert_eq!(
rej.reason,
RejectReason::ProtocolMismatch,
"rejection must cite a protocol mismatch"
);
}
other => return Err(format!("expected a rejection, got {other:?}").into()),
}
assert!(
server_task.await??,
"server must return an error on mismatch"
);
Ok(())
}

View file

@ -6,6 +6,10 @@ use crate::error::RendererError;
use ash::{Device, Instance, khr, vk};
/// Picks a physical device (GPU) that supports the required features and extensions.
///
/// # Errors
///
/// Returns [`RendererError::VulkanError`] if physical devices cannot be enumerated, or [`RendererError::NoSuitableGpu`] if none meets the requirements.
pub fn pick_physical_device(
instance: &Instance,
surface_loader: &khr::surface::Instance,
@ -27,6 +31,10 @@ pub fn pick_physical_device(
}
/// Creates a logical device and retrieves the graphics queue.
///
/// # Errors
///
/// Returns [`RendererError::VulkanError`] if the device cannot be created.
pub fn create_logical_device(
instance: &Instance,
physical_device: vk::PhysicalDevice,
@ -58,6 +66,10 @@ pub fn create_logical_device(
}
/// Finds a queue family that supports both graphics commands and presentation.
///
/// # Errors
///
/// Returns [`RendererError::VulkanError`] if surface-support queries fail, or [`RendererError::NoSuitableGpu`] if no family supports both graphics and presentation.
pub fn find_graphics_queue_family(
instance: &Instance,
physical_device: vk::PhysicalDevice,
@ -67,7 +79,10 @@ pub fn find_graphics_queue_family(
let props = unsafe { instance.get_physical_device_queue_family_properties(physical_device) };
for (index, prop) in props.iter().enumerate() {
#[expect(clippy::expect_used)]
#[expect(
clippy::expect_used,
reason = "a physical device's queue-family count never approaches u32::MAX"
)]
let index = u32::try_from(index).expect("Queue family index exceeds u32 range");
let graphics = prop.queue_flags.contains(vk::QueueFlags::GRAPHICS);
let present = unsafe {

View file

@ -6,6 +6,10 @@ use std::ffi::{CStr, c_char};
use tracing::{debug, error, info, warn};
/// Creates a Vulkan instance and optionally a debug messenger.
///
/// # Errors
///
/// Returns [`RendererError::VulkanError`] if instance creation fails, or if the debug messenger cannot be created in debug builds.
pub fn create_instance(
entry: &Entry,
required_extensions: &[*const c_char],
@ -62,6 +66,10 @@ pub fn create_instance(
}
/// The callback function invoked by Vulkan's validation layers.
///
/// # Safety
///
/// Invoked by the Vulkan loader, which must pass a valid `p_callback_data` pointer whose `p_message` is either null or a valid NUL-terminated C string. Not to be called directly.
unsafe extern "system" fn vulkan_debug_callback(
message_severity: vk::DebugUtilsMessageSeverityFlagsEXT,
_message_type: vk::DebugUtilsMessageTypeFlagsEXT,

View file

@ -36,6 +36,10 @@ impl Renderer {
/// This function loads the Vulkan library, creates an instance, selects a GPU,
/// and initializes a logical device with a graphics queue.
///
/// # Errors
///
/// Returns [`RendererError`] if any initialization step fails: loading Vulkan, creating the instance, surface, device, swapchain, pipeline, allocator, or initial geometry.
///
/// # Panics
///
/// Panics if `MAX_FRAMES_IN_FLIGHT` or vertex data sizes exceed `u32`/`u64` limits.
@ -96,7 +100,10 @@ impl Renderer {
let command_pool = unsafe { device.create_command_pool(&pool_create_info, None)? };
// 9. Command Buffers
#[expect(clippy::expect_used)]
#[expect(
clippy::expect_used,
reason = "MAX_FRAMES_IN_FLIGHT is a small compile-time constant"
)]
let alloc_info = vk::CommandBufferAllocateInfo::default()
.command_pool(command_pool)
.level(vk::CommandBufferLevel::PRIMARY)
@ -163,6 +170,10 @@ impl Renderer {
}
/// Creates a GPU memory allocator.
///
/// # Errors
///
/// Returns [`RendererError::AllocationError`] if the allocator cannot be initialized.
fn create_allocator(
instance: &ash::Instance,
device: &ash::Device,
@ -183,6 +194,10 @@ fn create_allocator(
}
/// Creates the 3D geometry buffers (vertex and index) for a cube.
///
/// # Errors
///
/// Returns [`RendererError::AllocationError`] if GPU memory cannot be allocated, or [`RendererError::VulkanError`] if a buffer cannot be created.
fn create_geometry(
device: &ash::Device,
allocator: &mut Allocator,
@ -257,6 +272,10 @@ fn create_geometry(
}
/// Creates the depth buffer resources (image, memory, and view).
///
/// # Errors
///
/// Returns [`RendererError::AllocationError`] if GPU memory cannot be allocated, or [`RendererError::VulkanError`] if the depth image or its view cannot be created.
fn create_depth_resources(
device: &ash::Device,
allocator: &mut Allocator,
@ -318,6 +337,10 @@ fn create_depth_resources(
}
/// Helper function to create and populate a GPU buffer.
///
/// # Errors
///
/// Returns [`RendererError::AllocationError`] if GPU memory cannot be allocated, or [`RendererError::VulkanError`] if the buffer cannot be created or bound.
fn create_gpu_buffer(
device: &ash::Device,
allocator: &mut Allocator,

View file

@ -24,7 +24,10 @@ impl Vertex {
///
/// # Panics
/// Panics if the size of the vertex structure exceeds the maximum value of a 32-bit unsigned integer.
#[expect(clippy::expect_used)]
#[expect(
clippy::expect_used,
reason = "the vertex struct size is far below u32::MAX"
)]
pub fn get_binding_description() -> ash::vk::VertexInputBindingDescription {
ash::vk::VertexInputBindingDescription::default()
.binding(0)

View file

@ -10,6 +10,10 @@ use std::io::Cursor;
/// Helper to load SPIR-V bytes and create a Vulkan Shader Module.
///
/// Vulkan expects shader code to be 32-bit aligned; `ash::util::read_spv` is used to correctly interpret the raw bytes as a slice of `u32`.
///
/// # Errors
///
/// Returns [`RendererError::IoError`] if `bytes` is not valid, 32-bit-aligned SPIR-V, or [`RendererError::VulkanError`] if module creation fails on the device.
pub fn create_shader_module(
device: &Device,
bytes: &[u8],
@ -27,9 +31,16 @@ pub fn create_shader_module(
/// 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.
///
/// # Errors
///
/// Returns [`RendererError::VulkanError`] if the device fails to create the pipeline layout.
pub fn create_pipeline_layout(device: &Device) -> Result<vk::PipelineLayout, RendererError> {
// A single push constant range is defined for the MVP matrix, allowing it to be updated for every draw call with high efficiency.
#[expect(clippy::expect_used)]
#[expect(
clippy::expect_used,
reason = "size_of::<Mat4>() is 64 bytes, well within u32 range"
)]
let push_constant_range = vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::VERTEX)
.offset(0)
@ -47,6 +58,10 @@ pub fn create_pipeline_layout(device: &Device) -> Result<vk::PipelineLayout, Ren
/// Creates a Graphics Pipeline for voxel rendering using Vulkan 1.3 Dynamic Rendering.
///
/// The pipeline encapsulates the entire state of the GPU for a specific draw operation, including shader stages, vertex input layout, rasterization settings, and blending.
///
/// # Errors
///
/// Returns [`RendererError::InvalidString`] if the shader entry-point name cannot be built, [`RendererError::IoError`] if an embedded shader is not valid SPIR-V, or [`RendererError::VulkanError`] if shader-module or pipeline creation fails on the device.
pub fn create_graphics_pipeline(
device: &Device,
layout: vk::PipelineLayout,
@ -147,6 +162,10 @@ pub fn create_graphics_pipeline(
}
/// Loads the vertex and fragment shader modules from embedded bytes.
///
/// # Errors
///
/// Returns [`RendererError::IoError`] if an embedded shader is not valid SPIR-V, or [`RendererError::VulkanError`] if module creation fails on the device.
fn load_shader_modules(
device: &Device,
) -> Result<(vk::ShaderModule, vk::ShaderModule), RendererError> {

View file

@ -17,14 +17,17 @@ pub struct Renderer {
/// The debug messenger for validation layer output.
pub(crate) debug_messenger: vk::DebugUtilsMessengerEXT,
/// Handle to the selected physical device (GPU).
#[expect(dead_code)]
#[expect(dead_code, reason = "retained for later device-capability queries")]
pub(crate) physical_device: vk::PhysicalDevice,
/// The logical Vulkan device.
pub(crate) device: Device,
/// The queue used for graphics operations.
pub(crate) graphics_queue: vk::Queue,
/// Index of the graphics queue family.
#[expect(dead_code)]
#[expect(
dead_code,
reason = "retained for later queue-family-dependent operations"
)]
pub(crate) graphics_queue_index: u32,
/// Surface extension loader.
pub(crate) surface_loader: khr::surface::Instance,
@ -37,7 +40,7 @@ pub struct Renderer {
/// Images acquired from the swapchain.
pub(crate) swapchain_images: Vec<vk::Image>,
/// The pixel format of the swapchain images.
#[expect(dead_code)]
#[expect(dead_code, reason = "retained for later swapchain recreation")]
pub(crate) swapchain_format: vk::Format,
/// The dimensions of the swapchain images.
pub(crate) swapchain_extent: vk::Extent2D,
@ -76,6 +79,10 @@ pub struct Renderer {
impl Renderer {
/// Renders a single frame.
///
/// # 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.
pub fn draw_frame(&mut self, camera_view: glam::Mat4) -> Result<(), RendererError> {
let sync = self
.sync
@ -154,6 +161,10 @@ impl Renderer {
}
/// Records the drawing commands into the given command buffer.
///
/// # Errors
///
/// Returns [`RendererError::VulkanError`] if beginning or ending command-buffer recording fails.
fn record_commands(
&self,
cmd: vk::CommandBuffer,
@ -262,7 +273,10 @@ impl Renderer {
self.graphics_pipeline,
);
#[expect(clippy::cast_precision_loss)]
#[expect(
clippy::cast_precision_loss,
reason = "swapchain extents are within f32's exact-integer range"
)]
let viewport = vk::Viewport {
x: 0.0,
y: 0.0,
@ -287,7 +301,10 @@ impl Renderer {
let aspect =
f64::from(self.swapchain_extent.width) / f64::from(self.swapchain_extent.height);
#[expect(clippy::cast_possible_truncation)]
#[expect(
clippy::cast_possible_truncation,
reason = "the aspect ratio is a small value; f32 precision is sufficient"
)]
let projection = glam::camera::rh::proj::vulkan::perspective(
45.0_f32.to_radians(),
aspect as f32,
@ -313,6 +330,10 @@ impl Renderer {
}
/// Transitions the swapchain image back to the presentation layout.
///
/// # Errors
///
/// Returns [`RendererError::VulkanError`] if the pipeline barrier command cannot be recorded.
fn transition_to_present_layout(
&self,
cmd: vk::CommandBuffer,
@ -350,8 +371,12 @@ impl Renderer {
/// Replaces the currently rendering mesh with a new set of vertices and indices.
///
/// # Errors
/// Returns a `RendererError` if new Vulkan buffers cannot be allocated or created.
#[expect(clippy::cast_possible_truncation)]
///
/// Returns [`RendererError::AllocationError`] if GPU memory cannot be allocated, or [`RendererError::VulkanError`] if the vertex or index buffers cannot be created.
#[expect(
clippy::cast_possible_truncation,
reason = "a chunk mesh's index count never approaches u32::MAX"
)]
pub fn update_mesh(
&mut self,
vertices: &[Vertex],

View file

@ -5,6 +5,10 @@ use ash::{Entry, Instance, khr, vk};
use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
/// Creates a Vulkan surface for the given window.
///
/// # Errors
///
/// Returns [`RendererError::VulkanError`] if the platform surface cannot be created for the given display and window handles.
pub fn create_surface(
entry: &Entry,
instance: &Instance,

View file

@ -4,6 +4,14 @@ use crate::error::RendererError;
use ash::{Device, Instance, khr, vk};
/// Creates a swapchain and retrieves its images.
///
/// # Errors
///
/// Returns [`RendererError::VulkanError`] if a surface query fails or the swapchain and its images cannot be created.
///
/// # Panics
///
/// Panics if the driver reports zero surface formats, which the Vulkan specification forbids for a supported surface.
pub fn create_swapchain(
instance: &Instance,
physical_device: vk::PhysicalDevice,
@ -87,6 +95,10 @@ pub fn create_swapchain(
}
/// Creates image views for the swapchain images.
///
/// # Errors
///
/// Returns [`RendererError::VulkanError`] if the device fails to create an image view.
pub fn create_image_views(
device: &Device,
images: &[vk::Image],

View file

@ -14,6 +14,10 @@ pub struct SyncPrimitives {
}
/// Creates all synchronization primitives for the given number of frames and images.
///
/// # Errors
///
/// Returns [`RendererError::VulkanError`] if the device fails to create a semaphore or fence.
pub fn create_sync_primitives(
device: &Device,
max_frames_in_flight: usize,
@ -43,6 +47,10 @@ pub fn create_sync_primitives(
}
/// Destroys all synchronization primitives.
///
/// # Safety
///
/// The caller must ensure every primitive in `sync` was created from `device`, is no longer in use by any in-flight GPU work, and is not destroyed again.
pub unsafe fn destroy_sync_primitives(device: &Device, sync: SyncPrimitives) {
unsafe {
for semaphore in sync.image_available {

View file

@ -13,12 +13,5 @@ pub fn add(left: u64, right: u64) -> u64 {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
#[path = "tests/lib.rs"]
mod tests;

View file

@ -0,0 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-only
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}

View file

@ -14,6 +14,7 @@ bevy_ecs = "0.19"
crossbeam-channel = "0.5.16"
glam.workspace = true
lru = "0.18.1"
net = { version = "0.1.0", path = "../net" }
serde_json.workspace = true
shared = { path = "../shared" }
tracing.workspace = true

View file

@ -62,53 +62,5 @@ impl ChunkCache {
}
#[cfg(test)]
mod tests {
use super::*;
use shared::generator::{VoxelGenerator, WorldGenConfig};
use shared::world::{BlockId, ChunkPos};
use std::num::NonZeroUsize;
/// Builds a generator with a small, cheap terrain configuration for cache tests.
fn test_generator() -> VoxelGenerator {
let config = WorldGenConfig {
base_height: 8,
noise_scale: 0.05,
surface_block: BlockId(1),
subsurface_block: BlockId(2),
stone_block: BlockId(3),
};
VoxelGenerator::new(config, 42)
}
/// A second lookup of the same position must be served from the store, not regenerated.
#[test]
fn repeated_lookup_is_a_cache_hit() {
let generator = test_generator();
let cache = ChunkCache::new(NonZeroUsize::new(4).unwrap_or(NonZeroUsize::MIN));
let pos = ChunkPos::new(0, 0, 0);
let first = cache.get_or_generate(pos, &generator);
let second = cache.get_or_generate(pos, &generator);
// Determinism guarantees identical output, and a single resident entry can only hold if the second call was a hit rather than a fresh generation-and-insert of a distinct value.
assert_eq!(first.blocks, second.blocks);
assert_eq!(cache.len(), 1);
}
/// Inserting beyond capacity evicts the least-recently-used entry.
#[test]
fn exceeding_capacity_evicts_oldest() {
let generator = test_generator();
let cache = ChunkCache::new(NonZeroUsize::new(2).unwrap_or(NonZeroUsize::MIN));
let _ = cache.get_or_generate(ChunkPos::new(0, 0, 0), &generator);
let _ = cache.get_or_generate(ChunkPos::new(1, 0, 0), &generator);
// Touch the first so the second becomes the least-recently-used before the overflow.
let _ = cache.get_or_generate(ChunkPos::new(0, 0, 0), &generator);
let _ = cache.get_or_generate(ChunkPos::new(2, 0, 0), &generator);
assert_eq!(cache.len(), 2);
assert!(cache.contains(ChunkPos::new(0, 0, 0)));
assert!(!cache.contains(ChunkPos::new(1, 0, 0)));
}
}
#[path = "tests/chunk_cache.rs"]
mod tests;

View file

@ -15,6 +15,8 @@ pub mod world_server;
use std::collections::HashSet;
use std::fs;
use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use anyhow::Context;
use bevy_ecs::prelude::{Query, ResMut, Schedule, With, World};
@ -23,6 +25,7 @@ use shared::generator::{VoxelGenerator, WorldGenConfig};
use shared::world::{ChunkPos, EntityPos};
use tracing::{debug, info};
use net::NetworkServer;
use player::{Player, Position, ViewDistance};
use world_server::{ServerWorld, cylinder_chunks};
@ -116,16 +119,26 @@ fn main() -> anyhow::Result<()> {
}
info!("Initial region ready; granting player control");
// Drive several ticks, marching the dummy one chunk along +X between each. The manual movement stands in for network-driven player input and exists only to exercise load/unload as the anchor moves.
for step in 0..5 {
info!(step, "tick");
// Spawn the networking thread and bind the QUIC endpoint. The synchronous simulation loop below communicates with it only by draining events.
let bind = SocketAddr::from((Ipv4Addr::LOCALHOST, net::DEFAULT_PORT));
let (network, local_addr) = NetworkServer::spawn(
bind,
env!("CARGO_PKG_VERSION").to_owned(),
// Placeholder advisory tick rate.
20,
)
.context("spawning network server")?;
info!(%local_addr, "network endpoint listening");
// Authoritative simulation loop.
loop {
schedule.run(&mut world);
let mut movers = world.query_filtered::<&mut Position, With<Player>>();
for mut position in movers.iter_mut(&mut world) {
position.0.chunk.x += 1;
for event in network.poll_events() {
info!(?event, "network event");
}
}
Ok(())
// Advisory ~20 Hz cadence until the real tick scheduler lands.
std::thread::sleep(Duration::from_millis(50));
}
}

View file

@ -47,8 +47,10 @@ pub struct SaveActor {
/// The sending end of the request channel; cloned into every worker so it can issue reads.
request_tx: Sender<SaveRequest>,
/// The actor thread handle, retained so it can be joined on shutdown.
// * Retained ahead of a dedicated shutdown path; not yet read because the server has no graceful-stop sequence.
#[expect(dead_code)]
#[expect(
dead_code,
reason = "retained for a future graceful-shutdown join path"
)]
handle: JoinHandle<()>,
}
@ -106,6 +108,10 @@ fn actor_loop(region_dir: &Path, request_rx: &Receiver<SaveRequest>) {
}
/// Flushes every dirty region to disk, returning the first error while still attempting the rest.
///
/// # Errors
///
/// Returns the first [`SaveError`] produced by [`RegionFile::save`]; remaining dirty regions are still flushed.
fn flush_dirty(regions: &mut HashMap<(i32, i32), RegionFile>) -> Result<(), SaveError> {
let mut result = Ok(());
for region in regions.values_mut() {
@ -125,6 +131,10 @@ fn flush_dirty(regions: &mut HashMap<(i32, i32), RegionFile>) -> Result<(), Save
}
/// Returns the region file covering `pos`, opening and caching it on first access.
///
/// # Errors
///
/// Returns a [`SaveError`] from [`RegionFile::open`] if the region file exists but cannot be read or decoded.
fn region_mut<'a>(
regions: &'a mut HashMap<(i32, i32), RegionFile>,
region_dir: &Path,
@ -141,6 +151,10 @@ fn region_mut<'a>(
}
/// Reads the stored chunk at `pos`, opening and caching its region file on first access.
///
/// # Errors
///
/// Returns a [`SaveError`] if the region file cannot be opened or the stored record cannot be decoded.
fn read_chunk(
regions: &mut HashMap<(i32, i32), RegionFile>,
region_dir: &Path,

View file

@ -43,6 +43,10 @@ pub struct RegionFile {
impl RegionFile {
/// Opens the region file at `path`, or yields an empty region if the file does not yet exist.
///
/// # Errors
///
/// Returns [`SaveError::Io`] if the file cannot be read, a decoding error from [`RegionIndex::decode`] if the index is malformed, or [`SaveError::PayloadTooLarge`] / [`SaveError::Truncated`] if a header entry's span falls outside the file.
pub fn open(path: PathBuf) -> Result<Self, SaveError> {
if !path.exists() {
return Ok(Self {
@ -107,6 +111,10 @@ impl RegionFile {
}
/// Decodes and returns the chunk at `pos`, or `None` if the region holds no record for it.
///
/// # Errors
///
/// Returns a decoding error from [`record::decode`] if the stored record is malformed.
pub fn read_chunk(&self, pos: ChunkPos) -> Result<Option<ChunkData>, SaveError> {
match self.records.get(&pos) {
Some(bytes) => {
@ -118,6 +126,10 @@ impl RegionFile {
}
/// Encodes `data` into a `SYNC` record stamped with `last_modified` and stores it under `pos`.
///
/// # Errors
///
/// Returns an encoding error from [`record::encode`] if serialization fails, or [`SaveError::PayloadTooLarge`] if the encoded record exceeds `u32::MAX` bytes.
pub fn write_chunk(
&mut self,
pos: ChunkPos,
@ -150,6 +162,10 @@ impl RegionFile {
}
/// Flushes the region to disk with a crash-safe whole-file atomic rewrite, clearing the dirty flag.
///
/// # Errors
///
/// Returns [`SaveError::PayloadTooLarge`] if a record's length exceeds `u32::MAX`, or [`SaveError::Io`] if the atomic write to disk fails.
pub fn save(&mut self) -> Result<(), SaveError> {
let image = self.serialize()?;
atomic_write(&self.path, &image)?;
@ -158,6 +174,10 @@ impl RegionFile {
}
/// Builds the complete on-disk file image: the encoded index followed by every record.
///
/// # Errors
///
/// Returns [`SaveError::PayloadTooLarge`] if the index or any record length exceeds `u32::MAX` bytes.
// * NOTE: this is a whole-file rewrite. The right way to do it for large saves is to append changed records into free space and rewriting only the header table, so save cost scales with chunks modified rather than total file size. The free list and absolute offsets already on disk support that switch without a format change.
// TODO: incremental save.
fn serialize(&mut self) -> Result<Vec<u8>, SaveError> {
@ -188,6 +208,10 @@ impl RegionFile {
}
/// Writes `bytes` to `path` via the POSIX atomic-write pattern: `.tmp` + fsync + rename.
///
/// # Errors
///
/// Returns [`SaveError::Io`] if the parent directory cannot be created, or if writing, syncing, or renaming the temporary file fails.
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), SaveError> {
// The region directory is created on demand so the first write to a fresh world succeeds.
if let Some(parent) = path.parent() {
@ -217,139 +241,5 @@ fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), SaveError> {
}
#[cfg(test)]
mod tests {
use super::*;
use shared::world::BlockId;
/// Builds a representative modified chunk with a few edits spanning the local index range.
fn sample(pos: ChunkPos) -> ChunkData {
let mut data = ChunkData::new(pos, 7);
data.set(0, BlockId(4));
data.set(1000, BlockId(9));
data.set(32_767, BlockId(2));
data
}
#[test]
fn region_coords_floor_negative_columns() {
// Truncating division would map -1 to region 0; Euclidean flooring maps it to region -1.
assert_eq!(region_coords(0, 0), (0, 0));
assert_eq!(region_coords(31, 31), (0, 0));
assert_eq!(region_coords(-1, -1), (-1, -1));
assert_eq!(region_coords(-32, -33), (-1, -2));
}
#[test]
fn region_path_names_the_region_file() {
let dir = Path::new("/saves/world/region");
assert_eq!(
region_path(dir, -1, 5),
Path::new("/saves/world/region/r.-1.0.region")
);
}
#[test]
fn open_missing_file_is_empty() -> Result<(), SaveError> {
let dir = tempfile::tempdir()?;
let region = RegionFile::open(dir.path().join("r.0.0.region"))?;
assert!(region.is_empty());
assert_eq!(region.read_chunk(ChunkPos::new(0, 0, 0))?, None);
Ok(())
}
#[test]
fn round_trips_chunks_through_disk() -> Result<(), SaveError> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("r.0.0.region");
let positions = [
ChunkPos::new(0, 0, 0),
ChunkPos::new(1, 2, 3),
ChunkPos::new(-5, 10, -30),
];
let mut region = RegionFile::open(path.clone())?;
for pos in positions {
region.write_chunk(pos, &sample(pos), 123)?;
}
assert!(region.is_dirty());
region.save()?;
assert!(!region.is_dirty());
// Reopen from disk in a fresh instance and confirm every chunk decodes byte-identically.
let reopened = RegionFile::open(path)?;
assert_eq!(reopened.len(), positions.len());
for pos in positions {
assert_eq!(reopened.read_chunk(pos)?, Some(sample(pos)));
}
// A position never written has no record.
assert_eq!(reopened.read_chunk(ChunkPos::new(9, 9, 9))?, None);
Ok(())
}
#[test]
fn record_offsets_are_valid_and_contiguous() -> Result<(), SaveError> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("r.0.0.region");
let mut region = RegionFile::open(path.clone())?;
for pos in [
ChunkPos::new(0, 0, 0),
ChunkPos::new(2, 0, 1),
ChunkPos::new(-1, 4, -1),
] {
region.write_chunk(pos, &sample(pos), 0)?;
}
region.save()?;
let reopened = RegionFile::open(path)?;
let index_len = reopened.index.encode()?.len() as u64;
// Records are packed contiguously immediately after the index, in ascending position order.
let mut expected_offset = index_len;
for (_pos, entry) in reopened.index.entries() {
assert_eq!(entry.offset, expected_offset);
expected_offset += u64::from(entry.length);
}
Ok(())
}
#[test]
fn remove_drops_only_the_named_chunk() -> Result<(), SaveError> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("r.0.0.region");
let kept = ChunkPos::new(0, 0, 0);
let dropped = ChunkPos::new(1, 1, 1);
let mut region = RegionFile::open(path.clone())?;
region.write_chunk(kept, &sample(kept), 0)?;
region.write_chunk(dropped, &sample(dropped), 0)?;
region.save()?;
let mut region = RegionFile::open(path.clone())?;
region.remove_chunk(dropped);
region.save()?;
let reopened = RegionFile::open(path)?;
assert_eq!(reopened.read_chunk(dropped)?, None);
assert_eq!(reopened.read_chunk(kept)?, Some(sample(kept)));
Ok(())
}
#[test]
fn stray_tmp_file_does_not_corrupt_reads() -> Result<(), SaveError> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("r.0.0.region");
let pos = ChunkPos::new(0, 0, 0);
let mut region = RegionFile::open(path.clone())?;
region.write_chunk(pos, &sample(pos), 0)?;
region.save()?;
// A leftover .tmp from an interrupted save must be ignored: only the renamed target is read.
fs::write(dir.path().join("r.0.0.region.tmp"), b"garbage")?;
let reopened = RegionFile::open(path)?;
assert_eq!(reopened.read_chunk(pos)?, Some(sample(pos)));
Ok(())
}
}
#[path = "../tests/region_file.rs"]
mod tests;

View file

@ -0,0 +1,50 @@
// SPDX-License-Identifier: AGPL-3.0-only
use super::*;
use shared::generator::{VoxelGenerator, WorldGenConfig};
use shared::world::{BlockId, ChunkPos};
use std::num::NonZeroUsize;
/// Builds a generator with a small, cheap terrain configuration for cache tests.
fn test_generator() -> VoxelGenerator {
let config = WorldGenConfig {
base_height: 8,
noise_scale: 0.05,
surface_block: BlockId(1),
subsurface_block: BlockId(2),
stone_block: BlockId(3),
};
VoxelGenerator::new(config, 42)
}
/// A second lookup of the same position must be served from the store, not regenerated.
#[test]
fn repeated_lookup_is_a_cache_hit() {
let generator = test_generator();
let cache = ChunkCache::new(NonZeroUsize::new(4).unwrap_or(NonZeroUsize::MIN));
let pos = ChunkPos::new(0, 0, 0);
let first = cache.get_or_generate(pos, &generator);
let second = cache.get_or_generate(pos, &generator);
// Determinism guarantees identical output, and a single resident entry can only hold if the second call was a hit rather than a fresh generation-and-insert of a distinct value.
assert_eq!(first.blocks, second.blocks);
assert_eq!(cache.len(), 1);
}
/// Inserting beyond capacity evicts the least-recently-used entry.
#[test]
fn exceeding_capacity_evicts_oldest() {
let generator = test_generator();
let cache = ChunkCache::new(NonZeroUsize::new(2).unwrap_or(NonZeroUsize::MIN));
let _ = cache.get_or_generate(ChunkPos::new(0, 0, 0), &generator);
let _ = cache.get_or_generate(ChunkPos::new(1, 0, 0), &generator);
// Touch the first so the second becomes the least-recently-used before the overflow.
let _ = cache.get_or_generate(ChunkPos::new(0, 0, 0), &generator);
let _ = cache.get_or_generate(ChunkPos::new(2, 0, 0), &generator);
assert_eq!(cache.len(), 2);
assert!(cache.contains(ChunkPos::new(0, 0, 0)));
assert!(!cache.contains(ChunkPos::new(1, 0, 0)));
}

View file

@ -0,0 +1,136 @@
// SPDX-License-Identifier: AGPL-3.0-only
use super::*;
use shared::world::BlockId;
/// Builds a representative modified chunk with a few edits spanning the local index range.
fn sample(pos: ChunkPos) -> ChunkData {
let mut data = ChunkData::new(pos, 7);
data.set(0, BlockId(4));
data.set(1000, BlockId(9));
data.set(32_767, BlockId(2));
data
}
#[test]
fn region_coords_floor_negative_columns() {
// Truncating division would map -1 to region 0; Euclidean flooring maps it to region -1.
assert_eq!(region_coords(0, 0), (0, 0));
assert_eq!(region_coords(31, 31), (0, 0));
assert_eq!(region_coords(-1, -1), (-1, -1));
assert_eq!(region_coords(-32, -33), (-1, -2));
}
#[test]
fn region_path_names_the_region_file() {
let dir = Path::new("/saves/world/region");
assert_eq!(
region_path(dir, -1, 5),
Path::new("/saves/world/region/r.-1.0.region")
);
}
#[test]
fn open_missing_file_is_empty() -> Result<(), SaveError> {
let dir = tempfile::tempdir()?;
let region = RegionFile::open(dir.path().join("r.0.0.region"))?;
assert!(region.is_empty());
assert_eq!(region.read_chunk(ChunkPos::new(0, 0, 0))?, None);
Ok(())
}
#[test]
fn round_trips_chunks_through_disk() -> Result<(), SaveError> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("r.0.0.region");
let positions = [
ChunkPos::new(0, 0, 0),
ChunkPos::new(1, 2, 3),
ChunkPos::new(-5, 10, -30),
];
let mut region = RegionFile::open(path.clone())?;
for pos in positions {
region.write_chunk(pos, &sample(pos), 123)?;
}
assert!(region.is_dirty());
region.save()?;
assert!(!region.is_dirty());
// Reopen from disk in a fresh instance and confirm every chunk decodes byte-identically.
let reopened = RegionFile::open(path)?;
assert_eq!(reopened.len(), positions.len());
for pos in positions {
assert_eq!(reopened.read_chunk(pos)?, Some(sample(pos)));
}
// A position never written has no record.
assert_eq!(reopened.read_chunk(ChunkPos::new(9, 9, 9))?, None);
Ok(())
}
#[test]
fn record_offsets_are_valid_and_contiguous() -> Result<(), SaveError> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("r.0.0.region");
let mut region = RegionFile::open(path.clone())?;
for pos in [
ChunkPos::new(0, 0, 0),
ChunkPos::new(2, 0, 1),
ChunkPos::new(-1, 4, -1),
] {
region.write_chunk(pos, &sample(pos), 0)?;
}
region.save()?;
let reopened = RegionFile::open(path)?;
let index_len = reopened.index.encode()?.len() as u64;
// Records are packed contiguously immediately after the index, in ascending position order.
let mut expected_offset = index_len;
for (_pos, entry) in reopened.index.entries() {
assert_eq!(entry.offset, expected_offset);
expected_offset += u64::from(entry.length);
}
Ok(())
}
#[test]
fn remove_drops_only_the_named_chunk() -> Result<(), SaveError> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("r.0.0.region");
let kept = ChunkPos::new(0, 0, 0);
let dropped = ChunkPos::new(1, 1, 1);
let mut region = RegionFile::open(path.clone())?;
region.write_chunk(kept, &sample(kept), 0)?;
region.write_chunk(dropped, &sample(dropped), 0)?;
region.save()?;
let mut region = RegionFile::open(path.clone())?;
region.remove_chunk(dropped);
region.save()?;
let reopened = RegionFile::open(path)?;
assert_eq!(reopened.read_chunk(dropped)?, None);
assert_eq!(reopened.read_chunk(kept)?, Some(sample(kept)));
Ok(())
}
#[test]
fn stray_tmp_file_does_not_corrupt_reads() -> Result<(), SaveError> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("r.0.0.region");
let pos = ChunkPos::new(0, 0, 0);
let mut region = RegionFile::open(path.clone())?;
region.write_chunk(pos, &sample(pos), 0)?;
region.save()?;
// A leftover .tmp from an interrupted save must be ignored: only the renamed target is read.
fs::write(dir.path().join("r.0.0.region.tmp"), b"garbage")?;
let reopened = RegionFile::open(path)?;
assert_eq!(reopened.read_chunk(pos)?, Some(sample(pos)));
Ok(())
}

View file

@ -0,0 +1,213 @@
// SPDX-License-Identifier: AGPL-3.0-only
use super::*;
use shared::generator::{VoxelGenerator, WorldGenConfig};
use shared::save::SaveError;
use shared::world::{BlockId, ChunkData, ChunkPos};
use std::collections::HashSet;
use std::time::{Duration, Instant};
use crate::save::{RegionFile, SaveRequest, region_path};
/// Builds a generator with a small, cheap terrain configuration for streaming tests.
fn test_generator() -> VoxelGenerator {
let config = WorldGenConfig {
base_height: 8,
noise_scale: 0.05,
surface_block: BlockId(1),
subsurface_block: BlockId(2),
stone_block: BlockId(3),
};
VoxelGenerator::new(config, 42)
}
/// Builds a server world whose saves resolve against `region_dir`, backed by a small baseline cache.
fn test_world(region_dir: std::path::PathBuf) -> ServerWorld {
let capacity = std::num::NonZeroUsize::new(64).unwrap_or(std::num::NonZeroUsize::MIN);
ServerWorld::new(test_generator(), region_dir, capacity)
}
/// Repeatedly reconciles `desired` until the worker pool reports no outstanding work, returning the final pass's stats. Fails the test if the pool does not drain within a fixed timeout.
fn drain_to_idle(world: &mut ServerWorld, desired: &HashSet<ChunkPos>) -> StreamStats {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let stats = world.reconcile(desired);
if stats.in_flight == 0 {
return stats;
}
assert!(
Instant::now() < deadline,
"worker pool did not drain in time"
);
std::thread::sleep(Duration::from_millis(1));
}
}
/// Issues a flush against the world's save actor and blocks until every dirty region is written. Because write-backs and this flush travel the same sender to the single actor thread, the reply confirms the preceding writes are durable.
fn flush(world: &ServerWorld) -> Result<(), SaveError> {
let (reply_tx, reply_rx) = crossbeam_channel::bounded(1);
// A send error means the actor has already stopped, leaving nothing to flush.
if world
.save_tx
.send(SaveRequest::Flush { reply: reply_tx })
.is_err()
{
return Ok(());
}
reply_rx.recv().unwrap_or(Ok(()))
}
#[test]
fn reconcile_converges_over_multiple_passes() -> Result<(), SaveError> {
// A fresh empty directory means every load is a miss and resolves to the baseline.
let dir = tempfile::tempdir()?;
let mut world = test_world(dir.path().to_path_buf());
let mut desired = HashSet::new();
cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut desired);
// The first pass only dispatches work; because loading is off-thread, nothing is resident yet and every position is in flight.
let first = world.reconcile(&desired);
assert_eq!(first.loaded, 0);
assert_eq!(first.resident, 0);
assert!(first.in_flight > 0);
// Later passes drain finished chunks until the pool is idle, at which point every desired position must be resident.
let final_stats = drain_to_idle(&mut world, &desired);
assert_eq!(final_stats.in_flight, 0);
assert_eq!(final_stats.resident, desired.len());
Ok(())
}
#[test]
fn evicted_chunk_is_not_repopulated_on_arrival() -> Result<(), SaveError> {
let dir = tempfile::tempdir()?;
let mut world = test_world(dir.path().to_path_buf());
let target = ChunkPos::new(0, 0, 0);
let mut desired = HashSet::new();
desired.insert(target);
// Dispatch the chunk, then immediately stop wanting it.
world.reconcile(&desired);
// Every subsequent pass reconciles against an empty desired set, so the finished chunk is discarded on arrival rather than inserted.
let empty = HashSet::new();
let final_stats = drain_to_idle(&mut world, &empty);
assert_eq!(final_stats.in_flight, 0);
assert_eq!(final_stats.resident, 0);
Ok(())
}
#[test]
fn saved_modification_is_applied_over_baseline() -> Result<(), SaveError> {
// A modified chunk is written to disk, then streamed back; the resident chunk must show the edit rather than the bare baseline.
let dir = tempfile::tempdir()?;
let pos = ChunkPos::new(0, 0, 0);
let edited_index = 100u32;
let edited_block = BlockId(999);
let mut data = ChunkData::new(pos, 0);
data.set(edited_index, edited_block);
let mut region = RegionFile::open(region_path(dir.path(), pos.x, pos.z))?;
region.write_chunk(pos, &data, 0)?;
region.save()?;
let mut world = test_world(dir.path().to_path_buf());
let mut desired = HashSet::new();
desired.insert(pos);
drain_to_idle(&mut world, &desired);
// The resident chunk must carry the stored edit layered over its regenerated baseline.
assert!(
world
.chunk(pos)
.is_some_and(|chunk| chunk.blocks[edited_index as usize] == edited_block)
);
Ok(())
}
#[test]
fn dirty_chunk_is_written_back_on_eviction() -> Result<(), SaveError> {
// A resident chunk edited away from its baseline must survive an evict -> flush -> reload round-trip.
let dir = tempfile::tempdir()?;
let pos = ChunkPos::new(0, 0, 0);
let edited_index = 100usize;
let edited_block = BlockId(999);
let mut world = test_world(dir.path().to_path_buf());
let mut desired = HashSet::new();
desired.insert(pos);
drain_to_idle(&mut world, &desired);
// Mutate the resident chunk so it diverges from the baseline the eviction diff regenerates.
assert!(
world
.chunks
.get_mut(&pos)
.map(|chunk| chunk.blocks[edited_index] = edited_block)
.is_some()
);
// Reconciling against an empty desired set evicts the chunk, sending its diff to the actor.
world.reconcile(&HashSet::new());
// The flush shares the eviction's sender, so its reply confirms the write-back is on disk.
flush(&world)?;
// A fresh world over the same directory must stream the chunk back with the edit intact.
let mut reloaded = test_world(dir.path().to_path_buf());
drain_to_idle(&mut reloaded, &desired);
assert!(
reloaded
.chunk(pos)
.is_some_and(|chunk| chunk.blocks[edited_index] == edited_block)
);
Ok(())
}
#[test]
fn clean_chunk_is_not_written_back_on_eviction() -> Result<(), SaveError> {
// An unmodified chunk equals its baseline, so eviction must persist no record for it.
let dir = tempfile::tempdir()?;
let pos = ChunkPos::new(0, 0, 0);
let mut world = test_world(dir.path().to_path_buf());
let mut desired = HashSet::new();
desired.insert(pos);
drain_to_idle(&mut world, &desired);
// Evict without modifying the chunk, then flush.
world.reconcile(&HashSet::new());
flush(&world)?;
// No record may exist for a chunk that never diverged from its baseline.
let region = RegionFile::open(region_path(dir.path(), pos.x, pos.z))?;
assert!(region.read_chunk(pos)?.is_none());
Ok(())
}
#[test]
fn cylinder_contains_expected_columns() {
let mut set = HashSet::new();
cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut set);
assert!(set.contains(&ChunkPos::new(0, 0, 0)));
// A corner cell is outside the disc (dx=2, dz=2 -> 8 > 4).
assert!(!set.contains(&ChunkPos::new(2, 0, 2)));
// An axis cell at exactly the radius is included (dx=2, dz=0 -> 4 == 4).
assert!(set.contains(&ChunkPos::new(2, 0, 0)));
// The vertical extent is radius/2 = 1, so y=2 is out of range.
assert!(!set.contains(&ChunkPos::new(0, 2, 0)));
assert!(set.contains(&ChunkPos::new(0, 1, 0)));
}
#[test]
fn cylinder_translates_with_center() {
let mut origin = HashSet::new();
cylinder_chunks(ChunkPos::new(0, 0, 0), 3, &mut origin);
let mut shifted = HashSet::new();
cylinder_chunks(ChunkPos::new(10, 0, -5), 3, &mut shifted);
// The shape is translation-invariant: the same count regardless of center.
assert_eq!(origin.len(), shifted.len());
}

View file

@ -46,13 +46,17 @@ pub struct ServerWorld {
/// Positions dispatched to a worker but not yet returned, preventing the same chunk being re-dispatched on subsequent passes.
in_flight: HashSet<ChunkPos>,
/// The dedicated thread owning all region files, kept alive for the world's lifetime.
// Retained so its request channel stays open for the workers; not read again after construction.
#[expect(dead_code)]
#[expect(
dead_code,
reason = "retained to keep the save request channel open for the workers"
)]
save_actor: SaveActor,
/// Handles to the generation worker threads, retained so they can be joined on shutdown.
// * NOTE: Retained ahead of a dedicated shutdown path
// TODO: Remove once the server has a graceful-stop sequence
#[expect(dead_code)]
#[expect(
dead_code,
reason = "retained for a future graceful-shutdown join path"
)]
workers: Vec<JoinHandle<()>>,
/// The same read-only generator handle the workers share, held so eviction can regenerate a chunk's baseline to diff against.
generator: Arc<VoxelGenerator>,
@ -226,6 +230,10 @@ fn load_chunk(
}
/// Sends a read request to the save actor and blocks for its reply, mapping a departed actor to an absent record so generation can still proceed.
///
/// # Errors
///
/// Returns the [`SaveError`] reported by the save actor if reading the stored chunk fails. A departed actor yields `Ok(None)` rather than an error.
fn request_saved_chunk(
save_tx: &Sender<SaveRequest>,
pos: ChunkPos,
@ -266,216 +274,5 @@ pub fn cylinder_chunks<S: std::hash::BuildHasher>(
}
#[cfg(test)]
mod tests {
use super::*;
use shared::generator::{VoxelGenerator, WorldGenConfig};
use shared::save::SaveError;
use shared::world::{BlockId, ChunkData, ChunkPos};
use std::collections::HashSet;
use std::time::{Duration, Instant};
use crate::save::{RegionFile, SaveRequest, region_path};
/// Builds a generator with a small, cheap terrain configuration for streaming tests.
fn test_generator() -> VoxelGenerator {
let config = WorldGenConfig {
base_height: 8,
noise_scale: 0.05,
surface_block: BlockId(1),
subsurface_block: BlockId(2),
stone_block: BlockId(3),
};
VoxelGenerator::new(config, 42)
}
/// Builds a server world whose saves resolve against `region_dir`, backed by a small baseline cache.
fn test_world(region_dir: std::path::PathBuf) -> ServerWorld {
let capacity = std::num::NonZeroUsize::new(64).unwrap_or(std::num::NonZeroUsize::MIN);
ServerWorld::new(test_generator(), region_dir, capacity)
}
/// Repeatedly reconciles `desired` until the worker pool reports no outstanding work, returning the final pass's stats. Fails the test if the pool does not drain within a fixed timeout.
fn drain_to_idle(world: &mut ServerWorld, desired: &HashSet<ChunkPos>) -> StreamStats {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let stats = world.reconcile(desired);
if stats.in_flight == 0 {
return stats;
}
assert!(
Instant::now() < deadline,
"worker pool did not drain in time"
);
std::thread::sleep(Duration::from_millis(1));
}
}
/// Issues a flush against the world's save actor and blocks until every dirty region is written. Because write-backs and this flush travel the same sender to the single actor thread, the reply confirms the preceding writes are durable.
fn flush(world: &ServerWorld) -> Result<(), SaveError> {
let (reply_tx, reply_rx) = crossbeam_channel::bounded(1);
// A send error means the actor has already stopped, leaving nothing to flush.
if world
.save_tx
.send(SaveRequest::Flush { reply: reply_tx })
.is_err()
{
return Ok(());
}
reply_rx.recv().unwrap_or(Ok(()))
}
#[test]
fn reconcile_converges_over_multiple_passes() -> Result<(), SaveError> {
// A fresh empty directory means every load is a miss and resolves to the baseline.
let dir = tempfile::tempdir()?;
let mut world = test_world(dir.path().to_path_buf());
let mut desired = HashSet::new();
cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut desired);
// The first pass only dispatches work; because loading is off-thread, nothing is resident yet and every position is in flight.
let first = world.reconcile(&desired);
assert_eq!(first.loaded, 0);
assert_eq!(first.resident, 0);
assert!(first.in_flight > 0);
// Later passes drain finished chunks until the pool is idle, at which point every desired position must be resident.
let final_stats = drain_to_idle(&mut world, &desired);
assert_eq!(final_stats.in_flight, 0);
assert_eq!(final_stats.resident, desired.len());
Ok(())
}
#[test]
fn evicted_chunk_is_not_repopulated_on_arrival() -> Result<(), SaveError> {
let dir = tempfile::tempdir()?;
let mut world = test_world(dir.path().to_path_buf());
let target = ChunkPos::new(0, 0, 0);
let mut desired = HashSet::new();
desired.insert(target);
// Dispatch the chunk, then immediately stop wanting it.
world.reconcile(&desired);
// Every subsequent pass reconciles against an empty desired set, so the finished chunk is discarded on arrival rather than inserted.
let empty = HashSet::new();
let final_stats = drain_to_idle(&mut world, &empty);
assert_eq!(final_stats.in_flight, 0);
assert_eq!(final_stats.resident, 0);
Ok(())
}
#[test]
fn saved_modification_is_applied_over_baseline() -> Result<(), SaveError> {
// A modified chunk is written to disk, then streamed back; the resident chunk must show the edit rather than the bare baseline.
let dir = tempfile::tempdir()?;
let pos = ChunkPos::new(0, 0, 0);
let edited_index = 100u32;
let edited_block = BlockId(999);
let mut data = ChunkData::new(pos, 0);
data.set(edited_index, edited_block);
let mut region = RegionFile::open(region_path(dir.path(), pos.x, pos.z))?;
region.write_chunk(pos, &data, 0)?;
region.save()?;
let mut world = test_world(dir.path().to_path_buf());
let mut desired = HashSet::new();
desired.insert(pos);
drain_to_idle(&mut world, &desired);
// The resident chunk must carry the stored edit layered over its regenerated baseline.
assert!(
world
.chunk(pos)
.is_some_and(|chunk| chunk.blocks[edited_index as usize] == edited_block)
);
Ok(())
}
#[test]
fn dirty_chunk_is_written_back_on_eviction() -> Result<(), SaveError> {
// A resident chunk edited away from its baseline must survive an evict -> flush -> reload round-trip.
let dir = tempfile::tempdir()?;
let pos = ChunkPos::new(0, 0, 0);
let edited_index = 100usize;
let edited_block = BlockId(999);
let mut world = test_world(dir.path().to_path_buf());
let mut desired = HashSet::new();
desired.insert(pos);
drain_to_idle(&mut world, &desired);
// Mutate the resident chunk so it diverges from the baseline the eviction diff regenerates.
assert!(
world
.chunks
.get_mut(&pos)
.map(|chunk| chunk.blocks[edited_index] = edited_block)
.is_some()
);
// Reconciling against an empty desired set evicts the chunk, sending its diff to the actor.
world.reconcile(&HashSet::new());
// The flush shares the eviction's sender, so its reply confirms the write-back is on disk.
flush(&world)?;
// A fresh world over the same directory must stream the chunk back with the edit intact.
let mut reloaded = test_world(dir.path().to_path_buf());
drain_to_idle(&mut reloaded, &desired);
assert!(
reloaded
.chunk(pos)
.is_some_and(|chunk| chunk.blocks[edited_index] == edited_block)
);
Ok(())
}
#[test]
fn clean_chunk_is_not_written_back_on_eviction() -> Result<(), SaveError> {
// An unmodified chunk equals its baseline, so eviction must persist no record for it.
let dir = tempfile::tempdir()?;
let pos = ChunkPos::new(0, 0, 0);
let mut world = test_world(dir.path().to_path_buf());
let mut desired = HashSet::new();
desired.insert(pos);
drain_to_idle(&mut world, &desired);
// Evict without modifying the chunk, then flush.
world.reconcile(&HashSet::new());
flush(&world)?;
// No record may exist for a chunk that never diverged from its baseline.
let region = RegionFile::open(region_path(dir.path(), pos.x, pos.z))?;
assert!(region.read_chunk(pos)?.is_none());
Ok(())
}
#[test]
fn cylinder_contains_expected_columns() {
let mut set = HashSet::new();
cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut set);
assert!(set.contains(&ChunkPos::new(0, 0, 0)));
// A corner cell is outside the disc (dx=2, dz=2 -> 8 > 4).
assert!(!set.contains(&ChunkPos::new(2, 0, 2)));
// An axis cell at exactly the radius is included (dx=2, dz=0 -> 4 == 4).
assert!(set.contains(&ChunkPos::new(2, 0, 0)));
// The vertical extent is radius/2 = 1, so y=2 is out of range.
assert!(!set.contains(&ChunkPos::new(0, 2, 0)));
assert!(set.contains(&ChunkPos::new(0, 1, 0)));
}
#[test]
fn cylinder_translates_with_center() {
let mut origin = HashSet::new();
cylinder_chunks(ChunkPos::new(0, 0, 0), 3, &mut origin);
let mut shifted = HashSet::new();
cylinder_chunks(ChunkPos::new(10, 0, -5), 3, &mut shifted);
// The shape is translation-invariant: the same count regardless of center.
assert_eq!(origin.len(), shifted.len());
}
}
#[path = "tests/world_server.rs"]
mod tests;

View file

@ -41,7 +41,11 @@ impl VoxelGenerator {
/// Generates a complete voxel chunk for the specified position.
#[must_use]
#[expect(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
#[expect(
clippy::cast_possible_wrap,
clippy::cast_possible_truncation,
reason = "chunk and voxel coordinates stay within the ranges these casts assume"
)]
pub fn generate_chunk(&self, pos: ChunkPos) -> Chunk {
let mut chunk = Chunk::default();

View file

@ -5,5 +5,6 @@
//! This crate contains data structures and constants that are used by both the client and the server.
pub mod generator;
pub mod protocol;
pub mod save;
pub mod world;

View file

@ -0,0 +1,198 @@
//! Network protocol types and constants.
use serde::{Deserialize, Serialize};
/// Wire-protocol version. Incremented on any breaking change to the message layout below.
pub const PROTOCOL_VERSION: u32 = 1;
/// Messages carried on the control stream (stream 0): handshake and disconnect.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ControlMessage {
/// First message a client sends after the QUIC/TLS handshake.
ClientHello(ClientHello),
/// Server acceptance carrying negotiated session parameters.
HandshakeAck(HandshakeAck),
/// Server refusal with a machine-readable reason.
HandshakeReject(HandshakeReject),
/// Orderly session teardown initiated by either side.
Disconnect(Disconnect),
}
/// First message a client sends after the QUIC/TLS handshake.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ClientHello {
/// Protocol version the client was built against; compared to `PROTOCOL_VERSION`.
pub protocol_version: u32,
/// Human-readable client build string (e.g. crate version + git hash).
pub client_build: String,
/// Identity the player presents. Minimal for M1.
pub player_identity: PlayerIdentity,
/// Content packs the client has installed. Empty in M1; validated later.
pub installed_packs: Vec<PackRef>,
/// Optional protocol feature bits the client requests. Zero in M1.
pub requested_features: FeatureFlags,
}
/// Server acceptance carrying negotiated session parameters.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HandshakeAck {
/// Server's protocol version (equal to the client's on success).
pub protocol_version: u32,
/// Human-readable server build string.
pub server_build: String,
/// Packs the world requires, each with an optional download source. May include `PackTier::Resource` entries (a server resource pack), which are delivered one-way and applied client-side rather than strict-matched; a consumer must branch on tier (or `PackTier::requires_strict_match`) before treating an entry as a match requirement.
pub world_packs: Vec<RequiredPack>,
/// Packs the client is missing relative to the server, each with an optional download source. As with `world_packs`, `PackTier::Resource` entries are delivered, not matched.
pub missing_packs: Vec<RequiredPack>,
/// Which stream carries which purpose for this session.
pub stream_layout: StreamLayout,
/// Advisory server tick rate in Hz, for client clock setup.
pub tick_rate_hint: u16,
}
/// Server refusal with a machine-readable reason.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HandshakeReject {
/// Machine-readable rejection category.
pub reason: RejectReason,
/// Human-readable detail for logs and UI.
pub detail: String,
/// Optional URL directing the user to a compatible build or pack, when the rejection is recoverable (e.g. `ProtocolMismatch`, `PackMismatch`).
pub upgrade_url: Option<String>,
}
/// Orderly session teardown initiated by either side.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Disconnect {
/// Human-readable reason shown to the peer and logged.
pub reason: String,
}
/// Machine-readable categories for handshake rejection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RejectReason {
/// Client protocol version does not match the server's.
ProtocolMismatch,
/// Client is missing required packs or has incompatible versions.
PackMismatch,
/// Client declined or failed to fetch a server resource pack the server marked required.
ResourcePackDeclined,
/// Client failed to authenticate.
AuthFailed,
/// Client is banned from the server.
Banned,
/// Server is full.
Full,
/// Server encountered an internal error during handshake.
ServerError,
}
/// Identity presented by the player to the server.
// TODO: use authenticated identity once the Account system exists.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PlayerIdentity {
/// Human-readable display name.
pub display_name: String,
}
/// Reference to a content pack (resource pack, data pack, or Lua mod) as it appears in a modlist exchanged during the handshake.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PackRef {
/// Namespaced content identifier of the pack (`namespace:id`). Charset validation is deferred to the modlist-matching concept (out of M1 scope).
pub id: String,
/// Human-readable semantic version. Informational only; not the match key.
pub version: String,
/// Canonical hash of the pack contents; the authoritative match key.
// TODO: pin the canonical hashing procedure (traversal order, newline normalization) so independent builds of one pack hash identically.
pub content_hash: [u8; 32],
/// Tier the pack was classified into, which governs whether a client/server mismatch on this pack is fatal or tolerated. Inferred by the owner from the pack's folder contents (see Load order), never self-declared.
pub tier: PackTier,
}
/// Classification of a content pack, determining the handshake matching rule applied to it. Inferred from folder contents, not self-declared: `assets/`-only is a resource pack, `data/`-only is a data pack, presence of `scripts/` is a Lua mod.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum PackTier {
/// Client-side asset overlay (`assets/` only). Never strict-matched between peers. A server may push one server resource pack of its own, delivered one-way and applied on top of the client's local pack stack; enforcement of a `required` server pack is apply-or-reject at the client, not a peer hash-match.
Resource,
/// Declarative content (`data/` only). Must match exactly between peers.
Data,
/// Lua mod (`scripts/`, optionally `data/` and `assets/`); full API access.
Mod {
/// Set when the mod ships no `data/` and every system is `scope = "client"`, so a client/server mismatch on it cannot desync authoritative state and is therefore tolerated. Not trusted blindly by the server for packs carrying data or server-scoped systems.
client_only: bool,
},
}
impl PackTier {
/// Returns whether a pack of this tier must match byte-for-byte between client and server for the connection to be accepted. Resource packs are never matched; data packs and non-`client_only` mods must match exactly. A `false` here does not imply the server never sends the pack, a server resource pack is delivered one-way despite not being part of bidirectional matching.
#[must_use]
pub fn requires_strict_match(self) -> bool {
match self {
PackTier::Resource => false,
PackTier::Data => true,
PackTier::Mod { client_only } => !client_only,
}
}
}
/// A pack the server's world requires, paired with an optional out-of-band download source. Sent server → client in the handshake; the client fetches any it lacks via the URL when present, otherwise over the QUIC asset stream.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RequiredPack {
/// Identity and tier of the required pack.
pub pack: PackRef,
/// Optional HTTP(S) URL to fetch the pack from, bypassing the QUIC asset stream for large downloads. `None` means fetch over the asset stream.
pub download_url: Option<String>,
/// Whether the connection is rejected if the client cannot obtain and apply this pack. For data/mod tiers this is always `true` (they are mandatory for a correct session). For a `PackTier::Resource` entry (a server resource pack) it distinguishes an *optional* overlay the client may decline and keep playing (`false`) from a *required* one whose decline or fetch failure rejects the connection (`true`).
pub required: bool,
}
/// Optional protocol feature bits.
#[repr(transparent)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct FeatureFlags(pub u32);
/// Mapping of logical purposes to QUIC stream IDs.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StreamLayout {
/// Stream ID for control messages (handshake, disconnect).
pub control: u8,
/// Stream ID for client input to server.
pub input: u8,
/// Stream ID for server authoritative state updates.
pub authority: u8,
/// Stream ID for highest detail chunk updates (LOD0).
pub chunk_lod0: u8,
/// Stream ID for chunk updates (LOD1).
pub chunk_lod1: u8,
/// Stream ID for chunk updates (LOD2).
pub chunk_lod2: u8,
/// Stream ID for chunk updates (LOD3).
pub chunk_lod3: u8,
/// Stream ID for lowest detail chunk updates (LOD4).
pub chunk_lod4: u8,
/// Stream ID for downloading assets.
pub asset: u8,
/// Stream ID for downloading mod scripts.
pub mod_data: u8,
}
impl Default for StreamLayout {
fn default() -> Self {
Self {
control: 0,
input: 1,
authority: 2,
chunk_lod0: 3,
chunk_lod1: 4,
chunk_lod2: 5,
chunk_lod3: 6,
chunk_lod4: 7,
asset: 8,
mod_data: 9,
}
}
}
#[cfg(test)]
#[path = "tests/protocol.rs"]
mod tests;

View file

@ -17,7 +17,11 @@ impl<'a> Reader<'a> {
Self { bytes, offset: 0 }
}
/// Returns the next `n` bytes and advances the cursor, or [`SaveError::Truncated`] if fewer remain.
/// Returns the next `n` bytes and advances the cursor.
///
/// # Errors
///
/// Returns [`SaveError::Truncated`] if fewer than `n` bytes remain, or if the offset addition overflows.
pub(crate) fn take(&mut self, n: usize) -> Result<&'a [u8], SaveError> {
let end = self.offset.checked_add(n).ok_or(SaveError::Truncated {
offset: self.offset,
@ -37,6 +41,10 @@ impl<'a> Reader<'a> {
}
/// Returns the next `N` bytes as a fixed-size array and advances the cursor.
///
/// # Errors
///
/// Returns [`SaveError::Truncated`] if fewer than `N` bytes remain.
pub(crate) fn take_array<const N: usize>(&mut self) -> Result<[u8; N], SaveError> {
let mut array = [0u8; N];
array.copy_from_slice(self.take(N)?);

View file

@ -32,6 +32,10 @@ pub struct RecordMeta {
}
/// Encodes `data` into a `SYNC` record, stamping it with `last_modified` (unix-ms).
///
/// # Errors
///
/// Returns [`SaveError::Postcard`] if serialization fails, [`SaveError::Io`] if zstd compression fails, or [`SaveError::PayloadTooLarge`] if either the uncompressed or compressed length exceeds `u32::MAX`.
pub fn encode(data: &ChunkData, last_modified: u64) -> Result<Vec<u8>, SaveError> {
let uncompressed = postcard::to_stdvec(data)?;
let compressed = zstd::encode_all(uncompressed.as_slice(), ZSTD_LEVEL)?;
@ -61,6 +65,10 @@ pub fn encode(data: &ChunkData, last_modified: u64) -> Result<Vec<u8>, SaveError
///
/// `bytes` is untrusted on-disk input, so every field is bounds-checked and the decompressed
/// payload length is validated against the header before deserialization is attempted.
///
/// # Errors
///
/// Returns [`SaveError::Truncated`] if the buffer ends mid-field, [`SaveError::BadMagic`] if the leading tag is not `SYNC`, [`SaveError::Io`] if zstd decompression fails, [`SaveError::LengthMismatch`] if the decompressed length disagrees with the header, or [`SaveError::Postcard`] if the payload fails to deserialize.
pub fn decode(bytes: &[u8]) -> Result<(RecordMeta, ChunkData), SaveError> {
let mut reader = Reader::new(bytes);
@ -98,73 +106,5 @@ pub fn decode(bytes: &[u8]) -> Result<(RecordMeta, ChunkData), SaveError> {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::world::{BlockId, ChunkPos};
/// Builds a representative modified chunk with a few edits spanning the local index range.
fn sample() -> ChunkData {
let mut data = ChunkData::new(ChunkPos::new(1, -2, 3), 7);
data.set(0, BlockId(4));
data.set(1000, BlockId(9));
data.set(32_767, BlockId(2));
data
}
#[test]
fn round_trips_payload_and_metadata() -> Result<(), SaveError> {
let data = sample();
let bytes = encode(&data, 123_456)?;
let (meta, decoded) = decode(&bytes)?;
assert_eq!(decoded, data);
assert_eq!(meta.chunk_format_version, CHUNK_FORMAT_VERSION);
assert_eq!(meta.flags, 0);
assert_eq!(meta.last_modified, 123_456);
Ok(())
}
#[test]
fn rejects_bad_magic() -> Result<(), SaveError> {
let mut bytes = encode(&sample(), 0)?;
bytes[0] = b'X';
assert!(matches!(decode(&bytes), Err(SaveError::BadMagic { .. })));
Ok(())
}
#[test]
fn rejects_truncated_header() -> Result<(), SaveError> {
let bytes = encode(&sample(), 0)?;
// A buffer shorter than the fixed header cannot yield a full record.
assert!(matches!(
decode(&bytes[..HEADER_LEN - 1]),
Err(SaveError::Truncated { .. })
));
Ok(())
}
#[test]
fn rejects_truncated_payload() -> Result<(), SaveError> {
let bytes = encode(&sample(), 0)?;
// Keep the whole header but cut the compressed payload short.
assert!(matches!(
decode(&bytes[..=HEADER_LEN]),
Err(SaveError::Truncated { .. })
));
Ok(())
}
#[test]
fn detects_declared_length_mismatch() -> Result<(), SaveError> {
let mut bytes = encode(&sample(), 0)?;
// The uncompressed-length field is the u32 at offset 16 (after magic, version,
// flags, and the timestamp). Overwriting it with a value the payload cannot
// decompress to must be caught by the post-decompression length check.
bytes[16..20].copy_from_slice(&1u32.to_le_bytes());
assert!(matches!(
decode(&bytes),
Err(SaveError::LengthMismatch { .. })
));
Ok(())
}
}
#[path = "../tests/record.rs"]
mod tests;

View file

@ -134,6 +134,10 @@ impl RegionIndex {
}
/// Serializes the index to its on-disk framing bytes.
///
/// # Errors
///
/// Returns [`SaveError::PayloadTooLarge`] if the header, free-list, or stamp table holds more than `u32::MAX` entries.
pub fn encode(&self) -> Result<Vec<u8>, SaveError> {
let mut out = Vec::new();
out.extend_from_slice(&MAGIC);
@ -171,6 +175,10 @@ impl RegionIndex {
}
/// Parses a region index from its framing bytes, ignoring any chunk records that follow it.
///
/// # Errors
///
/// Returns [`SaveError::Truncated`] if the buffer ends mid-field, [`SaveError::BadMagic`] if the leading tag is not the region magic, or [`SaveError::UnsupportedVersion`] if the format version is not recognised.
pub fn decode(bytes: &[u8]) -> Result<Self, SaveError> {
let mut reader = Reader::new(bytes);
@ -232,6 +240,10 @@ impl RegionIndex {
}
/// Reads a chunk position as three little-endian `i32`s.
///
/// # Errors
///
/// Returns [`SaveError::Truncated`] if fewer than twelve bytes remain.
fn read_pos(reader: &mut Reader) -> Result<ChunkPos, SaveError> {
let x = i32::from_le_bytes(reader.take_array()?);
let y = i32::from_le_bytes(reader.take_array()?);
@ -240,102 +252,14 @@ fn read_pos(reader: &mut Reader) -> Result<ChunkPos, SaveError> {
}
/// Narrows a table length to the `u32` the framing uses, failing loudly rather than truncating.
///
/// # Errors
///
/// Returns [`SaveError::PayloadTooLarge`] if `len` exceeds `u32::MAX`.
fn len_u32(len: usize) -> Result<u32, SaveError> {
u32::try_from(len).map_err(|_| SaveError::PayloadTooLarge { len })
}
#[cfg(test)]
mod tests {
use super::*;
/// Builds a populated index with entries at varied positions, a free span, and a stamp exception.
fn sample() -> RegionIndex {
let mut index = RegionIndex::new(3);
index.insert(
ChunkPos::new(0, 0, 0),
HeaderEntry {
offset: 4096,
length: 128,
flags: 0,
},
);
index.insert(
ChunkPos::new(-5, 12, -30),
HeaderEntry {
offset: 8192,
length: 256,
flags: 0,
},
);
index.push_free(FreeSpan {
offset: 512,
length: 64,
});
index.set_worldgen_version(ChunkPos::new(0, 0, 0), 2);
index.bump_tile_version();
index
}
#[test]
fn round_trips_index() -> Result<(), SaveError> {
let index = sample();
let decoded = RegionIndex::decode(&index.encode()?)?;
assert_eq!(decoded, index);
Ok(())
}
#[test]
fn preserves_worldgen_versions_above_u16_max() -> Result<(), SaveError> {
// Regression guard: base and stamp worldgen versions are u32, so a value that would not
// fit a u16 must survive encode -> decode without truncation.
let mut index = RegionIndex::new(70_000);
index.set_worldgen_version(ChunkPos::new(0, 0, 0), 100_000);
let decoded = RegionIndex::decode(&index.encode()?)?;
assert_eq!(decoded.base_worldgen_version(), 70_000);
assert_eq!(decoded.worldgen_version(ChunkPos::new(0, 0, 0)), 100_000);
Ok(())
}
#[test]
fn stamp_equal_to_base_is_not_recorded() {
let mut index = RegionIndex::new(7);
// A stamp equal to the base is redundant, so no exception entry is stored.
index.set_worldgen_version(ChunkPos::new(1, 1, 1), 7);
assert_eq!(index.worldgen_version(ChunkPos::new(1, 1, 1)), 7);
assert!(index.stamps.is_empty());
}
#[test]
fn rejects_bad_magic() -> Result<(), SaveError> {
let mut bytes = sample().encode()?;
bytes[0] = b'X';
assert!(matches!(
RegionIndex::decode(&bytes),
Err(SaveError::BadMagic { .. })
));
Ok(())
}
#[test]
fn rejects_unsupported_version() -> Result<(), SaveError> {
let mut bytes = sample().encode()?;
// The format_version u32 sits just after the 4 magic bytes.
bytes[4..8].copy_from_slice(&999u32.to_le_bytes());
assert!(matches!(
RegionIndex::decode(&bytes),
Err(SaveError::UnsupportedVersion { .. })
));
Ok(())
}
#[test]
fn rejects_truncated_table() -> Result<(), SaveError> {
let bytes = sample().encode()?;
// Cut the buffer mid-header-table so an entry read runs off the end.
assert!(matches!(
RegionIndex::decode(&bytes[..20]),
Err(SaveError::Truncated { .. })
));
Ok(())
}
}
#[path = "../tests/region.rs"]
mod tests;

View file

@ -0,0 +1,69 @@
// SPDX-License-Identifier: AGPL-3.0-only
use super::*;
/// Builds a chunk whose voxels cycle through `distinct` material ids, guaranteeing exactly `distinct` distinct materials and therefore a palette of that size.
fn chunk_cycling(distinct: usize) -> Chunk {
let mut chunk = Chunk::default();
for (i, block) in chunk.blocks.iter_mut().enumerate() {
// `distinct` is a small test constant, so the modulo result always fits in a u16.
#[expect(
clippy::cast_possible_truncation,
reason = "distinct is a small test constant within u16 range"
)]
let id = (i % distinct) as u16;
*block = BlockId(id);
}
chunk
}
#[test]
fn bit_width_matches_palette_size() {
// The 4->5 (2->3 bit) and 8->9 (3->4 bit) transitions are the boundaries where packing bugs hide.
assert_eq!(PalettedChunk::bits_for_palette(1), 1);
assert_eq!(PalettedChunk::bits_for_palette(2), 1);
assert_eq!(PalettedChunk::bits_for_palette(3), 2);
assert_eq!(PalettedChunk::bits_for_palette(4), 2);
assert_eq!(PalettedChunk::bits_for_palette(5), 3);
assert_eq!(PalettedChunk::bits_for_palette(8), 3);
assert_eq!(PalettedChunk::bits_for_palette(9), 4);
assert_eq!(PalettedChunk::bits_for_palette(16), 4);
assert_eq!(PalettedChunk::bits_for_palette(17), 5);
}
#[test]
fn round_trip_preserves_all_voxels() {
// Sizes span every bit-width boundary through five bits, including the all-air case (distinct = 1).
for distinct in [1usize, 2, 3, 4, 5, 8, 9, 16, 17] {
let original = chunk_cycling(distinct);
let paletted = PalettedChunk::from_chunk(&original);
assert_eq!(
paletted.palette.len(),
distinct,
"palette must hold exactly the distinct materials for {distinct}"
);
let restored = paletted.to_chunk();
assert_eq!(
original.blocks, restored.blocks,
"round trip must preserve every voxel for {distinct} materials"
);
}
}
#[test]
fn all_air_chunk_has_single_entry_palette() {
let paletted = PalettedChunk::from_chunk(&Chunk::default());
assert_eq!(paletted.palette, vec![BlockId::AIR]);
assert_eq!(paletted.bits_per_index, 1);
assert_eq!(paletted.to_chunk().blocks, Chunk::default().blocks);
}
#[test]
fn preserves_index_straddling_word_boundary() {
// With a 3-bit palette, voxel 21 begins at bit 63 and spills into the next 64-bit word; a distinctive value there pins the straddle handling.
let mut original = chunk_cycling(5);
original.blocks[21] = BlockId(4);
let restored = PalettedChunk::from_chunk(&original).to_chunk();
assert_eq!(restored.blocks[21], BlockId(4));
assert_eq!(original.blocks, restored.blocks);
}

View file

@ -0,0 +1,65 @@
// SPDX-License-Identifier: AGPL-3.0-only
use super::*;
/// A baseline chunk with a recognisable, non-uniform fill so edits are distinguishable from it.
fn baseline() -> Chunk {
let mut chunk = Chunk::default();
chunk.set(0, 0, 0, BlockId(1));
chunk.set(1, 2, 3, BlockId(2));
chunk
}
#[test]
fn materialize_reproduces_diffed_chunk() {
let base = baseline();
let mut current = base.clone();
current.set(5, 6, 7, BlockId(9));
current.set(0, 0, 0, BlockId(3));
let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, &current);
let restored = data.materialize(&base);
assert_eq!(restored.blocks, current.blocks);
}
#[test]
fn identical_chunk_diffs_to_nothing() {
let base = baseline();
let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, &base.clone());
assert!(data.is_unmodified());
assert_eq!(data.edits.len(), 0);
}
#[test]
fn reverted_edit_leaves_no_entry() {
let base = baseline();
let mut current = base.clone();
// Change a voxel and then change it straight back to its baseline value.
current.set(4, 4, 4, BlockId(7));
current.set(4, 4, 4, base.get(4, 4, 4));
let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, &current);
assert!(data.is_unmodified());
}
#[test]
fn diff_stores_only_changed_voxels() {
let base = baseline();
let mut current = base.clone();
current.set(1, 1, 1, BlockId(4));
current.set(2, 2, 2, BlockId(5));
current.set(3, 3, 3, BlockId(6));
let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, &current);
assert_eq!(data.edits.len(), 3);
}
#[test]
fn set_does_not_reconcile_against_baseline() {
// `set` is deliberately dumb: writing a baseline-equal value still records an entry.
let mut data = ChunkData::new(ChunkPos::new(0, 0, 0), 0);
data.set(42, BlockId::AIR);
assert_eq!(data.edits.len(), 1);
assert!(!data.is_unmodified());
}

View file

@ -0,0 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-only
use super::*;
#[test]
fn from_world_maps_positive_positions() {
// A block at 40 falls in chunk 1 (chunk 1 spans blocks 32..=63).
assert_eq!(ChunkPos::from_world(40.0, 0.0, 0.0).x, 1);
// The last block of chunk 0 (block 31) stays in chunk 0.
assert_eq!(ChunkPos::from_world(31.0, 0.0, 0.0).x, 0);
}
#[test]
fn from_world_floors_negative_positions() {
// Block -1 belongs to chunk -1, not chunk 0: this is the div_euclid contract.
assert_eq!(ChunkPos::from_world(-1.0, 0.0, 0.0).x, -1);
// Block -33 belongs to chunk -2 (chunk -2 spans blocks -64..=-33).
assert_eq!(ChunkPos::from_world(-33.0, 0.0, 0.0).x, -2);
}
#[test]
fn from_world_floors_fractional_positions() {
// A position of -0.5 lies inside block -1, which is in chunk -1.
assert_eq!(ChunkPos::from_world(-0.5, 0.0, 0.0).x, -1);
}

View file

@ -0,0 +1,50 @@
// SPDX-License-Identifier: AGPL-3.0-only
use super::*;
#[expect(
clippy::cast_precision_loss,
reason = "CHUNK_SIZE is 32, exactly representable as f32"
)]
const CHUNK_SIZE_F: f32 = CHUNK_SIZE as f32;
#[test]
fn renormalize_leaves_in_range_offsets_untouched() {
// A local offset already inside [0, CHUNK_SIZE) must not move the anchor.
let mut pos = EntityPos::new(ChunkPos::new(1, 2, 3), Vec3::new(5.0, 10.0, 15.0));
pos.renormalize();
assert_eq!(pos.chunk, ChunkPos::new(1, 2, 3));
assert!(pos.local.abs_diff_eq(Vec3::new(5.0, 10.0, 15.0), 1e-6));
}
#[test]
fn renormalize_carries_positive_overflow() {
// One block past the chunk's far edge lands in the next chunk at local 1.0.
let over = CHUNK_SIZE_F + 1.0;
let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(over, 0.0, 0.0));
pos.renormalize();
assert_eq!(pos.chunk, ChunkPos::new(1, 0, 0));
assert!(pos.local.abs_diff_eq(Vec3::new(1.0, 0.0, 0.0), 1e-6));
}
#[test]
fn renormalize_borrows_on_negative_offset() {
// The div_euclid analogue: -0.5 must borrow a chunk, not clamp to zero.
let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(-0.5, 0.0, 0.0));
pos.renormalize();
assert_eq!(pos.chunk, ChunkPos::new(-1, 0, 0));
assert!(
pos.local
.abs_diff_eq(Vec3::new(CHUNK_SIZE_F - 0.5, 0.0, 0.0), 1e-6)
);
}
#[test]
fn renormalize_carries_multiple_chunks() {
// A large offset carries more than one chunk in a single call.
let far = CHUNK_SIZE_F * 2.0 + 6.0;
let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(far, 0.0, 0.0));
pos.renormalize();
assert_eq!(pos.chunk, ChunkPos::new(2, 0, 0));
assert!(pos.local.abs_diff_eq(Vec3::new(6.0, 0.0, 0.0), 1e-6));
}

View file

@ -0,0 +1,87 @@
// SPDX-License-Identifier: AGPL-3.0-only
use super::*;
#[test]
fn stream_layout_default_is_canonical() {
let layout = StreamLayout::default();
assert_eq!(layout.control, 0);
assert_eq!(layout.input, 1);
assert_eq!(layout.authority, 2);
assert_eq!(layout.chunk_lod0, 3);
assert_eq!(layout.chunk_lod1, 4);
assert_eq!(layout.chunk_lod2, 5);
assert_eq!(layout.chunk_lod3, 6);
assert_eq!(layout.chunk_lod4, 7);
assert_eq!(layout.asset, 8);
assert_eq!(layout.mod_data, 9);
}
fn roundtrip_test(msg: &ControlMessage) -> Result<(), postcard::Error> {
let bytes = postcard::to_stdvec(msg)?;
let decoded: ControlMessage = postcard::from_bytes(&bytes)?;
assert_eq!(msg, &decoded);
Ok(())
}
#[test]
fn roundtrip_client_hello() -> Result<(), postcard::Error> {
let msg = ControlMessage::ClientHello(ClientHello {
protocol_version: PROTOCOL_VERSION,
client_build: "synvael-client-0.1.0".to_string(),
player_identity: PlayerIdentity {
display_name: "Player1".to_string(),
},
installed_packs: vec![PackRef {
id: "core:base".to_string(),
version: "1.0.0".to_string(),
content_hash: [0; 32],
tier: PackTier::Data,
}],
requested_features: FeatureFlags(0),
});
roundtrip_test(&msg)
}
#[test]
fn roundtrip_handshake_ack() -> Result<(), postcard::Error> {
let msg = ControlMessage::HandshakeAck(HandshakeAck {
protocol_version: PROTOCOL_VERSION,
server_build: "synvael-server-0.1.0".to_string(),
world_packs: vec![],
missing_packs: vec![],
stream_layout: StreamLayout::default(),
tick_rate_hint: 20,
});
roundtrip_test(&msg)
}
#[test]
fn roundtrip_handshake_reject() -> Result<(), postcard::Error> {
let msg = ControlMessage::HandshakeReject(HandshakeReject {
reason: RejectReason::ProtocolMismatch,
detail: "Expected v1, got v2".to_string(),
upgrade_url: Some("https://synvael.example/download".to_string()),
});
roundtrip_test(&msg)
}
#[test]
fn pack_tier_match_rules() {
// Resource packs are client-side overlays: a mismatch is always tolerated.
assert!(!PackTier::Resource.requires_strict_match());
// Data packs affect authoritative content and must match exactly.
assert!(PackTier::Data.requires_strict_match());
// A client-only mod cannot desync server state, so a mismatch is tolerated.
assert!(!PackTier::Mod { client_only: true }.requires_strict_match());
// A server-affecting mod must match exactly.
assert!(PackTier::Mod { client_only: false }.requires_strict_match());
}
#[test]
fn roundtrip_disconnect() -> Result<(), postcard::Error> {
let msg = ControlMessage::Disconnect(Disconnect {
reason: "Server closing".to_string(),
});
roundtrip_test(&msg)
}

View file

@ -0,0 +1,70 @@
// SPDX-License-Identifier: AGPL-3.0-only
use super::*;
use crate::world::{BlockId, ChunkPos};
/// Builds a representative modified chunk with a few edits spanning the local index range.
fn sample() -> ChunkData {
let mut data = ChunkData::new(ChunkPos::new(1, -2, 3), 7);
data.set(0, BlockId(4));
data.set(1000, BlockId(9));
data.set(32_767, BlockId(2));
data
}
#[test]
fn round_trips_payload_and_metadata() -> Result<(), SaveError> {
let data = sample();
let bytes = encode(&data, 123_456)?;
let (meta, decoded) = decode(&bytes)?;
assert_eq!(decoded, data);
assert_eq!(meta.chunk_format_version, CHUNK_FORMAT_VERSION);
assert_eq!(meta.flags, 0);
assert_eq!(meta.last_modified, 123_456);
Ok(())
}
#[test]
fn rejects_bad_magic() -> Result<(), SaveError> {
let mut bytes = encode(&sample(), 0)?;
bytes[0] = b'X';
assert!(matches!(decode(&bytes), Err(SaveError::BadMagic { .. })));
Ok(())
}
#[test]
fn rejects_truncated_header() -> Result<(), SaveError> {
let bytes = encode(&sample(), 0)?;
// A buffer shorter than the fixed header cannot yield a full record.
assert!(matches!(
decode(&bytes[..HEADER_LEN - 1]),
Err(SaveError::Truncated { .. })
));
Ok(())
}
#[test]
fn rejects_truncated_payload() -> Result<(), SaveError> {
let bytes = encode(&sample(), 0)?;
// Keep the whole header but cut the compressed payload short.
assert!(matches!(
decode(&bytes[..=HEADER_LEN]),
Err(SaveError::Truncated { .. })
));
Ok(())
}
#[test]
fn detects_declared_length_mismatch() -> Result<(), SaveError> {
let mut bytes = encode(&sample(), 0)?;
// The uncompressed-length field is the u32 at offset 16 (after magic, version,
// flags, and the timestamp). Overwriting it with a value the payload cannot
// decompress to must be caught by the post-decompression length check.
bytes[16..20].copy_from_slice(&1u32.to_le_bytes());
assert!(matches!(
decode(&bytes),
Err(SaveError::LengthMismatch { .. })
));
Ok(())
}

View file

@ -0,0 +1,94 @@
// SPDX-License-Identifier: AGPL-3.0-only
use super::*;
/// Builds a populated index with entries at varied positions, a free span, and a stamp exception.
fn sample() -> RegionIndex {
let mut index = RegionIndex::new(3);
index.insert(
ChunkPos::new(0, 0, 0),
HeaderEntry {
offset: 4096,
length: 128,
flags: 0,
},
);
index.insert(
ChunkPos::new(-5, 12, -30),
HeaderEntry {
offset: 8192,
length: 256,
flags: 0,
},
);
index.push_free(FreeSpan {
offset: 512,
length: 64,
});
index.set_worldgen_version(ChunkPos::new(0, 0, 0), 2);
index.bump_tile_version();
index
}
#[test]
fn round_trips_index() -> Result<(), SaveError> {
let index = sample();
let decoded = RegionIndex::decode(&index.encode()?)?;
assert_eq!(decoded, index);
Ok(())
}
#[test]
fn preserves_worldgen_versions_above_u16_max() -> Result<(), SaveError> {
// Regression guard: base and stamp worldgen versions are u32, so a value that would not
// fit a u16 must survive encode -> decode without truncation.
let mut index = RegionIndex::new(70_000);
index.set_worldgen_version(ChunkPos::new(0, 0, 0), 100_000);
let decoded = RegionIndex::decode(&index.encode()?)?;
assert_eq!(decoded.base_worldgen_version(), 70_000);
assert_eq!(decoded.worldgen_version(ChunkPos::new(0, 0, 0)), 100_000);
Ok(())
}
#[test]
fn stamp_equal_to_base_is_not_recorded() {
let mut index = RegionIndex::new(7);
// A stamp equal to the base is redundant, so no exception entry is stored.
index.set_worldgen_version(ChunkPos::new(1, 1, 1), 7);
assert_eq!(index.worldgen_version(ChunkPos::new(1, 1, 1)), 7);
assert!(index.stamps.is_empty());
}
#[test]
fn rejects_bad_magic() -> Result<(), SaveError> {
let mut bytes = sample().encode()?;
bytes[0] = b'X';
assert!(matches!(
RegionIndex::decode(&bytes),
Err(SaveError::BadMagic { .. })
));
Ok(())
}
#[test]
fn rejects_unsupported_version() -> Result<(), SaveError> {
let mut bytes = sample().encode()?;
// The format_version u32 sits just after the 4 magic bytes.
bytes[4..8].copy_from_slice(&999u32.to_le_bytes());
assert!(matches!(
RegionIndex::decode(&bytes),
Err(SaveError::UnsupportedVersion { .. })
));
Ok(())
}
#[test]
fn rejects_truncated_table() -> Result<(), SaveError> {
let bytes = sample().encode()?;
// Cut the buffer mid-header-table so an entry read runs off the end.
assert!(matches!(
RegionIndex::decode(&bytes[..20]),
Err(SaveError::Truncated { .. })
));
Ok(())
}

View file

@ -89,7 +89,10 @@ impl PalettedChunk {
let mut blocks = vec![BlockId::AIR; CHUNK_VOLUME].into_boxed_slice();
for (voxel, slot) in blocks.iter_mut().enumerate() {
// A stored index was produced from a palette position, so it is always in range for `palette`.
#[expect(clippy::cast_possible_truncation)]
#[expect(
clippy::cast_possible_truncation,
reason = "a packed index originates from a valid palette position and fits usize"
)]
let index = Self::read_packed(&self.indices, voxel, self.bits_per_index) as usize;
*slot = self.palette[index];
}
@ -150,69 +153,5 @@ impl PalettedChunk {
}
#[cfg(test)]
mod tests {
use super::*;
/// Builds a chunk whose voxels cycle through `distinct` material ids, guaranteeing exactly `distinct` distinct materials and therefore a palette of that size.
fn chunk_cycling(distinct: usize) -> Chunk {
let mut chunk = Chunk::default();
for (i, block) in chunk.blocks.iter_mut().enumerate() {
// `distinct` is a small test constant, so the modulo result always fits in a u16.
#[expect(clippy::cast_possible_truncation)]
let id = (i % distinct) as u16;
*block = BlockId(id);
}
chunk
}
#[test]
fn bit_width_matches_palette_size() {
// The 4->5 (2->3 bit) and 8->9 (3->4 bit) transitions are the boundaries where packing bugs hide.
assert_eq!(PalettedChunk::bits_for_palette(1), 1);
assert_eq!(PalettedChunk::bits_for_palette(2), 1);
assert_eq!(PalettedChunk::bits_for_palette(3), 2);
assert_eq!(PalettedChunk::bits_for_palette(4), 2);
assert_eq!(PalettedChunk::bits_for_palette(5), 3);
assert_eq!(PalettedChunk::bits_for_palette(8), 3);
assert_eq!(PalettedChunk::bits_for_palette(9), 4);
assert_eq!(PalettedChunk::bits_for_palette(16), 4);
assert_eq!(PalettedChunk::bits_for_palette(17), 5);
}
#[test]
fn round_trip_preserves_all_voxels() {
// Sizes span every bit-width boundary through five bits, including the all-air case (distinct = 1).
for distinct in [1usize, 2, 3, 4, 5, 8, 9, 16, 17] {
let original = chunk_cycling(distinct);
let paletted = PalettedChunk::from_chunk(&original);
assert_eq!(
paletted.palette.len(),
distinct,
"palette must hold exactly the distinct materials for {distinct}"
);
let restored = paletted.to_chunk();
assert_eq!(
original.blocks, restored.blocks,
"round trip must preserve every voxel for {distinct} materials"
);
}
}
#[test]
fn all_air_chunk_has_single_entry_palette() {
let paletted = PalettedChunk::from_chunk(&Chunk::default());
assert_eq!(paletted.palette, vec![BlockId::AIR]);
assert_eq!(paletted.bits_per_index, 1);
assert_eq!(paletted.to_chunk().blocks, Chunk::default().blocks);
}
#[test]
fn preserves_index_straddling_word_boundary() {
// With a 3-bit palette, voxel 21 begins at bit 63 and spills into the next 64-bit word; a distinctive value there pins the straddle handling.
let mut original = chunk_cycling(5);
original.blocks[21] = BlockId(4);
let restored = PalettedChunk::from_chunk(&original).to_chunk();
assert_eq!(restored.blocks[21], BlockId(4));
assert_eq!(original.blocks, restored.blocks);
}
}
#[path = "../tests/chunk.rs"]
mod tests;

View file

@ -77,8 +77,10 @@ impl ChunkData {
.enumerate()
{
if base != cur {
// `i` ranges over `0..CHUNK_VOLUME`, which fits comfortably in a `u32`.
#[expect(clippy::cast_possible_truncation)]
#[expect(
clippy::cast_possible_truncation,
reason = "i ranges over 0..CHUNK_VOLUME, which fits in u32"
)]
data.set(i as u32, cur);
}
}
@ -87,68 +89,5 @@ impl ChunkData {
}
#[cfg(test)]
mod tests {
use super::*;
/// A baseline chunk with a recognisable, non-uniform fill so edits are distinguishable from it.
fn baseline() -> Chunk {
let mut chunk = Chunk::default();
chunk.set(0, 0, 0, BlockId(1));
chunk.set(1, 2, 3, BlockId(2));
chunk
}
#[test]
fn materialize_reproduces_diffed_chunk() {
let base = baseline();
let mut current = base.clone();
current.set(5, 6, 7, BlockId(9));
current.set(0, 0, 0, BlockId(3));
let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, &current);
let restored = data.materialize(&base);
assert_eq!(restored.blocks, current.blocks);
}
#[test]
fn identical_chunk_diffs_to_nothing() {
let base = baseline();
let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, &base.clone());
assert!(data.is_unmodified());
assert_eq!(data.edits.len(), 0);
}
#[test]
fn reverted_edit_leaves_no_entry() {
let base = baseline();
let mut current = base.clone();
// Change a voxel and then change it straight back to its baseline value.
current.set(4, 4, 4, BlockId(7));
current.set(4, 4, 4, base.get(4, 4, 4));
let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, &current);
assert!(data.is_unmodified());
}
#[test]
fn diff_stores_only_changed_voxels() {
let base = baseline();
let mut current = base.clone();
current.set(1, 1, 1, BlockId(4));
current.set(2, 2, 2, BlockId(5));
current.set(3, 3, 3, BlockId(6));
let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, &current);
assert_eq!(data.edits.len(), 3);
}
#[test]
fn set_does_not_reconcile_against_baseline() {
// `set` is deliberately dumb: writing a baseline-equal value still records an entry.
let mut data = ChunkData::new(ChunkPos::new(0, 0, 0), 0);
data.set(42, BlockId::AIR);
assert_eq!(data.edits.len(), 1);
assert!(!data.is_unmodified());
}
}
#[path = "../tests/chunk_data.rs"]
mod tests;

View file

@ -25,7 +25,11 @@ impl ChunkPos {
/// Initializes a new chunk position from a world-space position measured in blocks.
#[must_use]
#[expect(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
reason = "floored world coordinates stay within i32 range for supported world sizes"
)]
pub fn from_world(x: f64, y: f64, z: f64) -> Self {
ChunkPos {
x: (x.floor() as i32).div_euclid(CHUNK_SIZE as i32),
@ -36,28 +40,5 @@ impl ChunkPos {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_world_maps_positive_positions() {
// A block at 40 falls in chunk 1 (chunk 1 spans blocks 32..=63).
assert_eq!(ChunkPos::from_world(40.0, 0.0, 0.0).x, 1);
// The last block of chunk 0 (block 31) stays in chunk 0.
assert_eq!(ChunkPos::from_world(31.0, 0.0, 0.0).x, 0);
}
#[test]
fn from_world_floors_negative_positions() {
// Block -1 belongs to chunk -1, not chunk 0: this is the div_euclid contract.
assert_eq!(ChunkPos::from_world(-1.0, 0.0, 0.0).x, -1);
// Block -33 belongs to chunk -2 (chunk -2 spans blocks -64..=-33).
assert_eq!(ChunkPos::from_world(-33.0, 0.0, 0.0).x, -2);
}
#[test]
fn from_world_floors_fractional_positions() {
// A position of -0.5 lies inside block -1, which is in chunk -1.
assert_eq!(ChunkPos::from_world(-0.5, 0.0, 0.0).x, -1);
}
}
#[path = "../tests/coords.rs"]
mod tests;

View file

@ -23,7 +23,11 @@ impl EntityPos {
}
/// Rebases the position so every component of `local` lies within `[0.0, CHUNK_SIZE)`, carrying any whole-chunk overflow into `chunk`.
#[expect(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
#[expect(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
reason = "chunk carry values are small and within f32/i32 exact range"
)]
pub fn renormalize(&mut self) {
let size = CHUNK_SIZE as f32;
@ -40,51 +44,5 @@ impl EntityPos {
}
#[cfg(test)]
mod tests {
use super::*;
// `CHUNK_SIZE` is 32, exactly representable, so the widening cannot lose precision here.
#[expect(clippy::cast_precision_loss)]
const CHUNK_SIZE_F: f32 = CHUNK_SIZE as f32;
#[test]
fn renormalize_leaves_in_range_offsets_untouched() {
// A local offset already inside [0, CHUNK_SIZE) must not move the anchor.
let mut pos = EntityPos::new(ChunkPos::new(1, 2, 3), Vec3::new(5.0, 10.0, 15.0));
pos.renormalize();
assert_eq!(pos.chunk, ChunkPos::new(1, 2, 3));
assert!(pos.local.abs_diff_eq(Vec3::new(5.0, 10.0, 15.0), 1e-6));
}
#[test]
fn renormalize_carries_positive_overflow() {
// One block past the chunk's far edge lands in the next chunk at local 1.0.
let over = CHUNK_SIZE_F + 1.0;
let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(over, 0.0, 0.0));
pos.renormalize();
assert_eq!(pos.chunk, ChunkPos::new(1, 0, 0));
assert!(pos.local.abs_diff_eq(Vec3::new(1.0, 0.0, 0.0), 1e-6));
}
#[test]
fn renormalize_borrows_on_negative_offset() {
// The div_euclid analogue: -0.5 must borrow a chunk, not clamp to zero.
let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(-0.5, 0.0, 0.0));
pos.renormalize();
assert_eq!(pos.chunk, ChunkPos::new(-1, 0, 0));
assert!(
pos.local
.abs_diff_eq(Vec3::new(CHUNK_SIZE_F - 0.5, 0.0, 0.0), 1e-6)
);
}
#[test]
fn renormalize_carries_multiple_chunks() {
// A large offset carries more than one chunk in a single call.
let far = CHUNK_SIZE_F * 2.0 + 6.0;
let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(far, 0.0, 0.0));
pos.renormalize();
assert_eq!(pos.chunk, ChunkPos::new(2, 0, 0));
assert!(pos.local.abs_diff_eq(Vec3::new(6.0, 0.0, 0.0), 1e-6));
}
}
#[path = "../tests/entity.rs"]
mod tests;

View file

@ -15,11 +15,14 @@ The data-pack loader reads the declarative files and **calls the same Lua API**
Each data-pack schema is treated as a stable contract, versioned as deliberately as the Lua API.
**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.
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).
## Consequences
- One source of truth for registration; declarative content and scripted content cannot diverge in behaviour because they end at the same API.
- Declarative-first means data is the default and code the exception: a new primitive gets a data schema first, and a Lua-only registration path signals a gap in that schema. The base game dogfoods the datapack path, keeping pure-data `core` content in `data/` and only behavioural systems in `scripts/`.
- Accepting a schema is a long-lived commitment, since data packs in the wild depend on it.
- Resource packs remain entirely client-side with no server involvement, and are kept conceptually separate from data packs.
- Full subsystem detail (load order, repo and user-data layout, resolution semantics) lives in [`docs/packs.md`](../packs.md).

View file

@ -0,0 +1,27 @@
# 0010. Dedicated `net` crate with a confined async runtime
- **Status:** Accepted
- **Date:** 2026-07-12
## 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.
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.
## Decision
Transport lives in a dedicated `net` crate, separate from `shared`, and the `tokio` runtime is confined to it.
- `net` owns the `quinn`, `tokio`, and `rustls` dependencies, plus the QUIC endpoints, connection lifecycle, and wire framing.
- `shared` continues to hold only the protocol message *types* (serde, no async).
- 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.
## Consequences
- `shared` stays lean: consumers of the protocol types do not compile the async stack.
- The async surface is quarantined. Only `net` deals with `tokio`, keeping the `server` and `client` loops synchronous and unchanged.
- The workspace now has six crates. `net` sits between `shared` (types it carries) and the two binaries (which drive it).
- The channel bridge is an explicit boundary that must be maintained: work crossing between the async runtime and the sync simulation flows through channels, never through shared async state or by making the sim async.
- A crypto provider backend is required by `rustls`; the transport code must install one before building QUIC configuration.

View file

@ -6,7 +6,9 @@ Two distinct, orthogonal systems. They are kept separate and are not collapsed i
Client-side asset overlays: textures, sounds, models, fonts, language files. No 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. The server has no involvement. Ownership sits with the asset pipeline (in `client`, or a sibling `assets` crate if it grows). Pack authors never touch Lua.
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 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. Fetch and enforcement semantics live in the vault's `Architecture/Load order.md` § Streaming.
## Data packs
@ -18,7 +20,9 @@ No parallel registration system is built. The loader reads the declarative files
data/blocks/stone.json → loader → blocks.register{ id = "stone", ... }
```
The loader belongs in `scripting` (or a sibling crate if it grows). Engine first-party content may use either JSON or Lua, whichever fits. Every data-pack schema is a stable contract, the same as the Lua API, version it deliberately.
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.
**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.
## Canonical load order