874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
217 lines
12 KiB
Python
Executable File
217 lines
12 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), removes it with
|
|
`Level.removeBlock`, or a survival player mines it (`ServerPlayerGameMode.destroyBlock`,
|
|
holding a tool or nothing, #844). 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]
|
|
# What a mining player holds: the right tool, a tool too weak or of another kind (swords and maces
|
|
# wear 2 a block, shears any block but fire), or nothing.
|
|
TOOLS = [plain('diamond_pickaxe'), plain('wooden_pickaxe'), plain('stone_axe'), plain('iron_shovel'), plain('iron_sword'),
|
|
plain('shears'), plain('mace'), '-']
|
|
|
|
|
|
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}
|
|
elif roll < 0.92:
|
|
op = {'op': 'remove'}
|
|
else:
|
|
# ServerPlayerGameMode.destroyBlock: a survival player mines it (#844). The tool comes
|
|
# from the roll, not another draw, so the cases after this one stay the same.
|
|
op = {'op': 'mine', 'item': TOOLS[int(roll * 1000) % len(TOOLS)]}
|
|
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', '--profile', 'fast', '-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() / 'fast/horizon-client'), '--resources', str(resources)], cwd=work,
|
|
env=lab.game_env(HORIZON_DISPENSE_FIXTURE=f'{work / "cases.jsonl"},{work / "rust.txt"}'))
|
|
# 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()
|