Files
horizon/tools/test_pick_block_data_parity.py
T
Ludovic MarieandClaude Opus 5.5 bd42496af0 Ctrl + Pick Block copies the block entity's data in creative
ServerGamePacketListenerImpl.addBlockDataToItem: with Ctrl held in creative,
Pick Block gives the block's item the block entity's data. What each type
turns into item components (removeComponentsFromTag and
collectImplicitComponents: container, custom_name, lock, container_loot,
sign text, waxed, bees, banner patterns, pot decorations, skull profile)
leaves its NBT; the rest becomes minecraft:block_entity_data, tagged with
the type's id. Placing the item does the reverse
(BlockItem.updateCustomBlockEntityTag, op-only types only for a game
master, then applyComponentsFromItemStack), for every block entity type,
which also fills placed chests, barrels, hoppers and the like from their
container component, as vanilla does, where only shulker boxes were.

The client sends includeData on Ctrl in multiplayer, a picked banner keeps
its patterns without Ctrl (AbstractBannerBlock.getCloneItemStack), a picked
beehive carries its honey level, a placed command block with data keeps its
own auto and output tracking, and a placed waxed sign opens no editor.

tools/test_pick_block_data_parity.py compares both directions with vanilla's
own block entities: 148/148 cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 10:59:56 +02:00

154 lines
10 KiB
Python

#!/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()