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 std::sync::atomic::{AtomicPtr, Ordering};
14
15use serde::Deserialize;
16use yog_abi::YogStr;
17
18/// Pixel position of a single slot in vanilla GUI coordinate space (the same
19/// space vanilla screens use: origin at the panel's top-left corner, one
20/// vanilla slot = 18×18px).
21#[derive(Debug, Clone, PartialEq, Deserialize, yog_data::Schema)]
22pub struct SlotLayout {
23 pub x: f32,
24 pub y: f32,
25 /// What the slot takes: item ids (`minecraft:redstone`) and item tags
26 /// (`#c:dusts`). Empty = anything.
27 #[serde(default)]
28 #[data(default)]
29 pub accepts: Vec<String>,
30}
31
32impl SlotLayout {
33 /// A slot at `(x, y)` taking anything.
34 pub fn at(x: f32, y: f32) -> Self {
35 Self { x, y, accepts: Vec::new() }
36 }
37}
38
39/// Vanilla spacing between adjacent slots in the default grid.
40pub const SLOT_SIZE: f32 = 18.0;
41
42/// Default position of the player's main inventory (3×9) relative to the
43/// panel's top-left corner — matches vanilla furnace/chest-style screens.
44pub const DEFAULT_PLAYER_INV_OFFSET: (f32, f32) = (8.0, 84.0);
45/// Extra vertical gap between the player's main inventory and their hotbar,
46/// on top of the normal per-row `SLOT_SIZE` spacing (also a vanilla convention).
47pub const PLAYER_INV_TO_HOTBAR_GAP: f32 = 4.0;
48/// Columns of [`InventoryDef::default_grid`] (a vanilla container row).
49const DEFAULT_GRID_COLS: usize = 9;
50/// Top-left slot of [`InventoryDef::default_grid`] — leaves room for the
51/// title above, like vanilla containers.
52const DEFAULT_GRID_ORIGIN: (f32, f32) = (8.0, 18.0);
53
54/// Describes one inventory-backed screen: how many slots a mod block has,
55/// where they sit, whether the player's own inventory is appended below,
56/// and which background texture to draw (vanilla default, or a mod's own).
57#[derive(Debug, Clone, yog_data::Schema)]
58pub struct InventoryDef {
59 pub id: String,
60 pub slot_count: usize,
61 /// Per-slot pixel positions. Empty = auto-generate a default grid (see
62 /// [`InventoryDef::default_grid`]).
63 pub layout: Vec<SlotLayout>,
64 /// Appends the player's main inventory (3×9) + hotbar (9) below the
65 /// custom slots — no armor, no offhand. Native vanilla slot rendering.
66 pub include_player_inventory: bool,
67 pub player_inv_offset: (f32, f32),
68 /// `None` = default vanilla-style panel texture.
69 pub background_texture: Option<String>,
70 pub title: String,
71 /// Screen size to center the container on — `None` uses the vanilla
72 /// default (176×166). Set this when a custom `on_ui_render` overlay
73 /// draws decoration at a different size, so the real vanilla `Slot`s
74 /// (positioned by `layout`/`player_inv_offset` relative to the
75 /// container's top-left corner) land under that decoration instead of
76 /// the two disagreeing about where the top-left corner even is.
77 pub background_size: Option<(f32, f32)>,
78 /// A machine's part of it (`.yui` `<RecipeArea kind="…">`): its slots
79 /// make recipes of a kind, like a crafting table.
80 pub machine: Option<Machine>,
81}
82
83/// A machine in an inventory: it makes recipes of `kind` from what's in
84/// its `places` — each a slot of the inventory holding one ingredient of
85/// one of the kind's slots — into its `result` slot.
86#[derive(Debug, Clone, PartialEq, Deserialize, yog_data::Schema)]
87pub struct Machine {
88 pub kind: String,
89 pub places: Vec<MachinePlace>,
90 pub result: usize,
91}
92
93/// One ingredient's place: the `index`-th of the kind's slot `slot`, in the
94/// inventory's slot `inv`.
95#[derive(Debug, Clone, PartialEq, Deserialize, yog_data::Schema)]
96pub struct MachinePlace {
97 pub slot: String,
98 pub index: usize,
99 pub inv: usize,
100}
101
102impl InventoryDef {
103 pub fn new(id: impl Into<String>, slot_count: usize) -> Self {
104 Self {
105 id: id.into(),
106 slot_count,
107 layout: Vec::new(),
108 include_player_inventory: true,
109 player_inv_offset: DEFAULT_PLAYER_INV_OFFSET,
110 background_texture: None,
111 title: String::new(),
112 background_size: None,
113 machine: None,
114 }
115 }
116
117 /// Makes part of it a machine (see [`Machine`]).
118 pub fn machine(mut self, machine: Machine) -> Self {
119 self.machine = Some(machine);
120 self
121 }
122
123 /// Explicit per-slot pixel positions, overriding the default grid.
124 pub fn layout(mut self, layout: Vec<SlotLayout>) -> Self {
125 self.layout = layout;
126 self
127 }
128
129 /// Remap the slot grid from a JSON array of `{"x":.., "y":..}` objects —
130 /// one entry per slot, in slot-index order. Lets users/mod-packs override
131 /// the layout without recompiling. Invalid or short JSON is ignored
132 /// (falls back to whatever layout was set before, or the default grid).
133 pub fn layout_from_json(mut self, json: &str) -> Self {
134 if let Ok(parsed) = serde_json::from_str::<Vec<SlotLayout>>(json) {
135 self.layout = parsed;
136 }
137 self
138 }
139
140 /// Whether the player's main inventory + hotbar (no armor/offhand) is
141 /// appended below the custom slots. Default: `true`.
142 pub fn include_player_inventory(mut self, v: bool) -> Self {
143 self.include_player_inventory = v;
144 self
145 }
146
147 pub fn player_inv_offset(mut self, x: f32, y: f32) -> Self {
148 self.player_inv_offset = (x, y);
149 self
150 }
151
152 /// Custom background texture (resource path); `None` keeps the default
153 /// vanilla-style panel.
154 pub fn background_texture(mut self, path: impl Into<String>) -> Self {
155 self.background_texture = Some(path.into());
156 self
157 }
158
159 /// Size (in GUI pixels) a custom `on_ui_render` overlay draws its
160 /// decoration at — used to center the real vanilla container on the
161 /// same footprint so its `Slot`s land under that decoration. Leave
162 /// unset to keep the vanilla default 176×166 centering.
163 pub fn background_size(mut self, w: f32, h: f32) -> Self {
164 self.background_size = Some((w, h));
165 self
166 }
167
168 pub fn title(mut self, title: impl Into<String>) -> Self {
169 self.title = title.into();
170 self
171 }
172
173 /// Resolve the effective slot layout: the explicit one if set, otherwise
174 /// [`InventoryDef::default_grid`].
175 pub fn resolved_layout(&self) -> Vec<SlotLayout> {
176 if !self.layout.is_empty() {
177 return self.layout.clone();
178 }
179 Self::default_grid(self.slot_count)
180 }
181
182 /// Default vanilla-style grid: up to 9 columns, 18px spacing, starting at
183 /// (8, 18) — leaves room for a title above, matches vanilla containers.
184 pub fn default_grid(slot_count: usize) -> Vec<SlotLayout> {
185 let (x0, y0) = DEFAULT_GRID_ORIGIN;
186 (0..slot_count)
187 .map(|i| {
188 let col = (i % DEFAULT_GRID_COLS) as f32;
189 let row = (i / DEFAULT_GRID_COLS) as f32;
190 SlotLayout::at(x0 + col * SLOT_SIZE, y0 + row * SLOT_SIZE)
191 })
192 .collect()
193 }
194}
195
196/// Encode an [`InventoryDef`]'s slot layout as `"x:y,x:y:a|b,..."` — the
197/// wire format used when handing the layout to the Java host (mirrors how
198/// `BlockDef::connect_groups` is comma-joined for its ABI struct); a slot
199/// that takes only some items adds them after a third `:`, `|`-joined.
200pub fn encode_layout(layout: &[SlotLayout]) -> String {
201 layout
202 .iter()
203 .map(|s| match s.accepts.is_empty() {
204 true => format!("{}:{}", s.x, s.y),
205 false => format!("{}:{}:{}", s.x, s.y, s.accepts.join("|")),
206 })
207 .collect::<Vec<_>>()
208 .join(",")
209}
210
211/// Inverse of [`encode_layout`]. Malformed entries are skipped.
212pub fn decode_layout(s: &str) -> Vec<SlotLayout> {
213 if s.is_empty() {
214 return Vec::new();
215 }
216 s.split(',')
217 .filter_map(|entry| {
218 let mut parts = entry.splitn(3, ':');
219 let (x, y) = (parts.next()?, parts.next()?);
220 let accepts = parts
221 .next()
222 .map(|a| a.split('|').filter(|s| !s.is_empty()).map(str::to_owned).collect())
223 .unwrap_or_default();
224 Some(SlotLayout {
225 x: x.parse().ok()?,
226 y: y.parse().ok()?,
227 accepts,
228 })
229 })
230 .collect()
231}
232
233// ── Client-side "currently open menu" snapshot ───────────────────────────────
234//
235// A mod that wants to decorate its own `yog-ui` widget tree with real slot
236// contents (see `yog-ui`'s `inv_slot` widget — purely data-driven, it has no
237// idea what "the currently open menu" even is) needs to read, on the client
238// render thread, what's actually in that menu's slots right now. A plain
239// Rust `static` cannot carry that across the mod/runtime boundary: the
240// runtime and each mod's own dynamic library each get their own separate
241// copy of any crate-level static, so writes on one side are invisible on
242// the other (this is what broke the original design).
243//
244// Instead, the runtime resolves and calls `yog_inventory_set_snapshot` — an
245// exported symbol every mod that depends on this crate carries automatically
246// — once per frame, right before invoking `on_ui_render` handlers, handing
247// over a fresh pointer to THIS mod's own copy of the `SNAPSHOT` static below.
248// Resolved the same way the runtime already resolves `yog_mod_register` /
249// `__yog_resolve_imports_final` on each loaded mod's library.
250
251/// Capacity of [`YogInvSnapshotRaw::slots`] — covers the largest realistic
252/// inventory screen (a mod's own slots + player main inventory + hotbar).
253pub const MAX_SNAPSHOT_SLOTS: usize = 64;
254
255/// One inventory slot's raw wire data for [`YogInvSnapshotRaw`].
256#[repr(C)]
257#[derive(Copy, Clone)]
258pub struct YogInvSlotRaw {
259 /// Empty (`len == 0`) if the slot itself is empty.
260 pub item_id: YogStr,
261 pub count: u32,
262 /// Screen-space pixel position.
263 pub x: i32,
264 pub y: i32,
265}
266
267impl YogInvSlotRaw {
268 pub const EMPTY: Self = Self {
269 item_id: YogStr::EMPTY,
270 count: 0,
271 x: 0,
272 y: 0,
273 };
274}
275
276/// Raw per-frame snapshot of the currently-open inventory screen, built and
277/// owned by the runtime, valid only for the duration of the render call that
278/// hands it over.
279#[repr(C)]
280#[derive(Copy, Clone)]
281pub struct YogInvSnapshotRaw {
282 pub slot_count: u32,
283 pub slots: [YogInvSlotRaw; MAX_SNAPSHOT_SLOTS],
284}
285
286impl YogInvSnapshotRaw {
287 pub const EMPTY: Self = Self {
288 slot_count: 0,
289 slots: [YogInvSlotRaw::EMPTY; MAX_SNAPSHOT_SLOTS],
290 };
291}
292
293static SNAPSHOT: AtomicPtr<YogInvSnapshotRaw> = AtomicPtr::new(std::ptr::null_mut());
294
295/// Called by the runtime once per frame, before running `on_ui_render`
296/// handlers, to refresh this mod's view of the currently-open inventory
297/// screen. Not meant to be called by mod code directly.
298#[doc(hidden)]
299#[unsafe(no_mangle)]
300pub extern "C" fn yog_inventory_set_snapshot(snapshot: *const YogInvSnapshotRaw) {
301 SNAPSHOT.store(snapshot as *mut _, Ordering::Release);
302}
303
304/// One inventory slot's content, as of the last snapshot.
305#[derive(Debug, Clone, Default)]
306pub struct SlotView {
307 /// `"namespace:item_id"` — empty if the slot is empty.
308 pub item_id: String,
309 pub count: u32,
310 /// Screen-space pixel position.
311 pub x: i32,
312 pub y: i32,
313}
314
315/// The filled slots of the snapshot the runtime handed over this frame
316/// (empty when no inventory screen is open).
317fn snapshot_slots() -> &'static [YogInvSlotRaw] {
318 let ptr = SNAPSHOT.load(Ordering::Acquire);
319 // SAFETY: the runtime keeps the snapshot alive for the render call that
320 // reads it, and only stores null or a valid pointer.
321 match unsafe { ptr.as_ref() } {
322 Some(snap) => &snap.slots[..(snap.slot_count as usize).min(MAX_SNAPSHOT_SLOTS)],
323 None => &[],
324 }
325}
326
327/// Number of slots in the currently-open inventory screen (0 if none is open).
328pub fn current_slot_count() -> usize {
329 snapshot_slots().len()
330}
331
332/// Slot `index`'s content right now, or `None` if out of range.
333pub fn current_slot(index: usize) -> Option<SlotView> {
334 let raw = snapshot_slots().get(index)?;
335 let item_id = if raw.item_id.is_empty() {
336 String::new()
337 } else {
338 unsafe { raw.item_id.as_str() }.to_owned()
339 };
340 Some(SlotView {
341 item_id,
342 count: raw.count,
343 x: raw.x,
344 y: raw.y,
345 })
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351
352 #[test]
353 fn default_grid_wraps_rows() {
354 let g = InventoryDef::new("t", DEFAULT_GRID_COLS + 1).resolved_layout();
355 assert_eq!(g[0], SlotLayout::at(8.0, 18.0));
356 assert_eq!(g[DEFAULT_GRID_COLS], SlotLayout::at(8.0, 36.0));
357 }
358
359 #[test]
360 fn layout_round_trips() {
361 let l = vec![
362 SlotLayout::at(1.5, 2.0),
363 SlotLayout {
364 accepts: vec!["minecraft:redstone".into(), "#c:dusts".into()],
365 ..SlotLayout::at(30.0, 4.0)
366 },
367 ];
368 assert_eq!(encode_layout(&l), "1.5:2,30:4:minecraft:redstone|#c:dusts");
369 assert_eq!(decode_layout(&encode_layout(&l)), l);
370 assert!(decode_layout("").is_empty());
371 assert_eq!(
372 decode_layout("1:2,bad,3"),
373 vec![SlotLayout::at(1.0, 2.0)]
374 );
375 }
376
377 #[test]
378 fn no_snapshot_means_no_slots() {
379 assert_eq!(current_slot_count(), 0);
380 assert!(current_slot(0).is_none());
381 }
382}