Rough click slot packet validation (#293)

## Description

This adds some validation for incoming inventory packets that makes it
so that you can't just spawn items by sending malicious packets. It adds
type 1 and type 2 validations as outlined in #292.

This also adds some new helpers, `InventoryWindow` and
`InventoryWindowMut`.

fixes #292

<details>

<summary>Playground</summary>

```rust
use valence::client::{default_event_handler, despawn_disconnected_clients};
use valence::prelude::event::PlayerInteractBlock;
use valence::prelude::*;

#[allow(unused_imports)]
use crate::extras::*;

const SPAWN_Y: i32 = 64;
const CHEST_POS: [i32; 3] = [0, SPAWN_Y + 1, 3];

pub fn build_app(app: &mut App) {
    app.add_plugin(ServerPlugin::new(()).with_connection_mode(ConnectionMode::Offline))
        .add_startup_system(setup)
        .add_system(default_event_handler.in_schedule(EventLoopSchedule))
        .add_system(init_clients)
        .add_system(despawn_disconnected_clients)
        .add_systems((toggle_gamemode_on_sneak, open_chest).in_schedule(EventLoopSchedule));
}

fn setup(mut commands: Commands, server: Res<Server>) {
    let mut instance = server.new_instance(DimensionId::default());

    for z in -5..5 {
        for x in -5..5 {
            instance.insert_chunk([x, z], Chunk::default());
        }
    }

    for z in -25..25 {
        for x in -25..25 {
            instance.set_block([x, SPAWN_Y, z], BlockState::GRASS_BLOCK);
        }
    }
    instance.set_block(CHEST_POS, BlockState::CHEST);

    commands.spawn(instance);

    let mut inventory = Inventory::new(InventoryKind::Generic9x3);
    inventory.set_slot(0, ItemStack::new(ItemKind::Apple, 100, None));
    inventory.set_slot(1, ItemStack::new(ItemKind::Diamond, 40, None));
    inventory.set_slot(2, ItemStack::new(ItemKind::Diamond, 30, None));
    commands.spawn(inventory);
}

fn init_clients(
    mut clients: Query<(&mut Position, &mut Location, &mut Inventory), Added<Client>>,
    instances: Query<Entity, With<Instance>>,
) {
    for (mut pos, mut loc, mut inv) in &mut clients {
        pos.0 = [0.5, SPAWN_Y as f64 + 1.0, 0.5].into();
        loc.0 = instances.single();

        inv.set_slot(24, ItemStack::new(ItemKind::Apple, 100, None));
        inv.set_slot(25, ItemStack::new(ItemKind::Apple, 10, None));
    }
}

// Add new systems here!

fn open_chest(
    mut commands: Commands,
    inventories: Query<Entity, (With<Inventory>, Without<Client>)>,
    mut events: EventReader<PlayerInteractBlock>,
) {
    let Ok(inventory) = inventories.get_single() else {
        return;
    };

    for event in events.iter() {
        if event.position != CHEST_POS.into() {
            continue;
        }
        let open_inventory = OpenInventory::new(inventory);
        commands.entity(event.client).insert(open_inventory);
    }
}

```

</details>

## Test Plan

Steps:
1. `cargo test`

---------

Co-authored-by: Ryan Johnson <ryanj00a@gmail.com>
This commit is contained in:
Carson McManus 2023-03-24 08:53:32 -04:00 committed by GitHub
parent 8d93ddee24
commit a57800959f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 1144 additions and 20 deletions

View file

@ -1,3 +1,4 @@
use std::borrow::Cow;
use std::cmp; use std::cmp;
use anyhow::bail; use anyhow::bail;
@ -8,7 +9,7 @@ use bevy_ecs::schedule::ScheduleLabel;
use bevy_ecs::system::{SystemParam, SystemState}; use bevy_ecs::system::{SystemParam, SystemState};
use glam::{DVec3, Vec3}; use glam::{DVec3, Vec3};
use paste::paste; use paste::paste;
use tracing::warn; use tracing::{debug, warn};
use uuid::Uuid; use uuid::Uuid;
use valence_protocol::block_pos::BlockPos; use valence_protocol::block_pos::BlockPos;
use valence_protocol::ident::Ident; use valence_protocol::ident::Ident;
@ -27,15 +28,19 @@ use valence_protocol::packet::c2s::play::update_structure_block::{
use valence_protocol::packet::c2s::play::{ use valence_protocol::packet::c2s::play::{
AdvancementTabC2s, ClientStatusC2s, ResourcePackStatusC2s, UpdatePlayerAbilitiesC2s, AdvancementTabC2s, ClientStatusC2s, ResourcePackStatusC2s, UpdatePlayerAbilitiesC2s,
}; };
use valence_protocol::packet::s2c::play::InventoryS2c;
use valence_protocol::packet::C2sPlayPacket; use valence_protocol::packet::C2sPlayPacket;
use valence_protocol::types::{Difficulty, Direction, Hand}; use valence_protocol::types::{Difficulty, Direction, Hand};
use valence_protocol::var_int::VarInt;
use super::{ use super::{
CursorItem, KeepaliveState, PlayerActionSequence, PlayerInventoryState, TeleportState, CursorItem, KeepaliveState, PlayerActionSequence, PlayerInventoryState, TeleportState,
}; };
use crate::client::Client; use crate::client::Client;
use crate::component::{Look, OnGround, Ping, Position}; use crate::component::{Look, OnGround, Ping, Position};
use crate::inventory::Inventory; use crate::inventory::{Inventory, InventorySettings};
use crate::packet::WritePacket;
use crate::prelude::OpenInventory;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct QueryBlockNbt { pub struct QueryBlockNbt {
@ -671,6 +676,7 @@ pub(crate) struct EventLoopQuery {
keepalive_state: &'static mut KeepaliveState, keepalive_state: &'static mut KeepaliveState,
cursor_item: &'static mut CursorItem, cursor_item: &'static mut CursorItem,
inventory: &'static mut Inventory, inventory: &'static mut Inventory,
open_inventory: Option<&'static mut OpenInventory>,
position: &'static mut Position, position: &'static mut Position,
look: &'static mut Look, look: &'static mut Look,
on_ground: &'static mut OnGround, on_ground: &'static mut OnGround,
@ -682,10 +688,17 @@ pub(crate) struct EventLoopQuery {
/// An exclusive system for running the event loop schedule. /// An exclusive system for running the event loop schedule.
fn run_event_loop( fn run_event_loop(
world: &mut World, world: &mut World,
state: &mut SystemState<(Query<EventLoopQuery>, ClientEvents, Commands)>, state: &mut SystemState<(
Query<EventLoopQuery>,
ClientEvents,
Commands,
Query<&Inventory, Without<Client>>,
Res<InventorySettings>,
)>,
mut clients_to_check: Local<Vec<Entity>>, mut clients_to_check: Local<Vec<Entity>>,
) { ) {
let (mut clients, mut events, mut commands) = state.get_mut(world); let (mut clients, mut events, mut commands, mut inventories, inventory_settings) =
state.get_mut(world);
update_all_event_buffers(&mut events); update_all_event_buffers(&mut events);
@ -703,7 +716,7 @@ fn run_event_loop(
q.client.dec.queue_bytes(bytes); q.client.dec.queue_bytes(bytes);
match handle_one_packet(&mut q, &mut events) { match handle_one_packet(&mut q, &mut events, &mut inventories, &inventory_settings) {
Ok(had_packet) => { Ok(had_packet) => {
if had_packet { if had_packet {
// We decoded one packet, but there might be more. // We decoded one packet, but there might be more.
@ -723,7 +736,8 @@ fn run_event_loop(
while !clients_to_check.is_empty() { while !clients_to_check.is_empty() {
world.run_schedule(EventLoopSchedule); world.run_schedule(EventLoopSchedule);
let (mut clients, mut events, mut commands) = state.get_mut(world); let (mut clients, mut events, mut commands, mut inventories, inventory_settings) =
state.get_mut(world);
clients_to_check.retain(|&entity| { clients_to_check.retain(|&entity| {
let Ok(mut q) = clients.get_mut(entity) else { let Ok(mut q) = clients.get_mut(entity) else {
@ -731,7 +745,7 @@ fn run_event_loop(
return false; return false;
}; };
match handle_one_packet(&mut q, &mut events) { match handle_one_packet(&mut q, &mut events, &mut inventories, &inventory_settings) {
Ok(had_packet) => had_packet, Ok(had_packet) => had_packet,
Err(e) => { Err(e) => {
warn!("failed to dispatch events for client {:?}: {e:?}", q.entity); warn!("failed to dispatch events for client {:?}: {e:?}", q.entity);
@ -748,6 +762,8 @@ fn run_event_loop(
fn handle_one_packet( fn handle_one_packet(
q: &mut EventLoopQueryItem, q: &mut EventLoopQueryItem,
events: &mut ClientEvents, events: &mut ClientEvents,
inventories: &mut Query<&Inventory, Without<Client>>,
inventory_settings: &Res<InventorySettings>,
) -> anyhow::Result<bool> { ) -> anyhow::Result<bool> {
let Some(pkt) = q.client.dec.try_next_packet::<C2sPlayPacket>()? else { let Some(pkt) = q.client.dec.try_next_packet::<C2sPlayPacket>()? else {
// No packets to decode. // No packets to decode.
@ -847,7 +863,57 @@ fn handle_one_packet(
}); });
} }
C2sPlayPacket::ClickSlotC2s(p) => { C2sPlayPacket::ClickSlotC2s(p) => {
if p.slot_idx < 0 { let open_inv = q
.open_inventory
.as_ref()
.and_then(|open| inventories.get_mut(open.entity).ok());
if let Err(msg) =
crate::inventory::validate_click_slot_impossible(&p, &q.inventory, open_inv)
{
debug!(
"client {:#?} invalid click slot packet: \"{}\" {:#?}",
q.entity, msg, p
);
let inventory = open_inv.unwrap_or(&q.inventory);
q.client.write_packet(&InventoryS2c {
window_id: if open_inv.is_some() {
q.player_inventory_state.window_id
} else {
0
},
state_id: VarInt(q.player_inventory_state.state_id.0),
slots: Cow::Borrowed(inventory.slot_slice()),
carried_item: Cow::Borrowed(&q.cursor_item.0),
});
return Ok(true);
}
if inventory_settings.enable_item_dupe_check {
if let Err(msg) = crate::inventory::validate_click_slot_item_duplication(
&p,
&q.inventory,
open_inv,
&q.cursor_item,
) {
debug!(
"client {:#?} click slot packet tried to incorrectly modify items: \"{}\" \
{:#?}",
q.entity, msg, p
);
let inventory = open_inv.unwrap_or(&q.inventory);
q.client.write_packet(&InventoryS2c {
window_id: if open_inv.is_some() {
q.player_inventory_state.window_id
} else {
0
},
state_id: VarInt(q.player_inventory_state.state_id.0),
slots: Cow::Borrowed(inventory.slot_slice()),
carried_item: Cow::Borrowed(&q.cursor_item.0),
});
return Ok(true);
}
}
if p.slot_idx < 0 && p.mode == ClickMode::Click {
if let Some(stack) = q.cursor_item.0.take() { if let Some(stack) = q.cursor_item.0.take() {
events.2.drop_item_stack.send(DropItemStack { events.2.drop_item_stack.send(DropItemStack {
client: entity, client: entity,

View file

@ -49,6 +49,14 @@ use crate::component::GameMode;
use crate::packet::WritePacket; use crate::packet::WritePacket;
use crate::prelude::FlushPacketsSet; use crate::prelude::FlushPacketsSet;
mod validate;
pub(crate) use validate::*;
/// The number of slots in the "main" part of the player inventory. 3 rows of 9,
/// plus the hotbar.
pub const PLAYER_INVENTORY_MAIN_SLOTS_COUNT: u16 = 36;
#[derive(Debug, Clone, Component)] #[derive(Debug, Clone, Component)]
pub struct Inventory { pub struct Inventory {
title: Text, title: Text,
@ -237,7 +245,7 @@ impl Inventory {
std::mem::replace(&mut self.title, title.into()) std::mem::replace(&mut self.title, title.into())
} }
fn slot_slice(&self) -> &[Option<ItemStack>] { pub(crate) fn slot_slice(&self) -> &[Option<ItemStack>] {
self.slots.as_ref() self.slots.as_ref()
} }
@ -303,6 +311,141 @@ impl OpenInventory {
} }
} }
/// A helper to represent the inventory window that the player is currently
/// viewing. Handles dispatching reads to the correct inventory.
///
/// This is a read-only version of [`InventoryWindowMut`].
///
/// ```
/// # use valence::prelude::*;
/// let mut player_inventory = Inventory::new(InventoryKind::Player);
/// player_inventory.set_slot(36, ItemStack::new(ItemKind::Diamond, 1, None));
/// let target_inventory = Inventory::new(InventoryKind::Generic9x3);
/// let window = InventoryWindow::new(&player_inventory, Some(&target_inventory));
/// assert_eq!(
/// window.slot(54),
/// Some(&ItemStack::new(ItemKind::Diamond, 1, None))
/// );
/// ```
pub struct InventoryWindow<'a> {
player_inventory: &'a Inventory,
open_inventory: Option<&'a Inventory>,
}
impl<'a> InventoryWindow<'a> {
pub fn new(player_inventory: &'a Inventory, open_inventory: Option<&'a Inventory>) -> Self {
Self {
player_inventory,
open_inventory,
}
}
#[track_caller]
pub fn slot(&self, idx: u16) -> Option<&ItemStack> {
if let Some(open_inv) = self.open_inventory.as_ref() {
if idx < open_inv.slot_count() {
return open_inv.slot(idx);
} else {
return self
.player_inventory
.slot(convert_to_player_slot_id(open_inv.kind(), idx));
}
} else {
return self.player_inventory.slot(idx);
}
}
#[track_caller]
pub fn slot_count(&self) -> u16 {
match self.open_inventory.as_ref() {
Some(inv) => inv.slot_count() + PLAYER_INVENTORY_MAIN_SLOTS_COUNT,
None => self.player_inventory.slot_count(),
}
}
}
/// A helper to represent the inventory window that the player is currently
/// viewing. Handles dispatching reads/writes to the correct inventory.
///
/// This is a writable version of [`InventoryWindow`].
///
/// ```
/// # use valence::prelude::*;
/// let mut player_inventory = Inventory::new(InventoryKind::Player);
/// let mut target_inventory = Inventory::new(InventoryKind::Generic9x3);
/// let mut window = InventoryWindowMut::new(&mut player_inventory, Some(&mut target_inventory));
/// window.set_slot(54, ItemStack::new(ItemKind::Diamond, 1, None));
/// assert_eq!(
/// player_inventory.slot(36),
/// Some(&ItemStack::new(ItemKind::Diamond, 1, None))
/// );
/// ```
pub struct InventoryWindowMut<'a> {
player_inventory: &'a mut Inventory,
open_inventory: Option<&'a mut Inventory>,
}
impl<'a> InventoryWindowMut<'a> {
pub fn new(
player_inventory: &'a mut Inventory,
open_inventory: Option<&'a mut Inventory>,
) -> Self {
Self {
player_inventory,
open_inventory,
}
}
#[track_caller]
pub fn slot(&self, idx: u16) -> Option<&ItemStack> {
if let Some(open_inv) = self.open_inventory.as_ref() {
if idx < open_inv.slot_count() {
return open_inv.slot(idx);
} else {
return self
.player_inventory
.slot(convert_to_player_slot_id(open_inv.kind(), idx));
}
} else {
return self.player_inventory.slot(idx);
}
}
#[track_caller]
#[must_use]
pub fn replace_slot(
&mut self,
idx: u16,
item: impl Into<Option<ItemStack>>,
) -> Option<ItemStack> {
assert!(idx < self.slot_count(), "slot index of {idx} out of bounds");
if let Some(open_inv) = self.open_inventory.as_mut() {
if idx < open_inv.slot_count() {
open_inv.replace_slot(idx, item)
} else {
self.player_inventory
.replace_slot(convert_to_player_slot_id(open_inv.kind(), idx), item)
}
} else {
self.player_inventory.replace_slot(idx, item)
}
}
#[track_caller]
#[inline]
pub fn set_slot(&mut self, idx: u16, item: impl Into<Option<ItemStack>>) {
let _ = self.replace_slot(idx, item);
}
pub fn slot_count(&self) -> u16 {
match self.open_inventory.as_ref() {
Some(inv) => inv.slot_count() + PLAYER_INVENTORY_MAIN_SLOTS_COUNT,
None => self.player_inventory.slot_count(),
}
}
}
pub(crate) struct InventoryPlugin; pub(crate) struct InventoryPlugin;
impl Plugin for InventoryPlugin { impl Plugin for InventoryPlugin {
@ -574,7 +717,7 @@ fn handle_click_container(
continue; continue;
} }
cursor_item.0 = event.carried_item.clone(); cursor_item.set_if_neq(CursorItem(event.carried_item.clone()));
for slot in event.slot_changes.clone() { for slot in event.slot_changes.clone() {
if (0i16..target_inventory.slot_count() as i16).contains(&slot.idx) { if (0i16..target_inventory.slot_count() as i16).contains(&slot.idx) {
@ -608,8 +751,6 @@ fn handle_click_container(
continue; continue;
} }
// TODO: do more validation on the click
cursor_item.set_if_neq(CursorItem(event.carried_item.clone())); cursor_item.set_if_neq(CursorItem(event.carried_item.clone()));
inv_state.client_updated_cursor_item = true; inv_state.client_updated_cursor_item = true;
@ -692,7 +833,7 @@ fn convert_to_player_slot_id(target_kind: InventoryKind, slot_id: u16) -> u16 {
} }
fn convert_hotbar_slot_id(slot_id: u16) -> u16 { fn convert_hotbar_slot_id(slot_id: u16) -> u16 {
slot_id + 36 slot_id + PLAYER_INVENTORY_MAIN_SLOTS_COUNT
} }
#[derive(Copy, Clone, PartialEq, Eq, Debug)] #[derive(Copy, Clone, PartialEq, Eq, Debug)]
@ -823,10 +964,25 @@ impl From<WindowType> for InventoryKind {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Resource)]
pub struct InventorySettings {
pub enable_item_dupe_check: bool,
}
impl Default for InventorySettings {
fn default() -> Self {
Self {
enable_item_dupe_check: true,
}
}
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use bevy_app::App; use bevy_app::App;
use valence_protocol::item::ItemKind; use valence_protocol::item::ItemKind;
use valence_protocol::packet::c2s::play::click_slot::{ClickMode, Slot};
use valence_protocol::packet::c2s::play::ClickSlotC2s;
use valence_protocol::packet::S2cPlayPacket; use valence_protocol::packet::S2cPlayPacket;
use super::*; use super::*;
@ -1098,6 +1254,11 @@ mod test {
let mut app = App::new(); let mut app = App::new();
let (client_ent, mut client_helper) = scenario_single_client(&mut app); let (client_ent, mut client_helper) = scenario_single_client(&mut app);
let inventory_ent = set_up_open_inventory(&mut app, client_ent); let inventory_ent = set_up_open_inventory(&mut app, client_ent);
let mut inventory = app
.world
.get_mut::<Inventory>(inventory_ent)
.expect("could not find inventory for client");
inventory.set_slot(20, ItemStack::new(ItemKind::Diamond, 2, None));
// Process a tick to get past the "on join" logic. // Process a tick to get past the "on join" logic.
app.update(); app.update();
@ -1342,7 +1503,7 @@ mod test {
mod dropping_items { mod dropping_items {
use valence_protocol::block_pos::BlockPos; use valence_protocol::block_pos::BlockPos;
use valence_protocol::packet::c2s::play::click_slot::ClickMode; use valence_protocol::packet::c2s::play::click_slot::{ClickMode, Slot};
use valence_protocol::packet::c2s::play::player_action::Action; use valence_protocol::packet::c2s::play::player_action::Action;
use valence_protocol::types::Direction; use valence_protocol::types::Direction;
@ -1567,7 +1728,10 @@ mod test {
button: 0, button: 0,
mode: ClickMode::DropKey, mode: ClickMode::DropKey,
state_id: VarInt(state_id), state_id: VarInt(state_id),
slots: vec![], slots: vec![Slot {
idx: 40,
item: Some(ItemStack::new(ItemKind::IronIngot, 31, None)),
}],
carried_item: None, carried_item: None,
}); });
@ -1615,7 +1779,10 @@ mod test {
button: 1, // pressing control button: 1, // pressing control
mode: ClickMode::DropKey, mode: ClickMode::DropKey,
state_id: VarInt(state_id), state_id: VarInt(state_id),
slots: vec![], slots: vec![Slot {
idx: 40,
item: None,
}],
carried_item: None, carried_item: None,
}); });
@ -1638,4 +1805,69 @@ mod test {
Ok(()) Ok(())
} }
} }
#[test]
fn dragging_items() -> anyhow::Result<()> {
let mut app = App::new();
let (client_ent, mut client_helper) = scenario_single_client(&mut app);
app.world.get_mut::<CursorItem>(client_ent).unwrap().0 =
Some(ItemStack::new(ItemKind::Diamond, 64, None));
// Process a tick to get past the "on join" logic.
app.update();
client_helper.clear_sent();
let inv_state = app.world.get::<PlayerInventoryState>(client_ent).unwrap();
let window_id = inv_state.window_id;
let state_id = inv_state.state_id.0;
let drag_packet = ClickSlotC2s {
window_id,
state_id: VarInt(state_id),
slot_idx: -999,
button: 2,
mode: ClickMode::Drag,
slots: vec![
Slot {
idx: 9,
item: Some(ItemStack::new(ItemKind::Diamond, 21, None)),
},
Slot {
idx: 10,
item: Some(ItemStack::new(ItemKind::Diamond, 21, None)),
},
Slot {
idx: 11,
item: Some(ItemStack::new(ItemKind::Diamond, 21, None)),
},
],
carried_item: Some(ItemStack::new(ItemKind::Diamond, 1, None)),
};
client_helper.send(&drag_packet);
app.update();
let sent_packets = client_helper.collect_sent()?;
assert_eq!(sent_packets.len(), 0);
let cursor_item = app
.world
.get::<CursorItem>(client_ent)
.expect("could not find client");
assert_eq!(
cursor_item.0,
Some(ItemStack::new(ItemKind::Diamond, 1, None))
);
let inventory = app
.world
.get::<Inventory>(client_ent)
.expect("could not find inventory");
for i in 9..12 {
assert_eq!(
inventory.slot(i),
Some(&ItemStack::new(ItemKind::Diamond, 21, None))
);
}
Ok(())
}
} }

View file

@ -0,0 +1,821 @@
use anyhow::{bail, ensure};
use valence_protocol::packet::c2s::play::click_slot::ClickMode;
use valence_protocol::packet::c2s::play::ClickSlotC2s;
use super::{Inventory, InventoryWindow, PLAYER_INVENTORY_MAIN_SLOTS_COUNT};
use crate::prelude::CursorItem;
/// Validates a click slot packet enforcing that all fields are valid.
pub(crate) fn validate_click_slot_impossible(
packet: &ClickSlotC2s,
player_inventory: &Inventory,
open_inventory: Option<&Inventory>,
) -> anyhow::Result<()> {
ensure!(
(packet.window_id == 0) == open_inventory.is_none(),
"window id and open inventory mismatch: window_id: {} open_inventory: {}",
packet.window_id,
open_inventory.is_some()
);
let max_slot = match open_inventory {
Some(inv) => inv.slot_count() + PLAYER_INVENTORY_MAIN_SLOTS_COUNT,
None => player_inventory.slot_count(),
};
// check all slot ids and item counts are valid
ensure!(
packet.slots.iter().all(|s| {
if !(0..=max_slot).contains(&(s.idx as u16)) {
return false;
}
if let Some(slot) = s.item.as_ref() {
let max_stack_size = slot
.item
.max_stack()
.max(slot.count())
.min(valence_protocol::item::STACK_MAX);
if !(1..=max_stack_size).contains(&slot.count()) {
return false;
}
}
true
}),
"invalid slot ids or item counts"
);
// check carried item count is valid
if let Some(carried_item) = &packet.carried_item {
let max_stack_size = carried_item
.item
.max_stack()
.max(carried_item.count())
.min(valence_protocol::item::STACK_MAX);
ensure!(
(1..=max_stack_size).contains(&carried_item.count()),
"invalid carried item count"
);
}
match packet.mode {
ClickMode::Click => {
ensure!((0..=1).contains(&packet.button), "invalid button");
ensure!(
(0..=max_slot).contains(&(packet.slot_idx as u16)) || packet.slot_idx == -999,
"invalid slot index"
)
}
ClickMode::ShiftClick => {
ensure!((0..=1).contains(&packet.button), "invalid button");
ensure!(
packet.carried_item.is_none(),
"carried item must be empty for a hotbar swap"
);
ensure!(
(0..=max_slot).contains(&(packet.slot_idx as u16)),
"invalid slot index"
)
}
ClickMode::Hotbar => {
ensure!(matches!(packet.button, 0..=8 | 40), "invalid button");
ensure!(
packet.carried_item.is_none(),
"carried item must be empty for a hotbar swap"
);
}
ClickMode::CreativeMiddleClick => {
ensure!(packet.button == 2, "invalid button");
ensure!(
(0..=max_slot).contains(&(packet.slot_idx as u16)),
"invalid slot index"
)
}
ClickMode::DropKey => {
ensure!((0..=1).contains(&packet.button), "invalid button");
ensure!(
packet.carried_item.is_none(),
"carried item must be empty for an item drop"
);
ensure!(
(0..=max_slot).contains(&(packet.slot_idx as u16)),
"invalid slot index"
)
}
ClickMode::Drag => {
ensure!(
matches!(packet.button, 0..=2 | 4..=6 | 8..=10),
"invalid button"
);
ensure!(
(0..=max_slot).contains(&(packet.slot_idx as u16)) || packet.slot_idx == -999,
"invalid slot index"
)
}
ClickMode::DoubleClick => ensure!(packet.button == 0, "invalid button"),
}
Ok(())
}
/// Validates a click slot packet, enforcing that items can't be duplicated, eg.
/// conservation of mass.
///
/// Relies on assertions made by [`validate_click_slot_impossible`].
pub(crate) fn validate_click_slot_item_duplication(
packet: &ClickSlotC2s,
player_inventory: &Inventory,
open_inventory: Option<&Inventory>,
cursor_item: &CursorItem,
) -> anyhow::Result<()> {
let window = InventoryWindow {
player_inventory,
open_inventory,
};
match packet.mode {
ClickMode::Click => {
if packet.slot_idx == -999 {
// Clicked outside the window, so the client is dropping an item
ensure!(packet.slots.is_empty(), "slot modifications must be empty");
// Clicked outside the window
let count_deltas = calculate_net_item_delta(packet, &window, cursor_item);
let expected_delta = match packet.button {
1 => -1,
0 => -cursor_item
.0
.as_ref()
.map(|s| s.count() as i32)
.unwrap_or(0),
_ => unreachable!(),
};
ensure!(
count_deltas == expected_delta,
"invalid item delta: expected {}, got {}",
expected_delta,
count_deltas
);
} else {
ensure!(
packet.slots.len() == 1,
"click must modify one slot, got {}",
packet.slots.len()
);
let old_slot = window.slot(packet.slots[0].idx as u16);
// TODO: make sure NBT is the same
// Sometimes, the client will add nbt data to an item if it's missing, like
// "Damage" to a sword
let should_swap = packet.button == 0
&& match (old_slot, cursor_item.0.as_ref()) {
(Some(old_slot), Some(cursor_item)) => old_slot.item != cursor_item.item,
(Some(_), None) => true,
(None, Some(cursor_item)) => {
cursor_item.count() <= cursor_item.item.max_stack()
}
(None, None) => false,
};
if should_swap {
// assert that a swap occurs
ensure!(
old_slot == packet.carried_item.as_ref()
&& cursor_item.0 == packet.slots[0].item,
"swapped items must match"
);
} else {
// assert that a merge occurs
let count_deltas = calculate_net_item_delta(packet, &window, cursor_item);
ensure!(
count_deltas == 0,
"invalid item delta for stack merge: {}",
count_deltas
);
}
}
}
ClickMode::ShiftClick => {
ensure!(
(2..=3).contains(&packet.slots.len()),
"shift click must modify 2 or 3 slots, got {}",
packet.slots.len()
);
let count_deltas = calculate_net_item_delta(packet, &window, cursor_item);
ensure!(
count_deltas == 0,
"invalid item delta: expected 0, got {}",
count_deltas
);
let Some(item_kind) = packet
.slots
.iter()
.filter_map(|s| s.item.as_ref())
.next()
.map(|s| s.item) else {
bail!("shift click must move an item");
};
let Some(old_slot_kind) = window.slot(packet.slot_idx as u16).map(|s| s.item) else {
bail!("shift click must move an item");
};
ensure!(
old_slot_kind == item_kind,
"shift click must move the same item kind as modified slots"
);
// assert all moved items are the same kind
ensure!(
packet
.slots
.iter()
.filter_map(|s| s.item.as_ref())
.all(|s| s.item == item_kind),
"shift click must move the same item kind"
);
}
ClickMode::Hotbar => {
ensure!(
packet.slots.len() == 2,
"hotbar swap must modify two slots, got {}",
packet.slots.len()
);
let count_deltas = calculate_net_item_delta(packet, &window, cursor_item);
ensure!(
count_deltas == 0,
"invalid item delta: expected 0, got {}",
count_deltas
);
// assert that a swap occurs
let old_slots = [
window.slot(packet.slots[0].idx as u16),
window.slot(packet.slots[1].idx as u16),
];
ensure!(
old_slots
.iter()
.any(|s| s == &packet.slots[0].item.as_ref())
&& old_slots
.iter()
.any(|s| s == &packet.slots[1].item.as_ref()),
"swapped items must match"
);
}
ClickMode::CreativeMiddleClick => {}
ClickMode::DropKey => {
ensure!(
packet.slots.len() == 1,
"drop key must modify exactly one slot"
);
ensure!(
packet.slot_idx == packet.slots.first().map(|s| s.idx).unwrap_or(-2),
"slot index does not match modified slot"
);
let old_slot = window.slot(packet.slot_idx as u16);
let new_slot = packet.slots[0].item.as_ref();
let is_transmuting = match (old_slot, new_slot) {
// TODO: make sure NBT is the same
// Sometimes, the client will add nbt data to an item if it's missing, like "Damage"
// to a sword
(Some(old_slot), Some(new_slot)) => old_slot.item != new_slot.item,
(_, None) => false,
(None, Some(_)) => true,
};
ensure!(!is_transmuting, "transmuting items is not allowed");
let count_deltas = calculate_net_item_delta(packet, &window, cursor_item);
let expected_delta = match packet.button {
0 => -1,
1 => -old_slot.map(|s| s.count() as i32).unwrap_or(0),
_ => unreachable!(),
};
ensure!(
count_deltas == expected_delta,
"invalid item delta: expected {}, got {}",
expected_delta,
count_deltas
);
}
ClickMode::Drag => {
if matches!(packet.button, 2 | 6 | 10) {
let count_deltas = calculate_net_item_delta(packet, &window, cursor_item);
ensure!(
count_deltas == 0,
"invalid item delta: expected 0, got {}",
count_deltas
);
} else {
ensure!(packet.slots.is_empty() && packet.carried_item == cursor_item.0);
}
}
ClickMode::DoubleClick => {
let count_deltas = calculate_net_item_delta(packet, &window, cursor_item);
ensure!(
count_deltas == 0,
"invalid item delta: expected 0, got {}",
count_deltas
);
}
}
Ok(())
}
/// Calculate the total difference in item counts if the changes in this packet
/// were to be applied.
///
/// Returns a positive number if items were added to the window, and a negative
/// number if items were removed from the window.
fn calculate_net_item_delta(
packet: &ClickSlotC2s,
window: &InventoryWindow,
cursor_item: &CursorItem,
) -> i32 {
let mut net_item_delta: i32 = 0;
for slot in &packet.slots {
let old_slot = window.slot(slot.idx as u16);
let new_slot = slot.item.as_ref();
net_item_delta += match (old_slot, new_slot) {
(Some(old), Some(new)) => new.count() as i32 - old.count() as i32,
(Some(old), None) => -(old.count() as i32),
(None, Some(new)) => new.count() as i32,
(None, None) => 0,
};
}
net_item_delta += match (cursor_item.0.as_ref(), packet.carried_item.as_ref()) {
(Some(old), Some(new)) => new.count() as i32 - old.count() as i32,
(Some(old), None) => -(old.count() as i32),
(None, Some(new)) => new.count() as i32,
(None, None) => 0,
};
net_item_delta
}
#[cfg(test)]
mod test {
use valence_protocol::item::{ItemKind, ItemStack};
use valence_protocol::packet::c2s::play::click_slot::Slot;
use valence_protocol::var_int::VarInt;
use super::*;
use crate::prelude::InventoryKind;
#[test]
fn net_item_delta_1() {
let drag_packet = ClickSlotC2s {
window_id: 2,
state_id: VarInt(14),
slot_idx: -999,
button: 2,
mode: ClickMode::Drag,
slots: vec![
Slot {
idx: 4,
item: Some(ItemStack::new(ItemKind::Diamond, 21, None)),
},
Slot {
idx: 3,
item: Some(ItemStack::new(ItemKind::Diamond, 21, None)),
},
Slot {
idx: 5,
item: Some(ItemStack::new(ItemKind::Diamond, 21, None)),
},
],
carried_item: Some(ItemStack::new(ItemKind::Diamond, 1, None)),
};
let player_inventory = Inventory::new(InventoryKind::Player);
let inventory = Inventory::new(InventoryKind::Generic9x1);
let window = InventoryWindow::new(&player_inventory, Some(&inventory));
let cursor_item = CursorItem(Some(ItemStack::new(ItemKind::Diamond, 64, None)));
assert_eq!(
calculate_net_item_delta(&drag_packet, &window, &cursor_item),
0
);
}
#[test]
fn net_item_delta_2() {
let drag_packet = ClickSlotC2s {
window_id: 2,
state_id: VarInt(14),
slot_idx: -999,
button: 2,
mode: ClickMode::Click,
slots: vec![
Slot {
idx: 2,
item: Some(ItemStack::new(ItemKind::Diamond, 2, None)),
},
Slot {
idx: 3,
item: Some(ItemStack::new(ItemKind::IronIngot, 2, None)),
},
Slot {
idx: 4,
item: Some(ItemStack::new(ItemKind::GoldIngot, 2, None)),
},
Slot {
idx: 5,
item: Some(ItemStack::new(ItemKind::Emerald, 2, None)),
},
],
carried_item: Some(ItemStack::new(ItemKind::OakWood, 2, None)),
};
let player_inventory = Inventory::new(InventoryKind::Player);
let inventory = Inventory::new(InventoryKind::Generic9x1);
let window = InventoryWindow::new(&player_inventory, Some(&inventory));
let cursor_item = CursorItem::default();
assert_eq!(
calculate_net_item_delta(&drag_packet, &window, &cursor_item),
10
);
}
#[test]
fn click_filled_slot_with_empty_cursor_success() {
let player_inventory = Inventory::new(InventoryKind::Player);
let mut inventory = Inventory::new(InventoryKind::Generic9x1);
inventory.set_slot(0, ItemStack::new(ItemKind::Diamond, 20, None));
let cursor_item = CursorItem::default();
let packet = ClickSlotC2s {
window_id: 1,
button: 0,
mode: ClickMode::Click,
state_id: VarInt(0),
slot_idx: 0,
slots: vec![Slot { idx: 0, item: None }],
carried_item: inventory.slot(0).cloned(),
};
validate_click_slot_impossible(&packet, &player_inventory, Some(&inventory))
.expect("packet should be valid");
validate_click_slot_item_duplication(
&packet,
&player_inventory,
Some(&inventory),
&cursor_item,
)
.expect("packet should not fail item duplication check");
}
#[test]
fn click_slot_with_filled_cursor_success() {
let player_inventory = Inventory::new(InventoryKind::Player);
let inventory1 = Inventory::new(InventoryKind::Generic9x1);
let mut inventory2 = Inventory::new(InventoryKind::Generic9x1);
inventory2.set_slot(0, ItemStack::new(ItemKind::Diamond, 10, None));
let cursor_item = CursorItem(Some(ItemStack::new(ItemKind::Diamond, 20, None)));
let packet1 = ClickSlotC2s {
window_id: 1,
button: 0,
mode: ClickMode::Click,
state_id: VarInt(0),
slot_idx: 0,
slots: vec![Slot {
idx: 0,
item: Some(ItemStack::new(ItemKind::Diamond, 20, None)),
}],
carried_item: None,
};
let packet2 = ClickSlotC2s {
window_id: 1,
button: 0,
mode: ClickMode::Click,
state_id: VarInt(0),
slot_idx: 0,
slots: vec![Slot {
idx: 0,
item: Some(ItemStack::new(ItemKind::Diamond, 30, None)),
}],
carried_item: None,
};
validate_click_slot_impossible(&packet1, &player_inventory, Some(&inventory1))
.expect("packet should be valid");
validate_click_slot_item_duplication(
&packet1,
&player_inventory,
Some(&inventory1),
&cursor_item,
)
.expect("packet should not fail item duplication check");
validate_click_slot_impossible(&packet2, &player_inventory, Some(&inventory2))
.expect("packet should be valid");
validate_click_slot_item_duplication(
&packet2,
&player_inventory,
Some(&inventory2),
&cursor_item,
)
.expect("packet should not fail item duplication check");
}
#[test]
fn click_filled_slot_with_filled_cursor_stack_overflow_success() {
let player_inventory = Inventory::new(InventoryKind::Player);
let mut inventory = Inventory::new(InventoryKind::Generic9x1);
inventory.set_slot(0, ItemStack::new(ItemKind::Diamond, 20, None));
let cursor_item = CursorItem(Some(ItemStack::new(ItemKind::Diamond, 64, None)));
let packet = ClickSlotC2s {
window_id: 1,
button: 0,
mode: ClickMode::Click,
state_id: VarInt(0),
slot_idx: 0,
slots: vec![Slot {
idx: 0,
item: Some(ItemStack::new(ItemKind::Diamond, 64, None)),
}],
carried_item: Some(ItemStack::new(ItemKind::Diamond, 20, None)),
};
validate_click_slot_impossible(&packet, &player_inventory, Some(&inventory))
.expect("packet should be valid");
validate_click_slot_item_duplication(
&packet,
&player_inventory,
Some(&inventory),
&cursor_item,
)
.expect("packet should not fail item duplication check");
}
#[test]
fn click_filled_slot_with_filled_cursor_different_item_success() {
let player_inventory = Inventory::new(InventoryKind::Player);
let mut inventory = Inventory::new(InventoryKind::Generic9x1);
inventory.set_slot(0, ItemStack::new(ItemKind::IronIngot, 2, None));
let cursor_item = CursorItem(Some(ItemStack::new(ItemKind::Diamond, 2, None)));
let packet = ClickSlotC2s {
window_id: 1,
button: 0,
mode: ClickMode::Click,
state_id: VarInt(0),
slot_idx: 0,
slots: vec![Slot {
idx: 0,
item: Some(ItemStack::new(ItemKind::Diamond, 2, None)),
}],
carried_item: Some(ItemStack::new(ItemKind::IronIngot, 2, None)),
};
validate_click_slot_impossible(&packet, &player_inventory, Some(&inventory))
.expect("packet should be valid");
validate_click_slot_item_duplication(
&packet,
&player_inventory,
Some(&inventory),
&cursor_item,
)
.expect("packet should not fail item duplication check");
}
#[test]
fn click_slot_with_filled_cursor_failure() {
let player_inventory = Inventory::new(InventoryKind::Player);
let inventory1 = Inventory::new(InventoryKind::Generic9x1);
let mut inventory2 = Inventory::new(InventoryKind::Generic9x1);
inventory2.set_slot(0, ItemStack::new(ItemKind::Diamond, 10, None));
let cursor_item = CursorItem(Some(ItemStack::new(ItemKind::Diamond, 20, None)));
let packet1 = ClickSlotC2s {
window_id: 1,
button: 0,
mode: ClickMode::Click,
state_id: VarInt(0),
slot_idx: 0,
slots: vec![Slot {
idx: 0,
item: Some(ItemStack::new(ItemKind::Diamond, 22, None)),
}],
carried_item: None,
};
let packet2 = ClickSlotC2s {
window_id: 1,
button: 0,
mode: ClickMode::Click,
state_id: VarInt(0),
slot_idx: 0,
slots: vec![Slot {
idx: 0,
item: Some(ItemStack::new(ItemKind::Diamond, 32, None)),
}],
carried_item: None,
};
let packet3 = ClickSlotC2s {
window_id: 1,
button: 0,
mode: ClickMode::Click,
state_id: VarInt(0),
slot_idx: 0,
slots: vec![
Slot {
idx: 0,
item: Some(ItemStack::new(ItemKind::Diamond, 22, None)),
},
Slot {
idx: 1,
item: Some(ItemStack::new(ItemKind::Diamond, 22, None)),
},
],
carried_item: None,
};
validate_click_slot_impossible(&packet1, &player_inventory, Some(&inventory1))
.expect("packet 1 should be valid");
validate_click_slot_item_duplication(
&packet1,
&player_inventory,
Some(&inventory1),
&cursor_item,
)
.expect_err("packet 1 should fail item duplication check");
validate_click_slot_impossible(&packet2, &player_inventory, Some(&inventory2))
.expect("packet 2 should be valid");
validate_click_slot_item_duplication(
&packet2,
&player_inventory,
Some(&inventory2),
&cursor_item,
)
.expect_err("packet 2 should fail item duplication check");
validate_click_slot_impossible(&packet3, &player_inventory, Some(&inventory1))
.expect("packet 3 should be valid");
validate_click_slot_item_duplication(
&packet3,
&player_inventory,
Some(&inventory1),
&cursor_item,
)
.expect_err("packet 3 should fail item duplication check");
}
#[test]
fn disallow_item_transmutation() {
// no alchemy allowed - make sure that lead can't be turned into gold
let mut player_inventory = Inventory::new(InventoryKind::Player);
player_inventory.set_slot(9, ItemStack::new(ItemKind::Lead, 2, None));
let cursor_item = CursorItem::default();
let packets = vec![
ClickSlotC2s {
window_id: 0,
button: 0,
mode: ClickMode::ShiftClick,
state_id: VarInt(0),
slot_idx: 9,
slots: vec![
Slot { idx: 9, item: None },
Slot {
idx: 36,
item: Some(ItemStack::new(ItemKind::GoldIngot, 2, None)),
},
],
carried_item: None,
},
ClickSlotC2s {
window_id: 0,
button: 0,
mode: ClickMode::Hotbar,
state_id: VarInt(0),
slot_idx: 9,
slots: vec![
Slot { idx: 9, item: None },
Slot {
idx: 36,
item: Some(ItemStack::new(ItemKind::GoldIngot, 2, None)),
},
],
carried_item: None,
},
ClickSlotC2s {
window_id: 0,
button: 0,
mode: ClickMode::Click,
state_id: VarInt(0),
slot_idx: 9,
slots: vec![Slot { idx: 9, item: None }],
carried_item: Some(ItemStack::new(ItemKind::GoldIngot, 2, None)),
},
ClickSlotC2s {
window_id: 0,
button: 0,
mode: ClickMode::DropKey,
state_id: VarInt(0),
slot_idx: 9,
slots: vec![Slot {
idx: 9,
item: Some(ItemStack::new(ItemKind::GoldIngot, 1, None)),
}],
carried_item: None,
},
];
for (i, packet) in packets.iter().enumerate() {
validate_click_slot_impossible(packet, &player_inventory, None)
.unwrap_or_else(|e| panic!("packet {i} should be valid: {e}"));
validate_click_slot_item_duplication(packet, &player_inventory, None, &cursor_item)
.expect_err(&format!(
"packet {i} passed item duplication check when it should have failed"
));
}
}
#[test]
fn allow_shift_click_overflow_to_new_stack() {
let mut player_inventory = Inventory::new(InventoryKind::Player);
player_inventory.set_slot(9, ItemStack::new(ItemKind::Diamond, 64, None));
player_inventory.set_slot(36, ItemStack::new(ItemKind::Diamond, 32, None));
let cursor_item = CursorItem::default();
let packet = ClickSlotC2s {
window_id: 0,
state_id: VarInt(2),
slot_idx: 9,
button: 0,
mode: ClickMode::ShiftClick,
slots: vec![
Slot {
idx: 37,
item: Some(ItemStack::new(ItemKind::Diamond, 32, None)),
},
Slot {
idx: 36,
item: Some(ItemStack::new(ItemKind::Diamond, 64, None)),
},
Slot { idx: 9, item: None },
],
carried_item: None,
};
validate_click_slot_impossible(&packet, &player_inventory, None)
.expect("packet should be valid");
validate_click_slot_item_duplication(&packet, &player_inventory, None, &cursor_item)
.expect("packet should pass item duplication check");
}
#[test]
fn allow_pickup_overfull_stack_click() {
let mut player_inventory = Inventory::new(InventoryKind::Player);
player_inventory.set_slot(9, ItemStack::new(ItemKind::Apple, 100, None));
let cursor_item = CursorItem::default();
let packet = ClickSlotC2s {
window_id: 0,
state_id: VarInt(2),
slot_idx: 9,
button: 0,
mode: ClickMode::Click,
slots: vec![Slot { idx: 9, item: None }],
carried_item: Some(ItemStack::new(ItemKind::Apple, 100, None)),
};
validate_click_slot_impossible(&packet, &player_inventory, None)
.expect("packet should be valid");
validate_click_slot_item_duplication(&packet, &player_inventory, None, &cursor_item)
.expect("packet should pass item duplication check");
}
#[test]
fn allow_place_overfull_stack_click() {
let player_inventory = Inventory::new(InventoryKind::Player);
let cursor_item = CursorItem(Some(ItemStack::new(ItemKind::Apple, 100, None)));
let packet = ClickSlotC2s {
window_id: 0,
state_id: VarInt(2),
slot_idx: 9,
button: 0,
mode: ClickMode::Click,
slots: vec![Slot {
idx: 9,
item: Some(ItemStack::new(ItemKind::Apple, 64, None)),
}],
carried_item: Some(ItemStack::new(ItemKind::Apple, 36, None)),
};
validate_click_slot_impossible(&packet, &player_inventory, None)
.expect("packet should be valid");
validate_click_slot_item_duplication(&packet, &player_inventory, None, &cursor_item)
.expect("packet should pass item duplication check");
}
}

View file

@ -59,7 +59,9 @@ pub mod prelude {
pub use entity::{EntityAnimation, EntityKind, EntityManager, EntityStatus, HeadYaw}; pub use entity::{EntityAnimation, EntityKind, EntityManager, EntityStatus, HeadYaw};
pub use glam::DVec3; pub use glam::DVec3;
pub use instance::{Block, BlockMut, BlockRef, Chunk, Instance}; pub use instance::{Block, BlockMut, BlockRef, Chunk, Instance};
pub use inventory::{Inventory, InventoryKind, OpenInventory}; pub use inventory::{
Inventory, InventoryKind, InventoryWindow, InventoryWindowMut, OpenInventory,
};
pub use player_list::{PlayerList, PlayerListEntry}; pub use player_list::{PlayerList, PlayerListEntry};
pub use protocol::block::{BlockState, PropName, PropValue}; pub use protocol::block::{BlockState, PropName, PropValue};
pub use protocol::ident::Ident; pub use protocol::ident::Ident;

View file

@ -25,7 +25,7 @@ use crate::config::{AsyncCallbacks, ConnectionMode, ServerPlugin};
use crate::dimension::{validate_dimensions, Dimension, DimensionId}; use crate::dimension::{validate_dimensions, Dimension, DimensionId};
use crate::entity::EntityPlugin; use crate::entity::EntityPlugin;
use crate::instance::{Instance, InstancePlugin}; use crate::instance::{Instance, InstancePlugin};
use crate::inventory::InventoryPlugin; use crate::inventory::{InventoryPlugin, InventorySettings};
use crate::player_list::PlayerListPlugin; use crate::player_list::PlayerListPlugin;
use crate::prelude::event::ClientEventPlugin; use crate::prelude::event::ClientEventPlugin;
use crate::prelude::ComponentPlugin; use crate::prelude::ComponentPlugin;
@ -303,6 +303,7 @@ pub fn build_plugin(
// Insert resources. // Insert resources.
app.insert_resource(server); app.insert_resource(server);
app.insert_resource(InventorySettings::default());
// Make the app loop forever at the configured TPS. // Make the app loop forever at the configured TPS.
{ {

View file

@ -16,8 +16,8 @@ pub struct ItemStack {
pub nbt: Option<Compound>, pub nbt: Option<Compound>,
} }
const STACK_MIN: u8 = 1; pub const STACK_MIN: u8 = 1;
const STACK_MAX: u8 = 127; pub const STACK_MAX: u8 = 127;
impl ItemStack { impl ItemStack {
pub fn new(item: ItemKind, count: u8, nbt: Option<Compound>) -> Self { pub fn new(item: ItemKind, count: u8, nbt: Option<Compound>) -> Self {

View file

@ -7,6 +7,8 @@ pub struct ClickSlotC2s {
pub window_id: u8, pub window_id: u8,
pub state_id: VarInt, pub state_id: VarInt,
pub slot_idx: i16, pub slot_idx: i16,
/// The button used to click the slot. An enum can't easily be used for this
/// because the meaning of this value depends on the mode.
pub button: i8, pub button: i8,
pub mode: ClickMode, pub mode: ClickMode,
pub slots: Vec<Slot>, pub slots: Vec<Slot>,