4490ff75fc
LevelChunk.setBlockState now runs BlockEntity.preRemoveSideEffects and removes the block entity when a block with one is replaced by another block (copper chests and copper golem statues keep theirs across weathering; UPDATE_SKIP_BLOCK_ENTITY_SIDEEFFECTS skips the side effects). Containers spill every slot through Containers.dropItemStack (spot and pieces of 10 to 30 with triangular motions from the level random, empty slots still drawing their spot), jukeboxes pop their record, lecterns their book; shulker boxes keep their contents for the dropped box. Campfires, decorated pots, chiseled bookshelves and shelves, which Horizon keeps as loaded, spill too. Block.dropResources now draws its loot and pop spots when the behaviour calls it, not after it returns, and spawnAfterBreak awards ore, sculk and spawner experience. Item entities and orbs the behaviour makes are added before it returns to the game, in the same tick. Player mining follows ServerPlayerGameMode.destroyBlock's order (removal and spill, tool wear, then the drops); explosions let the removal spill the contents. ExperienceOrb.award draws the merge id from the level random and moves the orb with its own random. Decorated pots copy their decorations and drop their sherds when cracked (loot tables' dynamic entries). The block update oracle replays preRemoveSideEffects too, and the new block removal harness (tools/test_block_removal_parity.py) compares setBlock/destroyBlock/removeBlock on every block entity holder with contents against vanilla: 2000/2000 cases match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
208 lines
11 KiB
Python
Executable File
208 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Compare what vanilla and Horizon do when a block with a block entity goes away.
|
|
|
|
A container, jukebox, lectern, campfire, decorated pot, chiseled bookshelf,
|
|
shelf, shulker box or another block entity holder at the origin holds random
|
|
contents; the case then replaces it with `Level.setBlock` (another block, the
|
|
same block in another state, with or without UPDATE_SKIP_BLOCK_ENTITY_SIDEEFFECTS),
|
|
destroys it with `Level.destroyBlock` (with or without drops) or removes it with
|
|
`Level.removeBlock`. Vanilla runs over the headless level of the dispenser oracle
|
|
(`VanillaDispenserOracle`, "remove" cases), which replays `LevelChunk.setBlockState`'s
|
|
`BlockEntity.preRemoveSideEffects`; Horizon runs its game glue in a headless game
|
|
(`HORIZON_DISPENSE_FIXTURE`). Both print the block changes, the contents left,
|
|
every item entity (position, motion, stack), level events, sounds, game events
|
|
and the level random. Motions from an item's own unseeded random ("~") are
|
|
compared within their spread.
|
|
"""
|
|
import json
|
|
import os
|
|
import random
|
|
try:
|
|
from . import block_update_harness as h, lab, test_dispenser_parity as d, test_rust_parity as base
|
|
except ImportError:
|
|
import block_update_harness as h
|
|
import lab
|
|
import test_dispenser_parity as d
|
|
import test_rust_parity as base
|
|
|
|
|
|
def plain(item, count=1):
|
|
return d.plain(item, count)
|
|
|
|
|
|
def named(item, count, name):
|
|
return '{id:"minecraft:%s",count:%d,components:{"minecraft:custom_name":"%s"}}' % (item, count, name)
|
|
|
|
|
|
STUFF = ['cobblestone', 'diamond', 'stick', 'iron_ingot', 'oak_planks', 'bread', 'arrow', 'redstone', 'ender_pearl', 'torch',
|
|
'iron_sword', 'bow', 'white_wool', 'glass', 'apple']
|
|
BOOKS = ['book', 'enchanted_book', 'writable_book', 'written_book', 'knowledge_book']
|
|
FOOD = ['porkchop', 'beef', 'chicken', 'mutton', 'rabbit', 'cod', 'salmon', 'potato', 'kelp']
|
|
DISCS = ['music_disc_cat', 'music_disc_13', 'music_disc_pigstep', 'music_disc_otherside']
|
|
|
|
# Blocks with a block entity: the slots a case may fill and what goes in them.
|
|
HOLDERS = {
|
|
'chest': (27, STUFF), 'trapped_chest': (27, STUFF), 'barrel': (27, STUFF), 'hopper': (5, STUFF),
|
|
'dispenser': (9, STUFF), 'dropper': (9, STUFF), 'crafter': (9, STUFF), 'furnace': (3, STUFF),
|
|
'blast_furnace': (3, STUFF), 'smoker': (3, FOOD), 'brewing_stand': (5, ['potion', 'blaze_powder', 'nether_wart', 'glass_bottle']),
|
|
'shulker_box': (27, STUFF), 'red_shulker_box': (27, STUFF), 'lime_shulker_box': (27, STUFF),
|
|
'copper_chest': (27, STUFF), 'exposed_copper_chest': (27, STUFF), 'weathered_copper_chest': (27, STUFF),
|
|
'oxidized_copper_chest': (27, STUFF), 'waxed_copper_chest': (27, STUFF), 'waxed_oxidized_copper_chest': (27, STUFF),
|
|
'jukebox': (1, DISCS), 'lectern': (1, ['writable_book', 'written_book']), 'campfire': (4, FOOD), 'soul_campfire': (4, FOOD),
|
|
'decorated_pot': (1, STUFF), 'chiseled_bookshelf': (6, BOOKS), 'oak_shelf': (3, STUFF), 'pale_oak_shelf': (3, STUFF),
|
|
'warped_shelf': (3, STUFF),
|
|
# Block entities without contents.
|
|
'ender_chest': (0, []), 'enchanting_table': (0, []), 'oak_sign': (0, []), 'white_banner': (0, []), 'beacon': (0, []),
|
|
'bell': (0, []), 'skeleton_skull': (0, []), 'daylight_detector': (0, []), 'comparator': (0, []), 'spawner': (0, []),
|
|
}
|
|
# Blocks whose block entity Horizon models with its slots, which vanilla lists
|
|
# when the block entity survives (the others are only ever replaced).
|
|
LISTED = {'chest', 'trapped_chest', 'barrel', 'hopper', 'dispenser', 'dropper', 'crafter', 'furnace', 'blast_furnace',
|
|
'smoker', 'brewing_stand', 'shulker_box', 'red_shulker_box', 'lime_shulker_box', 'copper_chest', 'exposed_copper_chest',
|
|
'weathered_copper_chest', 'oxidized_copper_chest', 'waxed_copper_chest', 'waxed_oxidized_copper_chest', 'jukebox'}
|
|
REPLACEMENTS = ['air', 'stone', 'glass', 'water', 'oak_planks', 'barrel', 'chest', 'hopper', 'blue_shulker_box', 'dirt', 'tnt',
|
|
'copper_chest', 'oxidized_copper_chest', 'lectern', 'jukebox']
|
|
FLAGS = [3, 3, 3, 2, 18, 2 | 256, 816, 3 | 64, 1]
|
|
|
|
|
|
def contents(rng, name, slots, pool):
|
|
"""Slot items for the holder (books where books go, discs in jukeboxes, ...)."""
|
|
items = []
|
|
if not slots:
|
|
return items
|
|
fill = rng.choice([0, 1, 1, 2, 3, slots]) if slots > 1 else rng.choice([0, 1, 1])
|
|
for slot in rng.sample(range(slots), min(fill, slots)):
|
|
item = rng.choice(pool)
|
|
count = 1 if name in ('jukebox', 'lectern', 'chiseled_bookshelf') or item in ('iron_sword', 'bow', 'potion',
|
|
'writable_book', 'written_book') else rng.randrange(1, 65)
|
|
if item == 'potion':
|
|
stack = d.potion('potion', rng.choice(['water', 'healing', 'swiftness']))
|
|
elif rng.random() < 0.1 and count > 0:
|
|
stack = named(item, count, 'Kept')
|
|
else:
|
|
stack = plain(item, count)
|
|
items.append([0, 0, 0, slot, stack])
|
|
return items
|
|
|
|
|
|
def holder_state(states, rng, name, items):
|
|
"""The block's state, with its book, record or slots shown where the state shows them."""
|
|
if name == 'lectern':
|
|
# A lectern shows a book exactly when it holds one.
|
|
return states.get(name, has_book='true' if items else 'false', facing=rng.choice(['north', 'south', 'east', 'west']))
|
|
if name == 'jukebox':
|
|
return states.get(name, has_record='true' if items else 'false')
|
|
if name == 'chiseled_bookshelf':
|
|
filled = {i[3] for i in items}
|
|
return states.get(name, **{f'slot_{i}_occupied': 'true' if i in filled else 'false' for i in range(6)})
|
|
if name in ('chest', 'trapped_chest'):
|
|
return states.get(name, type='single', facing=rng.choice(['north', 'south', 'east', 'west']))
|
|
return states.any(rng, name) if rng.random() < 0.3 else states.get(name)
|
|
|
|
|
|
def other_state(states, rng, name, current):
|
|
"""Another state of the same block, when it has one."""
|
|
choices = [i for i in states.facts.by_block['minecraft:' + name] if i != current]
|
|
return rng.choice(choices) if choices else None
|
|
|
|
|
|
def cases(facts, count, seed, only):
|
|
rng = random.Random(seed)
|
|
states = d.States(facts)
|
|
names = list(HOLDERS)
|
|
for n in range(count):
|
|
name = names[n % len(names)] if rng.random() < 0.7 else rng.choice(names)
|
|
if only and only not in name:
|
|
continue
|
|
slots, pool = HOLDERS[name]
|
|
items = contents(rng, name, slots, pool)
|
|
state = holder_state(states, rng, name, items)
|
|
cells = [[0, 0, 0, state]]
|
|
# A few neighbours: supports, comparators reading the container, fluids.
|
|
for _ in range(rng.randrange(0, 4)):
|
|
at = (rng.randrange(-1, 2), rng.randrange(-1, 2), rng.randrange(-1, 2))
|
|
if at != (0, 0, 0):
|
|
h.put(cells, at, states.get(rng.choice(['stone', 'dirt', 'glass', 'oak_planks', 'water', 'comparator', 'redstone_wire'])))
|
|
roll = rng.random()
|
|
if roll < 0.45:
|
|
if name in LISTED and rng.random() < 0.15:
|
|
target = other_state(states, rng, name, state)
|
|
target = target if target is not None else states.get(rng.choice(REPLACEMENTS))
|
|
else:
|
|
target = states.get(rng.choice(REPLACEMENTS))
|
|
op = {'op': 'set', 'state': target, 'flags': rng.choice(FLAGS)}
|
|
elif roll < 0.75:
|
|
op = {'op': 'destroy', 'drop': True}
|
|
elif roll < 0.85:
|
|
op = {'op': 'destroy', 'drop': False}
|
|
else:
|
|
op = {'op': 'remove'}
|
|
case = {**h.environment(rng), 'mode': 'remove', 'pos': [0, 0, 0], 'cells': cells, 'items': items, 'remove': op}
|
|
case.pop('raining', None)
|
|
case.pop('thundering', None)
|
|
yield case
|
|
|
|
|
|
def describe(case, facts):
|
|
cells = ' '.join(f'{c[0]},{c[1]},{c[2]}={facts.describe(c[3])}' for c in case['cells'])
|
|
op = dict(case['remove'])
|
|
if 'state' in op:
|
|
op['state'] = facts.describe(op['state'])
|
|
return f'{cells} items={json.dumps(case["items"])} remove={json.dumps(op)} seed={case["seed"]}'
|
|
|
|
|
|
def main():
|
|
parser = h.argument_parser(__doc__)
|
|
args = parser.parse_args()
|
|
if args.count == 40000:
|
|
args.count = 4000
|
|
lock, jars, libraries = base.verify_artifacts()
|
|
facts = h.load_facts()
|
|
resources = lab.LOCAL / 'horizon-client' / lock['minecraft']['version']
|
|
work = lab.LOCAL / 'block-removal-parity'
|
|
work.mkdir(parents=True, exist_ok=True)
|
|
all_cases = list(cases(facts, args.count, args.seed, args.only))
|
|
(work / 'cases.jsonl').write_text(''.join(json.dumps(case) + '\n' for case in all_cases))
|
|
classes = work / 'classes'
|
|
classes.mkdir(exist_ok=True)
|
|
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
|
|
java_root = lab.ROOT / 'mods/horizon/src/test/java'
|
|
d.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-sourcepath', str(java_root), '-nowarn',
|
|
'-cp', dependencies, '-d', str(classes), str(java_root / 'dev/horizonlab/parity/VanillaDispenserOracle.java')])
|
|
d.run(['java', '-Xmx3G', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaDispenserOracle',
|
|
str(resources), str(work / 'cases.jsonl'), str(work / 'native.txt')])
|
|
d.run(['cargo', 'build', '-q', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT,
|
|
env={**os.environ, 'CARGO_BUILD_JOBS': os.environ.get('CARGO_BUILD_JOBS', '3')})
|
|
d.run([str(lab.cargo_target() / 'release/horizon-client'), '--resources', str(resources)], cwd=work,
|
|
env={**os.environ, 'HORIZON_DISPENSE_FIXTURE': f'{work / "cases.jsonl"},{work / "rust.txt"}', 'HORIZON_NO_AUDIO': '1'})
|
|
# Loot table names (D) are vanilla's bookkeeping; the drops themselves are compared.
|
|
ignore = tuple(prefix + ' ' for prefix in args.ignore.split(',') if prefix) + ('D ',)
|
|
native = h.split_cases((work / 'native.txt').read_text(), ignore)
|
|
rust = h.split_cases((work / 'rust.txt').read_text(), ignore)
|
|
assert len(native) == len(rust) == len(all_cases), (len(native), len(rust), len(all_cases))
|
|
groups, errors = {}, {}
|
|
for case, a, b in zip(all_cases, native, rust):
|
|
if a and a[0].startswith('error'):
|
|
errors.setdefault(a[0], []).append(case)
|
|
continue
|
|
if not d.same(a, b):
|
|
block = facts.states[case['cells'][0][3]][0].split(':')[1]
|
|
groups.setdefault(f'{block} {case["remove"]["op"]}', []).append((case, a, b))
|
|
for kind, entries in sorted(errors.items(), key=lambda e: -len(e[1])):
|
|
print(f'oracle error x{len(entries)}: {kind}')
|
|
for name, entries in sorted(groups.items(), key=lambda e: -len(e[1])):
|
|
print(f'{name}: {len(entries)} differing')
|
|
for case, a, b in entries[:args.show]:
|
|
print(' case ' + describe(case, facts))
|
|
print(' native ' + ' | '.join(sorted(a)))
|
|
print(' rust ' + ' | '.join(sorted(b)))
|
|
total = sum(len(v) for v in groups.values())
|
|
skipped = sum(len(v) for v in errors.values())
|
|
print(f'{len(all_cases) - total - skipped}/{len(all_cases) - skipped} cases match ({skipped} oracle errors)')
|
|
(work / 'differences.json').write_text(json.dumps({k: [[c, a, b] for c, a, b in v[:5]] for k, v in groups.items()}))
|
|
h.finish(total + skipped)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|