mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
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:
1 parent
6eac540995
commit
2541cba005
4 files changed
+175
-1
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user