Skip to content

Commit d0124fe

Browse files
004: full semantic parity — open container fix + eat handshake + click_slot pacing
Three remaining backlog items closed to bring Rust/accel implementation into full behavioural parity with the Python reference. 1. open_block_container — was sending UseItem (right-click-air); now sends BlockPlace (right-click-block / "use_item_on") with the block position and a top-face cursor. This is the packet Python ref sends and the only one Paper accepts to open chests/furnaces/ crafting tables. 2. eat — was a fixed 1.6 s sleep; now subscribes to clientbound EntityStatus (id 0x1C) via the existing packet-hook mechanism, awaits status=9 (player finished using item) matching the bot's own entity_id, falls back to `timeout` if the server is slow. Mirrors Python ref's async with-EntityStatus-listener pattern. 3. click_slot — was send-and-forget; now waits up to 200 ms for the dispatcher to apply the server's SetSlot/WindowItems echo and bump state_id. 1.20.1 has no WindowConfirmation packet — the state_id field on every click IS the handshake. Pacing rapid clicks against the echo prevents Paper anti-cheat desync. Dispatcher additions to make the above work: * SetSlot (0x14) — updates state_id + per-slot * WindowItems (0x12) — updates state_id + bulk reload * OpenWindow (0x30) — sets window_id + container_slots size * CloseWindow (0x10) — clears transient state Verification: * cargo build -p minecraft_bot: clean. * cargo fmt --check: clean (after `cargo fmt --all`). * ruff check python/minecraft_bot tests: clean. * maturin develop --release: clean. * pytest tests/python/parity tests/python/perf -m "not live": 104 passed, 5 skipped. * pytest tests/python/unit: 980 passed. * cargo test --features live-smoke --test integration_bot_full: test_state_movement_and_combat_combined green on Paper 1.20.1. This brings 004 from "API parity" to "API + behavioural parity" across the three artefacts. Backlog list from the previous turn: * open_block_container — FIXED. * eat handshake — FIXED. * click_slot WindowConfirmation analogue — FIXED. * entity_velocity packet — still not wired (low priority, no method reads vx/vy/vz in 004's surface). * Per-method packet-trace tests — still smoke-only. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9b2dad4 commit d0124fe

4 files changed

Lines changed: 157 additions & 15 deletions

File tree

rust/src/bot.rs

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ use crate::protocol::v763::packets::play::clientbound::{
4040
experience::Experience as CbExperience, game_state_change::GameStateChange,
4141
held_item_slot::HeldItemSlot as CbHeldItemSlot, login::Login as CbLogin, map_chunk::MapChunk,
4242
multi_block_change::MultiBlockChange, named_entity_spawn::NamedEntitySpawn,
43-
position::Position as CbPosition, respawn::Respawn as CbRespawn, spawn_entity::SpawnEntity,
44-
unload_chunk::UnloadChunk, update_health::UpdateHealth,
43+
open_window::OpenWindow, position::Position as CbPosition, respawn::Respawn as CbRespawn,
44+
set_slot::SetSlot, spawn_entity::SpawnEntity, unload_chunk::UnloadChunk,
45+
update_health::UpdateHealth, window_items::WindowItems,
4546
};
4647
use crate::protocol::v763::packets::play::serverbound::block_dig::BlockDig;
4748
use crate::protocol::v763::packets::play::serverbound::position::Position as SbPosition;
@@ -71,6 +72,10 @@ const ID_SPAWN_ENTITY: i32 = 0x01;
7172
const ID_NAMED_ENTITY_SPAWN: i32 = 0x03;
7273
const ID_ENTITY_DESTROY: i32 = 0x3E;
7374
const ID_ENTITY_TELEPORT: i32 = 0x68;
75+
const ID_SET_SLOT: i32 = 0x14;
76+
const ID_WINDOW_ITEMS: i32 = 0x12;
77+
const ID_OPEN_WINDOW: i32 = 0x30;
78+
const ID_CLOSE_WINDOW: i32 = 0x10;
7479
/// Mojang entity type id for `minecraft:player`.
7580
const ENTITY_TYPE_PLAYER: i32 = 124;
7681
const ID_BLOCK_CHANGE: i32 = 0x0A;
@@ -226,6 +231,7 @@ impl Bot {
226231
let state = Arc::clone(&self.state);
227232
let hooks = Arc::clone(&self.hooks);
228233
let entities = Arc::clone(&self.entities_tracker);
234+
let inventory = Arc::clone(&self.inventory);
229235

230236
let handle = tokio::spawn(async move {
231237
while let Some((id, body)) = rx.recv().await {
@@ -409,6 +415,72 @@ impl Bot {
409415
}
410416
Ok(())
411417
}
418+
ID_SET_SLOT => {
419+
if let Ok(pkt) = SetSlot::decode(&mut br) {
420+
let mut inv = inventory.lock().await;
421+
inv.state_id = pkt.state_id;
422+
let item = pkt.item.as_ref().map(|s| {
423+
crate::inventory::ItemSlot::new(
424+
s.item_id as u32,
425+
s.count as u8,
426+
None, // NBT bytes — decoded later
427+
)
428+
});
429+
inv.apply_set_slot(pkt.window_id as u8, pkt.slot_index, item);
430+
}
431+
Ok(())
432+
}
433+
ID_WINDOW_ITEMS => {
434+
if let Ok(pkt) = WindowItems::decode(&mut br) {
435+
let mut inv = inventory.lock().await;
436+
inv.state_id = pkt.state_id;
437+
let items: Vec<Option<crate::inventory::ItemSlot>> = pkt
438+
.items
439+
.iter()
440+
.map(|opt| {
441+
opt.as_ref().map(|s| {
442+
crate::inventory::ItemSlot::new(
443+
s.item_id as u32,
444+
s.count as u8,
445+
None,
446+
)
447+
})
448+
})
449+
.collect();
450+
inv.apply_window_items(pkt.window_id, items);
451+
}
452+
Ok(())
453+
}
454+
ID_OPEN_WINDOW => {
455+
if let Ok(pkt) = OpenWindow::decode(&mut br) {
456+
// Inventory type -> rough slot count.
457+
// Mojang inventory_type values: 0..5 = generic
458+
// chests (9..54), 6=hopper(5), 13=furnace(3),
459+
// 14=crafting(10), etc. Use a coarse fallback
460+
// table — the next WindowItems wholesale
461+
// overwrites the size anyway.
462+
let container_size = match pkt.inventory_type {
463+
0 => 9,
464+
1 => 18,
465+
2 => 27,
466+
3 => 36,
467+
4 => 45,
468+
5 => 54,
469+
6 => 5,
470+
13 => 3,
471+
14 => 10,
472+
_ => 27,
473+
};
474+
let mut inv = inventory.lock().await;
475+
inv.apply_open_screen(pkt.window_id as u8, container_size);
476+
}
477+
Ok(())
478+
}
479+
ID_CLOSE_WINDOW => {
480+
let mut inv = inventory.lock().await;
481+
inv.apply_close_window();
482+
Ok(())
483+
}
412484
ID_SYNC_PLAYER_POSITION => {
413485
if let Ok(pkt) = CbPosition::decode(&mut br) {
414486
// Flags bits: 0x01=x rel, 0x02=y rel, 0x04=z rel,

rust/src/bot/containers.rs

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use std::time::Duration;
1212

1313
use super::Bot;
1414
use crate::errors::ProtocolError;
15+
use crate::protocol::v763::packets::play::serverbound::block_place::BlockPlace;
1516
use crate::protocol::v763::packets::play::serverbound::close_window::CloseWindow;
1617

1718
impl Bot {
@@ -24,17 +25,34 @@ impl Bot {
2425
z: i32,
2526
timeout: Duration,
2627
) -> Result<u8, ProtocolError> {
27-
// Aim at the block, then use_item (which delegates to a
28-
// right-click). The server replies with OpenScreen + WindowItems
29-
// which the dispatcher picks up to populate InventoryState.
28+
// v0.3.1: aim at block centre, then send BlockPlace (the
29+
// serverbound "use item on block" / right-click-block packet).
30+
// The previous UseItem (right-click-air) path didn't open
31+
// chests on the live server. The server responds with
32+
// OpenScreen + WindowItems, picked up by the dispatcher to
33+
// populate InventoryState.
3034
self.look_at(x as f64 + 0.5, y as f64 + 0.5, z as f64 + 0.5)
3135
.await?;
32-
self.use_item(0).await?;
33-
// Wait up to `timeout` for the dispatcher to set window_id.
36+
let pre_wid = self.inventory.lock().await.window_id;
37+
self.connection
38+
.send(&BlockPlace {
39+
hand: 0,
40+
location: (x, y, z),
41+
direction: 1, // top face — works for any container
42+
cursor_x: 0.5,
43+
cursor_y: 0.5,
44+
cursor_z: 0.5,
45+
inside_block: false,
46+
sequence: 0,
47+
})
48+
.await?;
49+
// Wait up to `timeout` for the dispatcher to assign a new
50+
// window_id. Pre-existing window_id (rare; bot was already
51+
// in a window) is treated as "we already have one".
3452
let deadline = std::time::Instant::now() + timeout;
3553
while std::time::Instant::now() < deadline {
3654
let wid = self.inventory.lock().await.window_id;
37-
if wid != 0 {
55+
if wid != pre_wid && wid != 0 {
3856
return Ok(wid);
3957
}
4058
tokio::time::sleep(Duration::from_millis(50)).await;

rust/src/bot/inventory.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,21 @@ impl Bot {
297297
changed_slots: Vec::new(),
298298
carried_item: None,
299299
})
300-
.await
300+
.await?;
301+
// v0.3.1: wait briefly (<= 200 ms) for SetSlot/WindowItems to
302+
// bump state_id. 1.20.1 has no WindowConfirmation packet; the
303+
// state_id field on every click serves that role. Paper's
304+
// anti-cheat rejects clicks whose state_id is stale, so
305+
// pacing rapid clicks against the server echo prevents
306+
// desync.
307+
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(200);
308+
while std::time::Instant::now() < deadline {
309+
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
310+
if self.inventory.lock().await.state_id != state_id {
311+
break;
312+
}
313+
}
314+
Ok(())
301315
}
302316

303317
/// Move the entire stack at `src` to `dst` via pick-up + put-down

rust/src/bot/tasks.rs

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,12 @@ impl Bot {
6868
self.swing_arm(0).await
6969
}
7070

71-
/// Eat the first food item in the hotbar. MVP: select food slot,
72-
/// send use_item, wait ~1.5s for eating animation, return.
71+
/// Eat the first food item in the hotbar. v0.3.1: subscribe to
72+
/// `EntityStatus(status=9)` (player finished using item) via
73+
/// the packet-hook mechanism, send `use_item`, await the status,
74+
/// fall back to the timeout if the server is slow.
7375
pub async fn eat(&self, timeout: Duration) -> Result<(), ProtocolError> {
7476
let foods = food_table();
75-
// Find a food item in player_slots.
7677
let mut food_slot: Option<u8> = None;
7778
{
7879
let inv = self.inventory.lock().await;
@@ -91,11 +92,48 @@ impl Bot {
9192
return Err(ProtocolError::DecodeError("eat: no food in hotbar".into()));
9293
}
9394
};
95+
let bot_eid = self.state.lock().await.entity_id.unwrap_or(0);
9496
self.select_slot(slot).await?;
97+
98+
// Register EntityStatus listener before sending use_item.
99+
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
100+
let tx_arc = std::sync::Arc::new(std::sync::Mutex::new(Some(tx)));
101+
let tx_clone = tx_arc.clone();
102+
self.on_packet(
103+
0x1C,
104+
Box::new(move |_id, body| {
105+
// EntityStatus body: VarInt(entity_id), i8(status).
106+
let mut val: i32 = 0;
107+
let mut bits = 0;
108+
let mut idx = 0;
109+
while idx < 5.min(body.len()) {
110+
let b = body[idx];
111+
val |= ((b & 0x7F) as i32) << bits;
112+
idx += 1;
113+
if (b & 0x80) == 0 {
114+
break;
115+
}
116+
bits += 7;
117+
}
118+
if val != bot_eid || idx >= body.len() {
119+
return;
120+
}
121+
let status = body[idx] as i8;
122+
// Mojang status code 9 = "player finished using item".
123+
if status == 9 {
124+
if let Ok(mut g) = tx_clone.lock() {
125+
if let Some(tx) = g.take() {
126+
let _ = tx.send(());
127+
}
128+
}
129+
}
130+
}),
131+
)
132+
.await;
133+
95134
self.use_item(0).await?;
96-
// Eating animation is ~1.6s for most foods. Wait up to `timeout`.
97-
let dt = timeout.min(Duration::from_millis(1600));
98-
tokio::time::sleep(dt).await;
135+
// Await EntityStatus(9) or fall back to `timeout`.
136+
let _ = tokio::time::timeout(timeout, rx).await;
99137
Ok(())
100138
}
101139

0 commit comments

Comments
 (0)