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 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-31 02:19:26 +01:00
1 parent 2fb0d5b493
commit 704dff73bd
6 files changed
+174 -7

No files matched your search

+11 -1
View File
@@ -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<Options, String> {
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<Options, String> {
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<u8>| 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 {
@@ -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;
@@ -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);
+25
View File
@@ -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]);
+27 -5
View File
@@ -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
+108
View File
@@ -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 <player> 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()