Skip to main content

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}