feat(client): report debug statistics to the terminal

This commit is contained in:
Serkyo 2026-08-02 01:43:27 +02:00
parent d445a29e5e
commit 15a3c74bec
2 changed files with 169 additions and 83 deletions

View file

@ -181,11 +181,6 @@ impl ChunkManager {
/// ///
/// `center` is the chunk the streaming region is currently anchored to, and is needed only to size the desired set; it is not retained. /// `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] #[must_use]
// The statistics overlay is the sole consumer and is wired in a later change; until then nothing in the binary reads this, though the tests below do.
#[cfg_attr(
not(test),
expect(dead_code, reason = "consumed by the statistics overlay")
)]
pub fn stats(&self, center: ChunkPos) -> ChunkStats { pub fn stats(&self, center: ChunkPos) -> ChunkStats {
ChunkStats { ChunkStats {
resident: self.resident.len(), resident: self.resident.len(),

View file

@ -8,8 +8,6 @@ mod camera;
mod chunks; mod chunks;
mod debug; mod debug;
mod mesh_pool; mod mesh_pool;
// The overlay that consumes this module is wired in a later change; until then the binary build reaches none of it, though the tests do.
#[expect(dead_code, reason = "consumed by the statistics overlay")]
mod stats; mod stats;
use std::time::Instant; use std::time::Instant;
@ -18,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};
@ -74,6 +75,20 @@ struct App {
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. /// Whether the debug statistics panel is being emitted. Collection is unconditional; only emission is gated on this.
stats_overlay: bool, 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 {
@ -95,6 +110,16 @@ impl Default for App {
last_center: None, last_center: None,
chunks: None, chunks: None,
stats_overlay: false, 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,
} }
} }
} }
@ -119,6 +144,145 @@ impl App {
} }
} }
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 {
fn resumed(&mut self, event_loop: &ActiveEventLoop) { fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let attributes = Window::default_attributes().with_title("Synvael"); let attributes = Window::default_attributes().with_title("Synvael");
@ -201,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) {
@ -241,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();
}
}
_ => (), _ => (),
} }
} }