Merge pull request #8 from Cryoforge-Nexus/feat/debug-stats-overlay

feat(workspace): runtime diagnostics and the debug statistics panel
This commit is contained in:
Serkyo 2026-08-03 23:13:06 +02:00 committed by GitHub
commit 9b21db0029
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 3239 additions and 238 deletions

114
Cargo.lock generated
View file

@ -553,6 +553,7 @@ dependencies = [
"raw-window-handle", "raw-window-handle",
"renderer", "renderer",
"shared", "shared",
"sysinfo",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"winit", "winit",
@ -1075,7 +1076,7 @@ dependencies = [
"objc2-metal 0.3.2", "objc2-metal 0.3.2",
"presser", "presser",
"thiserror 2.0.18", "thiserror 2.0.18",
"windows", "windows 0.62.2",
] ]
[[package]] [[package]]
@ -1449,6 +1450,15 @@ version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51"
[[package]]
name = "ntapi"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@ -2605,6 +2615,19 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "sysinfo"
version = "0.33.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01"
dependencies = [
"core-foundation-sys",
"libc",
"memchr",
"ntapi",
"windows 0.57.0",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.27.0" version = "3.27.0"
@ -3171,6 +3194,22 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"
@ -3180,6 +3219,22 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
dependencies = [
"windows-core 0.57.0",
"windows-targets",
]
[[package]] [[package]]
name = "windows" name = "windows"
version = "0.62.2" version = "0.62.2"
@ -3187,7 +3242,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [ dependencies = [
"windows-collections", "windows-collections",
"windows-core", "windows-core 0.62.2",
"windows-future", "windows-future",
"windows-numerics", "windows-numerics",
] ]
@ -3198,7 +3253,19 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [ dependencies = [
"windows-core", "windows-core 0.62.2",
]
[[package]]
name = "windows-core"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
dependencies = [
"windows-implement 0.57.0",
"windows-interface 0.57.0",
"windows-result 0.1.2",
"windows-targets",
] ]
[[package]] [[package]]
@ -3207,10 +3274,10 @@ version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [ dependencies = [
"windows-implement", "windows-implement 0.60.2",
"windows-interface", "windows-interface 0.59.3",
"windows-link", "windows-link",
"windows-result", "windows-result 0.4.1",
"windows-strings", "windows-strings",
] ]
@ -3220,11 +3287,22 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [ dependencies = [
"windows-core", "windows-core 0.62.2",
"windows-link", "windows-link",
"windows-threading", "windows-threading",
] ]
[[package]]
name = "windows-implement"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "windows-implement" name = "windows-implement"
version = "0.60.2" version = "0.60.2"
@ -3236,6 +3314,17 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "windows-interface"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "windows-interface" name = "windows-interface"
version = "0.59.3" version = "0.59.3"
@ -3259,10 +3348,19 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [ dependencies = [
"windows-core", "windows-core 0.62.2",
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.4.1"

View file

@ -17,6 +17,7 @@ bytemuck = { version = "1.21", features = ["derive"] }
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
serde_json = "1.0.149" serde_json = "1.0.149"
sysinfo = { version = "0.33", default-features = false, features = ["system"] }
ash-window = "0.13.0" ash-window = "0.13.0"
raw-window-handle = "0.6.2" raw-window-handle = "0.6.2"
thiserror = "2.0.18" thiserror = "2.0.18"

View file

@ -20,3 +20,4 @@ ash-window.workspace = true
shared = { path = "../shared" } shared = { path = "../shared" }
net = { version = "0.1.0", path = "../net" } net = { version = "0.1.0", path = "../net" }
crossbeam-channel = "0.5.16" crossbeam-channel = "0.5.16"
sysinfo.workspace = true

View file

@ -9,7 +9,7 @@ use renderer::vertex::Vertex;
use renderer::{MeshKey, RendererError}; use renderer::{MeshKey, RendererError};
use shared::protocol::chunk::ChunkMessage; use shared::protocol::chunk::ChunkMessage;
use shared::world::{CHUNK_SIZE, Chunk, ChunkPos}; use shared::world::{CHUNK_SIZE, Chunk, ChunkPos};
use tracing::{debug, error}; use tracing::error;
use crate::mesh_pool::{JobGen, MeshJob, MeshPool, MeshResult}; use crate::mesh_pool::{JobGen, MeshJob, MeshPool, MeshResult};
@ -70,6 +70,42 @@ impl MeshSink for renderer::Renderer {
} }
} }
/// Approximate resident footprint of one chunk, in bytes: a full `CHUNK_SIZE³` volume of `u16` block identifiers.
///
/// Ignores the `HashMap` entry and [`Arc`] header overheads, which are negligible beside the volume itself.
const CHUNK_RESIDENT_BYTES: u64 = (CHUNK_SIZE as u64).pow(3) * 2;
/// A snapshot of the streaming pipeline's state and cumulative throughput.
///
/// Every field is a plain value copied out of [`ChunkManager`] at the moment of the call; nothing is retained or shared, so a reader on a slower cadence than the frame loop observes one self-consistent instant.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct ChunkStats {
/// Chunks currently held in memory, each retaining its full voxel volume.
pub resident: usize,
/// Positions queued for re-meshing but not yet dispatched to the worker pool.
pub pending_remesh: usize,
/// Positions with a mesh job outstanding in the worker pool.
pub in_flight: usize,
/// Streaming radius, in chunks, currently in force.
pub load_radius: i32,
/// Size of the set of positions the streaming region wants resident. Residency lagging behind this figure indicates the server has not yet delivered the remainder.
pub desired: usize,
/// Chunks materialized from server deliveries since startup.
pub loaded_total: u64,
/// Chunks removed on the server's authoritative drop since startup.
pub dropped_total: u64,
/// Chunks evicted by the client's own radius check since startup, independently of any server drop.
pub evicted_total: u64,
/// Mesh jobs handed to the worker pool since startup.
pub dispatched_total: u64,
/// Finished meshes that were still current on return and were therefore uploaded since startup. The shortfall against `dispatched_total` is work superseded by a newer job or invalidated by eviction.
pub applied_total: u64,
/// Worker threads in the meshing pool.
pub mesh_workers: usize,
/// Estimated memory held by the resident chunk set, in bytes.
pub resident_bytes: u64,
}
/// Tracks which server-streamed chunks are resident and orchestrates neighbour-aware background meshing. /// Tracks which server-streamed chunks are resident and orchestrates neighbour-aware background meshing.
pub struct ChunkManager { pub struct ChunkManager {
/// Resident chunks keyed by position, retained so the mesher can sample voxels across chunk boundaries. Stored behind [`Arc`] so a chunk can be handed to a worker thread without copying its 64 KiB volume. /// Resident chunks keyed by position, retained so the mesher can sample voxels across chunk boundaries. Stored behind [`Arc`] so a chunk can be handed to a worker thread without copying its 64 KiB volume.
@ -85,6 +121,45 @@ pub struct ChunkManager {
pool: MeshPool, pool: MeshPool,
/// Reused all-air baseline that server [`ChunkData`](shared::world::ChunkData) diffs are materialized against. /// Reused all-air baseline that server [`ChunkData`](shared::world::ChunkData) diffs are materialized against.
baseline: Chunk, baseline: Chunk,
/// Running totals of pipeline throughput since startup, reported through [`ChunkManager::stats`].
totals: ChunkTotals,
}
/// Cumulative counts of the work the streaming pipeline has performed since startup.
///
/// Kept as a separate struct so the per-frame counters already computed inside [`ChunkManager::update`] fold into one place rather than becoming five loose fields on the manager.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
struct ChunkTotals {
/// Chunks materialized from server deliveries.
loaded: u64,
/// Chunks removed on the server's authoritative drop.
dropped: u64,
/// Chunks evicted by the client's own radius check.
evicted: u64,
/// Mesh jobs handed to the worker pool.
dispatched: u64,
/// Finished meshes uploaded because they were still current on return.
applied: u64,
}
impl ChunkTotals {
/// Folds one frame's per-category counts into the running totals.
///
/// Saturating addition is used throughout: these are monotonic diagnostic counters, and pinning them at `u64::MAX` is preferable to an overflow panic in the frame loop. Reaching the bound would require more chunk operations than any session performs.
fn accumulate(
&mut self,
loaded: usize,
dropped: usize,
evicted: usize,
dispatched: usize,
applied: usize,
) {
self.loaded = self.loaded.saturating_add(loaded as u64);
self.dropped = self.dropped.saturating_add(dropped as u64);
self.evicted = self.evicted.saturating_add(evicted as u64);
self.dispatched = self.dispatched.saturating_add(dispatched as u64);
self.applied = self.applied.saturating_add(applied as u64);
}
} }
impl ChunkManager { impl ChunkManager {
@ -98,6 +173,28 @@ impl ChunkManager {
next_gen: JobGen::FIRST, next_gen: JobGen::FIRST,
pool: MeshPool::new(), pool: MeshPool::new(),
baseline: Chunk::default(), baseline: Chunk::default(),
totals: ChunkTotals::default(),
}
}
/// Snapshots the streaming pipeline's current state and cumulative throughput.
///
/// `center` is the chunk the streaming region is currently anchored to, and is needed only to size the desired set; it is not retained.
#[must_use]
pub fn stats(&self, center: ChunkPos) -> ChunkStats {
ChunkStats {
resident: self.resident.len(),
pending_remesh: self.pending_remesh.len(),
in_flight: self.in_flight.len(),
load_radius: LOAD_RADIUS,
desired: desired_chunks(center, LOAD_RADIUS).len(),
loaded_total: self.totals.loaded,
dropped_total: self.totals.dropped,
evicted_total: self.totals.evicted,
dispatched_total: self.totals.dispatched,
applied_total: self.totals.applied,
mesh_workers: self.pool.worker_count(),
resident_bytes: self.resident.len() as u64 * CHUNK_RESIDENT_BYTES,
} }
} }
@ -115,19 +212,8 @@ impl ChunkManager {
let (loaded, dropped) = self.apply_deliveries(deliveries, sink); let (loaded, dropped) = self.apply_deliveries(deliveries, sink);
let dispatched = self.dispatch_pending(); let dispatched = self.dispatch_pending();
if loaded > 0 || dropped > 0 || unloaded > 0 || applied > 0 || dispatched > 0 { self.totals
debug!( .accumulate(loaded, dropped, unloaded, dispatched, applied);
loaded,
dropped,
unloaded,
dispatched,
applied,
pending = self.pending_remesh.len(),
in_flight = self.in_flight.len(),
resident = self.resident.len(),
"chunk stream reconciled"
);
}
} }
/// Ingests every finished mesh currently available from the pool, uploading the ones that are still current and discarding superseded or evicted ones. Returns the number uploaded. /// Ingests every finished mesh currently available from the pool, uploading the ones that are still current and discarding superseded or evicted ones. Returns the number uploaded.

View file

@ -6,6 +6,7 @@
//! //!
//! - **F1 + V**: filled terrain with vertex points overlaid, showing where the mesher placed geometry without losing the surface. //! - **F1 + V**: filled terrain with vertex points overlaid, showing where the mesher placed geometry without losing the surface.
//! - **F1 + B**: filled terrain with the triangle edges overlaid, showing the size and shape of the emitted quads. //! - **F1 + B**: filled terrain with the triangle edges overlaid, showing the size and shape of the emitted quads.
//! - **F1 + I**: the debug statistics panel.
//! //!
//! Holding a [`SOLO_MODIFIER`] (either Shift) as well drops the filled pass, leaving the debug geometry alone against the clear colour: **F1 + Shift + V** for points only, **F1 + Shift + B** for wireframe only. //! Holding a [`SOLO_MODIFIER`] (either Shift) as well drops the filled pass, leaving the debug geometry alone against the clear colour: **F1 + Shift + V** for points only, **F1 + Shift + B** for wireframe only.
//! //!
@ -20,6 +21,15 @@ const DEBUG_MODIFIER: KeyCode = KeyCode::F1;
/// The keys that, held alongside [`DEBUG_MODIFIER`], select the solo form of a debug view. Both shifts are accepted so the chord is reachable with either hand. /// The keys that, held alongside [`DEBUG_MODIFIER`], select the solo form of a debug view. Both shifts are accepted so the chord is reachable with either hand.
const SOLO_MODIFIER: [KeyCode; 2] = [KeyCode::ShiftLeft, KeyCode::ShiftRight]; const SOLO_MODIFIER: [KeyCode; 2] = [KeyCode::ShiftLeft, KeyCode::ShiftRight];
/// The key that, held alongside [`DEBUG_MODIFIER`], selects the vertex-point view.
const VERTEX_POINTS_OVERLAY_KEY: KeyCode = KeyCode::KeyV;
/// The key that, held alongside [`DEBUG_MODIFIER`], selects the wireframe view.
const WIREFRAME_OVERLAY_KEY: KeyCode = KeyCode::KeyB;
/// The key that, held alongside [`DEBUG_MODIFIER`], toggles the statistics panel.
const STATS_KEY: KeyCode = KeyCode::KeyI;
/// A debug operation requested by the input layer, applied by the caller. /// A debug operation requested by the input layer, applied by the caller.
/// ///
/// The layer deliberately returns an intent rather than acting directly, so it owns no renderer or window handles and stays a pure function of key events. /// The layer deliberately returns an intent rather than acting directly, so it owns no renderer or window handles and stays a pure function of key events.
@ -27,6 +37,8 @@ const SOLO_MODIFIER: [KeyCode; 2] = [KeyCode::ShiftLeft, KeyCode::ShiftRight];
pub(crate) enum DebugAction { pub(crate) enum DebugAction {
/// Applies the given rasterisation mode to the renderer. /// Applies the given rasterisation mode to the renderer.
SetRenderMode(RenderMode), SetRenderMode(RenderMode),
/// Enables or disables emission of the statistics panel.
SetStatsOverlay(bool),
} }
/// Owns debug-only input state and translates key events into [`DebugAction`]s. /// Owns debug-only input state and translates key events into [`DebugAction`]s.
@ -38,6 +50,8 @@ pub(crate) struct DebugControls {
solo_held: bool, solo_held: bool,
/// The rasterisation mode most recently requested, used to make each chord a toggle back to [`RenderMode::Filled`]. /// The rasterisation mode most recently requested, used to make each chord a toggle back to [`RenderMode::Filled`].
render_mode: RenderMode, render_mode: RenderMode,
/// Whether the statistics panel is being emitted.
stats_enabled: bool,
} }
impl DebugControls { impl DebugControls {
@ -60,6 +74,12 @@ impl DebugControls {
return None; return None;
} }
// The statistics chord is resolved before the raster table so the two axes never contend for a key. The solo modifier selects between overlaid and standalone geometry and has no meaning for a panel that draws none, so it is ignored here.
if code == STATS_KEY {
self.stats_enabled = !self.stats_enabled;
return Some(DebugAction::SetStatsOverlay(self.stats_enabled));
}
let requested = render_mode_for_key(code, self.solo_held)?; let requested = render_mode_for_key(code, self.solo_held)?;
// Re-pressing the chord for the active mode returns to the normal path, so a single chord both enables and disables its mode. // Re-pressing the chord for the active mode returns to the normal path, so a single chord both enables and disables its mode.
@ -78,10 +98,10 @@ impl DebugControls {
/// This is the single table a new rasterisation debug mode is added to: one key, one overlaid form, one solo form. /// This is the single table a new rasterisation debug mode is added to: one key, one overlaid form, one solo form.
const fn render_mode_for_key(code: KeyCode, solo: bool) -> Option<RenderMode> { const fn render_mode_for_key(code: KeyCode, solo: bool) -> Option<RenderMode> {
match (code, solo) { match (code, solo) {
(KeyCode::KeyV, false) => Some(RenderMode::FilledPoints), (VERTEX_POINTS_OVERLAY_KEY, false) => Some(RenderMode::FilledPoints),
(KeyCode::KeyV, true) => Some(RenderMode::Points), (VERTEX_POINTS_OVERLAY_KEY, true) => Some(RenderMode::Points),
(KeyCode::KeyB, false) => Some(RenderMode::FilledWireframe), (WIREFRAME_OVERLAY_KEY, false) => Some(RenderMode::FilledWireframe),
(KeyCode::KeyB, true) => Some(RenderMode::Wireframe), (WIREFRAME_OVERLAY_KEY, true) => Some(RenderMode::Wireframe),
_ => None, _ => None,
} }
} }

View file

@ -8,6 +8,7 @@ mod camera;
mod chunks; mod chunks;
mod debug; mod debug;
mod mesh_pool; mod mesh_pool;
mod stats;
use std::time::Instant; use std::time::Instant;
@ -15,6 +16,9 @@ use anyhow::{Context, Result};
use camera::Camera; use camera::Camera;
use glam::Vec3; use glam::Vec3;
use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
use shared::protocol::authority::{AuthorityMessage, ServerStats};
use shared::session::ServerKind;
use stats::{FrameAccumulator, HostMonitor, ServerIdentity, Snapshot};
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use winit::application::ApplicationHandler; use winit::application::ApplicationHandler;
use winit::event::{DeviceEvent, DeviceId, ElementState, WindowEvent}; use winit::event::{DeviceEvent, DeviceId, ElementState, WindowEvent};
@ -69,6 +73,22 @@ struct App {
last_center: Option<shared::world::ChunkPos>, last_center: Option<shared::world::ChunkPos>,
/// Streams chunk meshes in and out around the camera. `None` until the renderer is initialised on resume. /// Streams chunk meshes in and out around the camera. `None` until the renderer is initialised on resume.
chunks: Option<chunks::ChunkManager>, chunks: Option<chunks::ChunkManager>,
/// Whether the debug statistics panel is being emitted. Collection is unconditional; only emission is gated on this.
stats_overlay: bool,
/// Accumulates per-frame delta times and closes the measurement window the panel reports over.
frames: FrameAccumulator,
/// Owns the host inspection handle, refreshed on the panel's cadence rather than per frame.
host: HostMonitor,
/// Address dialled at startup, retained so the session identity can classify the server kind.
server_addr: std::net::SocketAddr,
/// Session identity, assembled once the handshake reply arrives.
identity: Option<ServerIdentity>,
/// The server's most recent report from the authority stream, retained between windows since it arrives on its own cadence.
server_stats: Option<ServerStats>,
/// Camera position at the previous frame, used to derive travelled distance.
last_position: Vec3,
/// Delta time of the previous frame, in seconds, used to derive speed from that distance.
last_dt: f32,
} }
impl Default for App { impl Default for App {
@ -89,6 +109,17 @@ impl Default for App {
connected: false, connected: false,
last_center: None, last_center: None,
chunks: None, chunks: None,
stats_overlay: false,
frames: FrameAccumulator::new(Instant::now()),
host: HostMonitor::new(),
server_addr: std::net::SocketAddr::from((
std::net::Ipv4Addr::LOCALHOST,
net::DEFAULT_PORT,
)),
identity: None,
server_stats: None,
last_position: Vec3::ZERO,
last_dt: 0.0,
} }
} }
} }
@ -105,8 +136,151 @@ impl App {
info!(?mode, "render mode toggled"); info!(?mode, "render mode toggled");
} }
} }
debug::DebugAction::SetStatsOverlay(enabled) => {
self.stats_overlay = enabled;
info!(enabled, "statistics overlay toggled");
} }
} }
}
}
impl App {
/// Advances one frame: resolves the handshake, samples timing, updates the camera and streamed chunks, emits statistics, and submits the draw.
fn redraw(&mut self, event_loop: &ActiveEventLoop) {
// Non-blocking check for the handshake outcome. The link is retained after success so its chunk channels can be used; only a failure discards it.
if !self.connected {
let outcome = self
.link
.as_ref()
.and_then(|link| link.handshake.try_recv().ok());
match outcome {
Some(Ok(ack)) => {
info!(
protocol_version = ack.protocol_version,
"handshake complete"
);
// The ack's build string, protocol version, and tick-rate hint are all reported by the panel, so the reply is retained rather than logged and dropped.
self.identity = Some(ServerIdentity {
kind: ServerKind::dedicated(self.server_addr),
address: self.server_addr,
server_build: ack.server_build,
protocol_version: ack.protocol_version,
tick_rate_hint: ack.tick_rate_hint,
});
self.connected = true;
}
Some(Err(reason)) => {
warn!("handshake failed: {reason}");
self.link = None;
}
// No outcome yet (empty), or the network thread ended (disconnected).
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
.last_frame
.map_or(0.0, |prev| now.duration_since(prev).as_secs_f32());
self.last_frame = Some(now);
// Collection is unconditional: gating it on the toggle would leave the first window after enabling the panel empty or wrong.
self.frames.record(dt);
let travelled = self.camera.position - self.last_position;
self.last_position = self.camera.position;
self.last_dt = dt;
// Drain the authority stream so the latest server report is the one the next window sees.
if let Some(link) = self.link.as_mut() {
while let Ok(AuthorityMessage::ServerStats(server_stats)) = link.authority.try_recv() {
self.server_stats = Some(server_stats);
}
}
self.camera.update(&self.input, dt);
// The accumulated motion has been applied; clear it so it is not counted twice.
self.input.mouse_delta = (0.0, 0.0);
// Reconcile streamed chunks toward the chunk the camera now occupies. `from_world` floors via `div_euclid`, so negative coordinates map to the correct chunk.
let pos = self.camera.position;
let center = shared::world::ChunkPos::from_world(
f64::from(pos.x),
f64::from(pos.y),
f64::from(pos.z),
);
// Subscribe to the region around the camera whenever the center chunk changes, so the server streams the matching set. The client subscribes with its own load radius so the server's resident set aligns with what the client keeps.
if self.connected && self.last_center != Some(center) {
if let Some(link) = self.link.as_ref() {
let radius = u16::try_from(chunks::LOAD_RADIUS).unwrap_or(u16::MAX);
link.subscribe
.send(shared::protocol::chunk::ChunkSubscribe { center, radius });
}
self.last_center = Some(center);
}
// Apply queued server deliveries and reconcile the resident set against the camera.
if let (Some(chunks), Some(link), Some(renderer)) = (
self.chunks.as_mut(),
self.link.as_mut(),
self.renderer.as_mut(),
) {
chunks.update(center, &mut link.chunks, renderer);
}
if let Some(frame) = self.frames.take_window(now) {
self.report_statistics(frame, center, travelled);
}
let view = self.camera.view_matrix();
if let Some(Err(e)) = self.renderer.as_mut().map(|r| r.draw_frame(view)) {
error!("Failed to draw frame: {e}");
event_loop.exit();
}
if let Some(window) = self.window.as_ref() {
window.request_redraw();
}
}
/// Composes one window's statistics from every source and emits the formatted panel.
///
/// Called on the panel's cadence rather than per frame, so the host refresh and the formatting cost are paid once per window. Emission is skipped while the overlay is disabled, but the window is still closed by the caller so the figures stay current.
fn report_statistics(
&mut self,
frame: stats::FrameStats,
center: shared::world::ChunkPos,
travelled: Vec3,
) {
if !self.stats_overlay {
return;
}
let usage = self.host.usage();
let camera = stats::camera_stats(
self.camera.position,
self.camera.forward(),
self.camera.yaw,
self.camera.pitch,
travelled,
self.last_dt,
);
let panel = stats::format_panel(&Snapshot {
frame,
camera,
chunks: self.chunks.as_ref().map(|chunks| chunks.stats(center)),
render: self.renderer.as_ref().and_then(renderer::Renderer::stats),
gpu: self.renderer.as_ref().map(renderer::Renderer::gpu_info),
memory: self.renderer.as_ref().map(renderer::Renderer::memory_usage),
net: self.link.as_ref().map(net::ClientLink::stats),
server: self.server_stats,
identity: self.identity.as_ref(),
host: self.host.info(),
usage,
});
info!("\n{panel}");
}
} }
impl ApplicationHandler for App { impl ApplicationHandler for App {
@ -191,10 +365,8 @@ impl ApplicationHandler for App {
installed_packs: Vec::new(), installed_packs: Vec::new(),
requested_features: shared::protocol::FeatureFlags(0), requested_features: shared::protocol::FeatureFlags(0),
}; };
let server_addr = info!("Connecting to server at {}", self.server_addr);
std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, net::DEFAULT_PORT)); self.link = Some(net::connect_in_background(self.server_addr, hello));
info!("Connecting to server at {server_addr}");
self.link = Some(net::connect_in_background(server_addr, hello));
} }
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
@ -231,78 +403,7 @@ impl ApplicationHandler for App {
} }
} }
} }
WindowEvent::RedrawRequested => { WindowEvent::RedrawRequested => self.redraw(event_loop),
// Non-blocking check for the handshake outcome. The link is retained after success so its chunk channels can be used; only a failure discards it.
if !self.connected {
let outcome = self
.link
.as_ref()
.and_then(|link| link.handshake.try_recv().ok());
match outcome {
Some(Ok(ack)) => {
info!(
protocol_version = ack.protocol_version,
"handshake complete"
);
self.connected = true;
}
Some(Err(reason)) => {
warn!("handshake failed: {reason}");
self.link = None;
}
// No outcome yet (empty), or the network thread ended (disconnected).
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
.last_frame
.map_or(0.0, |prev| now.duration_since(prev).as_secs_f32());
self.last_frame = Some(now);
self.camera.update(&self.input, dt);
// The accumulated motion has been applied; clear it so it is not counted twice.
self.input.mouse_delta = (0.0, 0.0);
// Reconcile streamed chunks toward the chunk the camera now occupies. `from_world` floors via `div_euclid`, so negative coordinates map to the correct chunk.
let pos = self.camera.position;
let center = shared::world::ChunkPos::from_world(
f64::from(pos.x),
f64::from(pos.y),
f64::from(pos.z),
);
// Subscribe to the region around the camera whenever the center chunk changes, so the server streams the matching set. The client subscribes with its own load radius so the server's resident set aligns with what the client keeps.
if self.connected && self.last_center != Some(center) {
if let Some(link) = self.link.as_ref() {
let radius = u16::try_from(chunks::LOAD_RADIUS).unwrap_or(u16::MAX);
link.subscribe
.send(shared::protocol::chunk::ChunkSubscribe { center, radius });
}
self.last_center = Some(center);
}
// Apply queued server deliveries and reconcile the resident set against the camera.
if let (Some(chunks), Some(link), Some(renderer)) = (
self.chunks.as_mut(),
self.link.as_mut(),
self.renderer.as_mut(),
) {
chunks.update(center, &mut link.chunks, renderer);
}
let view = self.camera.view_matrix();
if let Some(Err(e)) = self.renderer.as_mut().map(|r| r.draw_frame(view)) {
error!("Failed to draw frame: {e}");
event_loop.exit();
}
if let Some(window) = self.window.as_ref() {
window.request_redraw();
}
}
_ => (), _ => (),
} }
} }
@ -322,8 +423,12 @@ impl ApplicationHandler for App {
} }
fn main() -> Result<()> { fn main() -> Result<()> {
// An unset `RUST_LOG` leaves `from_default_env` with no directives, which discards every event including the statistics panel. A fallback keeps the client audible out of the box while `RUST_LOG` still overrides it.
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init(); .init();
info!("Starting Synvael client"); info!("Starting Synvael client");

View file

@ -61,11 +61,7 @@ pub(crate) struct MeshPool {
job_tx: Sender<MeshJob>, job_tx: Sender<MeshJob>,
/// Receiving end of the result queue; the main thread drains finished meshes. /// Receiving end of the result queue; the main thread drains finished meshes.
result_rx: Receiver<MeshResult>, result_rx: Receiver<MeshResult>,
/// Handles to the worker threads, retained for a future graceful-stop path that drops `job_tx` and joins them; the process currently relies on OS teardown at exit. /// Handles to the worker threads, retained for a future graceful-stop path that drops `job_tx` and joins them; the process currently relies on OS teardown at exit. Read in the meantime only for its length, by [`MeshPool::worker_count`].
#[expect(
dead_code,
reason = "retained for a future graceful-shutdown join path, mirroring the server pool"
)]
workers: Vec<JoinHandle<()>>, workers: Vec<JoinHandle<()>>,
} }
@ -117,6 +113,11 @@ impl MeshPool {
/// Enqueues a meshing job for the pool. /// Enqueues a meshing job for the pool.
/// ///
/// Returns the number of worker threads the pool was spawned with.
pub(crate) fn worker_count(&self) -> usize {
self.workers.len()
}
/// A send error (the workers have shut down) is ignored: there is nothing useful to do with the job, and shutdown only happens at process teardown. /// A send error (the workers have shut down) is ignored: there is nothing useful to do with the job, and shutdown only happens at process teardown.
pub(crate) fn dispatch(&self, job: MeshJob) { pub(crate) fn dispatch(&self, job: MeshJob) {
let _ = self.job_tx.send(job); let _ = self.job_tx.send(job);

605
crates/client/src/stats.rs Normal file
View file

@ -0,0 +1,605 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Collection and formatting of the debug statistics panel.
use std::fmt::Write as _;
use std::net::SocketAddr;
use std::time::{Duration, Instant};
use glam::{IVec3, Vec3};
use renderer::{GpuInfo, MemoryUsage, RenderStats};
use shared::protocol::authority::ServerStats;
use shared::session::ServerKind;
use shared::world::{CHUNK_SIZE, ChunkPos};
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System};
use crate::chunks::ChunkStats;
/// Wall-clock cadence at which a measurement window closes and a panel is emitted.
pub const STATS_INTERVAL: Duration = Duration::from_secs(1);
/// Bytes in one mebibyte, the unit memory figures are reported in.
const BYTES_PER_MIB: f32 = 1024.0 * 1024.0;
/// Converts a byte count to mebibytes for display.
///
/// The precision loss is intentional: the result is a display figure rounded to one decimal place, not an accounting quantity.
#[must_use]
#[expect(
clippy::cast_precision_loss,
reason = "the result is a display figure, not an exact byte count"
)]
fn mib(bytes: u64) -> f32 {
bytes as f32 / BYTES_PER_MIB
}
/// Frame timing aggregated over one measurement window.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct FrameStats {
/// Frames drawn in the window, expressed per second.
pub average_fps: f32,
/// Mean time between frames in the window, in milliseconds.
pub mean_frame_ms: f32,
/// Shortest time between frames in the window, in milliseconds.
pub min_frame_ms: f32,
/// Longest time between frames in the window, in milliseconds. The figure that exposes stutter a mean conceals.
pub max_frame_ms: f32,
/// Frames counted in the window.
pub frames: u32,
}
/// Accumulates per-frame delta times and closes a measurement window on a fixed cadence.
#[derive(Debug)]
pub struct FrameAccumulator {
/// Instant the current window opened; the window closes once [`STATS_INTERVAL`] has elapsed from here.
window_start: Instant,
/// Frames recorded in the current window.
frames: u32,
/// Summed delta time of every frame in the current window, in seconds.
total: f32,
/// Shortest delta time in the current window, in seconds.
min: f32,
/// Longest delta time in the current window, in seconds.
max: f32,
}
impl FrameAccumulator {
/// Opens the first measurement window at `now`.
#[must_use]
pub fn new(now: Instant) -> Self {
Self {
window_start: now,
frames: 0,
total: 0.0,
min: f32::INFINITY,
max: 0.0,
}
}
/// Records one frame whose delta time was `dt` seconds.
pub fn record(&mut self, dt: f32) {
self.frames = self.frames.saturating_add(1);
self.total += dt;
self.min = self.min.min(dt);
self.max = self.max.max(dt);
}
/// Closes the window and returns its summary once [`STATS_INTERVAL`] has elapsed since it opened, otherwise returns [`None`].
///
/// On close the accumulators reset and a fresh window opens at `now`, so windows tile the timeline without gaps or overlap.
pub fn take_window(&mut self, now: Instant) -> Option<FrameStats> {
let elapsed = now.saturating_duration_since(self.window_start);
if elapsed < STATS_INTERVAL {
return None;
}
let stats = summarise_frames(self.frames, self.total, self.min, self.max, elapsed);
self.window_start = now;
self.frames = 0;
self.total = 0.0;
self.min = f32::INFINITY;
self.max = 0.0;
Some(stats)
}
}
/// Derives a frame-timing summary from a window's raw accumulators.
///
/// Split out from [`FrameAccumulator::take_window`] so the arithmetic is exercisable without driving a clock. A window containing no frames reports zeroes throughout rather than dividing by zero, and its minimum is reported as zero rather than the sentinel infinity the accumulator starts from.
fn summarise_frames(frames: u32, total: f32, min: f32, max: f32, elapsed: Duration) -> FrameStats {
if frames == 0 {
return FrameStats {
average_fps: 0.0,
mean_frame_ms: 0.0,
min_frame_ms: 0.0,
max_frame_ms: 0.0,
frames: 0,
};
}
// Frame counts within a one-second window stay far inside f32's exact-integer range.
#[expect(
clippy::cast_precision_loss,
reason = "frame counts per window stay well within f32's exact-integer range"
)]
let count = frames as f32;
let seconds = elapsed.as_secs_f32();
FrameStats {
// The rate is frames over wall clock, not over summed delta time: the two differ whenever a frame's measured delta excludes time the loop spent elsewhere, and wall clock is the honest denominator.
average_fps: if seconds > 0.0 { count / seconds } else { 0.0 },
mean_frame_ms: total / count * 1000.0,
min_frame_ms: min * 1000.0,
max_frame_ms: max * 1000.0,
frames,
}
}
/// Where the camera is and where it is pointing, in every frame of reference worth reading at once.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct CameraStats {
/// Continuous world position, in blocks.
pub position: Vec3,
/// The block the camera occupies, floored from `position`.
pub block: IVec3,
/// The chunk containing that block.
pub chunk: ChunkPos,
/// Position within the containing chunk, in the range `0..CHUNK_SIZE` on each axis.
pub local: IVec3,
/// Cardinal direction the camera faces, from the dominant horizontal component of its forward vector.
pub facing: &'static str,
/// Signed axis matching `facing`, for readers who think in axes rather than compass points.
pub facing_axis: &'static str,
/// Camera yaw, in degrees.
pub yaw_degrees: f32,
/// Camera pitch, in degrees.
pub pitch_degrees: f32,
/// Magnitude of the camera's movement over the last frame, in blocks per second.
pub speed: f32,
}
/// Maps a forward vector to the cardinal direction and signed axis it points along.
///
/// Only the horizontal components are considered; pitch does not change which way the camera faces on the compass.
#[must_use]
fn facing_for(forward: Vec3) -> (&'static str, &'static str) {
if forward.x.abs() > forward.z.abs() {
if forward.x > 0.0 {
("east", "+X")
} else {
("west", "-X")
}
} else if forward.z > 0.0 {
("south", "+Z")
} else {
("north", "-Z")
}
}
/// Derives the camera figures from a position, orientation, and the distance covered since the previous frame.
///
/// `dt` is the previous frame's delta time in seconds; a zero or negative value yields a reported speed of zero rather than a division by zero.
#[must_use]
pub fn camera_stats(
position: Vec3,
forward: Vec3,
yaw: f32,
pitch: f32,
travelled: Vec3,
dt: f32,
) -> CameraStats {
// Flooring rather than truncating: a position of -0.5 lies in block -1, and truncation would place it in block 0.
let block = position.floor().as_ivec3();
let chunk = ChunkPos::from_world(
f64::from(position.x),
f64::from(position.y),
f64::from(position.z),
);
// The chunk edge is a compile-time constant of 32, so the narrowing cast is exact.
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
reason = "CHUNK_SIZE is a small compile-time constant"
)]
let size = CHUNK_SIZE as i32;
let local = IVec3::new(
block.x.rem_euclid(size),
block.y.rem_euclid(size),
block.z.rem_euclid(size),
);
let (facing, facing_axis) = facing_for(forward);
CameraStats {
position,
block,
chunk,
local,
facing,
facing_axis,
yaw_degrees: yaw.to_degrees(),
pitch_degrees: pitch.to_degrees(),
speed: if dt > 0.0 {
travelled.length() / dt
} else {
0.0
},
}
}
/// Facts about the machine and process that do not change while the client runs.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HostInfo {
/// Brand string of the first CPU the system reports.
pub cpu_brand: String,
/// Logical cores visible to the process.
pub logical_cores: usize,
/// Operating system name and version.
pub os: String,
/// Kernel version string.
pub kernel: String,
/// Version of this client binary, from the crate manifest.
pub client_build: &'static str,
}
/// Host and process figures that change from window to window.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct HostUsage {
/// Share of one core's worth of time this process consumed, in percent. Exceeds 100 on a process using more than one core.
pub process_cpu_percent: f32,
/// Resident set size of this process, in bytes.
pub process_memory_bytes: u64,
/// Virtual address space reserved by this process, in bytes.
pub process_virtual_bytes: u64,
/// Total physical memory installed, in bytes.
pub system_total_bytes: u64,
/// Physical memory available for allocation, in bytes.
pub system_available_bytes: u64,
/// Current clock of the first CPU the system reports, in MHz.
pub cpu_frequency_mhz: u64,
}
/// Owns the `sysinfo` handle and reads host figures on the panel's cadence.
///
/// Construction is expensive and the per-window refresh is deliberately narrow: only this process's entry and the CPU are refreshed, never the full system enumeration. The handle is therefore built once and kept for the lifetime of the client.
pub struct HostMonitor {
/// The `sysinfo` view of the machine, refreshed selectively.
system: System,
/// Identifier of this process, resolved once at construction.
pid: Pid,
/// Immutable facts read once at construction.
info: HostInfo,
}
impl HostMonitor {
/// Builds the monitor, reading the immutable host facts once.
#[must_use]
pub fn new() -> Self {
let mut system = System::new_with_specifics(
RefreshKind::nothing()
.with_cpu(sysinfo::CpuRefreshKind::everything())
.with_memory(sysinfo::MemoryRefreshKind::everything()),
);
system.refresh_processes(ProcessesToUpdate::All, true);
let info = HostInfo {
cpu_brand: system
.cpus()
.first()
.map_or_else(|| "unknown".to_owned(), |cpu| cpu.brand().trim().to_owned()),
logical_cores: system.cpus().len(),
os: System::long_os_version().unwrap_or_else(|| "unknown".to_owned()),
kernel: System::kernel_version().unwrap_or_else(|| "unknown".to_owned()),
client_build: env!("CARGO_PKG_VERSION"),
};
Self {
system,
pid: sysinfo::get_current_pid().unwrap_or_else(|_| Pid::from(0)),
info,
}
}
/// Returns the immutable host facts.
#[must_use]
pub const fn info(&self) -> &HostInfo {
&self.info
}
/// Refreshes and returns the changing host and process figures.
pub fn usage(&mut self) -> HostUsage {
self.system.refresh_cpu_usage();
self.system.refresh_memory();
self.system.refresh_processes_specifics(
ProcessesToUpdate::Some(&[self.pid]),
true,
ProcessRefreshKind::nothing().with_cpu().with_memory(),
);
let process = self.system.process(self.pid);
HostUsage {
process_cpu_percent: process.map_or(0.0, sysinfo::Process::cpu_usage),
process_memory_bytes: process.map_or(0, sysinfo::Process::memory),
process_virtual_bytes: process.map_or(0, sysinfo::Process::virtual_memory),
system_total_bytes: self.system.total_memory(),
system_available_bytes: self.system.available_memory(),
cpu_frequency_mhz: self
.system
.cpus()
.first()
.map_or(0, sysinfo::Cpu::frequency),
}
}
}
impl Default for HostMonitor {
fn default() -> Self {
Self::new()
}
}
/// Who the client is playing against, assembled from the address dialled and the handshake reply.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ServerIdentity {
/// Whether the server is integrated, local, or remote. Decided client-side; see [`ServerKind`].
pub kind: ServerKind,
/// The address the client dialled.
pub address: SocketAddr,
/// Build string the server reported in the handshake.
pub server_build: String,
/// Protocol version the two peers agreed on.
pub protocol_version: u32,
/// Nominal tick rate the server advertised, in Hz. Compare against the measured rate in [`ServerStats`].
pub tick_rate_hint: u16,
}
/// One window's worth of statistics from every source, ready to format.
///
/// Fields are [`Option`] wherever the source may not exist yet: before the renderer is initialised, before the handshake completes, or before the server has pushed its first report.
pub struct Snapshot<'a> {
/// Frame timing measured by the client over the window.
pub frame: FrameStats,
/// Camera position and orientation at the end of the window.
pub camera: CameraStats,
/// Chunk streaming state, owned by [`crate::chunks`].
pub chunks: Option<ChunkStats>,
/// What the renderer submitted on its most recent frame.
pub render: Option<RenderStats>,
/// The physical device the renderer selected.
pub gpu: Option<&'a GpuInfo>,
/// Live device memory figures.
pub memory: Option<MemoryUsage>,
/// Transport counters and QUIC path statistics.
pub net: Option<net::NetStats>,
/// The server's own most recent report, delivered over the authority stream.
pub server: Option<ServerStats>,
/// Session identity, present once the handshake has completed.
pub identity: Option<&'a ServerIdentity>,
/// Immutable host facts.
pub host: &'a HostInfo,
/// Host and process figures for this window.
pub usage: HostUsage,
}
/// Renders a snapshot as a multi-line panel.
///
/// Emission goes through a single `tracing` event rather than many, so the panel arrives as one cohesive block rather than interleaved with concurrent output from other threads. Sections whose source is absent are omitted entirely rather than printed as placeholders.
#[must_use]
#[expect(
clippy::too_many_lines,
reason = "a formatter is one statement per reported field; splitting it would only scatter the layout"
)]
pub fn format_panel(snapshot: &Snapshot) -> String {
let mut out = String::with_capacity(2048);
let camera = &snapshot.camera;
let frame = &snapshot.frame;
// `write!` into a String cannot fail, so the results are discarded rather than propagated.
let _ = writeln!(out, "── debug statistics ──");
let _ = writeln!(
out,
"frame {:.1} fps mean {:.2} ms min {:.2} ms max {:.2} ms ({} frames)",
frame.average_fps,
frame.mean_frame_ms,
frame.min_frame_ms,
frame.max_frame_ms,
frame.frames
);
let _ = writeln!(
out,
"pos {:.2} {:.2} {:.2} block {} {} {} speed {:.2} b/s",
camera.position.x,
camera.position.y,
camera.position.z,
camera.block.x,
camera.block.y,
camera.block.z,
camera.speed
);
let _ = writeln!(
out,
"chunk {} {} {} local {} {} {} facing {} ({}) yaw {:.1} pitch {:.1}",
camera.chunk.x,
camera.chunk.y,
camera.chunk.z,
camera.local.x,
camera.local.y,
camera.local.z,
camera.facing,
camera.facing_axis,
camera.yaw_degrees,
camera.pitch_degrees
);
if let Some(chunks) = snapshot.chunks {
let _ = writeln!(
out,
"chunks resident {} / desired {} radius {} remesh {} in-flight {} {:.1} MiB",
chunks.resident,
chunks.desired,
chunks.load_radius,
chunks.pending_remesh,
chunks.in_flight,
mib(chunks.resident_bytes)
);
let _ = writeln!(
out,
" loaded {} dropped {} evicted {} dispatched {} applied {} workers {}",
chunks.loaded_total,
chunks.dropped_total,
chunks.evicted_total,
chunks.dispatched_total,
chunks.applied_total,
chunks.mesh_workers
);
}
if let Some(render) = snapshot.render {
let _ = writeln!(
out,
"render {:?} meshes {} uploaded / {} visible / {} culled ({:.1}%) draws {}",
render.render_mode,
render.uploaded_meshes,
render.visible_meshes,
render.culled_meshes,
render.cull_ratio_percent(),
render.draw_calls
);
let _ = writeln!(
out,
" tris {} verts {} buffers {:.1} MiB vtx / {:.1} MiB idx presented {} skipped {}",
render.triangles,
render.vertices,
mib(render.vertex_bytes),
mib(render.index_bytes),
render.frames_presented,
render.frames_skipped
);
let _ = writeln!(
out,
" swapchain {}x{} x{} {} fov {:.1} near {} far {} aspect {:.3}",
render.swapchain.width,
render.swapchain.height,
render.swapchain.image_count,
render.swapchain.present_mode,
render.projection.fov_y_radians.to_degrees(),
render.projection.near,
render.projection.far,
render.projection.aspect
);
}
if let Some(gpu) = snapshot.gpu {
let _ = writeln!(
out,
"gpu {} ({}) vendor {:#06x} device {:#06x}",
gpu.device_name, gpu.device_type, gpu.vendor_id, gpu.device_id
);
let _ = writeln!(
out,
" driver {} vulkan {} vram {:.0} MiB",
gpu.driver_version,
gpu.api_version,
mib(gpu.vram_total_bytes)
);
}
if let Some(memory) = snapshot.memory {
let heap = match (memory.heap_usage_bytes, memory.heap_budget_bytes) {
(Some(used), Some(budget)) => {
format!("heap {:.0} / {:.0} MiB", mib(used), mib(budget))
}
// The extension is absent, so the driver publishes no figure to report.
_ => "heap unavailable".to_owned(),
};
let _ = writeln!(
out,
"vram {heap} allocator {:.1} / {:.1} MiB",
mib(memory.allocator_allocated_bytes),
mib(memory.allocator_capacity_bytes)
);
}
if let Some(identity) = snapshot.identity {
let _ = writeln!(
out,
"server {} {} build {} protocol {} nominal {} Hz",
identity.kind.label(),
identity.address,
identity.server_build,
identity.protocol_version,
identity.tick_rate_hint
);
}
if let Some(net) = snapshot.net {
let _ = writeln!(
out,
"net {} rtt {:.1} ms cwnd {} lost {} mtu {}",
if net.connected {
"connected"
} else {
"disconnected"
},
net.rtt_ms,
net.congestion_window,
net.lost_packets,
net.path_mtu
);
let _ = writeln!(
out,
" tx {:.2} MiB / {} dgram rx {:.2} MiB / {} dgram chunks {} drops {} subs {}",
mib(net.bytes_sent),
net.datagrams_sent,
mib(net.bytes_received),
net.datagrams_received,
net.chunks_received,
net.drops_received,
net.subscribes_sent
);
}
if let Some(server) = snapshot.server {
let _ = writeln!(
out,
"tick {:.1} tps mean {:.2} ms max {:.2} ms budget {:.0}% uptime {} s",
server.measured_tps,
server.mean_tick_ms,
server.max_tick_ms,
server.tick_budget_percent,
server.uptime_secs
);
let _ = writeln!(
out,
"world chunks {} resident / {} in flight clients {} entities {} players {}",
server.loaded_chunks,
server.chunks_in_flight,
server.connected_clients,
server.entities,
server.players
);
}
let host = snapshot.host;
let usage = snapshot.usage;
let _ = writeln!(
out,
"host {} x{} @ {} MHz {} kernel {}",
host.cpu_brand, host.logical_cores, usage.cpu_frequency_mhz, host.os, host.kernel
);
let _ = write!(
out,
"proc build {} cpu {:.1}% rss {:.1} MiB virt {:.1} MiB system {:.0} / {:.0} MiB free",
host.client_build,
usage.process_cpu_percent,
mib(usage.process_memory_bytes),
mib(usage.process_virtual_bytes),
mib(usage.system_available_bytes),
mib(usage.system_total_bytes)
);
out
}
#[cfg(test)]
#[path = "tests/stats.rs"]
mod tests;

View file

@ -228,3 +228,46 @@ fn evicted_mesh_is_discarded() {
assert!(sink.inserted.is_empty()); assert!(sink.inserted.is_empty());
assert!(sink.removed.is_empty()); assert!(sink.removed.is_empty());
} }
#[test]
fn stats_report_live_pipeline_state() {
let mut manager = ChunkManager::new();
let center = ChunkPos::new(0, 0, 0);
manager
.resident
.insert(center, Arc::new(ChunkManager::new().baseline.clone()));
manager.pending_remesh.insert(ChunkPos::new(1, 0, 0));
let stats = manager.stats(center);
assert_eq!(stats.resident, 1);
assert_eq!(stats.pending_remesh, 1);
assert_eq!(stats.in_flight, 0);
assert_eq!(stats.load_radius, LOAD_RADIUS);
assert_eq!(stats.desired, desired_chunks(center, LOAD_RADIUS).len());
// One resident chunk accounts for exactly one chunk's worth of voxel storage.
assert_eq!(stats.resident_bytes, CHUNK_RESIDENT_BYTES);
assert!(stats.mesh_workers >= 1);
}
#[test]
fn totals_accumulate_across_frames() {
let mut totals = ChunkTotals::default();
totals.accumulate(1, 2, 3, 4, 5);
totals.accumulate(10, 20, 30, 40, 50);
assert_eq!(totals.loaded, 11);
assert_eq!(totals.dropped, 22);
assert_eq!(totals.evicted, 33);
assert_eq!(totals.dispatched, 44);
assert_eq!(totals.applied, 55);
}
#[test]
fn totals_saturate_rather_than_overflow() {
let mut totals = ChunkTotals {
loaded: u64::MAX,
..ChunkTotals::default()
};
totals.accumulate(1, 0, 0, 0, 0);
assert_eq!(totals.loaded, u64::MAX);
}

View file

@ -141,3 +141,62 @@ fn solo_modifier_alone_produces_no_action() {
assert_eq!(controls.handle_key(KeyCode::ShiftLeft, true), None); assert_eq!(controls.handle_key(KeyCode::ShiftLeft, true), None);
assert_eq!(tap(&mut controls, KeyCode::KeyV), None); assert_eq!(tap(&mut controls, KeyCode::KeyV), None);
} }
#[test]
fn the_stats_chord_toggles_the_overlay_on_and_off() {
let mut controls = DebugControls::default();
controls.handle_key(DEBUG_MODIFIER, true);
assert_eq!(
tap(&mut controls, STATS_KEY),
Some(DebugAction::SetStatsOverlay(true))
);
assert_eq!(
tap(&mut controls, STATS_KEY),
Some(DebugAction::SetStatsOverlay(false))
);
}
#[test]
fn the_stats_chord_leaves_the_render_mode_untouched() {
let mut controls = DebugControls::default();
controls.handle_key(DEBUG_MODIFIER, true);
tap(&mut controls, KeyCode::KeyV);
tap(&mut controls, STATS_KEY);
// The raster axis must survive a toggle on the statistics axis; a single-enum design would have reset it.
assert_eq!(controls.render_mode, RenderMode::FilledPoints);
}
#[test]
fn a_raster_chord_leaves_the_stats_overlay_untouched() {
let mut controls = DebugControls::default();
controls.handle_key(DEBUG_MODIFIER, true);
tap(&mut controls, STATS_KEY);
assert_eq!(
tap(&mut controls, KeyCode::KeyB),
Some(DebugAction::SetRenderMode(RenderMode::FilledWireframe))
);
assert!(controls.stats_enabled);
}
#[test]
fn the_solo_modifier_does_not_change_the_stats_chord() {
let mut controls = DebugControls::default();
controls.handle_key(DEBUG_MODIFIER, true);
controls.handle_key(KeyCode::ShiftLeft, true);
assert_eq!(
tap(&mut controls, STATS_KEY),
Some(DebugAction::SetStatsOverlay(true))
);
}
#[test]
fn the_stats_chord_requires_the_debug_modifier() {
let mut controls = DebugControls::default();
assert_eq!(tap(&mut controls, STATS_KEY), None);
assert!(!controls.stats_enabled);
}

View file

@ -0,0 +1,285 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Unit tests for the statistics accumulator, derived arithmetic, and formatter in [`crate::stats`].
use super::*;
/// Asserts two f32 values agree to within a tolerance that survives the accumulated division and multiplication.
fn close(actual: f32, expected: f32) {
assert!(
(actual - expected).abs() < 0.01,
"expected {expected}, got {actual}"
);
}
#[test]
fn an_empty_window_reports_zeroes_rather_than_dividing_by_zero() {
let stats = summarise_frames(0, 0.0, f32::INFINITY, 0.0, STATS_INTERVAL);
close(stats.average_fps, 0.0);
close(stats.mean_frame_ms, 0.0);
// The sentinel the accumulator starts from must not leak into the reported minimum.
close(stats.min_frame_ms, 0.0);
assert_eq!(stats.frames, 0);
}
#[test]
fn a_steady_window_reports_the_matching_rate_and_frametime() {
// Sixty frames of 16.667 ms each, filling one second of wall clock.
let stats = summarise_frames(60, 1.0, 1.0 / 60.0, 1.0 / 60.0, Duration::from_secs(1));
close(stats.average_fps, 60.0);
close(stats.mean_frame_ms, 16.67);
close(stats.min_frame_ms, 16.67);
close(stats.max_frame_ms, 16.67);
}
#[test]
fn a_spike_is_visible_in_the_maximum_while_the_mean_stays_flat() {
// Fifty-nine cheap frames plus one 40 ms stall: an average of sixty frames per second conceals what the maximum exposes.
let stats = summarise_frames(60, 1.0, 0.010, 0.040, Duration::from_secs(1));
close(stats.average_fps, 60.0);
close(stats.mean_frame_ms, 16.67);
close(stats.max_frame_ms, 40.0);
close(stats.min_frame_ms, 10.0);
}
#[test]
fn a_single_frame_window_is_summarised_without_special_casing() {
let stats = summarise_frames(1, 0.5, 0.5, 0.5, Duration::from_secs(1));
close(stats.average_fps, 1.0);
close(stats.mean_frame_ms, 500.0);
close(stats.max_frame_ms, 500.0);
}
#[test]
fn zero_delta_frames_do_not_produce_a_non_finite_frametime() {
let stats = summarise_frames(4, 0.0, 0.0, 0.0, Duration::from_secs(1));
assert!(stats.mean_frame_ms.is_finite());
close(stats.mean_frame_ms, 0.0);
close(stats.average_fps, 4.0);
}
#[test]
fn a_window_closes_only_once_the_interval_has_elapsed() {
let start = Instant::now();
let mut accumulator = FrameAccumulator::new(start);
accumulator.record(0.016);
assert!(
accumulator
.take_window(start + Duration::from_millis(999))
.is_none()
);
assert!(accumulator.take_window(start + STATS_INTERVAL).is_some());
}
#[test]
fn closing_a_window_resets_the_accumulators() {
let start = Instant::now();
let mut accumulator = FrameAccumulator::new(start);
accumulator.record(0.100);
let _ = accumulator.take_window(start + STATS_INTERVAL);
accumulator.record(0.010);
let second = accumulator
.take_window(start + STATS_INTERVAL + STATS_INTERVAL)
.unwrap_or(FrameStats {
average_fps: 0.0,
mean_frame_ms: 0.0,
min_frame_ms: 0.0,
max_frame_ms: 0.0,
frames: 0,
});
// The 100 ms frame belonged to the first window and must not leak into the second's extremes.
assert_eq!(second.frames, 1);
close(second.max_frame_ms, 10.0);
close(second.min_frame_ms, 10.0);
}
#[test]
fn a_negative_position_floors_into_the_block_below_rather_than_truncating_toward_zero() {
let stats = camera_stats(
Vec3::new(-0.5, 1.5, -33.0),
Vec3::NEG_Z,
0.0,
0.0,
Vec3::ZERO,
0.0,
);
assert_eq!(stats.block, IVec3::new(-1, 1, -33));
// Chunk-local coordinates stay non-negative on the negative side of the origin.
assert!(stats.local.cmpge(IVec3::ZERO).all());
assert_eq!(usize::try_from(stats.local.x), Ok(CHUNK_SIZE - 1));
}
#[test]
fn each_horizontal_direction_maps_to_its_cardinal_and_axis() {
for (forward, expected) in [
(Vec3::X, ("east", "+X")),
(Vec3::NEG_X, ("west", "-X")),
(Vec3::Z, ("south", "+Z")),
(Vec3::NEG_Z, ("north", "-Z")),
] {
assert_eq!(facing_for(forward), expected);
}
}
#[test]
fn pitch_does_not_change_the_reported_cardinal_direction() {
// A steeply downward vector still faces north, since only the horizontal components decide.
assert_eq!(facing_for(Vec3::new(0.0, -0.99, -0.1)), ("north", "-Z"));
}
#[test]
fn speed_is_the_distance_covered_over_the_frame_delta() {
let stats = camera_stats(
Vec3::ZERO,
Vec3::NEG_Z,
0.0,
0.0,
Vec3::new(3.0, 4.0, 0.0),
0.5,
);
// A 5-block displacement over half a second is ten blocks per second.
close(stats.speed, 10.0);
}
#[test]
fn a_zero_frame_delta_reports_no_speed_rather_than_infinity() {
let stats = camera_stats(Vec3::ZERO, Vec3::NEG_Z, 0.0, 0.0, Vec3::X, 0.0);
assert!(stats.speed.is_finite());
close(stats.speed, 0.0);
}
#[test]
fn yaw_and_pitch_are_reported_in_degrees() {
let stats = camera_stats(
Vec3::ZERO,
Vec3::NEG_Z,
std::f32::consts::PI,
std::f32::consts::FRAC_PI_2,
Vec3::ZERO,
0.0,
);
close(stats.yaw_degrees, 180.0);
close(stats.pitch_degrees, 90.0);
}
#[test]
fn byte_counts_convert_to_mebibytes_on_the_binary_scale() {
close(mib(1024 * 1024), 1.0);
close(mib(0), 0.0);
close(mib(1024 * 1024 * 3 / 2), 1.5);
}
#[test]
fn the_formatter_emits_every_always_present_section() {
let host = HostInfo {
cpu_brand: "Test CPU".to_owned(),
logical_cores: 8,
os: "Test OS".to_owned(),
kernel: "1.2.3".to_owned(),
client_build: "0.0.0",
};
let snapshot = Snapshot {
frame: FrameStats {
average_fps: 60.0,
mean_frame_ms: 16.67,
min_frame_ms: 15.0,
max_frame_ms: 40.0,
frames: 60,
},
camera: camera_stats(
Vec3::new(1.0, 2.0, 3.0),
Vec3::NEG_Z,
0.0,
0.0,
Vec3::ZERO,
0.0,
),
chunks: None,
render: None,
gpu: None,
memory: None,
net: None,
server: None,
identity: None,
host: &host,
usage: HostUsage {
process_cpu_percent: 12.5,
process_memory_bytes: 1024 * 1024,
process_virtual_bytes: 2 * 1024 * 1024,
system_total_bytes: 16 * 1024 * 1024,
system_available_bytes: 8 * 1024 * 1024,
cpu_frequency_mhz: 4200,
},
};
let panel = format_panel(&snapshot);
assert!(panel.contains("60.0 fps"), "{panel}");
assert!(panel.contains("max 40.00 ms"), "{panel}");
assert!(panel.contains("facing north (-Z)"), "{panel}");
assert!(panel.contains("Test CPU x8 @ 4200 MHz"), "{panel}");
assert!(panel.contains("cpu 12.5%"), "{panel}");
assert!(panel.contains("rss 1.0 MiB"), "{panel}");
// Sections whose source is absent are omitted rather than printed as placeholders.
assert!(!panel.contains("gpu "), "{panel}");
assert!(!panel.contains("net "), "{panel}");
}
#[test]
fn absent_device_memory_figures_are_named_rather_than_reported_as_zero() {
let host = HostInfo {
cpu_brand: "Test CPU".to_owned(),
logical_cores: 1,
os: "Test OS".to_owned(),
kernel: "1.2.3".to_owned(),
client_build: "0.0.0",
};
let snapshot = Snapshot {
frame: FrameStats {
average_fps: 0.0,
mean_frame_ms: 0.0,
min_frame_ms: 0.0,
max_frame_ms: 0.0,
frames: 0,
},
camera: camera_stats(Vec3::ZERO, Vec3::NEG_Z, 0.0, 0.0, Vec3::ZERO, 0.0),
chunks: None,
render: None,
gpu: None,
memory: Some(MemoryUsage {
heap_usage_bytes: None,
heap_budget_bytes: None,
allocator_allocated_bytes: 1024 * 1024,
allocator_capacity_bytes: 2 * 1024 * 1024,
}),
net: None,
server: None,
identity: None,
host: &host,
usage: HostUsage {
process_cpu_percent: 0.0,
process_memory_bytes: 0,
process_virtual_bytes: 0,
system_total_bytes: 0,
system_available_bytes: 0,
cpu_frequency_mhz: 0,
},
};
let panel = format_panel(&snapshot);
assert!(panel.contains("heap unavailable"), "{panel}");
assert!(panel.contains("allocator 1.0 / 2.0 MiB"), "{panel}");
}

View file

@ -0,0 +1,97 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Authority-stream transport: the per-connection task that pushes server-authoritative state to a client.
use shared::protocol::authority::AuthorityMessage;
use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::mpsc::{Sender, UnboundedReceiver, UnboundedSender};
use tracing::{debug, warn};
use crate::codec::{read_frame, write_frame};
/// Maximum accepted authority frame length, in bytes.
///
/// Authority payloads are small fixed-shape records; the bound is generous relative to a [`ServerStats`](shared::protocol::authority::ServerStats) and exists to cap what a malformed or hostile length prefix can make the peer allocate.
pub const MAX_AUTHORITY_FRAME_LEN: usize = 64 * 1024;
/// A synchronous handle the simulation loop uses to push [`AuthorityMessage`]s to one connection.
#[derive(Debug, Clone)]
pub struct AuthoritySink {
/// Outbound queue drained by the connection's authority task.
tx: UnboundedSender<AuthorityMessage>,
}
impl AuthoritySink {
/// Wraps `tx` as an authority sink.
pub(crate) fn new(tx: UnboundedSender<AuthorityMessage>) -> Self {
Self { tx }
}
/// Queues `msg` for delivery on the connection's authority stream.
///
/// Non-blocking. A send failure means the receiving task has ended (the connection dropped); it is logged at debug and swallowed, since the simulation loop cannot act on a departed connection.
pub fn send(&self, msg: AuthorityMessage) {
if self.tx.send(msg).is_err() {
debug!("authority sink send failed; connection task has ended");
}
}
}
/// Runs the server-side authority pump for one connection until the stream or connection closes.
///
/// Opens the unidirectional stream, then writes every [`AuthorityMessage`] handed over by the simulation loop. The loop ends when the sink is dropped (the connection is being torn down) or a write fails.
pub(crate) async fn server_authority_task(
connection: quinn::Connection,
id: u64,
mut outbound: UnboundedReceiver<AuthorityMessage>,
) {
let mut send = match connection.open_uni().await {
Ok(stream) => stream,
Err(error) => {
warn!(%error, id, "failed to open authority stream");
return;
}
};
while let Some(message) = outbound.recv().await {
if let Err(error) = write_frame(&mut send, &message).await {
warn!(%error, id, "failed to write authority frame; ending authority stream");
break;
}
}
}
/// Runs the client-side authority pump for one connection until the stream or connection closes.
///
/// Accepts the unidirectional stream the server opens, then forwards every decoded [`AuthorityMessage`] to the UI thread. The loop ends when the stream closes or the UI drops its receiver.
pub(crate) async fn client_authority_task(
connection: quinn::Connection,
inbound: Sender<AuthorityMessage>,
) {
let mut recv = match connection.accept_uni().await {
Ok(stream) => stream,
Err(error) => {
debug!(%error, "authority stream never opened");
return;
}
};
loop {
match read_frame::<AuthorityMessage>(&mut recv, MAX_AUTHORITY_FRAME_LEN).await {
Ok(message) => match inbound.try_send(message) {
Ok(()) => {}
// A full channel means the UI is behind on a purely diagnostic stream; dropping the newest message is preferable to blocking the read loop.
Err(TrySendError::Full(_)) => {
debug!("authority delivery dropped; UI queue is full");
}
// A closed channel means the UI has gone away, so there is nothing left to deliver to.
Err(TrySendError::Closed(_)) => break,
},
Err(error) => {
// A read error is the normal end of the session (stream finished or reset).
debug!(%error, "authority stream read ended");
break;
}
}
}
}

View file

@ -2,12 +2,15 @@
//! Chunk-stream transport: the per-connection task that pumps chunk subscriptions and deliveries. //! Chunk-stream transport: the per-connection task that pumps chunk subscriptions and deliveries.
use std::sync::Arc;
use shared::protocol::chunk::{ChunkMessage, ChunkSubscribe}; use shared::protocol::chunk::{ChunkMessage, ChunkSubscribe};
use tokio::sync::mpsc::{Sender, UnboundedReceiver, UnboundedSender}; use tokio::sync::mpsc::{Sender, UnboundedReceiver, UnboundedSender};
use tracing::{debug, warn}; use tracing::{debug, warn};
use crate::codec::{MAX_CHUNK_FRAME_LEN, read_frame, write_frame}; use crate::codec::{MAX_CHUNK_FRAME_LEN, read_frame, write_frame};
use crate::runtime::ServerEvent; use crate::runtime::ServerEvent;
use crate::stats::NetCounters;
/// A synchronous handle the simulation loop uses to hand [`ChunkMessage`]s to a connection's chunk-stream task. /// A synchronous handle the simulation loop uses to hand [`ChunkMessage`]s to a connection's chunk-stream task.
/// ///
@ -123,6 +126,7 @@ pub(crate) async fn client_chunk_task(
connection: quinn::Connection, connection: quinn::Connection,
mut subscribe: UnboundedReceiver<ChunkSubscribe>, mut subscribe: UnboundedReceiver<ChunkSubscribe>,
deliveries: Sender<ChunkMessage>, deliveries: Sender<ChunkMessage>,
counters: &Arc<NetCounters>,
) { ) {
// The client opens the chunk stream after the handshake; the server accepts it, mirroring the control-stream convention. // The client opens the chunk stream after the handshake; the server accepts it, mirroring the control-stream convention.
let (mut send, mut recv) = match connection.open_bi().await { let (mut send, mut recv) = match connection.open_bi().await {
@ -138,6 +142,11 @@ pub(crate) async fn client_chunk_task(
loop { loop {
match read_frame::<ChunkMessage>(&mut recv, MAX_CHUNK_FRAME_LEN).await { match read_frame::<ChunkMessage>(&mut recv, MAX_CHUNK_FRAME_LEN).await {
Ok(message) => { Ok(message) => {
// Counted on arrival rather than on delivery to the UI, so the figure reflects what the transport received even while the UI thread is backpressuring below.
match message {
ChunkMessage::Chunk { .. } => counters.record_chunk(),
ChunkMessage::Drop { .. } => counters.record_drop(),
}
// `send` awaits when the delivery channel is full: the task suspends (yielding the runtime thread so the connection keeps ACKing) until the UI drains a slot, and until then reads no further frames, which backpressures the server via QUIC stream flow control. An error means the UI dropped its receiver, so the session ends. // `send` awaits when the delivery channel is full: the task suspends (yielding the runtime thread so the connection keeps ACKing) until the UI drains a slot, and until then reads no further frames, which backpressures the server via QUIC stream flow control. An error means the UI dropped its receiver, so the session ends.
if deliveries.send(message).await.is_err() { if deliveries.send(message).await.is_err() {
break; break;
@ -159,6 +168,7 @@ pub(crate) async fn client_chunk_task(
warn!(%error, "failed to write chunk subscribe; ending chunk stream"); warn!(%error, "failed to write chunk subscribe; ending chunk stream");
break; break;
} }
counters.record_subscribe();
} }
}; };

View file

@ -6,17 +6,22 @@
//! //!
//! 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. //! 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 authority;
pub mod chunk; pub mod chunk;
pub mod codec; pub mod codec;
pub mod endpoint; pub mod endpoint;
pub mod error; pub mod error;
pub mod handshake; pub mod handshake;
pub mod runtime; pub mod runtime;
pub mod stats;
pub use authority::AuthoritySink;
pub use chunk::{ChunkSink, ChunkSubscriber}; pub use chunk::{ChunkSink, ChunkSubscriber};
pub use runtime::{ pub use runtime::{
ChunkStream, ClientLink, ConnectOutcome, NetworkServer, ServerEvent, connect_in_background, AuthorityStream, ChunkStream, ClientLink, ConnectOutcome, NetworkServer, ServerEvent,
connect_in_background,
}; };
pub use stats::NetStats;
/// Default UDP port the server binds and the client connects to when none is configured. /// 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. // TODO: make the bind address and port configurable through server/client configuration.

View file

@ -3,16 +3,20 @@
//! Threaded `tokio` runtime bridge between the async network and the synchronous simulation. //! Threaded `tokio` runtime bridge between the async network and the synchronous simulation.
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::{Arc, OnceLock};
use std::thread; use std::thread;
use shared::protocol::authority::AuthorityMessage;
use shared::protocol::chunk::{ChunkMessage, ChunkSubscribe}; use shared::protocol::chunk::{ChunkMessage, ChunkSubscribe};
use shared::protocol::{ClientHello, HandshakeAck}; use shared::protocol::{ClientHello, HandshakeAck};
use tracing::{info, warn}; use tracing::{info, warn};
use crate::authority::{AuthoritySink, client_authority_task, server_authority_task};
use crate::chunk::{ChunkSink, ChunkSubscriber, chunk_stream_task, client_chunk_task}; use crate::chunk::{ChunkSink, ChunkSubscriber, chunk_stream_task, client_chunk_task};
use crate::endpoint::{client_endpoint, server_endpoint}; use crate::endpoint::{client_endpoint, server_endpoint};
use crate::error::NetError; use crate::error::NetError;
use crate::handshake::{ServerConnection, accept_connection, connect}; use crate::handshake::{ServerConnection, accept_connection, connect};
use crate::stats::{self, NetCounters, NetStats};
/// Channel receiver delivering the outcome of a background client connect: the negotiated [`HandshakeAck`] on success, or a human-readable error string on failure. /// 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>>; pub type ConnectOutcome = crossbeam_channel::Receiver<Result<HandshakeAck, String>>;
@ -24,6 +28,14 @@ pub type ChunkStream = tokio::sync::mpsc::Receiver<ChunkMessage>;
// TODO: revisit once meshing moves to a worker pool; the right depth follows the UI's consume rate, so this is a candidate to derive from the meshing budget / view distance in a config layer rather than a hand-set constant. // TODO: revisit once meshing moves to a worker pool; the right depth follows the UI's consume rate, so this is a candidate to derive from the meshing budget / view distance in a config layer rather than a hand-set constant.
const CHUNK_DELIVERY_CAPACITY: usize = 32; const CHUNK_DELIVERY_CAPACITY: usize = 32;
/// Bounded receiver of authority-stream messages, drained by the UI thread with `try_recv`.
pub type AuthorityStream = tokio::sync::mpsc::Receiver<AuthorityMessage>;
/// Capacity of the client's authority channel, in messages.
///
/// Shallow on purpose: the server pushes roughly one message per second, so anything beyond a small backlog is stale by the time the UI would read it. The network task drops rather than blocks when this fills.
const AUTHORITY_CAPACITY: usize = 4;
/// Handles a background client connection exposes to the synchronous UI thread. /// Handles a background client connection exposes to the synchronous UI thread.
/// ///
/// The network task keeps the QUIC connection alive on its own thread; this bundle is how the winit loop observes the handshake outcome, pushes subscription updates, and drains chunk deliveries, all without touching the async runtime. /// The network task keeps the QUIC connection alive on its own thread; this bundle is how the winit loop observes the handshake outcome, pushes subscription updates, and drains chunk deliveries, all without touching the async runtime.
@ -34,6 +46,22 @@ pub struct ClientLink {
pub subscribe: ChunkSubscriber, pub subscribe: ChunkSubscriber,
/// Receives chunk deliveries from the server, drained non-blocking each frame. /// Receives chunk deliveries from the server, drained non-blocking each frame.
pub chunks: ChunkStream, pub chunks: ChunkStream,
/// Receives periodic server-authoritative state, drained non-blocking each frame.
pub authority: AuthorityStream,
/// The live QUIC connection, published by the network thread once the handshake completes. Held privately so the `quinn` types stay inside this crate; the UI thread reads through [`ClientLink::stats`].
connection: Arc<OnceLock<quinn::Connection>>,
/// Application-level message counters shared with the chunk task.
counters: Arc<NetCounters>,
}
impl ClientLink {
/// Snapshots the connection's transport statistics.
///
/// Safe to call before the handshake completes; the result then reports the disconnected state rather than failing.
#[must_use]
pub fn stats(&self) -> NetStats {
stats::snapshot(self.connection.get(), &self.counters)
}
} }
/// An event surfaced by the network thread to the synchronous server loop. /// An event surfaced by the network thread to the synchronous server loop.
@ -47,6 +75,8 @@ pub enum ServerEvent {
hello: ClientHello, hello: ClientHello,
/// Outbound handle for delivering [`shared::protocol::chunk::ChunkMessage`]s to this client. The simulation loop retains it, keyed by `id`, until the matching [`ServerEvent::ClientDisconnected`]. /// Outbound handle for delivering [`shared::protocol::chunk::ChunkMessage`]s to this client. The simulation loop retains it, keyed by `id`, until the matching [`ServerEvent::ClientDisconnected`].
chunks: ChunkSink, chunks: ChunkSink,
/// Outbound handle for pushing [`shared::protocol::authority::AuthorityMessage`]s to this client, retained alongside `chunks` for the same lifetime.
authority: AuthoritySink,
}, },
/// A previously connected client's session ended. /// A previously connected client's session ended.
ClientDisconnected { ClientDisconnected {
@ -217,26 +247,29 @@ async fn handle_connection(
Ok(ServerConnection { Ok(ServerConnection {
connection, hello, .. connection, hello, ..
}) => { }) => {
// The outbound chunk channel bridges the sync simulation loop to this connection's chunk-stream task; the sink is handed to the loop via the connect event. // The outbound channels bridge the sync simulation loop to this connection's stream tasks; the sinks are handed to the loop via the connect event.
let (chunk_tx, chunk_rx) = tokio::sync::mpsc::unbounded_channel(); let (chunk_tx, chunk_rx) = tokio::sync::mpsc::unbounded_channel();
let (authority_tx, authority_rx) = tokio::sync::mpsc::unbounded_channel();
// If the receiver is gone the server is shutting down; drop the connection silently. // If the receiver is gone the server is shutting down; drop the connection silently.
if events if events
.send(ServerEvent::ClientConnected { .send(ServerEvent::ClientConnected {
id, id,
hello, hello,
chunks: ChunkSink::new(chunk_tx), chunks: ChunkSink::new(chunk_tx),
authority: AuthoritySink::new(authority_tx),
}) })
.is_err() .is_err()
{ {
return; return;
} }
// The chunk pump runs on its own task so the connection-close wait below does not block it. // Each pump runs on its own task so the connection-close wait below does not block either.
tokio::spawn(chunk_stream_task( tokio::spawn(chunk_stream_task(
connection.clone(), connection.clone(),
id, id,
events.clone(), events.clone(),
chunk_rx, chunk_rx,
)); ));
tokio::spawn(server_authority_task(connection.clone(), id, authority_rx));
let reason = connection.closed().await; let reason = connection.closed().await;
let _ = events.send(ServerEvent::ClientDisconnected { let _ = events.send(ServerEvent::ClientDisconnected {
id, id,
@ -257,11 +290,18 @@ pub fn connect_in_background(server_addr: SocketAddr, hello: ClientHello) -> Cli
let (outcome_tx, outcome_rx) = crossbeam_channel::bounded(1); let (outcome_tx, outcome_rx) = crossbeam_channel::bounded(1);
// Retained so a failure to spawn the thread can still be reported to the caller. // Retained so a failure to spawn the thread can still be reported to the caller.
let spawn_err_tx = outcome_tx.clone(); let spawn_err_tx = outcome_tx.clone();
// Published by the network thread once the handshake succeeds, so the UI thread can read `quinn`'s own connection statistics without owning the connection.
let connection = Arc::new(OnceLock::new());
let task_connection = Arc::clone(&connection);
let counters = Arc::new(NetCounters::default());
let task_counters = Arc::clone(&counters);
// Subscription updates flow UI -> network (sync send, async recv); chunk deliveries flow network -> UI (async send, sync try_recv). // Subscription updates flow UI -> network (sync send, async recv); chunk deliveries flow network -> UI (async send, sync try_recv).
let (subscribe_tx, subscribe_rx) = tokio::sync::mpsc::unbounded_channel::<ChunkSubscribe>(); let (subscribe_tx, subscribe_rx) = tokio::sync::mpsc::unbounded_channel::<ChunkSubscribe>();
// The delivery channel is bounded so a slow (e.g. debug-build) UI thread applies backpressure to the network task instead of letting undelivered chunks accumulate without limit. // The delivery channel is bounded so a slow (e.g. debug-build) UI thread applies backpressure to the network task instead of letting undelivered chunks accumulate without limit.
let (chunks_tx, chunks_rx) = let (chunks_tx, chunks_rx) =
tokio::sync::mpsc::channel::<ChunkMessage>(CHUNK_DELIVERY_CAPACITY); tokio::sync::mpsc::channel::<ChunkMessage>(CHUNK_DELIVERY_CAPACITY);
let (authority_tx, authority_rx) =
tokio::sync::mpsc::channel::<AuthorityMessage>(AUTHORITY_CAPACITY);
let spawned = thread::Builder::new() let spawned = thread::Builder::new()
.name("net-client".to_owned()) .name("net-client".to_owned())
@ -292,8 +332,18 @@ pub fn connect_in_background(server_addr: SocketAddr, hello: ClientHello) -> Cli
if outcome_tx.send(Ok(connected.ack.clone())).is_err() { if outcome_tx.send(Ok(connected.ack.clone())).is_err() {
return; return;
} }
// Pump the chunk stream on this thread until the UI drops its handles or the server closes the connection. // The cell is written exactly once, here; a failure would mean a second handshake on one link, which cannot occur.
client_chunk_task(connected.connection, subscribe_rx, chunks_tx).await; let _ = task_connection.set(connected.connection.clone());
// Pump both streams concurrently on this thread until the UI drops its handles or the server closes the connection. `join!` rather than `select!`: neither stream ending is a reason to abandon the other mid-frame.
tokio::join!(
client_chunk_task(
connected.connection.clone(),
subscribe_rx,
chunks_tx,
&task_counters,
),
client_authority_task(connected.connection, authority_tx),
);
warn!("server connection closed"); warn!("server connection closed");
} }
Err(error) => { Err(error) => {
@ -311,5 +361,8 @@ pub fn connect_in_background(server_addr: SocketAddr, hello: ClientHello) -> Cli
handshake: outcome_rx, handshake: outcome_rx,
subscribe: ChunkSubscriber::new(subscribe_tx), subscribe: ChunkSubscriber::new(subscribe_tx),
chunks: chunks_rx, chunks: chunks_rx,
authority: authority_rx,
connection,
counters,
} }
} }

103
crates/net/src/stats.rs Normal file
View file

@ -0,0 +1,103 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Transport statistics for a client connection.
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
/// Application-level message counters, incremented by the client's chunk task and read by the UI thread.
///
/// Held behind an [`Arc`] and mutated with relaxed atomics: each counter is independent, nothing else is ordered against them, and a reader that observes a slightly stale value is reporting a diagnostic figure, not making a decision.
#[derive(Debug, Default)]
pub(crate) struct NetCounters {
/// Chunk deliveries received from the server.
chunks_received: AtomicU64,
/// Chunk drop notifications received from the server.
drops_received: AtomicU64,
/// Subscription updates written to the server.
subscribes_sent: AtomicU64,
}
impl NetCounters {
/// Records one received chunk delivery.
pub(crate) fn record_chunk(&self) {
self.chunks_received.fetch_add(1, Ordering::Relaxed);
}
/// Records one received chunk drop notification.
pub(crate) fn record_drop(&self) {
self.drops_received.fetch_add(1, Ordering::Relaxed);
}
/// Records one subscription update written to the server.
pub(crate) fn record_subscribe(&self) {
self.subscribes_sent.fetch_add(1, Ordering::Relaxed);
}
}
/// A snapshot of one client connection's transport state.
///
/// Every field is a value copied at the moment of the call. A snapshot taken before the handshake completes reports `connected == false` and zeroes throughout, which is a meaningful state rather than missing data.
#[derive(Copy, Clone, Debug, Default, PartialEq)]
pub struct NetStats {
/// Whether a QUIC connection is currently established.
pub connected: bool,
/// Bytes carried in UDP datagrams sent on this connection, including QUIC framing and retransmissions.
pub bytes_sent: u64,
/// Bytes carried in UDP datagrams received on this connection, including QUIC framing.
pub bytes_received: u64,
/// UDP datagrams sent on this connection.
pub datagrams_sent: u64,
/// UDP datagrams received on this connection.
pub datagrams_received: u64,
/// Chunk deliveries received since the connection was established.
pub chunks_received: u64,
/// Chunk drop notifications received since the connection was established.
pub drops_received: u64,
/// Subscription updates written to the server since the connection was established.
pub subscribes_sent: u64,
/// The QUIC stack's current round-trip-time estimate, in milliseconds.
pub rtt_ms: f32,
/// Packets the congestion controller has declared lost on the current path.
pub lost_packets: u64,
/// Current congestion window, in bytes: the ceiling on data in flight.
pub congestion_window: u64,
/// Largest UDP payload the current path is known to carry, in bytes, as discovered by path MTU probing.
pub path_mtu: u16,
}
/// Reads a connection's transport statistics, or reports the disconnected state.
///
/// `connection` is [`None`] until the handshake completes and after the connection closes.
pub(crate) fn snapshot(
connection: Option<&quinn::Connection>,
counters: &Arc<NetCounters>,
) -> NetStats {
let mut stats = NetStats {
chunks_received: counters.chunks_received.load(Ordering::Relaxed),
drops_received: counters.drops_received.load(Ordering::Relaxed),
subscribes_sent: counters.subscribes_sent.load(Ordering::Relaxed),
..NetStats::default()
};
let Some(connection) = connection else {
return stats;
};
let quic = connection.stats();
// A connection handle outlives the connection itself; a close reason is how a torn-down connection distinguishes itself from a live one, and its final counters stay readable either way.
stats.connected = connection.close_reason().is_none();
stats.bytes_sent = quic.udp_tx.bytes;
stats.bytes_received = quic.udp_rx.bytes;
stats.datagrams_sent = quic.udp_tx.datagrams;
stats.datagrams_received = quic.udp_rx.datagrams;
stats.rtt_ms = quic.path.rtt.as_secs_f32() * 1000.0;
stats.lost_packets = quic.path.lost_packets;
stats.congestion_window = quic.path.cwnd;
stats.path_mtu = quic.path.current_mtu;
stats
}
#[cfg(test)]
#[path = "tests/stats.rs"]
mod tests;

View file

@ -0,0 +1,31 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Unit tests for the transport statistics snapshot.
use super::*;
#[test]
fn snapshot_without_a_connection_reports_the_disconnected_state() {
let counters = Arc::new(NetCounters::default());
let stats = snapshot(None, &counters);
assert!(!stats.connected);
assert_eq!(stats, NetStats::default());
}
#[test]
fn counters_are_reported_before_a_connection_exists() {
let counters = Arc::new(NetCounters::default());
counters.record_chunk();
counters.record_chunk();
counters.record_drop();
counters.record_subscribe();
let stats = snapshot(None, &counters);
assert_eq!(stats.chunks_received, 2);
assert_eq!(stats.drops_received, 1);
assert_eq!(stats.subscribes_sent, 1);
// Transport figures stay zero: they come from the QUIC stack, which has nothing to report yet.
assert_eq!(stats.bytes_sent, 0);
assert_eq!(stats.bytes_received, 0);
}

View file

@ -3,6 +3,7 @@
//! Logic for selecting physical devices and creating logical devices. //! Logic for selecting physical devices and creating logical devices.
use crate::error::RendererError; use crate::error::RendererError;
use crate::stats::{GpuInfo, decode_driver_version};
use ash::{Device, Instance, khr, vk}; use ash::{Device, Instance, khr, vk};
/// Picks a physical device (GPU) that supports the required features and extensions. /// Picks a physical device (GPU) that supports the required features and extensions.
@ -39,13 +40,18 @@ pub fn create_logical_device(
instance: &Instance, instance: &Instance,
physical_device: vk::PhysicalDevice, physical_device: vk::PhysicalDevice,
queue_family_index: u32, queue_family_index: u32,
memory_budget: bool,
) -> Result<(Device, vk::Queue), RendererError> { ) -> Result<(Device, vk::Queue), RendererError> {
let priorities = [1.0]; let priorities = [1.0];
let queue_info = vk::DeviceQueueCreateInfo::default() let queue_info = vk::DeviceQueueCreateInfo::default()
.queue_family_index(queue_family_index) .queue_family_index(queue_family_index)
.queue_priorities(&priorities); .queue_priorities(&priorities);
let device_extensions = [khr::swapchain::NAME.as_ptr()]; // `VK_EXT_memory_budget` is optional and is requested only where the device advertises it; naming an unsupported extension fails device creation outright.
let mut device_extensions = vec![khr::swapchain::NAME.as_ptr()];
if memory_budget {
device_extensions.push(ash::ext::memory_budget::NAME.as_ptr());
}
// Enable Vulkan 1.3 features // Enable Vulkan 1.3 features
let mut synchronization2_features = let mut synchronization2_features =
@ -103,20 +109,123 @@ pub fn find_graphics_queue_family(
Err(RendererError::NoSuitableGpu) Err(RendererError::NoSuitableGpu)
} }
/// Reports whether `physical_device` advertises the optional `VK_EXT_memory_budget` extension.
///
/// The extension is what makes driver-side VRAM usage and budget readable; without it those figures are simply unavailable, which is a reportable state rather than an error.
pub fn supports_memory_budget(instance: &Instance, physical_device: vk::PhysicalDevice) -> bool {
has_extension(instance, physical_device, ash::ext::memory_budget::NAME)
}
/// Queries the immutable properties of `physical_device` into a reportable snapshot.
///
/// `memory_budget` records whether the optional budget extension was enabled on the logical device, since the caller owns that decision and this query cannot observe it.
pub fn query_gpu_info(
instance: &Instance,
physical_device: vk::PhysicalDevice,
memory_budget: bool,
) -> GpuInfo {
let props = unsafe { instance.get_physical_device_properties(physical_device) };
let memory_props = unsafe { instance.get_physical_device_memory_properties(physical_device) };
// The name is a fixed-size, NUL-terminated array of `c_char`; `to_string_lossy` substitutes replacement characters rather than failing on a malformed driver string.
let device_name = unsafe { std::ffi::CStr::from_ptr(props.device_name.as_ptr()) }
.to_string_lossy()
.into_owned();
GpuInfo {
device_name,
device_type: device_type_name(props.device_type),
vendor_id: props.vendor_id,
device_id: props.device_id,
driver_version: decode_driver_version(props.vendor_id, props.driver_version),
api_version: format!(
"{}.{}.{}",
vk::api_version_major(props.api_version),
vk::api_version_minor(props.api_version),
vk::api_version_patch(props.api_version)
),
vram_total_bytes: device_local_heap_bytes(&memory_props),
memory_budget_supported: memory_budget,
}
}
/// Reads the driver's current usage and budget across the device-local heaps.
///
/// Returns `(usage, budget)` in bytes. Both are [`None`] unless `VK_EXT_memory_budget` is enabled, since the values are carried in a structure the extension defines. The figures cover every process on the device, not only this one.
pub fn query_memory_budget(
instance: &Instance,
physical_device: vk::PhysicalDevice,
memory_budget: bool,
) -> (Option<u64>, Option<u64>) {
if !memory_budget {
return (None, None);
}
let mut budget_props = vk::PhysicalDeviceMemoryBudgetPropertiesEXT::default();
let mut props = vk::PhysicalDeviceMemoryProperties2::default().push_next(&mut budget_props);
unsafe {
instance.get_physical_device_memory_properties2(physical_device, &mut props);
}
// Only the device-local heaps are of interest; host-visible system-memory heaps are not the resource under pressure. The three arrays are parallel and all sized `VK_MAX_MEMORY_HEAPS`, so zipping them cannot desynchronise.
let heaps = &props.memory_properties;
let count = heaps.memory_heap_count as usize;
let (usage, budget) = heaps.memory_heaps[..count]
.iter()
.zip(&budget_props.heap_usage[..count])
.zip(&budget_props.heap_budget[..count])
.filter(|((heap, _), _)| heap.flags.contains(vk::MemoryHeapFlags::DEVICE_LOCAL))
.fold((0u64, 0u64), |(usage, budget), ((_, used), allowed)| {
(usage.saturating_add(*used), budget.saturating_add(*allowed))
});
(Some(usage), Some(budget))
}
/// Sums the capacity of every heap flagged `DEVICE_LOCAL`, in bytes.
fn device_local_heap_bytes(props: &vk::PhysicalDeviceMemoryProperties) -> u64 {
props.memory_heaps[..props.memory_heap_count as usize]
.iter()
.filter(|heap| heap.flags.contains(vk::MemoryHeapFlags::DEVICE_LOCAL))
.map(|heap| heap.size)
.sum()
}
/// Returns a human-readable name for a physical-device class.
const fn device_type_name(device_type: vk::PhysicalDeviceType) -> &'static str {
match device_type {
vk::PhysicalDeviceType::DISCRETE_GPU => "discrete",
vk::PhysicalDeviceType::INTEGRATED_GPU => "integrated",
vk::PhysicalDeviceType::VIRTUAL_GPU => "virtual",
vk::PhysicalDeviceType::CPU => "cpu",
_ => "other",
}
}
/// Reports whether `physical_device` advertises the named device extension.
fn has_extension(
instance: &Instance,
physical_device: vk::PhysicalDevice,
name: &std::ffi::CStr,
) -> bool {
let extensions = unsafe {
instance
.enumerate_device_extension_properties(physical_device)
.unwrap_or_default()
};
extensions
.iter()
.any(|ext| unsafe { std::ffi::CStr::from_ptr(ext.extension_name.as_ptr()) } == name)
}
/// Reports whether a physical device can present to `surface` and supports the extensions the renderer requires.
fn is_device_suitable( fn is_device_suitable(
instance: &Instance, instance: &Instance,
device: vk::PhysicalDevice, device: vk::PhysicalDevice,
surface_loader: &khr::surface::Instance, surface_loader: &khr::surface::Instance,
surface: vk::SurfaceKHR, surface: vk::SurfaceKHR,
) -> bool { ) -> bool {
let extensions = unsafe { let has_swapchain = has_extension(instance, device, khr::swapchain::NAME);
instance
.enumerate_device_extension_properties(device)
.unwrap_or_default()
};
let has_swapchain = extensions.iter().any(|ext| unsafe {
std::ffi::CStr::from_ptr(ext.extension_name.as_ptr()) == khr::swapchain::NAME
});
let formats = unsafe { let formats = unsafe {
surface_loader surface_loader

View file

@ -14,6 +14,7 @@ mod instance;
pub mod meshing; pub mod meshing;
mod pipeline; mod pipeline;
mod renderer; mod renderer;
pub mod stats;
mod surface; mod surface;
mod swapchain; mod swapchain;
mod sync; mod sync;
@ -29,6 +30,7 @@ use std::ffi::c_char;
pub use error::RendererError; pub use error::RendererError;
pub use renderer::{MeshKey, RasterPass, RenderMode, Renderer}; pub use renderer::{MeshKey, RasterPass, RenderMode, Renderer};
pub use stats::{GpuInfo, MemoryUsage, ProjectionInfo, RenderStats, SwapchainInfo};
use std::collections::HashMap; use std::collections::HashMap;
@ -75,8 +77,15 @@ impl Renderer {
)?; )?;
// 5. Logical Device and Queue // 5. Logical Device and Queue
let (device, graphics_queue) = // Driver-side memory reporting is optional; the extension is detected here so it can be both enabled on the device and recorded in the reported device information.
device::create_logical_device(&instance, physical_device, graphics_queue_index)?; let memory_budget = device::supports_memory_budget(&instance, physical_device);
let (device, graphics_queue) = device::create_logical_device(
&instance,
physical_device,
graphics_queue_index,
memory_budget,
)?;
let gpu_info = device::query_gpu_info(&instance, physical_device, memory_budget);
// 6. Swapchain // 6. Swapchain
let (swapchain_loader, swapchain, swapchain_images, swapchain_format, swapchain_extent) = let (swapchain_loader, swapchain, swapchain_images, swapchain_format, swapchain_extent) =
@ -167,8 +176,14 @@ impl Renderer {
pipeline_layout, pipeline_layout,
pipelines, pipelines,
render_mode: RenderMode::default(), render_mode: RenderMode::default(),
gpu_info,
memory_budget,
sync: Some(sync), sync: Some(sync),
current_frame: 0, current_frame: 0,
present_mode: swapchain::present_mode_name(swapchain::PRESENT_MODE),
frames_presented: 0,
frames_skipped: 0,
last_frame_stats: None,
}) })
} }
} }

View file

@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
use crate::stats::{GpuInfo, MemoryUsage, ProjectionInfo, RenderStats, SwapchainInfo};
use crate::sync::SyncPrimitives; use crate::sync::SyncPrimitives;
use crate::{create_depth_resources, create_gpu_buffer, swapchain}; use crate::{create_depth_resources, create_gpu_buffer, swapchain};
use crate::{error::RendererError, frustum::Frustum, vertex::Vertex}; use crate::{error::RendererError, frustum::Frustum, vertex::Vertex};
@ -10,6 +11,15 @@ use std::collections::HashMap;
/// Opaque, renderer-side identifier for one uploaded chunk mesh. /// Opaque, renderer-side identifier for one uploaded chunk mesh.
pub type MeshKey = (i32, i32, i32); pub type MeshKey = (i32, i32, i32);
/// Vertical field of view of the perspective projection, in degrees.
const FOV_Y_DEGREES: f32 = 45.0;
/// Distance to the near clip plane, in blocks.
const NEAR_PLANE: f32 = 0.1;
/// Distance to the far clip plane, in blocks.
const FAR_PLANE: f32 = 500.0;
/// One rasterisation pass over the visible chunk meshes. /// One rasterisation pass over the visible chunk meshes.
/// ///
/// A pass corresponds one-to-one with a pipeline object, since polygon mode and depth-compare state are baked into a pipeline and cannot be changed by a command. Passes are the GPU-level primitive; [`RenderMode`] composes them into what is actually presented. /// A pass corresponds one-to-one with a pipeline object, since polygon mode and depth-compare state are baked into a pipeline and cannot be changed by a command. Passes are the GPU-level primitive; [`RenderMode`] composes them into what is actually presented.
@ -117,10 +127,34 @@ pub(crate) struct GpuMesh {
pub(crate) index_allocation: Allocation, pub(crate) index_allocation: Allocation,
/// Number of indices submitted in the mesh's `cmd_draw_indexed` call. /// Number of indices submitted in the mesh's `cmd_draw_indexed` call.
pub(crate) index_count: u32, pub(crate) index_count: u32,
/// Number of vertices in [`GpuMesh::vertex_buffer`]. Not needed to draw an indexed mesh; retained because the index count alone cannot recover it.
pub(crate) vertex_count: u32,
/// Size of [`GpuMesh::vertex_buffer`] in bytes, retained to report resident GPU geometry footprint.
pub(crate) vertex_bytes: u64,
/// Size of [`GpuMesh::index_buffer`] in bytes, retained to report resident GPU geometry footprint.
pub(crate) index_bytes: u64,
/// Chunk origin in world space (blocks); added to every vertex in the vertex shader. /// Chunk origin in world space (blocks); added to every vertex in the vertex shader.
pub(crate) world_offset: [f32; 3], pub(crate) world_offset: [f32; 3],
} }
/// What one frame's draw-call recording submitted, gathered where the work is issued.
///
/// Kept private and distinct from [`RenderStats`]: this carries only the values that are observable inside the recording pass, while the published snapshot additionally folds in renderer-wide state (resident mesh totals, cumulative frame counters) that the recording pass has no reason to look at.
struct Submission {
/// Meshes that survived frustum culling and were submitted.
visible_meshes: usize,
/// Meshes rejected by frustum culling.
culled_meshes: usize,
/// Indexed draw calls recorded: one per visible mesh per pass.
draw_calls: usize,
/// Triangles submitted, counted across every pass.
triangles: u64,
/// Vertices referenced by the submitted meshes, counted across every pass.
vertices: u64,
/// Projection parameters used to build the frame's matrix.
projection: ProjectionInfo,
}
/// The core renderer structure holding the Vulkan resources. /// The core renderer structure holding the Vulkan resources.
pub struct Renderer { pub struct Renderer {
/// Entry point to the Vulkan library. /// Entry point to the Vulkan library.
@ -183,6 +217,18 @@ pub struct Renderer {
pub(crate) sync: Option<SyncPrimitives>, pub(crate) sync: Option<SyncPrimitives>,
/// Index of the current frame being processed (0 to `crate::MAX_FRAMES_IN_FLIGHT` - 1). /// Index of the current frame being processed (0 to `crate::MAX_FRAMES_IN_FLIGHT` - 1).
pub(crate) current_frame: usize, pub(crate) current_frame: usize,
/// Presentation mode the swapchain was created with, retained as its Vulkan enum name for reporting.
pub(crate) present_mode: &'static str,
/// Frames presented since initialisation.
pub(crate) frames_presented: u64,
/// Frames abandoned before submission because the swapchain was out of date.
pub(crate) frames_skipped: u64,
/// Submission statistics for the most recently completed frame, or [`None`] before the first frame completes.
pub(crate) last_frame_stats: Option<RenderStats>,
/// Immutable properties of the selected physical device, queried once at initialisation.
pub(crate) gpu_info: GpuInfo,
/// Whether `VK_EXT_memory_budget` was enabled on the logical device, gating the driver-side figures in [`MemoryUsage`].
pub(crate) memory_budget: bool,
} }
impl Renderer { impl Renderer {
@ -218,6 +264,7 @@ impl Renderer {
let (image_index, _is_suboptimal) = match acquire { let (image_index, _is_suboptimal) = match acquire {
Ok(pair) => pair, Ok(pair) => pair,
Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => { Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => {
self.frames_skipped = self.frames_skipped.saturating_add(1);
self.recreate_swapchain(self.swapchain_extent.width, self.swapchain_extent.height)?; self.recreate_swapchain(self.swapchain_extent.width, self.swapchain_extent.height)?;
return Ok(()); return Ok(());
} }
@ -248,8 +295,8 @@ impl Renderer {
let image = self.swapchain_images[image_index as usize]; let image = self.swapchain_images[image_index as usize];
let view = self.swapchain_image_views[image_index as usize]; let view = self.swapchain_image_views[image_index as usize];
// 4. Record the actual rendering commands // 4. Record the actual rendering commands, retaining what they submitted.
self.record_commands(cmd, view, image, camera_view)?; let submission = self.record_commands(cmd, view, image, camera_view)?;
// 5. Submit the work to the GPU // 5. Submit the work to the GPU
let submit_info = vk::SubmitInfo::default() let submit_info = vk::SubmitInfo::default()
@ -276,6 +323,10 @@ impl Renderer {
// Advance the frame index regardless of the present outcome; the submitted work is already in flight on `in_flight_fence`. // Advance the frame index regardless of the present outcome; the submitted work is already in flight on `in_flight_fence`.
self.current_frame = (self.current_frame + 1) % crate::MAX_FRAMES_IN_FLIGHT; self.current_frame = (self.current_frame + 1) % crate::MAX_FRAMES_IN_FLIGHT;
self.frames_presented = self.frames_presented.saturating_add(1);
// The snapshot is published only once the frame has been submitted, so a reader never observes counts for a frame that was abandoned.
self.last_frame_stats = Some(self.frame_stats(&submission));
// A suboptimal (`Ok(true)`) or out-of-date swapchain is rebuilt so the next frame targets a surface-matched swapchain. The rebuilt swapchain also corrects the projection aspect ratio, which is derived from the swapchain extent. // A suboptimal (`Ok(true)`) or out-of-date swapchain is rebuilt so the next frame targets a surface-matched swapchain. The rebuilt swapchain also corrects the projection aspect ratio, which is derived from the swapchain extent.
match present { match present {
@ -375,7 +426,82 @@ impl Renderer {
} }
} }
/// Records the drawing commands into the given command buffer. /// Assembles the published snapshot for a completed frame from the per-frame submission counts and the renderer's own resident state.
fn frame_stats(&self, submission: &Submission) -> RenderStats {
// Buffer footprint is a property of what is resident, not of what was drawn, so it is summed over every uploaded mesh rather than over the visible subset.
let (vertex_bytes, index_bytes) = self
.chunk_meshes
.values()
.fold((0, 0), |(vertex, index), mesh| {
(vertex + mesh.vertex_bytes, index + mesh.index_bytes)
});
RenderStats {
uploaded_meshes: self.chunk_meshes.len(),
visible_meshes: submission.visible_meshes,
culled_meshes: submission.culled_meshes,
draw_calls: submission.draw_calls,
triangles: submission.triangles,
vertices: submission.vertices,
vertex_bytes,
index_bytes,
render_mode: self.render_mode,
projection: submission.projection,
swapchain: SwapchainInfo {
// The image count is bounded by the surface's maximum, far inside u32.
#[expect(
clippy::cast_possible_truncation,
reason = "swapchain image counts are single digits"
)]
image_count: self.swapchain_images.len() as u32,
width: self.swapchain_extent.width,
height: self.swapchain_extent.height,
present_mode: self.present_mode,
},
frames_presented: self.frames_presented,
frames_skipped: self.frames_skipped,
}
}
/// Returns the submission statistics for the most recently completed frame, or [`None`] before the first frame has been presented.
#[must_use]
pub const fn stats(&self) -> Option<RenderStats> {
self.last_frame_stats
}
/// Returns the immutable properties of the physical device the renderer selected.
#[must_use]
pub const fn gpu_info(&self) -> &GpuInfo {
&self.gpu_info
}
/// Reads live memory figures from the driver and the renderer's allocator.
///
/// The driver-side figures require `VK_EXT_memory_budget` and are [`None`] where it is unsupported. The allocator figures are always available but describe only this process's suballocations.
#[must_use]
pub fn memory_usage(&self) -> MemoryUsage {
let (heap_usage_bytes, heap_budget_bytes) = crate::device::query_memory_budget(
&self.instance,
self.physical_device,
self.memory_budget,
);
// The allocator is taken only during teardown, so a live renderer always observes it; absent it, the process-side figures are simply reported as zero rather than failing the whole snapshot.
let (allocator_allocated_bytes, allocator_capacity_bytes) =
self.allocator.as_ref().map_or((0, 0), |allocator| {
let report = allocator.generate_report();
(report.total_allocated_bytes, report.total_capacity_bytes)
});
MemoryUsage {
heap_usage_bytes,
heap_budget_bytes,
allocator_allocated_bytes,
allocator_capacity_bytes,
}
}
/// Records the drawing commands into the given command buffer, returning what they submitted.
/// ///
/// # Errors /// # Errors
/// ///
@ -386,7 +512,7 @@ impl Renderer {
view: vk::ImageView, view: vk::ImageView,
image: vk::Image, image: vk::Image,
camera_view: glam::Mat4, camera_view: glam::Mat4,
) -> Result<(), RendererError> { ) -> Result<Submission, RendererError> {
// Transition layouts for drawing // Transition layouts for drawing
self.transition_to_draw_layout(cmd, image); self.transition_to_draw_layout(cmd, image);
@ -425,14 +551,16 @@ impl Renderer {
unsafe { unsafe {
self.device.cmd_begin_rendering(cmd, &rendering_info); self.device.cmd_begin_rendering(cmd, &rendering_info);
self.issue_draw_calls(cmd, camera_view); }
let submission = self.issue_draw_calls(cmd, camera_view);
unsafe {
self.device.cmd_end_rendering(cmd); self.device.cmd_end_rendering(cmd);
} }
// Transition back to present // Transition back to present
self.transition_to_present_layout(cmd, image)?; self.transition_to_present_layout(cmd, image)?;
Ok(()) Ok(submission)
} }
/// Transitions the swapchain and depth images to layouts suitable for drawing. /// Transitions the swapchain and depth images to layouts suitable for drawing.
@ -479,29 +607,29 @@ impl Renderer {
unsafe { self.device.cmd_pipeline_barrier2(cmd, &dependency_info) }; unsafe { self.device.cmd_pipeline_barrier2(cmd, &dependency_info) };
} }
/// Issues the actual draw calls for the frame. /// Issues the actual draw calls for the frame, returning what was submitted.
fn issue_draw_calls(&self, cmd: vk::CommandBuffer, camera_view: glam::Mat4) { fn issue_draw_calls(&self, cmd: vk::CommandBuffer, camera_view: glam::Mat4) -> Submission {
unsafe { let projection = self.projection_info();
#[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,
width: self.swapchain_extent.width as f32,
height: self.swapchain_extent.height as f32,
min_depth: 0.0,
max_depth: 1.0,
};
self.device.cmd_set_viewport(cmd, 0, &[viewport]);
let scissor = vk::Rect2D { // The view matrix is supplied by the caller (the client's camera); the renderer owns only the projection, which depends on the swapchain aspect ratio it manages.
offset: vk::Offset2D { x: 0, y: 0 }, let mvp = glam::camera::rh::proj::vulkan::perspective(
extent: self.swapchain_extent, projection.fov_y_radians,
}; projection.aspect,
self.device.cmd_set_scissor(cmd, 0, &[scissor]); projection.near,
projection.far,
) * camera_view;
let (visible, culled) = self.cull_to_frustum(mvp);
let submission = self.summarise_submission(&visible, culled, projection);
self.set_dynamic_state(cmd);
self.record_passes(cmd, mvp, &visible);
submission
}
/// Derives this frame's projection parameters from the swapchain extent.
fn projection_info(&self) -> ProjectionInfo {
let aspect = let aspect =
f64::from(self.swapchain_extent.width) / f64::from(self.swapchain_extent.height); f64::from(self.swapchain_extent.width) / f64::from(self.swapchain_extent.height);
@ -509,17 +637,18 @@ impl Renderer {
clippy::cast_possible_truncation, clippy::cast_possible_truncation,
reason = "the aspect ratio is a small value; f32 precision is sufficient" reason = "the aspect ratio is a small value; f32 precision is sufficient"
)] )]
let projection = glam::camera::rh::proj::vulkan::perspective( ProjectionInfo {
45.0_f32.to_radians(), fov_y_radians: FOV_Y_DEGREES.to_radians(),
aspect as f32, aspect: aspect as f32,
0.1, near: NEAR_PLANE,
500.0, far: FAR_PLANE,
); }
}
// The view matrix is supplied by the caller (the client's camera); the renderer owns only the projection, which depends on the swapchain aspect ratio it manages. /// Partitions the uploaded meshes against the view frustum derived from `mvp`, returning the survivors and the number rejected.
let mvp = projection * camera_view; ///
/// Culling is performed once per frame rather than once per pass: the frustum does not change between passes, so the surviving set is shared by all of them.
// The view frustum is derived from the same matrix and reused to reject chunks whose bounding box lies entirely outside the view before any draw work is recorded. fn cull_to_frustum(&self, mvp: glam::Mat4) -> (Vec<&GpuMesh>, usize) {
let frustum = Frustum::from_view_proj(mvp); let frustum = Frustum::from_view_proj(mvp);
// A chunk spans CHUNK_SIZE blocks on each axis. The mesher centres block i on [i - 0.5, i + 0.5], so a chunk's box runs [offset - 0.5, offset + CHUNK_SIZE - 0.5]; the extent below is added to that shifted minimum corner. // A chunk spans CHUNK_SIZE blocks on each axis. The mesher centres block i on [i - 0.5, i + 0.5], so a chunk's box runs [offset - 0.5, offset + CHUNK_SIZE - 0.5]; the extent below is added to that shifted minimum corner.
@ -528,8 +657,8 @@ impl Renderer {
reason = "CHUNK_SIZE is 32, exactly representable as f32" reason = "CHUNK_SIZE is 32, exactly representable as f32"
)] )]
let chunk_extent = glam::Vec3::splat(shared::world::CHUNK_SIZE as f32); let chunk_extent = glam::Vec3::splat(shared::world::CHUNK_SIZE as f32);
// Culling is performed once per frame rather than once per pass: the frustum does not change between passes, so the surviving set is shared by all of them.
let mut culled: u32 = 0; let mut culled = 0;
let visible: Vec<&GpuMesh> = self let visible: Vec<&GpuMesh> = self
.chunk_meshes .chunk_meshes
.values() .values()
@ -544,10 +673,65 @@ impl Renderer {
}) })
.collect(); .collect();
if culled > 0 { (visible, culled)
tracing::debug!(culled, "chunks skipped by frustum culling");
} }
/// Totals what the surviving meshes will submit under the active render mode.
///
/// Geometry totals are summed once over the surviving set and multiplied by the pass count, since every pass submits the same meshes.
fn summarise_submission(
&self,
visible: &[&GpuMesh],
culled: usize,
projection: ProjectionInfo,
) -> Submission {
let per_pass_indices: u64 = visible.iter().map(|mesh| u64::from(mesh.index_count)).sum();
let per_pass_vertices: u64 = visible
.iter()
.map(|mesh| u64::from(mesh.vertex_count))
.sum();
let passes = self.render_mode.passes().len();
Submission {
visible_meshes: visible.len(),
culled_meshes: culled,
draw_calls: visible.len() * passes,
// Three indices per triangle; the mesher emits triangle lists exclusively.
triangles: per_pass_indices / 3 * passes as u64,
vertices: per_pass_vertices * passes as u64,
projection,
}
}
/// Records the viewport and scissor, which are dynamic pipeline state and must therefore be set on every command buffer.
fn set_dynamic_state(&self, cmd: vk::CommandBuffer) {
#[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,
width: self.swapchain_extent.width as f32,
height: self.swapchain_extent.height as f32,
min_depth: 0.0,
max_depth: 1.0,
};
let scissor = vk::Rect2D {
offset: vk::Offset2D { x: 0, y: 0 },
extent: self.swapchain_extent,
};
unsafe {
self.device.cmd_set_viewport(cmd, 0, &[viewport]);
self.device.cmd_set_scissor(cmd, 0, &[scissor]);
}
}
/// Records one indexed draw per visible mesh, for every pass the active render mode composes.
fn record_passes(&self, cmd: vk::CommandBuffer, mvp: glam::Mat4, visible: &[&GpuMesh]) {
unsafe {
// The MVP is identical for every chunk and every pass this frame, so it is pushed once before the loops. // The MVP is identical for every chunk and every pass this frame, so it is pushed once before the loops.
let mvp_bytes = bytemuck::cast_slice(mvp.as_ref()); let mvp_bytes = bytemuck::cast_slice(mvp.as_ref());
self.device.cmd_push_constants( self.device.cmd_push_constants(
@ -573,7 +757,7 @@ impl Renderer {
self.pipelines[pass.index()], self.pipelines[pass.index()],
); );
for mesh in &visible { for mesh in visible {
// The offset is padded to a vec4 to match the std140 layout of the push-constant block. The shader reads xyz as the chunk's world offset and w as the debug-tint weight for this pass. // The offset is padded to a vec4 to match the std140 layout of the push-constant block. The shader reads xyz as the chunk's world offset and w as the debug-tint weight for this pass.
let offset = [ let offset = [
mesh.world_offset[0], mesh.world_offset[0],
@ -696,6 +880,9 @@ impl Renderer {
index_buffer, index_buffer,
index_allocation, index_allocation,
index_count: indices.len() as u32, index_count: indices.len() as u32,
vertex_count: vertices.len() as u32,
vertex_bytes: std::mem::size_of_val(vertices) as u64,
index_bytes: std::mem::size_of_val(indices) as u64,
world_offset, world_offset,
}, },
); );

View file

@ -0,0 +1,160 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Snapshot types describing what the renderer submitted and what device it submitted to.
use crate::renderer::RenderMode;
/// The projection parameters used to build this frame's perspective matrix.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct ProjectionInfo {
/// Vertical field of view, in radians.
pub fov_y_radians: f32,
/// Width-to-height ratio of the render target, derived from the swapchain extent.
pub aspect: f32,
/// Distance to the near clip plane, in blocks.
pub near: f32,
/// Distance to the far clip plane, in blocks.
pub far: f32,
}
/// Description of the swapchain currently backing presentation.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct SwapchainInfo {
/// Number of images the swapchain was created with.
pub image_count: u32,
/// Width of the swapchain images, in pixels.
pub width: u32,
/// Height of the swapchain images, in pixels.
pub height: u32,
/// Presentation mode the swapchain was created with, rendered as its Vulkan enum name.
pub present_mode: &'static str,
}
/// What the renderer submitted for one frame, plus the cumulative frame counters.
///
/// Populated at the end of every successful [`Renderer::draw_frame`](crate::Renderer::draw_frame) and retained until the next frame replaces it, so a reader running on a slower cadence than the render loop always observes a complete, self-consistent frame rather than a partially-updated one.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct RenderStats {
/// Total chunk meshes currently uploaded to the GPU, visible or not.
pub uploaded_meshes: usize,
/// Meshes that survived frustum culling and were submitted this frame.
pub visible_meshes: usize,
/// Meshes rejected by frustum culling this frame.
pub culled_meshes: usize,
/// Indexed draw calls recorded this frame: one per visible mesh per raster pass.
pub draw_calls: usize,
/// Triangles submitted this frame, counted across every pass.
pub triangles: u64,
/// Vertices referenced by the submitted meshes, counted across every pass.
pub vertices: u64,
/// Bytes of vertex buffer held by every uploaded mesh, visible or not.
pub vertex_bytes: u64,
/// Bytes of index buffer held by every uploaded mesh, visible or not.
pub index_bytes: u64,
/// The render mode in force this frame, which determines the pass list and therefore the draw-call multiplier.
pub render_mode: RenderMode,
/// Projection parameters used to build this frame's matrix.
pub projection: ProjectionInfo,
/// The swapchain backing presentation at the end of this frame.
pub swapchain: SwapchainInfo,
/// Frames presented since renderer initialisation.
pub frames_presented: u64,
/// Frames abandoned before submission because the swapchain reported itself out of date, typically during a window resize.
pub frames_skipped: u64,
}
impl RenderStats {
/// Returns the fraction of uploaded meshes rejected by frustum culling this frame, in percent.
///
/// Returns zero when nothing was uploaded, since no meshes means no meshes were culled rather than an undefined ratio.
#[must_use]
pub fn cull_ratio_percent(&self) -> f32 {
let considered = self.visible_meshes + self.culled_meshes;
if considered == 0 {
return 0.0;
}
// Mesh counts are bounded by the resident chunk set (thousands), far inside f32's exact-integer range.
#[expect(
clippy::cast_precision_loss,
reason = "mesh counts stay well within f32's exact-integer range"
)]
{
self.culled_meshes as f32 / considered as f32 * 100.0
}
}
}
/// Immutable description of the physical device the renderer selected.
///
/// Queried once at initialisation: every field is a property of the device or driver and cannot change for the lifetime of the renderer. Live memory figures are not part of this and are read separately through [`MemoryUsage`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GpuInfo {
/// Marketing name the driver reports for the device.
pub device_name: String,
/// Device class: discrete, integrated, virtual, CPU, or other.
pub device_type: &'static str,
/// PCI vendor identifier, as reported by the driver.
pub vendor_id: u32,
/// Vendor-assigned device identifier.
pub device_id: u32,
/// Driver version, decoded with the vendor's own packing scheme where it differs from the Vulkan convention.
pub driver_version: String,
/// Vulkan API version the device supports, as `major.minor.patch`.
pub api_version: String,
/// Total capacity of every heap flagged `DEVICE_LOCAL`, in bytes. This is dedicated video memory on a discrete device and a share of system memory on an integrated one.
pub vram_total_bytes: u64,
/// Whether `VK_EXT_memory_budget` was available and enabled, and therefore whether [`MemoryUsage`] can report driver-side figures.
pub memory_budget_supported: bool,
}
/// Live memory figures, read on demand rather than cached.
///
/// Two independent views: the driver's own accounting of the device-local heaps (available only where `VK_EXT_memory_budget` is supported) and the renderer's allocator, which sees only what this process suballocates.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct MemoryUsage {
/// Bytes the driver reports as currently in use across the device-local heaps, by every process. [`None`] where `VK_EXT_memory_budget` is unsupported.
pub heap_usage_bytes: Option<u64>,
/// Bytes the driver reports this process may use across the device-local heaps before it risks eviction. [`None`] where `VK_EXT_memory_budget` is unsupported.
pub heap_budget_bytes: Option<u64>,
/// Bytes currently handed out by the renderer's allocator as live suballocations.
pub allocator_allocated_bytes: u64,
/// Bytes the renderer's allocator holds in device memory blocks, including regions not yet suballocated. Always at least `allocator_allocated_bytes`; the difference is allocator slack.
pub allocator_capacity_bytes: u64,
}
/// PCI vendor identifier for NVIDIA, whose driver packs `driver_version` differently from the Vulkan convention.
const VENDOR_NVIDIA: u32 = 0x10DE;
/// PCI vendor identifier for Intel, whose Windows driver packs `driver_version` differently from the Vulkan convention.
const VENDOR_INTEL: u32 = 0x8086;
/// Decodes a `VkPhysicalDeviceProperties::driverVersion` into a human-readable string.
///
/// The field is documented as vendor-specific, and two vendors deviate from the `VK_MAKE_VERSION` packing the rest follow. NVIDIA uses a four-component 10/8/8/6-bit layout. Intel's Windows driver uses a 14/18-bit split; its Linux (Mesa) driver follows the Vulkan convention, so the deviation is applied only on Windows. Every other vendor is decoded as major/minor/patch.
#[must_use]
pub fn decode_driver_version(vendor_id: u32, version: u32) -> String {
if vendor_id == VENDOR_NVIDIA {
return format!(
"{}.{}.{}.{}",
(version >> 22) & 0x3ff,
(version >> 14) & 0x0ff,
(version >> 6) & 0x0ff,
version & 0x3f
);
}
if vendor_id == VENDOR_INTEL && cfg!(windows) {
return format!("{}.{}", version >> 14, version & 0x3fff);
}
format!(
"{}.{}.{}",
version >> 22,
(version >> 12) & 0x3ff,
version & 0xfff
)
}
#[cfg(test)]
#[path = "tests/stats.rs"]
mod tests;

View file

@ -3,6 +3,26 @@
use crate::error::RendererError; use crate::error::RendererError;
use ash::{Device, Instance, khr, vk}; use ash::{Device, Instance, khr, vk};
/// Presentation mode every swapchain is created with.
///
/// `FIFO` is the only mode the specification guarantees to be supported, and it is vsync-locked, so presentation never tears.
// TODO: select from the surface's supported modes once a vsync setting exists; `MAILBOX` is the low-latency alternative where available.
pub const PRESENT_MODE: vk::PresentModeKHR = vk::PresentModeKHR::FIFO;
/// Returns the Vulkan enum name of a presentation mode, for reporting.
///
/// A mode outside the known set is reported as `"UNKNOWN"` rather than its numeric value, since the numeric value carries no meaning to a reader.
#[must_use]
pub const fn present_mode_name(mode: vk::PresentModeKHR) -> &'static str {
match mode {
vk::PresentModeKHR::IMMEDIATE => "IMMEDIATE",
vk::PresentModeKHR::MAILBOX => "MAILBOX",
vk::PresentModeKHR::FIFO => "FIFO",
vk::PresentModeKHR::FIFO_RELAXED => "FIFO_RELAXED",
_ => "UNKNOWN",
}
}
/// Creates a swapchain and retrieves its images. /// Creates a swapchain and retrieves its images.
/// ///
/// # Errors /// # Errors
@ -47,8 +67,6 @@ pub fn create_swapchain(
}) })
.unwrap_or(&surface_formats[0]); .unwrap_or(&surface_formats[0]);
let present_mode = vk::PresentModeKHR::FIFO;
let extent = if surface_capabilities.current_extent.width == u32::MAX { let extent = if surface_capabilities.current_extent.width == u32::MAX {
vk::Extent2D { vk::Extent2D {
width: width.clamp( width: width.clamp(
@ -85,7 +103,7 @@ pub fn create_swapchain(
.image_sharing_mode(vk::SharingMode::EXCLUSIVE) .image_sharing_mode(vk::SharingMode::EXCLUSIVE)
.pre_transform(surface_capabilities.current_transform) .pre_transform(surface_capabilities.current_transform)
.composite_alpha(vk::CompositeAlphaFlagsKHR::OPAQUE) .composite_alpha(vk::CompositeAlphaFlagsKHR::OPAQUE)
.present_mode(present_mode) .present_mode(PRESENT_MODE)
.clipped(true); .clipped(true);
let swapchain = unsafe { swapchain_loader.create_swapchain(&create_info, None)? }; let swapchain = unsafe { swapchain_loader.create_swapchain(&create_info, None)? };

View file

@ -0,0 +1,80 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Unit tests for the derived arithmetic in [`crate::stats`].
use super::*;
/// Builds a stats snapshot whose only meaningful fields are the two mesh counts the ratio is derived from.
fn stats_with_counts(visible: usize, culled: usize) -> RenderStats {
RenderStats {
uploaded_meshes: visible + culled,
visible_meshes: visible,
culled_meshes: culled,
draw_calls: 0,
triangles: 0,
vertices: 0,
vertex_bytes: 0,
index_bytes: 0,
render_mode: RenderMode::Filled,
projection: ProjectionInfo {
fov_y_radians: 0.0,
aspect: 1.0,
near: 0.1,
far: 500.0,
},
swapchain: SwapchainInfo {
image_count: 3,
width: 1,
height: 1,
present_mode: "FIFO",
},
frames_presented: 0,
frames_skipped: 0,
}
}
#[test]
fn cull_ratio_is_zero_when_nothing_is_uploaded() {
assert!((stats_with_counts(0, 0).cull_ratio_percent() - 0.0).abs() < f32::EPSILON);
}
#[test]
fn cull_ratio_is_zero_when_every_mesh_is_visible() {
assert!((stats_with_counts(8, 0).cull_ratio_percent() - 0.0).abs() < f32::EPSILON);
}
#[test]
fn cull_ratio_is_full_when_every_mesh_is_culled() {
assert!((stats_with_counts(0, 8).cull_ratio_percent() - 100.0).abs() < f32::EPSILON);
}
#[test]
fn cull_ratio_is_the_culled_share_of_the_considered_set() {
assert!((stats_with_counts(3, 1).cull_ratio_percent() - 25.0).abs() < f32::EPSILON);
}
#[test]
fn driver_version_uses_the_vulkan_convention_for_unknown_vendors() {
// 1.2.131 packed as 22/12/0-bit major/minor/patch.
let packed = (1 << 22) | (2 << 12) | 0x83;
assert_eq!(decode_driver_version(0x1002, packed), "1.2.131");
}
#[test]
fn driver_version_uses_the_four_component_layout_for_nvidia() {
// 535.104.5.0 packed as 10/8/8/6-bit components.
let packed = (535 << 22) | (104 << 14) | (5 << 6);
assert_eq!(decode_driver_version(VENDOR_NVIDIA, packed), "535.104.5.0");
}
#[test]
fn driver_version_for_intel_follows_the_host_platform_convention() {
// 101.4502 packed as a 14/18-bit split, which is the Windows layout; the same word decodes differently under the Vulkan convention Mesa follows on Linux.
let packed = (101 << 14) | 0x1196;
let expected = if cfg!(windows) {
"101.4502"
} else {
"0.405.406"
};
assert_eq!(decode_driver_version(VENDOR_INTEL, packed), expected);
}

View file

@ -1,10 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
//! Per-connection chunk-streaming state. //! Per-connection outbound streaming state: chunk subscription tracking and the authority-stream sink.
use std::collections::HashSet; use std::collections::HashSet;
use net::ChunkSink; use net::{AuthoritySink, ChunkSink};
use shared::protocol::authority::{AuthorityMessage, ServerStats};
use shared::protocol::chunk::ChunkMessage; use shared::protocol::chunk::ChunkMessage;
use shared::world::{Chunk, ChunkData, ChunkPos}; use shared::world::{Chunk, ChunkData, ChunkPos};
@ -17,6 +18,10 @@ pub const SERVER_MAX_RADIUS: u16 = 12;
/// Worldgen version stamped on delivered chunk diffs. A single version exists today; this becomes the chunk's stored version once worldgen versioning lands. /// Worldgen version stamped on delivered chunk diffs. A single version exists today; this becomes the chunk's stored version once worldgen versioning lands.
const WORLDGEN_VERSION: u32 = 0; const WORLDGEN_VERSION: u32 = 0;
/// Upper bound on chunks encoded and sent to one client per tick.
// TODO: replace the fixed count with a time budget once per-chunk cost varies with LOD.
const MAX_DELIVERIES_PER_TICK: usize = 32;
/// The load and drop lists produced by diffing a client's previous desired set against a new one. /// The load and drop lists produced by diffing a client's previous desired set against a new one.
#[derive(Debug, Default, PartialEq, Eq)] #[derive(Debug, Default, PartialEq, Eq)]
pub struct DesiredDiff { pub struct DesiredDiff {
@ -39,10 +44,12 @@ pub fn desired_diff<S: std::hash::BuildHasher>(
DesiredDiff { added, removed } DesiredDiff { added, removed }
} }
/// Tracks one connected client's chunk subscription and what has been delivered to it. /// Tracks one connected client's chunk subscription, what has been delivered to it, and the sinks used to push to it.
pub struct ClientStream { pub struct ClientStream {
/// Outbound handle onto the client's chunk stream. /// Outbound handle onto the client's chunk stream.
sink: ChunkSink, sink: ChunkSink,
/// Outbound handle onto the client's authority stream, carried here so both per-connection sinks share one lifetime and one lookup key.
authority: AuthoritySink,
/// The chunk positions the client currently wants resident, already clamped to [`SERVER_MAX_RADIUS`]. /// The chunk positions the client currently wants resident, already clamped to [`SERVER_MAX_RADIUS`].
desired: HashSet<ChunkPos>, desired: HashSet<ChunkPos>,
/// Positions already delivered to the client as [`ChunkMessage::Chunk`]. /// Positions already delivered to the client as [`ChunkMessage::Chunk`].
@ -52,9 +59,10 @@ pub struct ClientStream {
impl ClientStream { impl ClientStream {
/// Creates a stream for a freshly connected client that has not yet subscribed. /// Creates a stream for a freshly connected client that has not yet subscribed.
#[must_use] #[must_use]
pub fn new(sink: ChunkSink) -> Self { pub fn new(sink: ChunkSink, authority: AuthoritySink) -> Self {
Self { Self {
sink, sink,
authority,
desired: HashSet::new(), desired: HashSet::new(),
sent: HashSet::new(), sent: HashSet::new(),
} }
@ -90,7 +98,7 @@ impl ClientStream {
/// Delivers every desired-but-undelivered chunk that has become resident in `world`. /// Delivers every desired-but-undelivered chunk that has become resident in `world`.
/// ///
/// Each chunk is encoded as a [`ChunkData`] diff against `baseline` (an all-air chunk), making the payload self-contained. Positions still pending in the worker pool are skipped and retried on a later call. Returns the number of chunks delivered. /// Each chunk is encoded as a [`ChunkData`] diff against `baseline` (an all-air chunk), making the payload self-contained. Positions still pending in the worker pool are skipped and retried on a later call, as are positions beyond [`MAX_DELIVERIES_PER_TICK`]. Returns the number of chunks delivered.
pub fn flush(&mut self, world: &ServerWorld, baseline: &Chunk) -> usize { pub fn flush(&mut self, world: &ServerWorld, baseline: &Chunk) -> usize {
// Collected first to avoid borrowing `self.desired` while mutating `self.sent`. // Collected first to avoid borrowing `self.desired` while mutating `self.sent`.
let ready: Vec<ChunkPos> = self let ready: Vec<ChunkPos> = self
@ -102,6 +110,11 @@ impl ClientStream {
let mut delivered = 0; let mut delivered = 0;
for pos in ready { for pos in ready {
// The budget counts chunks actually encoded, so ticks where most of the desired set is still in flight are not charged for work they did not do.
if delivered >= MAX_DELIVERIES_PER_TICK {
break;
}
let Some(chunk) = world.chunk(pos) else { let Some(chunk) = world.chunk(pos) else {
// Not resident yet; a later flush retries once the worker pool returns it. // Not resident yet; a later flush retries once the worker pool returns it.
continue; continue;
@ -113,6 +126,13 @@ impl ClientStream {
} }
delivered delivered
} }
/// Pushes a diagnostics snapshot onto the client's authority stream.
///
/// Non-blocking, and silently ignored when the connection has already gone away; see [`AuthoritySink::send`].
pub fn send_stats(&self, stats: ServerStats) {
self.authority.send(AuthorityMessage::ServerStats(stats));
}
} }
#[cfg(test)] #[cfg(test)]

View file

@ -12,26 +12,43 @@ pub mod client_stream;
pub mod player; pub mod player;
/// On-disk persistence: region files and the atomic durability layer. /// On-disk persistence: region files and the atomic durability layer.
pub mod save; pub mod save;
/// Formatting of the periodic simulation statistics report.
pub mod stats;
/// Measurement of the simulation loop's achieved tick rate and per-tick cost.
pub mod tick_stats;
/// Authoritative chunk storage and generation logic for the server. /// Authoritative chunk storage and generation logic for the server.
pub mod world_server; pub mod world_server;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::net::{Ipv4Addr, SocketAddr}; use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration; use std::time::{Duration, Instant};
use anyhow::Context; use anyhow::Context;
use bevy_ecs::prelude::{Query, ResMut, Schedule, With, World}; use bevy_ecs::prelude::{Query, ResMut, Schedule, With, Without, World};
use glam::Vec3; use glam::Vec3;
use shared::generator::{VoxelGenerator, WorldGenConfig}; use shared::generator::{VoxelGenerator, WorldGenConfig};
use shared::protocol::authority::ServerStats;
use shared::world::{Chunk, ChunkPos, EntityPos}; use shared::world::{Chunk, ChunkPos, EntityPos};
use tracing::{debug, info, warn}; use tracing::{debug, info, trace, warn};
use client_stream::ClientStream; use client_stream::ClientStream;
use net::{NetworkServer, ServerEvent}; use net::{NetworkServer, ServerEvent};
use player::{Player, Position, ViewDistance}; use player::{Player, Position, ViewDistance};
use tick_stats::{TickMeter, TickWindow};
use world_server::{ServerWorld, cylinder_chunks}; use world_server::{ServerWorld, cylinder_chunks};
/// Nominal simulation rate, in ticks per second. Sole source of truth for both the loop's target period and the advisory rate advertised to clients in the handshake.
// TODO: make configurable through server configs once the real tick scheduler lands.
const TICK_RATE_HZ: u16 = 20;
/// Target wall-clock period of one simulation tick, derived from [`TICK_RATE_HZ`].
const TICK_PERIOD: Duration = Duration::from_millis(1000 / TICK_RATE_HZ as u64);
/// Wall-clock period between statistics panels written to the log.
// TODO: make configurable through server configs, alongside the tick rate.
const STATUS_INTERVAL: Duration = Duration::from_mins(1);
/// Streaming system: loads and unloads chunks so that the resident set matches the union of the cylinders around every player anchor. /// Streaming system: loads and unloads chunks so that the resident set matches the union of the cylinders around every player anchor.
fn stream_chunks( fn stream_chunks(
anchors: Query<(&Position, &ViewDistance), With<Player>>, anchors: Query<(&Position, &ViewDistance), With<Player>>,
@ -43,14 +60,7 @@ fn stream_chunks(
cylinder_chunks(position.0.chunk, view.0, &mut desired); cylinder_chunks(position.0.chunk, view.0, &mut desired);
} }
let stats = world.reconcile(&desired); world.reconcile(&desired);
info!(
loaded = stats.loaded,
unloaded = stats.unloaded,
resident = stats.resident,
in_flight = stats.in_flight,
"streaming reconcile"
);
} }
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
@ -105,15 +115,20 @@ fn main() -> anyhow::Result<()> {
// Loading phase: dispatch the initial region and wait for the worker pool to finish before granting control. // Loading phase: dispatch the initial region and wait for the worker pool to finish before granting control.
info!("Streaming initial region"); info!("Streaming initial region");
// The loop below spins as fast as the worker pool is polled, so progress is reported only when the resident count actually advances. Logging every iteration would emit thousands of identical lines before the endpoint is even bound.
let mut last_reported = usize::MAX;
loop { loop {
schedule.run(&mut world); schedule.run(&mut world);
let server_world = world.resource::<ServerWorld>(); let server_world = world.resource::<ServerWorld>();
let resident = server_world.loaded_count(); let resident = server_world.loaded_count();
let in_flight = server_world.in_flight_count(); let in_flight = server_world.in_flight_count();
if resident != last_reported {
// Loading progress is simply the resident fraction of all known chunks. // Loading progress is simply the resident fraction of all known chunks.
let total = resident + in_flight; let total = resident + in_flight;
debug!(resident, in_flight, total, "loading progress"); debug!(resident, in_flight, total, "loading progress");
last_reported = resident;
}
// The region is ready once at least one chunk has been generated and none remain in flight. // The region is ready once at least one chunk has been generated and none remain in flight.
if server_world.streaming_idle() && resident > 0 { if server_world.streaming_idle() && resident > 0 {
@ -124,29 +139,84 @@ fn main() -> anyhow::Result<()> {
// Spawn the networking thread and bind the QUIC endpoint. The synchronous simulation loop below communicates with it only by draining events. // 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 bind = SocketAddr::from((Ipv4Addr::LOCALHOST, net::DEFAULT_PORT));
let (network, local_addr) = NetworkServer::spawn( let (network, local_addr) =
bind, NetworkServer::spawn(bind, env!("CARGO_PKG_VERSION").to_owned(), TICK_RATE_HZ)
env!("CARGO_PKG_VERSION").to_owned(),
// Placeholder advisory tick rate.
20,
)
.context("spawning network server")?; .context("spawning network server")?;
info!(%local_addr, "network endpoint listening"); info!(%local_addr, "network endpoint listening");
run_simulation(&mut world, &network)
}
/// Assembles the diagnostics snapshot pushed to clients at the end of a measurement window.
///
/// The world and ECS figures are read at the moment of the call rather than averaged over the window: they describe a level of occupancy, for which the current value is the meaningful reading. Only the timing figures in `window` are aggregates.
fn collect_server_stats(
world: &mut World,
window: &TickWindow,
connected_clients: usize,
started_at: Instant,
) -> ServerStats {
let (loaded_chunks, chunks_in_flight) = {
let server_world = world.resource::<ServerWorld>();
(server_world.loaded_count(), server_world.in_flight_count())
};
let players = world
.query_filtered::<(), With<Player>>()
.iter(world)
.count();
let entities = world
.query_filtered::<(), (With<Position>, Without<Player>)>()
.iter(world)
.count();
ServerStats {
measured_tps: window.measured_tps,
mean_tick_ms: window.mean_tick_ms,
max_tick_ms: window.max_tick_ms,
tick_budget_percent: window.tick_budget_percent,
loaded_chunks: u32::try_from(loaded_chunks).unwrap_or(u32::MAX),
chunks_in_flight: u32::try_from(chunks_in_flight).unwrap_or(u32::MAX),
connected_clients: u32::try_from(connected_clients).unwrap_or(u32::MAX),
entities: u32::try_from(entities).unwrap_or(u32::MAX),
players: u32::try_from(players).unwrap_or(u32::MAX),
uptime_secs: started_at.elapsed().as_secs(),
}
}
/// Emits one measurement window's diagnostics to the log.
///
/// A dedicated server is headless and has no statistics panel to read, so the same snapshot pushed to clients is also reported locally. The cadence is the measurement window rather than the tick, which keeps the line rare enough to leave the log readable while still surfacing a rate collapse within a second.
fn report_statistics(stats: &ServerStats) {
info!("\n{}", stats::format_panel(stats));
}
/// Runs the authoritative simulation loop forever, at the fixed cadence given by [`TICK_PERIOD`].
fn run_simulation(world: &mut World, network: &NetworkServer) -> ! {
// Chunk diffs are computed against an all-air baseline so each delivered payload is self-contained: the client renders only server-owned content and has no generator to reconstruct a worldgen baseline. Allocated once and shared across every delivery. // Chunk diffs are computed against an all-air baseline so each delivered payload is self-contained: the client renders only server-owned content and has no generator to reconstruct a worldgen baseline. Allocated once and shared across every delivery.
let empty_baseline = Chunk::default(); let empty_baseline = Chunk::default();
// Per-connection streaming state, keyed by the stable session id the network thread assigns. // Per-connection streaming state, keyed by the stable session id the network thread assigns.
let mut clients: HashMap<u64, ClientStream> = HashMap::new(); let mut clients: HashMap<u64, ClientStream> = HashMap::new();
// Authoritative simulation loop. let started_at = Instant::now();
let mut meter = TickMeter::new(started_at, TICK_PERIOD);
// Seeded at startup so the first panel appears one interval in, rather than immediately on a world that has not yet settled.
let mut last_status = started_at;
loop { loop {
let tick_start = Instant::now();
// Fold network events into per-client subscription state. // Fold network events into per-client subscription state.
for event in network.poll_events() { for event in network.poll_events() {
match event { match event {
ServerEvent::ClientConnected { id, hello, chunks } => { ServerEvent::ClientConnected {
id,
hello,
chunks,
authority,
} => {
info!(id, name = %hello.player_identity.display_name, "client connected"); info!(id, name = %hello.player_identity.display_name, "client connected");
clients.insert(id, ClientStream::new(chunks)); clients.insert(id, ClientStream::new(chunks, authority));
} }
ServerEvent::ClientDisconnected { id, reason } => { ServerEvent::ClientDisconnected { id, reason } => {
info!(id, %reason, "client disconnected"); info!(id, %reason, "client disconnected");
@ -155,10 +225,8 @@ fn main() -> anyhow::Result<()> {
ServerEvent::ChunkSubscribe { id, request } => { ServerEvent::ChunkSubscribe { id, request } => {
if let Some(client) = clients.get_mut(&id) { if let Some(client) = clients.get_mut(&id) {
let (added, drops) = client.resubscribe(request.center, request.radius); let (added, drops) = client.resubscribe(request.center, request.radius);
info!( // Fires on every chunk boundary the client crosses, so it sits below the connect and disconnect events rather than beside them.
id, debug!(id, added, drops, "client resubscribed");
added, drops, "client {id}: +{added} chunks, -{drops} drops"
);
} else { } else {
warn!(id, "chunk subscribe from unknown session"); warn!(id, "chunk subscribe from unknown session");
} }
@ -178,11 +246,40 @@ fn main() -> anyhow::Result<()> {
for (id, client) in &mut clients { for (id, client) in &mut clients {
let delivered = client.flush(server_world, &empty_baseline); let delivered = client.flush(server_world, &empty_baseline);
if delivered > 0 { if delivered > 0 {
debug!(id, delivered, "delivered resident chunks"); // Per-tick and per-client, so it sits below the default filter: the aggregate chunk figures in the status line cover routine operation, and this level is for tracing an individual client's deliveries.
trace!(id, delivered, "delivered resident chunks");
} }
} }
// Advisory ~20 Hz cadence until the real tick scheduler lands. // Sleep only the unused remainder of the tick's budget, so the period stays [`TICK_PERIOD`] rather than growing with the cost of the work above. A tick that overruns its budget does not sleep at all; the overrun is reported because it is the signal that the server is falling behind its nominal rate.
std::thread::sleep(Duration::from_millis(50)); let elapsed = tick_start.elapsed();
meter.record(elapsed);
// Diagnostics are pushed on the authority stream once per measurement window, not per tick: the figures describe the window, and per-tick delivery would be pure waste.
let window_end = tick_start + elapsed;
if let Some(window) = meter.take_window(window_end) {
let stats = collect_server_stats(world, &window, clients.len(), started_at);
// Every window reaches the clients, which display it live; the log takes one panel per [`STATUS_INTERVAL`].
if window_end.duration_since(last_status) >= STATUS_INTERVAL {
report_statistics(&stats);
last_status = window_end;
}
for client in clients.values() {
client.send_stats(stats);
}
}
if elapsed > TICK_PERIOD {
warn!(
elapsed_ms = elapsed.as_secs_f32() * 1000.0,
budget_ms = TICK_PERIOD.as_secs_f32() * 1000.0,
"tick overran its budget"
);
} else {
// `saturating_sub` cannot underflow here (the branch already establishes `elapsed <= TICK_PERIOD`) and is used because `Duration` subtraction panics on overflow.
std::thread::sleep(TICK_PERIOD.saturating_sub(elapsed));
}
} }
} }

View file

@ -0,0 +1,40 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Formatting of the periodic simulation statistics report.
use std::fmt::Write as _;
use shared::protocol::authority::ServerStats;
/// Renders one measurement window's diagnostics as a multi-line panel.
#[must_use]
pub fn format_panel(stats: &ServerStats) -> String {
let mut out = String::with_capacity(256);
// `write!` into a String cannot fail, so the results are discarded rather than propagated.
let _ = writeln!(out, "── server statistics ──");
let _ = writeln!(
out,
"tick {:.1} tps mean {:.2} ms max {:.2} ms budget {:.0}% uptime {} s",
stats.measured_tps,
stats.mean_tick_ms,
stats.max_tick_ms,
stats.tick_budget_percent,
stats.uptime_secs
);
let _ = write!(
out,
"world chunks {} resident / {} in flight clients {} entities {} players {}",
stats.loaded_chunks,
stats.chunks_in_flight,
stats.connected_clients,
stats.entities,
stats.players
);
out
}
#[cfg(test)]
#[path = "tests/stats.rs"]
mod tests;

View file

@ -0,0 +1,48 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Unit tests for the statistics panel formatter in [`crate::stats`].
use super::*;
/// Builds a snapshot with distinguishable values in every field.
fn sample() -> ServerStats {
ServerStats {
measured_tps: 19.96,
mean_tick_ms: 1.234,
max_tick_ms: 4.567,
tick_budget_percent: 24.7,
loaded_chunks: 421,
chunks_in_flight: 3,
connected_clients: 2,
entities: 5,
players: 1,
uptime_secs: 42,
}
}
#[test]
fn every_field_of_the_snapshot_reaches_the_panel() {
let panel = format_panel(&sample());
assert!(panel.contains("20.0 tps"), "{panel}");
assert!(panel.contains("mean 1.23 ms"), "{panel}");
assert!(panel.contains("max 4.57 ms"), "{panel}");
assert!(panel.contains("budget 25%"), "{panel}");
assert!(panel.contains("uptime 42 s"), "{panel}");
assert!(
panel.contains("chunks 421 resident / 3 in flight"),
"{panel}"
);
assert!(panel.contains("clients 2"), "{panel}");
assert!(panel.contains("entities 5"), "{panel}");
assert!(panel.contains("players 1"), "{panel}");
}
#[test]
fn the_panel_is_a_header_and_two_rows_without_a_trailing_newline() {
let panel = format_panel(&sample());
assert_eq!(panel.lines().count(), 3);
// The caller supplies the leading newline, so a trailing one would open a blank line in the log.
assert!(!panel.ends_with('\n'), "{panel}");
}

View file

@ -0,0 +1,112 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Unit tests for the simulation loop's timing measurement.
use super::*;
/// The nominal 20 Hz period the server budgets each tick.
const PERIOD: Duration = Duration::from_millis(50);
/// Asserts two f32 values agree to within a tolerance that survives the accumulated division and multiplication.
fn close(actual: f32, expected: f32) {
assert!(
(actual - expected).abs() < 0.01,
"expected {expected}, got {actual}"
);
}
#[test]
fn empty_window_reports_zeroes_rather_than_dividing_by_zero() {
let window = summarise(0, Duration::ZERO, Duration::ZERO, REPORT_INTERVAL, PERIOD);
close(window.measured_tps, 0.0);
close(window.mean_tick_ms, 0.0);
close(window.tick_budget_percent, 0.0);
}
#[test]
fn a_window_at_the_nominal_rate_reports_the_nominal_rate() {
// Twenty ticks of 10 ms each, filling exactly one second of wall clock.
let window = summarise(
20,
Duration::from_millis(200),
Duration::from_millis(10),
Duration::from_secs(1),
PERIOD,
);
close(window.measured_tps, 20.0);
close(window.mean_tick_ms, 10.0);
close(window.max_tick_ms, 10.0);
// 10 ms of a 50 ms budget is one fifth of the period.
close(window.tick_budget_percent, 20.0);
}
#[test]
fn an_overrunning_server_reports_a_rate_below_nominal() {
// Ten ticks of 100 ms each: the body alone exceeds the 50 ms budget, so only half the nominal count fits in the second.
let window = summarise(
10,
Duration::from_secs(1),
Duration::from_millis(140),
Duration::from_secs(1),
PERIOD,
);
close(window.measured_tps, 10.0);
close(window.mean_tick_ms, 100.0);
close(window.tick_budget_percent, 200.0);
}
#[test]
fn the_maximum_is_reported_separately_from_the_mean() {
// Nine cheap ticks and one stall: the mean stays inside budget while the maximum does not.
let window = summarise(
10,
Duration::from_millis(100),
Duration::from_millis(91),
Duration::from_secs(1),
PERIOD,
);
close(window.mean_tick_ms, 10.0);
close(window.max_tick_ms, 91.0);
assert!(window.tick_budget_percent < 100.0);
}
#[test]
fn a_window_closes_only_once_the_interval_has_elapsed() {
let start = Instant::now();
let mut meter = TickMeter::new(start, PERIOD);
meter.record(Duration::from_millis(10));
assert!(
meter
.take_window(start + Duration::from_millis(999))
.is_none()
);
assert!(meter.take_window(start + REPORT_INTERVAL).is_some());
}
#[test]
fn closing_a_window_resets_the_accumulators() {
let start = Instant::now();
let mut meter = TickMeter::new(start, PERIOD);
meter.record(Duration::from_millis(40));
let _ = meter.take_window(start + REPORT_INTERVAL);
meter.record(Duration::from_millis(10));
let second = meter
.take_window(start + REPORT_INTERVAL + REPORT_INTERVAL)
.unwrap_or(TickWindow {
measured_tps: 0.0,
mean_tick_ms: 0.0,
max_tick_ms: 0.0,
tick_budget_percent: 0.0,
});
// The 40 ms tick belonged to the first window and must not leak into the second's maximum.
close(second.mean_tick_ms, 10.0);
close(second.max_tick_ms, 10.0);
close(second.measured_tps, 1.0);
}

View file

@ -0,0 +1,115 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Measurement of the simulation loop's own timing.
use std::time::{Duration, Instant};
/// Wall-clock cadence at which a measurement window closes and a report is produced.
///
/// One second is short enough to surface a stall promptly and long enough that the report costs nothing next to the ticks it summarises.
pub const REPORT_INTERVAL: Duration = Duration::from_secs(1);
/// The summary produced when a measurement window closes.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TickWindow {
/// Ticks completed in the window, expressed per second.
pub measured_tps: f32,
/// Mean duration of a tick body in the window, in milliseconds.
pub mean_tick_ms: f32,
/// Longest tick body in the window, in milliseconds.
pub max_tick_ms: f32,
/// Share of the nominal tick period consumed by the mean tick body, in percent.
pub tick_budget_percent: f32,
}
/// Accumulates tick timings and closes a measurement window on a fixed cadence.
#[derive(Debug)]
pub struct TickMeter {
/// Nominal period one tick is budgeted, against which utilisation is computed.
period: Duration,
/// Instant the current window opened; the window closes once [`REPORT_INTERVAL`] has elapsed from here.
window_start: Instant,
/// Tick bodies recorded in the current window.
ticks: u32,
/// Summed duration of every tick body in the current window.
total: Duration,
/// Longest single tick body in the current window.
max: Duration,
}
impl TickMeter {
/// Opens the first measurement window at `now`, budgeting each tick `period`.
#[must_use]
pub fn new(now: Instant, period: Duration) -> Self {
Self {
period,
window_start: now,
ticks: 0,
total: Duration::ZERO,
max: Duration::ZERO,
}
}
/// Records one completed tick body of duration `elapsed`.
pub fn record(&mut self, elapsed: Duration) {
self.ticks = self.ticks.saturating_add(1);
self.total = self.total.saturating_add(elapsed);
self.max = self.max.max(elapsed);
}
/// Closes the window and returns its summary once [`REPORT_INTERVAL`] has elapsed since it opened, otherwise returns [`None`].
///
/// On close the accumulators reset and a fresh window opens at `now`, so windows tile the timeline without gaps or overlap.
pub fn take_window(&mut self, now: Instant) -> Option<TickWindow> {
let elapsed = now.saturating_duration_since(self.window_start);
if elapsed < REPORT_INTERVAL {
return None;
}
let window = summarise(self.ticks, self.total, self.max, elapsed, self.period);
self.window_start = now;
self.ticks = 0;
self.total = Duration::ZERO;
self.max = Duration::ZERO;
Some(window)
}
}
/// Derives a window summary from its raw accumulators.
///
/// Split out from [`TickMeter::take_window`] so the arithmetic is exercisable without driving a clock. A window containing no ticks reports zeroes throughout rather than dividing by zero, which is the correct reading of "nothing completed".
fn summarise(
ticks: u32,
total: Duration,
max: Duration,
elapsed: Duration,
period: Duration,
) -> TickWindow {
if ticks == 0 || elapsed.is_zero() {
return TickWindow {
measured_tps: 0.0,
mean_tick_ms: 0.0,
max_tick_ms: max.as_secs_f32() * 1000.0,
tick_budget_percent: 0.0,
};
}
let mean = total.as_secs_f32() / f32::from(u16::try_from(ticks).unwrap_or(u16::MAX));
let period_secs = period.as_secs_f32();
TickWindow {
measured_tps: f32::from(u16::try_from(ticks).unwrap_or(u16::MAX)) / elapsed.as_secs_f32(),
mean_tick_ms: mean * 1000.0,
max_tick_ms: max.as_secs_f32() * 1000.0,
// A zero period would mean no budget exists to consume, so utilisation is undefined and reported as zero.
tick_budget_percent: if period_secs > 0.0 {
mean / period_secs * 100.0
} else {
0.0
},
}
}
#[cfg(test)]
#[path = "tests/tick_stats.rs"]
mod tests;

View file

@ -7,4 +7,5 @@
pub mod generator; pub mod generator;
pub mod protocol; pub mod protocol;
pub mod save; pub mod save;
pub mod session;
pub mod world; pub mod world;

View file

@ -2,8 +2,9 @@
//! Network protocol types and constants. //! Network protocol types and constants.
//! //!
//! The module is split by stream purpose: [`control`]-stream handshake and disconnect messages, and the [`chunk`]-sync request/delivery messages. Control-stream types are re-exported here so callers continue to refer to `shared::protocol::<Type>` regardless of the internal layout, while the chunk types stay namespaced under `shared::protocol::chunk` to keep the two protocols visually distinct. //! The module is split by stream purpose: [`control`]-stream handshake and disconnect messages, and the [`chunk`]-sync request/delivery messages, and the periodic [`authority`]-stream state. Control-stream types are re-exported here so callers continue to refer to `shared::protocol::<Type>` regardless of the internal layout, while the chunk types stay namespaced under `shared::protocol::chunk` to keep the two protocols visually distinct.
pub mod authority;
pub mod chunk; pub mod chunk;
mod control; mod control;

View file

@ -0,0 +1,45 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Authority-stream messages: periodic state pushed from server to client.
//!
//! The authority stream ([`StreamLayout::authority`](super::StreamLayout::authority), id 2) carries state the server is the sole authority over and pushes without being asked. Diagnostics are the first such payload; simulation snapshots will join them on the same stream.
use serde::{Deserialize, Serialize};
/// Messages carried on the authority stream (stream 2): periodic server-authoritative state.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum AuthorityMessage {
/// Periodic simulation diagnostics, emitted on a fixed wall-clock cadence rather than per tick.
ServerStats(ServerStats),
}
/// A snapshot of the server's simulation health, sent roughly once per second.
///
/// The measured figures exist because the nominal tick rate advertised in [`HandshakeAck::tick_rate_hint`](super::HandshakeAck::tick_rate_hint) is a constant: it states what the server intends to run at and can never reveal that it is falling behind. Everything here is observed.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
pub struct ServerStats {
/// Ticks actually completed in the reporting window, expressed per second. Below the nominal rate when the server is overrunning its budget.
pub measured_tps: f32,
/// Mean wall-clock duration of a tick body in the reporting window, in milliseconds, excluding the sleep that pads the tick out to its period.
pub mean_tick_ms: f32,
/// Longest tick body observed in the reporting window, in milliseconds. A mean within budget alongside a spiking maximum indicates intermittent stalls rather than sustained overload.
pub max_tick_ms: f32,
/// Share of the nominal tick period consumed by the mean tick body, in percent. Values at or above 100 mean the server no longer has headroom.
pub tick_budget_percent: f32,
/// Chunks resident in the server's world cache.
pub loaded_chunks: u32,
/// Chunk generation jobs outstanding in the server's worker pool.
pub chunks_in_flight: u32,
/// Clients with an established session.
pub connected_clients: u32,
/// Non-player entities occupying the world.
pub entities: u32,
/// Players currently in the world.
pub players: u32,
/// Wall-clock time since the simulation loop started, in seconds.
pub uptime_secs: u64,
}
#[cfg(test)]
#[path = "../tests/protocol_authority.rs"]
mod tests;

View file

@ -5,7 +5,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Wire-protocol version. Incremented on any breaking change to the message layout below. /// Wire-protocol version. Incremented on any breaking change to the message layout below.
pub const PROTOCOL_VERSION: u32 = 1; pub const PROTOCOL_VERSION: u32 = 2;
/// Messages carried on the control stream (stream 0): handshake and disconnect. /// Messages carried on the control stream (stream 0): handshake and disconnect.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]

View file

@ -0,0 +1,43 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Session-level concepts describing the relationship between a client and the server it is playing against.
/// Which kind of server a client session is running against.
///
/// Deliberately not part of the wire protocol. The client already knows the answer without asking: it either spawned a server in-process or dialled a socket. A server-declared field would be redundant at best and spoofable at worst, so the value is constructed client-side from facts the client already holds.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServerKind {
/// The server runs in this process, backing single-player.
Integrated,
/// The server is a separate process reached over the network.
Dedicated {
/// Whether the server's address is off this machine. Decided from the `SocketAddr` the client dialled, not from anything the server says.
remote: bool,
},
}
impl ServerKind {
/// Classifies a dedicated server from the address the client dialled.
///
/// A loopback address means the process is on this machine (a locally hosted server), which is distinct from an integrated one: it is still a separate process reached over a socket.
#[must_use]
pub const fn dedicated(addr: std::net::SocketAddr) -> Self {
Self::Dedicated {
remote: !addr.ip().is_loopback(),
}
}
/// Returns a short human-readable label for the session's server kind.
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Integrated => "integrated",
Self::Dedicated { remote: false } => "dedicated (local)",
Self::Dedicated { remote: true } => "dedicated (remote)",
}
}
}
#[cfg(test)]
#[path = "tests/session.rs"]
mod tests;

View file

@ -0,0 +1,37 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Wire round-trip tests for the authority-stream messages.
use super::*;
#[test]
fn roundtrip_server_stats() -> Result<(), postcard::Error> {
let msg = AuthorityMessage::ServerStats(ServerStats {
measured_tps: 19.4,
mean_tick_ms: 12.5,
max_tick_ms: 48.0,
tick_budget_percent: 25.0,
loaded_chunks: 4096,
chunks_in_flight: 12,
connected_clients: 1,
entities: 37,
players: 1,
uptime_secs: 3600,
});
let bytes = postcard::to_stdvec(&msg)?;
let decoded: AuthorityMessage = postcard::from_bytes(&bytes)?;
assert_eq!(decoded, msg);
Ok(())
}
#[test]
fn default_stats_survive_a_round_trip() {
// The default is what a server reports before its first window has closed; it must decode as cleanly as a populated one.
let msg = AuthorityMessage::ServerStats(ServerStats::default());
let bytes = postcard::to_stdvec(&msg).unwrap_or_default();
assert_eq!(
postcard::from_bytes::<AuthorityMessage>(&bytes).ok(),
Some(msg)
);
}

View file

@ -0,0 +1,43 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Unit tests for server-kind classification.
use super::*;
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
#[test]
fn loopback_addresses_classify_as_local() {
let v4 = SocketAddr::from((Ipv4Addr::LOCALHOST, 25565));
let v6 = SocketAddr::from((Ipv6Addr::LOCALHOST, 25565));
assert_eq!(
ServerKind::dedicated(v4),
ServerKind::Dedicated { remote: false }
);
assert_eq!(
ServerKind::dedicated(v6),
ServerKind::Dedicated { remote: false }
);
}
#[test]
fn non_loopback_addresses_classify_as_remote() {
let addr = SocketAddr::from((Ipv4Addr::new(10, 0, 0, 4), 25565));
assert_eq!(
ServerKind::dedicated(addr),
ServerKind::Dedicated { remote: true }
);
}
#[test]
fn each_kind_has_a_distinct_label() {
assert_eq!(ServerKind::Integrated.label(), "integrated");
assert_eq!(
ServerKind::Dedicated { remote: false }.label(),
"dedicated (local)"
);
assert_eq!(
ServerKind::Dedicated { remote: true }.label(),
"dedicated (remote)"
);
}

View file

@ -39,6 +39,8 @@ Each subsystem note should name the design topic it implements (by title, e.g. "
- [`adr/0007-declarative-content-via-modding-api.md`](adr/0007-declarative-content-via-modding-api.md): declarative content loads through the modding API. - [`adr/0007-declarative-content-via-modding-api.md`](adr/0007-declarative-content-via-modding-api.md): declarative content loads through the modding API.
- [`adr/0008-split-coordinate-entity-positions.md`](adr/0008-split-coordinate-entity-positions.md): split-coordinate entity positions. - [`adr/0008-split-coordinate-entity-positions.md`](adr/0008-split-coordinate-entity-positions.md): split-coordinate entity positions.
- [`adr/0009-baseline-relative-sparse-chunk-persistence.md`](adr/0009-baseline-relative-sparse-chunk-persistence.md): baseline-relative sparse chunk persistence. - [`adr/0009-baseline-relative-sparse-chunk-persistence.md`](adr/0009-baseline-relative-sparse-chunk-persistence.md): baseline-relative sparse chunk persistence.
- [`adr/0010-net-crate-async-runtime.md`](adr/0010-net-crate-async-runtime.md): dedicated `net` crate with a confined async runtime.
- [`adr/0011-authority-stream-for-server-pushed-state.md`](adr/0011-authority-stream-for-server-pushed-state.md): a dedicated authority stream for server-pushed state.
- [`adr/template.md`](adr/template.md): template for new decisions. - [`adr/template.md`](adr/template.md): template for new decisions.
Subsystem notes: Subsystem notes:
@ -46,6 +48,8 @@ Subsystem notes:
- [`packs.md`](packs.md): data packs & resource packs (load order, layout, resolution). - [`packs.md`](packs.md): data packs & resource packs (load order, layout, resolution).
- [`rendering.md`](rendering.md): rendering & coordinate gotchas (Vulkan clip space, Blender/glTF import). - [`rendering.md`](rendering.md): rendering & coordinate gotchas (Vulkan clip space, Blender/glTF import).
- [`chunk_streaming.md`](chunk_streaming.md): chunk streaming and async worker pipeline. - [`chunk_streaming.md`](chunk_streaming.md): chunk streaming and async worker pipeline.
- [`meshing.md`](meshing.md): greedy meshing, the mesh worker pool, and frustum culling.
- [`diagnostics.md`](diagnostics.md): runtime statistics collection and the debug panel.
- [`save_format.md`](save_format.md): chunk persistence, region-file layout, save actor, and load pipeline. - [`save_format.md`](save_format.md): chunk persistence, region-file layout, save actor, and load pipeline.
Further subsystem notes are added here as systems are implemented and locked. Further subsystem notes are added here as systems are implemented and locked.

View file

@ -0,0 +1,34 @@
# 0011. A dedicated authority stream for server-pushed state
- **Status:** Accepted
- **Date:** 2026-07-31
## Context
The simulation is server-authoritative ([ADR-0004](0004-server-authoritative-simulation.md)), so a category of traffic exists that the client never asks for: state the server pushes on its own cadence. Simulation snapshots are the eventual bulk of it; periodic server diagnostics were the first concrete instance.
Two existing streams could have absorbed that traffic, and both are a poor fit:
- The **control stream** (stream 0) carries the handshake and disconnect. It is request/response and effectively one-shot per connection. Adding a recurring push to it mixes lifecycle negotiation with steady-state traffic, and a burst of pushed state would sit in the same ordered stream as a disconnect notice that should arrive promptly.
- The **chunk stream** (stream 3) is bidirectional and carries large frames (a 1 MiB cap). Head-of-line blocking is per-stream in QUIC, so a small, time-sensitive state push queued behind a multi-hundred-kilobyte chunk delivery would inherit that chunk's latency. That is precisely the coupling separate streams exist to avoid.
Stream assignment is a wire contract shared by both peers: `StreamLayout` fixes the ids, and changing one is a protocol break. The decision is therefore made once, ahead of the snapshot work that will depend on it, rather than discovered later.
## Decision
Server-pushed authoritative state travels on its own unidirectional-in-practice stream, `StreamLayout::authority` (stream 2), carrying `shared::protocol::authority::AuthorityMessage`.
- The stream is **server => client only**. Nothing the client sends belongs on it; client input gets its own stream when it lands.
- `AuthorityMessage` is an enum, so new pushed payloads are added as variants rather than as new streams. `ServerStats` is the first variant; simulation snapshots will join it.
- Frames use the existing length-prefixed `postcard` codec with `MAX_AUTHORITY_FRAME_LEN` (64 KiB), well above a fixed-shape diagnostics record, and set to bound what a malformed length prefix can make a peer allocate.
- The async/sync bridge follows the pattern established for chunk delivery ([ADR-0010](0010-net-crate-async-runtime.md)): the simulation loop holds an `AuthoritySink`, a synchronous non-blocking handle wrapping a `tokio` MPSC sender, so neither `server` nor `client` names a `tokio` type.
- A send on a departed connection is logged at debug and dropped. The simulation loop cannot act on a disconnected client, and pushed state is by definition unsolicited, so failure to deliver it is not an error condition for the sender.
## Consequences
- Latency of pushed state is independent of chunk delivery volume. A client pulling its initial region at full rate still receives snapshots on time.
- Adding a pushed payload is one enum variant, with no new stream to negotiate on either peer and no `StreamLayout` change.
- The stream layout now commits four ids (control 0, reserved 1, authority 2, chunk LOD0 3). Reassigning any of them is a `PROTOCOL_VERSION` bump.
- Loss and ordering semantics are per-stream: authority messages are ordered relative to each other and unordered relative to chunk deliveries. Anything requiring a snapshot to be interpreted against a specific delivered chunk must carry its own correlation (a tick number or chunk version), rather than relying on arrival order across streams.
- The sink is fire-and-forget and unbounded. That is appropriate for a low-rate diagnostics push, but snapshots at tick rate will need a bound and a drop policy: a slow client must not be allowed to grow the server's queue without limit. This is the known follow-up before snapshots ship.
- Diagnostics being *on* the authority stream rather than beside it means they are subject to the same server-authoritative framing: the client reports what the server measured, never what it inferred. See [`docs/diagnostics.md`](../diagnostics.md).

View file

@ -64,13 +64,15 @@ The server-side pump is `chunk_stream_task`; its client mirror is `client_chunk_
Each connected client is tracked by a `ClientStream` holding its `ChunkSink`, its current desired set (radius-clamped to `SERVER_MAX_RADIUS`), and its `sent` set. On each subscription, `desired_diff(previous, new)` yields the load list (`new previous`) and drop list (`previous new`); a `ChunkMessage::Drop` is emitted for every already-**sent** chunk that left the set. Newly-desired chunks are **not** sent immediately — chunk loads are async, so `ClientStream::flush` runs each tick and delivers every desired-but-unsent chunk that has since become resident, retrying on later ticks until the pool returns it. Each connected client is tracked by a `ClientStream` holding its `ChunkSink`, its current desired set (radius-clamped to `SERVER_MAX_RADIUS`), and its `sent` set. On each subscription, `desired_diff(previous, new)` yields the load list (`new previous`) and drop list (`previous new`); a `ChunkMessage::Drop` is emitted for every already-**sent** chunk that left the set. Newly-desired chunks are **not** sent immediately — chunk loads are async, so `ClientStream::flush` runs each tick and delivers every desired-but-unsent chunk that has since become resident, retrying on later ticks until the pool returns it.
Delivery is bounded by `MAX_DELIVERIES_PER_TICK` (32 chunks per client per tick). Encoding a chunk is the expensive part of `flush`, and a client whose subscription has just jumped can have hundreds of chunks pending at once; without a cap that backlog is encoded in a single tick and shows up directly as a tick overrun. The budget counts chunks **actually encoded**, so a tick where most of the desired set is still in flight is not charged for work it did not do. The fixed count is a placeholder for a time budget, which becomes necessary once per-chunk cost varies with LOD.
### Self-contained payloads (all-air diff) ### Self-contained payloads (all-air diff)
`ChunkMessage::Chunk` carries a `ChunkData` (the sparse, baseline-relative form; see [ADR-0009](adr/0009-baseline-relative-sparse-chunk-persistence.md)). Because the client runs **no** worldgen (the server owns world content; worldgen never runs client-side), it cannot reconstruct a worldgen baseline to diff against. So delivered chunks are diffed against an **all-air baseline** (`Chunk::default()`): the edits become the chunk's full non-air content, and the client materializes each payload against its own all-air `Chunk::default()`. This makes every delivery self-contained, at the cost of not exploiting the deterministic baseline for compression — a compression concern deferred to the LOD/compression pass. `ChunkMessage::Chunk` carries a `ChunkData` (the sparse, baseline-relative form; see [ADR-0009](adr/0009-baseline-relative-sparse-chunk-persistence.md)). Because the client runs **no** worldgen (the server owns world content; worldgen never runs client-side), it cannot reconstruct a worldgen baseline to diff against. So delivered chunks are diffed against an **all-air baseline** (`Chunk::default()`): the edits become the chunk's full non-air content, and the client materializes each payload against its own all-air `Chunk::default()`. This makes every delivery self-contained, at the cost of not exploiting the deterministic baseline for compression — a compression concern deferred to the LOD/compression pass.
### Client application ### Client application
The client subscribes with its own `LOAD_RADIUS` (so the server's per-client resident set matches what the client keeps) whenever its center chunk changes. It drains deliveries under a per-frame meshing budget: `ChunkMessage::Chunk` → materialize → `generate_mesh``insert_mesh` (skipping empty meshes); `ChunkMessage::Drop``remove_mesh`. It **also** evicts chunks outside `LOAD_RADIUS` locally, independent of the server `Drop`, so memory stays bounded even if the server is slow. The client subscribes with its own `LOAD_RADIUS` (so the server's per-client resident set matches what the client keeps) whenever its center chunk changes. Deliveries are drained under per-frame budgets: a `ChunkMessage::Chunk` is materialized and the position (plus its six neighbours) is queued for meshing, while a `ChunkMessage::Drop` removes the mesh. Meshing itself runs on a worker pool rather than inline, so the client retains chunk voxels after upload; that pipeline is described in [`meshing.md`](meshing.md). The client **also** evicts chunks outside `LOAD_RADIUS` locally, independent of the server `Drop`, so memory stays bounded even if the server is slow.
## Multiplayer ## Multiplayer

83
docs/diagnostics.md Normal file
View file

@ -0,0 +1,83 @@
# Runtime diagnostics
How the engine reports on itself: what each crate measures, how those measurements are aggregated into the client's statistics panel, and how the server's own figures reach the client. The panel lives in [`crates/client/src/stats.rs`](../crates/client/src/stats.rs); the sources are spread across `renderer`, `net`, `server`, and `shared`.
## Why this exists
Every figure here is **measured, not declared**. The nominal tick rate advertised in the handshake is a constant: it states what the server intends to run at and can never reveal that it is falling behind. The same holds throughout, since a configured frame cap says nothing about achieved frame time and a load radius says nothing about how many chunks are actually resident. Diagnostics exist to close that gap, so the answer to "is this slow, and where" comes from observation rather than from configuration.
The immediate motivation is that the client is now doing enough work per frame (materialising deliveries, dispatching mesh jobs, ingesting meshes, culling, submitting) that a frame-time regression has several plausible causes and no way to distinguish them by inspection.
## The measurement layers
Each crate measures what only it can see, and exposes a plain snapshot type. No crate formats, and no crate reaches into another's internals.
| Source | Type | What it observes |
|--------|------|------------------|
| `renderer` | `RenderStats` | Uploaded / visible / culled meshes, draw calls, triangles, vertices, geometry bytes, active render mode, projection, swapchain, frames presented and skipped |
| `renderer` | `GpuInfo` | Device name and class, vendor and device ids, driver and API versions, total device-local memory. Queried once, since every field is immutable for the renderer's lifetime |
| `renderer` | `MemoryUsage` | Two independent views of GPU memory: the driver's heap accounting and the renderer's own allocator |
| `net` | `NetStats` | Application counters (chunks received, drops received, subscribes sent) plus QUIC path state (RTT, lost packets, congestion window, path MTU, bytes and datagrams) |
| `client` | `ChunkStats` | Resident chunks, uploaded meshes, in-flight mesh jobs, pending re-meshes, desired-set size |
| `client` | `FrameStats` | Frame count, mean / min / max frame time, achieved FPS over the window |
| `client` | `HostInfo` / `HostUsage` | CPU brand and core count, OS and kernel, then process CPU and memory against system totals |
| `server` | `ServerStats` | Measured TPS, mean and max tick body, tick-budget utilisation, resident and in-flight chunks, connected clients, entities, players, uptime |
### Separating the immutable from the live
`GpuInfo` and `HostInfo` are queried once; `MemoryUsage` and `HostUsage` are read per window. The split is deliberate: device name and driver version cannot change while the renderer lives, and re-querying them each window would pay for a string allocation to learn nothing. Live figures are read on demand precisely because they are not cacheable.
`MemoryUsage` reports the driver's heap figures as `Option`, because they require `VK_EXT_memory_budget`. Where the extension is unavailable the allocator's own figures still report, since this process's suballocations are always knowable even when the driver's total is not. The panel must therefore render a missing driver figure as missing rather than substituting zero, which would read as "no memory in use".
`decode_driver_version` exists because `VkPhysicalDeviceProperties::driverVersion` is documented as vendor-specific and two vendors deviate from the standard packing: NVIDIA uses a 10/8/8/6-bit layout, and Intel's *Windows* driver uses a 14/18-bit split while its Mesa driver follows the Vulkan convention. The decode is unit-tested per vendor, since a mis-decoded driver version is the kind of wrong-but-plausible output nobody notices.
## Windowed measurement
Everything is reported over a **window**, not instantaneously. Both `client::stats::STATS_INTERVAL` and `server::tick_stats::REPORT_INTERVAL` are one second: short enough to surface a stall promptly, long enough that producing a report costs nothing next to the work it summarises.
A window carries a mean *and* a maximum for exactly one reason: they answer different questions. A mean comfortably inside budget alongside a spiking maximum indicates intermittent stalls, a hitch, whereas a mean at budget indicates sustained overload. Reporting only the mean hides the first case, which is the one users actually feel.
The server additionally reports `tick_budget_percent`, the share of the nominal tick period consumed by the mean tick body. It is derived rather than measured, but it is the figure that says whether headroom exists; values at or above 100 mean the loop no longer has any. The tick body is timed *excluding* the sleep that pads a tick out to its period, so the number reflects work rather than pacing.
`TickMeter` computes this with no division-by-zero hazard: a zero period means no budget exists to consume, so utilisation is undefined and reported as zero rather than as infinity.
### Collection is unconditional; emission is gated
The panel is toggled with the **F1 + I** chord (see `crates/client/src/debug.rs`), but the toggle gates *emission only*. Accumulation runs whether or not the panel is on, and the window closes on schedule either way.
This matters more than it sounds. Gating collection on the toggle would make the first window after enabling the panel partial, reporting a fraction of a second of frames as though it were a full window, and the first thing anyone does when something feels wrong is turn the panel on. The figures must already be correct at that moment.
The panel is emitted through `tracing` at `info` as a multi-line block, consistent with the project-wide prohibition on `println!` for diagnostics. The server formats its own figures the same way, so a dedicated server's log and a client's panel present the same numbers identically.
## Getting the server's figures to the client
`ServerStats` is a `shared` protocol type pushed on the authority stream (stream 2) once per window; the stream's design is [ADR-0011](adr/0011-authority-stream-for-server-pushed-state.md). The client drains it non-blockingly each frame and retains the most recent snapshot, so the panel always has a value even though server and client windows are not aligned.
The retention is intentional: aligning the two cadences would require synchronisation for a display figure. A snapshot up to a second old is the correct trade, and the server's own `uptime_secs` makes staleness visible if it ever matters.
The server also formats and logs the same `ServerStats` locally, so a dedicated host is diagnosable without a client attached.
### What is *not* on the wire
`ServerKind` (integrated, dedicated local, or dedicated remote) is deliberately **not** a protocol field. The client already knows the answer without asking: it either spawned a server in-process or dialled a socket, and a loopback address distinguishes a locally hosted process from a remote one. A server-declared field would be redundant at best and spoofable at worst, so the value is constructed client-side from facts the client already holds.
The general rule this instances: a diagnostic should be sourced from whichever side *observes* it. The server reports its own tick health because only it can measure that; the client classifies the session because only it knows how the session was established.
## Concurrency
Two boundaries are crossed, with a different primitive for each.
**Client net counters** (`NetCounters`) are incremented on the async chunk task and read on the winit thread, held behind an `Arc` and mutated with **relaxed atomics**. Relaxed is correct here rather than merely cheap: each counter is independent, nothing else is ordered against them, and a reader observing a slightly stale value is reporting a diagnostic figure, not making a decision. Paying for stronger ordering would buy precision nobody consumes.
**Renderer frame stats** are populated at the end of every successful `draw_frame` and **retained whole** until the next frame replaces them. A reader on the panel's one-second cadence therefore observes a complete, self-consistent frame rather than a half-updated struct, a snapshot at a point rather than field-by-field sampling. That property is what makes it safe for the panel to run on a cadence unrelated to the render loop.
## Testing
Formatting and derivation are pure and are tested; live capture is not.
- `crates/server/src/tests/tick_stats.rs`: window closing, mean and max derivation, budget utilisation including the zero-period case.
- `crates/renderer/src/tests/stats.rs`: `decode_driver_version` per vendor, and `cull_ratio_percent` including the nothing-uploaded case.
- `crates/client/src/tests/stats.rs`: frame accumulation and panel formatting, including absent optional sources.
- `crates/shared/src/tests/session.rs`: `ServerKind` classification from loopback and non-loopback addresses.
Vulkan device queries, `sysinfo` host readings, and live QUIC path statistics depend on real hardware and a live connection, and are verified by running the client.

104
docs/meshing.md Normal file
View file

@ -0,0 +1,104 @@
# Chunk meshing & visibility
How a dense voxel chunk becomes drawn triangles: the greedy mesher, the neighbour-awareness it requires, the background worker pool that keeps it off the winit thread, and the frustum cull that decides what is submitted. The mesher and cull live in [`crates/renderer/src/meshing.rs`](../crates/renderer/src/meshing.rs) and [`crates/renderer/src/frustum.rs`](../crates/renderer/src/frustum.rs); the orchestration in [`crates/client/src/mesh_pool.rs`](../crates/client/src/mesh_pool.rs) and [`crates/client/src/chunks.rs`](../crates/client/src/chunks.rs).
Where chunks *come from* is [`chunk_streaming.md`](chunk_streaming.md); this note picks up once a chunk is resident on the client.
## Crate ownership
The mesher lives in `renderer`, not in `client` and not in `shared`.
It sits next to `renderer::vertex::Vertex` (the module was renamed from `mesh` to free the name) because the mesher's output format *is* the renderer's vertex format. Keeping them in one crate means that format never has to become a cross-crate contract, and it keeps geometry generation out of the crate that `AGENTS.md` wants confined to input, windowing, and presentation glue. The client previously held its own copy; that copy is gone.
Nothing about this makes `renderer` a dependency for drawing alone: `generate_mesh` is a pure CPU function, with no device handles and no GPU state, and it is unit-testable as such.
## The greedy mesher
`generate_mesh(chunk, neighbors)` returns `(Vec<Vertex>, Vec<u32>)` and is a pure function: no GPU handles, no device state, no I/O. Naively, a solid voxel emits six quads and a chunk emits up to `6 x 32³` of them, almost all interior and immediately hidden. The mesher instead emits the *visible surface*, merged.
Each of the three axes is swept slice by slice. For one slice, a 2D mask of exposed faces is built over the two perpendicular axes, and the mask is then merged into rectangles: a run is extended along the first axis while the key matches, then the run is extended along the second axis while every cell of the candidate row matches. The result is the largest axis-aligned quad available for that key, and a flat plane of one material collapses from thousands of quads to one.
The half-scale voxel grid ([ADR-0002](adr/0002-half-scale-voxel-grid.md)) makes this load-bearing rather than an optimisation: flat terrain costs roughly 8x the faces of a metre-grid world, and merging collapses exactly the runs that scaling creates.
**The merge key** (`FaceKey`) is the block id plus the *signed* face direction. The sign is not decoration: a top face and the bottom face of the voxel directly above it are coplanar and share a material, and merging them would fuse two surfaces that face opposite ways and shade differently. `FaceDir` therefore distinguishes `PosY` from `NegY`, and likewise on the other axes.
**Face colour** is currently a function of face direction alone, standing in for lighting until materials land. It is part of the key only implicitly, since direction already is.
### Vertex extents: a shared convention
The mesher centres block `i` on the interval `[i - 0.5, i + 0.5]`, hence the `± 0.5` offsets throughout the quad emitters. A chunk's geometry therefore spans `[offset - 0.5, offset + CHUNK_SIZE - 0.5]`, **not** `[offset, offset + CHUNK_SIZE]`.
That half-block shift is duplicated in the frustum cull, which builds each chunk's bounding box from the same shifted minimum corner. Nothing in the type system ties the two together: if the mesher's extents ever change, the cull's box must change with them, or chunks will be culled while still partially on screen (or drawn while fully off it). The coupling is noted at both sites; treat it as an invariant of this file pair.
## Neighbour-aware boundary culling
A face is emitted only when the voxel adjoining it is air. For interior voxels that test is local, but for the `32²` faces on each of a chunk's six sides the adjoining voxel lives in another chunk. `Neighbors<'a>` carries borrowed handles to the six face-adjacent chunks for exactly this test.
An **absent** neighbour (`None`) means "not resident", and the boundary is treated as **exposed**, so its faces are emitted. The alternative, treating absence as solid, would cull those faces and leave visible holes along the load frontier as the player moves. Emitting them costs geometry that will be re-meshed away once the neighbour arrives, which is the correct trade: a transient over-draw beats a transient hole.
The consequence is that **a chunk's mesh is a function of seven chunks, not one**. Any change to residency invalidates the meshes of everything adjacent to it, which is what makes the staleness protocol below necessary. It is also why the client retains chunk voxels after uploading geometry: a neighbour arriving later needs this chunk's boundary voxels to re-mesh against. That retention is a real memory cost, recorded as a follow-up in `crates/client/src/chunks.rs`.
## The mesh worker pool
Meshing a chunk is far too expensive to run on the winit thread, so `MeshPool` owns a set of worker threads fed by `crossbeam-channel` (multi-consumer, unlike `std::sync::mpsc`), mirroring the server's generation pool.
Jobs are **owned snapshots**: a `MeshJob` carries the chunk and its six neighbours as `Arc` handles, so dispatch is a refcount bump rather than a copy of a 64 KiB volume, and the worker borrows nothing from the manager. Neighbours are snapshotted *at dispatch time*, which is precisely the state the resulting mesh will be correct for.
### Staleness: the generation protocol
Between dispatching a job for a position and the worker returning it, the world can have moved on: the chunk may have been evicted, or a neighbour may have loaded or dropped, making the in-flight mesh wrong before it arrives. Applying it would upload geometry that does not match the resident voxels.
Every dispatch is therefore stamped with a `JobGen`, a monotonic token drawn from a single global counter (not one counter per position, so no two dispatches ever share a token). The manager records the latest generation per in-flight position, and a returned mesh is applied only when **both** hold:
1. the position is still wanted (still resident), and
2. the generation recorded as in-flight for it still equals the mesh's own generation.
A missing entry means the position was evicted; a mismatch means a newer job superseded this one. Either way the result is discarded rather than uploaded. This is the same shape as the eviction race in server-side chunk residency: a late arrival is made *harmless* rather than prevented. It generalises, too, because re-dispatch is then free, costing nothing but the superseded worker's wasted effort.
`JobGen::next` wraps rather than panics on overflow. Wrapping requires 2⁶⁴ dispatches in one session, and a collision would additionally require the wrapped-to job to still be outstanding.
### Per-frame budgets
`ChunkManager::update` runs three bounded phases per frame, so a burst of deliveries degrades frame *pacing* rather than causing a stall:
- **`LOADS_PER_UPDATE`** (4) bounds chunk deliveries materialised per frame. Excess stays queued in the transport and is picked up next frame. Drops are not charged against this budget, since removing a mesh is cheap and delaying it only wastes memory.
- **`MESHES_PER_UPDATE`** (16) bounds mesh jobs dispatched per frame, drained from a pending re-mesh *set*. The set deduplicates: a burst of deliveries re-meshes each affected neighbour once, not once per delivery. The budget exceeds `LOADS_PER_UPDATE` because one delivery can enqueue up to seven jobs, itself plus six neighbours.
- **Ingesting finished meshes is unbounded.** Uploading already-computed geometry is cheap next to computing it, and throttling it would only let completed work pile up.
### The `MeshSink` boundary
The manager never names `Renderer`. It uploads through a `MeshSink` trait (insert, remove), implemented for `renderer::Renderer` in the client. The chunk manager is thus a pure orchestration state machine, testable against a recording fake with no Vulkan device involved, which is what makes the residency, budget, and staleness logic unit-testable at all. See `crates/client/src/tests/chunks.rs`.
## Frustum culling
Uploaded geometry is not unconditionally drawn. Each frame, `Frustum::from_view_proj` extracts six world-space planes from the combined view-projection matrix (Gribb-Hartmann), and every chunk mesh is tested with `intersects_aabb` before its draw call is recorded.
Two details are easy to get wrong and are pinned by tests:
- **Vulkan depth range.** Clip space here is `[0, 1]`, so the near plane is the third matrix row alone (`r2`), not `r3 + r2` as in OpenGL's `[-1, 1]` convention. The OpenGL form culls geometry directly ahead of the camera. See [`rendering.md`](rendering.md) for the broader clip-space conventions.
- **Row versus column.** `glam` stores matrices column-major while the derivation operates on rows of the combined matrix, so rows are read explicitly.
The box test uses the **positive vertex**: for each plane, the box corner farthest along that plane's normal is selected per axis. If even that corner lies behind the plane, the whole box does. The test is conservative, since a box straddling two planes' outsides without being inside the frustum can pass, which is the correct bias for culling: a false *visible* costs a wasted draw, a false *hidden* costs a visible artefact.
Planes are normalised at construction so plane evaluation returns true signed distances, which keeps the test usable for distance-based decisions (LOD selection) later.
## Debug render modes
The mesher's output is inspected through render modes, layered over two concepts:
- **`RasterPass`** is the GPU-level primitive and maps one-to-one onto a compiled pipeline, because polygon mode and depth-compare state are baked into a pipeline and cannot be set by a command. All passes share one pipeline layout and differ only in that state.
- **`RenderMode`** composes passes into what is presented, as an ordered list. `Filled` is one pass; `FilledWireframe` draws the terrain and then overlays edges, keeping the surface readable while showing the size and shape of the quads the greedy mesher actually emitted.
Adding a mode is one variant plus one arm in `RenderMode::passes`, and needs a new pass only if it requires rasterisation state no existing pass provides. `RasterPass::ALL` has its length pinned to `RasterPass::COUNT` at compile time, so a variant that is not listed fails to build rather than silently indexing the wrong pipeline.
Debug passes are sized and tinted in the vertex shader rather than through separate geometry, so no extra vertex data is uploaded to support them. The chords that select these modes are documented in `crates/client/src/debug.rs`; they sit behind an F1 modifier so they cannot collide with movement keys.
## Testing
The mesher and the frustum are pure algorithmic code, which is where the testing policy in `AGENTS.md` directs effort:
- `crates/renderer/src/tests/meshing.rs`: merge behaviour, face-direction keying (opposing coplanar faces must not merge), boundary culling against present and absent neighbours, empty and full chunks.
- `crates/renderer/src/tests/frustum.rs`: plane extraction under the Vulkan depth range, and the AABB test on inside, outside, and straddling boxes.
- `crates/client/src/tests/chunks.rs`: residency, per-frame budgets, and the generation protocol, driven against a `MeshSink` fake.
The Vulkan submission path itself (pipeline creation, command recording, presentation) is verified by running the client, not by unit tests.