Content mods: a block declares its own state properties (#5290)

A content block's states were exactly its template's, copied row for row, and
`default_state` could only overlay values on properties the template already
had. So a pipe could not have a connection per side, and a machine could not
have `facing` and `lit`.

A block file may now declare `state_properties` — a boolean, or an enum of
values — and the block's states become its template's states times their
combinations, appended after the template's run so a block's states stay one
contiguous run in registry order.

The combination order puts every property at its declared default first, and
`Rename` carries how many states a block gives each of its template's, so a
template state still moves to exactly one state: the block's default. The
`facts.json` defaults and `render-definitions.json` mappings are unchanged.

Refused: a name its template already has, a bad name or value, a
`default_state` value the property does not have, and more combinations than
a world holds (2^20) — the last before the product is built.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
This commit is contained in:
sirrayiandDeepSeek Harness committed 2026-10-31 17:36:25 +00:00
1 parent b8cf85f8c3
commit e7f4440f05
4 files changed
+472 -51

No files matched your search

+160 -51
View File
@@ -16,7 +16,7 @@
//! content mod the root is the base root itself.
use crate::{Result, blocks::MAP_COLORS};
use horizon_core::geometry::{Aabb, Axis, ShapeOp, Vec3, VoxelShape};
use horizon_mods::content::{Block, Content, CreativeTab, MobEffect, Rarity};
use horizon_mods::content::{Block, Content, CreativeTab, MobEffect, Rarity, StateProperty};
use horizon_world::nbt::{self, JavaString, Limits, Tag};
use serde::{Deserialize, Serialize, de};
use std::{
@@ -204,6 +204,11 @@ struct Rename {
to: String,
states: Range<u32>,
first: u32,
/// States this block gives each of its template's: one per combination
/// of the state properties it declares (1 when it declares none). A
/// template state moves to the first of them, which is the one every
/// declared property is at its default for.
fanout: u32,
}
impl Rename {
fn new(from: &str, to: &str) -> Self {
@@ -212,6 +217,7 @@ impl Rename {
to: to.to_owned(),
states: 0..0,
first: 0,
fanout: 1,
}
}
/// The id itself, `Block{id}`, and `block.<ns>.<path>` / `item.<ns>.<path>` keys.
@@ -233,7 +239,77 @@ impl Rename {
fn id(&self, id: u32) -> Option<u32> {
self.states
.contains(&id)
.then(|| self.first + (id - self.states.start))
.then(|| self.first + (id - self.states.start) * self.fanout)
}
}
/// The combinations of a block's declared state properties, in a fixed
/// order: by name (a `BTreeMap`'s), each one's declared default first, and
/// the first name varying slowest. So the first combination is every
/// property at its default, which is the one a template state maps to.
fn combinations(
declared: &BTreeMap<String, StateProperty>,
default_state: &BTreeMap<String, String>,
) -> Result<Vec<Vec<(String, String)>>> {
let mut orders: Vec<(&str, Vec<&str>)> = Vec::new();
for (name, property) in declared {
let mut values = property.values();
if let Some(default) = default_state.get(name) {
let Some(at) = values.iter().position(|v| v == default) else {
return Err(
format!("{name}: default_state {default:?} is not one of its values").into(),
);
};
values.rotate_left(at);
}
orders.push((name, values));
}
// Every combination is a state of each of the template's, so a block's run
// is its template's states times this many: refused before it is built.
let mut product: u64 = 1;
for (_, values) in &orders {
product = product.saturating_mul(values.len() as u64);
}
if product > MAX_STATES as u64 {
return Err(format!(
"{product} state property combinations, more than the {MAX_STATES} a world can hold"
)
.into());
}
let mut combos: Vec<Vec<(String, String)>> = vec![Vec::new()];
for (name, values) in orders {
let mut next = Vec::with_capacity(combos.len().saturating_mul(values.len()));
for combo in &combos {
for value in &values {
let mut c = combo.clone();
c.push((name.to_owned(), (*value).to_owned()));
next.push(c);
}
}
combos = next;
}
Ok(combos)
}
/// Puts a declared state property's values on one of a block's state rows.
/// Only `facts.json` keeps a state's own row, whose fifth field is
/// `{"id": …, "properties": {…}}`; the other files' rows are per block, so
/// they have no property map to extend.
fn set_state_properties(row: &mut J, combo: &[(String, String)]) {
let J::Arr(parts) = row else {
return;
};
let Some(J::Obj(state)) = parts.get_mut(4) else {
return;
};
if !state.iter().any(|(k, _)| k == "properties") {
state.push(("properties".into(), J::Obj(Vec::new())));
}
let Some((_, properties)) = state.iter_mut().find(|(k, _)| k == "properties") else {
return;
};
for (name, value) in combo {
properties.set(name, J::Str(value.clone()));
}
}
@@ -330,6 +406,10 @@ struct NewBlock<'a> {
item: Option<String>,
shapes: Option<Vec<VoxelShape>>,
default: u32,
/// The declared state properties' combinations, every property at its
/// default first (one empty combination when the block declares none):
/// the block's states are its template's states times these.
combos: Vec<Vec<(String, String)>>,
}
/// An item being added (declared, or a block's).
struct NewItem {
@@ -499,20 +579,34 @@ impl<'a> Plan<'a> {
decl.id, decl.template
)
})?;
let Some(J::Arr(rows)) = facts.get("states") else {
unreachable!()
};
let properties = |id: u32| -> Option<&J> {
let J::Arr(row) = rows.get(id as usize)? else {
return None;
};
row.get(4)?.get("properties")
};
for key in decl.state_properties.keys() {
if properties(default).is_some_and(|p| p.get(key).is_some()) {
return Err(format!(
"{}: state property {key} is already on {}",
decl.id, decl.template
)
.into());
}
}
let combos = combinations(&decl.state_properties, &decl.default_state)
.map_err(|e| format!("{}: {e}", decl.id))?;
if !decl.default_state.is_empty() {
let Some(J::Arr(rows)) = facts.get("states") else {
unreachable!()
};
let properties = |id: u32| -> Option<&J> {
let J::Arr(row) = rows.get(id as usize)? else {
return None;
};
row.get(4)?.get("properties")
};
let mut wanted = properties(default).cloned().ok_or_else(|| {
format!("{}: template default has no state properties", decl.id)
})?;
for (key, value) in &decl.default_state {
if decl.state_properties.contains_key(key) {
continue;
}
if wanted.get(key).is_none() {
return Err(format!(
"{}: unknown state property {key} on {}",
@@ -547,7 +641,10 @@ impl<'a> Plan<'a> {
}
let mut rename = Rename::new(&decl.template, &decl.id);
rename.first = next;
next += states.len() as u32;
rename.fanout = combos.len() as u32;
let count =
u32::try_from(states.len() as u64 * combos.len() as u64).unwrap_or(u32::MAX);
next = next.saturating_add(count);
rename.states = states;
if let Some(s) = &decl.properties.sounds {
if !defaults.contains_key(s) {
@@ -561,6 +658,7 @@ impl<'a> Plan<'a> {
item: None,
shapes,
default,
combos,
});
}
}
@@ -569,7 +667,7 @@ impl<'a> Plan<'a> {
for b in &blocks {
*by_mod
.entry(&b.decl.id[..b.decl.id.find(':').unwrap_or(0)])
.or_default() += b.rename.states.len() as u32;
.or_default() += (b.rename.states.len() as u32).saturating_mul(b.rename.fanout);
}
let list: Vec<String> = by_mod.iter().map(|(m, n)| format!("{m} {n}")).collect();
return Err(format!(
@@ -672,47 +770,58 @@ impl<'a> Plan<'a> {
}
for b in &self.blocks {
let renames = self.renames(b);
for (i, id) in b.rename.states.clone().enumerate() {
let mut row = list[id as usize].clone();
for r in &renames {
row.rename(r, ids);
}
if gameplay {
row.set("id", J::Num((b.rename.first + i as u32).into()));
if !b.decl.item {
row.set("item", J::Str("minecraft:air".into()));
// One row per template state per combination the block declares,
// so its states stay one run in registry order.
let mut next = b.rename.first;
for (t, id) in b.rename.states.clone().enumerate() {
for combo in &b.combos {
let mut row = list[id as usize].clone();
for r in &renames {
row.rename(r, ids);
}
self.override_gameplay(b, &mut row);
if let Some(shapes) = &b.shapes {
let shape = &shapes[i];
let boxes = shape
.boxes()
.iter()
.map(|b| {
J::Arr(
[
b.min(Axis::X),
b.min(Axis::Y),
b.min(Axis::Z),
b.max(Axis::X),
b.max(Axis::Y),
b.max(Axis::Z),
]
.into_iter()
.map(J::f64)
.collect(),
)
})
.collect();
row.set("outline", J::Arr(boxes));
row.set("outline_edges", J::Arr(shape_edges(shape)));
row.set(
"full_collision",
J::Bool(!shape.join_is_not_empty(&VoxelShape::block(), ShapeOp::XOR)?),
);
if !combo.is_empty() {
set_state_properties(&mut row, combo);
}
if gameplay {
row.set("id", J::Num(next.into()));
if !b.decl.item {
row.set("item", J::Str("minecraft:air".into()));
}
self.override_gameplay(b, &mut row);
if let Some(shapes) = &b.shapes {
let shape = &shapes[t];
let boxes = shape
.boxes()
.iter()
.map(|b| {
J::Arr(
[
b.min(Axis::X),
b.min(Axis::Y),
b.min(Axis::Z),
b.max(Axis::X),
b.max(Axis::Y),
b.max(Axis::Z),
]
.into_iter()
.map(J::f64)
.collect(),
)
})
.collect();
row.set("outline", J::Arr(boxes));
row.set("outline_edges", J::Arr(shape_edges(shape)));
row.set(
"full_collision",
J::Bool(
!shape.join_is_not_empty(&VoxelShape::block(), ShapeOp::XOR)?,
),
);
}
}
list.push(row);
next += 1;
}
list.push(row);
}
}
Ok(())
@@ -1871,3 +1871,228 @@ fn creative_tabs_come_on_their_own_page() {
assert_eq!(creative.page, 0);
fs::remove_dir_all(&scratch).unwrap();
}
/// A block that gives each of its template's states a run moves a template
/// state to the first of its own: the combination every declared property is
/// at its default for, which is the one the id cursor points at first.
#[test]
fn a_fan_out_rename_moves_a_template_state_to_its_first_state() {
let mut r = Rename::new("minecraft:test", "testmod:test");
r.states = 10..12;
r.first = 100;
r.fanout = 6;
assert_eq!(
(r.id(9), r.id(10), r.id(11), r.id(12)),
(None, Some(100), Some(106), None)
);
}
/// A base root of one template with two states, and a mod that adds a block
/// to it. `declarations` go in as the block file.
fn block_base(tag: &str, declarations: &[&str]) -> (PathBuf, Vec<ContentMod>) {
let scratch = std::env::temp_dir().join(format!("horizon-mod-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
let base = scratch.join("base");
fs::create_dir_all(&base).unwrap();
let facts = r#"{"states":[
["minecraft:test",false,false,"",{"id":"minecraft:test","properties":{"axis":"x"}}],
["minecraft:test",false,false,"",{"id":"minecraft:test","properties":{"axis":"y"}}]],
"defaults":{"minecraft:test":0}}"#;
fs::write(base.join("facts.json"), facts).unwrap();
fs::write(
base.join("gameplay-facts.json"),
r#"{"states":[{"item":"minecraft:test"},{"item":"minecraft:test"}]}"#,
)
.unwrap();
fs::write(base.join("items.json"), r#"{"minecraft:test":{}}"#).unwrap();
fs::write(
base.join("registries.json"),
r#"{"minecraft:item":{"entries":{"minecraft:test":{"protocol_id":0}}},"minecraft:block":{"entries":{"minecraft:test":{"protocol_id":0}}}}"#,
)
.unwrap();
let mods: Vec<ContentMod> = declarations
.iter()
.enumerate()
.map(|(i, declaration)| {
let dir = scratch.join(format!("mod{i}"));
folder_mod(
&dir,
&[
("horizon-mod.toml", &manifest("testmod", "")),
("data/testmod/horizon/block/test.json", *declaration),
],
)
})
.collect();
(base, mods)
}
/// A block's states are its template's times the combinations of the state
/// properties it declares, in a fixed order with every default first.
#[test]
fn content_block_state_properties_fan_out_its_states() {
let declaration = r#"{"template":"minecraft:test","item":false,
"state_properties":{"lit":{"type":"boolean"},
"facing":{"type":"enum","values":["north","south","east"]}}}"#;
let (base, mods) = block_base("block-states", &[declaration]);
let plan = Plan::new(&base, &mods).unwrap();
let facts = fs::read_to_string(base.join("facts.json")).unwrap();
let mut doc: J = serde_json::from_str(&facts).unwrap();
plan.json("facts.json", &mut doc).unwrap();
// Two template states times the six combinations, after the template's.
let Some(J::Arr(rows)) = doc.get("states") else {
panic!("no states")
};
assert_eq!(rows.len(), 2 + 12);
// The default is the first combination of the template's default state.
assert_eq!(
doc.get("defaults").unwrap().get("testmod:test"),
Some(&J::Num(2.into()))
);
let properties = |i: usize| -> BTreeMap<String, String> {
let J::Arr(row) = &rows[i] else {
panic!("row {i} is not a state")
};
let Some(J::Obj(state)) = row.get(4) else {
panic!("row {i} has no state")
};
let Some((_, J::Obj(p))) = state.iter().find(|(k, _)| k == "properties") else {
panic!("row {i} has no properties")
};
p.iter()
.map(|(k, v)| match v {
J::Str(s) => (k.clone(), s.clone()),
_ => panic!("{k} is not a string"),
})
.collect()
};
// By name `facing` varies slowest, and each one's default comes first.
assert_eq!(
properties(2),
BTreeMap::from([
("axis".to_owned(), "x".to_owned()),
("facing".to_owned(), "north".to_owned()),
("lit".to_owned(), "false".to_owned()),
])
);
// And the last is the second template state and the last combination.
assert_eq!(
properties(13),
BTreeMap::from([
("axis".to_owned(), "y".to_owned()),
("facing".to_owned(), "east".to_owned()),
("lit".to_owned(), "true".to_owned()),
])
);
// Every state of the block is a different one: the registry indexes
// states by their name and properties, so a repeat would be a collision.
let states: BTreeSet<BTreeMap<String, String>> = (2..14).map(properties).collect();
assert_eq!(states.len(), 12);
fs::remove_dir_all(&base).unwrap();
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
}
/// The declared default comes first in the run, so its id is the block's
/// default with no arithmetic of its own.
#[test]
fn content_block_state_properties_put_the_declared_default_first() {
let declaration = r#"{"template":"minecraft:test","item":false,
"state_properties":{"facing":{"type":"enum","values":["north","south","east"]}},
"default_state":{"facing":"south"}}"#;
let (base, mods) = block_base("block-default-first", &[declaration]);
let plan = Plan::new(&base, &mods).unwrap();
let facts = fs::read_to_string(base.join("facts.json")).unwrap();
let mut doc: J = serde_json::from_str(&facts).unwrap();
plan.json("facts.json", &mut doc).unwrap();
let Some(J::Arr(rows)) = doc.get("states") else {
panic!("no states")
};
// Two template states times three values.
assert_eq!(rows.len(), 2 + 6);
let facing = |i: usize| -> String {
let J::Arr(row) = &rows[i] else {
panic!("row {i} is not a state")
};
let Some(J::Obj(state)) = row.get(4) else {
panic!("row {i} has no state")
};
match state
.iter()
.find(|(k, _)| k == "properties")
.map(|(_, p)| p)
{
Some(p) => match p.get("facing") {
Some(J::Str(s)) => s.clone(),
_ => panic!("row {i} has no facing"),
},
None => panic!("row {i} has no properties"),
}
};
// The declared default is the block's default, and it comes first.
assert_eq!(
doc.get("defaults").unwrap().get("testmod:test"),
Some(&J::Num(2.into()))
);
assert_eq!(facing(2), "south");
// The rest keep their declared order, rotated to start at the default.
assert_eq!(
(facing(3), facing(4)),
("east".to_owned(), "north".to_owned())
);
fs::remove_dir_all(&base).unwrap();
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
}
/// What a declaration may not do: shadow its template's property, name a
/// value its own property does not have, or ask for more states than a world
/// holds.
#[test]
fn content_block_state_properties_are_validated() {
// The template's `axis` is not the mod's to redefine.
let (base, mods) = block_base(
"block-shadow",
&[r#"{"template":"minecraft:test","state_properties":{"axis":{"type":"boolean"}}}"#],
);
let error = match Plan::new(&base, &mods) {
Err(e) => e.to_string(),
Ok(_) => panic!("a shadowed template property was accepted"),
};
assert!(error.contains("axis is already on"), "{error}");
fs::remove_dir_all(&base).unwrap();
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
// A default is one of the values the block declared.
let (base, mods) = block_base(
"block-bad-default",
&[r#"{"template":"minecraft:test",
"state_properties":{"lit":{"type":"boolean"}},"default_state":{"lit":"maybe"}}"#],
);
let error = match Plan::new(&base, &mods) {
Err(e) => e.to_string(),
Ok(_) => panic!("a default outside a property's values was accepted"),
};
assert!(error.contains("is not one of its values"), "{error}");
fs::remove_dir_all(&base).unwrap();
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
// Twenty-one booleans are more combinations than a world can hold, and
// are refused before the product is built.
let declared: Vec<String> = (0..21)
.map(|i| format!(r#""p{i}":{{"type":"boolean"}}"#))
.collect();
let declaration = format!(
r#"{{"template":"minecraft:test","item":false,"state_properties":{{{}}}}}"#,
declared.join(",")
);
let (base, mods) = block_base("block-too-many", &[&declaration]);
let error = match Plan::new(&base, &mods) {
Err(e) => e.to_string(),
Ok(_) => panic!("more states than a world holds were accepted"),
};
assert!(
error.contains("more than the") && error.contains("world can hold"),
"{error}"
);
fs::remove_dir_all(&base).unwrap();
fs::remove_dir_all(base.parent().unwrap().join("mod0")).ok();
}
+66
View File
@@ -83,6 +83,37 @@ pub struct BlockShapes {
pub values: BTreeMap<String, Vec<[f64; 6]>>,
}
/// A state property a block declares on its own, beside its template's.
/// A block's states become the template's states times these values, so a
/// pipe can have a connection per side without a template that has one.
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum StateProperty {
/// `false` and `true`, in vanilla's spelling.
Boolean,
/// A list of values, in the order the state space is built in.
Enum { values: Vec<String> },
}
impl StateProperty {
/// Its values, in the order the state space is built in; a boolean's two
/// come `false` first, as vanilla orders them.
pub fn values(&self) -> Vec<&str> {
match self {
StateProperty::Boolean => vec!["false", "true"],
StateProperty::Enum { values } => values.iter().map(String::as_str).collect(),
}
}
}
/// A vanilla state property's name, and a value's: lower case, digits and
/// underscores (`minecraft:oak_log` is `axis`, a repeater is `delay`).
fn state_name(s: &str) -> bool {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct BlockFile {
@@ -91,6 +122,8 @@ struct BlockFile {
properties: BlockProperties,
shapes: Option<BlockShapes>,
#[serde(default)]
state_properties: BTreeMap<String, StateProperty>,
#[serde(default)]
default_state: BTreeMap<String, String>,
#[serde(default = "yes")]
item: bool,
@@ -111,6 +144,8 @@ pub struct Block {
pub template: String,
pub properties: BlockProperties,
pub shapes: Option<BlockShapes>,
/// State properties of its own, which its template does not have.
pub state_properties: BTreeMap<String, StateProperty>,
/// State properties overlaid on the template default.
pub default_state: BTreeMap<String, String>,
/// Also add a block item with the same id.
@@ -408,6 +443,7 @@ impl Content {
template: file.template,
properties: file.properties,
shapes: file.shapes,
state_properties: file.state_properties,
default_state: file.default_state,
item: file.item,
components: file.components,
@@ -537,6 +573,35 @@ fn check_block(file: &BlockFile) -> Result<(), String> {
}
}
}
for (name, property) in &file.state_properties {
if !state_name(name) {
return Err(format!(
"state property {name:?} is lower case letters, digits and underscores"
));
}
let values = property.values();
if values.is_empty() {
return Err(format!("state property {name:?} needs a value"));
}
let mut seen = BTreeSet::new();
for value in values {
if !state_name(value) {
return Err(format!(
"state property {name:?}: value {value:?} is lower case letters, digits and underscores"
));
}
if !seen.insert(value) {
return Err(format!("state property {name:?}: value {value:?} repeats"));
}
}
}
for key in file.default_state.keys() {
if !state_name(key) {
return Err(format!(
"default_state key {key:?} is lower case letters, digits and underscores"
));
}
}
let p = &file.properties;
let finite = |v: Option<f32>| v.is_none_or(f32::is_finite);
if !finite(p.hardness) || !finite(p.explosion_resistance) || !finite(p.friction) {
@@ -659,6 +724,7 @@ capabilities = ["registries"]
id: "ruby:ruby_block".into(),
template: "minecraft:iron_block".into(),
shapes: None,
state_properties: BTreeMap::new(),
default_state: BTreeMap::new(),
properties: BlockProperties {
hardness: Some(4.0),
+21
View File
@@ -79,6 +79,27 @@ template ([modpacks.md](modpacks.md#registries-02-data-driven-blocks-items-and-m
"item": true }
```
A block copies its template's states. It can also declare state properties of
its own, which its template does not have — how a pipe says which sides it
connects to, or a machine whether it is running. Its states then become its
template's states times the combinations of the ones it declares, in a fixed
order with every declared default first, so ids do not move between loads:
```json
{ "template": "minecraft:stone",
"state_properties": {
"facing": { "type": "enum", "values": ["north", "south", "east", "west"] },
"lit": { "type": "boolean" } },
"default_state": { "lit": "true" } }
```
A `boolean` is `false` and `true`, as vanilla spells them; an `enum` is a list
of values, and a name or value is lower case letters, digits and underscores.
A declared name may not be one the template already has, and `default_state`
takes one value per declared property too, so a block's default is exactly one
state of its own. Every combination has to fit in the states a world holds
(2^20), so a declaration that would not is refused when the mods load.
An item copies its template's data components, with `max_stack_size` and
`rarity` changed, and any other 26.3 component set or removed in
`components`, written as a data component patch is: `minecraft:<type>` sets