874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
290 lines
12 KiB
Python
290 lines
12 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, sub-sound, minecart and riding-minecart
|
|
instances), stops (by instance, by sound and/or source, all), category gains,
|
|
source volume options and refreshes, channel ends, delayed and queued sounds,
|
|
pauses, minecart states 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 math
|
|
import random
|
|
|
|
try:
|
|
from . import audio_harness as h, lab
|
|
except ImportError:
|
|
import audio_harness as h
|
|
import lab
|
|
|
|
|
|
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(lab.dbits, at), looping, delay,
|
|
'linear' if linear else 'none', relative, seed)
|
|
|
|
def cart(self, name, at, delta, removed=0):
|
|
self.add('cart', name, *map(lab.dbits, at), *map(lab.dbits, delta), removed)
|
|
|
|
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()
|
|
# Minecarts: the rolling sound and the two riding sounds, by speed,
|
|
# underwater, frozen ticks, getting out and the cart's removal.
|
|
s.cart('k', (10.3, 64.0, 5.7), (0.0, 0.0, 0.0))
|
|
s.play('m', 'k', kind='cart', seed=61)
|
|
s.tick(2)
|
|
for i, v in enumerate([0.005, 0.0099, 0.01, 0.1, 0.3, 0.5, 0.51, 0.8, 1.0, 1.2, 2.0, 0.0]):
|
|
s.cart('k', (10.3 + i * 0.37123, 64.0625, 5.7 - i * 0.1), (v * 0.6, -0.04, v * 0.8))
|
|
s.tick(2)
|
|
if i == 4:
|
|
s.add('freeze', 1)
|
|
s.tick(3)
|
|
s.add('freeze', 0)
|
|
s.add('ride', 'k')
|
|
s.play('r', 'k', kind='rcartu', seed=62)
|
|
s.play('i', 'k', kind='rcart', seed=63)
|
|
if i == 8:
|
|
s.add('player', 1, 0)
|
|
s.tick(3)
|
|
s.add('player', 0, 0)
|
|
s.add('ride', '-')
|
|
s.tick(3)
|
|
s.cart('k', (1.0, 2.0, 3.0), (0.2, 0.0, 0.0), removed=1)
|
|
s.tick(3)
|
|
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 cart in ('k0', 'k1'):
|
|
s.cart(cart, (0.0, 64.0, 0.0), (0.0, 0.0, 0.0))
|
|
s.add('ride', '-')
|
|
s.add('freeze', 0)
|
|
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)
|
|
elif op < 0.62:
|
|
speed = rng.choice([0.0, 0.005, 0.01, 0.3, 0.5, 1.0, rng.uniform(0, 1.5)])
|
|
angle = rng.uniform(0, 6.3)
|
|
at = (rng.uniform(-50, 50), rng.uniform(0, 80), rng.uniform(-50, 50))
|
|
s.cart(rng.choice(['k0', 'k1']), at, (speed * math.cos(angle), rng.uniform(-0.1, 0.1), speed * math.sin(angle)), int(rng.random() < 0.08))
|
|
elif op < 0.65:
|
|
s.add('ride', rng.choice(['k0', 'k1', '-']))
|
|
elif op < 0.66:
|
|
s.add('freeze', int(rng.random() < 0.5))
|
|
elif op < 0.70:
|
|
s.play(name, rng.choice(['k0', 'k1']), kind=rng.choice(['cart', 'rcart', 'rcartu']), seed=rng.getrandbits(63))
|
|
live.add(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, MinecartSoundInstance and RidingMinecartSoundInstance ticks (speed, '
|
|
'riding, underwater, frozen ticks, removal), 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()
|