Synvael/crates/server/src/main.rs
Serkyo c33643027c fix(server): sleep the remainder of the tick period
The simulation loop slept a flat 50 ms after the tick body, so the real period was the tick's work plus 50 ms and drifted with load; the advertised 20 Hz was never the rate actually achieved. The loop now timestamps the top of the tick and sleeps only the unused remainder of the budget, reporting an overrun instead of sleeping when the body exceeds it.
2026-07-30 22:29:43 +02:00

208 lines
9.2 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-only
//! Dedicated server for Synvael.
//!
//! The server handles the authoritative game simulation, including world management, physics, and combat.
/// A bounded LRU cache of regenerated chunk baselines, shared across the worker pool.
pub mod chunk_cache;
/// Per-connection chunk-streaming state: desired-set tracking and delivery.
pub mod client_stream;
/// Entity components describing players and other world-streaming anchors.
pub mod player;
/// On-disk persistence: region files and the atomic durability layer.
pub mod save;
/// Authoritative chunk storage and generation logic for the server.
pub mod world_server;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::net::{Ipv4Addr, SocketAddr};
use std::time::{Duration, Instant};
use anyhow::Context;
use bevy_ecs::prelude::{Query, ResMut, Schedule, With, World};
use glam::Vec3;
use shared::generator::{VoxelGenerator, WorldGenConfig};
use shared::world::{Chunk, ChunkPos, EntityPos};
use tracing::{debug, info, warn};
use client_stream::ClientStream;
use net::{NetworkServer, ServerEvent};
use player::{Player, Position, ViewDistance};
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);
/// Streaming system: loads and unloads chunks so that the resident set matches the union of the cylinders around every player anchor.
fn stream_chunks(
anchors: Query<(&Position, &ViewDistance), With<Player>>,
mut world: ResMut<ServerWorld>,
) {
// Desired set is the union of every anchor's cylinder; a chunk survives as long as it lies within any one player's view.
let mut desired = HashSet::new();
for (position, view) in &anchors {
cylinder_chunks(position.0.chunk, view.0, &mut desired);
}
let stats = world.reconcile(&desired);
info!(
loaded = stats.loaded,
unloaded = stats.unloaded,
resident = stats.resident,
in_flight = stats.in_flight,
"streaming reconcile"
);
}
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("debug")),
)
.init();
info!("Starting Synvael server");
let config_str = fs::read_to_string("assets/data/worldgen/default.json")
.context("reading worldgen config assets/data/worldgen/default.json")?;
let worldgen_config: WorldGenConfig =
serde_json::from_str(&config_str).context("parsing worldgen config as JSON")?;
info!("Successfully loaded world configuration");
debug!("Base height: {}", worldgen_config.base_height);
debug!("Noise scale: {}", worldgen_config.noise_scale);
debug!("Surface block: {}", worldgen_config.surface_block.0);
debug!("Subsurface block: {}", worldgen_config.subsurface_block.0);
debug!("Stone block: {}", worldgen_config.stone_block.0);
let seed = 4_813_530;
let generator = VoxelGenerator::new(worldgen_config, seed);
// The region directory holds the `.region` save files for this world
// TODO: resolve it per named world under a shared save root.
let region_dir = std::path::PathBuf::from("saves/default/region");
// Number of chunk baselines the worker pool retains before evicting the least-recently-used entry
// TODO: make this configurable through server configs
let cache_capacity =
std::num::NonZeroUsize::new(4_096).context("chunk cache capacity is non-zero")?;
let mut world = World::new();
world.insert_resource(ServerWorld::new(generator, region_dir, cache_capacity));
// Spawn a single dummy player anchor at the world origin.
world.spawn((
Player,
Position(EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::ZERO)),
ViewDistance(4),
));
// A schedule is one tick's worth of systems; running it advances the world.
let mut schedule = Schedule::default();
schedule.add_systems(stream_chunks);
// Loading phase: dispatch the initial region and wait for the worker pool to finish before granting control.
info!("Streaming initial region");
loop {
schedule.run(&mut world);
let server_world = world.resource::<ServerWorld>();
let resident = server_world.loaded_count();
let in_flight = server_world.in_flight_count();
// Loading progress is simply the resident fraction of all known chunks.
let total = resident + in_flight;
debug!(resident, in_flight, total, "loading progress");
// The region is ready once at least one chunk has been generated and none remain in flight.
if server_world.streaming_idle() && resident > 0 {
break;
}
}
info!("Initial region ready; granting player control");
// Spawn the networking thread and bind the QUIC endpoint. The synchronous simulation loop below communicates with it only by draining events.
let bind = SocketAddr::from((Ipv4Addr::LOCALHOST, net::DEFAULT_PORT));
let (network, local_addr) =
NetworkServer::spawn(bind, env!("CARGO_PKG_VERSION").to_owned(), TICK_RATE_HZ)
.context("spawning network server")?;
info!(%local_addr, "network endpoint listening");
run_simulation(&mut world, &network)
}
/// 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.
let empty_baseline = Chunk::default();
// Per-connection streaming state, keyed by the stable session id the network thread assigns.
let mut clients: HashMap<u64, ClientStream> = HashMap::new();
loop {
let tick_start = Instant::now();
// Fold network events into per-client subscription state.
for event in network.poll_events() {
match event {
ServerEvent::ClientConnected { id, hello, chunks } => {
info!(id, name = %hello.player_identity.display_name, "client connected");
clients.insert(id, ClientStream::new(chunks));
}
ServerEvent::ClientDisconnected { id, reason } => {
info!(id, %reason, "client disconnected");
clients.remove(&id);
}
ServerEvent::ChunkSubscribe { id, request } => {
if let Some(client) = clients.get_mut(&id) {
let (added, drops) = client.resubscribe(request.center, request.radius);
info!(
id,
added, drops, "client {id}: +{added} chunks, -{drops} drops"
);
} else {
warn!(id, "chunk subscribe from unknown session");
}
}
}
}
// Reconcile the resident world to the union of every client's desired set. A chunk survives as long as any connected client wants it; when no client is connected the union is empty and the world drains.
let mut desired = HashSet::new();
for client in clients.values() {
desired.extend(client.desired().iter().copied());
}
world.resource_mut::<ServerWorld>().reconcile(&desired);
// Deliver newly-resident chunks to each client. Loads dispatched above may not be resident this tick; `flush` retries on later ticks until the worker pool returns them.
let server_world = world.resource::<ServerWorld>();
for (id, client) in &mut clients {
let delivered = client.flush(server_world, &empty_baseline);
if delivered > 0 {
debug!(id, delivered, "delivered resident chunks");
}
}
// 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.
let elapsed = tick_start.elapsed();
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));
}
}
}