diff --git a/crates/horizon-client/examples/pick_block_data_fixture.rs b/crates/horizon-client/examples/pick_block_data_fixture.rs new file mode 100644 index 0000000000..b47f56733f --- /dev/null +++ b/crates/horizon-client/examples/pick_block_data_fixture.rs @@ -0,0 +1,96 @@ +//! Block entity data on items for tools/test_pick_block_data_parity.py: reads +//! one JSON case per line (see VanillaPickBlockDataOracle) and prints the +//! picked item's component patch, a tab, then the placed block entity. +//! +//! usage: pick_block_data_fixture INPUT OUTPUT +use horizon_client::block_entity_item::{pick, place}; +use horizon_client::block_entity_nbt::canonical; +use horizon_client::java_text::{double_to_string, float_to_string}; +use horizon_world::nbt::{Tag, parse_snbt}; +use serde_json::Value; +use std::fs; + +/// ParityData.canon: sorted keys, typed numbers, Java's float text. +fn canon(tag: &Tag, out: &mut String) { + let join = |out: &mut String, prefix: &str, items: Vec| { + out.push_str(prefix); + out.push_str(&items.join(",")); + out.push(']'); + }; + match tag { + Tag::Float(v) => out.push_str(&format!("{}f", float_to_string(f32::from_bits(*v)))), + Tag::Double(v) => out.push_str(&format!("{}d", double_to_string(f64::from_bits(*v)))), + Tag::List(items) => join( + out, + "[", + items + .iter() + .map(|t| { + let mut s = String::new(); + canon(t, &mut s); + s + }) + .collect(), + ), + Tag::Compound(map) => { + let mut keys: Vec<_> = map.iter().map(|(k, v)| (k.to_string_lossy(), v)).collect(); + keys.sort_by(|a, b| a.0.cmp(&b.0)); + out.push('{'); + for (i, (k, v)) in keys.into_iter().enumerate() { + if i > 0 { + out.push(','); + } + out.push_str(&k); + out.push(':'); + canon(v, out); + } + out.push('}'); + } + other => out.push_str(&horizon_client::item_data::canon_tag(other)), + } +} + +fn snbt(value: &Value) -> Option { + parse_snbt(value.as_str()?).ok().map(|(tag, _)| tag) +} + +fn case(line: &str) -> Option { + let case: Value = serde_json::from_str(line).ok()?; + let saved = canonical(&snbt(&case["nbt"])?)?; + let picked = pick(&saved); + let patch = match case.get("patch") { + Some(p) => match snbt(p)? { + Tag::Compound(c) => c, + _ => return None, + }, + None => picked.clone(), + }; + let target = canonical(&snbt(&case["target"])?)?; + let Tag::Compound(mut placed) = place(&target, &patch, case["op"].as_bool()?) else { + return None; + }; + // keepPacked is the chunk's, not saveWithFullMetadata's. + placed.remove(&"keepPacked".into()); + let mut out = String::new(); + canon(&Tag::Compound(picked), &mut out); + out.push('\t'); + canon(&Tag::Compound(placed), &mut out); + Some(out) +} + +fn main() -> horizon_client::Result<()> { + let args: Vec = std::env::args().collect(); + let input = fs::read_to_string( + args.get(1) + .ok_or("usage: pick_block_data_fixture INPUT OUTPUT")?, + )?; + let lines: Vec = input + .lines() + .map(|line| case(line).unwrap_or_else(|| "error".to_owned())) + .collect(); + fs::write( + args.get(2).ok_or("missing OUTPUT")?, + lines.join("\n") + "\n", + )?; + Ok(()) +} diff --git a/crates/horizon-client/src/block_entity_item.rs b/crates/horizon-client/src/block_entity_item.rs new file mode 100644 index 0000000000..762de7c224 --- /dev/null +++ b/crates/horizon-client/src/block_entity_item.rs @@ -0,0 +1,439 @@ +//! Block entities carried by items, vanilla 26.3: Ctrl + Pick Block in creative +//! (`ServerGamePacketListenerImpl.addBlockDataToItem`) and placing such an item +//! (`BlockItem.updateCustomBlockEntityTag`, then +//! `BlockEntity.applyComponentsFromItemStack`). Both work on the block entity's +//! saved NBT (`block_entity_nbt::canonical`) and the item's component patch. +//! +//! What a type turns into item components (`removeComponentsFromTag`, +//! `collectImplicitComponents`) leaves its NBT; the rest goes to +//! `minecraft:block_entity_data`, tagged with the type's id. +use crate::block_entity_nbt::{self, canonical}; +use horizon_world::nbt::{Compound, JavaString, Tag}; + +pub const BLOCK_ENTITY_DATA: &str = "minecraft:block_entity_data"; + +/// `BlockEntityTypes.OP_ONLY_CUSTOM_DATA`: the types whose data only a player +/// who can use game master blocks places (`BlockEntityType.onlyOpCanSetNbt`). +pub const OP_ONLY: [&str; 6] = [ + "minecraft:command_block", + "minecraft:lectern", + "minecraft:sign", + "minecraft:hanging_sign", + "minecraft:mob_spawner", + "minecraft:trial_spawner", +]; + +/// A part of a block entity's NBT that its item holds as a component. +#[derive(Clone, Copy)] +enum Part { + /// The NBT key and the component hold the same value. + Same(&'static str, &'static str), + /// `Items` as `minecraft:container`, in a container of this many slots. + Items(usize), + /// `LootTable` and `LootTableSeed` as `minecraft:container_loot`. + Loot, + /// `is_waxed` as `minecraft:waxed`. + Waxed, + /// A decorated pot's `item` as `minecraft:container`. + PotItem, + /// Left out of the item's data, with no component. + Drop(&'static str), +} +const NAME: Part = Part::Same("CustomName", "minecraft:custom_name"); +const LOCK: Part = Part::Same("lock", "minecraft:lock"); +const CONTAINER: &str = "minecraft:container"; + +/// Each type's `removeComponentsFromTag`, `collectImplicitComponents` and +/// `applyImplicitComponents`. +fn parts(id: &str) -> &'static [Part] { + match id { + "minecraft:chest" + | "minecraft:trapped_chest" + | "minecraft:barrel" + | "minecraft:shulker_box" => &[NAME, LOCK, Part::Items(27), Part::Loot], + "minecraft:dispenser" | "minecraft:dropper" | "minecraft:crafter" => { + &[NAME, LOCK, Part::Items(9), Part::Loot] + } + "minecraft:hopper" => &[NAME, LOCK, Part::Items(5), Part::Loot], + "minecraft:furnace" | "minecraft:smoker" | "minecraft:blast_furnace" => { + &[NAME, LOCK, Part::Items(3)] + } + "minecraft:brewing_stand" => &[NAME, LOCK, Part::Items(5)], + "minecraft:beacon" => &[NAME, LOCK], + "minecraft:enchanting_table" => &[NAME], + "minecraft:command_block" => &[NAME, Part::Drop("conditionMet"), Part::Drop("powered")], + "minecraft:campfire" => &[Part::Items(4)], + "minecraft:chiseled_bookshelf" => &[Part::Items(6)], + "minecraft:shelf" => &[Part::Items(3)], + "minecraft:beehive" => &[Part::Same("bees", "minecraft:bees")], + "minecraft:banner" => &[Part::Same("patterns", "minecraft:banner_patterns"), NAME], + "minecraft:decorated_pot" => &[ + Part::Same("sherds", "minecraft:pot_decorations"), + Part::PotItem, + ], + "minecraft:sign" | "minecraft:hanging_sign" => &[ + Part::Same("front_text", "minecraft:sign_text_front"), + Part::Same("back_text", "minecraft:sign_text_back"), + Part::Waxed, + ], + "minecraft:skull" => &[ + Part::Same("profile", "minecraft:profile"), + Part::Same("note_block_sound", "minecraft:note_block_sound"), + Part::Same("custom_name", "minecraft:custom_name"), + ], + _ => &[], + } +} + +/// The component a part reads, and what it holds for `tag` (None: unset). +fn collect(part: Part, tag: &Tag) -> Option<(&'static str, Option)> { + Some(match part { + Part::Same(key, component) => ( + component, + tag.get(key).cloned().or_else(|| unset(component)), + ), + Part::Items(_) => { + let slots = match tag.get("Items") { + Some(Tag::List(items)) => items.iter().filter_map(slot_entry).collect(), + _ => Vec::new(), + }; + (CONTAINER, Some(Tag::List(slots))) + } + Part::Loot => { + let table = tag.get("LootTable").cloned(); + let loot = table.map(|t| { + let mut entries = vec![("loot_table", t)]; + if let Some(seed) = tag.get("LootTableSeed") { + entries.push(("seed", seed.clone())); + } + Tag::compound(entries) + }); + ("minecraft:container_loot", loot) + } + Part::Waxed => ( + "minecraft:waxed", + matches!(tag.get("is_waxed"), Some(Tag::Byte(1))) + .then(|| Tag::Compound(Compound::new())), + ), + Part::PotItem => { + let slots = match tag.get("item") { + Some(Tag::Compound(item)) => Some(container_slot(0, item.clone())), + _ => None, + }; + (CONTAINER, Some(Tag::List(slots.into_iter().collect()))) + } + Part::Drop(_) => return None, + }) +} + +/// What a type that always collects `component` gives when its NBT leaves the +/// value out (its empty value, which the NBT omits). +fn unset(component: &str) -> Option { + Some(match component { + "minecraft:bees" | "minecraft:banner_patterns" => Tag::List(Vec::new()), + "minecraft:pot_decorations" => Tag::Compound(Compound::new()), + "minecraft:sign_text_front" | "minecraft:sign_text_back" => { + block_entity_nbt::sign_side(None) + } + _ => return None, + }) +} + +/// An `Items` entry as a `minecraft:container` slot. +fn slot_entry(entry: &Tag) -> Option { + let Tag::Compound(entry) = entry else { + return None; + }; + let mut item = entry.clone(); + let slot = match item.remove(&JavaString::from("Slot"))? { + Tag::Byte(s) => i32::from(s), + _ => return None, + }; + Some(container_slot(slot, item)) +} + +/// An `ItemContainerContents` slot: `ItemStackTemplate` leaves out a count of 1. +fn container_slot(slot: i32, mut item: Compound) -> Tag { + if item.get(&JavaString::from("count")) == Some(&Tag::Int(1)) { + item.remove(&JavaString::from("count")); + } + Tag::compound([("slot", Tag::Int(slot)), ("item", Tag::Compound(item))]) +} + +/// The value of `component` on the clone item of a type that collects it: a +/// picked item leaves it out of its patch, and a placed item without it in its +/// patch gives it to the block entity. +fn item_default(component: &str) -> Option { + Some(match component { + CONTAINER | "minecraft:bees" | "minecraft:banner_patterns" => Tag::List(Vec::new()), + "minecraft:pot_decorations" => { + let brick = || Tag::compound([("id", Tag::String("minecraft:brick".into()))]); + Tag::compound([ + ("back", brick()), + ("front", brick()), + ("left", brick()), + ("right", brick()), + ]) + } + "minecraft:sign_text_front" | "minecraft:sign_text_back" => { + block_entity_nbt::sign_side(None) + } + _ => return None, + }) +} + +const METADATA: [&str; 6] = ["id", "x", "y", "z", "keepPacked", "components"]; + +/// `saveCustomOnly`: the saved NBT without its id, position and components. +fn custom_only(saved: &Tag) -> Compound { + match saved { + Tag::Compound(c) => c + .iter() + .filter(|(k, _)| !METADATA.contains(&k.to_string_lossy().as_str())) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + _ => Compound::new(), + } +} + +/// `addBlockDataToItem`: the component patch Ctrl + Pick Block gives the clone +/// item of the block entity saved as `saved` (a full, canonical compound). +pub fn pick(saved: &Tag) -> Compound { + let id = block_entity_nbt::id(saved).unwrap_or_default(); + let mut patch = Compound::new(); + // BlockItem.setBlockEntityData after removeComponentsFromTag. + let mut data = custom_only(saved); + for part in parts(&id) { + let key = match *part { + Part::Same(key, _) | Part::Drop(key) => key, + Part::Items(_) => "Items", + Part::Loot => { + data.remove(&JavaString::from("LootTableSeed")); + "LootTable" + } + Part::Waxed => "is_waxed", + Part::PotItem => "item", + }; + data.remove(&JavaString::from(key)); + } + if !data.is_empty() { + data.insert("id".into(), Tag::String(id.as_str().into())); + patch.insert(BLOCK_ENTITY_DATA.into(), Tag::Compound(data)); + } + // ItemStack.applyComponents(collectComponents()): the block entity's own + // components, then the implicit ones; a value equal to the item's own is + // no change. + let mut collected = match saved.get("components") { + Some(Tag::Compound(c)) => c.clone(), + _ => Compound::new(), + }; + for part in parts(&id) { + if let Some((component, value)) = collect(*part, saved) { + match value { + Some(v) => collected.insert(component.into(), v), + None => collected.remove(&JavaString::from(component)), + }; + } + } + for (component, value) in collected { + if item_default(&component.to_string_lossy()).as_ref() == Some(&value) { + patch.remove(&component); + } else { + patch.insert(component, value); + } + } + patch +} + +/// The block entity saved as `saved` after an item with component patch +/// `components` is placed as it: `BlockItem.updateCustomBlockEntityTag` (the +/// item's data of the same type, op-only types only when `game_master`, the +/// player can use game master blocks), then `applyComponentsFromItemStack`. +pub fn place(saved: &Tag, components: &Compound, game_master: bool) -> Tag { + let Some(id) = block_entity_nbt::id(saved) else { + return saved.clone(); + }; + let Tag::Compound(mut tag) = saved.clone() else { + return saved.clone(); + }; + // TypedEntityData.loadInto: saveCustomOnly, merge, loadCustomOnly when changed. + if let Some(Tag::Compound(data)) = components.get(&JavaString::from(BLOCK_ENTITY_DATA)) + && data + .get(&JavaString::from("id")) + .is_some_and(|t| matches!(t, Tag::String(s) if s.to_string_lossy() == id)) + && (game_master || !OP_ONLY.contains(&id.as_str())) + { + let custom = custom_only(saved); + let mut merged = custom.clone(); + let mut data = data.clone(); + data.remove(&JavaString::from("id")); + crate::data_cmd::merge_compound(&mut merged, &data); + if merged != custom { + for key in METADATA { + if let Some(v) = tag.get(&JavaString::from(key)) { + merged.insert(key.into(), v.clone()); + } + } + if let Some(Tag::Compound(loaded)) = canonical(&Tag::Compound(merged)) { + tag = loaded; + } + } + } + // applyImplicitComponents, then the rest of the patch as the block + // entity's own components. + let mut read = vec![BLOCK_ENTITY_DATA, "minecraft:block_state"]; + // The item's components: its patch over its own defaults. + let get = |c: &str| match components.get(&JavaString::from(c)) { + Some(v) => Some(v.clone()), + None if components.contains_key(&JavaString::from(format!("!{c}").as_str())) => None, + None => item_default(c), + }; + let mut set = |key: &str, value: Option| match value { + Some(v) => tag.insert(key.into(), v), + None => tag.remove(&JavaString::from(key)), + }; + for part in parts(&id) { + match *part { + Part::Same(key, component) => { + read.push(component); + set(key, get(component)); + } + Part::Items(size) => { + read.push(CONTAINER); + let items = container_slots(get(CONTAINER).as_ref()) + .filter(|(slot, _)| (*slot as usize) < size) + .map(|(slot, mut item)| { + item.insert("Slot".into(), Tag::Byte(slot as i8)); + Tag::Compound(item) + }) + .collect(); + set("Items", Some(Tag::List(items))); + } + Part::Loot => { + read.push("minecraft:container_loot"); + if let Some(loot) = get("minecraft:container_loot") { + set("LootTable", loot.get("loot_table").cloned()); + set("LootTableSeed", loot.get("seed").cloned()); + } + } + Part::Waxed => { + read.push("minecraft:waxed"); + set( + "is_waxed", + Some(Tag::Byte(i8::from(get("minecraft:waxed").is_some()))), + ); + } + Part::PotItem => { + read.push(CONTAINER); + let first = container_slots(get(CONTAINER).as_ref()).find(|(slot, _)| *slot == 0); + set("item", first.map(|(_, item)| Tag::Compound(item))); + } + Part::Drop(_) => {} + } + } + let own: Compound = components + .iter() + .filter(|(k, _)| { + let k = k.to_string_lossy(); + !k.starts_with('!') && !read.contains(&k.as_str()) + }) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + tag.insert("components".into(), Tag::Compound(own)); + let tag = Tag::Compound(tag); + canonical(&tag).unwrap_or(tag) +} + +/// The slots of a `minecraft:container` component. +fn container_slots(container: Option<&Tag>) -> impl Iterator + '_ { + let list = match container { + Some(Tag::List(list)) => list.as_slice(), + _ => &[], + }; + list.iter().filter_map(|entry| { + let slot = match entry.get("slot")? { + Tag::Int(s) => *s, + _ => return None, + }; + match entry.get("item")? { + Tag::Compound(item) => Some((slot, item.clone())), + _ => None, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn snbt(text: &str) -> Tag { + horizon_world::nbt::parse_snbt(text).expect("snbt").0 + } + fn saved(text: &str) -> Tag { + canonical(&snbt(text)).expect("known type") + } + fn canon(patch: &Compound) -> String { + crate::item_data::canon_tag(&Tag::Compound(patch.clone())) + } + + #[test] + fn a_filled_named_chest_gives_its_items_and_name_as_components() { + let chest = saved( + "{id:\"minecraft:chest\",x:1,y:2,z:3,CustomName:\"Loot\",\ + Items:[{Slot:4b,id:\"minecraft:stone\",count:5}]}", + ); + let patch = pick(&chest); + assert_eq!( + canon(&patch), + crate::item_data::canon_tag(&snbt( + "{\"minecraft:container\":[{slot:4,item:{id:\"minecraft:stone\",count:5}}],\ + \"minecraft:custom_name\":\"Loot\"}" + )) + ); + // Placing it back gives the same chest. + let empty = saved("{id:\"minecraft:chest\",x:1,y:2,z:3}"); + assert_eq!(place(&empty, &patch, false), chest); + // An empty chest has nothing to carry. + assert!(pick(&empty).is_empty()); + } + + #[test] + fn a_command_block_keeps_its_command_in_block_entity_data() { + let block = saved( + "{id:\"minecraft:command_block\",x:0,y:0,z:0,Command:\"say hi\",auto:1b,powered:1b}", + ); + let patch = pick(&block); + let data = patch + .get(&JavaString::from(BLOCK_ENTITY_DATA)) + .expect("data"); + assert_eq!(data.get("Command"), Some(&Tag::String("say hi".into()))); + assert_eq!(data.get("powered"), None); + assert_eq!( + data.get("id"), + Some(&Tag::String("minecraft:command_block".into())) + ); + // Only a player who can use game master blocks places it. + let fresh = saved("{id:\"minecraft:command_block\",x:0,y:0,z:0}"); + assert_eq!(place(&fresh, &patch, false), fresh); + assert_eq!( + place(&fresh, &patch, true).get("Command"), + Some(&Tag::String("say hi".into())) + ); + // Data of another type is ignored. + let chest = saved("{id:\"minecraft:chest\",x:0,y:0,z:0}"); + assert_eq!(place(&chest, &patch, true), chest); + } + + #[test] + fn a_written_sign_gives_its_text_and_wax() { + let sign = saved( + "{id:\"minecraft:sign\",x:0,y:0,z:0,is_waxed:1b,\ + front_text:{messages:[\"a\",\"b\",\"\",\"\"]}}", + ); + let patch = pick(&sign); + let keys: Vec = patch.keys().map(|k| k.to_string_lossy()).collect(); + assert_eq!(keys, ["minecraft:sign_text_front", "minecraft:waxed"]); + let fresh = saved("{id:\"minecraft:sign\",x:0,y:0,z:0}"); + assert_eq!(place(&fresh, &patch, false), sign); + } +} diff --git a/crates/horizon-client/src/block_entity_nbt.rs b/crates/horizon-client/src/block_entity_nbt.rs index 3db290408c..54d230eb40 100644 --- a/crates/horizon-client/src/block_entity_nbt.rs +++ b/crates/horizon-client/src/block_entity_nbt.rs @@ -259,7 +259,7 @@ fn is_pos(tag: &Tag) -> bool { matches!(tag, Tag::IntArray(v) if v.len() == 3) } /// The loaded `SignText` of one side, or the empty text. -fn sign_side(tag: Option<&Tag>) -> Tag { +pub(crate) fn sign_side(tag: Option<&Tag>) -> Tag { let empty = || string(""); let mut messages: Vec = match tag.and_then(|t| t.get("messages")) { Some(Tag::List(list)) => list.iter().filter_map(component).collect(), diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index f684447cc8..700ff1d407 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -10,6 +10,7 @@ mod banners; mod beacon; mod beehives; mod block_entity_anims; +mod block_items; mod blockctl; mod boats; mod books; @@ -2569,19 +2570,21 @@ impl Game<'_> { }; let target = [pos.x, pos.y, pos.z]; if let Some(entity) = BlockEntity::for_block(&self.res.facts.get(state).name) { - let sign = matches!(entity, BlockEntity::Sign(_)); - let command_block = matches!(entity, BlockEntity::CommandBlock(_)); - self.world - .block_entities - .insert((target[0], target[1], target[2]), entity); - if command_block { - self.command_block_placed((target[0], target[1], target[2])); - } - self.fill_placed_box(target, &stack); - self.name_placed_container(target, &stack); - self.fill_placed_banner(target, &stack); - if sign { - self.open_sign_editor((target[0], target[1], target[2]), true); + let key = (target[0], target[1], target[2]); + self.world.block_entities.insert(key, entity); + let game_master = self.can_use_game_master_blocks(); + self.apply_placed_item(target, &stack, game_master); + // Block.setPlacedBy. + match self.world.block_entities.get(&key) { + Some(BlockEntity::CommandBlock(_)) => { + let data = stack.data().is_some_and(|d| { + d.other(horizon_client::block_entity_item::BLOCK_ENTITY_DATA) + .is_some() + }); + self.command_block_placed(key, !data); + } + Some(BlockEntity::Sign(sign)) if !sign.waxed => self.open_sign_editor(key, true), + _ => {} } } self.swing_arm(); @@ -8901,17 +8904,11 @@ impl Game<'_> { } MouseButton::Middle if pressed => { // Minecraft.pickBlockOrEntity -> the server's handlePickItemFromBlock: - // the block's clone item, picked from the inventory (`pick_item`). + // the block's clone item, with its block entity's data on Ctrl in + // creative, picked from the inventory (`pick_item`). if let Some(hit) = self.pick() { - let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]); - let item = self.res.facts.get(state).item.clone(); - if &*item != "minecraft:air" { - let stack = ItemStack { - item, - count: 1, - damage: 0, - extra: None, - }; + let include_data = self.creative && self.has_control_down(); + if let Some(stack) = self.picked_stack(hit.pos, include_data) { let infinite = self.creative; horizon_client::pick_item::pick( &mut self.inventory[..36], diff --git a/crates/horizon-client/src/game/banners.rs b/crates/horizon-client/src/game/banners.rs index d3f0df42b8..6875fcb7f0 100644 --- a/crates/horizon-client/src/game/banners.rs +++ b/crates/horizon-client/src/game/banners.rs @@ -5,7 +5,6 @@ use super::Game; use horizon_client::{ banner::{self, BannerLayer}, containers::BlockEntity, - items::ItemStack, }; impl Game<'_> { @@ -17,19 +16,6 @@ impl Game<'_> { }; layer_textures(&self.res.stations.banners, &b.layers) } - /// A placed banner takes the item's layers and name. - pub(super) fn fill_placed_banner(&mut self, pos: [i32; 3], stack: &ItemStack) { - self.pending.banner.remove(&pos); - if !stack.item.ends_with("_banner") { - return; - } - if let Some(BlockEntity::Banner(b)) = - self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2])) - { - b.layers = banner::layers(stack).to_vec(); - b.name = stack.custom_name().map(str::to_owned); - } - } } /// Texture names and tints of banner layers (`Sheets.getBannerSprite`). diff --git a/crates/horizon-client/src/game/block_items.rs b/crates/horizon-client/src/game/block_items.rs new file mode 100644 index 0000000000..e9a426404f --- /dev/null +++ b/crates/horizon-client/src/game/block_items.rs @@ -0,0 +1,117 @@ +//! Block entities and the items that carry them +//! (`horizon_client::block_entity_item`): the item Pick Block gives, with the +//! block entity's data on Ctrl in creative, and the data and components a +//! placed item gives its block entity. +use super::Game; +use horizon_client::{ + block_entity_item, block_entity_nbt, containers::BlockEntity, item_data, items::ItemStack, save, +}; +use horizon_world::nbt::{Compound, Tag}; + +/// The component patch of `stack`. +fn components(stack: &ItemStack) -> Compound { + match item_data::encode_components(stack) { + Some(Tag::Compound(c)) => c, + _ => Compound::new(), + } +} + +impl Game<'_> { + /// The block entity at `key` as vanilla saves it, with its custom name. + fn saved_block_entity(&self, key: (i32, i32, i32)) -> Option { + let entity = self.world.block_entities.get(&key)?; + let block = &self + .res + .facts + .get(self.world.block(key.0, key.1, key.2)) + .name; + let mut tag = save::encode_block_entity(key, entity, block); + if let (Some(name), Tag::Compound(map)) = (self.world.block_names.get(&key), &mut tag) { + map.insert("CustomName".into(), Tag::String(name.as_str().into())); + } + block_entity_nbt::canonical(&tag) + } + + /// `BlockState.getCloneItemStack` of the block at `pos`, then, with + /// `include_data`, `ServerGamePacketListenerImpl.addBlockDataToItem`. + pub(super) fn picked_stack(&self, pos: [i32; 3], include_data: bool) -> Option { + let key = (pos[0], pos[1], pos[2]); + let state = self.world.block(pos[0], pos[1], pos[2]); + let facts = self.res.facts.get(state); + if &*facts.item == "minecraft:air" { + return None; + } + let mut stack = ItemStack::new(&facts.item, 1); + let mut patch = Compound::new(); + // BeehiveBlock.getCloneItemStack: the honey level as the item's block state. + if include_data + && let Some(level) = self + .res + .assets + .registry + .state(state) + .filter(|s| matches!(s.name.as_str(), "minecraft:beehive" | "minecraft:bee_nest")) + .and_then(|s| s.properties.get("honey_level")) + { + patch.insert( + "minecraft:block_state".into(), + Tag::compound([("honey_level", Tag::String(level.as_str().into()))]), + ); + } + // AbstractBannerBlock.getCloneItemStack: the banner's own item + // (BannerBlockEntity.getItem), data or not. + let banner = matches!( + self.world.block_entities.get(&key), + Some(BlockEntity::Banner(_)) + ); + if (include_data || banner) + && let Some(saved) = self.saved_block_entity(key) + { + patch.extend(block_entity_item::pick(&saved)); + } + if !patch.is_empty() { + let mut all = components(&stack); + all.extend(patch); + (stack.damage, stack.extra) = item_data::decode_components(Some(&Tag::Compound(all))); + } + Some(stack) + } + + /// `BlockItem.updateCustomBlockEntityTag` and `updateBlockEntityComponents` + /// for the block entity just placed at `pos` from `stack`; `game_master`: + /// the placer can use game master blocks (a dispenser can't). + pub(super) fn apply_placed_item( + &mut self, + pos: [i32; 3], + stack: &ItemStack, + game_master: bool, + ) { + let key = (pos[0], pos[1], pos[2]); + self.pending.banner.remove(&pos); + let Some(saved) = self.saved_block_entity(key) else { + return; + }; + let placed = block_entity_item::place(&saved, &components(stack), game_master); + if placed == saved { + return; + } + let Some((_, mut entity)) = save::decode_block_entity(&placed) else { + return; + }; + // The new crafter's state flag (CrafterBlock.newBlockEntity), which + // its saved data has no part of. + if let (BlockEntity::Crafter(new), Some(BlockEntity::Crafter(old))) = + (&mut entity, self.world.block_entities.get(&key)) + { + new.state_triggered = old.state_triggered; + } + // Banners and signs keep their name themselves. + if !matches!(entity, BlockEntity::Banner(_) | BlockEntity::Sign(_)) { + match save::text(placed.get("CustomName")) { + Some(name) => self.world.block_names.insert(key, name), + None => self.world.block_names.remove(&key), + }; + } + self.world.block_entities.insert(key, entity); + } +} diff --git a/crates/horizon-client/src/game/command_blocks.rs b/crates/horizon-client/src/game/command_blocks.rs index f97d5dfc31..97419c9863 100644 --- a/crates/horizon-client/src/game/command_blocks.rs +++ b/crates/horizon-client/src/game/command_blocks.rs @@ -210,15 +210,16 @@ impl Game<'_> { self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at)); } - /// `CommandBlock.setPlacedBy`: output tracking follows - /// `send_command_feedback`, then the block takes the signal around it. - pub(super) fn command_block_placed(&mut self, pos: Pos) { + /// `CommandBlock.setPlacedBy`: unless the item carried block entity data + /// (`defaults` false), output tracking follows `send_command_feedback` and + /// the block's own automatic flag; then the block takes the signal around it. + pub(super) fn command_block_placed(&mut self, pos: Pos, defaults: bool) { let feedback = self.ui.rules.bool("send_command_feedback"); let Some(name) = self.block_state_at(pos).map(|s| s.name.as_str()) else { return; }; let auto = command_block::automatic_block(name); - if let Some(c) = command_block_mut(&mut self.world, pos) { + if defaults && let Some(c) = command_block_mut(&mut self.world, pos) { c.track_output = feedback; c.auto = auto; } diff --git a/crates/horizon-client/src/game/dispenser.rs b/crates/horizon-client/src/game/dispenser.rs index 14489e8374..c6cf3ff569 100644 --- a/crates/horizon-client/src/game/dispenser.rs +++ b/crates/horizon-client/src/game/dispenser.rs @@ -1541,8 +1541,7 @@ impl Game<'_> { let target = (placed.x, placed.y, placed.z); if let Some(entity) = BlockEntity::for_block(&self.res.facts.get(state).name) { self.world.block_entities.insert(target, entity); - self.fill_placed_box([target.0, target.1, target.2], &stack); - self.name_placed_container([target.0, target.1, target.2], &stack); + self.apply_placed_item([target.0, target.1, target.2], &stack, false); } self.process_level_events()?; Ok((shrink(stack), CLICK)) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 776f06c04c..b3b5fabf82 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2278,7 +2278,7 @@ impl Game<'_> { if let Some(hit) = self.pick() { self.send(S::PickItemFromBlock(play::PickItemFromBlock { pos: hit.pos, - include_data: false, + include_data: self.has_control_down(), })); } } diff --git a/crates/horizon-client/src/game/storage.rs b/crates/horizon-client/src/game/storage.rs index 7017b2f5f6..df84ec6e90 100644 --- a/crates/horizon-client/src/game/storage.rs +++ b/crates/horizon-client/src/game/storage.rs @@ -8,18 +8,6 @@ pub(super) fn is_shulker_box(name: &str) -> bool { name.ends_with("shulker_box") } impl Game<'_> { - /// A placed container takes the item's custom name. - pub(super) fn name_placed_container(&mut self, pos: [i32; 3], stack: &ItemStack) { - let key = (pos[0], pos[1], pos[2]); - if let Some(name) = stack.custom_name() - && !matches!( - self.world.block_entities.get(&key), - Some(BlockEntity::Banner(_) | BlockEntity::Sign(_)) - ) - { - self.world.block_names.insert(key, name.to_owned()); - } - } /// The custom name of the open container, or its default title /// (a double chest is named by whichever half has a name). pub(super) fn container_title(&self, default: String) -> String { @@ -29,22 +17,6 @@ impl Game<'_> { .cloned() .unwrap_or(default) } - /// A placed shulker box takes the item's contents. - pub(super) fn fill_placed_box(&mut self, pos: [i32; 3], stack: &ItemStack) { - let Some(data) = stack.data() else { return }; - if !is_shulker_box(&stack.item) || data.container.is_empty() { - return; - } - if let Some(BlockEntity::Chest(items)) = - self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2])) - { - for (slot, s) in &data.container { - if let Some(cell) = items.get_mut(usize::from(*slot)) { - *cell = Some(s.clone()); - } - } - } - } /// ShulkerBoxSlot.mayPlace: no shulker box inside a shulker box. pub(super) fn refuses(&self, slot: usize, stack: &ItemStack) -> bool { (300..400).contains(&slot) diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs index faa7d63128..b56891996e 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -17,6 +17,7 @@ pub mod beehive; pub mod benchmark; pub mod biome; pub mod block_entity_anim; +pub mod block_entity_item; pub mod block_entity_nbt; pub mod block_entity_render; pub mod block_transform; diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaPickBlockDataOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaPickBlockDataOracle.java new file mode 100644 index 0000000000..2c464891ab --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaPickBlockDataOracle.java @@ -0,0 +1,91 @@ +package dev.horizonlab.parity; + +import com.google.gson.JsonParser; +import com.mojang.logging.LogUtils; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import net.minecraft.core.BlockPos; +import net.minecraft.core.component.DataComponentPatch; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.TagParser; +import net.minecraft.resources.Identifier; +import net.minecraft.util.ProblemReporter; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.TagValueOutput; +import org.slf4j.Logger; + +/** + * Original oracle: block entity data on items. Each case line is JSON: a block, + * the saved NBT of its block entity, the block entity an item is placed as + * ("target", NBT), the item's component patch ("patch", SNBT; absent: the item + * Ctrl + Pick Block gave) and whether the player can use game master blocks. + * Prints the picked item's component patch, a tab, then the placed block + * entity's saveWithFullMetadata. + */ +public final class VanillaPickBlockDataOracle { + static final Logger LOGGER = LogUtils.getLogger(); + static final BlockPos POS = new BlockPos(0, 64, 0); + + static BlockEntity load(BlockState state, String snbt) throws Exception { + BlockEntity entity = BlockEntity.loadStatic(POS, state, TagParser.parseCompoundFully(snbt), ParityData.access); + if (entity == null) throw new IllegalStateException("no block entity: " + snbt); + return entity; + } + + /** ServerGamePacketListenerImpl.addBlockDataToItem on the block's item. */ + static ItemStack pick(BlockState state, BlockEntity entity) { + ItemStack item = new ItemStack(state.getBlock().asItem()); + try (ProblemReporter.ScopedCollector reporter = new ProblemReporter.ScopedCollector(entity.problemPath(), LOGGER)) { + TagValueOutput output = TagValueOutput.createWithContext(reporter, ParityData.access); + entity.saveCustomOnly(output); + entity.removeComponentsFromTag(output); + BlockItem.setBlockEntityData(item, entity.getType(), output); + item.applyComponents(entity.collectComponents()); + } + return item; + } + + /** BlockItem.updateCustomBlockEntityTag, then updateBlockEntityComponents. */ + static void place(BlockEntity entity, ItemStack item, boolean gameMaster) { + var data = item.get(DataComponents.BLOCK_ENTITY_DATA); + if (data != null && data.type() == entity.getType() && (!entity.getType().onlyOpCanSetNbt() || gameMaster)) { + data.loadInto(entity, ParityData.access); + } + entity.applyComponentsFromItemStack(item); + } + + public static void main(String[] args) throws Exception { + ParityData.init(); + 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) lines::iterator) { + var c = JsonParser.parseString(line).getAsJsonObject(); + try { + BlockState state = BuiltInRegistries.BLOCK.getValue(Identifier.parse(c.get("block").getAsString())).defaultBlockState(); + ItemStack picked = pick(state, load(state, c.get("nbt").getAsString())); + ItemStack item = picked; + if (c.has("patch")) { + var patch = DataComponentPatch.CODEC.parse(ParityData.ops, TagParser.parseCompoundFully(c.get("patch").getAsString())).getOrThrow(); + item = new ItemStack(state.getBlock().asItem()); + item.applyComponents(patch); + } + BlockEntity target = load(state, c.get("target").getAsString()); + place(target, item, c.get("op").getAsBoolean()); + CompoundTag saved = target.saveWithFullMetadata(ParityData.access); + var patch = DataComponentPatch.CODEC.encodeStart(ParityData.ops, picked.getComponentsPatch()).getOrThrow(); + out.println(ParityData.canon(patch) + "\t" + ParityData.canon(saved)); + } catch (Exception e) { + System.err.println(line + ": " + e); + out.println("error"); + } + } + } + } +} diff --git a/tools/test_pick_block_data_parity.py b/tools/test_pick_block_data_parity.py new file mode 100644 index 0000000000..4feb69f954 --- /dev/null +++ b/tools/test_pick_block_data_parity.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Compare block entity data on items with vanilla 26.3. + +The Java oracle (VanillaPickBlockDataOracle) loads each block entity with +BlockEntity.loadStatic and runs ServerGamePacketListenerImpl.addBlockDataToItem +(Ctrl + Pick Block in creative) on the block's item, then places an item as a +block entity: BlockItem.updateCustomBlockEntityTag (op-only types only for a +game master) and BlockEntity.applyComponentsFromItemStack. The Rust example +(pick_block_data_fixture) runs horizon_client::block_entity_item on the +canonical saved NBT. The picked component patch and the placed block entity's +saveWithFullMetadata are compared for every block entity type that carries data: +containers with items, names, locks and loot tables, signs, banners, command +blocks, spawners, trial spawners, vaults, beehives, pots, skulls, lecterns and +more, picked and placed back, with hand-written patches (data of another type, +op-only data without permission, merges into a filled block entity, leftover +components). +""" +import argparse +import json +try: + from . import interaction_lab as lib +except ImportError: + import interaction_lab as lib + +POS = 'x:0,y:64,z:0' +STONE = '{id:"minecraft:stone",count:5}' +NAME = 'CustomName:"Box"' +PIG = 'SpawnData:{entity:{id:"minecraft:pig"}}' +# A block entity that spawns from nothing: what loading it saves (a spawner's +# potentials and a trial spawner's next spawn come from its first spawn data). +FRESH = {'mob_spawner': PIG, 'trial_spawner': 'spawn_data:{entity:{}}'} + + +def be(type_id, body=''): + return '{id:"minecraft:%s",%s%s}' % (type_id, POS, ',' + body if body else '') + + +def items(*slots): + return 'Items:[%s]' % ','.join('{Slot:%db,id:"minecraft:%s",count:%d}' % s for s in slots) + + +# (block, block entity type, saved NBT bodies to pick and place back). +SAMPLES = [ + ('chest', 'chest', ['', items((0, 'stone', 5), (26, 'diamond', 1)), NAME, 'lock:{items:"minecraft:diamond"}', + 'LootTable:"minecraft:chests/simple_dungeon",LootTableSeed:42L', + 'LootTable:"minecraft:chests/igloo_chest"', items((3, 'dirt', 1)) + ',' + NAME]), + ('trapped_chest', 'trapped_chest', [items((1, 'stone', 2))]), + ('barrel', 'barrel', [items((5, 'apple', 3)) + ',' + NAME]), + ('red_shulker_box', 'shulker_box', ['', items((0, 'stone', 64), (13, 'emerald', 2)) + ',' + NAME]), + ('hopper', 'hopper', ['TransferCooldown:5', items((4, 'stone', 1)) + ',TransferCooldown:2']), + ('dispenser', 'dispenser', [items((8, 'arrow', 16))]), + ('dropper', 'dropper', [items((0, 'stone', 1)) + ',' + NAME]), + ('crafter', 'crafter', ['disabled_slots:[I;1,4],triggered:1,crafting_ticks_remaining:3', + items((2, 'stick', 1)) + ',disabled_slots:[I;0]']), + ('furnace', 'furnace', ['cooking_time_spent:20,cooking_total_time:200,lit_time_remaining:100,lit_total_time:1600,' + + items((0, 'iron_ore', 3), (1, 'coal', 4)) + ',RecipesUsed:{"minecraft:iron_ingot":2}']), + ('smoker', 'smoker', [items((2, 'cooked_beef', 1))]), + ('blast_furnace', 'blast_furnace', [NAME + ',lit_time_remaining:7']), + ('brewing_stand', 'brewing_stand', ['BrewTime:30,Fuel:10,' + items((4, 'blaze_powder', 1))]), + # Levels is computed by the beacon's tick, never loaded. + ('beacon', 'beacon', ['primary_effect:"minecraft:speed",secondary_effect:"minecraft:regeneration"', + NAME + ',lock:{items:"minecraft:stick"}']), + ('enchanting_table', 'enchanting_table', ['', NAME]), + ('command_block', 'command_block', ['Command:"say hi",auto:1b,powered:1b,conditionMet:1b,TrackOutput:0b,SuccessCount:3', + 'Command:"time set day",' + NAME, 'LastOutput:"{\\"text\\":\\"x\\"}"', + 'UpdateLastExecution:0b,LastExecution:20L']), + ('repeating_command_block', 'command_block', ['Command:"say loop",auto:1b']), + ('campfire', 'campfire', [items((0, 'beef', 1), (3, 'porkchop', 1)) + ',CookingTimes:[I;5,0,0,9],CookingTotalTimes:[I;600,0,0,600]']), + ('chiseled_bookshelf', 'chiseled_bookshelf', [items((0, 'book', 1), (5, 'enchanted_book', 1)) + ',last_interacted_slot:5']), + ('oak_shelf', 'shelf', [items((1, 'stone', 1)) + ',align_items_to_bottom:1b']), + ('beehive', 'beehive', ['', 'bees:[{entity_data:{id:"minecraft:bee"},ticks_in_hive:5,min_ticks_in_hive:600}]', + 'flower_pos:[I;1,2,3]']), + ('white_banner', 'banner', ['', 'patterns:[{pattern:"minecraft:creeper",color:"red"}]', + 'patterns:[{pattern:"minecraft:stripe_top",color:"blue"}],' + NAME]), + ('decorated_pot', 'decorated_pot', ['', 'sherds:{back:{id:"minecraft:angler_pottery_sherd"},front:{id:"minecraft:skull_pottery_sherd"}}', + 'item:{id:"minecraft:diamond",count:3}', 'LootTable:"minecraft:archaeology/desert_pyramid"']), + ('oak_sign', 'sign', ['', 'front_text:{messages:["hello","world","",""]}', + 'front_text:{messages:["a","","",""],color:"red",has_glowing_text:1b},back_text:{messages:["","b","",""]},is_waxed:1b']), + ('oak_hanging_sign', 'hanging_sign', ['back_text:{messages:["x","","",""]}']), + ('skeleton_skull', 'skull', ['', 'custom_name:"Bones"', 'note_block_sound:"minecraft:block.note_block.bell"']), + ('spawner', 'mob_spawner', ['Delay:20,SpawnData:{entity:{id:"minecraft:zombie"}},MinSpawnDelay:200,MaxSpawnDelay:800', + 'SpawnData:{entity:{id:"minecraft:pig"}},SpawnCount:2,RequiredPlayerRange:8']), + ('trial_spawner', 'trial_spawner', ['spawn_data:{entity:{id:"minecraft:zombie"}}', + 'normal_config:"minecraft:trial_chamber/melee/zombie/normal",required_player_range:10,spawn_data:{entity:{}}']), + ('vault', 'vault', ['', 'config:{key_item:{id:"minecraft:ominous_trial_key",count:1}}']), + ('jukebox', 'jukebox', ['', 'RecordItem:{id:"minecraft:music_disc_cat",count:1},ticks_since_song_started:40L']), + ('lectern', 'lectern', ['', 'Book:{id:"minecraft:writable_book",count:1,components:{"minecraft:writable_book_content":{pages:[{raw:"a"},{raw:"b"}]}}},Page:1']), + ('sculk_sensor', 'sculk_sensor', ['last_vibration_frequency:4']), + ('creaking_heart', 'creaking_heart', ['']), + ('bell', 'bell', ['']), + ('structure_block', 'structure_block', ['name:"minecraft:house",mode:"SAVE",posY:2']), +] + +# Patches placed as (block, block entity type, target body, patch SNBT, game master). +DATA = '"minecraft:block_entity_data"' +PATCHES = [ + ('command_block', 'command_block', '', '{%s:{id:"minecraft:command_block",Command:"say op"}}' % DATA, False), + ('command_block', 'command_block', '', '{%s:{id:"minecraft:chest",Command:"say wrong"}}' % DATA, True), + ('command_block', 'command_block', 'Command:"old",auto:1b', '{%s:{id:"minecraft:command_block",TrackOutput:0b}}' % DATA, True), + ('oak_sign', 'sign', '', '{%s:{id:"minecraft:sign",is_waxed:1b}}' % DATA, False), + ('oak_sign', 'sign', '', '{%s:{id:"minecraft:sign",is_waxed:1b}}' % DATA, True), + ('chest', 'chest', items((0, 'stone', 1)), '{%s:{id:"minecraft:chest",Items:[{Slot:1b,id:"minecraft:dirt",count:1}]}}' % DATA, True), + ('chest', 'chest', '', '{"minecraft:container":[{slot:30,item:%s},{slot:2,item:%s}]}' % (STONE, STONE), False), + ('chest', 'chest', '', '{"minecraft:container_loot":{loot_table:"minecraft:chests/simple_dungeon",seed:7L}}', False), + ('chest', 'chest', '', '{"minecraft:lore":["a"],"minecraft:custom_name":"Named"}', False), + ('hopper', 'hopper', 'TransferCooldown:4', '{%s:{id:"minecraft:hopper",TransferCooldown:9}}' % DATA, False), + ('spawner', 'mob_spawner', PIG, '{%s:{id:"minecraft:mob_spawner",SpawnData:{entity:{id:"minecraft:skeleton"}}}}' % DATA, True), + ('spawner', 'mob_spawner', PIG, '{%s:{id:"minecraft:mob_spawner",SpawnData:{entity:{id:"minecraft:skeleton"}}}}' % DATA, False), + ('trial_spawner', 'trial_spawner', 'spawn_data:{entity:{}}', '{%s:{id:"minecraft:trial_spawner",required_player_range:4}}' % DATA, True), + ('vault', 'vault', '', '{%s:{id:"minecraft:vault",config:{key_item:{id:"minecraft:stick",count:1}}}}' % DATA, False), + ('decorated_pot', 'decorated_pot', '', '{"minecraft:container":[{slot:1,item:%s}]}' % STONE, False), + ('white_banner', 'banner', NAME, '{"minecraft:banner_patterns":[{pattern:"minecraft:flower",color:"lime"}]}', False), + ('beehive', 'beehive', 'bees:[{entity_data:{id:"minecraft:bee"},ticks_in_hive:1,min_ticks_in_hive:2}]', '{}', False), + ('decorated_pot', 'decorated_pot', '', '{"!minecraft:pot_decorations":{}}', False), + ('hopper', 'hopper', items((0, 'stone', 1)), '{"minecraft:container":[{slot:4,item:%s},{slot:5,item:%s}]}' % (STONE, STONE), False), + ('oak_sign', 'sign', '', '{"minecraft:waxed":{},"minecraft:sign_text_back":{messages:["","","","z"]}}', False), +] + + +def cases(): + out = [] + for block, type_id, bodies in SAMPLES: + for body in bodies: + for op in (True, False): + out.append({'block': 'minecraft:' + block, 'nbt': be(type_id, body), + 'target': be(type_id, FRESH.get(type_id, '')), 'op': op}) + for block, type_id, target, patch, op in PATCHES: + out.append({'block': 'minecraft:' + block, 'nbt': be(type_id, FRESH.get(type_id, '')), 'target': be(type_id, target), 'patch': patch, 'op': op}) + return out + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--limit', type=int, default=4) + args = parser.parse_args() + work = lib.work_dir('pick-block-data-parity') + vanilla = lib.Vanilla(work) + vanilla.compile('ParityData', 'VanillaPickBlockDataOracle') + all_cases = cases() + (work / 'cases.jsonl').write_text(''.join(json.dumps(c) + '\n' for c in all_cases)) + vanilla.run('VanillaPickBlockDataOracle', work / 'cases.jsonl', work / 'native.txt') + lib.cargo_example('horizon-client', 'pick_block_data_fixture', work / 'cases.jsonl', work / 'rust.txt') + errors = sum(line == 'error' for line in (work / 'native.txt').read_text().split('\n')) + if errors: + print(f'{errors} cases fail in vanilla') + total = lib.compare(all_cases, work / 'native.txt', work / 'rust.txt', + lambda case, line: case['block'] + (' patch' if 'patch' in case else ''), + lambda case: json.dumps(case), args.limit) + raise SystemExit(1 if total or errors else 0) + + +if __name__ == '__main__': + main()