Files
horizon-airdef/tools/test_sound_engine_parity.py
T
Ludovic Marie d68efbabfc Compare the sound engine bookkeeping with the real vanilla SoundEngine
The oracle runs vanilla SoundManager/SoundEngine on OpenAL Soft null device
(inline executor, silent buffers) and replays scripts of plays, stops,
category gains, option volumes, channel ends, pauses, queued, delayed and
looping sounds and tickable ambient instances, printing every AL source
parameter, isActive, subtitle notification and pool count per step.

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

248 lines
9.7 KiB
Python

#!/usr/bin/env python3
"""Compare the sound engine's bookkeeping with the vanilla 26.3 client.
The oracle runs the real `SoundManager`/`SoundEngine` on OpenAL Soft's "No
Output" device (see VanillaSoundRig) and replays scripts of plays (simple,
biome loop, underwater loop and sub-sound instances), stops (by instance, by
sound and/or source, all), category gains, source volume options and
refreshes, channel ends, delayed and queued sounds, pauses and client ticks.
After every step it prints each instance's `isActive` and the AL source the
engine configured (state, gain, pitch, position, relative, looping, distance
model, max distance, rolloff, reference distance), the subtitle notifications
and the channel pool counts. Horizon's `SoundEngine` replays the scripts
against a backend that records the same AL source fields.
"""
import json
import random
try:
from . import audio_harness as h
except ImportError:
import audio_harness as h
def dbits(v):
import struct
return struct.pack('>d', v).hex()
class Script:
def __init__(self, events, streams):
self.lines = []
self.events, self.streams = events, streams
def add(self, *parts):
self.lines.append(' '.join(map(str, parts)))
def play(self, name, event, source=4, volume=1.0, pitch=1.0, at=(0.0, 0.0, 0.0), looping=0, delay=0, linear=True, relative=0, seed=0, kind='simple'):
self.add('play', name, kind, event, source, h.bits(volume), h.bits(pitch), *map(dbits, at), looping, delay,
'linear' if linear else 'none', relative, seed)
def tick(self, n=1, paused=0):
for _ in range(n):
self.add('tick', paused)
def reset(self):
self.add('stopmatch', '-', '-')
for source in range(11):
self.add('volume', source, h.bits(1.0))
self.tick()
def scripted(s):
stone, cave, click = 'minecraft:block.stone.break', 'minecraft:ambient.cave', 'minecraft:ui.button.click'
music, loop, uloop = 'minecraft:music.game', 'minecraft:ambient.crimson_forest.loop', 'minecraft:ambient.underwater.loop'
# Positional and relative sounds, lifetimes and channel ends.
s.play('a', stone, 4, 1.0, 0.8, (10.5, 64.0, -3.25), seed=5)
s.play('b', click, 10, 0.25, 1.0, linear=False, relative=1, seed=6)
s.play('c', cave, 8, 4.0, 1.0, (100.0, 20.0, 100.0), seed=7)
s.play('d', stone, 4, 1.5, 3.0, (0.5, 0.5, 0.5), seed=8)
s.play('e', stone, 4, 0.5, 0.1, (1.0, 2.0, 3.0), seed=9)
s.tick(3)
s.add('finish', 'a')
s.tick(25)
s.add('finish', 'b')
s.add('stop', 'c')
s.tick(2)
s.reset()
# Volumes: options, category gains, refreshes, silent starts.
s.add('volume', 4, h.bits(0.5))
s.add('volume', 0, h.bits(0.75))
s.play('a', stone, 4, 1.0, 1.0, (1.0, 1.0, 1.0), seed=1)
s.add('volume', 4, h.bits(0.0))
s.play('b', stone, 4, 1.0, 1.0, (1.0, 1.0, 1.0), seed=2)
s.add('volume', 1, h.bits(0.0))
s.play('m', music, 1, 1.0, 1.0, linear=False, relative=1, seed=3)
s.add('refresh', 4)
s.add('volume', 4, h.bits(0.3))
s.add('refresh', 0)
s.add('gain', 1, h.bits(0.5))
s.add('gain', 1, h.bits(1.5))
s.add('gain', 4, h.bits(-0.2))
s.add('volume', 1, h.bits(1.0))
s.add('refresh', 1)
s.tick(2)
s.add('volume', 0, h.bits(2.0))
s.add('refresh', 0)
s.reset()
# Biome loops: fades, crossfade, stopping, removal.
s.play('l1', loop, kind='loop', seed=11)
s.add('fade', 'l1', 'in')
s.tick(45)
s.play('l2', 'minecraft:ambient.warped_forest.loop', kind='loop', seed=12)
s.add('fade', 'l1', 'out')
s.add('fade', 'l2', 'in')
s.tick(50)
s.add('fade', 'l2', 'out')
s.tick(10)
s.add('fade', 'l2', 'in')
s.tick(60)
s.add('volume', 8, h.bits(0.0))
s.play('l3', loop, kind='loop', seed=13)
s.tick(3)
s.reset()
# Underwater loop and sub-sounds.
s.add('player', 1, 0)
s.play('u', uloop, kind='uloop', seed=21)
s.play('v', 'minecraft:ambient.underwater.loop.additions.rare', kind='usub', seed=22)
s.tick(50)
s.add('player', 0, 0)
s.tick(5)
s.add('player', 1, 0)
s.tick(5)
s.add('player', 0, 0)
s.tick(30)
s.add('player', 1, 1)
s.play('u2', uloop, kind='uloop', seed=23)
s.tick(3)
s.add('player', 0, 0)
s.reset()
# Queued tickables, delayed and manually looped sounds.
s.play('q', loop, kind='loop', seed=31)
s.add('stop', 'q')
s.tick(25)
s.add('queue', 'q')
s.add('fade', 'q', 'in')
s.tick(3)
s.play('w', stone, 4, 1.0, 1.0, (2.0, 2.0, 2.0), seed=32)
s.add('delayed', 'w', 5)
s.tick(7)
s.play('r', stone, 4, 1.0, 1.0, (3.0, 3.0, 3.0), looping=1, delay=4, seed=33)
s.tick(22)
s.add('finish', 'r')
s.tick(8)
s.add('finish', 'r')
s.tick(6)
s.play('n', stone, 4, 1.0, 1.0, (3.0, 3.0, 3.0), looping=1, seed=34)
s.tick(2)
s.reset()
# Stopping by source and sound, pauses.
s.play('a', stone, 4, seed=41)
s.play('b', stone, 5, seed=42)
s.play('c', cave, 8, seed=43)
s.play('m', music, 1, linear=False, relative=1, seed=44)
s.play('u', click, 10, linear=False, relative=1, seed=45)
s.add('pause')
s.tick(1, paused=1)
s.add('stop', 'm')
s.tick(1, paused=1)
s.add('resume')
s.add('stopmatch', stone, '-')
s.tick(25)
s.add('stopmatch', '-', 8)
s.add('stopmatch', click, 4)
s.add('stopmatch', click, 10)
s.tick(2)
s.reset()
# Unknown, empty and intentionally empty events; replaying an instance.
for i, event in enumerate(['minecraft:not.a.sound', 'minecraft:intentionally_empty', 'minecraft:empty', 'minecraft:entity.generic.eat']):
s.play(f'x{i}', event, 6, seed=50 + i)
s.add('replay', 'x3')
s.tick(25)
s.reset()
# Channel pools.
for i in range(252):
s.play('p', stone, 4, 1.0, 1.0, (float(i), 0.0, 0.0), seed=i)
for i in range(10):
s.play('s', music, 1, linear=False, relative=1, seed=i)
s.tick(2)
s.add('stopmatch', '-', '-')
s.tick(1)
def fuzz(s, rng, steps):
names = ['f0', 'f1', 'f2', 'f3', 'f4', 'f5']
live = set()
for _ in range(steps):
op = rng.random()
name = rng.choice(names)
if op < 0.25:
kind = rng.choice(['simple', 'simple', 'simple', 'loop', 'uloop', 'usub'])
event = rng.choice(s.streams if kind != 'simple' and rng.random() < 0.5 else s.events)
at = tuple(rng.choice([0.0, rng.uniform(-200, 200)]) for _ in range(3))
s.play(name, event, rng.randrange(11), rng.choice([1.0, 0.5, 2.0, 0.0, 4.0, rng.uniform(0, 3)]), rng.choice([1.0, 0.5, 2.5, rng.uniform(0.2, 3)]),
at, int(rng.random() < 0.2), rng.choice([0, 0, 0, 3]), rng.random() < 0.7, int(rng.random() < 0.3), rng.getrandbits(63), kind)
live.add(name)
elif not live:
s.tick()
elif op < 0.32:
s.add('finish', rng.choice(sorted(live)))
elif op < 0.36:
s.add('stop', rng.choice(sorted(live)))
elif op < 0.40:
s.add('fade', rng.choice(sorted(live)), rng.choice(['in', 'out']))
elif op < 0.42:
s.add('stopmatch', rng.choice(['-', rng.choice(s.events)]), rng.choice(['-', rng.randrange(11)]))
elif op < 0.45:
s.add('volume', rng.randrange(11), h.bits(rng.choice([0.0, 1.0, 0.5, rng.random()])))
elif op < 0.48:
s.add('refresh', rng.randrange(11))
elif op < 0.51:
s.add('gain', rng.randrange(11), h.bits(rng.choice([0.0, 1.0, 0.3, 1.2])))
elif op < 0.53:
s.add('player', int(rng.random() < 0.6), int(rng.random() < 0.05))
elif op < 0.55:
s.add('replay', rng.choice(sorted(live)))
elif op < 0.56:
s.add('pause')
s.tick(rng.randrange(1, 4), paused=1)
s.add('resume')
elif op < 0.57:
s.add('forget', name)
live.discard(name)
else:
s.tick(rng.randrange(1, 6))
def fixtures():
sounds = json.loads((h.RESOURCES / 'assets/minecraft/sounds.json').read_text())
names = sorted(sounds)
streams = sorted('minecraft:' + n for n in names if any(isinstance(e, dict) and e.get('stream') for e in sounds[n].get('sounds', [])))
rng = random.Random(0x454e47494e45)
events = sorted(set(['minecraft:' + rng.choice(names) for _ in range(60)] + ['minecraft:ambient.cave', 'minecraft:block.stone.break']))
s = Script(events, streams)
scripted(s)
for _ in range(40):
s.reset()
fuzz(s, rng, 150)
return s.lines
def main():
lines = fixtures()
matched, report = h.run_harness(
'sound-engine', 'VanillaSoundEngineOracle', 'sound_engine_fixture', ''.join(l + '\n' for l in lines),
extra_sources=[h.lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaSoundRig.java'],
java_args=[h.RESOURCES], rust_args=[h.RESOURCES],
scope='Real SoundEngine (OpenAL Soft null device, inline executor, silent static buffers, never-ready streams): play results, '
'AL source parameters set by play/tickInGameSound/refreshCategoryVolume (gain from calculateVolume, clamped pitch, linear '
'attenuation distance, looping, position, relative), isActive, the 20-tick minimum lifetime, channel ends, stop by instance/sound/source/all, '
'pauseAllExcept/resume, tickMusicWhenPaused, queued tickables, playDelayed and manual looping, LoopSoundInstance, '
'UnderLiquidAmbientSoundInstance and UnderLiquidSubSound ticks, subtitle notifications and ranges, and the 247/8 channel pools. '
'Not compared: decoded sample output, OpenAL panning/HRTF and distance gain inside the mixer, asset loading timing.')
print(f'{matched:,} sound engine lines match the pinned client ({len(lines):,} steps): {report}', flush=True)
if __name__ == '__main__':
main()