From d75ad778e3d12d7c869e665f7bab4ecf8d417334 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:20:30 +0200 Subject: [PATCH] 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 --- .../examples/container_screen_probe.rs | 86 ++++++ crates/horizon-client/src/container_screen.rs | 278 ++++++++++++++++++ crates/horizon-client/src/game.rs | 176 ++++++++++- crates/horizon-client/src/game/control.rs | 4 + crates/horizon-client/src/game/item_draw.rs | 4 +- crates/horizon-client/src/game/recipe_book.rs | 21 +- crates/horizon-client/src/game/remote.rs | 35 ++- .../src/game/station_control.rs | 6 +- crates/horizon-client/src/lib.rs | 1 + .../parity/VanillaContainerScreenOracle.java | 170 +++++++++++ tools/test_container_screen_parity.py | 95 ++++++ 11 files changed, 854 insertions(+), 22 deletions(-) create mode 100644 crates/horizon-client/examples/container_screen_probe.rs create mode 100644 crates/horizon-client/src/container_screen.rs create mode 100644 mods/horizon/src/test/java/dev/horizonlab/parity/VanillaContainerScreenOracle.java create mode 100644 tools/test_container_screen_parity.py diff --git a/crates/horizon-client/examples/container_screen_probe.rs b/crates/horizon-client/examples/container_screen_probe.rs new file mode 100644 index 000000000..b89b23a2a --- /dev/null +++ b/crates/horizon-client/examples/container_screen_probe.rs @@ -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 = 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(()) +} diff --git a/crates/horizon-client/src/container_screen.rs b/crates/horizon-client/src/container_screen.rs new file mode 100644 index 000000000..2c490861a --- /dev/null +++ b/crates/horizon-client/src/container_screen.rs @@ -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) -> 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 { + Slot(S), + Outside, + Nothing, +} + +impl Target { + /// Outside wins over a hovered slot, as in `mouseClicked`. + pub fn of(slot: Option, 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