Frame identity harness: the base's and the change's pictures, pixel for pixel

tools/test_frame_identity_parity.py builds the base (BASE_SHA, else the merge-base
with origin/main) in a worktree and target folder of its own under
.local/frame-identity/, draws fixed scenes offscreen with it and with the change
under HORIZON_STILL=1, and requires identical decoded pixels, writing a diff image
where they differ: HDR off (day over water, night with light sources, stained
glass, the Nether), Golden Hour (API 0.1) in SDR and with offscreen HDR output,
and HDR turned off in a running game. Each session checks it drew what it is for,
so a pack that fails to load, or HDR that stays off, in both builds can't pass.
Lavapipe where it is installed, else the same adapter for both builds. A change
that touches no file a game binary is built from reuses the change's binaries.

The differential screenshot test of docs/design/hdr-linear-light.md (Tests 1, 2),
first of the shader API 0.2 series (#9861).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Nikita Maximilian GrimmandClaude Opus 5.5 committed 2026-11-05 13:45:58 +01:00
1 parent f319701e8c
commit e60be31a02
3 files changed
+621

No files matched your search

+2
View File
@@ -38,6 +38,8 @@ All runners on a machine share `PARITY_MACHINE_JOBS` slots (default: half the CP
The game logs into `HORIZON_HOME` and falls back to the player's own game folder when it is unset, so a harness that starts the game, `horizon-ctl`, the launcher or the dedicated server builds the environment with `lab.game_env(**extra)`: no audio, and fresh scratch folders for `HORIZON_HOME`, `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and `XDG_CONFIG_HOME`, removed when the harness exits (a folder you pass, or that the environment already has, is kept). The runner also gives each harness its own `HORIZON_HOME` under `.local/parity-runner/game-home/`. `tests/test_game_home.py` fails for a script in `tools/` that starts the game without it.
`frame_identity` (`tools/test_frame_identity_parity.py`) holds the picture where a change shouldn't move it, as the HDR design note's first tests ask ([design/hdr-linear-light.md](design/hdr-linear-light.md#tests)). Its oracle is the base build, not vanilla: pictures of the game hold Minecraft's textures and can't be committed. It builds `BASE_SHA` (else the merge-base with `origin/main`) in a worktree and target folder of its own under `.local/frame-identity/`, kept between runs so a later base rebuilds incrementally, draws fixed scenes offscreen with each build under `HORIZON_STILL=1` (HDR off; the Golden Hour pack in SDR and with offscreen HDR output; HDR turned off in a running game), and requires identical pixels, writing `diff-<scene>.png` where they aren't. Both builds draw on lavapipe where Mesa's Vulkan driver is installed, else on the machine's default adapter. When the change touches only `tools/`, `tests/`, `.github/` or Markdown outside `crates/`, the base reuses the change's binaries (`--build-base` builds it anyway), so the run checks that two runs of one build agree.
### Other machines
Harnesses can also run on the Mac mini, the desktop and playhorizon (`tools/parity_hosts.json` describes them; `desktop` and `playhorizon` use their CI runner's environment and vanilla data, the desktop's entry describes its 10-26 setup, retired, and refuses work: docs/release-flow.md, "The desktop's runner"; its `default` list is empty, so pass `--hosts macmini,playhorizon` or set `PARITY_HOSTS`, after copying `shared-local/` to `~/code/horizon-parity/shared-local` on each host). The runner mirrors the worktree there with rsync (not `.git`, `.local`, `target` or ignored files) into `~/code/horizon-parity/remote/<host><path>`, whose `.local` links to the vanilla data in `~/code/horizon-parity/shared-local`, and starts `tools/parity.py --worker` over ssh. The worker builds the probes (about 80 s the first time) and runs what it is sent under the same tracer, admitting harnesses against its own free memory. One queue, longest first, feeds every machine, remote ones first: the Mac takes up to 14 harnesses, the desktop 8, playhorizon 3 (on CPUs 0-3: its CI runner has 4-7). Each pass comes back with the content hash of every file it depended on, so it is keyed on exactly what ran, and logs come back to `.local/parity-runner/logs/`; state, sharing and `gate.py` work as before. If a host can't be reached, or drops out, its harnesses run here.
+141
View File
@@ -0,0 +1,141 @@
"""The frame identity harness's PNG comparison: pixels decide, not how the encoder filtered its rows."""
import struct
import tempfile
import unittest
import zlib
from pathlib import Path
from tools import test_frame_identity_parity as identity
def paeth(a, b, c):
p = a + b - c
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
return a if pa <= pb and pa <= pc else b if pb <= pc else c
def encode(path, width, height, depth, rows, kind):
"""An RGB PNG of `rows` (bytes), every row filtered with PNG filter `kind` (0 None, 1 Sub, 2 Up, 3 Average, 4 Paeth)."""
bpp = 3 * depth // 8
stream, previous = b'', bytes(width * bpp)
for row in rows:
out = bytearray()
for i, value in enumerate(row):
a = row[i - bpp] if i >= bpp else 0
b = previous[i]
c = previous[i - bpp] if i >= bpp else 0
predicted = (0, a, b, (a + b) >> 1, paeth(a, b, c))[kind]
out.append((value - predicted) & 255)
stream += bytes([kind]) + out
previous = row
def chunk(name, body):
return struct.pack('>I', len(body)) + name + body + struct.pack('>I', zlib.crc32(name + body))
header = struct.pack('>IIBBBBB', width, height, depth, 2, 0, 0, 0)
Path(path).write_bytes(b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', header) + chunk(b'IDAT', zlib.compress(stream))
+ chunk(b'IEND', b''))
def picture(width, height, depth):
"""A gradient with every byte value somewhere, so each filter's arithmetic wraps."""
bpp = 3 * depth // 8
return [bytes((x * 37 + y * 101 + i * 59) & 255 for x in range(width) for i in range(bpp)) for y in range(height)]
class CompareTests(unittest.TestCase):
def setUp(self):
self.dir = Path(tempfile.mkdtemp())
def test_the_same_pixels_filtered_differently_are_identical(self):
for depth in (8, 16):
rows = picture(7, 5, depth)
encode(self.dir / 'a.png', 7, 5, depth, rows, 0)
for kind in (1, 2, 3, 4):
encode(self.dir / 'b.png', 7, 5, depth, rows, kind)
self.assertIsNone(identity.compare(self.dir / 'a.png', self.dir / 'b.png', self.dir / 'd.png'),
f'{depth}-bit, filter {kind}')
self.assertFalse((self.dir / 'd.png').exists())
def test_one_changed_pixel_is_counted_and_marked_red(self):
rows = picture(6, 4, 8)
changed = [bytearray(r) for r in rows]
changed[2][3 * 4 + 1] ^= 4 # 🤖 Agent: pixel (4, 2), green, by 4 steps
encode(self.dir / 'a.png', 6, 4, 8, rows, 4)
encode(self.dir / 'b.png', 6, 4, 8, [bytes(r) for r in changed], 4)
problem = identity.compare(self.dir / 'a.png', self.dir / 'b.png', self.dir / 'd.png')
self.assertEqual(problem, '1 of 24 pixels (4.167%), largest step 4')
width, height, bpp, raw = identity.read_png(self.dir / 'd.png')
diff = identity.unfilter(width, height, bpp, raw)
reds = [(x, y) for y, row in enumerate(diff) for x in range(width)
if row[3 * x] == 255 and row[3 * x + 1] < 255]
self.assertEqual(reds, [(4, 2)])
def test_a_16_bit_step_counts_in_16_bit_units(self):
rows = picture(3, 2, 16)
changed = [bytearray(r) for r in rows]
low = changed[1][5]
changed[1][5] = (low + 1) & 255 # 🤖 Agent: pixel (0, 1), green's low byte
carry = 255 if low == 255 else 1
encode(self.dir / 'a.png', 3, 2, 16, rows, 0)
encode(self.dir / 'b.png', 3, 2, 16, [bytes(r) for r in changed], 2)
problem = identity.compare(self.dir / 'a.png', self.dir / 'b.png', self.dir / 'd.png')
self.assertEqual(problem, f'1 of 6 pixels (16.667%), largest step {carry}')
def test_a_different_size_is_reported(self):
encode(self.dir / 'a.png', 4, 4, 8, picture(4, 4, 8), 0)
encode(self.dir / 'b.png', 4, 3, 8, picture(4, 3, 8), 0)
self.assertIn('size or format differs',
identity.compare(self.dir / 'a.png', self.dir / 'b.png', self.dir / 'd.png'))
class SessionCheckTests(unittest.TestCase):
"""A session that didn't do its job would pass as identical in both builds: the harness says so instead."""
def setUp(self):
self.dir = Path(tempfile.mkdtemp())
self.rows = picture(4, 4, 8)
def shot(self, name, rows=None):
encode(self.dir / name, 4, 4, 8, rows or self.rows, 0)
def test_a_pack_that_changes_nothing_and_hdr_that_stays_off_are_reported(self):
scenes = identity.SESSIONS['golden_hour'][2]
for scene in scenes:
self.shot(f'vanilla.{scene}.png')
self.shot(f'golden_hour.{scene}.png')
for scene in identity.SESSIONS['hdr_toggle'][2]:
self.shot(f'hdr_toggle.{scene}.png')
self.shot(f'hdr_toggle.off.{scene}.png')
self.shot(f'hdr_toggle.off.{scene}-hdr.png')
problems = identity.session_checks(self.dir, ['vanilla', 'golden_hour', 'hdr_toggle'])
self.assertEqual(sorted(problems), sorted(
[f'golden_hour.{s}: the same as without the pack: it didn\'t run' for s in scenes]
+ [f'hdr_toggle.{s}: HDR output was off' for s in identity.SESSIONS['hdr_toggle'][2]]
+ [f'hdr_toggle.off.{s}: HDR output was on' for s in identity.SESSIONS['hdr_toggle'][2]]))
def test_sessions_that_did_their_job_pass(self):
other = [bytes(255 - v for v in r) for r in self.rows]
for scene in identity.SESSIONS['golden_hour'][2]:
self.shot(f'vanilla.{scene}.png')
self.shot(f'golden_hour.{scene}.png', other)
for scene in identity.SESSIONS['golden_hour_hdr'][2]:
self.shot(f'golden_hour_hdr.{scene}.png')
self.shot(f'golden_hour_hdr.{scene}-hdr.png')
self.assertEqual(identity.session_checks(self.dir, ['vanilla', 'golden_hour', 'golden_hour_hdr']), [])
class BaseTests(unittest.TestCase):
def test_only_files_no_binary_is_built_from_let_the_base_reuse_the_change(self):
changed = ['tools/test_frame_identity_parity.py', 'tests/test_frame_identity.py', '.github/workflows/ci.yml',
'docs/parity-workflow.md', 'README.md', 'docs/modpacks/atm10-mods.csv', 'crates/x/README.md',
'crates/horizon-client-render/src/gfx.rs']
original = identity.git
identity.git = lambda *args, cwd=None: '\n'.join(changed)
try:
self.assertEqual(identity.needs_own_build('base'), changed[5:])
finally:
identity.git = original
if __name__ == '__main__':
unittest.main()
+478
View File
@@ -0,0 +1,478 @@
#!/usr/bin/env python3
"""The picture a change leaves alone stays the same, pixel for pixel (docs/design/hdr-linear-light.md, Tests 1 and 2).
Pictures of the game hold Minecraft's textures and can't be committed, so this test is differential: it builds the base
(BASE_SHA on the parity runner, else the merge-base with origin/main) and the change, draws the same fixed scenes with
each offscreen under HORIZON_STILL=1 (a frozen scene draws the same frame every run, docs/agent-control.md), and
requires the decoded pixels to be identical. The oracle is the base build, not vanilla.
Sessions, each a fresh game and world (seed 7) per build:
- vanilla: HDR off and no shader pack: day over water, night with light sources, stained glass, the Nether.
- golden_hour: the Golden Hour example pack (shader API 0.1) in SDR, the same scenes.
- golden_hour_hdr: Golden Hour with offscreen HDR output on (HORIZON_HDR_OUTPUT=pq): each screenshot and its -hdr.png.
- hdr_toggle: vanilla started with HDR on (the 16-bit frame), shot, HDR turned off from its settings page, shot.
Both builds draw on the same adapter: Mesa's lavapipe where its Vulkan driver is installed (Linux), else the machine's
default. When the base and the change differ only in files no game binary is built from (tools/, tests/, .github/,
Markdown outside crates/), the base reuses the change's binaries: the comparison is then of two runs of one build.
Each session also checks it drew what it is for (the pack changes the picture, HDR shots have their -hdr.png), so a
pack that fails to load, or HDR that stays off, in both builds doesn't pass as identical.
--build-base builds it anyway. The base builds in a worktree and target folder of its own under .local/frame-identity/,
kept between runs so that a later base rebuilds only what changed.
On a difference it writes diff-<scene>.png beside the two shots: the base dimmed to grey, differing pixels in red.
test_frame_identity_parity.py [--base REV] [--build-base] [--bin-dir DIR] [--base-bin-dir DIR] [--sessions a,b]
"""
import argparse
import json
import os
import shutil
import struct
import subprocess
import sys
import tempfile
import time
import zlib
from pathlib import Path
try:
from . import lab
except ImportError:
import lab
ROOT = lab.ROOT
RUN = lab.LOCAL / 'frame-identity'
BASE_SRC = RUN / 'base-src'
BASE_TARGET = RUN / 'target-base'
EXE = '.exe' if os.name == 'nt' else ''
SIZE = (854, 480)
SEED = '7'
# 🤖 Agent: changes under these, and Markdown outside crates/, build no game binary, so the base can reuse the
# change's. Not all of docs/: horizon-port-pack embeds docs/modpacks/atm10-mods.csv.
NO_BINARY = ('tools/', 'tests/', '.github/')
BUILD = ['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '-p', 'horizon-client-lib', '--bins']
# 🤖 Agent: Where the overworld scenes are built, high above seed 7's spawn, so terrain below hardly shows.
X, Y, Z = 8, 160, 8
def git(*args, cwd=ROOT):
return subprocess.run(['git', *args], cwd=cwd, check=True, capture_output=True, text=True).stdout.strip()
def base_revision(requested):
rev = requested or os.environ.get('BASE_SHA') or git('merge-base', 'HEAD', 'origin/main')
return git('rev-parse', f'{rev}^{{commit}}')
def needs_own_build(base):
"""Whether the base can't reuse the change's binaries: it differs in a file a game binary may be built from."""
changed = [p for p in git('diff', '--name-only', base, 'HEAD').splitlines() if p]
return [p for p in changed if not (p.startswith(NO_BINARY) or (p.endswith('.md') and not p.startswith('crates/')))]
def checkout_base(base):
"""The base's sources in a worktree of their own, moved to `base` in place, so that only the files that changed
since the last run get new times and the base's target folder rebuilds incrementally."""
git('worktree', 'prune')
listed = git('worktree', 'list', '--porcelain')
if BASE_SRC.exists() and f'worktree {BASE_SRC.as_posix()}' not in listed.replace('\\', '/'):
shutil.rmtree(BASE_SRC)
if not BASE_SRC.exists():
BASE_SRC.parent.mkdir(parents=True, exist_ok=True)
git('worktree', 'add', '--detach', '--force', str(BASE_SRC), base)
else:
git('checkout', '--detach', '--force', base, cwd=BASE_SRC)
git('clean', '-fdq', cwd=BASE_SRC)
def build(cwd, target, jobs):
env = dict(os.environ, CARGO_TARGET_DIR=str(target))
command = BUILD + (['-j', str(jobs)] if jobs else [])
started = time.monotonic()
subprocess.run(command, cwd=cwd, env=env, check=True)
print(f'built {cwd} in {time.monotonic() - started:.0f} s', flush=True)
return target / 'fast'
def lavapipe():
"""Mesa's software Vulkan driver's manifest, where it is installed."""
for folder in ('/usr/share/vulkan/icd.d', '/usr/local/share/vulkan/icd.d', '/etc/vulkan/icd.d'):
for path in sorted(Path(folder).glob('lvp_icd*.json')) if Path(folder).is_dir() else []:
return path
return None
# 🤖 Agent: PNG, with the standard library only.
def read_png(path):
"""(width, height, bytes per pixel, the IDAT stream decompressed): pixel rows still filtered."""
data = Path(path).read_bytes()
if data[:8] != b'\x89PNG\r\n\x1a\n':
raise ValueError(f'{path}: not a PNG')
at, idat, header = 8, [], None
while at < len(data):
length, kind = struct.unpack('>I4s', data[at:at + 8])
body = data[at + 8:at + 8 + length]
if kind == b'IHDR':
header = struct.unpack('>IIBBBBB', body)
elif kind == b'IDAT':
idat.append(body)
at += 12 + length
width, height, depth, colour, _, _, interlace = header
channels = {0: 1, 2: 3, 4: 2, 6: 4}[colour]
if interlace or depth not in (8, 16):
raise ValueError(f'{path}: {depth}-bit, interlace {interlace} is not what the game writes')
return width, height, channels * depth // 8, zlib.decompress(b''.join(idat))
def unfilter(width, height, bpp, raw):
"""The pixel rows of a decompressed PNG stream."""
stride = width * bpp
rows, previous = [], bytearray(stride)
for y in range(height):
kind = raw[y * (stride + 1)]
row = bytearray(raw[y * (stride + 1) + 1:(y + 1) * (stride + 1)])
if kind == 1:
for i in range(bpp, stride):
row[i] = (row[i] + row[i - bpp]) & 255
elif kind == 2:
for i in range(stride):
row[i] = (row[i] + previous[i]) & 255
elif kind == 3:
for i in range(stride):
left = row[i - bpp] if i >= bpp else 0
row[i] = (row[i] + ((left + previous[i]) >> 1)) & 255
elif kind == 4:
for i in range(stride):
a = row[i - bpp] if i >= bpp else 0
b = previous[i]
c = previous[i - bpp] if i >= bpp else 0
p = a + b - c
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
row[i] = (row[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 255
rows.append(row)
previous = row
return rows
def write_png(path, width, height, rows):
"""An 8-bit RGB PNG of `rows` (bytes, width * 3 each)."""
def chunk(kind, body):
return struct.pack('>I', len(body)) + kind + body + struct.pack('>I', zlib.crc32(kind + body))
stream = b''.join(b'\0' + bytes(r) for r in rows)
Path(path).write_bytes(b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0))
+ chunk(b'IDAT', zlib.compress(stream, 6)) + chunk(b'IEND', b''))
def compare(base_path, head_path, diff_path):
"""None when the two PNGs hold the same pixels, else what differs (writing the diff image, unless None)."""
a, b = read_png(base_path), read_png(head_path)
if a[:3] != b[:3]:
return f'size or format differs: {a[:3]} against {b[:3]}'
if a[3] == b[3]:
return None
width, height, bpp, _ = a
rows_a, rows_b = unfilter(*a), unfilter(*b)
if rows_a == rows_b:
return None
if diff_path is None:
return 'the pixels differ'
channel = 2 if bpp in (6, 8) else 1
differing, largest, picture = 0, 0, []
for ra, rb in zip(rows_a, rows_b):
out = bytearray()
for x in range(width):
pa, pb = ra[x * bpp:(x + 1) * bpp], rb[x * bpp:(x + 1) * bpp]
grey = pa[0] // 3 if bpp < 3 else (pa[0] + pa[channel] + pa[2 * channel]) // 9
if pa != pb:
differing += 1
step = max(abs(int.from_bytes(pa[i:i + channel], 'big') - int.from_bytes(pb[i:i + channel], 'big'))
for i in range(0, bpp, channel))
largest = max(largest, step)
out += bytes((255, grey, grey))
else:
out += bytes((grey, grey, grey))
picture.append(out)
write_png(diff_path, width, height, picture)
return f'{differing} of {width * height} pixels ({100 * differing / (width * height):.3f}%), largest step {largest}'
# 🤖 Agent: the game.
class Game:
"""One build's game, started offscreen in its own game folder and driven through horizon-ctl."""
def __init__(self, bin_dir, folder, env, args):
self.ctl_path = bin_dir / f'horizon-ctl{EXE}'
self.client = bin_dir / f'horizon-client{EXE}'
self.folder = folder
self.env = env
# 🤖 Agent: Sockets need short paths.
self.socket = Path(tempfile.mkdtemp(prefix='hzfi')) / 'control.sock'
self.args = args
def ctl(self, *words, timeout=600, check=True):
result = subprocess.run([str(self.ctl_path), '--socket', str(self.socket), '--timeout', str(timeout),
*map(str, words)], cwd=self.folder, env=self.env, capture_output=True, text=True,
encoding='utf-8', errors='replace')
if check and result.returncode:
raise RuntimeError(f'horizon-ctl {" ".join(map(str, words))}: {result.stdout}{result.stderr}'[:2000])
text = result.stdout.strip()
return json.loads(text) if text.startswith('{') else text
def command(self, text):
"""Runs `text`; on chunks not loaded yet (forceloaded ones still generating) waits and tries again for a
minute. 🤖 Agent: a command on chunks not loaded changes less of the scene, differently each run."""
for _ in range(60):
reply = self.ctl('command', text)
if 'not loaded' not in json.dumps(reply):
return reply
self.ctl('wait', 20)
raise RuntimeError(f'/{text}: {reply}')
def launch(self):
# 🤖 Agent: A fixed name: the player's offline UUID, and so the hand's skin, follows it, and a random one is printed.
self.ctl('launch', '--game', self.client, '--resources', self.args.resources, '--world', 'scenes',
'--seed', SEED, '--creative', '--offscreen', '--wait', '--game-dir', self.folder, '--name', 'Frames',
'--width', SIZE[0], '--height', SIZE[1], '--render-distance', '6', timeout=900)
def settle(self, ticks=40):
"""Until no chunk is pending, then `ticks` more for the frame to catch up (chunks fade in on the clock)."""
self.ctl('wait-ready', 300, timeout=330)
self.ctl('wait', ticks)
def quit(self):
try:
self.ctl('quit', timeout=120, check=False)
except (OSError, subprocess.SubprocessError):
pass
def prepare(game):
"""The same quiet world for every session: nothing that moves, nothing printed over the picture. Each part is
built once its chunks are loaded around the player."""
game.ctl('close', check=False)
for c in ('gamerule send_command_feedback false', 'gamerule advance_time false', 'gamerule advance_weather false',
'gamerule spawn_mobs false', 'weather clear 1000000', 'effect clear @s',
f'forceload add {X - 40} {Z - 40} {X + 40} {Z + 40}'):
game.command(c)
game.command(f'execute in minecraft:overworld run tp @s {X + 0.5} {Y + 3} {Z + 0.5}')
game.settle()
# 🤖 Agent: Day over water: a stone platform with a pool, sand around it, the sky and clouds behind.
game.command(f'fill {X - 8} {Y - 1} {Z - 8} {X + 8} {Y - 1} {Z + 8} minecraft:stone')
game.command(f'fill {X - 4} {Y - 1} {Z - 4} {X + 4} {Y - 1} {Z + 4} minecraft:sand')
game.command(f'fill {X - 3} {Y - 1} {Z - 3} {X + 3} {Y - 1} {Z + 3} minecraft:water')
game.command(f'setblock {X + 6} {Y} {Z - 6} minecraft:oak_log')
game.command(f'setblock {X + 6} {Y + 1} {Z - 6} minecraft:oak_leaves[persistent=true]')
# 🤖 Agent: Night with light sources: a closed room east of it, lit by torches, a lantern, glowstone and lava.
rx = X + 24
game.command(f'fill {rx - 6} {Y - 1} {Z - 6} {rx + 6} {Y + 6} {Z + 6} minecraft:smooth_stone hollow')
for x, z, block in ((-4, 4, 'torch'), (4, 4, 'lantern'), (0, 5, 'glowstone'), (-2, -4, 'soul_torch')):
game.command(f'setblock {rx + x} {Y} {Z + z} minecraft:{block}')
game.command(f'setblock {rx + 3} {Y - 1} {Z - 3} minecraft:lava')
# 🤖 Agent: Stained glass: panes and blocks of several colours before a white wall, with ice and water behind them.
gx = X - 24
game.command(f'fill {gx - 5} {Y - 1} {Z + 6} {gx + 5} {Y + 5} {Z + 6} minecraft:white_concrete')
game.command(f'fill {gx - 5} {Y - 1} {Z - 6} {gx + 5} {Y - 1} {Z + 5} minecraft:stone')
for i, colour in enumerate(('red', 'lime', 'blue', 'yellow', 'magenta')):
game.command(f'fill {gx - 5 + 2 * i} {Y} {Z + 2} {gx - 4 + 2 * i} {Y + 2} {Z + 2} minecraft:{colour}_stained_glass')
game.command(f'setblock {gx - 5 + 2 * i} {Y + 3} {Z} minecraft:{colour}_stained_glass_pane')
game.command(f'fill {gx - 1} {Y} {Z + 4} {gx + 1} {Y} {Z + 4} minecraft:ice')
game.command(f'fill {gx - 3} {Y} {Z + 5} {gx + 3} {Y} {Z + 5} minecraft:water')
# 🤖 Agent: The Nether: a pocket carved out of its rock, lit by glowstone, lava and soul fire.
# 🤖 Agent: Carved before the player goes there: the Nether's rock and lava would hold it, and set it on fire.
game.command('execute in minecraft:the_nether run forceload add -16 -16 16 16')
game.command('execute in minecraft:the_nether run fill -6 60 -6 6 68 6 minecraft:netherrack hollow')
game.command('execute in minecraft:the_nether run fill -5 61 -5 5 67 5 minecraft:air')
for c in ('setblock 0 67 4 minecraft:glowstone', 'setblock -3 60 3 minecraft:lava',
'setblock 3 60 3 minecraft:soul_soil', 'setblock 3 61 3 minecraft:soul_fire',
'setblock -4 61 -1 minecraft:crimson_fungus', 'setblock 4 60 -2 minecraft:nether_quartz_ore'):
game.command(f'execute in minecraft:the_nether run {c}')
# 🤖 Agent: the player still ticks in a frozen world and falls: each viewpoint stands on a block.
for where, _, (x, y, z), _, _ in SCENES.values():
game.command(f'execute in minecraft:{where} run setblock {int(x // 1)} {y - 1} {int(z // 1)} minecraft:stone')
game.command('kill @e[type=!player]')
SCENES = {
# 🤖 Agent: name: (where, time of day, the player's feet, yaw, pitch)
'day_water': ('overworld', 6000, (X + 0.5, Y + 3, Z - 9.5), 0, 25),
'night_lights': ('overworld', 18000, (X + 24.5, Y, Z - 4.5), 0, 20),
'glass': ('overworld', 6000, (X - 23.5, Y, Z - 3.5), 0, 5),
'nether': ('the_nether', 18000, (0.5, 61, -4.5), 0, 15),
}
def shoot(game, out, scene):
where, day_time, (x, y, z), yaw, pitch = SCENES[scene]
game.command(f'time set {day_time}')
game.command(f'execute in minecraft:{where} run tp @s {x} {y} {z} {yaw} {pitch}')
game.ctl('look', yaw, pitch)
game.settle()
game.ctl('screenshot', out / f'{scene}.png')
def set_hdr(game, on):
"""Turn HDR output on or off from its settings page, as a player does."""
game.ctl('options', 'graphics-hdr')
widgets = game.ctl('ui')
widgets = widgets.get('widgets', widgets) if isinstance(widgets, dict) else widgets
# 🤖 Agent: The page's tab is labelled HDR too: the switch is the button reading "HDR: On" or "HDR: Off".
switch = next((w for w in widgets if w.get('kind') == 'button' and str(w.get('label', '')).startswith('HDR: ')),
None)
if switch is None:
raise RuntimeError(f'no HDR switch on the HDR page: {json.dumps(widgets)[:600]}')
if switch['label'].endswith(': On') != on:
game.ctl('click', switch['id'])
game.ctl('close')
game.ctl('close', check=False)
game.settle(60)
SESSIONS = {
# 🤖 Agent: name: (horizon-graphics.txt, extra environment, scenes, HDR turned off after the first shots)
'vanilla': ('', {}, list(SCENES), False),
'golden_hour': ('', {'HORIZON_SHADER_PACK': 'golden_hour'}, list(SCENES), False),
'golden_hour_hdr': ('hdr:true\n', {'HORIZON_SHADER_PACK': 'golden_hour', 'HORIZON_HDR_OUTPUT': 'pq'},
['day_water', 'night_lights'], False),
'hdr_toggle': ('hdr:true\n', {'HORIZON_HDR_OUTPUT': 'pq'}, ['day_water', 'night_lights'], True),
}
# 🤖 Agent: Auto paper white and peak are 203 and 1000 offscreen; written out so a change of Auto doesn't move the
# pictures. TAA and frame generation build on earlier frames, which HORIZON_STILL doesn't cover: off, as by default.
GRAPHICS = 'hdrPaperWhite:203\nhdrPeak:1000\ntaa:false\nfxaa:false\nframeGeneration:off\n'
def run_session(name, label, bin_dir, out, env_base, args):
graphics, extra, scenes, toggle = SESSIONS[name]
folder = RUN / 'games' / f'{name}-{label}'
shutil.rmtree(folder, ignore_errors=True)
folder.mkdir(parents=True)
(folder / 'horizon-graphics.txt').write_text(GRAPHICS + graphics)
# 🤖 Agent: No chat, no tutorial toasts, no pause when the offscreen window has no focus.
(folder / 'options.txt').write_text('chatVisibility:2\ntutorialStep:none\npauseOnLostFocus:false\n')
if 'HORIZON_SHADER_PACK' in extra:
shutil.copytree(ROOT / 'crates/horizon-shader-api/packs/golden_hour', folder / 'shaderpacks/golden_hour')
env = lab.game_env(env_base, HORIZON_STILL='1', **extra)
out.mkdir(parents=True, exist_ok=True)
game = Game(bin_dir, folder, env, args)
started = time.monotonic()
try:
game.launch()
prepare(game)
for scene in scenes:
shoot_named(game, out, name, scene)
if toggle:
set_hdr(game, False)
for scene in scenes:
shoot_named(game, out, f'{name}.off', scene)
(out / f'{name}.log').write_text(str(game.ctl('logs', '--lines', '400', check=False)), encoding='utf-8')
finally:
game.quit()
print(f'{label} {name}: {time.monotonic() - started:.0f} s', flush=True)
def shoot_named(game, out, prefix, scene):
shoot(game, out, scene)
for suffix in ('.png', '-hdr.png'):
shot = out / f'{scene}{suffix}'
if shot.exists():
shot.replace(out / f'{prefix}.{scene}{suffix}')
def session_checks(shots, sessions):
"""That each session drew what it is for, so a pack that fails to load, or HDR that doesn't turn on, in both
builds can't pass as identical: HDR shots have their -hdr.png and only they do, and the pack changes the picture."""
problems = []
for name in sessions:
graphics, extra, scenes, toggle = SESSIONS[name]
hdr = 'hdr:true' in graphics
for scene in scenes:
for prefix, on in ((name, hdr), *(((f'{name}.off', False),) if toggle else ())):
if (shots / f'{prefix}.{scene}-hdr.png').exists() != on:
problems.append(f'{prefix}.{scene}: HDR output was {"off" if on else "on"}')
if 'HORIZON_SHADER_PACK' in extra and 'vanilla' in sessions and not hdr:
same = [s for s in scenes if compare(shots / f'vanilla.{s}.png', shots / f'{name}.{s}.png', None) is None]
problems += [f'{name}.{s}: the same as without the pack: it didn\'t run' for s in same]
return problems
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--base', help='the revision to compare with (default: BASE_SHA, else the merge-base with origin/main)')
parser.add_argument('--build-base', action='store_true', help='build the base even where it could reuse the change\'s binaries')
parser.add_argument('--bin-dir', help='the change\'s prebuilt horizon-client and horizon-ctl (default: build them)')
parser.add_argument('--base-bin-dir', help='the base\'s prebuilt binaries (default: build them, or reuse the change\'s)')
parser.add_argument('--sessions', default=','.join(SESSIONS), help=f'which of {", ".join(SESSIONS)}')
parser.add_argument('--resources', default=str(lab.RESOURCES), help='the prepared resources both builds read')
parser.add_argument('-j', '--jobs', type=int, help='cargo build jobs')
args = parser.parse_args()
sessions = [s for s in args.sessions.split(',') if s]
unknown = [s for s in sessions if s not in SESSIONS]
if unknown:
sys.exit(f'unknown sessions: {unknown}')
base = base_revision(args.base)
head = git('rev-parse', 'HEAD')
print(f'base {base[:10]}, change {head[:10]}', flush=True)
timings = {}
started = time.monotonic()
head_bin = Path(args.bin_dir).resolve() if args.bin_dir else build(ROOT, lab.cargo_target(), args.jobs)
timings['change_build_s'] = round(time.monotonic() - started)
started = time.monotonic()
own = needs_own_build(base)
if args.base_bin_dir:
base_bin = Path(args.base_bin_dir).resolve()
elif own or args.build_base:
print(f'the base builds on its own ({len(own)} changed files a binary may use)', flush=True)
checkout_base(base)
base_bin = build(BASE_SRC, BASE_TARGET, args.jobs)
else:
print('the change touches no file a game binary is built from: the base reuses its binaries', flush=True)
base_bin = head_bin
timings['base_build_s'] = round(time.monotonic() - started)
env = dict(os.environ)
icd = lavapipe()
if icd:
env.update(VK_DRIVER_FILES=str(icd), VK_ICD_FILENAMES=str(icd), WGPU_BACKEND='vulkan')
print(f'drawing on lavapipe ({icd})', flush=True)
else:
print('no lavapipe here: both builds draw on this machine\'s default adapter', flush=True)
shots = RUN / 'shots'
shutil.rmtree(shots, ignore_errors=True)
failures, compared = [], 0
for name in sessions:
for label, bin_dir in (('base', base_bin), ('change', head_bin)):
session_started = time.monotonic()
run_session(name, label, bin_dir, shots / label, env, args)
timings[f'{name}_{label}_s'] = round(time.monotonic() - session_started)
for path in sorted((shots / 'base').glob(f'{name}.*.png')):
other = shots / 'change' / path.name
compared += 1
if not other.exists():
failures.append(f'{path.name}: the change wrote no such screenshot')
continue
problem = compare(path, other, shots / 'change' / f'diff-{path.name}')
print(f' {path.name}: {"identical" if problem is None else problem}', flush=True)
if problem:
failures.append(f'{path.name}: {problem}')
extra = sorted(p.name for p in (shots / 'change').glob(f'{name}.*.png')
if not (shots / 'base' / p.name).exists() and not p.name.startswith('diff-'))
failures += [f'{n}: the base wrote no such screenshot' for n in extra]
if compared == 0:
failures.append('no screenshots were taken')
failures += session_checks(shots / 'base', sessions)
lab.write_json(RUN / 'report.json', {'base': base, 'change': head, 'lavapipe': str(icd) if icd else None,
'compared': compared, 'failures': failures, 'timings': timings})
print(json.dumps(timings), flush=True)
print('frame identity:', 'PASS' if not failures else f'{len(failures)} FAILED', f'({compared} pictures)')
for f in failures:
print(' ', f)
sys.exit(1 if failures else 0)
if __name__ == '__main__':
main()