feat(mods): draw item stacks on screens

Co-Authored-By: Codex gpt-6.1-terra <[email protected]>
This commit is contained in:
[email protected]andCodex gpt-6.1-terra committed 2026-11-02 10:15:45 +01:00
1 parent ce8d86cb23
commit ff5551084a
9 files changed
+126 -2

No files matched your search

@@ -450,4 +450,7 @@ impl Host for super::Game<'_> {
) -> u32 {
self.native_move_items(dimension, from, from_face, to, to_face, item, max)
}
fn item_stack(&self, batch: &mut HudBatch, stack: &ItemStack, at: [f32; 2], size: f32) -> bool {
self.native_item_stack(batch, stack, at, size)
}
}
@@ -6932,6 +6932,24 @@ impl Game<'_> {
batch.scale_since(start, at[0], at[1], size / 16.0, size / 16.0);
true
}
/// Draw a stack and its slot decorations, except the count, at `at`,
/// `size` GUI pixels square. False for an unknown item. API @API:item_stack@.
pub(super) fn native_item_stack(
&self,
batch: &mut HudBatch,
stack: &ItemStack,
at: [f32; 2],
size: f32,
) -> bool {
if self.res.items().get(&stack.item).is_none() {
return false;
}
let start = batch.vertices.len();
self.draw_item_model(batch, stack, at[0], at[1]);
self.draw_item_decorations(batch, stack, at[0], at[1], Some(String::new()));
batch.scale_since(start, at[0], at[1], size / 16.0, size / 16.0);
true
}
/// Item `item`'s icon drawn with model `model` (`minecraft:paper` as
/// `mymod:wheel_icon`, an `item_model` of a content mod's), as `native_item`
/// draws one; false for an unknown item. API 111.
+3
View File
@@ -664,4 +664,7 @@ pub trait Host {
item: Option<&str>,
max: u32,
) -> u32;
/// Draw a stack and its slot decorations, except the count, at `at`,
/// `size` GUI pixels square. False for an unknown item. API @API:item_stack@.
fn item_stack(&self, batch: &mut HudBatch, stack: &ItemStack, at: [f32; 2], size: f32) -> bool;
}
+61
View File
@@ -0,0 +1,61 @@
"""Exercise the screen stack helper without a game window."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from test_world_input_capture import method
ROOT = Path(__file__).resolve().parents[1]
class NativeItemStackTests(unittest.TestCase):
@unittest.skipUnless(shutil.which("rustc"), "requires Rust")
def test_stack_and_decorations_are_forwarded_and_scaled(self):
source = (ROOT / "crates/horizon-client/src/game/native_hooks.rs").read_text(encoding="utf-8")
actual = method(source.replace("pub(super) fn", "fn"), "native_item_stack")
fixture = r"""
struct ItemStack { item: String, count: u32, damage: u32 }
struct Items;
impl Items { fn get(&self, id: &str) -> Option<()> { (id == "known").then_some(()) } }
struct Resources;
impl Resources { fn items(&self) -> Items { Items } }
struct HudBatch { vertices: Vec<u32>, scaled: Option<(usize, f32, f32, f32, f32)> }
impl HudBatch {
fn scale_since(&mut self, start: usize, x: f32, y: f32, sx: f32, sy: f32) {
self.scaled = Some((start, x, y, sx, sy));
}
}
struct Game { res: Resources }
impl Game {
fn draw_item_model(&self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) {
assert_eq!((stack.count, stack.damage, x, y), (42, 13, 2.0, 3.0));
batch.vertices.push(1);
}
fn draw_item_decorations(&self, batch: &mut HudBatch, stack: &ItemStack,
x: f32, y: f32, count: Option<String>) {
assert_eq!((stack.count, stack.damage, x, y, count.as_deref()), (42, 13, 2.0, 3.0, Some("")));
batch.vertices.push(2);
}
METHOD
}
#[test]
fn stack_and_unknown_item() {
let game = Game { res: Resources };
let mut batch = HudBatch { vertices: vec![0], scaled: None };
let mut stack = ItemStack { item: "unknown".into(), count: 42, damage: 13 };
assert!(!game.native_item_stack(&mut batch, &stack, [2.0, 3.0], 32.0));
assert_eq!(batch.vertices, vec![0]);
assert_eq!(batch.scaled, None);
stack.item = "known".into();
assert!(game.native_item_stack(&mut batch, &stack, [2.0, 3.0], 32.0));
assert_eq!(batch.vertices, vec![0, 1, 2]);
assert_eq!(batch.scaled, Some((1, 2.0, 3.0, 2.0, 2.0)));
}
""".replace("METHOD", actual)
with tempfile.TemporaryDirectory() as folder:
src, exe = Path(folder) / "item.rs", Path(folder) / "item.exe"
src.write_text(fixture, encoding="utf-8")
subprocess.run(["rustc", "--edition=2024", "--test", str(src), "-o", str(exe)], check=True)
subprocess.run([str(exe)], check=True)
+2
View File
@@ -79,10 +79,12 @@ class StampTests(unittest.TestCase):
path = self.fragment("helper")
fragment = json.loads(path.read_text())
fragment["hooks"] = []
fragment["helpers"] = [{"name": "helper", "target": "target::helper"}]
path.write_text(json.dumps(fragment))
self.assertFalse(stamp.stamp(False))
self.assertIn("NATIVE_HOOKS_API: u32 = 8;", (self.root / stamp.SOURCE).read_text())
self.assertEqual(json.loads((self.root / stamp.CATALOG).read_text())["hooks"], [])
self.assertEqual(json.loads((self.root / stamp.CATALOG).read_text())["helpers"], fragment["helpers"])
def test_catalog_facts_travel_with_the_hook(self):
self.git("add", ".")
+1 -1
View File
@@ -2,7 +2,7 @@
A hook PR adds a uniquely named JSON file here instead of editing the API number,
the API histories or `native-hooks.json`. Helper-only additions use an empty
`hooks` array. Example:
`hooks` array and list new Host helpers in `helpers` as catalog entries. Example:
```json
{
@@ -0,0 +1,8 @@
{
"hooks": [],
"helpers": [
{"name": "item_stack", "target": "horizon_client::game::native_hooks::Game<'_>::native_item_stack"}
],
"rust_summary": "drawing a component-bearing item stack and its decorations on a screen (`native_item_stack`).",
"doc_summary": "drawing an item stack on a screen: `native_item_stack(batch, stack, at, size)` draws its model, glint, damage bar, cooldown and mod decorations, but not its count. It returns false for an unknown item. Bundles need \"hooks\": @API:item_stack@."
}
+6
View File
@@ -50,6 +50,7 @@ def stamp(check):
raise ValueError("missing numeric NATIVE_HOOKS_API")
version = int(match.group(1))
names = {hook["name"] for hook in catalog["hooks"]}
helper_names = {helper["name"] for helper in catalog.get("helpers", [])}
extra = {}
for path in ordered:
fragment = json.loads(path.read_text())
@@ -63,6 +64,11 @@ def stamp(check):
# The catalog's optional facts about a hook travel with it.
entry.update({k: hook[k] for k in ("neoforge", "side", "cancellable", "mod_api") if k in hook})
catalog["hooks"].append(entry)
for helper in fragment.get("helpers", []):
if helper["name"] in helper_names:
raise ValueError(f"duplicate helper: {helper['name']}")
helper_names.add(helper["name"])
catalog.setdefault("helpers", []).append(helper)
rust_summary = fragment["rust_summary"].replace(marker, str(version))
doc_summary = fragment["doc_summary"].replace(marker, str(version))
if not rust_summary or not doc_summary:
+24 -1
View File
@@ -880,10 +880,33 @@ fn generate(root: &Path, check: bool) -> Result<(), String> {
let mut host_traits = String::new();
let mut host_impl = String::new();
let mut names = HashSet::new();
for helper in catalog["helpers"]
let mut helpers = catalog["helpers"]
.as_array()
.ok_or("catalog helpers must be an array")?
.clone();
let mut fragments = fs::read_dir(root.join("tools/native-hook-fragments"))
.map_err(|e| e.to_string())?
.map(|entry| entry.map(|entry| entry.path()).map_err(|e| e.to_string()))
.collect::<Result<Vec<_>, _>>()?;
fragments.sort();
for path in fragments
.into_iter()
.filter(|path| path.extension().is_some_and(|ext| ext == "json"))
{
let fragment: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).map_err(|e| e.to_string())?)
.map_err(|e| format!("{}: {e}", path.display()))?;
if let Some(pending) = fragment.get("helpers") {
helpers.extend(
pending
.as_array()
.ok_or("fragment helpers must be an array")?
.iter()
.cloned(),
);
}
}
for helper in &helpers {
let name = helper["name"].as_str().ok_or("helper name missing")?;
if !names.insert(name) {
return Err(format!("duplicate helper {name}"));