Read the physical Shift keys in container screens and drop a stack clicked outside

Quick move, the recipe book's place-all, its search box and the hold-Shift
tooltips read self.keys, which holds remapped and toggled keys: with toggle
sneak, a Left Shift press behind a screen is dropped by the sneak mapping and
sync_toggle then removes ShiftLeft. Vanilla reads the event's own modifiers
(InputWithModifiers.hasShiftDown) or Screen.hasShiftDown: the physical keys.
Track them in physical_keys and read them through has_shift_down.

Clicking outside the window did nothing. AbstractContainerScreen.mouseClicked
sends slot -999 when hasClickedOutside (AbstractRecipeBookScreen's rule when
the screen has a book): a held stack starts a quick craft drag whose release
sends PICKUP -999, which AbstractContainerMenu.doClick turns into a drop of
the stack (left) or one item (right), thrown from the hand with a swing. The
mouse handling is now horizon_client::container_screen, checked against the
real screens by tools/test_container_screen_parity.py; servers get the same
ContainerClick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ludovic Marie
2026-10-14 15:20:30 +02:00
parent 66ce394d2d
commit d75ad778e3
11 changed files with 854 additions and 22 deletions
@@ -0,0 +1,86 @@
#![forbid(unsafe_code)]
//! Runs tools/test_container_screen_parity.py's screens and mouse events
//! through `horizon_client::container_screen`.
use horizon_client::{
Result,
container_screen::{Book, Button, Clicks, Image, Release, Target, clicked_outside},
menu::Input,
};
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
fn input_name(input: Input) -> &'static str {
match input {
Input::Pickup => "PICKUP",
Input::QuickMove => "QUICK_MOVE",
Input::Swap => "SWAP",
Input::Clone => "CLONE",
Input::Throw => "THROW",
Input::QuickCraft => "QUICK_CRAFT",
Input::PickupAll => "PICKUP_ALL",
}
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = BufReader::new(File::open(&args[1])?);
let mut out = BufWriter::new(File::create(&args[2])?);
for (n, line) in input.lines().enumerate() {
let c: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
let int = |v: &Value| v.as_i64().unwrap() as i32;
let image = Image {
left: int(&c["image"][0]),
top: int(&c["image"][1]),
width: int(&c["image"][2]),
height: int(&c["image"][3]),
};
let book = c.get("book").map(|b| Book {
visible: b[0].as_bool().unwrap(),
selected_tab_hovered: b[1].as_bool().unwrap(),
});
let mut clicks = Clicks::default();
for ev in c["events"].as_array().unwrap() {
let name = ev[0].as_str().unwrap();
let (x, y) = (ev[1].as_f64().unwrap(), ev[2].as_f64().unwrap());
let outside = clicked_outside(x, y, image, book);
let mut line = format!("{name} {outside}");
if name != "point" {
// The case says whether the event is over the menu's slot (7).
let on_slot = ev[6].as_bool().unwrap();
line += &format!(" {on_slot}");
let target = Target::of(on_slot.then_some(7), outside);
let button = match ev[3].as_i64().unwrap() {
1 => Button::Left,
3 => Button::Right,
_ => Button::Middle,
};
let shift = ev[4].as_i64().unwrap() & 3 != 0;
let carried_empty = !ev[5].as_bool().unwrap();
let click = if name == "press" {
clicks.press(&target, button, carried_empty, shift, false)
} else {
match clicks.release(&target, button, carried_empty, false, shift, false) {
Release::Click(input) => Some(input),
Release::QuickCraft => panic!("no drags in these cases"),
Release::Nothing => None,
}
};
if let Some(input) = click {
let id = match target {
Target::Slot(s) => s,
Target::Outside => -999,
Target::Nothing => -1,
};
line += &format!(" click {id} {} {}", button.menu_button(), input_name(input));
}
}
writeln!(out, "{line}")?;
}
}
out.flush()?;
Ok(())
}
@@ -0,0 +1,278 @@
//! The mouse handling every survival and container screen shares
//! (`AbstractContainerScreen.mouseClicked` / `mouseReleased`): which slot id
//! a click targets (-999 outside the window, `hasClickedOutside` with the
//! recipe book's own rule) and which `ContainerInput` it sends. Shift is
//! the event's own modifiers (`InputWithModifiers.hasShiftDown`: the
//! physical Shift keys, never the remapped or toggled sneak key).
use crate::menu::Input;
/// The screen's window: `leftPos`, `topPos`, `imageWidth`, `imageHeight`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Image {
pub left: i32,
pub top: i32,
pub width: i32,
pub height: i32,
}
/// The recipe book of an `AbstractRecipeBookScreen`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Book {
/// `RecipeBookComponent.isVisible`.
pub visible: bool,
/// `selectedTab.isHoveredOrFocused()`.
pub selected_tab_hovered: bool,
}
/// `RecipeBookComponent.IMAGE_WIDTH`.
const BOOK_WIDTH: i32 = 147;
fn outside(mx: f64, my: f64, image: Image) -> bool {
mx < f64::from(image.left)
|| my < f64::from(image.top)
|| mx >= f64::from(image.left + image.width)
|| my >= f64::from(image.top + image.height)
}
/// `AbstractContainerScreen.hasClickedOutside`, or
/// `AbstractRecipeBookScreen.hasClickedOutside` when the screen has a book
/// (`RecipeBookComponent.hasClickedOutside`: a click on the book, or with
/// its selected tab hovered, is not outside).
pub fn clicked_outside(mx: f64, my: f64, image: Image, book: Option<Book>) -> bool {
let out = outside(mx, my, image);
let Some(book) = book else {
return out;
};
let book_outside = !book.visible || {
let on_book = f64::from(image.left - BOOK_WIDTH) < mx
&& mx < f64::from(image.left)
&& f64::from(image.top) < my
&& my < f64::from(image.top + image.height);
out && !on_book && !book.selected_tab_hovered
};
book_outside && out
}
/// What a click points at: a slot, outside the window (slot id -999), or
/// neither (slot id -1).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Target<S> {
Slot(S),
Outside,
Nothing,
}
impl<S> Target<S> {
/// Outside wins over a hovered slot, as in `mouseClicked`.
pub fn of(slot: Option<S>, outside: bool) -> Self {
match slot {
_ if outside => Self::Outside,
Some(s) => Self::Slot(s),
None => Self::Nothing,
}
}
}
/// The left, right and middle buttons (SDL's 1, 3 and 2).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Button {
Left,
Right,
Middle,
}
impl Button {
/// `getContainerClickButton`: the button a menu click carries.
pub fn menu_button(self) -> i32 {
match self {
Self::Left => 0,
Self::Right => 1,
Self::Middle => 2,
}
}
}
/// The input state `mouseClicked`/`mouseReleased` keep between events.
#[derive(Clone, Debug, Default)]
pub struct Clicks {
/// `isQuickCrafting` with `quickCraftingButton`.
quick_crafting: Option<Button>,
skip_next_release: bool,
}
/// What a release does.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Release {
/// `slotClicked` with this input on the release's target.
Click(Input),
/// `quickCraftToSlots` over the dragged slots.
QuickCraft,
Nothing,
}
impl Clicks {
/// A quick craft drag in progress, and the button that started it.
pub fn quick_crafting(&self) -> Option<Button> {
self.quick_crafting
}
/// A press Horizon handled itself (a double click's `PICKUP_ALL`): its
/// release clicks nothing more.
pub fn skip_release(&mut self) {
self.quick_crafting = None;
self.skip_next_release = true;
}
/// `mouseClicked` (no double click): the `slotClicked` input it sends on
/// `target`, if any. A press holding a stack starts a quick craft drag
/// instead. `cloning` is `keyPickItem` with infinite materials.
pub fn press<S>(
&mut self,
target: &Target<S>,
button: Button,
carried_empty: bool,
shift: bool,
cloning: bool,
) -> Option<Input> {
self.skip_next_release = false;
if button == Button::Middle && !cloning {
return None;
}
if matches!(target, Target::Nothing) || self.quick_crafting.is_some() {
return None;
}
if !carried_empty {
self.quick_crafting = Some(button);
return None;
}
self.skip_next_release = true;
Some(if cloning {
Input::Clone
} else if shift && !matches!(target, Target::Outside) {
Input::QuickMove
} else if matches!(target, Target::Outside) {
Input::Throw
} else {
Input::Pickup
})
}
/// `mouseReleased` (no double click). `dragged` is whether the quick
/// craft drag collected any slots; `pick_item` is the release of the
/// `keyPickItem` button.
pub fn release<S>(
&mut self,
target: &Target<S>,
button: Button,
carried_empty: bool,
dragged: bool,
shift: bool,
pick_item: bool,
) -> Release {
if self.quick_crafting.is_some_and(|b| b != button) {
self.quick_crafting = None;
self.skip_next_release = true;
return Release::Nothing;
}
if self.skip_next_release {
self.skip_next_release = false;
return Release::Nothing;
}
let quick_crafting = self.quick_crafting.take().is_some();
if quick_crafting && dragged {
Release::QuickCraft
} else if carried_empty {
Release::Nothing
} else if pick_item {
Release::Click(Input::Clone)
} else if shift && !matches!(target, Target::Outside) {
Release::Click(Input::QuickMove)
} else {
Release::Click(Input::Pickup)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const INV: Image = Image {
left: 100,
top: 50,
width: 176,
height: 166,
};
#[test]
fn window_edges() {
assert!(!clicked_outside(100.0, 50.0, INV, None));
assert!(clicked_outside(99.9, 60.0, INV, None));
assert!(clicked_outside(276.0, 60.0, INV, None));
assert!(!clicked_outside(275.9, 215.9, INV, None));
}
#[test]
fn the_open_book_is_not_outside() {
let book = Book {
visible: true,
selected_tab_hovered: false,
};
assert!(!clicked_outside(10.0, 100.0, INV, Some(book)));
assert!(clicked_outside(-60.0, 100.0, INV, Some(book)));
assert!(!clicked_outside(
-60.0,
100.0,
INV,
Some(Book {
selected_tab_hovered: true,
..book
})
));
let closed = Book {
visible: false,
..book
};
assert!(clicked_outside(10.0, 100.0, INV, Some(closed)));
}
#[test]
fn a_carried_stack_thrown_outside() {
let mut clicks = Clicks::default();
let out = Target::<usize>::Outside;
assert_eq!(clicks.press(&out, Button::Left, false, false, false), None);
assert_eq!(
clicks.release(&out, Button::Left, false, false, false, false),
Release::Click(Input::Pickup)
);
// Shift never quick-moves outside, and an empty hand throws nothing.
assert_eq!(
clicks.press(&out, Button::Right, true, true, false),
Some(Input::Throw)
);
assert_eq!(
clicks.release(&out, Button::Right, true, false, true, false),
Release::Nothing
);
}
#[test]
fn another_button_cancels_the_drag() {
let mut clicks = Clicks::default();
let slot = Target::Slot(3);
clicks.press(&slot, Button::Left, false, false, false);
assert_eq!(
clicks.release(&slot, Button::Right, false, true, false, false),
Release::Nothing
);
assert_eq!(
clicks.release(&slot, Button::Left, false, true, false, false),
Release::Nothing
);
assert_eq!(
clicks.release(&slot, Button::Left, false, true, true, false),
Release::Click(Input::QuickMove)
);
}
}
+165 -11
View File
@@ -595,6 +595,8 @@ struct Game<'a> {
craft_size: usize,
/// A quick craft drag over inventory slots: (button, game slots so far).
inv_drag: Option<(i32, Vec<usize>)>,
/// `AbstractContainerScreen`'s quick craft and skip-release state.
inv_clicks: horizon_client::container_screen::Clicks,
/// The last inventory click, for the double click that gathers a stack.
last_inv_click: Option<(std::time::Instant, usize, bool)>,
/// Open container block entity, if any.
@@ -611,6 +613,8 @@ struct Game<'a> {
/// The game mode; `creative` mirrors `mode == Creative`.
mode: horizon_client::command::args::GameMode,
keys: HashSet<KeyCode>,
// Physical input is separate from remapped and toggled movement keys.
physical_keys: HashSet<KeyCode>,
left: bool,
right: bool,
cursor: (f32, f32),
@@ -4076,13 +4080,30 @@ impl Game<'_> {
/// The plain inventory mouse press: starts a quick craft drag when a
/// stack is held, gathers on a double click, else the normal click.
fn inventory_press(&mut self, right: bool) {
use horizon_client::container_screen::Target;
use horizon_client::menu::Input;
let slot = self.renderer.as_ref().and_then(|r| {
let batch = HudBatch::new([r.config.width, r.config.height]);
self.slot_at(&batch, self.cursor.0, self.cursor.1)
});
let shift =
self.keys.contains(&KeyCode::ShiftLeft) || self.keys.contains(&KeyCode::ShiftRight);
let (slot, outside) = self.inventory_target();
let shift = self.has_shift_down();
let target = Target::of(slot, outside);
let input = self.inv_clicks.press(
&target,
Self::screen_button(right),
self.carried.is_none(),
shift,
false,
);
if outside {
// slotId -999: an empty hand throws nothing; a carried stack
// starts a drag whose release drops it.
match input {
Some(input) => self.throw_click(right, input),
None if self.inv_clicks.quick_crafting().is_some() => {
self.inv_drag = Some((i32::from(right), Vec::new()));
}
None => {}
}
return;
}
if let Some(slot) = slot
&& !shift
&& slot != 200
@@ -4096,6 +4117,8 @@ impl Game<'_> {
self.last_inv_click = Some((now, slot, right));
if double && self.menu_click(Some(slot), 0, Input::PickupAll) {
self.last_inv_click = None;
// The double click's own release (`mouseReleased`'s PICKUP_ALL).
self.inv_clicks.skip_release();
return;
}
if self.carried.is_some() {
@@ -4122,6 +4145,125 @@ impl Game<'_> {
slots.push(slot);
}
}
/// The hovered slot and whether the cursor is outside the window.
fn inventory_target(&self) -> (Option<usize>, bool) {
let Some(r) = self.renderer.as_ref() else {
return (None, false);
};
let batch = HudBatch::new([r.config.width, r.config.height]);
(
self.slot_at(&batch, self.cursor.0, self.cursor.1),
self.clicked_outside(&batch),
)
}
fn screen_button(right: bool) -> horizon_client::container_screen::Button {
use horizon_client::container_screen::Button;
if right { Button::Right } else { Button::Left }
}
/// `Screen.hasShiftDown` / `InputWithModifiers.hasShiftDown`: the
/// physical Shift keys, whatever the sneak key is bound or toggled to.
pub(super) fn has_shift_down(&self) -> bool {
self.physical_keys.contains(&KeyCode::ShiftLeft)
|| self.physical_keys.contains(&KeyCode::ShiftRight)
}
/// `hasClickedOutside` of the open screen at the cursor.
fn clicked_outside(&self, batch: &HudBatch) -> bool {
use horizon_client::container_screen::{Book, Image, clicked_outside};
let (ox, oy) = self.inventory_origin(batch);
let (shift, width) = match self.station.as_ref().map(|s| s.kind) {
Some(stations::Kind::Merchant) => (-50.0, 276),
Some(stations::Kind::Beacon) => (beacon::SHIFT, 230),
_ => (0.0, 176),
};
let image = Image {
left: (ox + shift) as i32,
top: oy as i32,
width,
height: self.screen_height() as i32,
};
let book = self
.recipe_book_area(batch)
.map(|(visible, tab_hovered)| Book {
visible,
selected_tab_hovered: tab_hovered,
});
clicked_outside(
f64::from(self.cursor.0),
f64::from(self.cursor.1),
image,
book,
)
}
/// A click outside the window (slot id -999): `THROW` does nothing; a
/// `PICKUP` drops the carried stack (left) or one of it (right), thrown
/// from the hand (`AbstractContainerMenu.doClick`, `LivingEntity.drop`).
fn throw_click(&mut self, right: bool, input: horizon_client::menu::Input) {
use horizon_client::menu::Input;
let button = i32::from(right);
if self.remote.is_some() {
self.remote_throw(right, input);
return;
}
let before = self.carried.clone();
if !self.menu_click(None, button, input) && input == Input::Pickup {
// A screen the menu engine doesn't model: the same doClick.
if let Some(c) = self.carried.as_mut() {
let thrown = if right {
let one = ItemStack {
count: 1,
..c.clone()
};
c.count -= 1;
if c.count == 0 {
self.carried = None;
}
one
} else {
self.carried.take().expect("some")
};
self.player_drop(thrown);
}
}
if self.carried != before {
self.swing_arm();
}
if let Some(key) = self.container {
self.container_changed(key);
}
}
/// Mouse release over an inventory screen: `mouseReleased` ends or
/// cancels the drag, drops a stack released outside, or clicks.
fn inventory_mouse_release(&mut self, right: bool) {
use horizon_client::container_screen::{Release, Target};
let (slot, outside) = self.inventory_target();
let dragged = self.inv_drag.as_ref().is_some_and(|(_, s)| !s.is_empty());
let release = self.inv_clicks.release(
&Target::of(slot, outside),
Self::screen_button(right),
self.carried.is_none(),
dragged,
self.has_shift_down(),
false,
);
match (release, Target::of(slot, outside)) {
(Release::QuickCraft, _) => self.inventory_release(),
(Release::Click(input), Target::Outside) => {
self.inv_drag = None;
self.throw_click(right, input);
}
(Release::Click(input), Target::Slot(slot)) if self.inv_drag.is_some() => {
// The drag never reached a slot: a plain click where it ends.
self.inv_drag = None;
if !self.menu_click(Some(slot), i32::from(right), input) {
self.click_inventory(right);
}
if let Some(key) = self.container {
self.container_changed(key);
}
}
_ => self.inv_drag = None,
}
}
/// Mouse release: finishes a quick craft drag (one slot is a plain click).
fn inventory_release(&mut self) {
use horizon_client::menu::Input;
@@ -4174,8 +4316,7 @@ impl Game<'_> {
let Some(slot) = self.slot_at(&batch, self.cursor.0, self.cursor.1) else {
return;
};
let held_shift =
self.keys.contains(&KeyCode::ShiftLeft) || self.keys.contains(&KeyCode::ShiftRight);
let held_shift = self.has_shift_down();
if self.crafter_click(slot, held_shift, right) {
return;
}
@@ -4191,8 +4332,7 @@ impl Game<'_> {
return;
}
let max = |items: &Items, s: &ItemStack| items.get(&s.item).map_or(64, |i| i.max_stack);
let shift =
self.keys.contains(&KeyCode::ShiftLeft) || self.keys.contains(&KeyCode::ShiftRight);
let shift = held_shift;
if slot >= 300
&& let Some(key) = self.container
{
@@ -7803,6 +7943,18 @@ impl ApplicationHandler for Game<'_> {
if self.window.as_ref().is_none_or(|w| w.id() != id) {
return;
}
if let WindowEvent::KeyboardInput { event, .. } = &event
&& let PhysicalKey::Code(key) = event.physical_key
{
if event.state == ElementState::Pressed {
self.physical_keys.insert(key);
} else {
self.physical_keys.remove(&key);
}
}
if let WindowEvent::Focused(false) = &event {
self.physical_keys.clear();
}
if matches!(
event,
WindowEvent::KeyboardInput { .. }
@@ -8017,7 +8169,7 @@ impl ApplicationHandler for Game<'_> {
}
}
Screen::Inventory => {
self.inventory_release();
self.inventory_mouse_release(button == MouseButton::Right);
self.loom_release();
}
Screen::Chat if pressed && button == MouseButton::Left => self.chat_click(),
@@ -8528,6 +8680,7 @@ pub fn run() -> Result<()> {
craft_grid: vec![None; 9],
craft_size: 2,
inv_drag: None,
inv_clicks: Default::default(),
last_inv_click: None,
container: None,
station: None,
@@ -8543,6 +8696,7 @@ pub fn run() -> Result<()> {
horizon_client::command::args::GameMode::Survival
},
keys: HashSet::new(),
physical_keys: HashSet::new(),
left: false,
right: false,
cursor: (0.0, 0.0),
@@ -166,10 +166,12 @@ impl Game<'_> {
"down" => self.control_key_down(key)?,
"up" => {
self.keys.remove(&key);
self.physical_keys.remove(&key);
}
"tap" => {
self.control_key_down(key)?;
self.keys.remove(&key);
self.physical_keys.remove(&key);
}
other => {
return Err(
@@ -181,6 +183,7 @@ impl Game<'_> {
}
"release" => {
self.keys.clear();
self.physical_keys.clear();
self.left = false;
self.right = false;
ok()
@@ -506,6 +509,7 @@ impl Game<'_> {
}
fn control_key_down(&mut self, key: KeyCode) -> Result<()> {
self.physical_keys.insert(key);
if self.screen == Screen::Chat {
self.chat_key_code(key, None);
return Ok(());
+1 -3
View File
@@ -15,7 +15,6 @@ use horizon_client::{
mesher::Vertex,
};
use serde_json::Value;
use winit::keyboard::KeyCode;
/// The game's answers to what the resolver cannot read from the stack.
pub(super) struct Env<'a, 'g> {
@@ -59,8 +58,7 @@ impl ItemEnv for Env<'_, '_> {
total as f32
}
fn has_shift_down(&self) -> bool {
self.game.keys.contains(&KeyCode::ShiftLeft)
|| self.game.keys.contains(&KeyCode::ShiftRight)
self.game.has_shift_down()
}
fn local_time(&self, pattern: &str, _locale: &str, _time_zone: Option<&str>) -> String {
local_time(pattern)
+18 -3
View File
@@ -169,7 +169,7 @@ impl Game<'_> {
pub(super) fn recipe_key(&mut self, event: &winit::event::KeyEvent) {
use winit::keyboard::{KeyCode, PhysicalKey};
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
let shift = self.keys.contains(&KeyCode::ShiftLeft);
let shift = self.has_shift_down();
let Some(search) = self.recipe_ui.search.as_mut() else {
return;
};
@@ -620,8 +620,23 @@ impl Game<'_> {
false
}
fn shift_down(&self) -> bool {
use winit::keyboard::KeyCode;
self.keys.contains(&KeyCode::ShiftLeft) || self.keys.contains(&KeyCode::ShiftRight)
self.has_shift_down()
}
/// The open screen's book for `hasClickedOutside`: (visible, selected tab
/// hovered); None when the screen has no book.
pub(super) fn recipe_book_area(&self, batch: &HudBatch) -> Option<(bool, bool)> {
let book = self.active_book()?;
if !self.book_open(book) {
return Some((false, false));
}
let (xo, yo) = self.book_origin(batch);
let tabs = &self.recipe_ui.view.tabs;
let selected = self.recipe_ui.tab.unwrap_or(Tab::Search);
let hovered = tabs.iter().position(|t| *t == selected).is_some_and(|n| {
let (x, y) = self.tab_pos(xo, yo, n);
inside(self.cursor, x, y, 35.0, 27.0)
});
Some((true, hovered))
}
/// The grid, result and furnace slots clicks on which end a ghost recipe.
fn is_book_slot(&self, book: BookType, slot: usize) -> bool {
+33 -2
View File
@@ -2282,8 +2282,7 @@ impl Game<'_> {
};
let slot = (0..64).find(|&s| Self::menu_slot(&menu, s) == Some(internal));
let Some(slot) = slot else { return };
let shift =
self.keys.contains(&KeyCode::ShiftLeft) || self.keys.contains(&KeyCode::ShiftRight);
let shift = self.has_shift_down();
let carried = self.to_network(&self.carried).map(|s| Hashed::of(&s));
let state = self.remote.as_ref().map_or(0, |r| r.state_id);
self.send(S::ContainerClick(play::ContainerClick {
@@ -2296,6 +2295,38 @@ impl Game<'_> {
carried,
}));
}
/// A click outside a server's window (slot -999): predicted like
/// `MultiPlayerGameMode.handleContainerInput` (the carried stack after
/// `doClick`; the server drops the stack and swings the hand).
pub(super) fn remote_throw(&mut self, right: bool, input: horizon_client::menu::Input) {
use horizon_client::menu::Input;
let container = self
.remote
.as_ref()
.and_then(|r| r.menu.as_ref())
.map_or(0, |m| m.0);
if input == Input::Pickup
&& let Some(c) = self.carried.as_mut()
{
if right && c.count > 1 {
c.count -= 1;
} else {
self.carried = None;
}
self.swing_arm();
}
let carried = self.to_network(&self.carried).map(|s| Hashed::of(&s));
let state = self.remote.as_ref().map_or(0, |r| r.state_id);
self.send(S::ContainerClick(play::ContainerClick {
container,
state,
slot: -999,
button: i8::from(right),
input: if input == Input::Throw { 4 } else { 0 },
changed: Vec::new(),
carried,
}));
}
pub(super) fn remote_close_container(&mut self) {
let id = self
.remote
@@ -46,13 +46,13 @@ impl Game<'_> {
use winit::keyboard::KeyCode;
let saved = self.cursor;
self.cursor = (x, y);
let had_shift = self.keys.contains(&KeyCode::ShiftLeft);
let had_shift = self.physical_keys.contains(&KeyCode::ShiftLeft);
if shift {
self.keys.insert(KeyCode::ShiftLeft);
self.physical_keys.insert(KeyCode::ShiftLeft);
}
self.click_inventory(right);
if shift && !had_shift {
self.keys.remove(&KeyCode::ShiftLeft);
self.physical_keys.remove(&KeyCode::ShiftLeft);
}
self.cursor = saved;
}
+1
View File
@@ -31,6 +31,7 @@ pub mod collisions;
pub mod command;
pub mod command_storage;
pub mod component;
pub mod container_screen;
pub mod containers;
pub mod control;
pub mod crafting;
@@ -0,0 +1,170 @@
package dev.horizonlab.parity;
import com.google.gson.*;
import java.io.*;
import java.lang.reflect.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Options;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.inventory.*;
import net.minecraft.client.gui.screens.recipebook.*;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.client.input.MouseButtonInfo;
import net.minecraft.core.NonNullList;
import net.minecraft.world.SimpleContainer;
import net.minecraft.world.inventory.*;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
/**
* Original oracle for the mouse handling of the survival and container
* screens: the real AbstractContainerScreen.mouseClicked/mouseReleased of
* ContainerScreen, HopperScreen, ShulkerBoxScreen and BeaconScreen (one slot
* in the menu, the carried stack and the event's Shift modifiers from the
* case) recording every slotClicked call they make, and the real
* hasClickedOutside of those screens, MerchantScreen and the recipe book
* screens (InventoryScreen, CraftingScreen, FurnaceScreen) with their
* RecipeBookComponent's visibility and selected tab hover from the case.
*/
public final class VanillaContainerScreenOracle {
static Object unsafe;
static Method allocate;
static final StringBuilder clicks = new StringBuilder();
@SuppressWarnings("unchecked")
static <T> T alloc(Class<T> type) throws Exception {
return (T) allocate.invoke(unsafe, type);
}
static void put(Object target, Class<?> owner, String name, Object value) throws Exception {
Field f = owner.getDeclaredField(name);
long offset = (long) unsafe.getClass().getMethod("objectFieldOffset", Field.class).invoke(unsafe, f);
Class<?> t = f.getType();
String setter = t == int.class ? "putInt" : t == boolean.class ? "putBoolean" : "putObject";
Class<?> param = t.isPrimitive() ? t : Object.class;
unsafe.getClass().getMethod(setter, Object.class, long.class, param).invoke(unsafe, target, offset, value);
}
static void record(int slotId, int button, ContainerInput input) {
clicks.append(" click ").append(slotId).append(' ').append(button).append(' ').append(input.name());
}
// The screens with slotClicked recording what the real mouse handling sends.
public static final class Chest extends ContainerScreen {
Chest() { super(null, null, null); }
@Override protected void slotClicked(Slot slot, int slotId, int button, ContainerInput input) { record(slotId, button, input); }
}
public static final class Hopper extends HopperScreen {
Hopper() { super(null, null, null); }
@Override protected void slotClicked(Slot slot, int slotId, int button, ContainerInput input) { record(slotId, button, input); }
}
public static final class Shulker extends ShulkerBoxScreen {
Shulker() { super(null, null, null); }
@Override protected void slotClicked(Slot slot, int slotId, int button, ContainerInput input) { record(slotId, button, input); }
}
public static final class Beacon extends BeaconScreen {
Beacon() { super(null, null, null); }
@Override protected void slotClicked(Slot slot, int slotId, int button, ContainerInput input) { record(slotId, button, input); }
}
static AbstractContainerScreen<?> screen(String kind) throws Exception {
return switch (kind) {
case "chest" -> alloc(Chest.class);
case "hopper" -> alloc(Hopper.class);
case "shulker" -> alloc(Shulker.class);
case "beacon" -> alloc(Beacon.class);
case "merchant" -> alloc(MerchantScreen.class);
case "inventory" -> alloc(InventoryScreen.class);
case "crafting" -> alloc(CraftingScreen.class);
case "furnace" -> alloc(FurnaceScreen.class);
default -> throw new IllegalArgumentException(kind);
};
}
public static void main(String[] args) throws Exception {
ParityData.init();
var u = Class.forName("sun.misc.Unsafe");
Field theUnsafe = u.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
unsafe = theUnsafe.get(null);
allocate = u.getMethod("allocateInstance", Class.class);
Minecraft shell = alloc(Minecraft.class);
put(shell, Minecraft.class, "fixerUpper", net.minecraft.util.datafix.DataFixers.getDataFixer());
Field instance = Minecraft.class.getDeclaredField("instance");
instance.setAccessible(true);
instance.set(null, shell);
Options options = new Options(shell, Files.createTempDirectory("opts").toFile());
put(shell, Minecraft.class, "options", options);
Method outside = AbstractContainerScreen.class.getDeclaredMethod("hasClickedOutside", double.class, double.class, int.class, int.class);
outside.setAccessible(true);
Method hovered = AbstractContainerScreen.class.getDeclaredMethod("getHoveredSlot", double.class, double.class);
hovered.setAccessible(true);
Field carried = AbstractContainerMenu.class.getDeclaredField("carried");
carried.setAccessible(true);
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8));
var lines = Files.lines(Path.of(args[0]))) {
int n = 0;
for (var line : (Iterable<String>) lines::iterator) {
var c = JsonParser.parseString(line).getAsJsonObject();
out.println("== " + n++);
String kind = c.get("kind").getAsString();
var screen = screen(kind);
var image = c.getAsJsonArray("image");
put(screen, AbstractContainerScreen.class, "leftPos", image.get(0).getAsInt());
put(screen, AbstractContainerScreen.class, "topPos", image.get(1).getAsInt());
put(screen, AbstractContainerScreen.class, "imageWidth", image.get(2).getAsInt());
put(screen, AbstractContainerScreen.class, "imageHeight", image.get(3).getAsInt());
put(screen, Screen.class, "minecraft", shell);
put(screen, Screen.class, "children", new ArrayList<>());
put(screen, AbstractContainerScreen.class, "quickCraftSlots", new HashSet<Slot>());
put(screen, AbstractContainerScreen.class, "lastQuickMoved", ItemStack.EMPTY);
if (c.has("book")) {
var book = c.getAsJsonArray("book");
var component = alloc(CraftingRecipeBookComponent.class);
put(component, RecipeBookComponent.class, "visible", book.get(0).getAsBoolean());
var tab = alloc(RecipeBookTabButton.class);
put(tab, net.minecraft.client.gui.components.AbstractWidget.class, "isHovered", book.get(1).getAsBoolean());
put(component, RecipeBookComponent.class, "selectedTab", tab);
put(screen, AbstractRecipeBookScreen.class, "recipeBookComponent", component);
}
var menu = alloc(ChestMenu.class);
NonNullList<Slot> slots = NonNullList.create();
var pos = c.getAsJsonArray("slot");
var slot = new Slot(new SimpleContainer(1), 0, pos.get(0).getAsInt(), pos.get(1).getAsInt());
slot.index = 7;
slots.add(slot);
put(menu, AbstractContainerMenu.class, "slots", slots);
carried.set(menu, ItemStack.EMPTY);
put(screen, AbstractContainerScreen.class, "menu", menu);
int left = image.get(0).getAsInt(), top = image.get(1).getAsInt();
for (var e : c.getAsJsonArray("events")) {
var ev = e.getAsJsonArray();
String name = ev.get(0).getAsString();
double x = ev.get(1).getAsDouble(), y = ev.get(2).getAsDouble();
var sb = new StringBuilder(name);
sb.append(' ').append(outside.invoke(screen, x, y, left, top));
if (!name.equals("point")) {
sb.append(' ').append(hovered.invoke(screen, x, y) != null);
carried.set(menu, ev.get(5).getAsBoolean() ? new ItemStack(Items.STONE, 5) : ItemStack.EMPTY);
var event = new MouseButtonEvent(x, y, new MouseButtonInfo(ev.get(3).getAsInt(), ev.get(4).getAsInt()));
clicks.setLength(0);
if (name.equals("press")) {
screen.mouseClicked(event, false);
} else {
screen.mouseReleased(event);
}
sb.append(clicks);
}
out.println(sb);
}
}
}
System.exit(0);
}
}
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Compare the survival and container screens' mouse handling with the pinned
client: the real AbstractContainerScreen.mouseClicked/mouseReleased (the slot
id a click targets, -999 outside the window, and the ContainerInput it sends:
PICKUP, QUICK_MOVE from the event's own Shift modifiers, THROW, the quick
craft drag a held stack starts and the drop its release outside makes) and
hasClickedOutside, with AbstractRecipeBookScreen's and RecipeBookComponent's
rule for the recipe book screens."""
import json
import random
try:
from . import lab, oracle_harness as harness
except ImportError:
import lab, oracle_harness as harness
# imageWidth, imageHeight; the recipe book screens only compare hasClickedOutside.
SCREENS = {'chest': None, 'hopper': (176, 133), 'shulker': (176, 167), 'beacon': (230, 219),
'merchant': (276, 166), 'inventory': (176, 166), 'crafting': (176, 166), 'furnace': (176, 166)}
CLICKING = ('chest', 'hopper', 'shulker', 'beacon')
BOOKS = ('inventory', 'crafting', 'furnace')
def quarter(rng, lo, hi):
return rng.randrange(int(lo * 4), int(hi * 4)) / 4
def point(rng, left, top, width, height):
"""Mostly near the window's edges and the recipe book's area."""
pick = rng.random()
if pick < 0.5:
x = rng.choice([left, left + width, left - 147]) + quarter(rng, -3, 3)
y = quarter(rng, top - 20, top + height + 20)
elif pick < 0.8:
x = quarter(rng, left - 30, left + width + 30)
y = rng.choice([top, top + height]) + quarter(rng, -3, 3)
else:
x = quarter(rng, left - 200, left + width + 60)
y = quarter(rng, top - 40, top + height + 40)
return x, y
def cases(rng):
for _ in range(4000):
kind = rng.choice(list(SCREENS))
width, height = SCREENS[kind] or (176, 114 + 18 * rng.randint(1, 6))
left, top = rng.randrange(0, 300), rng.randrange(0, 120)
sx, sy = rng.randrange(0, width - 16), rng.randrange(0, height - 16)
case = dict(kind=kind, image=[left, top, width, height], slot=[sx, sy], events=[])
if kind in BOOKS:
case['book'] = [rng.random() < 0.7, rng.random() < 0.2]
for _ in range(rng.randrange(4, 24)):
if kind not in CLICKING:
case['events'].append(['point', *point(rng, left, top, width, height)])
continue
on_slot = rng.random() < 0.3
if on_slot:
x = quarter(rng, left + sx - 1, left + sx + 17)
y = quarter(rng, top + sy - 1, top + sy + 17)
else:
while True:
x, y = point(rng, left, top, width, height)
if not (left + sx - 1 <= x < left + sx + 17 and top + sy - 1 <= y < top + sy + 17):
break
case['events'].append([rng.choice(['press', 'release']), x, y, rng.choice([1, 1, 3]),
rng.choice([0, 0, 1, 2, 3]), rng.random() < 0.5, on_slot])
yield case
def make_cases(build):
path = build / 'cases.jsonl'
count = 0
with path.open('w') as out:
for case in cases(random.Random(0x434c4b53)):
out.write(json.dumps(case, separators=(',', ':')) + '\n')
count += 1
return path, count
def main():
scope = ('The real AbstractContainerScreen.mouseClicked and mouseReleased of ContainerScreen (one to six rows), '
'HopperScreen, ShulkerBoxScreen and BeaconScreen with one menu slot, press and release of the left and right '
'buttons with the carried stack empty or not and the event Shift modifiers (left, right, both, none), '
'recording every slotClicked (slot id, menu button, ContainerInput); and hasClickedOutside of those screens, '
'MerchantScreen and the recipe book screens (InventoryScreen, CraftingScreen, FurnaceScreen) with the book '
'visible or not and its selected tab hovered or not. Drags (mouseDragged, quickCraftToSlots), double '
'clicks and keyPickItem cloning are not exercised; the menu clicks themselves are menu_click\'s.')
harness.differential(
'rust-container-screen-parity', 'VanillaContainerScreenOracle', 'container_screen_probe', make_cases, scope,
sources=[lab.ROOT / 'crates/horizon-client/src/container_screen.rs',
lab.ROOT / 'crates/horizon-client/src/menu.rs'])
if __name__ == '__main__':
main()