yog_inventory/lib.rs
1//! yog-inventory — real Minecraft Container/Menu screens for Yog mods.
2//!
3//! Unlike `yog-ui` (a HUD-drawn overlay screen with no real item slots), this
4//! crate describes actual vanilla-style inventory screens: a block's own
5//! slots plus, optionally, the player's own inventory grid underneath —
6//! backed by a real Minecraft `BlockEntity`/`AbstractContainerMenu` on the
7//! Java side, with real drag-and-drop and network sync for free.
8//!
9//! See `DESIGN.md` for the full architecture and phased plan. This crate
10//! currently covers the data model (phase 2); the Java-side `BlockEntity`/
11//! `Menu`/`Screen` plumbing lands in later phases.
12
13use serde::Deserialize;
14
15/// Pixel position of a single slot in vanilla GUI coordinate space (the same
16/// space vanilla screens use: origin at the panel's top-left corner, one
17/// vanilla slot = 18×18px).
18#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
19pub struct SlotLayout {
20 pub x: f32,
21 pub y: f32,
22}
23
24/// Vanilla spacing between adjacent slots in the default grid.
25pub const SLOT_SIZE: f32 = 18.0;
26
27/// Default position of the player's main inventory (3×9) relative to the
28/// panel's top-left corner — matches vanilla furnace/chest-style screens.
29pub const DEFAULT_PLAYER_INV_OFFSET: (f32, f32) = (8.0, 84.0);
30/// Extra vertical gap between the player's main inventory and their hotbar,
31/// on top of the normal per-row `SLOT_SIZE` spacing (also a vanilla convention).
32pub const PLAYER_INV_TO_HOTBAR_GAP: f32 = 4.0;
33
34/// Describes one inventory-backed screen: how many slots a mod block has,
35/// where they sit, whether the player's own inventory is appended below,
36/// and which background texture to draw (vanilla default, or a mod's own).
37#[derive(Debug, Clone)]
38pub struct InventoryDef {
39 pub id: String,
40 pub slot_count: usize,
41 /// Per-slot pixel positions. Empty = auto-generate a default grid (see
42 /// [`InventoryDef::default_grid`]).
43 pub layout: Vec<SlotLayout>,
44 /// Appends the player's main inventory (3×9) + hotbar (9) below the
45 /// custom slots — no armor, no offhand. Native vanilla slot rendering.
46 pub include_player_inventory: bool,
47 pub player_inv_offset: (f32, f32),
48 /// `None` = default vanilla-style panel texture.
49 pub background_texture: Option<String>,
50 pub title: String,
51}
52
53impl InventoryDef {
54 pub fn new(id: impl Into<String>, slot_count: usize) -> Self {
55 Self {
56 id: id.into(),
57 slot_count,
58 layout: Vec::new(),
59 include_player_inventory: true,
60 player_inv_offset: DEFAULT_PLAYER_INV_OFFSET,
61 background_texture: None,
62 title: String::new(),
63 }
64 }
65
66 /// Explicit per-slot pixel positions, overriding the default grid.
67 pub fn layout(mut self, layout: Vec<SlotLayout>) -> Self {
68 self.layout = layout;
69 self
70 }
71
72 /// Remap the slot grid from a JSON array of `{"x":.., "y":..}` objects —
73 /// one entry per slot, in slot-index order. Lets users/mod-packs override
74 /// the layout without recompiling. Invalid or short JSON is ignored
75 /// (falls back to whatever layout was set before, or the default grid).
76 pub fn layout_from_json(mut self, json: &str) -> Self {
77 if let Ok(parsed) = serde_json::from_str::<Vec<SlotLayout>>(json) {
78 self.layout = parsed;
79 }
80 self
81 }
82
83 /// Whether the player's main inventory + hotbar (no armor/offhand) is
84 /// appended below the custom slots. Default: `true`.
85 pub fn include_player_inventory(mut self, v: bool) -> Self {
86 self.include_player_inventory = v;
87 self
88 }
89
90 pub fn player_inv_offset(mut self, x: f32, y: f32) -> Self {
91 self.player_inv_offset = (x, y);
92 self
93 }
94
95 /// Custom background texture (resource path); `None` keeps the default
96 /// vanilla-style panel.
97 pub fn background_texture(mut self, path: impl Into<String>) -> Self {
98 self.background_texture = Some(path.into());
99 self
100 }
101
102 pub fn title(mut self, title: impl Into<String>) -> Self {
103 self.title = title.into();
104 self
105 }
106
107 /// Resolve the effective slot layout: the explicit one if set, otherwise
108 /// a default vanilla-style grid (9 columns, wrapping downward, 18px
109 /// spacing, starting at (8, 18) — same convention as vanilla containers).
110 pub fn resolved_layout(&self) -> Vec<SlotLayout> {
111 if !self.layout.is_empty() {
112 return self.layout.clone();
113 }
114 Self::default_grid(self.slot_count)
115 }
116
117 /// Default vanilla-style grid: up to 9 columns, 18px spacing, starting at
118 /// (8, 18) — leaves room for a title above, matches vanilla containers.
119 pub fn default_grid(slot_count: usize) -> Vec<SlotLayout> {
120 const COLS: usize = 9;
121 (0..slot_count)
122 .map(|i| {
123 let col = (i % COLS) as f32;
124 let row = (i / COLS) as f32;
125 SlotLayout {
126 x: 8.0 + col * SLOT_SIZE,
127 y: 18.0 + row * SLOT_SIZE,
128 }
129 })
130 .collect()
131 }
132}
133
134/// Encode an [`InventoryDef`]'s slot layout as `"x:y,x:y,..."` — the
135/// wire format used when handing the layout to the Java host (mirrors how
136/// `BlockDef::connect_groups` is comma-joined for its ABI struct).
137pub fn encode_layout(layout: &[SlotLayout]) -> String {
138 layout
139 .iter()
140 .map(|s| format!("{}:{}", s.x, s.y))
141 .collect::<Vec<_>>()
142 .join(",")
143}
144
145/// Inverse of [`encode_layout`]. Malformed entries are skipped.
146pub fn decode_layout(s: &str) -> Vec<SlotLayout> {
147 if s.is_empty() {
148 return Vec::new();
149 }
150 s.split(',')
151 .filter_map(|pair| {
152 let (x, y) = pair.split_once(':')?;
153 Some(SlotLayout {
154 x: x.parse().ok()?,
155 y: y.parse().ok()?,
156 })
157 })
158 .collect()
159}
160
161// ── Client-side "currently open menu" snapshot ───────────────────────────────
162//
163// A mod that wants to decorate its own `yog-ui` widget tree with real slot
164// contents (see `yog-ui`'s `inv_slot` widget — purely data-driven, it has no
165// idea what "the currently open menu" even is) needs to read, on the client
166// render thread, what's actually in that menu's slots right now. A plain
167// Rust `static` cannot carry that across the mod/runtime boundary: the
168// runtime and each mod's own dynamic library each get their own separate
169// copy of any crate-level static, so writes on one side are invisible on
170// the other (this is what broke the original design).
171//
172// Instead, the runtime resolves and calls `yog_inventory_set_snapshot` — an
173// exported symbol every mod that depends on this crate carries automatically
174// — once per frame, right before invoking `on_ui_render` handlers, handing
175// over a fresh pointer to THIS mod's own copy of the `SNAPSHOT` static below.
176// Resolved the same way the runtime already resolves `yog_mod_register` /
177// `__yog_resolve_imports_final` on each loaded mod's library.
178
179use std::sync::atomic::{AtomicPtr, Ordering};
180use yog_abi::YogStr;
181
182/// Capacity of [`YogInvSnapshotRaw::slots`] — covers the largest realistic
183/// inventory screen (a mod's own slots + player main inventory + hotbar).
184pub const MAX_SNAPSHOT_SLOTS: usize = 64;
185
186/// One inventory slot's raw wire data for [`YogInvSnapshotRaw`].
187#[repr(C)]
188#[derive(Copy, Clone)]
189pub struct YogInvSlotRaw {
190 /// Empty (`len == 0`) if the slot itself is empty.
191 pub item_id: YogStr,
192 pub count: u32,
193 /// Screen-space pixel position.
194 pub x: i32,
195 pub y: i32,
196}
197
198impl YogInvSlotRaw {
199 pub const EMPTY: Self = Self {
200 item_id: YogStr::EMPTY,
201 count: 0,
202 x: 0,
203 y: 0,
204 };
205}
206
207/// Raw per-frame snapshot of the currently-open inventory screen, built and
208/// owned by the runtime, valid only for the duration of the render call that
209/// hands it over.
210#[repr(C)]
211#[derive(Copy, Clone)]
212pub struct YogInvSnapshotRaw {
213 pub slot_count: u32,
214 pub slots: [YogInvSlotRaw; MAX_SNAPSHOT_SLOTS],
215}
216
217impl YogInvSnapshotRaw {
218 pub const EMPTY: Self = Self {
219 slot_count: 0,
220 slots: [YogInvSlotRaw::EMPTY; MAX_SNAPSHOT_SLOTS],
221 };
222}
223
224static SNAPSHOT: AtomicPtr<YogInvSnapshotRaw> = AtomicPtr::new(std::ptr::null_mut());
225
226/// Called by the runtime once per frame, before running `on_ui_render`
227/// handlers, to refresh this mod's view of the currently-open inventory
228/// screen. Not meant to be called by mod code directly.
229#[doc(hidden)]
230#[no_mangle]
231pub extern "C" fn yog_inventory_set_snapshot(snapshot: *const YogInvSnapshotRaw) {
232 SNAPSHOT.store(snapshot as *mut _, Ordering::Release);
233}
234
235/// One inventory slot's content, as of the last snapshot.
236#[derive(Debug, Clone, Default)]
237pub struct SlotView {
238 /// `"namespace:item_id"` — empty if the slot is empty.
239 pub item_id: String,
240 pub count: u32,
241 /// Screen-space pixel position.
242 pub x: i32,
243 pub y: i32,
244}
245
246/// Number of slots in the currently-open inventory screen (0 if none is open).
247pub fn current_slot_count() -> usize {
248 let ptr = SNAPSHOT.load(Ordering::Acquire);
249 if ptr.is_null() {
250 return 0;
251 }
252 unsafe { &*ptr }.slot_count as usize
253}
254
255/// Slot `index`'s content right now, or `None` if out of range.
256pub fn current_slot(index: usize) -> Option<SlotView> {
257 let ptr = SNAPSHOT.load(Ordering::Acquire);
258 if ptr.is_null() {
259 return None;
260 }
261 let snap = unsafe { &*ptr };
262 if index >= snap.slot_count as usize {
263 return None;
264 }
265 let raw = &snap.slots[index];
266 let item_id = if raw.item_id.is_empty() {
267 String::new()
268 } else {
269 unsafe { raw.item_id.as_str() }.to_owned()
270 };
271 Some(SlotView {
272 item_id,
273 count: raw.count,
274 x: raw.x,
275 y: raw.y,
276 })
277}