Native mods: a recipe ingredient of a mod's own kind

An ingredient that names a type in a namespace outside minecraft: used
to fail the recipe file whole. It now reaches native_ingredient(kind,
data, items), a free function in crafting.rs which mixin bundles hook:
fill the item list with what the ingredient matches, as a tag is. The
list holds item ids only (components are not checked; a match-time
hook follows separately). Duplicates are removed keeping first-seen
order. The hook is called once per ingredient by each copy that loads
the recipes — the crafting tables', the network copy's — so at least
twice per load, and the answer must be the same. An unclaimed kind
leaves the list empty and the recipe fails, as an unknown kind does; a
bare type name is minecraft's own.

The catalog target names horizon_client_lib, the package crafting.rs
compiles into since #5446's split: horizon-mixins resolves the crate
by its package, and refuses horizon_client::crafting::... (proven on a
staged build; the same split left menu's and breathing's catalog
targets on the old crate name — their bundles would fail the same way,
left to their own fix).

Part of #5367: predicates over components need a match-time hook, not
this load-time list, and follow separately.

Co-Authored-By: ZCode AI agent (GLM-5.3)
This commit is contained in:
huiandZCode AI agent (GLM-5.3) committed 2026-11-02 01:47:42 +01:00
1 parent fd88a545d9
commit df3a7ffd73
4 files changed
+88

No files matched your search

+49
View File
@@ -19,6 +19,30 @@ use std::{
sync::Arc,
};
/// A recipe ingredient of a mod's own kind: the entry named a type in a
/// namespace outside `minecraft:`, and the loader kept it for this call.
/// The kind and the JSON the file wrote are here; fill `items` with the
/// item ids the ingredient matches (item ids only: components are not
/// checked here; a match-time hook follows separately). Duplicates are
/// removed keeping first-seen order. The hook may be called more than
/// once per recipe; the answer must be the same. An unclaimed kind
/// leaves the list empty and the recipe fails, as an unknown kind does.
/// API @API:ingredient@.
#[inline(never)]
pub fn native_ingredient(
kind: &str,
data: &serde_json::Map<String, Value>,
items: &mut Vec<String>,
) {
let _ = (kind, data, items);
}
/// Keep first-seen order, drop the rest.
fn dedup_keep_order(items: &mut Vec<String>) {
let mut seen = std::collections::HashSet::new();
items.retain(|i| seen.insert(i.clone()));
}
/// A set of accepted items (expanded from an item, list or tag), and the
/// items in its `HolderSet` order (`Ingredient.display`'s `TagSlotDisplay`).
#[derive(Clone, Debug)]
@@ -107,6 +131,14 @@ impl Ingredient {
.cloned()
.collect()
}
// A mod's own kind: the hook answers with the item list it
// matches; a bare type name is minecraft's own, not a mod's.
k if k.contains(':') && !k.starts_with("minecraft:") => {
let mut list = Vec::new();
native_ingredient(k, o, &mut list);
dedup_keep_order(&mut list);
return (!list.is_empty()).then(|| Self::of(list));
}
_ => return None,
};
(!kept.is_empty()).then(|| Self::of(kept))
@@ -1913,6 +1945,23 @@ mod tests {
/// NeoForge's standard ingredient shapes resolve to their flat item
/// lists; a kind the loader doesn't know still fails the file.
#[test]
fn an_unclaimed_ingredient_kind_fails_and_dedup_keeps_order() {
let items = Items::for_tests(HashMap::new());
let json = |s: &str| serde_json::from_str::<Value>(s).unwrap();
assert!(
Ingredient::parse(&json(r#"{"type":"mymod:aspect","aspect":"fire"}"#), &items,)
.is_none()
);
assert!(
Ingredient::parse(&json(r#"{"type":"minecraft:future_shape"}"#), &items,).is_none()
);
assert!(Ingredient::parse(&json(r#"{"type":"aspect"}"#), &items,).is_none());
let mut list = vec!["b".to_string(), "a".to_string(), "b".to_string()];
dedup_keep_order(&mut list);
assert_eq!(list, vec!["b".to_string(), "a".to_string()]);
}
#[test]
fn neoforge_ingredient_shapes_parse() {
let mut tags = HashMap::new();
@@ -6357,6 +6357,23 @@ mod tests {
}
}
#[test]
fn native_ingredient_stays_hookable() {
let source = include_str!("../crafting.rs");
let at = source.find("pub fn native_ingredient(").unwrap();
let start = source[..at].rfind("///").unwrap();
let body = &source[start..start + source[start..].find("\n}\n").unwrap()];
for line in body.lines() {
assert!(
!line.contains("cfg"),
"a cfg in the ingredient hook: {line}"
);
for mac in ["!(", "![", "! {", "!{"] {
assert!(!line.contains(mac), "a macro: {line}");
}
}
}
#[test]
fn loot_table_load_stays_hookable() {
let source = include_str!("../../../horizon-world/src/loot.rs").replace("\r\n", "\n");
@@ -6459,6 +6476,8 @@ mod tests {
let menu_hooks = free_hooks(include_str!("../menu.rs"));
// The air supply's (`horizon_client::breathing`).
let breath_hooks = free_hooks(include_str!("../breathing.rs"));
// The crafting loaders' (`horizon_client_lib::crafting`).
let crafting_hooks = free_hooks(include_str!("../crafting.rs"));
let declared: BTreeSet<_> = section
.lines()
.chain(level_section.lines())
@@ -6473,6 +6492,7 @@ mod tests {
.chain(loot_hooks.iter().copied())
.chain(menu_hooks.iter().copied())
.chain(breath_hooks.iter().copied())
.chain(crafting_hooks.iter().copied())
.collect();
let mut names = BTreeSet::new();
let mut contracts = BTreeSet::new();
@@ -6490,6 +6510,10 @@ mod tests {
format!("horizon_world::item_defaults::native_{name}")
} else if loot_hooks.contains(name) {
format!("horizon_world::loot::native_{name}")
} else if crafting_hooks.contains(name) {
// crafting.rs compiles into horizon-client-lib (#5446's
// split): horizon-mixins resolves the crate by its package.
format!("horizon_client_lib::crafting::native_{name}")
} else if menu_hooks.contains(name) {
format!("horizon_client::menu::native_{name}")
} else if breath_hooks.contains(name) {
+9
View File
@@ -436,6 +436,15 @@ position), `block_entity` (the broken block's entity as it was before the break,
vanilla's `time_check` and `weather_check`). The game sets it for a block a player or an explosion breaks and for a
mob's death loot; on other rolls `place` is `None`.
### A mod's own ingredient kinds
A recipe's ingredient that names a type in a namespace outside `minecraft:` reaches
`native_ingredient(kind, data, items)` at load time. Fill `items` with the item ids the
ingredient matches (item ids only: components are not checked; a match-time hook follows
separately). Duplicates are removed keeping first-seen order. The hook may be called more
than once per recipe; the answer must be the same. An unclaimed kind leaves the list empty
and the recipe fails, as an unknown kind does.
### Data files
A bundle's `data/` folder (any files) is staged as `native-data/<mod>/` at the root of the workspace. A helper or
@@ -0,0 +1,6 @@
{
"hooks": [{"name": "ingredient", "target": "horizon_client_lib::crafting::native_ingredient"}],
"rust_summary": "a recipe ingredient of a mod's own kind\n(`native_ingredient`, which the unknown ingredient type in a namespace of\nown reaches with the kind, its JSON and the item list to fill).",
"doc_summary": "a recipe ingredient of a mod's own kind. The ingredient entry that names a type in a namespace outside minecraft: reaches native_ingredient(kind, data, items) at load time. Fill items with the item ids the ingredient matches (item ids only: components are not checked; a match-time hook follows separately). Duplicates are removed keeping first-seen order. An unclaimed kind leaves the list empty and the recipe fails. The hook may be called more than once per recipe; the answer must be the same. Bundles that use it need \"hooks\": @API:ingredient@.",
"comments": ["crates/horizon-client/src/crafting.rs"]
}