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