Read the axe, hoe and shovel block transformers from the data pack

A new block transformer module evaluates the data pack's rule based state providers the way BlockTransformer.transformBlock does (matching blocks and tags, all-of, copy properties) and derives the waxed twin of a copper block from the wax-off transforms. A harness compares every transformer, block state, face and block above with vanilla's own providers and with HoneycombItem.getWaxed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Sonnet 5.5 committed 2026-10-10 07:08:27 +02:00
1 parent 89630c80fe
commit d900b2a8e6
5 files changed
+532

No files matched your search

@@ -0,0 +1,94 @@
//! Block transformer lookups for tools/test_block_transformer_parity.py: reads
//! "transformer TAB face TAB clicked TAB above" lines (states as
//! "name|prop=value,...") and prints the oracle's result lines.
//!
//! usage: block_transform_fixture RESOURCES INPUT OUTPUT
use horizon_client::{
assets::Assets,
block_transform::{Around, Transformers},
items::Items,
};
use horizon_world::chunk::BlockState;
use std::{fs, path::Path};
fn parse(text: &str) -> BlockState {
let (name, props) = text.split_once('|').unwrap_or((text, ""));
BlockState {
name: name.to_owned(),
properties: props
.split(',')
.filter(|p| !p.is_empty())
.filter_map(|p| p.split_once('='))
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect(),
}
}
fn describe(state: &BlockState) -> String {
let props: Vec<String> = state
.properties
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
format!("{}|{}", state.name, props.join(","))
}
struct Column {
clicked: BlockState,
above: BlockState,
}
impl Around for Column {
fn state(&self, offset: [i32; 3]) -> BlockState {
match offset {
[0, 0, 0] => self.clicked.clone(),
[0, 1, 0] => self.above.clone(),
_ => BlockState {
name: "minecraft:stone".into(),
properties: Default::default(),
},
}
}
}
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
let root = Path::new(args.get(1).ok_or("usage: block_transform_fixture RESOURCES INPUT OUTPUT")?);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let items = Items::catalog(root)?;
let assets = Assets::load(root)?;
let transformers = Transformers::load(root, assets.registry)?;
let mut out = String::new();
for line in input.lines() {
let f: Vec<&str> = line.split('\t').collect();
let around = Column {
clicked: parse(f[2]),
above: parse(f[3]),
};
if f[0] == "honeycomb" {
let waxed = transformers.waxed(assets.registry, &around.clicked);
out += &format!("{}\n", waxed.map_or("none".to_owned(), |s| describe(&s)));
continue;
}
let id = format!("minecraft:{}", f[0]);
match transformers.apply(&items, assets.registry, &id, f[1], &around) {
None => out += "none\n",
Some((index, t, state)) => {
out += &format!(
"{index}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
describe(&state),
t.sound,
t.particle,
t.loot.as_deref().unwrap_or("-"),
t.drop_strategy,
t.update_from_neighbors,
t.transform_type,
t.consume_on_use,
t.damage
);
}
}
}
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
Ok(())
}
@@ -0,0 +1,259 @@
//! The `minecraft:block_transformer` registry (axe, hoe, shovel) that
//! `BlockTransformer.transformBlock` reads: each transform's rule based state
//! provider is evaluated against the clicked block and the block above.
//! `tools/test_block_transformer_parity.py` compares every block state with
//! vanilla.
use crate::{Result, items::Items};
use horizon_world::{chunk::BlockState, registry::BlockRegistry};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
enum Predicate {
Blocks(Vec<String>),
Tag { tag: String, offset: [i32; 3] },
AllOf(Vec<Predicate>),
AnyOf(Vec<Predicate>),
}
enum Provider {
Rules(Vec<(Predicate, Provider)>),
State(BlockState),
/// `CopyPropertiesProvider`: the source with the clicked block's properties.
Copy(Box<Provider>),
}
/// One `BlockTransformData`.
pub struct Transform {
provider: Provider,
pub disallowed_faces: Vec<String>,
pub sound: String,
pub particle: String,
pub loot: Option<String>,
pub drop_strategy: String,
pub update_from_neighbors: bool,
pub transform_type: String,
pub consume_on_use: bool,
pub damage: u32,
}
pub struct Transformers {
transformers: HashMap<String, Vec<Transform>>,
}
/// What the transformer reads of the world: the block state at an offset from
/// the clicked position.
pub trait Around {
fn state(&self, offset: [i32; 3]) -> BlockState;
}
fn state_of(value: &Value, registry: &BlockRegistry) -> Option<BlockState> {
let name = value["id"].as_str().or_else(|| value["Name"].as_str())?;
let default = registry.default_state(name)?;
let mut state = registry.state(default)?.clone();
for (k, v) in value["properties"].as_object().into_iter().flatten() {
state.properties.insert(k.clone(), v.as_str()?.to_owned());
}
Some(state)
}
fn predicate(value: &Value) -> Option<Predicate> {
Some(match value["type"].as_str()? {
"minecraft:matching_blocks" => Predicate::Blocks(match &value["blocks"] {
Value::String(s) => vec![s.clone()],
Value::Array(a) => a.iter().filter_map(|b| b.as_str().map(str::to_owned)).collect(),
_ => return None,
}),
"minecraft:matching_block_tag" => {
let o = value["offset"].as_array();
let at = |i: usize| o.and_then(|o| o.get(i)).and_then(Value::as_i64).unwrap_or(0) as i32;
Predicate::Tag {
tag: value["tag"].as_str()?.to_owned(),
offset: [at(0), at(1), at(2)],
}
}
"minecraft:all_of" => Predicate::AllOf(
value["predicates"]
.as_array()?
.iter()
.map(predicate)
.collect::<Option<_>>()?,
),
"minecraft:any_of" => Predicate::AnyOf(
value["predicates"]
.as_array()?
.iter()
.map(predicate)
.collect::<Option<_>>()?,
),
_ => return None,
})
}
fn provider(value: &Value, registry: &BlockRegistry) -> Option<Provider> {
match value["type"].as_str() {
Some("minecraft:rule_based") => {
let mut rules = Vec::new();
for rule in value["rules"].as_array()? {
rules.push((predicate(&rule["if_true"])?, provider(&rule["then"], registry)?));
}
Some(Provider::Rules(rules))
}
Some("minecraft:copy_properties") => Some(Provider::Copy(Box::new(provider(
&value["source"],
registry,
)?))),
_ => state_of(value, registry).map(Provider::State),
}
}
/// `BlockState.withPropertiesOf`: the properties both blocks have.
fn with_properties_of(
registry: &BlockRegistry,
mut state: BlockState,
old: &BlockState,
) -> Option<BlockState> {
let defaults = registry
.default_state(&state.name)
.and_then(|id| registry.state(id))?;
for (k, v) in &old.properties {
if defaults.properties.contains_key(k) {
let mut candidate = state.clone();
candidate.properties.insert(k.clone(), v.clone());
if registry.resolve(&candidate).is_ok() {
state = candidate;
}
}
}
Some(state)
}
impl Predicate {
fn test(&self, items: &Items, around: &dyn Around) -> bool {
match self {
Self::Blocks(names) => {
let state = around.state([0, 0, 0]);
names.iter().any(|n| *n == state.name)
}
Self::Tag { tag, offset } => {
let state = around.state(*offset);
items
.block_tags
.get(tag)
.is_some_and(|members| members.contains(&state.name))
}
Self::AllOf(all) => all.iter().all(|p| p.test(items, around)),
Self::AnyOf(any) => any.iter().any(|p| p.test(items, around)),
}
}
}
impl Provider {
fn state(
&self,
items: &Items,
registry: &BlockRegistry,
around: &dyn Around,
) -> Option<BlockState> {
match self {
Self::Rules(rules) => rules
.iter()
.find(|(p, _)| p.test(items, around))
.and_then(|(_, then)| then.state(items, registry, around)),
Self::State(state) => Some(state.clone()),
Self::Copy(source) => {
let state = source.state(items, registry, around)?;
with_properties_of(registry, state, &around.state([0, 0, 0]))
}
}
}
}
impl Transformers {
/// Reads `data/minecraft/block_transformer/*.json`.
pub fn load(root: &Path, registry: &BlockRegistry) -> Result<Self> {
let mut transformers = HashMap::new();
let dir = root.join("data/minecraft/block_transformer");
if dir.exists() {
for entry in fs::read_dir(&dir)?.flatten() {
let path = entry.path();
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let list: Value = serde_json::from_slice(&fs::read(&path)?)?;
let mut transforms = Vec::new();
for t in list.as_array().into_iter().flatten() {
let text = |k: &str, d: &str| t[k].as_str().unwrap_or(d).to_owned();
let Some(provider) = provider(&t["block_state_provider"], registry) else {
continue;
};
transforms.push(Transform {
provider,
disallowed_faces: t["disallowed_faces"]
.as_array()
.into_iter()
.flatten()
.filter_map(|f| f.as_str().map(str::to_owned))
.collect(),
sound: text("sound", "minecraft:empty"),
particle: text("particle", "none"),
loot: t["loot"].as_str().map(str::to_owned),
drop_strategy: text("drop_strategy", "from_middle"),
update_from_neighbors: t["update_from_neighbors"].as_bool().unwrap_or(true),
transform_type: text("transform_type", "single_block"),
consume_on_use: t["consume_on_use"].as_bool().unwrap_or(true),
damage: t["item_damage_per_use"].as_u64().unwrap_or(0) as u32,
});
}
transformers.insert(format!("minecraft:{stem}"), transforms);
}
}
Ok(Self { transformers })
}
/// `BlockTransformer.transformBlock` without the world writes: the first
/// transform that allows the clicked face and provides a state, with its
/// index.
pub fn apply<'a>(
&'a self,
items: &Items,
registry: &BlockRegistry,
id: &str,
face: &str,
around: &dyn Around,
) -> Option<(usize, &'a Transform, BlockState)> {
self.transformers
.get(id)?
.iter()
.enumerate()
.filter(|(_, t)| !t.disallowed_faces.iter().any(|f| f == face))
.find_map(|(i, t)| Some((i, t, t.provider.state(items, registry, around)?)))
}
/// `HoneycombItem.getWaxed`: the waxed twin of a copper block, found from
/// the axe's wax-off transforms (`WAX_OFF_BY_BLOCK` is the inverse of
/// `WAXABLES`).
pub fn waxed(&self, registry: &BlockRegistry, old: &BlockState) -> Option<BlockState> {
for transform in self.transformers.get("minecraft:axe")? {
if transform.particle != "wax_off" {
continue;
}
let Provider::Rules(rules) = &transform.provider else {
continue;
};
for (predicate, then) in rules {
if let (Predicate::Blocks(names), Provider::Copy(source)) = (predicate, then)
&& let Provider::State(unwaxed) = &**source
&& unwaxed.name == old.name
&& let Some(waxed) = names.first()
{
let target = registry
.default_state(waxed)
.and_then(|id| registry.state(id))?
.clone();
return with_properties_of(registry, target, old);
}
}
}
None
}
}
+1
View File
@@ -10,6 +10,7 @@ pub mod audio;
pub mod banner;
pub mod biome;
pub mod block_entity_render;
pub mod block_transform;
pub mod blocks;
pub mod bossbar;
pub mod brewing;
@@ -0,0 +1,118 @@
package dev.horizonlab.parity;
import java.io.PrintWriter;
import java.lang.reflect.Proxy;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.LevelAccessor;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.Property;
import net.minecraft.core.component.BlockTransformer;
/**
* Original oracle: the data-driven axe/hoe/shovel BlockTransformer entries.
* "dump" lists every block state; a case line "transformer TAB face TAB
* clicked TAB above" (states as "name|prop=value,...") prints the first
* transform that yields a state and its settings, as BlockTransformer.transformBlock
* would pick it.
*/
public final class VanillaBlockTransformerOracle {
static String describe(BlockState state) {
StringBuilder out = new StringBuilder(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString()).append('|');
List<Property<?>> props = new ArrayList<>(state.getProperties());
props.sort(Comparator.comparing(Property::getName));
boolean first = true;
for (Property<?> p : props) {
out.append(first ? "" : ",").append(p.getName()).append('=').append(name(state, p));
first = false;
}
return out.toString();
}
@SuppressWarnings({"unchecked", "rawtypes"})
static String name(BlockState state, Property p) {
return p.getName(state.getValue(p));
}
@SuppressWarnings({"unchecked", "rawtypes"})
static BlockState parse(String text) {
String[] parts = text.split("\\|", -1);
Block block = BuiltInRegistries.BLOCK.getValue(Identifier.parse(parts[0]));
BlockState state = block.defaultBlockState();
if (!parts[1].isEmpty()) {
for (String kv : parts[1].split(",")) {
String[] pair = kv.split("=");
Property p = block.getStateDefinition().getProperty(pair[0]);
state = state.setValue(p, (Comparable) p.getValue(pair[1]).orElseThrow());
}
}
return state;
}
static LevelAccessor level(BlockState clicked, BlockState above) {
return (LevelAccessor) Proxy.newProxyInstance(LevelAccessor.class.getClassLoader(), new Class<?>[]{LevelAccessor.class}, (proxy, method, args) -> {
if (method.getName().equals("getBlockState")) {
BlockPos pos = (BlockPos) args[0];
return pos.getY() == 0 ? clicked : pos.getY() == 1 && pos.getX() == 0 && pos.getZ() == 0 ? above : net.minecraft.world.level.block.Blocks.STONE.defaultBlockState();
}
throw new UnsupportedOperationException(method.getName());
});
}
public static void main(String[] args) throws Exception {
ParityData.init();
if (args[0].equals("dump")) {
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8))) {
for (Block block : BuiltInRegistries.BLOCK) {
for (BlockState state : block.getStateDefinition().getPossibleStates()) out.println(describe(state));
}
}
return;
}
var registry = ParityData.access.lookupOrThrow(Registries.BLOCK_TRANSFORMER);
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8));
var lines = Files.lines(Path.of(args[0]))) {
for (String line : (Iterable<String>) lines::iterator) {
String[] f = line.split("\t", -1);
try {
if (f[0].equals("honeycomb")) {
out.println(net.minecraft.world.item.HoneycombItem.getWaxed(parse(f[2])).map(VanillaBlockTransformerOracle::describe).orElse("none"));
continue;
}
var transformer = registry.getOrThrow(ResourceKey.create(Registries.BLOCK_TRANSFORMER, Identifier.parse(f[0]))).value();
Direction face = Direction.byName(f[1]);
BlockState clicked = parse(f[2]);
LevelAccessor level = level(clicked, parse(f[3]));
String result = "none";
int index = 0;
for (var data : transformer.transforms()) {
if (!data.disallowedFaces().contains(face)) {
BlockState state = data.blockStateProvider().value().getOptionalState(level, RandomSource.create(0L), BlockPos.ZERO);
if (state != null) {
result = index + "\t" + describe(state) + "\t" + BuiltInRegistries.SOUND_EVENT.getKey(data.sound().value())
+ "\t" + data.particle().name().toLowerCase() + "\t"
+ data.loot().map(k -> k.identifier().toString()).orElse("-") + "\t" + data.dropStrategy().name().toLowerCase()
+ "\t" + data.updateFromNeighbors() + "\t" + data.transformType().name().toLowerCase() + "\t" + data.consumeOnUse()
+ "\t" + data.itemDamagePerUse();
break;
}
}
index++;
}
out.println(result);
} catch (Throwable e) {
out.println("error " + e.getClass().getSimpleName() + " " + e.getMessage());
}
}
}
}
}
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Compare the data-driven axe/hoe/shovel block transformers with vanilla.
The Java oracle (VanillaBlockTransformerOracle) evaluates the registry's
BlockStateProviders on a stand-in level; the Rust example
(block_transform_fixture) evaluates horizon_client::block_transform. Cases are
every block state any transformer mentions (plus a random sample of all states)
under each transformer, with a few blocks above and three clicked faces.
"""
import argparse
import random
try:
from . import interaction_lab as lib, lab
except ImportError:
import interaction_lab as lib
import lab
ABOVE = ['minecraft:air|', 'minecraft:cave_air|', 'minecraft:stone|', 'minecraft:water|level=0', 'minecraft:oak_slab|type=bottom,waterlogged=false']
FACES = ['up', 'down', 'north']
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--seed', type=int, default=7)
parser.add_argument('--sample', type=int, default=3000)
parser.add_argument('--limit', type=int, default=4)
args = parser.parse_args()
work = lib.work_dir('block-transformer-parity')
vanilla = lib.Vanilla(work)
vanilla.compile('ParityData', 'VanillaBlockTransformerOracle')
vanilla.run('VanillaBlockTransformerOracle', 'dump', work / 'states.txt')
states = (work / 'states.txt').read_text().splitlines()
mentioned = set()
for path in (lib.RESOURCES / 'data/minecraft/block_transformer').glob('*.json'):
text = path.read_text()
for name in set(__import__('re').findall(r'"minecraft:([a-z_0-9]+)"', text)):
mentioned.add('minecraft:' + name)
tags = set()
for tag in ['turns_into_farmland', 'turns_into_dirt_path', 'air']:
for name in __import__('json').loads((lib.RESOURCES / 'data/minecraft/tags/block' / (tag + '.json')).read_text())['values']:
if not name.startswith('#'):
tags.add(name)
rng = random.Random(args.seed)
chosen = [s for s in states if s.split('|')[0] in mentioned or s.split('|')[0] in tags or 'copper' in s or 'lightning_rod' in s]
chosen += rng.sample(states, args.sample)
cases = []
for transformer in ['axe', 'hoe', 'shovel', 'honeycomb']:
for state in chosen:
for face in FACES:
cases.append('\t'.join([transformer, face, state, rng.choice(ABOVE)]))
(work / 'cases.txt').write_text(''.join(c + '\n' for c in cases))
vanilla.run('VanillaBlockTransformerOracle', work / 'cases.txt', work / 'native.txt')
lib.cargo_example('horizon-client', 'block_transform_fixture', lib.RESOURCES, work / 'cases.txt', work / 'rust.txt')
total = lib.compare(cases, work / 'native.txt', work / 'rust.txt',
lambda case, line: case.split('\t')[0] + ' ' + line.split('\t')[0][:12], None, args.limit)
raise SystemExit(1 if total else 0)
if __name__ == '__main__':
main()