Server packet rules: world border, carried item, game mode, beacon, pick block

ServerGamePacketListenerImpl, where the server differed:
- handleAttack and handleInteract: a target outside the world border is
  ignored (WorldBorder.isWithinBounds(target.blockPosition())).
- handleSetCarriedItem: a slot past the hotbar logs "<name> tried to set
  an invalid carried item"; a new slot stops only a main-hand use.
- handleChangeGameMode without the permission logs "Player <name> tried
  to change game mode to <mode> without required permissions".
- handleSetBeaconPacket: effects the beacon can't give, or no payment,
  disconnect (multiplayer.disconnect.generic) with "Player <name> tried
  to set invalid beacon effects".
- handlePickItemFromBlock: only a loaded block.

horizon-bots gains --raw TICK:ID:HEX, and
tools/server_packet_rules_e2e.py checks the first four on a dedicated
server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-31 04:46:44 +01:00
1 parent 87a9d03fd8
commit 16fd80bdcf
4 files changed
+290 -16

No files matched your search

+31
View File
@@ -81,6 +81,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
registry id, count), to test what a server takes
--click X,Y,Z:SLOT:BUTTON:MODE 3 s after joining, open the block at X,Y,Z (a chest), then a second later
click SLOT of its window (ServerboundContainerClickPacket), to test what a server takes
--raw TICK:ID:HEX,... raw serverbound play packets (id, body in hex) sent TICK ticks after joining,
to test what a server takes
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
--report FILE a JSON summary at the end (- prints it after the per-second lines)
";
@@ -142,6 +144,8 @@ struct Options {
no_loaded: bool,
height: f64,
ground: bool,
/// --raw: (tick after joining, packet id, body).
raw: Vec<(u64, i32, Vec<u8>)>,
/// ServerboundSetCreativeModeSlotPackets to send once joined: (slot, item id, count).
give: Vec<(i16, i32, i32)>,
/// --click: the block to open, then the slot, button and click type in its window.
@@ -172,6 +176,7 @@ fn options() -> Result<Options, String> {
no_loaded: false,
height: 24.0,
ground: false,
raw: Vec::new(),
give: Vec::new(),
click: None,
name: "Bot".into(),
@@ -246,6 +251,27 @@ fn options() -> Result<Options, String> {
let parse = |s: &str| s.parse::<i32>().map_err(|_| bad());
o.click = Some((at, parse(slot)? as i16, parse(button)? as i8, parse(mode)?));
}
"--raw" => {
for part in value.split(',') {
let bad = || number("TICK:ID:HEX");
let [tick, id, hex] = part.split(':').collect::<Vec<_>>()[..] else {
return Err(bad());
};
let body = (0..hex.len())
.step_by(2)
.map(|i| {
hex.get(i..i + 2)
.and_then(|b| u8::from_str_radix(b, 16).ok())
})
.collect::<Option<Vec<u8>>>()
.ok_or_else(bad)?;
o.raw.push((
tick.parse().map_err(|_| bad())?,
id.parse().map_err(|_| bad())?,
body,
));
}
}
"--height" => o.height = value.parse().map_err(|_| number("blocks"))?,
"--timer" => {
o.timer = value.parse().map_err(|_| number("a speed, like 1.5"))?;
@@ -935,6 +961,11 @@ fn run_bot(
);
}
}
for (at, id, body) in &o.raw {
if *at == tick {
send(raw(*id, |w| w.bytes(body)));
}
}
// The costly floods go out wherever the bot is, chunks or not (--no-read gets none).
for i in 0..o.flood {
sequence += 1;
+53 -13
View File
@@ -1317,12 +1317,22 @@ impl Game<'_> {
8 if !spectator => self.guest_jab(host, conn),
_ => {}
},
// handleSetCarriedItem: a new slot stops a main-hand use.
S::SetCarriedItem(p) => {
if (0..9).contains(&p.slot) {
host.hub.guests[gi].selected = p.slot as usize;
if let Some(play) = host.play.get_mut(&conn) {
play.using = None;
}
let g = &mut host.hub.guests[gi];
if !(0..9).contains(&p.slot) {
eprintln!("{} tried to set an invalid carried item", g.name);
return;
}
let changed = g.selected != p.slot as usize;
g.selected = p.slot as usize;
if let Some(play) = host.play.get_mut(&conn)
&& changed
&& play
.using
.is_some_and(|(slot, _)| slot != super::shield::OFFHAND_SLOT)
{
play.using = None;
}
}
// ServerGamePacketListenerImpl.handlePunch: the main hand swings
@@ -1467,12 +1477,13 @@ impl Game<'_> {
}
S::Attack(p) => self.guest_attack(host, conn, p.id),
S::Interact(p) => {
// handleInteract: within the entity interaction range plus 3
// of the target's box, or nothing happens.
// handleInteract: a target inside the world border, within the entity
// interaction range plus 3 of its box, or nothing happens.
if let Some(target) = self.guest_target_box(host, gi, p.id)
&& self.as_guest(host, conn, |g| {
g.within_entity_interaction_range(&target, 3.0)
}) != Some(true)
&& (!self.inside_border(&target)
|| self.as_guest(host, conn, |g| {
g.within_entity_interaction_range(&target, 3.0)
}) != Some(true))
{
return;
}
@@ -1744,6 +1755,22 @@ impl Game<'_> {
self.send_admin_feedback(host, Some(conn));
}
}
S::ChangeGameMode(p) => {
if let Some(mode) = usize::try_from(p.mode)
.ok()
.and_then(|index| horizon_client::command::args::GameMode::ALL.get(index))
{
let name = horizon_client::text::Text::translate(
&format!("gameMode.{}", mode.name()),
&[],
);
eprintln!(
"Player {} tried to change game mode to {} without required permissions",
host.hub.guests[gi].name,
name.plain()
);
}
}
// handleEntityTagQuery: a game master's F3+I on an entity of their
// level gets its Entity.saveWithoutId.
S::EntityTagQuery(p) if host.hub.guests[gi].permission >= 2 => {
@@ -1800,6 +1827,10 @@ impl Game<'_> {
// data only with infinite materials.
S::PickItemFromBlock(p) => {
self.guest_pick_item(host, conn, |g| {
// handlePickItemFromBlock: within reach and loaded.
if !g.world.loaded(p.pos[0], p.pos[2]) {
return None;
}
let eye = g.eye_position()?;
let d2: f64 = (0..3)
.map(|a| {
@@ -2099,6 +2130,13 @@ impl Game<'_> {
/// interaction: the host, another player, a mob, a vehicle or object, a
/// dragon part or crystal, a projectile. None for an id the level doesn't
/// have (the handlers then find nothing either).
/// `WorldBorder.isWithinBounds(target.blockPosition())` for an entity of this box:
/// the block its feet are in (the box's bottom centre).
fn inside_border(&self, target: &Aabb) -> bool {
let feet = target.bottom_center();
self.border.contains(feet.x.floor(), feet.z.floor())
}
pub(super) fn guest_target_box(&self, host: &LanHost, gi: usize, id: i32) -> Option<Aabb> {
let conn = host.hub.guests[gi].conn;
if id == host.host_entity {
@@ -2138,10 +2176,12 @@ impl Game<'_> {
if host.hub.guests[gi].spectator() {
return;
}
// handleAttack: `isWithinAttackRange(mainHandItem, box, 3.0)` before
// anything else, players, mobs, objects and fireballs alike.
// handleAttack: a target inside the world border, then
// `isWithinAttackRange(mainHandItem, box, 3.0)` before anything else, players,
// mobs, objects and fireballs alike.
if let Some(target) = self.guest_target_box(host, gi, id)
&& self.as_guest(host, conn, |g| g.within_attack_range(&target)) != Some(true)
&& (!self.inside_border(&target)
|| self.as_guest(host, conn, |g| g.within_attack_range(&target)) != Some(true))
{
return;
}
+20 -3
View File
@@ -564,13 +564,30 @@ impl Game<'_> {
}
}
let data = self.beacon_data(host, pos);
let mut updated = false;
let mut updated = None;
self.lan_station_op(host, conn, None, |menu, env| {
menu.data.copy_from_slice(&data);
let power = |k: usize| powers[k].as_ref().map(|(id, n)| (*id, n.as_str()));
updated = menu.beacon_update(env, power(0), power(1));
updated = Some(menu.beacon_update(env, power(0), power(1)));
});
if !updated {
// handleSetBeaconPacket: effects the beacon can't give, or no payment, disconnect.
if updated == Some(false) {
let name = host
.hub
.guests
.iter()
.find(|g| g.conn == conn)
.map(|g| g.name.clone());
eprintln!(
"Player {} tried to set invalid beacon effects",
name.unwrap_or_default()
);
host.hub.kick(
conn,
horizon_protocol::text::translatable("multiplayer.disconnect.generic", &[]),
);
}
if updated != Some(true) {
return;
}
let [primary, secondary] = powers.map(|p| p.map(|(_, n)| n));
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""Small rules of vanilla's ServerGamePacketListenerImpl on Horizon's dedicated server, end to end.
python3 tools/server_packet_rules_e2e.py --bin-dir target/fast
A survival dedicated server in a flat world, a world border 20 blocks wide around 0 0 (no damage):
- handleAttack: a player outside the world border isn't hit, from 3 blocks away; a pair inside it fights;
- handleSetCarriedItem: hotbar slot 12 is refused, and the console says "<name> tried to set an invalid
carried item";
- handleChangeGameMode: a player without the permission asks for creative; the console says
"Player <name> tried to change game mode to Creative Mode without required permissions";
- handleSetBeaconPacket: a player opens a beacon and sets Speed without paying; vanilla disconnects them
(multiplayer.disconnect.generic) and warns "Player <name> tried to set invalid beacon effects".
horizon-bots stand in for the clients (`--attack`, `--raw`). 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
from concurrent.futures import ThreadPoolExecutor
import json
import os
from pathlib import Path
import re
import shutil
import struct
import subprocess
import sys
import tempfile
import time
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lab # noqa: E402
from server_bench import DONE, Server # noqa: E402
EXE = '.exe' if os.name == 'nt' else ''
PROPERTIES = {
'online-mode': 'false',
'white-list': 'false',
'level-type': 'minecraft\\:flat',
'generate-structures': 'false',
'gamemode': 'survival',
'difficulty': 'easy',
'pvp': 'true',
'spawn-protection': '0',
'view-distance': '3',
'simulation-distance': '3',
}
# A flat world's grass top is at y = -61.
GROUND = -60
BEACON = (2, GROUND, 0)
SECONDS = 14
SPEED = 0 # minecraft:speed in the mob_effect registry
class Failed(Exception):
pass
def serverbound_ids():
"""The play state's serverbound packet ids, from horizon-protocol's table."""
text = (lab.ROOT / 'crates/horizon-protocol/src/ids.rs').read_text()
play = text[text.index('pub mod play {'):]
block = play[play.index('pub mod serverbound {'):]
block = block[:block.index('}')]
return {m[1]: int(m[2]) for m in re.finditer(r'pub const (\w+): i32 = (\d+);', block)}
def varint(n):
out = b''
n &= 0xFFFFFFFF
while True:
if n < 0x80:
return out + bytes([n])
out += bytes([n & 0x7F | 0x80])
n >>= 7
def block_pos(x, y, z):
return struct.pack('>q', ((x & 0x3FFFFFF) << 38) | ((z & 0x3FFFFFF) << 12) | (y & 0xFFF))
def bot(bins, port, out, name, behaviour, *extra):
report = out / f'{name}.json'
subprocess.run([bins / f'horizon-bots{EXE}', '--server', f'127.0.0.1:{port}', '--bots', '1',
'--behaviour', behaviour, '--height', '0', '--seconds', str(SECONDS), '--name', name,
'--report', report, *extra],
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=SECONDS + 180)
return json.loads(report.read_text())['per_bot'][0]
def run(bins, folder, out):
ids = serverbound_ids()
use_on = (varint(0) + block_pos(*BEACON) + varint(1) + struct.pack('>fff', 0.5, 1.0, 0.5)
+ b'\0\0' + varint(1))
# Speed as the primary power, no secondary, and nothing paid.
beacon = b'\1' + varint(SPEED) + b'\0'
raw = ','.join([
f'40:{ids["SET_CARRIED_ITEM"]}:{struct.pack(">h", 12).hex()}',
f'40:{ids["CHANGE_GAME_MODE"]}:{varint(1).hex()}',
f'60:{ids["USE_ITEM_ON"]}:{use_on.hex()}',
f'80:{ids["SET_BEACON"]}:{beacon.hex()}',
])
folder.mkdir(parents=True)
port = lab.free_port()
props = dict(PROPERTIES, **{'server-port': str(port)})
(folder / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
(folder / 'eula.txt').write_text('eula=true\n')
command = [bins / f'horizon-client{EXE}', '--resources', lab.RESOURCES, '--dedicated', '--nogui']
server = Server(command, folder, out / 'server.log')
try:
server.wait_for(DONE, 300)
for line in ('setworldspawn 0 -60 0', 'gamerule respawn_radius 0', 'worldborder center 0 0',
'worldborder set 20', 'worldborder damage amount 0',
'setblock {} {} {} minecraft:beacon'.format(*BEACON)):
server.console(line)
with ThreadPoolExecutor(5) as pool:
# (victim, attacker): outside the border, then a pair inside it.
pairs = [(pool.submit(bot, bins, port, out, f'V{i}', 'stand'),
pool.submit(bot, bins, port, out, f'A{i}', 'stand', '--attack', f'V{i}0',
'--attack-after', '6'))
for i in range(2)]
rules = pool.submit(bot, bins, port, out, 'R', 'idle', '--raw', raw)
# Side by side first, so each attacker sees its victim (and its entity id), then apart.
for i in range(2):
for name in (f'V{i}0', f'A{i}0'):
server.wait_for(rf'{name} joined the game', 60)
z = 5.5 * (1 if i else -1)
server.console(f'tp V{i}0 0.5 {GROUND} {z}')
server.console(f'tp A{i}0 1.5 {GROUND} {z}')
time.sleep(2)
server.console(f'tp V00 12.5 {GROUND} -5.5')
server.console(f'tp A00 9.5 {GROUND} -5.5')
server.console(f'tp V10 -3.5 {GROUND} 5.5')
server.console(f'tp A10 -0.5 {GROUND} 5.5')
results = [(v.result(), a.result()) for v, a in pairs]
rule_bot = rules.result()
lines = server.since(0)
finally:
server.stop()
failed = []
for (victim, attacker), (where, hits) in zip(results, [('outside the border', False),
('inside the border', True)]):
health = victim['health']
hit = health is not None and health < 20
line = f'a victim {where}: {attacker["attacks"]} attacks, health {health}'
print(line)
if attacker['attacks'] == 0 or health is None:
failed.append(f'{line}: the bots never fought ({attacker["error"]}, {victim["error"]})')
elif hit != hits:
failed.append(f'{line}: expected {"a hit" if hits else "no hit"}')
for want in ('R0 tried to set an invalid carried item',
'Player R0 tried to change game mode to Creative Mode without required permissions',
'Player R0 tried to set invalid beacon effects'):
if any(want in l for l in lines):
print(f'console: {want}')
else:
failed.append(f'the console never said "{want}"')
if rule_bot['error']:
print(f'R0 was disconnected: {rule_bot["error"]}')
else:
failed.append('R0 was not disconnected after the unpaid beacon')
if failed:
raise Failed('\n'.join(failed))
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-packet-rules-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-packet-rules-e2e-') as scratch:
try:
run(bins, Path(scratch) / 'server', out)
except (Failed, RuntimeError) as e:
sys.exit(f'server_packet_rules_e2e failed (logs in {out}):\n{e}')
print('server_packet_rules_e2e: the border, carried item, game mode and beacon rules hold')
if __name__ == '__main__':
main()