From 2541cba00548d53301b01810d921703628b8be2d Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:28:01 +0100 Subject: [PATCH] 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 --- crates/horizon-bots/src/main.rs | 52 +++++++++++ crates/horizon-client/src/lan/containers.rs | 13 ++- crates/horizon-client/src/lan/hub.rs | 13 +++ tools/server_container_click_e2e.py | 98 +++++++++++++++++++++ 4 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 tools/server_container_click_e2e.py diff --git a/crates/horizon-bots/src/main.rs b/crates/horizon-bots/src/main.rs index fc8ebb549a..8defb31333 100644 --- a/crates/horizon-bots/src/main.rs +++ b/crates/horizon-bots/src/main.rs @@ -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, @@ -165,6 +169,7 @@ fn options() -> Result { height: 24.0, ground: false, give: Vec::new(), + click: None, name: "Bot".into(), report: None, brand: None, @@ -218,6 +223,21 @@ fn options() -> Result { 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 = at + .split(',') + .map(str::parse) + .collect::>() + .map_err(|_| bad())?; + let at = <[i32; 3]>::try_from(at).map_err(|_| bad())?; + let parse = |s: &str| s.parse::().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; diff --git a/crates/horizon-client/src/lan/containers.rs b/crates/horizon-client/src/lan/containers.rs index 6e5030b53a..2e5d9ec557 100644 --- a/crates/horizon-client/src/lan/containers.rs +++ b/crates/horizon-client/src/lan/containers.rs @@ -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), diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index b1db5b0933..9aafc0d845 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -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))), } } diff --git a/tools/server_container_click_e2e.py b/tools/server_container_click_e2e.py new file mode 100644 index 0000000000..79418e1db1 --- /dev/null +++ b/tools/server_container_click_e2e.py @@ -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 `.""" + 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()