Container clicks past the menu are ignored, as in vanilla

AbstractContainerMenu.isValidSlotIndex: a click on a slot past the open
menu does nothing. The hub mapped it to the inventory past its 36 slots
(a chest's slot 63 reached the main inventory's slot 9), so a click
could throw or take items outside the menu. The bundle selection
(setSelectedBundleItemIndex) has the same range now.

The packets' codecs: an unknown click type reads as PICKUP (ByIdMap,
OutOfBoundsStrategy.ZERO), and a bundle index below -1 disconnects
(ServerboundSelectBundleItemPacket's constructor).

horizon-bots gains --click, and tools/server_container_click_e2e.py
checks a click on a chest's slot 63 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 02:29:58 +01:00
1 parent 6eac540995
commit 2541cba005
4 files changed
+175 -1

No files matched your search

+52
View File
@@ -77,6 +77,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
on it; higher, or over water: a no-fall or jesus cheat, to test an anticheat)
--give SLOT:ID:COUNT,... creative-slot packets sent once joined (inventory menu slot, item
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
--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)
";
@@ -139,6 +141,8 @@ struct Options {
ground: bool,
/// 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.
click: Option<([i32; 3], i16, i8, i32)>,
name: String,
/// The `minecraft:brand` to send, if any.
brand: Option<String>,
@@ -165,6 +169,7 @@ fn options() -> Result<Options, String> {
height: 24.0,
ground: false,
give: Vec::new(),
click: None,
name: "Bot".into(),
report: None,
brand: None,
@@ -218,6 +223,21 @@ fn options() -> Result<Options, String> {
o.give.push((slot, item, count));
}
}
"--click" => {
let bad = || number("X,Y,Z:SLOT:BUTTON:MODE");
let n: Vec<&str> = value.split(':').collect();
let [at, slot, button, mode] = n[..] else {
return Err(bad());
};
let at: Vec<i32> = at
.split(',')
.map(str::parse)
.collect::<Result<_, _>>()
.map_err(|_| bad())?;
let at = <[i32; 3]>::try_from(at).map_err(|_| bad())?;
let parse = |s: &str| s.parse::<i32>().map_err(|_| bad());
o.click = Some((at, parse(slot)? as i16, parse(button)? as i8, parse(mode)?));
}
"--height" => o.height = value.parse().map_err(|_| number("blocks"))?,
"--timer" => {
o.timer = value.parse().map_err(|_| number("a speed, like 1.5"))?;
@@ -873,6 +893,38 @@ fn run_bot(
}
}
}
if let Some((at, slot, button, input)) = o.click {
// Ticks of 50 ms: the block 3 s after joining, the click a second later.
if tick == 60 {
sequence += 1;
send(
play::Serverbound::UseItemOn(play::UseItemOn {
hand: 0,
pos: at,
face: 1,
cursor: [0.5, 1.0, 0.5],
inside: false,
world_border: false,
sequence,
})
.encode(),
);
} else if tick == 80 {
// The first window a player opens is 1.
send(
play::Serverbound::ContainerClick(play::ContainerClick {
container: 1,
state: 0,
slot,
button,
input,
changed: Vec::new(),
carried: None,
})
.encode(),
);
}
}
// The costly floods go out wherever the bot is, chunks or not (--no-read gets none).
for i in 0..o.flood {
sequence += 1;
+12 -1
View File
@@ -524,6 +524,15 @@ impl Hub {
{
return Vec::new();
}
// AbstractContainerMenu.isValidSlotIndex: -999 or below the menu's slot
// count. A slot past the menu would reach the inventory beyond its 36 slots.
let slots = match self.guests[i].menu.as_ref().filter(|_| in_menu) {
Some(m) => m.size + 36,
None => 46,
};
if i32::from(p.slot) >= slots as i32 && p.slot != -999 {
return Vec::new();
}
let source = self.guests[i].menu.as_ref().map(|m| m.slots);
let shulker_box = self
.sync
@@ -593,10 +602,12 @@ impl Hub {
.as_mut()
.and_then(|items| items.get_mut(slot))
.and_then(Option::as_mut),
Some((size, _)) => g
// setSelectedBundleItemIndex: a slot of the menu only.
Some((size, _)) if slot - size < 36 => g
.inventory
.get_mut(player_slot(slot - size))
.and_then(Option::as_mut),
Some(_) => None,
None => match slot {
9..=35 => g.inventory.get_mut(slot).and_then(Option::as_mut),
36..=44 => g.inventory.get_mut(slot - 36).and_then(Option::as_mut),
+13
View File
@@ -2534,6 +2534,19 @@ impl Hub {
break;
}
}
// ContainerInput's codec (ByIdMap, OutOfBoundsStrategy.ZERO) reads an
// unknown click type as PICKUP.
S::ContainerClick(mut p) => {
if !(0..=6).contains(&p.input) {
p.input = 0;
}
events.push(HostEvent::Packet(conn, Box::new(S::ContainerClick(p))));
}
// ServerboundSelectBundleItemPacket's constructor: no index below -1.
S::SelectBundleItem(p) if p.index < -1 => {
let reason = format!("Invalid selectedItemIndex: {}", p.index);
self.kick(conn, literal(&reason));
}
other => events.push(HostEvent::Packet(conn, Box::new(other))),
}
}
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""ServerboundContainerClickPacket's slot range on a dedicated server, end to end.
python3 tools/server_container_click_e2e.py --bin-dir target/fast
AbstractContainerMenu.isValidSlotIndex: a click is taken on -1, -999 or a slot below the menu's slot
count, and ignored past it. A chest's menu has 63 slots (27 + the player's 36). A bot opens a chest and
throws (click type 4, button 1: the whole stack) from slot 63, past the menu: nothing happens, and the
stone in the player's first inventory row (slot 9) stays. Before the fix, slot 63 reached that stone and
threw it.
horizon-bots stand in for the client (`--click`). 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 ''
CHEST = (2, -60, 0)
THROW = 4
def entity_data(s, player, path):
"""The console's answer to `data get entity <player> <path>`."""
mark = s.count()
s.console(f'data get entity {player} {path}')
deadline = time.time() + 30
while time.time() < deadline:
for line in s.since(mark):
if 'has the following entity data' in line:
return line.split('entity data:', 1)[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} {path}`')
def check(bins, scratch, out):
s, port = server(bins, scratch / 'server', out, 'server', {})
try:
for line in ('setworldspawn 0 -60 0', 'gamerule respawn_radius 0',
'setblock {} {} {} minecraft:chest'.format(*CHEST)):
s.console(line)
click = '{},{},{}:{}:1:{}'.format(*CHEST, 63, THROW)
bot = subprocess.Popen(
[bins / f'horizon-bots{EXE}', '--server', f'127.0.0.1:{port}', '--bots', '1', '--behaviour', 'idle',
'--height', '0', '--seconds', '8', '--name', 'Click', '--click', click],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
s.wait_for(r'Click0 joined the game', 60)
# Ten stacks: the hotbar (slots 0 to 8), then the main inventory's first slot, 9.
s.console('give Click0 minecraft:stone 640')
time.sleep(1)
if not re.search(r'Slot: 9b', entity_data(s, 'Click0', 'Inventory')):
raise Failed('`give` did not fill slot 9')
time.sleep(5)
items = entity_data(s, 'Click0', 'Inventory')
finally:
bot.kill()
bot.wait()
finally:
s.stop()
if not re.search(r'Slot: 9b', items) or 'minecraft:stone' not in items:
raise Failed(f'a click on slot 63 of a chest threw the stone in inventory slot 9: {items.strip()}')
print('a click past the chest menu (slot 63) is ignored: the stone in slot 9 stays')
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-container-click-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-container-click-e2e-') as scratch:
try:
check(bins, Path(scratch), out)
except (Failed, RuntimeError) as e:
sys.exit(f'server_container_click_e2e failed (logs in {out}): {e}')
if __name__ == '__main__':
main()