From b347ceb19511017831a268667c2a320794502333 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:45:46 +0200 Subject: [PATCH 01/17] Dedicated server: vanilla's chunk pacing and movement checks PlayerChunkSender: one unacknowledged batch until the client's first acknowledgement and ten after it, batches capped at the client's desired chunks per tick (not twice that), and the quota reset to one chunk whenever every batch is acknowledged. ServerGamePacketListenerImpl.handleMovePlayer: a move further from where the tick began (firstGood) than 100 blocks squared per move packet (300 while gliding; the count falls back to 1 past 5 packets) logs " moved too quickly!" and pulls the player back, as the player_movement_check and elytra_movement_check game rules allow. NaN coordinates or non-finite angles and two position moves in one client tick disconnect with invalid_player_movement, positions are clamped to the world's limits, an unanswered teleport is sent again after 20 ticks, and accepting a teleport runs the move it carries. The move is then replayed against the blocks: a survival or adventure player who ends more than 0.25 blocks short of the target (horizontally; vanilla drops the vertical part) logs " moved wrongly!" and is sent back unless they were already stuck, and any move into a block the old box did not touch (isEntityCollidingWithAnythingNew) is sent back silently. Spectators and sleepers skip both. Angles wrap as Mth.wrapDegrees does. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/examples/lan_flat.rs | 3 +- crates/horizon-client/src/game/lan.rs | 10 + crates/horizon-client/src/lan/guest.rs | 26 +- crates/horizon-client/src/lan/hub.rs | 299 ++++++++++++++++++--- crates/horizon-client/tests/lan_bot.rs | 1 + 5 files changed, 297 insertions(+), 42 deletions(-) diff --git a/crates/horizon-client/examples/lan_flat.rs b/crates/horizon-client/examples/lan_flat.rs index 16fc36b95..d1a492edf 100644 --- a/crates/horizon-client/examples/lan_flat.rs +++ b/crates/horizon-client/examples/lan_flat.rs @@ -121,6 +121,7 @@ fn main() -> Result<(), Box> { clock_rate: 1.0, tick_rate: 20.0, tick_frozen: false, + collision: None, }; for event in hub.tick(&mut ctx) { match event { @@ -132,7 +133,7 @@ fn main() -> Result<(), Box> { eprintln!("[lan] {packet:?}"); } } - HostEvent::Chat(line) => println!("{line}"), + HostEvent::Chat(line) | HostEvent::Warn(line) => println!("{line}"), HostEvent::Left(_) => {} } } diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 1d00134e4..ad30dd813 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -710,7 +710,12 @@ impl Game<'_> { }, tick_rate: self.clock.rate(), tick_frozen: self.clock.frozen(), + collision: Some(self.collision), }; + host.hub.movement_checks = ( + self.ui.rules.bool("player_movement_check"), + self.ui.rules.bool("elytra_movement_check"), + ); host.hub.tick(&mut ctx) }; if let Some(state) = &state @@ -772,6 +777,10 @@ impl Game<'_> { super::dedicated::log("INFO", &format!("[Not Secure] {line}")); } HostEvent::Chat(line) => self.system_message(Text::literal(line)), + HostEvent::Warn(line) if self.options.dedicated.is_some() => { + super::dedicated::log("WARN", &line); + } + HostEvent::Warn(line) => log::warn!("{line}"), HostEvent::Packet(conn, packet) => { self.lan_guest_packet(&mut host, conn, *packet); } @@ -831,6 +840,7 @@ impl Game<'_> { clock_rate: self.ui.clock.rate, tick_rate: self.clock.rate(), tick_frozen: self.clock.frozen(), + collision: Some(self.collision), }; host.hub.place_new_player(conn, &mut ctx); } diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index 243c90d18..28b833d3c 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -71,15 +71,31 @@ pub struct Guest { /// the client accepts it (ServerGamePacketListenerImpl.awaitingPositionFromClient). pub awaiting_teleport: Option<(i32, [f64; 3])>, pub teleport_counter: i32, + /// The hub tick the pending teleport was (re)sent on (awaitingTeleportTime). + pub teleport_tick: u64, + /// Where the player was when this tick's packets began (firstGoodX/Y/Z), + /// for the moved-too-quickly check. + pub first_good: [f64; 3], + /// Position moves received in all and by the start of this tick + /// (receivedMovePacketCount, knownMovePacketCount). + pub received_moves: i32, + pub known_moves: i32, + /// A position move arrived since the client's last ClientTickEnd + /// (receivedPositionThisTick): a second one is a protocol error. + pub position_this_tick: bool, /// The client finished loading terrain (ServerboundPlayerLoadedPacket). pub loaded: bool, // Chunks. pub center: Option<(i32, i32)>, pub sent_chunks: HashSet<(i32, i32)>, pub chunk_queue: Vec<(i32, i32)>, + /// PlayerChunkSender: batches sent but not yet acknowledged, how many + /// may be (1 until the client's first acknowledgement, then 10), the + /// rate the client asks for and the chunks the next batch may hold. pub unacknowledged: u32, + pub max_unacknowledged: u32, pub chunks_per_tick: f32, - pub chunk_credit: f32, + pub batch_quota: f32, // Entities. pub tracker: Tracker, // Player state the gameplay code reads and writes. @@ -167,13 +183,19 @@ impl Guest { sleeping: false, awaiting_teleport: None, teleport_counter: 0, + teleport_tick: 0, + first_good: position, + received_moves: 0, + known_moves: 0, + position_this_tick: false, loaded: false, center: None, sent_chunks: HashSet::new(), chunk_queue: Vec::new(), unacknowledged: 0, + max_unacknowledged: 1, chunks_per_tick: 9.0, - chunk_credit: 0.0, + batch_quota: 0.0, tracker: Tracker::default(), motion: None, inventory: vec![None; 36], diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index cb438508e..b0e30c664 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -12,17 +12,24 @@ use super::{ guest::{Guest, SentEffect, stack_to_network}, }; use crate::{ + collisions::SceneCollisions, effects::{EffectInstance, PlayerEffects}, items::ItemStack, world::World, }; +use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape}; use horizon_protocol::ids::play::clientbound as ids; use horizon_protocol::{ entity_data::{Value, serializer}, packets::play::{self, Clientbound as C, Serverbound as S}, text::{literal, translatable}, }; -use horizon_world::nbt::{JavaString, Tag}; +use horizon_world::{ + block_collision::BlockCollisionRegistry, + block_collision_scan::BlockCollisionScan, + collision_context::CollisionContext, + nbt::{JavaString, Tag}, +}; use std::sync::{Arc, atomic::Ordering}; /// GameEvent types (ClientboundGameEventPacket). @@ -88,6 +95,9 @@ pub struct TickCtx<'a> { /// TickRateManager: the tick rate and whether the game is frozen. pub tick_rate: f32, pub tick_frozen: bool, + /// Block collisions, for ServerGamePacketListenerImpl's moved-wrongly + /// check (none: moves are not checked against blocks). + pub collision: Option<&'a BlockCollisionRegistry<'a>>, } /// What the gameplay code has to do for a guest. @@ -99,6 +109,8 @@ pub enum HostEvent { Packet(u32, Box), /// A chat line to show on the host (already broadcast to the guests). Chat(String), + /// A server log warning (ServerGamePacketListenerImpl's LOGGER.warn). + Warn(String), } pub struct Hub { @@ -108,6 +120,9 @@ pub struct Hub { pub guests: Vec, pub(super) sync: Arc, pub ticks: u64, + /// The `player_movement_check` and `elytra_movement_check` game rules, + /// set by the game before each tick. + pub movement_checks: (bool, bool), last_clock: i64, last_rate: f32, ticking: (f32, bool), @@ -196,6 +211,7 @@ impl Hub { guests: Vec::new(), sync, ticks: 0, + movement_checks: (true, true), last_clock: i64::MIN, last_rate: 0.0, ticking: (20.0, false), @@ -713,7 +729,9 @@ impl Hub { /// ServerGamePacketListenerImpl.teleport: move the client and wait for its confirmation. pub fn teleport(&mut self, i: usize, position: Option<[f64; 3]>) { + let ticks = self.ticks; let g = &mut self.guests[i]; + g.teleport_tick = ticks; if let Some(p) = position { g.position = p; g.previous_position = p; @@ -1051,15 +1069,16 @@ impl Hub { g.chunk_queue .sort_by_key(|&(x, z)| i64::from(x - cx).pow(2) + i64::from(z - cz).pow(2)); } - // Send ready chunks, closest first, in batches. - if g.chunk_queue.is_empty() || g.unacknowledged >= 10 || self.ticks <= g.placed_at + 1 { + // PlayerChunkSender.sendNextChunks: ready chunks, closest first, in + // batches the client's acknowledgements pace. + if self.ticks <= g.placed_at + 1 || g.unacknowledged >= g.max_unacknowledged { return; } - g.chunk_credit = (g.chunk_credit + g.chunks_per_tick).min(g.chunks_per_tick.max(1.0) * 2.0); - let budget = g.chunk_credit.floor() as usize; - if budget == 0 { + g.batch_quota = (g.batch_quota + g.chunks_per_tick).min(g.chunks_per_tick.max(1.0)); + if g.batch_quota < 1.0 || g.chunk_queue.is_empty() { return; } + let budget = g.batch_quota.floor() as usize; let mut packets = Vec::new(); let mut taken = Vec::new(); for (idx, key) in g.chunk_queue.iter().enumerate() { @@ -1080,7 +1099,7 @@ impl Hub { if packets.is_empty() { return; } - g.chunk_credit -= packets.len() as f32; + g.batch_quota -= packets.len() as f32; g.send(&C::ChunkBatchStart(horizon_protocol::packets::Empty)); for p in &packets { g.send(p); @@ -1462,6 +1481,11 @@ impl Hub { } fn process_inbox(&mut self, i: usize, ctx: &mut TickCtx, events: &mut Vec) { + // ServerGamePacketListenerImpl.tickPlayer ran between the last + // tick's packets and these: resetPosition, and the moves it counted. + let g = &mut self.guests[i]; + g.first_good = g.position; + g.known_moves = g.received_moves; let mut budget = 400; while budget > 0 { let Some(packet) = self.guests[i].inbox.pop_front() else { @@ -1490,23 +1514,42 @@ impl Hub { self.guests[i].last_action_tick = self.ticks; } match packet { + // handleAcceptTeleportPacket. S::AcceptTeleportation(p) => { let g = &mut self.guests[i]; - if g.awaiting_teleport.is_some_and(|(id, _)| id == p.id) { + if let Some((id, at)) = g.awaiting_teleport + && id == p.id + { + if p.x.is_nan() + || p.y.is_nan() + || p.z.is_nan() + || !p.yaw.is_finite() + || !p.pitch.is_finite() + { + self.kick(conn, invalid_movement()); + continue; + } + g.position = at; g.awaiting_teleport = None; + let rot = (p.yaw, p.pitch); + self.position_change(i, [p.x, p.y, p.z], rot, 0, ctx, events); } } S::MovePlayerPos(p) => { - self.move_player(i, Some([p.x, p.y, p.z]), None, p.flags); + self.move_player(i, Some([p.x, p.y, p.z]), None, p.flags, ctx, events); } S::MovePlayerPosRot(p) => { - self.move_player(i, Some([p.x, p.y, p.z]), Some((p.yaw, p.pitch)), p.flags); + let (pos, rot) = ([p.x, p.y, p.z], (p.yaw, p.pitch)); + self.move_player(i, Some(pos), Some(rot), p.flags, ctx, events); } S::MovePlayerRot(p) => { - self.move_player(i, None, Some((p.yaw, p.pitch)), p.flags); + self.move_player(i, None, Some((p.yaw, p.pitch)), p.flags, ctx, events); + } + S::MovePlayerStatus(p) => { + self.move_player(i, None, None, p.flags, ctx, events); } - S::MovePlayerStatus(p) => self.move_player(i, None, None, p.flags), S::PlayerLoaded(_) => self.guests[i].loaded = true, + // PlayerChunkSender.onChunkBatchReceivedByClient. S::ChunkBatchReceived(p) => { let g = &mut self.guests[i]; g.unacknowledged = g.unacknowledged.saturating_sub(1); @@ -1515,7 +1558,12 @@ impl Hub { } else { p.chunks_per_tick.clamp(0.01, 64.0) }; + if g.unacknowledged == 0 { + g.batch_quota = 1.0; + } + g.max_unacknowledged = 10; } + S::ClientTickEnd(_) => self.guests[i].position_this_tick = false, S::ClientInformation(info) => { let g = &mut self.guests[i]; g.view_distance = @@ -1534,7 +1582,6 @@ impl Hub { 0 => self.guests[i].sleeping = false, _ => {} }, - S::ClientTickEnd(_) => {} S::Chat(c) => { let text: String = c.message.chars().filter(|c| !c.is_control()).collect(); if !text.is_empty() { @@ -1546,48 +1593,205 @@ impl Hub { other => events.push(HostEvent::Packet(conn, Box::new(other))), } } - let _ = ctx; } /// ServerGamePacketListenerImpl.handleMovePlayer. - fn move_player(&mut self, i: usize, pos: Option<[f64; 3]>, rot: Option<(f32, f32)>, flags: u8) { - let g = &mut self.guests[i]; - if g.awaiting_teleport.is_some() || !g.loaded && g.ticks == 0 && pos.is_none() { + fn move_player( + &mut self, + i: usize, + pos: Option<[f64; 3]>, + rot: Option<(f32, f32)>, + flags: u8, + ctx: &TickCtx, + events: &mut Vec, + ) { + let conn = self.guests[i].conn; + // containsInvalidValues, on the packet's values (0 where it has none). + let (yaw, pitch) = rot.unwrap_or_default(); + if pos.is_some_and(|p| p.iter().any(|v| v.is_nan())) + || !yaw.is_finite() + || !pitch.is_finite() + { + self.kick(conn, invalid_movement()); return; } - if let Some(p) = pos { - if p.iter().any(|v| !v.is_finite() || v.abs() > 3.0e7) { + let g = &mut self.guests[i]; + if pos.is_some() { + if g.position_this_tick { + self.kick(conn, invalid_movement()); return; } - // A jump of more than a hundred blocks is a rejected move. - let d2: f64 = (0..3).map(|k| (p[k] - g.position[k]).powi(2)).sum(); - if d2 > 100.0 * 100.0 { - let at = g.position; - self.teleport(i, Some(at)); - return; - } - g.previous_position = g.position; - g.position = p; + g.position_this_tick = true; } - let moved = pos.map_or([0.0; 3], |_| { - [0, 1, 2].map(|k| g.position[k] - g.previous_position[k]) - }); + if !g.loaded && g.ticks == 0 && pos.is_none() { + return; + } + // updateAwaitingTeleport: moves wait for the client to accept the + // teleport, which is sent again after 20 ticks without an answer. + if let Some((_, at)) = g.awaiting_teleport { + if self.ticks - g.teleport_tick > 20 { + self.teleport(i, Some(at)); + } + return; + } + let to = pos.unwrap_or(g.position); + let rot = rot.unwrap_or((g.yaw, g.pitch)); + self.position_change(i, to, rot, flags, ctx, events); + } + + /// ServerGamePacketListenerImpl.handlePlayerPositionChange. + fn position_change( + &mut self, + i: usize, + to: [f64; 3], + (yaw, pitch): (f32, f32), + flags: u8, + ctx: &TickCtx, + events: &mut Vec, + ) { + let (frozen, (check, elytra_check)) = (self.ticking.1, self.movement_checks); + let g = &mut self.guests[i]; + let to = [ + to[0].clamp(-3.0E7, 3.0E7), + to[1].clamp(-2.0E7, 2.0E7), + to[2].clamp(-3.0E7, 3.0E7), + ]; + let (yaw, pitch) = (wrap_degrees(yaw), wrap_degrees(pitch)); + let d = [0, 1, 2].map(|k| to[k] - g.first_good[k]); + let moved = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; + if g.sleeping { + if moved > 1.0 { + (g.yaw, g.pitch) = (yaw, pitch); + self.teleport(i, None); + } + return; + } + // TickRateManager.runsNormally: not frozen (a frozen game's steps + // are not counted either; Horizon's hub does not see them). + if !frozen { + g.received_moves += 1; + let mut packets = g.received_moves - g.known_moves; + if packets > 5 { + packets = 1; + } + // shouldCheckPlayerMovement. The server holds no velocity for a + // guest (getDeltaMovement), so nothing is expected. + if check && (!g.fall_flying || elytra_check) { + let limit = if g.fall_flying { 300.0 } else { 100.0 }; + if moved > limit * f64::from(packets) { + events.push(HostEvent::Warn(format!( + "{} moved too quickly! {},{},{}", + g.name, + crate::java_text::double_to_string(d[0]), + crate::java_text::double_to_string(d[1]), + crate::java_text::double_to_string(d[2]) + ))); + self.teleport(i, None); + return; + } + } + } + let start = g.position; + let (wrongly, refused) = self.collide(i, to, ctx); + if wrongly { + let line = format!("{} moved wrongly!", self.guests[i].name); + events.push(HostEvent::Warn(line)); + } + if refused { + let g = &mut self.guests[i]; + (g.yaw, g.pitch) = (yaw, pitch); + self.teleport(i, Some(start)); + return; + } + let g = &mut self.guests[i]; + g.previous_position = g.position; + g.position = to; + let moved = [0, 1, 2].map(|k| g.position[k] - g.previous_position[k]); if g.moves.is_empty() { g.moves_from_ground = g.on_ground; } g.moves.push((moved, flags & 1 != 0)); - if let Some((yaw, pitch)) = rot - && yaw.is_finite() - && pitch.is_finite() - { - g.yaw = yaw % 360.0; - g.pitch = pitch.clamp(-90.0, 90.0); - g.head_yaw = g.yaw; - } + g.yaw = yaw; + g.pitch = pitch.clamp(-90.0, 90.0); + g.head_yaw = g.yaw; g.on_ground = flags & 1 != 0; g.ticks = g.ticks.max(1); } + /// The collision part of handlePlayerPositionChange: whether the move + /// counts as moved wrongly (logged), and whether it is refused (the + /// player is sent back where they were). + fn collide(&self, i: usize, to: [f64; 3], ctx: &TickCtx) -> (bool, bool) { + let g = &self.guests[i]; + // noPhysics (spectators) and sleepers move freely. + let Some(binding) = ctx.collision else { + return (false, false); + }; + if g.spectator() || g.sleeping { + return (false, false); + } + let height = if g.fall_flying { + 0.6 + } else if g.sneaking { + 1.5 + } else { + 1.8 + }; + let at = |p: [f64; 3]| { + Aabb::new( + Vec3::new(p[0] - 0.3, p[1], p[2] - 0.3), + Vec3::new(p[0] + 0.3, p[1] + height, p[2] + 0.3), + ) + }; + let (old, new) = (at(g.position), at(to)); + let mut scene = SceneCollisions { + world: ctx.world, + binding, + subject: None, + }; + // player.move, then how far from the target it ended (vanilla drops + // the vertical part: `yDist > -0.5 || yDist < 0.5` always holds). + let movement = Vec3::new( + to[0] - g.position[0], + to[1] - g.position[1], + to[2] - g.position[2], + ); + let fail = !g.creative() + && scene + .resolve(old, movement, 0.6, g.on_ground) + .is_ok_and(|done| { + let (dx, dz) = (movement.x - done.x, movement.z - done.z); + dx * dx + dz * dz > 0.0625 + }); + if fail && scene.unobstructed(old).unwrap_or(true) { + return (true, true); + } + // isEntityCollidingWithAnythingNew: a block the old box did not + // already touch. + let shrink = f64::from(1.0E-5_f32); + let shrink = Vec3::new(shrink, shrink, shrink); + let Ok(old_shape) = VoxelShape::from_box(old.deflate(shrink)) else { + return (fail, false); + }; + let context = CollisionContext::position(g.position[1]); + let Ok(scan) = + BlockCollisionScan::new(&mut scene, context, new.deflate(shrink), false, 1 << 20) + else { + return (fail, false); + }; + for hit in scan { + let Ok(hit) = hit else { break }; + if !hit + .shape + .join_is_not_empty(&old_shape, ShapeOp::AND) + .unwrap_or(true) + { + return (fail, true); + } + } + (fail, false) + } + /// Acknowledge a block interaction (ClientboundBlockChangedAckPacket). pub fn ack(&self, conn: u32, sequence: i32) { if sequence >= 0 @@ -1684,6 +1888,23 @@ impl Hub { } /// A packet Horizon sends as raw bytes (no fields worth a type). +/// multiplayer.disconnect.invalid_player_movement. +/// Mth.wrapDegrees. +fn wrap_degrees(degrees: f32) -> f32 { + let mut wrapped = degrees % 360.0; + if wrapped >= 180.0 { + wrapped -= 360.0; + } + if wrapped < -180.0 { + wrapped += 360.0; + } + wrapped +} + +fn invalid_movement() -> Tag { + translatable("multiplayer.disconnect.invalid_player_movement", &[]) +} + fn raw(id: i32, payload: Vec) -> C { C::Unknown(id, payload) } diff --git a/crates/horizon-client/tests/lan_bot.rs b/crates/horizon-client/tests/lan_bot.rs index 36f53444c..6b1d6bd5b 100644 --- a/crates/horizon-client/tests/lan_bot.rs +++ b/crates/horizon-client/tests/lan_bot.rs @@ -193,6 +193,7 @@ impl Server { clock_rate: 1.0, tick_rate: 20.0, tick_frozen: false, + collision: None, }; let events = self.hub.tick(&mut ctx); for event in events { From c8003301bcab7ada444666edb09949fe9975eb99 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:45:46 +0200 Subject: [PATCH 02/17] horizon-bots: vanilla's chunk batch sizing, chunk-gated movement and chunk accounting The bots acknowledge chunk batches with ChunkBatchSizeCalculator's desired chunks per tick, from how long each batch takes to arrive, instead of a fixed 25. A bot moves only while it holds the chunk it stands in, as vanilla's client stops ticking its player in a chunk it hasn't received, so no bot outruns a server's chunk sending. Each bot keeps the chunks it holds, so the report separates chunks that entered its view from chunks sent again while it still held them, and counts the moves the server refused and the ticks it waited for its chunk. server_bench.py runs one pass per behaviour (walk, explore), spawns every bot on the world spawn itself (respawn_radius 0) so both servers see the same paths, can run the bots on another machine (--bots-via, --bots-bin, --connect) and the servers under nice, and reports new, dropped and re-sent chunks per second, pull-backs and the server's "moved too quickly" lines. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-bots/src/main.rs | 117 +++++++++++++++++++++++++++++--- tools/server_bench.py | 104 +++++++++++++++++++++------- 2 files changed, 188 insertions(+), 33 deletions(-) diff --git a/crates/horizon-bots/src/main.rs b/crates/horizon-bots/src/main.rs index d76da5e84..3eb15beec 100644 --- a/crates/horizon-bots/src/main.rs +++ b/crates/horizon-bots/src/main.rs @@ -9,6 +9,11 @@ //! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json //! //! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`. +//! A bot moves only while it holds the chunk it stands in: vanilla's client stops ticking its player in a chunk +//! it hasn't received, so a player can't outrun the server's chunk sending, on any server. +//! Each bot also keeps the chunks it holds, as a client's chunk cache does, so the report tells chunks that +//! entered the view apart from chunks sent again while the bot still held them (`resent`), and counts the +//! times the server pulled a bot back (`pulled_back`: a teleport after the first, as for "moved too quickly"). use horizon_protocol::{ PROTOCOL_VERSION, @@ -25,6 +30,7 @@ use horizon_protocol::{ }; use serde_json::json; use std::{ + collections::HashSet, sync::{ Arc, Mutex, atomic::{AtomicBool, AtomicU64, Ordering}, @@ -44,7 +50,7 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m --view-distance N what the bots ask for; default 10 --seed N default 12345 --name PREFIX bot names: PREFIX0, PREFIX1...; default Bot - --report FILE a JSON summary at the end + --report FILE a JSON summary at the end (- prints it after the per-second lines) "; #[derive(Clone, Copy, Debug, PartialEq)] @@ -129,6 +135,17 @@ fn options() -> Result { struct Bot { online: AtomicBool, chunks: AtomicU64, + /// Chunks received while the bot already held them (no ForgetLevelChunk in between). + resent: AtomicU64, + /// Different chunks received over the run. + unique: AtomicU64, + forgotten: AtomicU64, + /// Teleports after the spawn one: the server refused a move. + pulled_back: AtomicU64, + /// Ticks the bot couldn't move because it didn't hold its chunk yet. + waited: AtomicU64, + /// The chunks the bot holds (ClientChunkCache). + held: Mutex>, /// Where the server last put the bot (a teleport), to start from. teleport: Mutex>, error: Mutex>, @@ -268,6 +285,36 @@ fn join(o: &Options, name: &str) -> Result { } } +/// ChunkBatchSizeCalculator: the chunks per tick a vanilla client asks for, from how long its batches take +/// to arrive. +struct BatchSize { + nanos_per_chunk: f64, + old_samples_weight: u32, + started: Instant, +} + +impl BatchSize { + fn new() -> Self { + Self { + nanos_per_chunk: 2_000_000.0, + old_samples_weight: 1, + started: Instant::now(), + } + } + fn finished(&mut self, size: i32) { + if size > 0 { + let per_chunk = self.started.elapsed().as_nanos() as f64 / f64::from(size); + let clamped = per_chunk.clamp(self.nanos_per_chunk / 3.0, self.nanos_per_chunk * 3.0); + let weight = f64::from(self.old_samples_weight); + self.nanos_per_chunk = (self.nanos_per_chunk * weight + clamped) / (weight + 1.0); + self.old_samples_weight = (self.old_samples_weight + 1).min(49); + } + } + fn desired_chunks_per_tick(&self) -> f32 { + (7_000_000.0 / self.nanos_per_chunk) as f32 + } +} + /// Reads the play state: answers what a client must answer, counts chunks, records teleports. fn read( mut reader: horizon_protocol::connection::ConnReader, @@ -277,6 +324,9 @@ fn read( use ids::play::clientbound as cb; use ids::play::serverbound as sb; let send = |body: Vec| writer.lock().unwrap().send(&body); + let mut seen = HashSet::new(); + let mut batch = BatchSize::new(); + let mut teleports = 0u64; loop { let (id, body) = match reader.recv() { Ok(packet) => packet, @@ -299,14 +349,38 @@ fn read( }, cb::LEVEL_CHUNK_WITH_LIGHT => { bot.chunks.fetch_add(1, Ordering::Relaxed); + if let (Ok(x), Ok(z)) = (r.i32(), r.i32()) { + if !bot.held.lock().unwrap().insert((x, z)) { + bot.resent.fetch_add(1, Ordering::Relaxed); + } + if seen.insert((x, z)) { + bot.unique.fetch_add(1, Ordering::Relaxed); + } + } Ok(()) } - cb::CHUNK_BATCH_FINISHED => send( - play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived { - chunks_per_tick: 25.0, - }) - .encode(), - ), + cb::FORGET_LEVEL_CHUNK => { + if let Ok(play::Clientbound::ForgetLevelChunk(p)) = + play::Clientbound::decode(id, &body) + && bot.held.lock().unwrap().remove(&(p.pos[0], p.pos[1])) + { + bot.forgotten.fetch_add(1, Ordering::Relaxed); + } + Ok(()) + } + cb::CHUNK_BATCH_START => { + batch.started = Instant::now(); + Ok(()) + } + cb::CHUNK_BATCH_FINISHED => { + batch.finished(r.varint().unwrap_or(0)); + send( + play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived { + chunks_per_tick: batch.desired_chunks_per_tick(), + }) + .encode(), + ) + } cb::PLAYER_POSITION => match play::Clientbound::decode(id, &body) { Ok(play::Clientbound::PlayerPosition(p)) => { let mut at = bot.teleport.lock().unwrap(); @@ -320,6 +394,10 @@ fn read( } }); *at = Some(pos); + teleports += 1; + if teleports > 1 { + bot.pulled_back.fetch_add(1, Ordering::Relaxed); + } send( play::Serverbound::AcceptTeleportation(play::AcceptTeleportation { id: p.teleport, @@ -416,7 +494,10 @@ fn run_bot( if let Some(at) = bot.teleport.lock().unwrap().take() { pos = at; } - if pos[1] < cruise { + let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4); + if !bot.held.lock().unwrap().contains(&here) { + bot.waited.fetch_add(1, Ordering::Relaxed); + } else if pos[1] < cruise { pos[1] = (pos[1] + 0.5).min(cruise); } else { match behaviour { @@ -520,6 +601,9 @@ fn main() { .filter(|b| b.online.load(Ordering::SeqCst)) .count(); let chunks: u64 = bots.iter().map(|b| b.chunks.load(Ordering::Relaxed)).sum(); + let total = |f: fn(&Bot) -> &AtomicU64| -> u64 { + bots.iter().map(|b| f(b).load(Ordering::Relaxed)).sum() + }; let failed = bots .iter() .filter(|b| b.error.lock().unwrap().is_some()) @@ -536,6 +620,11 @@ fn main() { "failed": failed, "chunks": chunks, "chunks_per_second": chunks - previous_chunks, + "unique": total(|b| &b.unique), + "forgotten": total(|b| &b.forgotten), + "resent": total(|b| &b.resent), + "pulled_back": total(|b| &b.pulled_back), + "waited_ticks": total(|b| &b.waited), }) ); previous_chunks = chunks; @@ -558,15 +647,25 @@ fn main() { "seconds": o.seconds, "joined": bots.iter().filter(|b| b.joined_after_ms.load(Ordering::SeqCst) > 0).count(), "chunks": bots.iter().map(|b| b.chunks.load(Ordering::Relaxed)).sum::(), + "resent": bots.iter().map(|b| b.resent.load(Ordering::Relaxed)).sum::(), + "pulled_back": bots.iter().map(|b| b.pulled_back.load(Ordering::Relaxed)).sum::(), + "waited_ticks": bots.iter().map(|b| b.waited.load(Ordering::Relaxed)).sum::(), "per_bot": bots.iter().enumerate().map(|(i, b)| json!({ "name": format!("{}{i}", o.name), "behaviour": behaviour_of(&o.behaviour, i).name(), "joined_after_ms": b.joined_after_ms.load(Ordering::SeqCst), "chunks": b.chunks.load(Ordering::Relaxed), + "unique_chunks": b.unique.load(Ordering::Relaxed), + "resent": b.resent.load(Ordering::Relaxed), + "forgotten": b.forgotten.load(Ordering::Relaxed), + "pulled_back": b.pulled_back.load(Ordering::Relaxed), + "waited_ticks": b.waited.load(Ordering::Relaxed), "error": *b.error.lock().unwrap(), })).collect::>(), }); - if let Some(path) = &o.report { + if o.report.as_deref() == Some("-") { + println!("{summary:#}"); + } else if let Some(path) = &o.report { if let Err(e) = std::fs::write(path, format!("{summary:#}\n")) { eprintln!("horizon-bots: {path}: {e}"); } diff --git a/tools/server_bench.py b/tools/server_bench.py index 5a64c80cf..91f11a7ad 100755 --- a/tools/server_bench.py +++ b/tools/server_bench.py @@ -8,10 +8,16 @@ then measures for a while: - MSPT (average, P50, P95, P99) from each server's own `/tick query`, asked every 5 seconds on the console: vanilla's ServerTickRateManager numbers, which Horizon reproduces, so both measure themselves the same way; - RSS and CPU of the server's whole process tree, once a second; -- chunks the bots receive per second (new terrain while the `explore` bots fly outwards); +- chunks the bots receive per second (new terrain while the `explore` bots fly outwards), chunks sent again + while a bot still held them, and the moves the server refused (pull-backs; "moved too quickly" and + "moved wrongly" lines); - the start time to `Done`, and every bot that failed to join or was dropped. - python3 tools/server_bench.py --servers horizon,vanilla --bots 1,10,25 --seconds 120 + python3 tools/server_bench.py --servers horizon,vanilla --bots 1,10,25 --behaviour walk,explore + +The bots can run on another machine, so they don't take the server's CPU: `--bots-via 'ssh macmini'` with +`--bots-bin` naming horizon-bots there and `--connect` the address it reaches this machine at. `--nice 19` +runs every server at the lowest priority (a shared machine). The vanilla side runs the pinned server jar from tools/lab.py prepare (your own copy). It needs Java 25. The Horizon side runs target/release/horizon-server with the engine beside it; build both first: @@ -186,8 +192,16 @@ def server_command(kind, directory, args): return [server, '--dir', directory, 'run'] -def run_once(kind, bots, args, out): - directory = out / f'{kind}-{bots}' +def bots_command(args): + """horizon-bots here, or through `--bots-via` (for example `ssh macmini`) on another machine.""" + if args.bots_via: + return [*args.bots_via.split(), args.bots_bin or 'horizon-bots'] + return [args.bots_bin or BIN / f'horizon-bots{EXE}'] + + +def run_once(kind, bots, behaviour, args, out): + name = f'{kind}-{behaviour}-{bots}' + directory = out / name shutil.rmtree(directory, ignore_errors=True) directory.mkdir(parents=True) port = free_port() @@ -195,19 +209,24 @@ def run_once(kind, bots, args, out): (directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items())) (directory / 'eula.txt').write_text('eula=true\n') command = server_command(kind, directory, args) + if args.nice: + command = ['nice', '-n', str(args.nice), *command] started = time.time() - server = Server(command, directory, out / f'{kind}-{bots}.log') - result = {'server': kind, 'bots': bots} + server = Server(command, directory, out / f'{name}.log') + result = {'server': kind, 'bots': bots, 'behaviour': behaviour} try: server.wait_for(r'/INFO\]: Done \(', 600) result['start_seconds'] = round(time.time() - started, 2) + # Every bot appears on the world spawn itself, not spread around it, so both servers see the same paths. + server.console('gamerule respawn_radius 0') time.sleep(5) - report = out / f'{kind}-{bots}-bots.json' + report = out / f'{name}-bots.json' total = bots * args.join_every + args.warmup + args.seconds + # The summary comes back on stdout after the per-second lines, so it works over ssh too. bot_process = subprocess.Popen( - [BIN / f'horizon-bots{EXE}', '--server', f'127.0.0.1:{port}', '--bots', str(bots), - '--behaviour', args.behaviour, '--join-every', str(args.join_every), '--seconds', str(int(total)), - '--seed', str(args.seed), '--report', report], + [*bots_command(args), '--server', f'{args.connect}:{port}', '--bots', str(bots), + '--behaviour', behaviour, '--join-every', str(args.join_every), '--seconds', str(int(total)), + '--seed', str(args.seed), '--report', '-'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) bot_lines = [] threading.Thread(target=lambda: bot_lines.extend(bot_process.stdout), daemon=True).start() @@ -229,11 +248,27 @@ def run_once(kind, bots, args, out): if (t := tick_query(server)) is not None: ticks.append(t) next_query = now + 5 - measured = [json.loads(l) for l in list(bot_lines) if l.startswith('{')] - window = [l for l in measured if l['t'] >= measured[-1]['t'] - args.seconds] if measured else [] bot_process.wait(timeout=total + 120) - summary = json.loads(report.read_text()) if report.exists() else {} + time.sleep(0.5) + measured, rest = [], [] + for line in list(bot_lines): + try: + sample = json.loads(line) + except ValueError: + sample = None + if isinstance(sample, dict) and 't' in sample: + measured.append(sample) + else: + rest.append(line) + window = [l for l in measured if l['t'] >= measured[-1]['t'] - args.seconds] if measured else [] + summary_text = ''.join(rest) + summary = json.loads(summary_text) if summary_text.strip() else {} + report.write_text(json.dumps(summary, indent=2) + '\n') mean = lambda xs: round(statistics.fmean(xs), 2) if xs else None + per_bot = summary.get('per_bot', []) + by_behaviour = {} + for b in per_bot: + by_behaviour.setdefault(b['behaviour'], []).append(b) result.update({ 'mspt': {k: mean([t[k] for t in ticks]) for k in ('average', 'p50', 'p95', 'p99')}, 'mspt_worst_p99': max((t['p99'] for t in ticks), default=None), @@ -242,6 +277,19 @@ def run_once(kind, bots, args, out): 'rss_mb': {'mean': mean([r / 1e6 for r in rss]), 'max': round(max(rss) / 1e6, 1) if rss else None}, 'cpu_percent': mean(cpu), 'chunks_per_second': mean([l['chunks_per_second'] for l in window]), + # Over the measured window: different chunks reaching the bots, chunks they had dropped and got + # again (walking back), and ticks the bots stood still waiting for their own chunk. + **({k + '_per_second': round((window[-1][k] - window[0][k]) / max(len(window) - 1, 1), 2) + for k in ('unique', 'forgotten', 'waited_ticks')} if len(window) > 1 else {}), + 'chunks_total': summary.get('chunks'), + # Per bot over the whole run: every chunk received, the different ones, and those sent again + # while the bot still held them. + 'chunks_per_bot': {k: mean([b['chunks'] for b in v]) for k, v in by_behaviour.items()}, + 'unique_chunks_per_bot': {k: mean([b['unique_chunks'] for b in v]) for k, v in by_behaviour.items()}, + 'resent': summary.get('resent'), + 'pulled_back': summary.get('pulled_back'), + 'moved_too_quickly': sum('moved too quickly!' in l for l in server.since(0)), + 'moved_wrongly': sum('moved wrongly!' in l for l in server.since(0)), 'bots_joined': summary.get('joined'), 'bots_failed': [b for b in summary.get('per_bot', []) if b.get('error')], 'tick_queries': len(ticks), @@ -257,23 +305,28 @@ def main(): parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0]) parser.add_argument('--servers', default='horizon,vanilla') parser.add_argument('--bots', default='1,10,25,50') - parser.add_argument('--behaviour', default='mixed') + parser.add_argument('--behaviour', default='walk,explore', help='one pass per behaviour (comma-separated)') parser.add_argument('--join-every', type=float, default=1.0) parser.add_argument('--warmup', type=int, default=60) parser.add_argument('--seconds', type=int, default=300) parser.add_argument('--seed', type=int, default=12345) parser.add_argument('--heap', default='4g', help='the Java heap, -Xms and -Xmx (vanilla)') parser.add_argument('--out', type=Path, default=None, help='the report (JSON)') + parser.add_argument('--nice', type=int, default=0, help='run the servers under nice -n N') + parser.add_argument('--bots-via', default='', help="a command prefix to run the bots elsewhere ('ssh host')") + parser.add_argument('--bots-bin', default='', help='horizon-bots (on the --bots-via machine)') + parser.add_argument('--connect', default='127.0.0.1', help='the address the bots reach the server at') args = parser.parse_args() out = lab.LOCAL / 'server-bench' out.mkdir(parents=True, exist_ok=True) runs = [] - for bots in [int(b) for b in args.bots.split(',')]: - for kind in args.servers.split(','): - print(f'{kind}, {bots} bots...', flush=True) - result = run_once(kind, bots, args, out) - runs.append(result) - print(json.dumps(result), flush=True) + for behaviour in args.behaviour.split(','): + for bots in [int(b) for b in args.bots.split(',')]: + for kind in args.servers.split(','): + print(f'{kind}, {bots} {behaviour} bots...', flush=True) + result = run_once(kind, bots, behaviour, args, out) + runs.append(result) + print(json.dumps(result), flush=True) report = { 'kind': 'horizon-server-benchmark', 'date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds'), @@ -286,13 +339,16 @@ def main(): path = args.out or out / 'report.json' path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(report, indent=2) + '\n') - print(f'\n{"server":8} {"bots":>4} {"start s":>8} {"MSPT":>6} {"P95":>6} {"P99":>6} {"RSS MB":>7} {"CPU %":>6} {"chunks/s":>8} {"joined":>6}') + print(f'\n{"server":8} {"pass":8} {"bots":>4} {"start s":>8} {"MSPT":>6} {"P95":>6} {"P99":>6} {"RSS MB":>7} ' + f'{"CPU %":>6} {"chunks/s":>8} {"/bot":>6} {"resent":>6} {"pulled":>6} {"joined":>6}') for r in runs: if 'error' in r: - print(f'{r["server"]:8} {r["bots"]:>4} failed: {r["error"].splitlines()[0]}') + print(f'{r["server"]:8} {r["behaviour"]:8} {r["bots"]:>4} failed: {r["error"].splitlines()[0]}') continue - print(f'{r["server"]:8} {r["bots"]:>4} {r["start_seconds"]:>8} {r["mspt"]["average"]!s:>6} {r["mspt"]["p95"]!s:>6} ' - f'{r["mspt"]["p99"]!s:>6} {r["rss_mb"]["mean"]!s:>7} {r["cpu_percent"]!s:>6} {r["chunks_per_second"]!s:>8} ' + per_bot = r['chunks_per_bot'].get(r['behaviour']) + print(f'{r["server"]:8} {r["behaviour"]:8} {r["bots"]:>4} {r["start_seconds"]:>8} {r["mspt"]["average"]!s:>6} ' + f'{r["mspt"]["p95"]!s:>6} {r["mspt"]["p99"]!s:>6} {r["rss_mb"]["mean"]!s:>7} {r["cpu_percent"]!s:>6} ' + f'{r["chunks_per_second"]!s:>8} {per_bot!s:>6} {r["resent"]!s:>6} {r["pulled_back"]!s:>6} ' f'{r["bots_joined"]!s:>6}') print(f'\nWrote {path}') From 3cd26c6d0b249ca442dd3e2e7fc38cbcd70cf862 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:24:30 +0200 Subject: [PATCH 03/17] server_bench: --gamerule, more game rules for both servers Horizon has no locator bar yet, so equal-work runs turn vanilla's off (--gamerule locator_bar=false): its waypoint updates cost every move a pass over every other player. Co-Authored-By: Claude Opus 5.5 --- tools/server_bench.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/server_bench.py b/tools/server_bench.py index 91f11a7ad..e50a49e64 100755 --- a/tools/server_bench.py +++ b/tools/server_bench.py @@ -219,6 +219,8 @@ def run_once(kind, bots, behaviour, args, out): result['start_seconds'] = round(time.time() - started, 2) # Every bot appears on the world spawn itself, not spread around it, so both servers see the same paths. server.console('gamerule respawn_radius 0') + for rule in filter(None, args.gamerule.split(',')): + server.console('gamerule ' + rule.replace('=', ' ')) time.sleep(5) report = out / f'{name}-bots.json' total = bots * args.join_every + args.warmup + args.seconds @@ -310,6 +312,9 @@ def main(): parser.add_argument('--warmup', type=int, default=60) parser.add_argument('--seconds', type=int, default=300) parser.add_argument('--seed', type=int, default=12345) + parser.add_argument('--gamerule', default='', + help="more game rules for both servers, 'name=value,...' (locator_bar=false: Horizon has " + "no locator bar yet)") parser.add_argument('--heap', default='4g', help='the Java heap, -Xms and -Xmx (vanilla)') parser.add_argument('--out', type=Path, default=None, help='the report (JSON)') parser.add_argument('--nice', type=int, default=0, help='run the servers under nice -n N') From c5851e77aa6bab2850e3f4692980e1b5ac3d564c Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:42:27 +0200 Subject: [PATCH 04/17] horizon-bots: --explore-speed, for an explore pass with equal work Co-Authored-By: Claude Opus 5.5 --- crates/horizon-bots/src/main.rs | 15 ++++++++++++--- tools/server_bench.py | 10 ++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/horizon-bots/src/main.rs b/crates/horizon-bots/src/main.rs index 3eb15beec..090daeb4e 100644 --- a/crates/horizon-bots/src/main.rs +++ b/crates/horizon-bots/src/main.rs @@ -49,6 +49,7 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m --seconds N how long to run after the first bot joins; default 60 --view-distance N what the bots ask for; default 10 --seed N default 12345 + --explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come) --name PREFIX bot names: PREFIX0, PREFIX1...; default Bot --report FILE a JSON summary at the end (- prints it after the per-second lines) "; @@ -79,6 +80,7 @@ struct Options { seconds: u64, view_distance: i8, seed: u64, + explore_speed: f64, name: String, report: Option, } @@ -93,6 +95,7 @@ fn options() -> Result { seconds: 60, view_distance: 10, seed: 12345, + explore_speed: 22.0, name: "Bot".into(), report: None, }; @@ -116,6 +119,9 @@ fn options() -> Result { "--seconds" => o.seconds = value.parse().map_err(|_| number("seconds"))?, "--view-distance" => o.view_distance = value.parse().map_err(|_| number("2 to 32"))?, "--seed" => o.seed = value.parse().map_err(|_| number("a number"))?, + "--explore-speed" => { + o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?; + } "--name" => o.name = value, "--report" => o.report = Some(value), _ => return Err(format!("unknown option {arg}; --help lists them")), @@ -521,9 +527,11 @@ fn run_bot( } } Behaviour::Explore => { - // Flying fast in creative: about 22 blocks a second, which keeps new chunks coming. - pos[0] += heading.cos() * 1.1; - pos[2] += heading.sin() * 1.1; + // Flying in creative, by default about 22 blocks a second, which keeps new + // chunks coming as fast as the server sends them. + let step = o.explore_speed / 20.0; + pos[0] += heading.cos() * step; + pos[2] += heading.sin() * step; } } } @@ -643,6 +651,7 @@ fn main() { "bots": o.bots, "behaviour": o.behaviour, "seed": o.seed, + "explore_speed": o.explore_speed, "view_distance": o.view_distance, "seconds": o.seconds, "joined": bots.iter().filter(|b| b.joined_after_ms.load(Ordering::SeqCst) > 0).count(), diff --git a/tools/server_bench.py b/tools/server_bench.py index e50a49e64..c79d6aab1 100755 --- a/tools/server_bench.py +++ b/tools/server_bench.py @@ -17,7 +17,9 @@ then measures for a while: The bots can run on another machine, so they don't take the server's CPU: `--bots-via 'ssh macmini'` with `--bots-bin` naming horizon-bots there and `--connect` the address it reaches this machine at. `--nice 19` -runs every server at the lowest priority (a shared machine). +runs every server at the lowest priority (a shared machine). The explore bots fly as fast as each server +sends them chunks, so a faster server gets more terrain to generate; `--explore-speed` slows them to a rate +both servers keep up with, for equal work. The vanilla side runs the pinned server jar from tools/lab.py prepare (your own copy). It needs Java 25. The Horizon side runs target/release/horizon-server with the engine beside it; build both first: @@ -228,7 +230,8 @@ def run_once(kind, bots, behaviour, args, out): bot_process = subprocess.Popen( [*bots_command(args), '--server', f'{args.connect}:{port}', '--bots', str(bots), '--behaviour', behaviour, '--join-every', str(args.join_every), '--seconds', str(int(total)), - '--seed', str(args.seed), '--report', '-'], + '--seed', str(args.seed), '--report', '-', + *(['--explore-speed', str(args.explore_speed)] if args.explore_speed else [])], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) bot_lines = [] threading.Thread(target=lambda: bot_lines.extend(bot_process.stdout), daemon=True).start() @@ -312,6 +315,9 @@ def main(): parser.add_argument('--warmup', type=int, default=60) parser.add_argument('--seconds', type=int, default=300) parser.add_argument('--seed', type=int, default=12345) + parser.add_argument('--explore-speed', type=float, default=0, + help='blocks a second the explore bots fly (default: the bots\' 22, as fast as chunks ' + 'come); a speed both servers keep up with makes the explore pass equal work') parser.add_argument('--gamerule', default='', help="more game rules for both servers, 'name=value,...' (locator_bar=false: Horizon has " "no locator bar yet)") From 6b0b34098eaa855e3526a87192031f662479689a Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:09:50 +0200 Subject: [PATCH 05/17] Benchmark: Horizon's server against vanilla 26.3 on equal work Walk and explore passes at 1 to 100 bots on the ThinkPad (bots on the Mac mini), vanilla with the locator bar off, plus an explore pass at a rate both servers keep up with. The README has the method, the tables, the profile of what remains at 25 walking bots and the earlier runs; docs/server.md section 9 and the Scale row summarise it. Co-Authored-By: Claude Opus 5.5 --- .../server-equal-work-2026-10-17/README.md | 168 +++++ .../explore-equal-rate.json | 267 +++++++ .../server-equal-work-2026-10-17/horizon.json | 415 +++++++++++ .../run-1-before-chunk-cache.json | 693 ++++++++++++++++++ .../run-2-chunk-cache.json | 413 +++++++++++ .../server-equal-work-2026-10-17/vanilla.json | 324 ++++++++ docs/server.md | 20 +- 7 files changed, 2297 insertions(+), 3 deletions(-) create mode 100644 benchmarks/server-equal-work-2026-10-17/README.md create mode 100644 benchmarks/server-equal-work-2026-10-17/explore-equal-rate.json create mode 100644 benchmarks/server-equal-work-2026-10-17/horizon.json create mode 100644 benchmarks/server-equal-work-2026-10-17/run-1-before-chunk-cache.json create mode 100644 benchmarks/server-equal-work-2026-10-17/run-2-chunk-cache.json create mode 100644 benchmarks/server-equal-work-2026-10-17/vanilla.json diff --git a/benchmarks/server-equal-work-2026-10-17/README.md b/benchmarks/server-equal-work-2026-10-17/README.md new file mode 100644 index 000000000..737b05858 --- /dev/null +++ b/benchmarks/server-equal-work-2026-10-17/README.md @@ -0,0 +1,168 @@ +# Horizon's server against vanilla 26.3, equal work (2026-10-17) + +The same bots on Horizon's dedicated server and on vanilla 26.3's, one server at a time, on the same +machine, with the work each server does made the same before the numbers are compared. This replaces the +first run in docs/server.md section 9, where the work wasn't equal. + +## What was made equal + +The first run (PR #556) had three differences that made the comparison unfair. Each is fixed in the code +that ran here: + +- **Refused moves.** Vanilla pulls a player back when a move is too fast ("moved too quickly") or ends + inside a block ("moved wrongly"). Horizon now runs both checks the way vanilla does (PR #586), so a bot + that flies too fast is pulled back on both servers. +- **Chunk sending.** Vanilla paces chunks by the client's acknowledgements (PlayerChunkSender: one + unacknowledged batch at first, then ten, each at most the client's desired chunks per tick). Horizon used + to send twice as many per batch; it now paces them the same way (PR #586), and the bots acknowledge + batches and ask for a rate the way vanilla's client does, so both servers send at the pace the client + asks for. Chunks sent again while the bot still held + them (`resent`) are counted: 0 on both servers in every run. +- **Movement gated on chunks.** A bot doesn't move while the chunk it stands in hasn't arrived (vanilla's + client waits the same way), so a slower server isn't asked to teleport bots into terrain it hasn't sent. + +One difference isn't in Horizon yet: vanilla 26.3's locator bar (waypoints), which costs vanilla O(players²) +work per move. Every vanilla run here sets `gamerule locator_bar false`, so neither server does it. + +## Method + +`tools/server_bench.py` runs each configuration on a fresh server: + +- the same `server.properties` on both (seed 12345, view and simulation distance 10, offline, creative, + difficulty easy, `sync-chunk-writes=true`), and `gamerule respawn_radius 0`; +- the server waits for `Done`, the bots join one a second, a 60-second warm-up, then 300 seconds of + measurement; +- MSPT (average, P50, P95, P99) comes from each server's own `/tick query`, asked every 5 seconds on the + console, so both measure themselves the same way (vanilla's ServerTickRateManager numbers, which Horizon + reproduces); TPS is 20 while the average fits in 50 ms, else 1000 / MSPT; +- RSS and CPU are the server's whole process tree, once a second (100 % is one core); +- chunk counts come from the bots: chunks received per second, chunks no bot had before (new terrain), and + pull-backs (position packets that move a bot back). + +The bots are `horizon-bots` (creative, flying 24 blocks above spawn, 20 moves a second): + +- `walk`: flies to random points within 48 blocks of spawn at sprint speed (5.6 blocks a second); +- `explore`: flies straight outwards, by default at about 22 blocks a second, as fast as the server sends + chunks. + +Machine: the ThinkPad (Intel Core i7-7500U, 2 cores / 4 threads, 32 GB, NixOS), servers under `nice -n 19` +because it also runs other services; vanilla on OpenJDK 25.0.4.1 with a 4 GB heap (`-Xms4g -Xmx4g`). The +bots ran on the Mac mini and reached the ThinkPad over the tailnet, so they took none of the server's CPU. + +What ran: Horizon from `origin/main` at bf851bfe with #586 (move checks and chunk pacing), the bots and +`server_bench.py` from this PR, #623 (sections encoded once, its first commit) and #649 (a tick's packets +handed to each connection together). The ThinkPad copy wasn't a git checkout, so the JSON reports have an +empty `commit`. Vanilla: the pinned 26.3 server jar. + +## Results + +MSPT is the average milliseconds per tick (50 is the limit for 20 TPS); P95 is the 95th percentile. CPU is +the whole process tree. "New chunks/s" are chunks no bot had before. + +### Walk (the same work on both servers) + +| Bots | Server | MSPT | P95 | TPS | RSS | CPU | Chunks/s | New chunks/s | +|---:|---|---:|---:|---:|---:|---:|---:|---:| +| 1 | Horizon | 5.5 | 12.1 | 20.0 | 1.37 GB | 57 % | 9 | 0.3 | +| 1 | Vanilla | 18.3 | 27.3 | 20.0 | 3.35 GB | 66 % | 10 | 0.3 | +| 10 | Horizon | 11.5 | 22.1 | 20.0 | 1.41 GB | 124 % | 94 | 2.4 | +| 10 | Vanilla | 16.8 | 30.5 | 20.0 | 3.41 GB | 84 % | 100 | 2.8 | +| 25 | Horizon | 20.7 | 40.4 | 20.0 | 1.45 GB | 170 % | 236 | 5.7 | +| 25 | Vanilla | 16.0 | 26.4 | 20.0 | 3.47 GB | 115 % | 252 | 6.7 | +| 50 | Horizon | 31.4 | 79.0 | 20.0 | 1.52 GB | 173 % | 451 | 10.3 | +| 50 | Vanilla | 20.5 | 29.7 | 20.0 | 3.55 GB | 194 % | 502 | 12.2 | +| 100 | Horizon | 114.5 | 256.2 | 8.7 | 1.33 GB | 181 % | 484 | 30.8 | +| 100 | Vanilla | stopped by its watchdog | | | | | | | + +Both servers sent the same chunks (about 10 a second per bot, the churn of flying around) and sent none +twice. Vanilla pulled back a few bots for moving too quickly when a tick ran long (5 to 33 per run); +Horizon none. At 100 bots vanilla's watchdog stopped the server (a tick ran over 60 s, in +`ChunkMap$TrackedEntity.updatePlayer`); Horizon kept running at 8.7 TPS. + +### Explore at a rate both servers keep up with (equal work) + +The bots fly outwards at 0.8 blocks a second (`--explore-speed 0.8`), slow enough that neither server makes +them wait for terrain, so both generate and send the same new chunks. + +| Bots | Server | MSPT | P95 | TPS | RSS | CPU | Chunks/s | New chunks/s | +|---:|---|---:|---:|---:|---:|---:|---:|---:| +| 1 | Horizon | 4.0 | 6.6 | 20.0 | 1.36 GB | 25 % | 2 | 1.3 | +| 1 | Vanilla | 18.1 | 27.1 | 20.0 | 3.32 GB | 73 % | 2 | 1.4 | +| 10 | Horizon | 13.5 | 24.8 | 20.0 | 1.64 GB | 89 % | 13 | 11.8 | +| 10 | Vanilla | 29.2 | 43.0 | 20.0 | 3.52 GB | 143 % | 13 | 11.9 | +| 25 | Horizon | 20.2 | 36.8 | 20.0 | 1.74 GB | 121 % | 32 | 28.9 | +| 25 | Vanilla | 37.0 | 49.4 | 20.0 | 3.73 GB | 169 % | 32 | 28.9 | + +### Explore as fast as each server sends chunks (not equal work) + +The default 22 blocks a second is faster than either server can generate, so each server sets the pace and +a faster server is given more work: from 10 bots Horizon generated 1.5 to 2 times vanilla's new chunks a +second. Read it as throughput, not as a like-for-like tick time. + +| Bots | Server | MSPT | P95 | TPS | RSS | CPU | Chunks/s | New chunks/s | +|---:|---|---:|---:|---:|---:|---:|---:|---:| +| 1 | Horizon | 10.6 | 24.6 | 20.0 | 1.34 GB | 322 % | 39 | 34.6 | +| 1 | Vanilla | 9.9 | 21.3 | 20.0 | 3.48 GB | 245 % | 36 | 33.4 | +| 10 | Horizon | 30.4 | 52.3 | 20.0 | 2.18 GB | 345 % | 47 | 46.7 | +| 10 | Vanilla | 17.0 | 29.6 | 20.0 | 4.04 GB | 242 % | 22 | 22.4 | +| 25 | Horizon | 50.7 | 89.3 | 19.7 | 2.30 GB | 286 % | 50 | 49.7 | +| 25 | Vanilla | 24.2 | 36.5 | 20.0 | 4.40 GB | 320 % | 34 | 33.7 | +| 50 | Horizon | 50.7 | 102.7 | 19.7 | 1.72 GB | 213 % | 97 | 96.5 | +| 50 | Vanilla | stopped by its watchdog | | | | | | | +| 100 | Horizon | 92.9 | 139.6 | 10.8 | 1.70 GB | 322 % | 114 | 113.6 | +| 100 | Vanilla | stopped by its watchdog | | | | | | | + +Vanilla's watchdog stopped it at 50 bots (in `ClientboundLightUpdatePacketData`, copying light for a chunk +packet) and at 100 (in `Entity.move` from a move packet). + +## Findings + +- Horizon's RSS is 1.9 to 2.6 times smaller in every run, and it starts in 11 s against vanilla's 15 s. + Vanilla's heap is fixed at 4 GB (`-Xms4g`), so part of its RSS is that setting rather than need. +- With 1 bot Horizon's tick is 3.3 times shorter walking and 4.5 times shorter exploring at equal work; + at 10 bots it is still shorter in both. +- At equal exploring work Horizon's tick is about half of vanilla's at 10 and 25 bots. +- When the bots only fly around spawn (`walk`), vanilla's tick hardly grows with players (16 to 20 ms from + 1 to 50 bots) while Horizon's grows by about 0.5 ms per bot, so vanilla is ahead from about 20 bots: 16.0 + against 20.7 ms at 25 and 20.5 against 31.4 ms at 50. Both keep 20 TPS up to 50. +- With 100 bots vanilla's watchdog stops it in every pass, and Horizon keeps running at 9 to 11 TPS. + +### Where Horizon's walk tick goes at 25 bots + +Profiled on the ThinkPad with `perf` (the server thread, 20 s, self time): + +| Share | What | Whose | +|---:|---|---| +| 18 % | `Streaming::update`: every tick it walks all loaded columns and proto chunks and tests each against every player's area (unloading, dropping protos, finishing and decoration candidates), so the cost grows with players times chunks | streaming / engine tick | +| 20 % | `PalettedContainer::get` and `World::block`, mostly from block entity ticks: on the Mac profile `tick_hoppers` looks up the block under every block entity in the loaded world each tick and compares names as strings, and spawners rebuild their display id | engine tick | +| 8 % | `light_data` when a chunk is sent: the light layers were built twice per chunk; a follow-up commit on #623 builds them once and keeps each section's packed layers | server (this work) | +| 7 % | encoding chunk sections and heightmaps for the chunks each bot newly sees | server | +| 4 % | `Hub::tick` (moves, tracking) | server | +| 2 % | the moved-wrongly collision check | server | + +The first two grow with the number of players but aren't specific to the server: they are the +engine's tick and streaming, reported to perf-server-2. Before #623 and #649, encoding every chunk section +once per player and waking each connection's writer thread once per packet cost more than everything above +(at 50 walking bots the tick was 281 ms; see the earlier runs). + +## Earlier runs (same machine and method) + +- `run-1-before-chunk-cache.json`: before #623, with vanilla's locator bar on. Horizon's walk tick was + 27.5, 40.7, 281 and 467 ms at 10, 25, 50 and 100 bots, and vanilla's 33.2 ms at 50 (the locator bar). +- `run-2-chunk-cache.json`: Horizon with #623 only: 13.2, 23.5, 31.4 and 84.4 ms walking at 10 to 100 + bots. + +## Files + +- `horizon.json`, `vanilla.json`: the walk and explore passes (vanilla with `locator_bar=false`). +- `explore-equal-rate.json`: the explore pass at 0.8 blocks a second, both servers. +- `run-1-before-chunk-cache.json`, `run-2-chunk-cache.json`: the earlier runs. + +To run it again (bots on another machine reached at `--connect`): + + cargo build --release -p horizon-client --bin horizon-client -p horizon-server -p horizon-bots + python3 tools/server_bench.py --servers horizon,vanilla --bots 1,10,25,50,100 --behaviour walk,explore \ + --gamerule locator_bar=false --nice 19 --bots-via 'ssh macmini' --bots-bin /path/to/horizon-bots \ + --connect --out results.json + python3 tools/server_bench.py --servers horizon,vanilla --bots 1,10,25 --behaviour explore \ + --explore-speed 0.8 --gamerule locator_bar=false --nice 19 ... --out explore-equal-rate.json diff --git a/benchmarks/server-equal-work-2026-10-17/explore-equal-rate.json b/benchmarks/server-equal-work-2026-10-17/explore-equal-rate.json new file mode 100644 index 000000000..bd097e43a --- /dev/null +++ b/benchmarks/server-equal-work-2026-10-17/explore-equal-rate.json @@ -0,0 +1,267 @@ +{ + "kind": "horizon-server-benchmark", + "date": "2026-10-17T05:47:12+00:00", + "machine": { + "system": "Linux", + "machine": "x86_64", + "cpus": 4 + }, + "commit": "", + "java": [ + "openjdk 25.0.4.1 2026-08-18" + ], + "settings": { + "servers": "horizon,vanilla", + "bots": "1,10,25", + "behaviour": "explore", + "join_every": 1.0, + "warmup": 60, + "seconds": 300, + "seed": 12345, + "explore_speed": 0.8, + "gamerule": "locator_bar=false", + "heap": "4g", + "out": "/home/ludovic/scratch/sh2-bench5.json", + "nice": 19, + "bots_via": "ssh -o BatchMode=yes macmini", + "bots_bin": "/tmp/sh2-horizon-bots", + "connect": "100.72.51.8", + "properties": { + "level-seed": "12345", + "view-distance": "10", + "simulation-distance": "10", + "online-mode": "false", + "white-list": "false", + "gamemode": "creative", + "force-gamemode": "true", + "spawn-protection": "0", + "max-players": "200", + "difficulty": "easy", + "sync-chunk-writes": "true" + } + }, + "runs": [ + { + "server": "horizon", + "bots": 1, + "behaviour": "explore", + "start_seconds": 10.91, + "mspt": { + "average": 4.02, + "p50": 3.75, + "p95": 6.63, + "p99": 14.55 + }, + "mspt_worst_p99": 63.3, + "tps": 20.0, + "rss_mb": { + "mean": 1392.42, + "max": 1746.1 + }, + "cpu_percent": 24.52, + "chunks_per_second": 1.69, + "unique_per_second": 1.34, + "forgotten_per_second": 1.62, + "waited_ticks_per_second": 0.0, + "chunks_total": 1048, + "chunks_per_bot": { + "explore": 1048.0 + }, + "unique_chunks_per_bot": { + "explore": 952.0 + }, + "resent": 0, + "pulled_back": 0, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 1, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "vanilla", + "bots": 1, + "behaviour": "explore", + "start_seconds": 15.13, + "mspt": { + "average": 18.12, + "p50": 18.78, + "p95": 27.07, + "p99": 60.81 + }, + "mspt_worst_p99": 1397.6, + "tps": 20.0, + "rss_mb": { + "mean": 3397.31, + "max": 3430.2 + }, + "cpu_percent": 72.99, + "chunks_per_second": 1.69, + "unique_per_second": 1.38, + "forgotten_per_second": 1.62, + "waited_ticks_per_second": 0.0, + "chunks_total": 1048, + "chunks_per_bot": { + "explore": 1048.0 + }, + "unique_chunks_per_bot": { + "explore": 952.0 + }, + "resent": 0, + "pulled_back": 0, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 1, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 10, + "behaviour": "explore", + "start_seconds": 11.12, + "mspt": { + "average": 13.49, + "p50": 12.09, + "p95": 24.81, + "p99": 40.9 + }, + "mspt_worst_p99": 101.4, + "tps": 20.0, + "rss_mb": { + "mean": 1684.04, + "max": 2660.6 + }, + "cpu_percent": 89.04, + "chunks_per_second": 13.08, + "unique_per_second": 11.84, + "forgotten_per_second": 13.02, + "waited_ticks_per_second": 0.0, + "chunks_total": 9277, + "chunks_per_bot": { + "explore": 927.7 + }, + "unique_chunks_per_bot": { + "explore": 889.9 + }, + "resent": 0, + "pulled_back": 3381, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 10, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "vanilla", + "bots": 10, + "behaviour": "explore", + "start_seconds": 14.84, + "mspt": { + "average": 29.18, + "p50": 26.76, + "p95": 43.02, + "p99": 80.16 + }, + "mspt_worst_p99": 425.5, + "tps": 20.0, + "rss_mb": { + "mean": 3609.51, + "max": 3791.6 + }, + "cpu_percent": 142.79, + "chunks_per_second": 13.1, + "unique_per_second": 11.88, + "forgotten_per_second": 13.12, + "waited_ticks_per_second": 0.0, + "chunks_total": 9245, + "chunks_per_bot": { + "explore": 924.5 + }, + "unique_chunks_per_bot": { + "explore": 885.3 + }, + "resent": 0, + "pulled_back": 3388, + "moved_too_quickly": 6, + "moved_wrongly": 0, + "bots_joined": 10, + "bots_failed": [], + "tick_queries": 59 + }, + { + "server": "horizon", + "bots": 25, + "behaviour": "explore", + "start_seconds": 11.02, + "mspt": { + "average": 20.25, + "p50": 18.73, + "p95": 36.78, + "p99": 55.76 + }, + "mspt_worst_p99": 92.3, + "tps": 20.0, + "rss_mb": { + "mean": 1782.26, + "max": 2741.9 + }, + "cpu_percent": 120.8, + "chunks_per_second": 32.22, + "unique_per_second": 28.89, + "forgotten_per_second": 32.19, + "waited_ticks_per_second": 0.0, + "chunks_total": 23357, + "chunks_per_bot": { + "explore": 934.28 + }, + "unique_chunks_per_bot": { + "explore": 890.32 + }, + "resent": 0, + "pulled_back": 11580, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 25, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "vanilla", + "bots": 25, + "behaviour": "explore", + "start_seconds": 14.54, + "mspt": { + "average": 37.04, + "p50": 34.77, + "p95": 49.42, + "p99": 101.67 + }, + "mspt_worst_p99": 735.2, + "tps": 20.0, + "rss_mb": { + "mean": 3815.46, + "max": 4202.0 + }, + "cpu_percent": 169.15, + "chunks_per_second": 32.34, + "unique_per_second": 28.93, + "forgotten_per_second": 32.31, + "waited_ticks_per_second": 0.0, + "chunks_total": 23306, + "chunks_per_bot": { + "explore": 932.24 + }, + "unique_chunks_per_bot": { + "explore": 886.96 + }, + "resent": 0, + "pulled_back": 11539, + "moved_too_quickly": 6, + "moved_wrongly": 0, + "bots_joined": 25, + "bots_failed": [], + "tick_queries": 59 + } + ] +} diff --git a/benchmarks/server-equal-work-2026-10-17/horizon.json b/benchmarks/server-equal-work-2026-10-17/horizon.json new file mode 100644 index 000000000..d0b162c44 --- /dev/null +++ b/benchmarks/server-equal-work-2026-10-17/horizon.json @@ -0,0 +1,415 @@ +{ + "kind": "horizon-server-benchmark", + "date": "2026-10-17T05:04:57+00:00", + "machine": { + "system": "Linux", + "machine": "x86_64", + "cpus": 4 + }, + "commit": "", + "java": [ + "openjdk 25.0.4.1 2026-08-18" + ], + "settings": { + "servers": "horizon", + "bots": "1,10,25,50,100", + "behaviour": "walk,explore", + "join_every": 1.0, + "warmup": 60, + "seconds": 300, + "seed": 12345, + "explore_speed": 0, + "gamerule": "locator_bar=false", + "heap": "4g", + "out": "/home/ludovic/scratch/sh2-bench4.json", + "nice": 19, + "bots_via": "ssh -o BatchMode=yes macmini", + "bots_bin": "/tmp/sh2-horizon-bots", + "connect": "100.72.51.8", + "properties": { + "level-seed": "12345", + "view-distance": "10", + "simulation-distance": "10", + "online-mode": "false", + "white-list": "false", + "gamemode": "creative", + "force-gamemode": "true", + "spawn-protection": "0", + "max-players": "200", + "difficulty": "easy", + "sync-chunk-writes": "true" + } + }, + "runs": [ + { + "server": "horizon", + "bots": 1, + "behaviour": "walk", + "start_seconds": 11.02, + "mspt": { + "average": 5.45, + "p50": 4.63, + "p95": 12.14, + "p99": 30.63 + }, + "mspt_worst_p99": 91.5, + "tps": 20.0, + "rss_mb": { + "mean": 1398.5, + "max": 1708.4 + }, + "cpu_percent": 57.03, + "chunks_per_second": 9.17, + "unique_per_second": 0.26, + "forgotten_per_second": 9.2, + "waited_ticks_per_second": 0.0, + "chunks_total": 3708, + "chunks_per_bot": { + "walk": 3708.0 + }, + "unique_chunks_per_bot": { + "walk": 694.0 + }, + "resent": 0, + "pulled_back": 0, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 1, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 10, + "behaviour": "walk", + "start_seconds": 11.02, + "mspt": { + "average": 11.51, + "p50": 10.6, + "p95": 22.13, + "p99": 36.92 + }, + "mspt_worst_p99": 71.9, + "tps": 20.0, + "rss_mb": { + "mean": 1440.2, + "max": 1832.4 + }, + "cpu_percent": 124.19, + "chunks_per_second": 94.04, + "unique_per_second": 2.38, + "forgotten_per_second": 93.95, + "waited_ticks_per_second": 0.0, + "chunks_total": 37269, + "chunks_per_bot": { + "walk": 3726.9 + }, + "unique_chunks_per_bot": { + "walk": 688.9 + }, + "resent": 0, + "pulled_back": 0, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 10, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 25, + "behaviour": "walk", + "start_seconds": 10.91, + "mspt": { + "average": 20.74, + "p50": 19.27, + "p95": 40.42, + "p99": 57.12 + }, + "mspt_worst_p99": 102.0, + "tps": 20.0, + "rss_mb": { + "mean": 1485.4, + "max": 1871.9 + }, + "cpu_percent": 169.52, + "chunks_per_second": 235.81, + "unique_per_second": 5.68, + "forgotten_per_second": 235.83, + "waited_ticks_per_second": 0.0, + "chunks_total": 94456, + "chunks_per_bot": { + "walk": 3778.24 + }, + "unique_chunks_per_bot": { + "walk": 692.96 + }, + "resent": 0, + "pulled_back": 0, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 25, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 50, + "behaviour": "walk", + "start_seconds": 11.12, + "mspt": { + "average": 31.41, + "p50": 25.39, + "p95": 78.97, + "p99": 110.73 + }, + "mspt_worst_p99": 192.9, + "tps": 20.0, + "rss_mb": { + "mean": 1558.34, + "max": 1915.3 + }, + "cpu_percent": 173.27, + "chunks_per_second": 450.85, + "unique_per_second": 10.3, + "forgotten_per_second": 451.86, + "waited_ticks_per_second": 0.0, + "chunks_total": 186885, + "chunks_per_bot": { + "walk": 3737.7 + }, + "unique_chunks_per_bot": { + "walk": 688.9 + }, + "resent": 0, + "pulled_back": 0, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 50, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 100, + "behaviour": "walk", + "start_seconds": 18.16, + "mspt": { + "average": 114.55, + "p50": 101.37, + "p95": 256.19, + "p99": 338.64 + }, + "mspt_worst_p99": 442.6, + "tps": 8.73, + "rss_mb": { + "mean": 1357.65, + "max": 1358.4 + }, + "cpu_percent": 181.21, + "chunks_per_second": 484.16, + "unique_per_second": 30.76, + "forgotten_per_second": 478.77, + "waited_ticks_per_second": 0.0, + "chunks_total": 212947, + "chunks_per_bot": { + "walk": 2129.47 + }, + "unique_chunks_per_bot": { + "walk": 583.02 + }, + "resent": 0, + "pulled_back": 0, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 100, + "bots_failed": [], + "tick_queries": 57 + }, + { + "server": "horizon", + "bots": 1, + "behaviour": "explore", + "start_seconds": 17.45, + "mspt": { + "average": 10.62, + "p50": 9.2, + "p95": 24.61, + "p99": 38.18 + }, + "mspt_worst_p99": 81.1, + "tps": 20.0, + "rss_mb": { + "mean": 1372.73, + "max": 1568.7 + }, + "cpu_percent": 322.38, + "chunks_per_second": 39.21, + "unique_per_second": 34.6, + "forgotten_per_second": 38.55, + "waited_ticks_per_second": 0.51, + "chunks_total": 12549, + "chunks_per_bot": { + "explore": 12549.0 + }, + "unique_chunks_per_bot": { + "explore": 11168.0 + }, + "resent": 0, + "pulled_back": 0, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 1, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 10, + "behaviour": "explore", + "start_seconds": 11.32, + "mspt": { + "average": 30.38, + "p50": 27.84, + "p95": 52.31, + "p99": 77.89 + }, + "mspt_worst_p99": 158.3, + "tps": 20.0, + "rss_mb": { + "mean": 2231.56, + "max": 4022.6 + }, + "cpu_percent": 344.99, + "chunks_per_second": 47.34, + "unique_per_second": 46.73, + "forgotten_per_second": 44.57, + "waited_ticks_per_second": 112.27, + "chunks_total": 19870, + "chunks_per_bot": { + "explore": 1987.0 + }, + "unique_chunks_per_bot": { + "explore": 1966.5 + }, + "resent": 0, + "pulled_back": 16130, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 10, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 25, + "behaviour": "explore", + "start_seconds": 11.42, + "mspt": { + "average": 50.67, + "p50": 47.61, + "p95": 89.3, + "p99": 119.15 + }, + "mspt_worst_p99": 222.3, + "tps": 19.73, + "rss_mb": { + "mean": 2358.4, + "max": 4273.7 + }, + "cpu_percent": 286.32, + "chunks_per_second": 50.39, + "unique_per_second": 49.67, + "forgotten_per_second": 47.45, + "waited_ticks_per_second": 311.85, + "chunks_total": 28954, + "chunks_per_bot": { + "explore": 1158.16 + }, + "unique_chunks_per_bot": { + "explore": 1147.56 + }, + "resent": 0, + "pulled_back": 36319, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 25, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 50, + "behaviour": "explore", + "start_seconds": 20.06, + "mspt": { + "average": 50.69, + "p50": 47.48, + "p95": 102.67, + "p99": 133.27 + }, + "mspt_worst_p99": 274.4, + "tps": 19.73, + "rss_mb": { + "mean": 1764.01, + "max": 2542.0 + }, + "cpu_percent": 212.83, + "chunks_per_second": 97.33, + "unique_per_second": 96.47, + "forgotten_per_second": 76.74, + "waited_ticks_per_second": 750.49, + "chunks_total": 38538, + "chunks_per_bot": { + "explore": 770.76 + }, + "unique_chunks_per_bot": { + "explore": 763.84 + }, + "resent": 0, + "pulled_back": 39880, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 50, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 100, + "behaviour": "explore", + "start_seconds": 11.82, + "mspt": { + "average": 92.93, + "p50": 87.84, + "p95": 139.6, + "p99": 178.38 + }, + "mspt_worst_p99": 272.9, + "tps": 10.76, + "rss_mb": { + "mean": 1736.38, + "max": 1855.7 + }, + "cpu_percent": 321.53, + "chunks_per_second": 113.89, + "unique_per_second": 113.56, + "forgotten_per_second": 101.68, + "waited_ticks_per_second": 1179.4, + "chunks_total": 84617, + "chunks_per_bot": { + "explore": 846.17 + }, + "unique_chunks_per_bot": { + "explore": 845.01 + }, + "resent": 0, + "pulled_back": 128104, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 100, + "bots_failed": [], + "tick_queries": 58 + } + ] +} diff --git a/benchmarks/server-equal-work-2026-10-17/run-1-before-chunk-cache.json b/benchmarks/server-equal-work-2026-10-17/run-1-before-chunk-cache.json new file mode 100644 index 000000000..d79b3f179 --- /dev/null +++ b/benchmarks/server-equal-work-2026-10-17/run-1-before-chunk-cache.json @@ -0,0 +1,693 @@ +{ + "kind": "horizon-server-benchmark", + "date": "2026-10-17T00:37:35+00:00", + "machine": { + "system": "Linux", + "machine": "x86_64", + "cpus": 4 + }, + "commit": "", + "java": [ + "openjdk 25.0.4.1 2026-08-18" + ], + "settings": { + "servers": "horizon,vanilla", + "bots": "1,10,25,50,100", + "behaviour": "walk,explore", + "join_every": 1.0, + "warmup": 60, + "seconds": 300, + "seed": 12345, + "heap": "4g", + "out": "/home/ludovic/scratch/sh2-bench.json", + "nice": 19, + "bots_via": "ssh -o BatchMode=yes macmini", + "bots_bin": "/tmp/sh2-horizon-bots", + "connect": "100.72.51.8", + "properties": { + "level-seed": "12345", + "view-distance": "10", + "simulation-distance": "10", + "online-mode": "false", + "white-list": "false", + "gamemode": "creative", + "force-gamemode": "true", + "spawn-protection": "0", + "max-players": "200", + "difficulty": "easy", + "sync-chunk-writes": "true" + } + }, + "runs": [ + { + "server": "horizon", + "bots": 1, + "behaviour": "walk", + "start_seconds": 11.12, + "mspt": { + "average": 7.63, + "p50": 5.51, + "p95": 17.46, + "p99": 36.79 + }, + "mspt_worst_p99": 79.6, + "tps": 20.0, + "rss_mb": { + "mean": 1391.94, + "max": 1700.7 + }, + "cpu_percent": 60.18, + "chunks_per_second": 9.15, + "unique_per_second": 0.29, + "forgotten_per_second": 9.18, + "waited_ticks_per_second": 0.0, + "chunks_total": 3682, + "chunks_per_bot": { + "walk": 3682.0 + }, + "unique_chunks_per_bot": { + "walk": 692.0 + }, + "resent": 0, + "pulled_back": 0, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 1, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "vanilla", + "bots": 1, + "behaviour": "walk", + "start_seconds": 15.35, + "mspt": { + "average": 18.08, + "p50": 18.66, + "p95": 28.05, + "p99": 46.94 + }, + "mspt_worst_p99": 456.4, + "tps": 20.0, + "rss_mb": { + "mean": 3455.72, + "max": 3471.7 + }, + "cpu_percent": 65.67, + "chunks_per_second": 9.89, + "unique_per_second": 0.29, + "forgotten_per_second": 9.92, + "waited_ticks_per_second": 0.0, + "chunks_total": 3827, + "chunks_per_bot": { + "walk": 3827.0 + }, + "unique_chunks_per_bot": { + "walk": 699.0 + }, + "resent": 0, + "pulled_back": 0, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 1, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 10, + "behaviour": "walk", + "start_seconds": 11.22, + "mspt": { + "average": 27.53, + "p50": 24.93, + "p95": 53.93, + "p99": 77.03 + }, + "mspt_worst_p99": 111.4, + "tps": 20.0, + "rss_mb": { + "mean": 1433.85, + "max": 1831.9 + }, + "cpu_percent": 138.65, + "chunks_per_second": 95.98, + "unique_per_second": 2.38, + "forgotten_per_second": 95.78, + "waited_ticks_per_second": 0.0, + "chunks_total": 38041, + "chunks_per_bot": { + "walk": 3804.1 + }, + "unique_chunks_per_bot": { + "walk": 690.8 + }, + "resent": 0, + "pulled_back": 0, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 10, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "vanilla", + "bots": 10, + "behaviour": "walk", + "start_seconds": 14.83, + "mspt": { + "average": 18.92, + "p50": 16.02, + "p95": 39.0, + "p99": 56.13 + }, + "mspt_worst_p99": 180.3, + "tps": 20.0, + "rss_mb": { + "mean": 3479.45, + "max": 3535.7 + }, + "cpu_percent": 99.39, + "chunks_per_second": 99.93, + "unique_per_second": 2.94, + "forgotten_per_second": 100.04, + "waited_ticks_per_second": 0.0, + "chunks_total": 38411, + "chunks_per_bot": { + "walk": 3841.1 + }, + "unique_chunks_per_bot": { + "walk": 697.1 + }, + "resent": 0, + "pulled_back": 7, + "moved_too_quickly": 7, + "moved_wrongly": 0, + "bots_joined": 10, + "bots_failed": [], + "tick_queries": 59 + }, + { + "server": "horizon", + "bots": 25, + "behaviour": "walk", + "start_seconds": 11.52, + "mspt": { + "average": 40.69, + "p50": 37.28, + "p95": 81.16, + "p99": 108.42 + }, + "mspt_worst_p99": 149.9, + "tps": 20.0, + "rss_mb": { + "mean": 1444.12, + "max": 1882.6 + }, + "cpu_percent": 176.94, + "chunks_per_second": 244.11, + "unique_per_second": 6.51, + "forgotten_per_second": 243.96, + "waited_ticks_per_second": 0.0, + "chunks_total": 95219, + "chunks_per_bot": { + "walk": 3808.76 + }, + "unique_chunks_per_bot": { + "walk": 695.64 + }, + "resent": 0, + "pulled_back": 0, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 25, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "vanilla", + "bots": 25, + "behaviour": "walk", + "start_seconds": 15.23, + "mspt": { + "average": 18.81, + "p50": 17.26, + "p95": 30.16, + "p99": 53.9 + }, + "mspt_worst_p99": 390.0, + "tps": 20.0, + "rss_mb": { + "mean": 3517.36, + "max": 3555.7 + }, + "cpu_percent": 148.52, + "chunks_per_second": 252.34, + "unique_per_second": 7.75, + "forgotten_per_second": 251.95, + "waited_ticks_per_second": 0.0, + "chunks_total": 96645, + "chunks_per_bot": { + "walk": 3865.8 + }, + "unique_chunks_per_bot": { + "walk": 698.4 + }, + "resent": 0, + "pulled_back": 11, + "moved_too_quickly": 11, + "moved_wrongly": 0, + "bots_joined": 25, + "bots_failed": [], + "tick_queries": 59 + }, + { + "server": "horizon", + "bots": 50, + "behaviour": "walk", + "start_seconds": 12.72, + "mspt": { + "average": 281.42, + "p50": 278.28, + "p95": 343.64, + "p99": 384.23 + }, + "mspt_worst_p99": 475.1, + "tps": 3.55, + "rss_mb": { + "mean": 1334.32, + "max": 1335.4 + }, + "cpu_percent": 197.11, + "chunks_per_second": 391.5, + "unique_per_second": 21.24, + "forgotten_per_second": 388.33, + "waited_ticks_per_second": 0.0, + "chunks_total": 153136, + "chunks_per_bot": { + "walk": 3062.72 + }, + "unique_chunks_per_bot": { + "walk": 661.82 + }, + "resent": 0, + "pulled_back": 0, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 50, + "bots_failed": [], + "tick_queries": 57 + }, + { + "server": "vanilla", + "bots": 50, + "behaviour": "walk", + "start_seconds": 15.74, + "mspt": { + "average": 33.21, + "p50": 29.9, + "p95": 53.37, + "p99": 109.14 + }, + "mspt_worst_p99": 833.3, + "tps": 20.0, + "rss_mb": { + "mean": 3568.07, + "max": 3594.9 + }, + "cpu_percent": 266.59, + "chunks_per_second": 496.3, + "unique_per_second": 18.64, + "forgotten_per_second": 495.77, + "waited_ticks_per_second": 0.0, + "chunks_total": 191965, + "chunks_per_bot": { + "walk": 3839.3 + }, + "unique_chunks_per_bot": { + "walk": 696.22 + }, + "resent": 0, + "pulled_back": 184, + "moved_too_quickly": 183, + "moved_wrongly": 0, + "bots_joined": 50, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 100, + "behaviour": "walk", + "start_seconds": 11.22, + "mspt": { + "average": 467.37, + "p50": 464.62, + "p95": 524.07, + "p99": 548.77 + }, + "mspt_worst_p99": 581.1, + "tps": 2.14, + "rss_mb": { + "mean": 1353.68, + "max": 1354.9 + }, + "cpu_percent": 191.96, + "chunks_per_second": 377.62, + "unique_per_second": 55.8, + "forgotten_per_second": 348.88, + "waited_ticks_per_second": 0.0, + "chunks_total": 165719, + "chunks_per_bot": { + "walk": 1657.19 + }, + "unique_chunks_per_bot": { + "walk": 549.8 + }, + "resent": 0, + "pulled_back": 0, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 100, + "bots_failed": [], + "tick_queries": 56 + }, + { + "server": "vanilla", + "bots": 100, + "behaviour": "walk", + "start_seconds": 14.84, + "error": "[Errno 32] Broken pipe" + }, + { + "server": "horizon", + "bots": 1, + "behaviour": "explore", + "start_seconds": 11.12, + "mspt": { + "average": 17.07, + "p50": 16.6, + "p95": 32.82, + "p99": 50.3 + }, + "mspt_worst_p99": 113.9, + "tps": 20.0, + "rss_mb": { + "mean": 1391.72, + "max": 1566.6 + }, + "cpu_percent": 333.8, + "chunks_per_second": 39.42, + "unique_per_second": 34.77, + "forgotten_per_second": 39.34, + "waited_ticks_per_second": 0.0, + "chunks_total": 13860, + "chunks_per_bot": { + "explore": 13860.0 + }, + "unique_chunks_per_bot": { + "explore": 12379.0 + }, + "resent": 0, + "pulled_back": 192, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 1, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "vanilla", + "bots": 1, + "behaviour": "explore", + "start_seconds": 16.03, + "mspt": { + "average": 9.9, + "p50": 8.04, + "p95": 20.41, + "p99": 63.62 + }, + "mspt_worst_p99": 351.1, + "tps": 20.0, + "rss_mb": { + "mean": 3709.92, + "max": 3776.2 + }, + "cpu_percent": 246.64, + "chunks_per_second": 36.06, + "unique_per_second": 33.33, + "forgotten_per_second": 35.73, + "waited_ticks_per_second": 0.0, + "chunks_total": 12369, + "chunks_per_bot": { + "explore": 12369.0 + }, + "unique_chunks_per_bot": { + "explore": 11559.0 + }, + "resent": 0, + "pulled_back": 222, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 1, + "bots_failed": [], + "tick_queries": 59 + }, + { + "server": "horizon", + "bots": 10, + "behaviour": "explore", + "start_seconds": 11.42, + "mspt": { + "average": 33.72, + "p50": 31.08, + "p95": 61.07, + "p99": 89.09 + }, + "mspt_worst_p99": 142.6, + "tps": 20.0, + "rss_mb": { + "mean": 2239.97, + "max": 4108.8 + }, + "cpu_percent": 350.83, + "chunks_per_second": 46.49, + "unique_per_second": 46.08, + "forgotten_per_second": 44.33, + "waited_ticks_per_second": 111.28, + "chunks_total": 19915, + "chunks_per_bot": { + "explore": 1991.5 + }, + "unique_chunks_per_bot": { + "explore": 1974.4 + }, + "resent": 0, + "pulled_back": 17516, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 10, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "vanilla", + "bots": 10, + "behaviour": "explore", + "start_seconds": 15.15, + "mspt": { + "average": 16.25, + "p50": 13.67, + "p95": 28.92, + "p99": 115.9 + }, + "mspt_worst_p99": 1202.1, + "tps": 20.0, + "rss_mb": { + "mean": 4202.72, + "max": 4502.7 + }, + "cpu_percent": 247.47, + "chunks_per_second": 22.3, + "unique_per_second": 22.25, + "forgotten_per_second": 22.64, + "waited_ticks_per_second": 78.89, + "chunks_total": 10092, + "chunks_per_bot": { + "explore": 1009.2 + }, + "unique_chunks_per_bot": { + "explore": 1007.4 + }, + "resent": 0, + "pulled_back": 16286, + "moved_too_quickly": 12, + "moved_wrongly": 0, + "bots_joined": 10, + "bots_failed": [], + "tick_queries": 59 + }, + { + "server": "horizon", + "bots": 25, + "behaviour": "explore", + "start_seconds": 11.82, + "mspt": { + "average": 48.03, + "p50": 44.99, + "p95": 83.06, + "p99": 109.63 + }, + "mspt_worst_p99": 191.8, + "tps": 20.0, + "rss_mb": { + "mean": 2214.16, + "max": 4598.6 + }, + "cpu_percent": 356.03, + "chunks_per_second": 64.2, + "unique_per_second": 63.5, + "forgotten_per_second": 60.9, + "waited_ticks_per_second": 301.65, + "chunks_total": 32247, + "chunks_per_bot": { + "explore": 1289.88 + }, + "unique_chunks_per_bot": { + "explore": 1279.4 + }, + "resent": 0, + "pulled_back": 42159, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 25, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "vanilla", + "bots": 25, + "behaviour": "explore", + "start_seconds": 14.73, + "mspt": { + "average": 25.73, + "p50": 22.75, + "p95": 37.72, + "p99": 201.98 + }, + "mspt_worst_p99": 4051.5, + "tps": 20.0, + "rss_mb": { + "mean": 4355.9, + "max": 4983.2 + }, + "cpu_percent": 309.3, + "chunks_per_second": 33.33, + "unique_per_second": 33.21, + "forgotten_per_second": 34.22, + "waited_ticks_per_second": 258.68, + "chunks_total": 19426, + "chunks_per_bot": { + "explore": 777.04 + }, + "unique_chunks_per_bot": { + "explore": 772.92 + }, + "resent": 0, + "pulled_back": 53918, + "moved_too_quickly": 93, + "moved_wrongly": 0, + "bots_joined": 25, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 50, + "behaviour": "explore", + "start_seconds": 11.01, + "mspt": { + "average": 108.61, + "p50": 107.37, + "p95": 151.96, + "p99": 177.41 + }, + "mspt_worst_p99": 271.6, + "tps": 9.21, + "rss_mb": { + "mean": 1775.98, + "max": 1956.9 + }, + "cpu_percent": 359.7, + "chunks_per_second": 100.32, + "unique_per_second": 98.91, + "forgotten_per_second": 95.42, + "waited_ticks_per_second": 518.15, + "chunks_total": 55956, + "chunks_per_bot": { + "explore": 1119.12 + }, + "unique_chunks_per_bot": { + "explore": 1111.42 + }, + "resent": 0, + "pulled_back": 69967, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 50, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "vanilla", + "bots": 50, + "behaviour": "explore", + "start_seconds": 14.74, + "error": "[Errno 32] Broken pipe" + }, + { + "server": "horizon", + "bots": 100, + "behaviour": "explore", + "start_seconds": 11.12, + "mspt": { + "average": 244.43, + "p50": 243.58, + "p95": 308.39, + "p99": 360.12 + }, + "mspt_worst_p99": 470.2, + "tps": 4.09, + "rss_mb": { + "mean": 1806.85, + "max": 2084.5 + }, + "cpu_percent": 310.46, + "chunks_per_second": 186.87, + "unique_per_second": 182.99, + "forgotten_per_second": 161.75, + "waited_ticks_per_second": 1131.65, + "chunks_total": 100389, + "chunks_per_bot": { + "explore": 1003.89 + }, + "unique_chunks_per_bot": { + "explore": 991.9 + }, + "resent": 0, + "pulled_back": 56791, + "moved_too_quickly": 344, + "moved_wrongly": 0, + "bots_joined": 100, + "bots_failed": [], + "tick_queries": 57 + }, + { + "server": "vanilla", + "bots": 100, + "behaviour": "explore", + "start_seconds": 14.83, + "error": "[Errno 32] Broken pipe" + } + ] +} diff --git a/benchmarks/server-equal-work-2026-10-17/run-2-chunk-cache.json b/benchmarks/server-equal-work-2026-10-17/run-2-chunk-cache.json new file mode 100644 index 000000000..4c182395c --- /dev/null +++ b/benchmarks/server-equal-work-2026-10-17/run-2-chunk-cache.json @@ -0,0 +1,413 @@ +{ + "kind": "horizon-server-benchmark", + "date": "2026-10-17T02:22:59+00:00", + "machine": { + "system": "Linux", + "machine": "x86_64", + "cpus": 4 + }, + "commit": "", + "java": [ + "openjdk 25.0.4.1 2026-08-18" + ], + "settings": { + "servers": "horizon", + "bots": "1,10,25,50,100", + "behaviour": "walk,explore", + "join_every": 1.0, + "warmup": 60, + "seconds": 300, + "seed": 12345, + "heap": "4g", + "out": "/home/ludovic/scratch/sh2-bench2.json", + "nice": 19, + "bots_via": "ssh -o BatchMode=yes macmini", + "bots_bin": "/tmp/sh2-horizon-bots", + "connect": "100.72.51.8", + "properties": { + "level-seed": "12345", + "view-distance": "10", + "simulation-distance": "10", + "online-mode": "false", + "white-list": "false", + "gamemode": "creative", + "force-gamemode": "true", + "spawn-protection": "0", + "max-players": "200", + "difficulty": "easy", + "sync-chunk-writes": "true" + } + }, + "runs": [ + { + "server": "horizon", + "bots": 1, + "behaviour": "walk", + "start_seconds": 11.12, + "mspt": { + "average": 5.41, + "p50": 4.63, + "p95": 11.61, + "p99": 30.5 + }, + "mspt_worst_p99": 64.6, + "tps": 20.0, + "rss_mb": { + "mean": 1398.84, + "max": 1708.5 + }, + "cpu_percent": 56.84, + "chunks_per_second": 9.19, + "unique_per_second": 0.27, + "forgotten_per_second": 9.22, + "waited_ticks_per_second": 0.0, + "chunks_total": 3713, + "chunks_per_bot": { + "walk": 3713.0 + }, + "unique_chunks_per_bot": { + "walk": 694.0 + }, + "resent": 0, + "pulled_back": 0, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 1, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 10, + "behaviour": "walk", + "start_seconds": 10.92, + "mspt": { + "average": 13.18, + "p50": 11.74, + "p95": 26.5, + "p99": 44.82 + }, + "mspt_worst_p99": 87.6, + "tps": 20.0, + "rss_mb": { + "mean": 1439.83, + "max": 1831.6 + }, + "cpu_percent": 132.94, + "chunks_per_second": 93.87, + "unique_per_second": 2.38, + "forgotten_per_second": 93.77, + "waited_ticks_per_second": 0.0, + "chunks_total": 37240, + "chunks_per_bot": { + "walk": 3724.0 + }, + "unique_chunks_per_bot": { + "walk": 688.7 + }, + "resent": 0, + "pulled_back": 0, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 10, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 25, + "behaviour": "walk", + "start_seconds": 10.92, + "mspt": { + "average": 23.52, + "p50": 21.92, + "p95": 44.7, + "p99": 63.12 + }, + "mspt_worst_p99": 119.1, + "tps": 20.0, + "rss_mb": { + "mean": 1485.82, + "max": 1872.0 + }, + "cpu_percent": 173.82, + "chunks_per_second": 235.63, + "unique_per_second": 5.64, + "forgotten_per_second": 235.72, + "waited_ticks_per_second": 0.0, + "chunks_total": 94549, + "chunks_per_bot": { + "walk": 3781.96 + }, + "unique_chunks_per_bot": { + "walk": 692.8 + }, + "resent": 0, + "pulled_back": 0, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 25, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 50, + "behaviour": "walk", + "start_seconds": 10.91, + "mspt": { + "average": 31.36, + "p50": 26.08, + "p95": 70.6, + "p99": 92.83 + }, + "mspt_worst_p99": 125.5, + "tps": 20.0, + "rss_mb": { + "mean": 1556.81, + "max": 1913.0 + }, + "cpu_percent": 192.16, + "chunks_per_second": 468.69, + "unique_per_second": 10.43, + "forgotten_per_second": 468.78, + "waited_ticks_per_second": 0.0, + "chunks_total": 193547, + "chunks_per_bot": { + "walk": 3870.94 + }, + "unique_chunks_per_bot": { + "walk": 692.84 + }, + "resent": 0, + "pulled_back": 0, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 50, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 100, + "behaviour": "walk", + "start_seconds": 11.02, + "mspt": { + "average": 84.38, + "p50": 81.5, + "p95": 132.92, + "p99": 168.38 + }, + "mspt_worst_p99": 245.5, + "tps": 11.85, + "rss_mb": { + "mean": 1395.64, + "max": 1941.3 + }, + "cpu_percent": 271.28, + "chunks_per_second": 850.05, + "unique_per_second": 21.79, + "forgotten_per_second": 851.1, + "waited_ticks_per_second": 0.0, + "chunks_total": 368464, + "chunks_per_bot": { + "walk": 3684.64 + }, + "unique_chunks_per_bot": { + "walk": 680.58 + }, + "resent": 0, + "pulled_back": 0, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 100, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 1, + "behaviour": "explore", + "start_seconds": 11.02, + "mspt": { + "average": 11.63, + "p50": 10.27, + "p95": 24.52, + "p99": 40.53 + }, + "mspt_worst_p99": 99.9, + "tps": 20.0, + "rss_mb": { + "mean": 1413.81, + "max": 1596.1 + }, + "cpu_percent": 329.84, + "chunks_per_second": 39.47, + "unique_per_second": 34.81, + "forgotten_per_second": 39.42, + "waited_ticks_per_second": 0.0, + "chunks_total": 13798, + "chunks_per_bot": { + "explore": 13798.0 + }, + "unique_chunks_per_bot": { + "explore": 12330.0 + }, + "resent": 0, + "pulled_back": 185, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 1, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 10, + "behaviour": "explore", + "start_seconds": 11.02, + "mspt": { + "average": 28.49, + "p50": 26.2, + "p95": 48.58, + "p99": 72.04 + }, + "mspt_worst_p99": 125.7, + "tps": 20.0, + "rss_mb": { + "mean": 2231.06, + "max": 4077.8 + }, + "cpu_percent": 344.95, + "chunks_per_second": 46.31, + "unique_per_second": 45.88, + "forgotten_per_second": 44.14, + "waited_ticks_per_second": 111.95, + "chunks_total": 19722, + "chunks_per_bot": { + "explore": 1972.2 + }, + "unique_chunks_per_bot": { + "explore": 1957.0 + }, + "resent": 0, + "pulled_back": 18533, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 10, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 25, + "behaviour": "explore", + "start_seconds": 11.12, + "mspt": { + "average": 40.04, + "p50": 37.61, + "p95": 62.39, + "p99": 83.64 + }, + "mspt_worst_p99": 147.0, + "tps": 20.0, + "rss_mb": { + "mean": 2466.85, + "max": 4504.5 + }, + "cpu_percent": 340.13, + "chunks_per_second": 60.61, + "unique_per_second": 59.62, + "forgotten_per_second": 56.28, + "waited_ticks_per_second": 299.57, + "chunks_total": 32877, + "chunks_per_bot": { + "explore": 1315.08 + }, + "unique_chunks_per_bot": { + "explore": 1300.72 + }, + "resent": 0, + "pulled_back": 46272, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 25, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 50, + "behaviour": "explore", + "start_seconds": 11.32, + "mspt": { + "average": 61.82, + "p50": 59.21, + "p95": 90.46, + "p99": 115.58 + }, + "mspt_worst_p99": 320.9, + "tps": 16.18, + "rss_mb": { + "mean": 2634.54, + "max": 4883.1 + }, + "cpu_percent": 356.96, + "chunks_per_second": 95.26, + "unique_per_second": 94.31, + "forgotten_per_second": 85.86, + "waited_ticks_per_second": 528.08, + "chunks_total": 59251, + "chunks_per_bot": { + "explore": 1185.02 + }, + "unique_chunks_per_bot": { + "explore": 1173.96 + }, + "resent": 0, + "pulled_back": 99493, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 50, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "horizon", + "bots": 100, + "behaviour": "explore", + "start_seconds": 10.81, + "mspt": { + "average": 103.24, + "p50": 100.03, + "p95": 144.77, + "p99": 178.8 + }, + "mspt_worst_p99": 258.6, + "tps": 9.69, + "rss_mb": { + "mean": 1784.48, + "max": 1922.0 + }, + "cpu_percent": 336.94, + "chunks_per_second": 115.06, + "unique_per_second": 114.86, + "forgotten_per_second": 109.43, + "waited_ticks_per_second": 1127.57, + "chunks_total": 93839, + "chunks_per_bot": { + "explore": 938.39 + }, + "unique_chunks_per_bot": { + "explore": 935.28 + }, + "resent": 0, + "pulled_back": 133404, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 100, + "bots_failed": [], + "tick_queries": 58 + } + ] +} diff --git a/benchmarks/server-equal-work-2026-10-17/vanilla.json b/benchmarks/server-equal-work-2026-10-17/vanilla.json new file mode 100644 index 000000000..014554cee --- /dev/null +++ b/benchmarks/server-equal-work-2026-10-17/vanilla.json @@ -0,0 +1,324 @@ +{ + "kind": "horizon-server-benchmark", + "date": "2026-10-17T03:29:30+00:00", + "machine": { + "system": "Linux", + "machine": "x86_64", + "cpus": 4 + }, + "commit": "", + "java": [ + "openjdk 25.0.4.1 2026-08-18" + ], + "settings": { + "servers": "vanilla", + "bots": "1,10,25,50,100", + "behaviour": "walk,explore", + "join_every": 1.0, + "warmup": 60, + "seconds": 300, + "seed": 12345, + "gamerule": "locator_bar=false", + "heap": "4g", + "out": "/home/ludovic/scratch/sh2-bench3.json", + "nice": 19, + "bots_via": "ssh -o BatchMode=yes macmini", + "bots_bin": "/tmp/sh2-horizon-bots", + "connect": "100.72.51.8", + "properties": { + "level-seed": "12345", + "view-distance": "10", + "simulation-distance": "10", + "online-mode": "false", + "white-list": "false", + "gamemode": "creative", + "force-gamemode": "true", + "spawn-protection": "0", + "max-players": "200", + "difficulty": "easy", + "sync-chunk-writes": "true" + } + }, + "runs": [ + { + "server": "vanilla", + "bots": 1, + "behaviour": "walk", + "start_seconds": 15.43, + "mspt": { + "average": 18.32, + "p50": 19.26, + "p95": 27.34, + "p99": 45.48 + }, + "mspt_worst_p99": 393.3, + "tps": 20.0, + "rss_mb": { + "mean": 3428.43, + "max": 3437.7 + }, + "cpu_percent": 66.47, + "chunks_per_second": 9.92, + "unique_per_second": 0.29, + "forgotten_per_second": 9.95, + "waited_ticks_per_second": 0.0, + "chunks_total": 3850, + "chunks_per_bot": { + "walk": 3850.0 + }, + "unique_chunks_per_bot": { + "walk": 699.0 + }, + "resent": 0, + "pulled_back": 0, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 1, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "vanilla", + "bots": 10, + "behaviour": "walk", + "start_seconds": 14.76, + "mspt": { + "average": 16.8, + "p50": 14.55, + "p95": 30.52, + "p99": 50.01 + }, + "mspt_worst_p99": 205.2, + "tps": 20.0, + "rss_mb": { + "mean": 3490.15, + "max": 3580.7 + }, + "cpu_percent": 83.7, + "chunks_per_second": 100.01, + "unique_per_second": 2.83, + "forgotten_per_second": 100.04, + "waited_ticks_per_second": 0.0, + "chunks_total": 38623, + "chunks_per_bot": { + "walk": 3862.3 + }, + "unique_chunks_per_bot": { + "walk": 697.2 + }, + "resent": 0, + "pulled_back": 5, + "moved_too_quickly": 5, + "moved_wrongly": 0, + "bots_joined": 10, + "bots_failed": [], + "tick_queries": 59 + }, + { + "server": "vanilla", + "bots": 25, + "behaviour": "walk", + "start_seconds": 14.84, + "mspt": { + "average": 16.01, + "p50": 14.55, + "p95": 26.39, + "p99": 51.26 + }, + "mspt_worst_p99": 397.8, + "tps": 20.0, + "rss_mb": { + "mean": 3550.02, + "max": 3593.5 + }, + "cpu_percent": 114.96, + "chunks_per_second": 251.62, + "unique_per_second": 6.72, + "forgotten_per_second": 251.16, + "waited_ticks_per_second": 0.0, + "chunks_total": 98701, + "chunks_per_bot": { + "walk": 3948.04 + }, + "unique_chunks_per_bot": { + "walk": 698.84 + }, + "resent": 0, + "pulled_back": 10, + "moved_too_quickly": 10, + "moved_wrongly": 0, + "bots_joined": 25, + "bots_failed": [], + "tick_queries": 59 + }, + { + "server": "vanilla", + "bots": 50, + "behaviour": "walk", + "start_seconds": 14.43, + "mspt": { + "average": 20.52, + "p50": 19.55, + "p95": 29.72, + "p99": 49.04 + }, + "mspt_worst_p99": 600.4, + "tps": 20.0, + "rss_mb": { + "mean": 3633.12, + "max": 3658.7 + }, + "cpu_percent": 193.84, + "chunks_per_second": 501.86, + "unique_per_second": 12.15, + "forgotten_per_second": 501.8, + "waited_ticks_per_second": 0.0, + "chunks_total": 203429, + "chunks_per_bot": { + "walk": 4068.58 + }, + "unique_chunks_per_bot": { + "walk": 698.66 + }, + "resent": 0, + "pulled_back": 33, + "moved_too_quickly": 32, + "moved_wrongly": 0, + "bots_joined": 50, + "bots_failed": [], + "tick_queries": 59 + }, + { + "server": "vanilla", + "bots": 100, + "behaviour": "walk", + "start_seconds": 15.34, + "error": "[Errno 32] Broken pipe" + }, + { + "server": "vanilla", + "bots": 1, + "behaviour": "explore", + "start_seconds": 15.55, + "mspt": { + "average": 9.93, + "p50": 8.12, + "p95": 21.33, + "p99": 60.19 + }, + "mspt_worst_p99": 373.0, + "tps": 20.0, + "rss_mb": { + "mean": 3564.11, + "max": 3655.0 + }, + "cpu_percent": 245.18, + "chunks_per_second": 35.93, + "unique_per_second": 33.35, + "forgotten_per_second": 35.64, + "waited_ticks_per_second": 0.0, + "chunks_total": 12340, + "chunks_per_bot": { + "explore": 12340.0 + }, + "unique_chunks_per_bot": { + "explore": 11569.0 + }, + "resent": 0, + "pulled_back": 261, + "moved_too_quickly": 0, + "moved_wrongly": 0, + "bots_joined": 1, + "bots_failed": [], + "tick_queries": 59 + }, + { + "server": "vanilla", + "bots": 10, + "behaviour": "explore", + "start_seconds": 15.03, + "mspt": { + "average": 16.99, + "p50": 14.46, + "p95": 29.61, + "p99": 132.18 + }, + "mspt_worst_p99": 1244.0, + "tps": 20.0, + "rss_mb": { + "mean": 4135.2, + "max": 4453.1 + }, + "cpu_percent": 242.32, + "chunks_per_second": 22.39, + "unique_per_second": 22.41, + "forgotten_per_second": 22.78, + "waited_ticks_per_second": 78.78, + "chunks_total": 10177, + "chunks_per_bot": { + "explore": 1017.7 + }, + "unique_chunks_per_bot": { + "explore": 1016.2 + }, + "resent": 0, + "pulled_back": 17314, + "moved_too_quickly": 19, + "moved_wrongly": 0, + "bots_joined": 10, + "bots_failed": [], + "tick_queries": 59 + }, + { + "server": "vanilla", + "bots": 25, + "behaviour": "explore", + "start_seconds": 14.84, + "mspt": { + "average": 24.19, + "p50": 21.22, + "p95": 36.51, + "p99": 174.82 + }, + "mspt_worst_p99": 1532.8, + "tps": 20.0, + "rss_mb": { + "mean": 4504.55, + "max": 4988.9 + }, + "cpu_percent": 319.55, + "chunks_per_second": 33.78, + "unique_per_second": 33.66, + "forgotten_per_second": 34.64, + "waited_ticks_per_second": 269.22, + "chunks_total": 20522, + "chunks_per_bot": { + "explore": 820.88 + }, + "unique_chunks_per_bot": { + "explore": 815.76 + }, + "resent": 0, + "pulled_back": 46246, + "moved_too_quickly": 28, + "moved_wrongly": 0, + "bots_joined": 25, + "bots_failed": [], + "tick_queries": 58 + }, + { + "server": "vanilla", + "bots": 50, + "behaviour": "explore", + "start_seconds": 14.73, + "error": "[Errno 32] Broken pipe" + }, + { + "server": "vanilla", + "bots": 100, + "behaviour": "explore", + "start_seconds": 14.54, + "error": "[Errno 32] Broken pipe" + } + ] +} diff --git a/docs/server.md b/docs/server.md index 6e165f534..39b790535 100644 --- a/docs/server.md +++ b/docs/server.md @@ -84,7 +84,7 @@ not tick cost, so it is in scope here (see section 9). | Management | Only stdin. There is no service, no backups, no status beyond `list`, no restart after a crash, and a server can't be managed while it runs in the background. | | Packaging | The server is the full game client. Resources come from `horizon-prepare`, which needs a JDK and the official launcher's client install, both awkward on a headless VPS. | | Tick times | The dedicated loop never recorded its tick times in the game clock (only the windowed loop did), so `/tick query` on a dedicated server reported 0 ms. Fixed in PR 2b, which the benchmark needs (section 9). | -| Scale | Not measured with more than one player. The tick is single-threaded, and each remote player costs the hub one tracking and chunk-sending pass. | +| Scale | Measured against vanilla on equal work (section 9, `benchmarks/server-equal-work-2026-10-17/`): 20 TPS up to 50 bots, 9 to 11 TPS at 100, where vanilla's watchdog stops it. Horizon is ahead with few players and when exploring; with 25 to 50 players near spawn vanilla's tick is shorter. | ### Harnesses on main @@ -457,8 +457,22 @@ reproducible by anyone, and show the same gameplay on both sides. - Vanilla's chunk sending yields under load. - Horizon sent each nearby `walk` bot 1.5–4 times as many chunks. - Horizon sent 2–4 times the chunks per second in every multi-bot run. The next run equalises the work - (slower `explore`, a `walk`-only pass) on a quiet machine before anything is published. + Horizon sent 2–4 times the chunks per second in every multi-bot run. +- **Equal-work run (2026-10-17,** `benchmarks/server-equal-work-2026-10-17/`, on the ThinkPad with the bots + on the Mac mini). Horizon now checks moves and paces chunks as vanilla does (#586), the bots wait for the + chunk they stand in, an `--explore-speed` pass keeps both servers at the same rate, and vanilla runs with + `gamerule locator_bar false` (Horizon has no locator bar yet). Results: + - Memory: Horizon's RSS is 1.9 to 2.6 times smaller in every run (vanilla's heap is fixed at 4 GB). + - Walking near spawn: Horizon's tick is shorter with 1 and 10 bots (5.5 against 18.3 ms, 11.5 against + 16.8), and longer with 25 and 50 (20.7 against 16.0, 31.4 against 20.5). Both keep 20 TPS up to 50. + - Exploring at the same rate: Horizon's tick is about half of vanilla's (13.5 against 29.2 ms at 10 bots, + 20.2 against 37.0 at 25). + - At 100 bots: vanilla's watchdog stops it in every pass; Horizon runs at 9 to 11 TPS. + + Two server costs were found and fixed on the way: every chunk section was encoded once per player (#623), + and every packet woke its connection's writer thread (#649). Together they took the 50-bot walking tick + from 281 ms to 31 ms. At 25 walking bots most of what remains is the engine's streaming and block entity + ticks, both of which grow with players (the README has the profile). - **Ownership.** perf-server-1 owns making the tick faster. This work only measures it, files what it finds, and fixes startup, RAM and packaging. From c98a4f7a58c2f2f945ef7801ab4dd9711bd54d6c Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:07:14 +0200 Subject: [PATCH 06/17] Server advancements: flushDirty skips players whose progress didn't change PlayerAdvancements.flushDirty only works when it is the first packet or progress changed. Horizon walked the whole advancement tree for every network player every tick to find nothing new: a fifth of the server tick with 50 players. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/lan/advancements.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/horizon-client/src/lan/advancements.rs b/crates/horizon-client/src/lan/advancements.rs index 0e0caf7ff..e0b8ad450 100644 --- a/crates/horizon-client/src/lan/advancements.rs +++ b/crates/horizon-client/src/lan/advancements.rs @@ -144,6 +144,13 @@ impl ServerAdvancements { ) -> Option> { let n = self.tree.list.len(); let first = visible.len() != n; + // flushDirty does nothing unless it is the first packet or progress + // changed: visibility follows progress alone, and every grant and + // revoke is in `changed`. This skips walking the whole tree for + // every player every tick. + if !first && changed.is_empty() { + return None; + } let now = self .tree .visible(&self.kids, |i| progress.done(&self.tree.list[i])); From 47a00d283dde557496639ac438a3372be042d746 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:07:58 +0200 Subject: [PATCH 07/17] Hoppers: look up the block only under container block entities tick_hoppers read the block under every block entity in the loaded world each tick to find the hoppers. A hopper's block entity is always a plain container (BlockEntity::Chest), so only those need the lookup. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/machines.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/machines.rs b/crates/horizon-client/src/game/machines.rs index 0d334d248..6947041f2 100644 --- a/crates/horizon-client/src/game/machines.rs +++ b/crates/horizon-client/src/game/machines.rs @@ -506,11 +506,15 @@ impl Game<'_> { .default_state("minecraft:hopper") .map(|s| self.res.facts.get(s).name.clone()); let Some(hopper) = hopper else { return Ok(()) }; + // A hopper's block entity is a plain container (`Chest`): only those + // need their block looked up (the ticker runs for a HopperBlockEntity + // whose block is still a hopper, `BlockEntityType.isValid`). let mut positions: Vec = self .world .block_entities - .keys() - .copied() + .iter() + .filter(|(_, e)| matches!(e, BlockEntity::Chest(_))) + .map(|(p, _)| *p) .filter(|p| self.res.facts.get(self.world.block(p.0, p.1, p.2)).name == hopper) .collect(); positions.sort_unstable(); From 690e656048f9ad8b85cd3892dae7d2140c05f29d Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:23:09 +0200 Subject: [PATCH 08/17] Chunk streaming: look at each column once when players' areas overlap The generation scan walked every area's square of columns, so with 50 players standing near each other it tested about 100,000 columns a tick (each against every player's range) to find the few hundred around them. The squares are now merged into one run per row first. The candidates are the same: they were sorted and deduplicated before. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/streaming.rs | 75 ++++++++++++++++++++------ 1 file changed, 58 insertions(+), 17 deletions(-) diff --git a/crates/horizon-client/src/streaming.rs b/crates/horizon-client/src/streaming.rs index 1754f1550..38fa33c7d 100644 --- a/crates/horizon-client/src/streaming.rs +++ b/crates/horizon-client/src/streaming.rs @@ -264,25 +264,34 @@ impl GenerationScan { } { let mut wanted = Vec::new(); - let areas: Vec<((i32, i32), i32)> = - std::iter::once((input.ranges.center, input.ranges.radius)) - .chain(input.ranges.extra.iter().copied()) - .collect(); - for (center, radius) in areas { + // Each area's square of columns, as one run per row. Players' + // areas overlap (a server's players often stand together), so + // the runs of a row are merged and each column is looked at once. + let mut runs: Vec<(i32, i32, i32)> = Vec::new(); + for (center, radius) in std::iter::once((input.ranges.center, input.ranges.radius)) + .chain(input.ranges.extra.iter().copied()) + { let r = radius + margin.max(reach + 1); - for dz in -r..=r { - for dx in -r..=r { - let (x, z) = (center.0 + dx, center.1 + dz); - if !input.ranges.requested(x, z, input.decorates) - || input.world.chunks.contains_key(&(x, z)) - || input.generating.contains(&(x, z)) - || input.protos.contains_key(&(x, z)) - || input.finishing.contains(&(x, z)) - { - continue; - } - wanted.push((input.ranges.distance(x, z), x, z)); + runs.extend((center.1 - r..=center.1 + r).map(|z| (z, center.0 - r, center.0 + r))); + } + runs.sort_unstable(); + let mut runs = runs.into_iter().peekable(); + while let Some((z, x0, mut x1)) = runs.next() { + while let Some((_, _, next_x1)) = + runs.next_if(|&(nz, nx0, _)| nz == z && nx0 <= x1.saturating_add(1)) + { + x1 = x1.max(next_x1); + } + for x in x0..=x1 { + if !input.ranges.requested(x, z, input.decorates) + || input.world.chunks.contains_key(&(x, z)) + || input.generating.contains(&(x, z)) + || input.protos.contains_key(&(x, z)) + || input.finishing.contains(&(x, z)) + { + continue; } + wanted.push((input.ranges.distance(x, z), x, z)); } } // Forced chunks and their generation neighbourhood. @@ -1569,6 +1578,38 @@ mod tests { } } + #[test] + fn generation_scan_looks_at_overlapping_areas_once() { + // A server's players: many areas around one spot, some apart, one + // touching another only at a corner, and part of the land present. + for decorates in [false, true] { + let mut f = GenerationFixture::new(10, decorates); + for i in 0..40 { + f.extra.push(((i % 7 - 3, i / 7 - 3), 19)); + } + f.extra.push(((200, -150), 19)); + f.extra.push(((243, -107), 19)); + f.extra.push(((-60, 5), 4)); + f.forced.insert((90, 90)); + let chunk = Arc::new(Chunk::empty(0, 0)); + for x in -40i32..40 { + for z in -40i32..40 { + match (x * 7 + z * 13).rem_euclid(5) { + 0 => drop(f.world.chunks.insert((x, z), chunk.clone())), + 1 => drop(f.protos.insert((x, z), None)), + 2 if x % 3 == 0 => drop(f.generating.insert((x, z))), + _ => {} + } + } + } + assert!(baseline_candidates(f.input(), usize::MAX).len() > 1000); + for capacity in [usize::MAX, f.generating.len() + 7] { + let mut gate = GenerationScan::default(); + assert!(f.assert_same(&mut gate, capacity)); + } + } + } + #[test] #[ignore = "reproducible actual generation scheduler benchmark; run with --ignored --nocapture"] fn generation_scan_benchmark() { From 66a8fa118187115643b5e3385697601bd6da8fc6 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:13:39 +0200 Subject: [PATCH 09/17] Entity tracking: each entity type's range once per tick, not per player The hub looked up an entity's tracking range (a binary search over type names) for every player and every entity each tick: about 87,000 string comparisons a tick with 50 players. The ranges are now worked out once per entity per tick; the visibility test is the same. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/lan/entity.rs | 10 +++++----- crates/horizon-client/src/lan/hub.rs | 17 ++++++++++++----- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/crates/horizon-client/src/lan/entity.rs b/crates/horizon-client/src/lan/entity.rs index 3b926b372..39c2c4571 100644 --- a/crates/horizon-client/src/lan/entity.rs +++ b/crates/horizon-client/src/lan/entity.rs @@ -66,11 +66,11 @@ fn encode(v: f64) -> i64 { (v * 4096.0).round() as i64 } -/// The horizontal range within which a client sees an entity of `kind` -/// (TrackedEntity.updatePlayer: the type's range, capped by the view distance). -pub fn visible_range(kind: &str, view_distance: i32) -> f64 { - let chunks = i32::from(tracking::settings(kind).0); - f64::from(chunks.min(view_distance) * 16) +/// The type's tracking range in chunks: a client sees an entity of `kind` +/// within it, capped by the view distance, horizontally +/// (TrackedEntity.updatePlayer). +pub fn tracking_chunks(kind: &str) -> i32 { + i32::from(tracking::settings(kind).0) } impl Tracker { diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index 8fcc41329..4b3acea4c 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -1362,6 +1362,12 @@ impl Hub { all.extend(ctx.entities.iter().cloned()); let by_id: std::collections::HashMap = all.iter().enumerate().map(|(i, e)| (e.id, i)).collect(); + // Each entity type's tracking range, looked up once rather than for + // every player. + let ranges: Vec = all + .iter() + .map(|e| super::entity::tracking_chunks(&e.kind)) + .collect(); for gi in 0..self.guests.len() { if !self.guests[gi].placed { continue; @@ -1371,8 +1377,9 @@ impl Hub { let origin = self.guests[gi].position; // TrackedEntity.updatePlayer: within the type's range (capped by the // view distance) horizontally, in a chunk the client has. - let visible = |g: &Guest, e: &EntityState| { - let range = super::entity::visible_range(&e.kind, view); + let visible = |g: &Guest, ei: usize| { + let e = &all[ei]; + let range = f64::from(ranges[ei].min(view) * 16); let (dx, dz) = (origin[0] - e.position[0], origin[2] - e.position[2]); let chunk = ( (e.position[0].floor() as i32) >> 4, @@ -1384,7 +1391,7 @@ impl Hub { let mut removed = Vec::new(); for id in self.guests[gi].tracker.ids() { match by_id.get(&id) { - Some(&ei) if visible(&self.guests[gi], &all[ei]) => {} + Some(&ei) if visible(&self.guests[gi], ei) => {} _ => removed.push(id), } } @@ -1394,14 +1401,14 @@ impl Hub { } self.guests[gi].send(&C::RemoveEntities(play::RemoveEntities { ids: removed })); } - for e in &all { + for (ei, e) in all.iter().enumerate() { if e.id == me { continue; } if self.guests[gi].tracker.is_tracking(e.id) { let packets = self.guests[gi].tracker.update(e); self.guests[gi].send_all(&packets); - } else if visible(&self.guests[gi], e) { + } else if visible(&self.guests[gi], ei) { let Some(kind) = self.entity_type(&e.kind) else { continue; }; From 4cdad5aae68cb3ace52f156d81a3a74c8922c871 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:00:27 +0200 Subject: [PATCH 10/17] Chunk packets: one cache lock per column for its non-air sections encode_in asks whether each of the nine surrounding columns' 24 sections holds anything, to decide which light layers exist: 216 cache lookups, each locking the cache and SipHashing a section's address. The column's sections now share one lock, and the cache uses the Fx hash. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/lan/chunks.rs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/lan/chunks.rs b/crates/horizon-client/src/lan/chunks.rs index 6b8c41e55..7d09427f7 100644 --- a/crates/horizon-client/src/lan/chunks.rs +++ b/crates/horizon-client/src/lan/chunks.rs @@ -4,6 +4,7 @@ use crate::{ chunk::{Chunk, Light, MIN_Y, SECTIONS, Section}, containers::BlockEntity, + fast_hash::FxHashMap, }; use horizon_protocol::{ buf::set_bit, @@ -91,7 +92,7 @@ pub struct ChunkEncoder { /// The save's tables, for the heightmaps. pub tables: Option>, /// Sections already looked at, by address (see `Encoded`). - cache: std::sync::Mutex>, + cache: std::sync::Mutex>, /// Each column's heightmaps, with the sections they were made from. heights: std::sync::Mutex>, /// The level's sections among a column's: all of them in the Overworld, @@ -258,22 +259,29 @@ impl ChunkEncoder { /// The cached entry for a section, made with `non_air` when missing. fn cached(&self, section: &Arc
, read: impl FnOnce(&mut Encoded) -> T) -> T { - let mut cache = self.cache.lock().unwrap(); + read(self.entry(&mut self.cache.lock().unwrap(), section)) + } + + /// A section's entry in the locked cache, made with `non_air` when missing. + fn entry<'a>( + &self, + cache: &'a mut FxHashMap, + section: &Arc
, + ) -> &'a mut Encoded { if cache.len() >= CACHE_SWEEP { cache.retain(|_, e| e.section.strong_count() > 0); if cache.len() >= CACHE_SWEEP / 2 { cache.clear(); } } - let entry = cache + cache .entry(Arc::as_ptr(section) as usize) .or_insert_with(|| Encoded { section: Arc::downgrade(section), non_air: self.non_air(section), wire: None, light: None, - }); - read(entry) + }) } /// LevelChunkSection.hasOnlyAir, negated. @@ -382,8 +390,10 @@ impl ChunkEncoder { /// The non-air sections of a column as bits (LevelChunkSection.hasOnlyAir). fn occupied(&self, chunk: &Chunk) -> u64 { let mut bits = 0u64; + // One lock for the column: encode_in asks for nine columns' sections. + let mut cache = self.cache.lock().unwrap(); for (i, s) in chunk.sections[self.sections.clone()].iter().enumerate() { - if self.cached(s, |e| e.non_air) { + if self.entry(&mut cache, s).non_air { bits |= 1 << i; } } From a7af5b76cf8c2d1c83361ea618de3e171b054ce1 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:03:47 +0200 Subject: [PATCH 11/17] Chunk packets: keep each section's block entity blocks with its bytes Every chunk packet scanned all 4,096 blocks of each section holding a block entity block to list them, though the section's wire bytes were already cached. The cache now keeps those blocks (index and state, in scan order) beside the bytes, made in the same pass that encodes them. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/lan/chunks.rs | 140 ++++++++++++++++++------ 1 file changed, 108 insertions(+), 32 deletions(-) diff --git a/crates/horizon-client/src/lan/chunks.rs b/crates/horizon-client/src/lan/chunks.rs index 6b8c41e55..ab440af42 100644 --- a/crates/horizon-client/src/lan/chunks.rs +++ b/crates/horizon-client/src/lan/chunks.rs @@ -115,12 +115,16 @@ struct Heights { struct Encoded { section: std::sync::Weak
, non_air: bool, - /// LevelChunkSection.write, and whether it holds block entity blocks. - wire: Option<(Arc<[u8]>, bool)>, + /// LevelChunkSection.write, and its block entity blocks. + wire: Option<(Arc<[u8]>, Entities)>, /// The section's light as the wire's two DataLayers, when not uniform. light: Option>, } +/// A section's blocks that have a block entity type, as (index, state) in +/// index order (y, then z, then x). +type Entities = Arc<[(u16, u16)]>; + /// One section's light as DataLayers (two blocks per byte, the even one in /// the low nibble), and whether each holds any light. struct Layers { @@ -291,14 +295,14 @@ impl ChunkEncoder { } /// A section on the wire, from the cache or encoded now. - fn section_wire(&self, section: &Arc
) -> (Arc<[u8]>, bool) { + fn section_wire(&self, section: &Arc
) -> (Arc<[u8]>, Entities) { if let Some(wire) = self.cached(section, |e| e.wire.clone()) { return wire; } let (encoded, entities) = self.section(section); let mut w = horizon_protocol::buf::Writer::new(); encoded.write(&mut w); - let wire: (Arc<[u8]>, bool) = (w.data.into(), entities); + let wire: (Arc<[u8]>, Entities) = (w.data.into(), entities.into()); self.cached(section, |e| e.wire = Some(wire.clone())); wire } @@ -326,9 +330,9 @@ impl ChunkEncoder { .is_none_or(|f| f & NON_AIR == 0) } - fn section(&self, section: &Section) -> (horizon_protocol::chunk::Section, bool) { + fn section(&self, section: &Section) -> (horizon_protocol::chunk::Section, Vec<(u16, u16)>) { let mut values = Vec::with_capacity(BLOCKS); - let (mut non_empty, mut fluids, mut entities) = (0i16, 0i16, false); + let (mut non_empty, mut fluids, mut entities) = (0i16, 0i16, Vec::new()); let mut count = |state: u16, n: i16| { let f = self.flags.get(usize::from(state)).copied().unwrap_or(0); if f & NON_AIR != 0 { @@ -342,12 +346,16 @@ impl ChunkEncoder { // One block everywhere (all air, mostly): what from_values would give. let uniform = section.blocks.uniform(); if let Some(v) = uniform { - entities = count(v as u16, BLOCKS as i16); + if count(v as u16, BLOCKS as i16) { + entities = (0..BLOCKS as u16).map(|i| (i, v as u16)).collect(); + } } else { let mut unpacked = [0u16; BLOCKS]; section.unpack(&mut unpacked); - for &v in &unpacked { - entities |= count(v, 1); + for (i, &v) in unpacked.iter().enumerate() { + if count(v, 1) { + entities.push((i as u16, v)); + } values.push(i32::from(v)); } } @@ -431,29 +439,30 @@ impl ChunkEncoder { .skip(self.sections.start) .take(self.sections.len()) { - let (encoded, has_entities) = self.section_wire(s); - if has_entities { - for y in 0..16 { - for z in 0..16 { - for x in 0..16 { - let state = s.get(crate::chunk::index(x, y, z)); - if let Some(kind) = self.entity_type(state) { - let wy = MIN_Y + (i as i32) * 16 + y; - let pos = (chunk.x * 16 + x, wy, chunk.z * 16 + z); - entity_infos.push(BlockEntityInfo { - packed_xz: ((x << 4) | z) as i8, - y: wy as i16, - kind, - // BlockEntityInfo.create: an empty update tag is not sent. - tag: block_entities - .get(&pos) - .and_then(entity_tag) - .unwrap_or(Tag::End), - }); - } - } - } - } + let (encoded, entities) = self.section_wire(s); + // The section's block entity blocks, kept with its bytes, in the + // order a scan of every block would find them. + for &(index, state) in entities.iter() { + let Some(kind) = self.entity_type(state) else { + continue; + }; + let (x, y, z) = ( + i32::from(index & 15), + i32::from(index >> 8), + i32::from(index >> 4 & 15), + ); + let wy = MIN_Y + (i as i32) * 16 + y; + let pos = (chunk.x * 16 + x, wy, chunk.z * 16 + z); + entity_infos.push(BlockEntityInfo { + packed_xz: ((x << 4) | z) as i8, + y: wy as i16, + kind, + // BlockEntityInfo.create: an empty update tag is not sent. + tag: block_entities + .get(&pos) + .and_then(entity_tag) + .unwrap_or(Tag::End), + }); } buffer.extend_from_slice(&encoded); } @@ -669,6 +678,73 @@ mod tests { assert_eq!(block_entity_type("minecraft:stone"), None); } + #[test] + fn block_entities_come_from_the_cached_section_in_scan_order() { + let builtin = Builtin::parse( + r#"{"minecraft:block_entity_type": {"entries": { + "minecraft:chest": {"protocol_id": 1}, + "minecraft:sign": {"protocol_id": 7}}}}"#, + ) + .unwrap(); + let states = [ + "minecraft:air", + "minecraft:stone", + "minecraft:chest", + "minecraft:oak_sign", + ] + .map(|n| (Arc::::from(n), false)); + let encoder = ChunkEncoder::new(states, &builtin, &[], &[]); + let mut chunk = Chunk::empty(2, -3); + for (x, y, z, state) in [(15, 3, 0, 2), (0, 3, 15, 3), (4, 3, 4, 1), (7, 40, 2, 2)] { + chunk.set(x, MIN_Y + y, z, state); + } + // Every block of a section looked at, as the scan did before the cache. + let scan = |chunk: &Chunk| { + let mut found = Vec::new(); + for (i, s) in chunk.sections.iter().enumerate() { + for y in 0..16 { + for z in 0..16 { + for x in 0..16 { + let state = s.get(crate::chunk::index(x, y, z)); + if let Some(kind) = encoder.entity_type(state) { + found.push((x, MIN_Y + i as i32 * 16 + y, z, kind)); + } + } + } + } + } + // Then the order the packet gives them, as encode_blocks does. + java_hash_order(&mut found, |e| { + (chunk.x * 16 + e.0, e.1, chunk.z * 16 + e.2) + }); + found + }; + let sent = |chunk: &Chunk| { + let packet = encoder.encode(chunk, &HashMap::new()); + let infos = packet.chunk.block_entities; + infos + .iter() + .map(|e| { + let xz = e.packed_xz as u8; + ( + i32::from(xz >> 4), + i32::from(e.y), + i32::from(xz & 15), + e.kind, + ) + }) + .collect::>() + }; + let expected = scan(&chunk); + assert_eq!(expected.len(), 3); + assert_eq!(sent(&chunk), expected); + // From the cache the second time, and after an edit. + assert_eq!(sent(&chunk), expected); + chunk.set(15, MIN_Y + 3, 0, 0); + chunk.set(9, MIN_Y + 3, 9, 3); + assert_eq!(sent(&chunk), scan(&chunk)); + } + fn encoder() -> ChunkEncoder { let states = ["minecraft:air", "minecraft:stone", "minecraft:water"] .map(|n| (Arc::::from(n), n.ends_with("water"))); From 1ac9c72ea31e6110386ee97b24a78d6291ed24cc Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:15:36 +0200 Subject: [PATCH 12/17] Server: Fx hashing for each player's sent chunks and tracked entities The hub tests every player's sent-chunk set for every entity and every column of the view each tick, and each player's tracked-entity map for every entity: tens of thousands of SipHash lookups a tick with 50 players. Both now use the Fx hash. Their iteration order was random before and carries no meaning. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/lan/entity.rs | 2 +- crates/horizon-client/src/lan/guest.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/lan/entity.rs b/crates/horizon-client/src/lan/entity.rs index 3b926b372..0f82ea60c 100644 --- a/crates/horizon-client/src/lan/entity.rs +++ b/crates/horizon-client/src/lan/entity.rs @@ -58,7 +58,7 @@ struct Sent { #[derive(Default)] pub struct Tracker { - sent: std::collections::HashMap, + sent: crate::fast_hash::FxHashMap, } /// VecDeltaCodec.encode. diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index db426ca21..720cb53d6 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -8,7 +8,7 @@ use horizon_protocol::{ packets::{configuration::ClientInformation, play}, }; use std::{ - collections::{HashSet, VecDeque}, + collections::VecDeque, sync::{Arc, atomic::AtomicI32}, }; @@ -77,7 +77,7 @@ pub struct Guest { pub loaded: bool, // Chunks. pub center: Option<(i32, i32)>, - pub sent_chunks: HashSet<(i32, i32)>, + pub sent_chunks: crate::fast_hash::FxHashSet<(i32, i32)>, pub chunk_queue: Vec<(i32, i32)>, pub unacknowledged: u32, pub chunks_per_tick: f32, @@ -189,7 +189,7 @@ impl Guest { teleport_counter: 0, loaded: false, center: None, - sent_chunks: HashSet::new(), + sent_chunks: Default::default(), chunk_queue: Vec::new(), unacknowledged: 0, chunks_per_tick: 9.0, From d989d4033f2ce9bdc31d72328aeb534c949e5d3c Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:16:54 +0200 Subject: [PATCH 13/17] Advancements: read the tree on another thread while the world loads The first criterion trigger of a world's first tick loaded the advancement tree: about 1,400 JSON files read and parsed on the game thread, about 60 ms of a 125 ms first tick. open_world now starts that read on a thread, and the first advancement_tree() takes its result, the same tree as before. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 1 + .../horizon-client/src/game/advancements.rs | 22 ++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 0f898f8bf..ba1aab9e0 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -6231,6 +6231,7 @@ impl Game<'_> { self.fns = Default::default(); self.bosses = Default::default(); reset_world_progress(&mut self.adv, &mut self.stats); + self.prefetch_advancement_tree(); self.restore_player_environment = true; // RandomSequences: the world's data/minecraft/random_sequences.dat. self.rand_sequences = diff --git a/crates/horizon-client/src/game/advancements.rs b/crates/horizon-client/src/game/advancements.rs index 22d9a4da0..5cf09bc44 100644 --- a/crates/horizon-client/src/game/advancements.rs +++ b/crates/horizon-client/src/game/advancements.rs @@ -88,6 +88,9 @@ pub(super) struct AdvState { pub(super) changed: BTreeSet, pub(super) unlocked: Vec, pub(super) announced: Vec, + /// The tree being read on another thread since the world opened + /// (`prefetch_advancement_tree`), taken by the first `advancement_tree`. + pub(super) pending_tree: Option>, } /// The game's world for predicates. struct View<'a, 'g>(&'a Game<'g>); @@ -221,10 +224,27 @@ impl Game<'_> { /// The advancement tree, loaded once. pub(super) fn advancement_tree(&mut self) -> Arc { if self.adv.tree.list.is_empty() { - self.adv.tree = Arc::new(Tree::load(&self.options.resources.join("data"))); + let tree = match self.adv.pending_tree.take().map(|h| h.join()) { + Some(Ok(tree)) => tree, + _ => Tree::load(&self.options.resources.join("data")), + }; + self.adv.tree = Arc::new(tree); } self.adv.tree.clone() } + /// Start reading the advancement files on another thread while the world + /// loads. The first tick's first criterion trigger used to read and parse + /// all of them on the game thread (about 60 ms). + pub(super) fn prefetch_advancement_tree(&mut self) { + if !self.adv.tree.list.is_empty() || self.adv.pending_tree.is_some() { + return; + } + let data = self.options.resources.join("data"); + self.adv.pending_tree = std::thread::Builder::new() + .name("horizon-advancements".into()) + .spawn(move || Tree::load(&data)) + .ok(); + } pub(super) fn load_advancements(&mut self) { if self.adv.loaded || self.remote.is_some() || self.no_player_here() { return; From 05821d4e621c438e7a1ed933adb9f451ee323185 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:38:54 +0200 Subject: [PATCH 14/17] Launcher: Windows tests check behaviour, not symlink paths On Windows the launcher links another launcher's client jar with a hard link (a copy across volumes) and its libraries and assets with a junction, since symlinks need developer mode. canonicalize() of a hard link is its own path, so the sources tests, which compared canonical paths, failed on Windows and blocked every launcher release since launcher-v0.4.0. They now check that the linked jar is the launcher's file (a byte written through one shows in the other), which holds for a symlink and a hard link alike. The modpack profile test compares paths as paths rather than strings ending in "a/carry". mklink /J's paths are always quoted: Rust only quotes arguments with spaces, and cmd reads an unquoted & or ^ in a user folder as its own syntax. CI runs every launcher test target, not just the library's. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/launcher.yml | 3 ++- launcher/src/modpacks.rs | 3 ++- launcher/src/sources.rs | 16 +++++++++++--- launcher/src/sources_tests.rs | 38 +++++++++++++++++++++++++++------- 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/.github/workflows/launcher.yml b/.github/workflows/launcher.yml index 2ab491207..4f2a6076c 100644 --- a/.github/workflows/launcher.yml +++ b/.github/workflows/launcher.yml @@ -138,7 +138,8 @@ jobs: echo "::notice::No Metal toolchain on this runner: GPUI compiles its shaders at run time" echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV" fi - - run: cargo test --release --locked --lib ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"} + # Every test target (the library, the binary, tests/), on each system the launcher ships for. + - run: cargo test --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"} - run: cargo build --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"} - name: The version is the release's run: | diff --git a/launcher/src/modpacks.rs b/launcher/src/modpacks.rs index 6d4553a14..6ff53e4be 100644 --- a/launcher/src/modpacks.rs +++ b/launcher/src/modpacks.rs @@ -241,7 +241,8 @@ mod tests { .map(|a| a.to_string_lossy().into_owned()) .collect(); assert_eq!(args.len(), 4); - assert!(args[1].ends_with("a/carry") && args[3].ends_with("b/carry")); + assert_eq!(Path::new(&args[1]), work.join("mods").join("a").join("carry")); + assert_eq!(Path::new(&args[3]), work.join("mods").join("b").join("carry")); assert_eq!(cmd.get_current_dir(), Some(profile_home(&work).as_path())); assert!(profile_home(&work).is_dir()); fs::remove_dir_all(&root).unwrap(); diff --git a/launcher/src/sources.rs b/launcher/src/sources.rs index aafdb51e4..f6a9753ea 100644 --- a/launcher/src/sources.rs +++ b/launcher/src/sources.rs @@ -426,16 +426,26 @@ fn make_link(target: &Path, link: &Path) -> std::io::Result<()> { std::os::unix::fs::symlink(target, link) } -/// Symlinks need developer mode on Windows: a junction for a folder, a hard link for a file. +/// Symlinks need developer mode on Windows: a junction for a folder, a hard link for a file (a +/// copy on another volume). #[cfg(windows)] fn make_link(target: &Path, link: &Path) -> std::io::Result<()> { + use std::os::windows::process::CommandExt; if !target.is_dir() { return fs::hard_link(target, link).or_else(|_| fs::copy(target, link).map(|_| ())); } + // Always quoted: cmd reads `&`, `^` or `(` in an unquoted path (a user folder may have + // them) as its own syntax. A Windows path never holds a quote. + let quoted = |path: &Path| { + let mut arg = std::ffi::OsString::from("\""); + arg.push(path); + arg.push("\""); + arg + }; let status = std::process::Command::new("cmd") .args(["/C", "mklink", "/J"]) - .arg(link) - .arg(target) + .raw_arg(quoted(link)) + .raw_arg(quoted(target)) .stdout(std::process::Stdio::null()) .status()?; match status.success() { diff --git a/launcher/src/sources_tests.rs b/launcher/src/sources_tests.rs index f603aaf0a..381d19f27 100644 --- a/launcher/src/sources_tests.rs +++ b/launcher/src/sources_tests.rs @@ -94,15 +94,25 @@ fn resolve_with( } } +/// Whether `a` and `b` are one file, as a symlink (Unix) or a hard link (Windows) makes them: +/// a byte written through `a` shows in `b`. Both are left as they were. +fn same_file(a: &Path, b: &Path) -> bool { + use std::io::Write; + let (before_a, before_b) = (fs::read(a).unwrap(), fs::read(b).unwrap()); + let mut file = fs::OpenOptions::new().write(true).open(a).unwrap(); + file.write_all(&[!before_a[0]]).unwrap(); + drop(file); + let same = fs::read(b).unwrap() != before_b; + fs::write(a, &before_a).unwrap(); + same +} + /// The linked folder is the official layout, pointing at the launcher's files. fn assert_linked(linked: &Path, base: &Path, jar: &str) { let v = linked.join("versions/26.3"); assert_eq!(fs::read(v.join("26.3.jar")).unwrap(), CLIENT); assert_eq!(fs::read(v.join("26.3.json")).unwrap(), MANIFEST); - assert_eq!( - fs::canonicalize(v.join("26.3.jar")).unwrap(), - fs::canonicalize(base.join(jar)).unwrap() - ); + assert!(same_file(&v.join("26.3.jar"), &base.join(jar))); assert_eq!( fs::read(linked.join("libraries/org/lib/1/lib-1.jar")).unwrap(), b"lib" @@ -110,6 +120,19 @@ fn assert_linked(linked: &Path, base: &Path, jar: &str) { assert!(linked.join("assets/indexes/30.json").is_file()); } +/// Folders whose names cmd would read as its own syntax unquoted are still linked (Windows makes +/// a junction through `mklink /J`; a name without spaces isn't quoted by itself). +#[test] +fn folders_with_ampersands_are_linked() { + let s = Scratch::new("a&b(c)"); + let jar = "libraries/com/mojang/minecraft/26.3/minecraft-26.3-client.jar"; + let dir = s.launcher("Prism&Co", "", jar); + let linked = s.0.join("linked&here"); + let got = resolve_with(&dir, &[], &linked, &Mojang::new(MANIFEST)).unwrap(); + assert_eq!(got, linked); + assert_linked(&linked, &dir, jar); +} + #[test] fn maven_names_become_repository_paths() { assert_eq!( @@ -295,10 +318,9 @@ fn relinking_follows_the_launcher_the_files_now_come_from() { let modrinth = s.0.join("ModrinthApp"); s.write("ModrinthApp/meta/versions/26.3/26.3.jar", CLIENT); resolve_with(&modrinth, &[], &linked, &mojang).unwrap(); - assert_eq!( - fs::canonicalize(linked.join("versions/26.3/26.3.jar")).unwrap(), - fs::canonicalize(modrinth.join("meta/versions/26.3/26.3.jar")).unwrap() - ); + let jar = linked.join("versions/26.3/26.3.jar"); + assert!(same_file(&jar, &modrinth.join("meta/versions/26.3/26.3.jar"))); + assert!(!same_file(&jar, &prism.join(prism_jar))); // The Modrinth App folder has no libraries or assets: the old links are gone, and the // folders they pointed at are untouched. assert!(fs::symlink_metadata(linked.join("libraries")).is_err()); From bff5891073489bd2bcd850869c071e5942498cbe Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:46:16 +0200 Subject: [PATCH 15/17] Launcher: Cargo.lock has horizon-port-pack's horizon-mods dependency horizon-port-pack now depends on horizon-mods (1f1d5a5a), which the launcher's own lock file didn't have, so every --locked build of the launcher failed. Co-Authored-By: Claude Opus 5.5 --- launcher/Cargo.lock | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/launcher/Cargo.lock b/launcher/Cargo.lock index af0588af6..bde1037b3 100644 --- a/launcher/Cargo.lock +++ b/launcher/Cargo.lock @@ -2681,10 +2681,22 @@ dependencies = [ "zip", ] +[[package]] +name = "horizon-mods" +version = "0.1.0" +dependencies = [ + "log", + "serde", + "serde_json", + "toml 1.1.8+spec-1.1.0", + "zip", +] + [[package]] name = "horizon-port-pack" version = "0.1.0" dependencies = [ + "horizon-mods", "serde", "serde_json", "sha1", From f49af8d995b2908f167e8a42c8363e00f9d65911 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:59:01 +0200 Subject: [PATCH 16/17] Launcher releases: count every crate the launcher builds in LAUNCHER_PATHS and launcher.yml's push paths now list horizon-bench, horizon-friends, horizon-mods and horizon-port-pack too, the launcher's path packages per cargo metadata. A change there (like the horizon-mods dependency that left launcher/Cargo.lock stale) then builds and releases the launcher instead of going unnoticed. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/launcher.yml | 10 +++++++--- launcher/Cargo.lock | 12 ++++++++++++ tools/release_train.py | 12 +++++++++++- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/.github/workflows/launcher.yml b/.github/workflows/launcher.yml index 2ab491207..55db3965a 100644 --- a/.github/workflows/launcher.yml +++ b/.github/workflows/launcher.yml @@ -1,7 +1,6 @@ name: launcher # Builds the launcher on Linux, macOS and Windows, and releases it by itself (docs/release-flow.md): after a -# push to main that changes the launcher or what it builds in (crates/horizon-prepare, crates/horizon-theme, -# crates/horizon-telemetry), +# push to main that changes the launcher or a crate it builds in (the paths below), # every 10 minutes (a release waiting for merges to settle goes out) and by hand, tools/release_train.py decides as # for the game. # A release is launcher-vX.Y.Z: the patch after the last launcher tag, or the next minor when a merged pull @@ -14,10 +13,15 @@ on: push: branches: [main, launcher, 'launcher-*'] paths: + # Every crate the launcher builds in, as tools/release_train.py's LAUNCHER_PATHS. - 'launcher/**' + - 'crates/horizon-bench/**' + - 'crates/horizon-friends/**' + - 'crates/horizon-mods/**' + - 'crates/horizon-port-pack/**' - 'crates/horizon-prepare/**' - - 'crates/horizon-theme/**' - 'crates/horizon-telemetry/**' + - 'crates/horizon-theme/**' - '.github/workflows/launcher.yml' schedule: - cron: '7-59/10 * * * *' diff --git a/launcher/Cargo.lock b/launcher/Cargo.lock index af0588af6..bde1037b3 100644 --- a/launcher/Cargo.lock +++ b/launcher/Cargo.lock @@ -2681,10 +2681,22 @@ dependencies = [ "zip", ] +[[package]] +name = "horizon-mods" +version = "0.1.0" +dependencies = [ + "log", + "serde", + "serde_json", + "toml 1.1.8+spec-1.1.0", + "zip", +] + [[package]] name = "horizon-port-pack" version = "0.1.0" dependencies = [ + "horizon-mods", "serde", "serde_json", "sha1", diff --git a/tools/release_train.py b/tools/release_train.py index a1af28449..70e7b0287 100644 --- a/tools/release_train.py +++ b/tools/release_train.py @@ -39,7 +39,17 @@ EXPORTERS = "mods/horizon/src/test/java/dev/horizonlab/parity/" # What players run. Documentation (*.md), tests, benchmarks and harness probes (examples/) never count. GAME_PATHS = ["crates/", "xtask/", "packaging/", "Cargo.toml", "Cargo.lock", "research-lock.json"] -LAUNCHER_PATHS = ["launcher/", "crates/horizon-prepare/", "crates/horizon-theme/", "crates/horizon-telemetry/"] +# Every crate the launcher builds in (cargo metadata --manifest-path launcher/Cargo.toml: the path packages). +LAUNCHER_PATHS = [ + "launcher/", + "crates/horizon-bench/", + "crates/horizon-friends/", + "crates/horizon-mods/", + "crates/horizon-port-pack/", + "crates/horizon-prepare/", + "crates/horizon-telemetry/", + "crates/horizon-theme/", +] LAUNCHER_IGNORED = ["launcher/links.json", "launcher/docs/", "launcher/tests/"] FOOTER = { From bbde190f06c66764b26d4e919ac371567c7b38b5 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:51:53 +0200 Subject: [PATCH 17/17] Merge main into feat/server-move-checks TickCtx gained `collision` here; main's two newer initializers (lan_actions respawn, server_travel's server_ctx) now pass it too, as every other one does. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/lan_actions.rs | 1 + crates/horizon-client/src/game/server_travel.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 1d3433cf0..7cd4b0474 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -944,6 +944,7 @@ impl Game<'_> { }, tick_rate: self.clock.rate(), tick_frozen: self.clock.frozen(), + collision: Some(self.collision), }; host.hub.respawn(i, spawn, &ctx); } diff --git a/crates/horizon-client/src/game/server_travel.rs b/crates/horizon-client/src/game/server_travel.rs index d5b1e4d95..e82291978 100644 --- a/crates/horizon-client/src/game/server_travel.rs +++ b/crates/horizon-client/src/game/server_travel.rs @@ -243,6 +243,7 @@ impl Game<'_> { }, tick_rate: self.clock.rate(), tick_frozen: self.clock.frozen(), + collision: Some(self.collision), } } }