Files
horizon-airdef/tools/test_spawner_parity.py
T
Ludovic Marie 79e04e22d6 Monster spawners spawn, spin their mob and take spawn eggs
Spawners were never ticked: Horizon kept their block entity as loaded NBT,
and a freshly generated dungeon or mineshaft spawner lost even that, so no
mob ever came out, nothing turned inside and a spawn egg used on one spawned
a mob next to it instead of setting the spawner's.

The spawner is now a modelled block entity (mobs::spawner): BaseSpawner's
load and save with SpawnData's codec (normalised ids, custom spawn rules,
weighted potentials, the next data shared with the one potential when the
potentials are missing), serverTick (the player range, spawner_blocks_work,
the delay, getOrCreateNextSpawnData, Pos, the spawn box against blocks and
solid shulkers, custom light rules and peaceful, SpawnPlacements, the nearby
cap, the yaw, checkSpawnRules and checkSpawnObstruction, finalizeSpawn for
an id alone, the 2004 burst, entity_place and the spawn poof) and delay with
its block event, in the level random's order. The client copy keeps its own
delay for the spin and puffs smoke and flames; SpawnerRenderer draws the
next mob small and turning. SpawnEggItem.useOn on a spawner sets its mob
(setEntityId), tells the player when spawner_blocks_work is off and fails in
peaceful for monsters. Breaking one pops 15 + nextInt(15) + nextInt(15)
experience whatever the tool. LAN guests get the update tag, the block event
and the entity event.

tools/test_spawner_parity.py ticks vanilla's SpawnerBlockEntity on the
natural spawn oracle's level against mobs::spawner on the same worlds (6,000
cases); tools/test_use_item_on_parity.py now covers spawn eggs on spawners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:33:25 +02:00

282 lines
14 KiB
Python

#!/usr/bin/env python3
"""Compare monster spawners (BaseSpawner.serverTick) with vanilla.
Java (VanillaNaturalSpawnOracle, mode=spawner) loads a SpawnerBlockEntity from
each case's NBT on the oracle's stand-in ServerLevel and runs
`SpawnerBlockEntity.serverTick` for a number of ticks. Rust
(natural_spawn_fixture, mode=spawner) loads `mobs::spawner::Spawner` from the
same NBT and ticks it on the same world. Both log, tick by tick, every spawn
(type, exact position and yaw bits), `sendBlockUpdated`, the block event, the
2004 level event, the entity_place game event and the spawn animation, then
the saved delay, next spawn data and potentials, and the next draw of the
level random, which pins every draw in between.
Worlds are rooms around the spawner (dungeons, caves, open fields, flooded
rooms, nether halls) with dark, dim and lit light, players inside and outside
the required range (spectators, a negative range), mobs of the spawner's type
already around it, every difficulty and the spawner_blocks_work rule. Spawn
data covers generated spawners (no potentials), loaded ones (potentials
missing, so shared with the next data), weighted potentials (zero weights
included), custom light rules, fixed `Pos`, configured entities (no
finalizeSpawn), ids without a namespace, unknown, invalid and missing ids, and
delays, counts, caps and ranges from the edge values up.
finalizeSpawn runs with a fixed random in the oracle (nextInt(100) answers 99
so spiders do not grow skeleton jockeys); its own outcomes are not compared.
Entity types that are not mobs (Horizon creates only mobs from a spawner) are
not in the corpus.
"""
import argparse
import base64
import json
import os
from pathlib import Path
import platform
import random
import subprocess
try:
from . import lab, test_rust_parity as base, test_natural_spawn_parity as natural
except ImportError:
import lab
import test_rust_parity as base
import test_natural_spawn_parity as natural
box = natural.box
MONSTERS = ['zombie', 'skeleton', 'spider', 'cave_spider', 'blaze', 'silverfish', 'magma_cube', 'creeper', 'witch',
'enderman', 'zombified_piglin', 'husk', 'stray', 'bogged', 'slime', 'drowned', 'piglin', 'wither_skeleton',
'endermite', 'parched', 'zombie_villager', 'breeze']
CREATURES = ['pig', 'cow', 'chicken', 'sheep', 'rabbit', 'wolf', 'bat', 'squid', 'cod', 'strider', 'fox', 'horse']
FLOORS = ['minecraft:cobblestone', 'minecraft:mossy_cobblestone', 'minecraft:stone', 'minecraft:deepslate',
'minecraft:grass_block', 'minecraft:netherrack', 'minecraft:sand', 'minecraft:infested_stone']
def entity_snbt(rng, mob, spos):
r = rng.random()
if r < 0.55:
return f'{{id:"minecraft:{mob}"}}'
if r < 0.65:
return f'{{id:"{mob}"}}'
if r < 0.75:
return f'{{id:"minecraft:{mob}",PersistenceRequired:1b}}'
if r < 0.83:
x, y, z = (spos[0] + rng.uniform(-3, 3), spos[1] + rng.choice([0, 0, 1, -1, 3]), spos[2] + rng.uniform(-3, 3))
return f'{{id:"minecraft:{mob}",Pos:[{x:.4f}d,{y}.0d,{z:.4f}d]}}'
if r < 0.88:
return '{id:"minecraft:not_an_entity"}'
if r < 0.93:
return f'{{id:"{mob.capitalize()}"}}'
return '{}'
def spawn_data(rng, mob, spos, rules=True):
out = f'entity:{entity_snbt(rng, mob, spos)}'
if rules and rng.random() < 0.2:
def limit():
lo = rng.randrange(0, 16)
hi = rng.randrange(lo, 16)
return rng.choice([str(lo), f'[I;{lo},{hi}]', f'{{min_inclusive:{lo},max_inclusive:{hi}}}'])
parts = []
if rng.random() < 0.7:
parts.append(f'block_light_limit:{limit()}')
if rng.random() < 0.7:
parts.append(f'sky_light_limit:{limit()}')
out += ',custom_spawn_rules:{' + ','.join(parts) + '}'
return '{' + out + '}'
def spawner_snbt(rng, mob, spos, pool):
fields = []
shape = rng.random()
if shape < 0.35:
# Generated (setEntityId): the next data and no potentials.
fields.append(f'SpawnData:{{entity:{{id:"minecraft:{mob}"}}}}')
fields.append('SpawnPotentials:[]')
elif shape < 0.55:
# Potentials missing: the next data becomes the one potential.
fields.append(f'SpawnData:{spawn_data(rng, mob, spos)}')
elif shape < 0.85:
n = rng.randrange(1, 5)
entries = []
for _ in range(n):
other = mob if rng.random() < 0.5 else rng.choice(pool)
weight = rng.choice([1, 1, 2, 5, 0, 20])
entries.append(f'{{data:{spawn_data(rng, other, spos)},weight:{weight}}}')
fields.append('SpawnPotentials:[' + ','.join(entries) + ']')
if rng.random() < 0.5:
fields.append(f'SpawnData:{spawn_data(rng, mob, spos)}')
elif shape < 0.95:
fields.append(f'SpawnData:{spawn_data(rng, mob, spos)}')
fields.append(f'SpawnPotentials:[{{data:{spawn_data(rng, mob, spos)},weight:1}}]')
# else: an empty spawner (placed from the item).
fields.append(f'Delay:{rng.choice([0, 0, 1, 3, 20, -1, 5])}s')
if rng.random() < 0.8:
lo = rng.choice([0, 1, 5, 10, 20, 200])
hi = rng.choice([lo, lo + 1, lo + 10, lo + 40, max(lo - 5, 0)])
fields.append(f'MinSpawnDelay:{lo}s')
fields.append(f'MaxSpawnDelay:{hi}s')
if rng.random() < 0.6:
fields.append(f'SpawnCount:{rng.choice([1, 2, 4, 4, 6, 8, 0])}s')
if rng.random() < 0.5:
fields.append(f'MaxNearbyEntities:{rng.choice([0, 1, 3, 6, 6, 10])}s')
if rng.random() < 0.4:
fields.append(f'RequiredPlayerRange:{rng.choice([16, 16, 4, 32, -1, 0])}s')
if rng.random() < 0.5:
fields.append(f'SpawnRange:{rng.choice([1, 2, 4, 4, 6, 8])}s')
return '{' + ','.join(fields) + '}'
def room(rng):
"""A world around the spawner; returns (world, spawner position, the mob)."""
w = natural.World(rng)
dim = rng.choices(['overworld', 'nether', 'end'], [80, 17, 3])[0]
w.dim = f'minecraft:{"the_" if dim != "overworld" else ""}{dim}'
w.kv['dim'] = w.dim
w.min_y = -64 if dim == 'overworld' else 0
sx, sy, sz = rng.randrange(-8, 9), rng.randrange(-20, 60) if dim == 'overworld' else rng.randrange(30, 90), rng.randrange(-8, 9)
w.surface = sy
if dim == 'nether':
w.kv['biome'] = rng.choice(list(natural.NETHER))
pool = ['blaze', 'magma_cube', 'zombified_piglin', 'wither_skeleton', 'piglin', 'skeleton', 'strider', 'ghast']
elif dim == 'end':
w.kv['biome'] = 'minecraft:the_end'
pool = ['enderman', 'endermite', 'zombie', 'shulker']
else:
w.kv['biome'] = rng.choice(['minecraft:plains', 'minecraft:desert', 'minecraft:dripstone_caves',
'minecraft:deep_dark', 'minecraft:swamp', 'minecraft:forest'])
pool = MONSTERS + CREATURES
mob = rng.choice(pool)
half, height = rng.choice([3, 4, 4, 5, 6]), rng.choice([3, 4, 4, 6])
kind = rng.random()
floor = rng.choice(FLOORS if dim == 'overworld' else ['minecraft:netherrack', 'minecraft:nether_bricks', 'minecraft:end_stone'])
if kind < 0.65:
# A dungeon or hall: solid all around, an air room.
w.block(box(sx - 12, sy - 6, sz - 12, sx + 12, sy + 10, sz + 12), rng.choice(['minecraft:stone', 'minecraft:cobblestone', 'minecraft:netherrack']))
w.block(box(sx - half, sy - 1, sz - half, sx + half, sy - 1, sz + half), floor)
w.block(box(sx - half, sy, sz - half, sx + half, sy + height - 1, sz + half), rng.choice(['minecraft:cave_air', 'minecraft:air']))
elif kind < 0.85:
# Open ground.
w.block(box(sx - 16, sy - 6, sz - 16, sx + 16, sy - 2, sz + 16), 'minecraft:dirt')
w.block(box(sx - 16, sy - 1, sz - 16, sx + 16, sy - 1, sz + 16), floor)
else:
# A flooded room.
w.block(box(sx - 12, sy - 6, sz - 12, sx + 12, sy + 10, sz + 12), 'minecraft:stone')
w.block(box(sx - half, sy - 1, sz - half, sx + half, sy - 1, sz + half), floor)
w.block(box(sx - half, sy, sz - half, sx + half, sy + height - 1, sz + half), 'minecraft:water')
if rng.random() < 0.5:
mob = rng.choice(['drowned', 'squid', 'cod', 'guardian', 'zombie'])
if rng.random() < 0.3:
# Pillars and slabs in the way.
for _ in range(rng.randrange(1, 6)):
px, pz = sx + rng.randrange(-half, half + 1), sz + rng.randrange(-half, half + 1)
w.block(box(px, sy, pz, px, sy + rng.randrange(0, 3), pz), rng.choice(['minecraft:cobblestone', 'minecraft:oak_slab', 'minecraft:torch', 'minecraft:glass']))
w.block(box(sx, sy, sz, sx, sy, sz), 'minecraft:spawner')
sky, light = rng.choice([(0, 0), (0, 0), (0, 0), (0, 3), (0, 7), (0, 12), (15, 0), (15, 15), (8, 8)])
w.light(box(sx - 16, sy - 2, sz - 16, sx + 16, sy + 12, sz + 16), sky, light)
if rng.random() < 0.25:
# A torch's glow on one side.
w.light(box(sx, sy - 1, sz - half, sx + half, sy + height, sz + half), sky, rng.choice([7, 11, 14]))
return w, (sx, sy, sz), mob, pool
def players(w, rng, spos):
for _ in range(rng.choice([1, 1, 1, 2, 0])):
r = rng.random()
if r < 0.7:
d = rng.uniform(0, 15)
elif r < 0.85:
d = rng.uniform(14, 18)
else:
d = rng.uniform(18, 40)
import math
a, b = rng.uniform(0, 6.283), rng.uniform(-0.6, 0.6)
x = spos[0] + 0.5 + d * math.cos(a) * math.cos(b)
y = spos[1] + 0.5 + d * math.sin(b)
z = spos[2] + 0.5 + d * math.sin(a) * math.cos(b)
mode = rng.choice(['survival'] * 7 + ['creative', 'adventure', 'spectator'])
w.players.append(f'{x:.3f},{y:.3f},{z:.3f}/{mode}')
def corpus():
rng = random.Random(0x5350574E)
cases = []
for k in range(6000):
w, spos, mob, pool = room(rng)
players(w, rng, spos)
if rng.random() < 0.5:
for _ in range(rng.choice([1, 2, 3, 5, 6, 8])):
t = mob if rng.random() < 0.8 else rng.choice(pool)
x, z = spos[0] + rng.uniform(-6, 7), spos[2] + rng.uniform(-6, 7)
w.entities.append(f'minecraft:{t}/{x:.3f},{spos[1] + rng.choice([0, 0, -1, 2])},{z:.3f}')
natural.common(w, rng, rng.randrange(-2**47, 2**47))
w.kv.pop('unloaded', None)
if rng.random() < 0.05:
w.kv['spawners'] = 0
snbt = spawner_snbt(rng, mob, spos, pool)
w.kv['spos'] = f'{spos[0]},{spos[1]},{spos[2]}'
w.kv['snbt'] = base64.b64encode(snbt.encode()).decode()
w.kv['ticks'] = rng.choice([1, 2, 5, 21, 40, 80, 120])
cases.append(f'spawner/{k} mode=spawner {w.tokens()}')
return cases
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--prepare', action='store_true')
args = parser.parse_args()
build = lab.LOCAL / 'rust-spawner-parity'
build.mkdir(parents=True, exist_ok=True)
if args.prepare:
lab.prepare()
lock, jars, libraries = base.verify_artifacts()
resources = lab.LOCAL / 'horizon-client/26.3'
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNaturalSpawnOracle.java'
sources = [oracle, Path(__file__), lab.ROOT / 'crates/horizon-client/examples/natural_spawn_fixture.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawner.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_selection.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/spawn_rules.rs']
tested = base.source_hashes(sources)
inputs = build / 'inputs.txt'
cases = corpus()
inputs.write_text(''.join(line + '\n' for line in cases), encoding='utf-8')
classes = build / 'classes'
classes.mkdir(exist_ok=True)
deps = os.pathsep.join(map(str, libraries))
def run(command, name):
with (build / (name + '.log')).open('w', encoding='utf-8') as log:
base.run(list(map(str, command)), stdout=log, stderr=subprocess.STDOUT)
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-sourcepath', str(lab.ROOT / 'mods/horizon/src/test/java'),
'-cp', str(jars['server']) + os.pathsep + deps, '-d', classes, oracle], 'compile-java')
run(['java', '-Xmx3G', '-cp', os.pathsep.join([str(classes), str(jars['server']), deps]),
'dev.horizonlab.parity.VanillaNaturalSpawnOracle', inputs, build / 'server.txt'], 'server')
run(['cargo', 'build', '--release', '-p', 'horizon-client', '--example', 'natural_spawn_fixture'], 'compile-rust')
metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'], cwd=lab.ROOT, text=True))
binary = Path(metadata['target_directory']) / 'release/examples/natural_spawn_fixture'
run([binary, resources, inputs, build / 'rust.txt'], 'rust')
vanilla = (build / 'server.txt').read_text().splitlines()
errors = [l for l in vanilla if ' error ' in l]
if errors:
raise SystemExit(f'oracle errors ({len(errors)}): {errors[:5]}')
rust = (build / 'rust.txt').read_text().splitlines()
bad = [(a, b) for a, b in zip(vanilla, rust) if a != b]
if bad or len(vanilla) != len(rust):
report = '\n'.join(f' vanilla {a[:2000]}\n rust {b[:2000]}' for a, b in bad[:12])
raise SystemExit(f'{len(bad)} of {len(vanilla)} cases differ (rust {len(rust)} lines):\n{report}')
base.ensure_sources_unchanged(sources, tested)
spawns = sum(line.count(' spawn minecraft:') for line in vanilla)
delays = sum(line.count(' bevent 1 ') for line in vanilla)
updates = sum(line.count(' update') for line in vanilla)
lab.write_json(build / 'report.json', {
'schema': 1, 'minecraft': lock['minecraft']['version'], 'platform': platform.platform(), 'cases': len(cases),
'spawns': spawns, 'delays': delays, 'block_updates': updates, 'sources_sha256': tested,
'scope': 'BaseSpawner: load (SpawnData and potentials codecs, shared next data), serverTick (player range, '
'spawner_blocks_work, delay, getOrCreateNextSpawnData, Pos, getSpawnAABB collision, custom spawn '
'rules and peaceful, SpawnPlacements, the nearby cap, yaw, checkSpawnRules and checkSpawnObstruction, '
'events) and delay (weighted potentials), with every level random draw. finalizeSpawn runs with a '
'fixed random in the oracle; its own outcomes are not compared.'})
print(f'{len(cases):,} spawner cases match vanilla ({spawns:,} spawns, {delays:,} delays, {updates:,} block updates)')
if __name__ == '__main__':
main()