Merge pull request #4181 from horizon-rust/tools/bots-abusive-client

🤖 Agent: all checks green on 6edf1ff46 (horizon-bots only, skip-release). Queued.
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-10-30 07:22:09 +01:00
commit 68f036059e
1 file changed
+255 -7
+255 -7
View File
@@ -21,18 +21,19 @@ use horizon_protocol::{
connection::{ConnWriter, Connection},
crypto::offline_uuid,
ids,
item::Stack,
packets::{
Empty, configuration,
handshake::{self, Intention},
login::{self, ServerHello},
play,
play, recipes,
},
};
use serde_json::json;
use std::{
collections::HashSet,
sync::{
Arc, Mutex,
Arc, LazyLock, Mutex,
atomic::{AtomicBool, AtomicU64, Ordering},
},
thread,
@@ -44,7 +45,22 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--server HOST:PORT default 127.0.0.1:25565
--bots N default 10
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed
--behaviour B idle, walk, explore or mixed (60 % walk, 20 % explore, 20 % idle); default mixed.
One abusive client, to test a server's limits (run it beside a normal crowd):
flood-move (--flood moves a tick, each 64 to 256 blocks away, ignoring chunks),
flood-acks (--flood chunk batch acks asking for 64 chunks a tick, while exploring),
flood-packets (--flood rotations a tick),
flood-chunks (one move a tick, 9.9 blocks straight ahead: as fast as the
moved-too-quickly check allows, never waiting for chunks),
and --flood a tick of packets that cost the server work:
flood-use (place and break stone around itself), flood-click (inventory
clicks with a stale state), flood-suggest (command suggestions, every
other one 30 000 characters long), flood-book (100 pages of 1024 characters
into a writable book), flood-sign (four 384-character lines on the block
below), flood-recipe (recipe book placements)
--flood N packets a tick for the flood behaviours; default 100
--no-read stop reading from the server once spawned (a client that lets the
server's replies pile up)
--join-every SECONDS default 1
--seconds N how long to run after the first bot joins; default 60
--view-distance N what the bots ask for; default 10
@@ -59,6 +75,16 @@ enum Behaviour {
Idle,
Walk,
Explore,
FloodMove,
FloodAcks,
FloodPackets,
FloodChunks,
FloodUse,
FloodClick,
FloodSuggest,
FloodBook,
FloodSign,
FloodRecipe,
}
impl Behaviour {
@@ -67,6 +93,16 @@ impl Behaviour {
Self::Idle => "idle",
Self::Walk => "walk",
Self::Explore => "explore",
Self::FloodMove => "flood-move",
Self::FloodAcks => "flood-acks",
Self::FloodPackets => "flood-packets",
Self::FloodChunks => "flood-chunks",
Self::FloodUse => "flood-use",
Self::FloodClick => "flood-click",
Self::FloodSuggest => "flood-suggest",
Self::FloodBook => "flood-book",
Self::FloodSign => "flood-sign",
Self::FloodRecipe => "flood-recipe",
}
}
}
@@ -81,6 +117,8 @@ struct Options {
view_distance: i8,
seed: u64,
explore_speed: f64,
flood: usize,
no_read: bool,
name: String,
report: Option<String>,
}
@@ -96,6 +134,8 @@ fn options() -> Result<Options, String> {
view_distance: 10,
seed: 12345,
explore_speed: 22.0,
flood: 100,
no_read: false,
name: "Bot".into(),
report: None,
};
@@ -105,6 +145,10 @@ fn options() -> Result<Options, String> {
print!("{HELP}");
std::process::exit(0);
}
if arg == "--no-read" {
o.no_read = true;
continue;
}
let value = args.next().ok_or(format!("{arg} needs a value"))?;
let number = |what: &str| format!("{arg} needs {what}, not {value:?}");
match arg.as_str() {
@@ -122,14 +166,31 @@ fn options() -> Result<Options, String> {
"--explore-speed" => {
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
}
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
_ => return Err(format!("unknown option {arg}; --help lists them")),
}
}
if !["idle", "walk", "explore", "mixed"].contains(&o.behaviour.as_str()) {
const BEHAVIOURS: [&str; 14] = [
"idle",
"walk",
"explore",
"mixed",
"flood-move",
"flood-acks",
"flood-packets",
"flood-chunks",
"flood-use",
"flood-click",
"flood-suggest",
"flood-book",
"flood-sign",
"flood-recipe",
];
if !BEHAVIOURS.contains(&o.behaviour.as_str()) {
return Err(format!(
"--behaviour {}: use idle, walk, explore or mixed",
"--behaviour {}: use idle, walk, explore, mixed or a flood behaviour (--help lists them)",
o.behaviour
));
}
@@ -156,6 +217,8 @@ struct Bot {
teleport: Mutex<Option<[f64; 3]>>,
error: Mutex<Option<String>>,
joined_after_ms: AtomicU64,
/// The bot stopped reading (--no-read).
deaf: AtomicBool,
}
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
@@ -185,6 +248,89 @@ fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
w.data
}
/// Item ids of the pinned version (26.3's item registry).
const STONE: i32 = 1;
const WRITABLE_BOOK: i32 = 1371;
/// The `i`th packet of a costly flood this tick, the bot at `pos`; none for other behaviours.
fn costly(
behaviour: Behaviour,
i: usize,
sequence: i32,
pos: [f64; 3],
random: &mut Random,
) -> Option<Vec<u8>> {
use ids::play::serverbound as sb;
static LONG_COMMAND: LazyLock<String> =
LazyLock::new(|| format!("/msg {}", "x".repeat(30_000)));
static BOOK: LazyLock<Vec<u8>> = LazyLock::new(|| {
play::Serverbound::EditBook(play::EditBook {
slot: 0,
pages: vec!["a".repeat(1024); 100],
title: None,
})
.encode()
});
let block = pos.map(|v| v.floor() as i32);
let packet = match behaviour {
Behaviour::FloodUse => {
// A block within reach: place stone there, or break what is there.
let at = [0, 1, 2].map(|k| block[k] + (random.unit() * 7.0) as i32 - 3);
if i % 2 == 0 {
play::Serverbound::UseItemOn(play::UseItemOn {
hand: 0,
pos: at,
face: 1,
cursor: [0.5, 1.0, 0.5],
inside: false,
world_border: false,
sequence,
})
} else {
play::Serverbound::PlayerAction(play::PlayerAction {
action: 0,
pos: at,
direction: 1,
sequence,
})
}
}
Behaviour::FloodClick => play::Serverbound::ContainerClick(play::ContainerClick {
container: 0,
state: 0,
slot: 36 + (i % 9) as i16,
button: 0,
input: 0,
changed: Vec::new(),
carried: None,
}),
Behaviour::FloodSuggest => {
let text = if i % 2 == 0 {
"/"
} else {
LONG_COMMAND.as_str()
};
return Some(raw(sb::COMMAND_SUGGESTION, |w| {
w.varint(sequence);
w.string(text);
}));
}
Behaviour::FloodBook => return Some(BOOK.clone()),
Behaviour::FloodSign => play::Serverbound::SignUpdate(play::SignUpdate {
pos: [block[0], block[1] - 1, block[2]],
lines: std::array::from_fn(|_| "a".repeat(384)),
front: true,
}),
Behaviour::FloodRecipe => play::Serverbound::PlaceRecipe(recipes::PlaceRecipe {
container: 0,
recipe: (i % 2042) as i32,
use_max: true,
}),
_ => return None,
};
Some(packet.encode())
}
fn err(e: impl std::fmt::Display) -> String {
e.to_string()
}
@@ -339,6 +485,12 @@ fn read(
let mut batch = BatchSize::new();
let mut teleports = 0u64;
loop {
if bot.deaf.load(Ordering::Relaxed) {
while bot.online.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(100));
}
return;
}
let (id, body) = match reader.recv() {
Ok(packet) => packet,
Err(e) => {
@@ -489,6 +641,24 @@ fn run_bot(
.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, |_| {}));
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 {
Behaviour::FloodUse => Some(Stack::new(STONE, 64)),
Behaviour::FloodBook => Some(Stack::new(WRITABLE_BOOK, 1)),
_ => None,
};
if tool.is_some() {
send(
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
slot: 36,
item: tool,
})
.encode(),
);
send(play::Serverbound::SetCarriedItem(play::SetCarriedItem { slot: 0 }).encode());
}
let mut sequence = 0;
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
let mut pos = spawn;
let cruise = spawn[1] + 24.0;
@@ -504,7 +674,38 @@ fn run_bot(
pos = at;
}
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
if !lock(&bot.held).contains(&here) {
// The abusive client doesn't wait for its chunk, as a real client must.
if behaviour == Behaviour::FloodMove {
// The last of these moves goes out below, with the tick's end.
pos[1] = cruise;
for _ in 1..o.flood.max(1) {
let angle = random.unit() * std::f64::consts::TAU;
let reach = 64.0 + random.unit() * 192.0;
pos[0] += angle.cos() * reach;
pos[2] += angle.sin() * reach;
send(
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
x: pos[0],
y: pos[1],
z: pos[2],
flags: 0,
})
.encode(),
);
}
let angle = random.unit() * std::f64::consts::TAU;
pos[0] += angle.cos() * 128.0;
pos[2] += angle.sin() * 128.0;
} else if behaviour == Behaviour::FloodChunks {
// Up to cruising height first, then just under the server's 10 blocks a tick:
// every tick asks for new chunks.
if pos[1] < cruise {
pos[1] = (pos[1] + 0.5).min(cruise);
} else {
pos[0] += heading.cos() * 9.9;
pos[2] += heading.sin() * 9.9;
}
} else if !lock(&bot.held).contains(&here) {
bot.waited.fetch_add(1, Ordering::Relaxed);
} else if pos[1] < cruise {
pos[1] = (pos[1] + 0.5).min(cruise);
@@ -529,7 +730,37 @@ fn run_bot(
pos[2] += dz / distance * step;
}
}
Behaviour::Explore => {
Behaviour::FloodMove
| Behaviour::FloodChunks
| Behaviour::FloodUse
| Behaviour::FloodClick
| Behaviour::FloodSuggest
| Behaviour::FloodBook
| Behaviour::FloodSign
| Behaviour::FloodRecipe => {}
Behaviour::FloodPackets => {
for i in 0..o.flood {
send(
play::Serverbound::MovePlayerRot(play::MovePlayerRot {
yaw: (i % 360) as f32,
pitch: 0.0,
flags: 0,
})
.encode(),
);
}
}
Behaviour::FloodAcks | Behaviour::Explore => {
if behaviour == Behaviour::FloodAcks {
for _ in 0..o.flood {
send(
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
chunks_per_tick: 64.0,
})
.encode(),
);
}
}
// 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;
@@ -538,6 +769,13 @@ fn run_bot(
}
}
}
// The costly floods go out wherever the bot is, chunks or not (--no-read gets none).
for i in 0..o.flood {
sequence += 1;
if let Some(body) = costly(behaviour, i, sequence, pos, &mut random) {
send(body);
}
}
let ok = send(
play::Serverbound::MovePlayerPos(play::MovePlayerPos {
x: pos[0],
@@ -569,6 +807,16 @@ fn behaviour_of(mode: &str, index: usize) -> Behaviour {
"idle" => Behaviour::Idle,
"walk" => Behaviour::Walk,
"explore" => Behaviour::Explore,
"flood-move" => Behaviour::FloodMove,
"flood-acks" => Behaviour::FloodAcks,
"flood-packets" => Behaviour::FloodPackets,
"flood-chunks" => Behaviour::FloodChunks,
"flood-use" => Behaviour::FloodUse,
"flood-click" => Behaviour::FloodClick,
"flood-suggest" => Behaviour::FloodSuggest,
"flood-book" => Behaviour::FloodBook,
"flood-sign" => Behaviour::FloodSign,
"flood-recipe" => Behaviour::FloodRecipe,
// mixed: of every five bots, three walk, one explores, one stands.
_ => match index % 5 {
0 | 2 | 4 => Behaviour::Walk,