From 704dff73bd62e2543c1a195fe830cd4c6ef4acbf Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:46:56 +0100 Subject: [PATCH] Ignore a player's moves and actions until their client has loaded ServerGamePacketListenerImpl.hasClientLoaded: until the client sends ServerboundPlayerLoadedPacket, or 60 ticks pass (60 again after a respawn), and while the player is dead, vanilla ignores their moves, vehicle moves, block actions, item use, attacks, interactions, spectator actions and player commands, and PlayerInput doesn't change crouching. The server only skipped a rotation-only move before the first position, and never re-armed the wait after a respawn. horizon-bots gains --no-loaded, and tools/server_client_loaded_e2e.py checks a dedicated server ignores such a client for its first 3 seconds. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-bots/src/main.rs | 12 +- crates/horizon-client/src/game/lan_actions.rs | 2 + .../horizon-client/src/game/lan_vehicles.rs | 2 +- crates/horizon-client/src/lan/guest.rs | 25 ++++ crates/horizon-client/src/lan/hub.rs | 32 +++++- tools/server_client_loaded_e2e.py | 108 ++++++++++++++++++ 6 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 tools/server_client_loaded_e2e.py diff --git a/crates/horizon-bots/src/main.rs b/crates/horizon-bots/src/main.rs index fc8ebb549a..d41ff11b8a 100644 --- a/crates/horizon-bots/src/main.rs +++ b/crates/horizon-bots/src/main.rs @@ -63,6 +63,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m --attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test); the report gives each bot's last health --attack-after S start attacking S seconds after joining; default 5 + --no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server + ignores the player's moves and actions for its first 3 seconds --no-read stop reading from the server once spawned (a client that lets the server's replies pile up) --join-every SECONDS default 1 @@ -135,6 +137,7 @@ struct Options { attack_after: f64, flood: usize, no_read: bool, + no_loaded: bool, height: f64, ground: bool, /// ServerboundSetCreativeModeSlotPackets to send once joined: (slot, item id, count). @@ -162,6 +165,7 @@ fn options() -> Result { attack_after: 5.0, flood: 100, no_read: false, + no_loaded: false, height: 24.0, ground: false, give: Vec::new(), @@ -180,6 +184,10 @@ fn options() -> Result { o.no_read = true; continue; } + if arg == "--no-loaded" { + o.no_loaded = true; + continue; + } if arg == "--ground" { o.ground = true; continue; @@ -732,7 +740,9 @@ fn run_bot( bot.joined_after_ms .store(asked.elapsed().as_millis() as u64, Ordering::SeqCst); let send = |body: Vec| lock(&writer).send(&body).is_ok(); - send(raw(sb::PLAYER_LOADED, |_| {})); + if !o.no_loaded { + send(raw(sb::PLAYER_LOADED, |_| {})); + } bot.deaf.store(o.no_read, Ordering::Relaxed); // What the costly floods need in hand (creative: the server takes the bot's word for it). let tool = match behaviour { diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 4b29b7b0c6..a90daeaaf0 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -2519,7 +2519,9 @@ impl Game<'_> { return; } let g = &mut host.hub.guests[i]; + // restartClientLoadTimerAfterRespawn. g.dead = false; + g.load_timeout = horizon_client::lan::guest::CLIENT_LOADED_TIMEOUT; g.death_time = 0; g.health = 20.0; g.food = 20; diff --git a/crates/horizon-client/src/game/lan_vehicles.rs b/crates/horizon-client/src/game/lan_vehicles.rs index ebb7aeecd6..11899898b6 100644 --- a/crates/horizon-client/src/game/lan_vehicles.rs +++ b/crates/horizon-client/src/game/lan_vehicles.rs @@ -270,7 +270,7 @@ impl Game<'_> { return; } let g = &host.hub.guests[gi]; - if g.awaiting_teleport.is_some() || !g.loaded { + if g.awaiting_teleport.is_some() || !g.client_loaded() { return; } let owner = host.hub.singleplayer_owner == Some(g.uuid); diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index 017904d7dd..4b274af7e5 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -57,6 +57,9 @@ pub struct SentEffect { pub tick_count: i32, } +/// ServerGamePacketListenerImpl.CLIENT_LOADED_TIMEOUT_TIME. +pub const CLIENT_LOADED_TIMEOUT: u8 = 60; + pub struct Guest { pub conn: u32, /// The client's address (IP bans, `list`). @@ -161,6 +164,10 @@ pub struct Guest { pub moved: bool, /// The client finished loading terrain (ServerboundPlayerLoadedPacket). pub loaded: bool, + /// Ticks left before the server stops waiting for that packet + /// (ServerGamePacketListenerImpl.clientLoadedTimeoutTimer): 60 at join and + /// after a respawn, 0 once it comes. + pub load_timeout: u8, // Chunks. pub center: Option<(i32, i32)>, pub sent_chunks: crate::fast_hash::FxHashSet<(i32, i32)>, @@ -347,6 +354,7 @@ impl Guest { anticheat: Default::default(), moved: false, loaded: false, + load_timeout: CLIENT_LOADED_TIMEOUT, center: None, sent_chunks: Default::default(), chunk_queue: Vec::new(), @@ -447,6 +455,11 @@ impl Guest { pub fn creative(&self) -> bool { self.game_mode == CREATIVE } + /// ServerGamePacketListenerImpl.hasClientLoaded: not dead (waitingForRespawn) and + /// loaded, or waited for long enough. Until then the player's actions are ignored. + pub fn client_loaded(&self) -> bool { + !self.dead && self.load_timeout == 0 + } pub fn spectator(&self) -> bool { self.game_mode == SPECTATOR } @@ -1043,6 +1056,18 @@ mod tests { ) } + #[test] + fn the_client_has_loaded_once_it_says_so_or_after_60_ticks_and_not_while_dead() { + let mut g = guest_at([0.0; 3]); + assert!(!g.client_loaded()); + g.load_timeout -= 59; + assert!(!g.client_loaded()); + g.load_timeout -= 1; + assert!(g.client_loaded()); + g.dead = true; + assert!(!g.client_loaded()); + } + #[test] fn a_dead_player_leaves_its_vehicle_when_its_body_is_removed() { let mut g = guest_at([0.5, -120.0, 0.5]); diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index b1db5b0933..365b6ed78d 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -2361,6 +2361,8 @@ impl Hub { let g = &mut self.guests[i]; g.first_good = g.position; g.known_moves = g.received_moves; + // ServerPlayer.tick: tickClientLoadTimeout. + g.load_timeout = g.load_timeout.saturating_sub(1); let mut budget = PACKETS_PER_TICK; while budget > 0 { let Some((packet, bytes)) = self.guests[i].inbox.pop_front() else { @@ -2385,13 +2387,24 @@ impl Hub { | S::KeepAlive(_) => false, S::MovePlayerPos(p) => self.moved_far(i, [p.x, p.y, p.z]), S::MovePlayerPosRot(p) => self.moved_far(i, [p.x, p.y, p.z]), - S::PlayerInput(_) => self.guests[i].loaded, + S::PlayerInput(_) => self.guests[i].client_loaded(), _ => true, }; if acted { self.guests[i].last_action_tick = self.ticks; } match packet { + // hasClientLoaded: these handlers ignore the player's actions until + // the client says the level around it is loaded (or 60 ticks pass), + // and while it is dead. + S::PlayerAction(_) + | S::UseItemOn(_) + | S::UseItem(_) + | S::Attack(_) + | S::Interact(_) + | S::SpectatorAction(_) + | S::PlayerCommand(_) + if !self.guests[i].client_loaded() => {} // handleAcceptTeleportPacket. S::AcceptTeleportation(p) => { let g = &mut self.guests[i]; @@ -2431,7 +2444,12 @@ impl Hub { S::MovePlayerStatus(p) => { self.move_player(i, None, None, p.flags, ctx, events); } - S::PlayerLoaded(_) => self.guests[i].loaded = true, + // handleAcceptPlayerLoad: markClientLoaded. + S::PlayerLoaded(_) => { + let g = &mut self.guests[i]; + g.loaded = true; + g.load_timeout = 0; + } // PlayerChunkSender.onChunkBatchReceivedByClient. S::ChunkBatchReceived(p) => { let g = &mut self.guests[i]; @@ -2487,8 +2505,11 @@ impl Hub { } } S::PlayerInput(p) => { - self.guests[i].input = p.flags; - self.guests[i].sneaking = p.flags & 32 != 0; + let g = &mut self.guests[i]; + g.input = p.flags; + if g.client_loaded() { + g.sneaking = p.flags & 32 != 0; + } // handlePlayerInput only records keys and updates crouching. // Sprinting persists after the sprint key is released (or // starts with a double tap); PlayerCommand changes it. @@ -2567,7 +2588,8 @@ impl Hub { } g.position_this_tick = true; } - if g.landing || !g.loaded && g.ticks == 0 && pos.is_none() { + // handleMovePlayer: nothing moves before the client has loaded. + if g.landing || !g.client_loaded() { return; } // updateAwaitingTeleport: moves wait for the client to accept the diff --git a/tools/server_client_loaded_e2e.py b/tools/server_client_loaded_e2e.py new file mode 100644 index 0000000000..d409262e35 --- /dev/null +++ b/tools/server_client_loaded_e2e.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""ServerGamePacketListenerImpl.hasClientLoaded on a dedicated server, end to end. + + python3 tools/server_client_loaded_e2e.py --bin-dir target/fast + +Until the client says the level around it is loaded (ServerboundPlayerLoadedPacket), or 60 ticks +pass, vanilla ignores the player's moves and actions. A bot that never sends that packet climbs from +spawn (0.5 blocks a tick): after 1.5 s the server still has it where it spawned; after 6 s its moves +are taken. A bot that sends it moves at once. + +horizon-bots stand in for the clients (`--no-loaded`). The server runs in a fresh folder with fresh +HORIZON_HOME and XDG folders (lab.game_env), on a free port. Build first: + cargo build --profile fast -p horizon-client --bin horizon-client + cargo build --profile fast -p horizon-bots +""" +import argparse +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import time + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import lab # noqa: E402 +from server_horizon_only_e2e import Failed, server # noqa: E402 + +EXE = '.exe' if os.name == 'nt' else '' + + +def bot(bins, port, name, *extra): + return subprocess.Popen( + [bins / f'horizon-bots{EXE}', '--server', f'127.0.0.1:{port}', '--bots', '1', '--behaviour', 'idle', + '--seconds', '10', '--name', name, *extra], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def height(s, player): + """The player's y as the server has it (`data get entity Pos[1]`).""" + mark = s.count() + s.console(f'data get entity {player} Pos[1]') + deadline = time.time() + 30 + while time.time() < deadline: + for line in s.since(mark): + m = re.search(rf'{player} has the following entity data: (-?[\d.]+)d', line) + if m: + return float(m[1]) + if 'No entity was found' in line: + raise Failed(f'{player} is not on the server') + time.sleep(0.1) + raise Failed(f'no answer to `data get entity {player} Pos[1]`') + + +def watch(s, port, bins, name, *extra): + """The bot's height on the server when it joins, 1.5 s later and 6 s later.""" + b = bot(bins, port, name, *extra) + try: + s.wait_for(rf'{name}0 joined the game', 60) + start = height(s, f'{name}0') + time.sleep(1.5 - 0.1) + early = height(s, f'{name}0') + time.sleep(4.5) + late = height(s, f'{name}0') + finally: + b.kill() + b.wait() + return start, early, late + + +def check(bins, scratch, out): + s, port = server(bins, scratch / 'server', out, 'server', {}) + try: + late = watch(s, port, bins, 'Late', '--no-loaded') + loaded = watch(s, port, bins, 'Loaded') + finally: + s.stop() + start, early, after = late + if early != start: + raise Failed(f'a client that has not loaded moved from y {start} to {early} within 1.5 s') + if after <= start + 1.0: + raise Failed(f'after 6 s the server still ignores its moves (y {start} → {after})') + print(f'not loaded: still at y {early} after 1.5 s, moved to y {after} after 6 s (the 60-tick timeout)') + start, early, _ = loaded + if early <= start + 1.0: + raise Failed(f'a loaded client did not move within 1.5 s (y {start} → {early})') + print(f'loaded: moved from y {start} to {early} within 1.5 s') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--bin-dir', default=str(lab.ROOT / 'target/fast')) + parser.add_argument('--out', default=str(lab.LOCAL / 'server-client-loaded-e2e')) + args = parser.parse_args() + bins, out = Path(args.bin_dir).resolve(), Path(args.out) + shutil.rmtree(out, ignore_errors=True) + out.mkdir(parents=True) + os.environ.update(lab.game_env()) + with tempfile.TemporaryDirectory(prefix='server-client-loaded-e2e-') as scratch: + try: + check(bins, Path(scratch), out) + except (Failed, RuntimeError) as e: + sys.exit(f'server_client_loaded_e2e failed (logs in {out}): {e}') + + +if __name__ == '__main__': + main()