mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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]>
154 lines
10 KiB
Python
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()
|