diff --git a/docs/parity-workflow.md b/docs/parity-workflow.md index 1b00080e7..f9f222071 100644 --- a/docs/parity-workflow.md +++ b/docs/parity-workflow.md @@ -32,6 +32,10 @@ Passes are shared between worktrees. A harness's key depends only on the content Every runner on the machine shares `PARITY_MACHINE_JOBS` slots (default: half the CPU threads), so several agents' runs together can't overload it. Parallel harnesses are also admitted by memory: each harness's recorded peak is reserved, within three quarters of the memory available at the start, and a harness starts only if the memory free at that moment still leaves 2 GB. `-j` caps the count here (default: `PARITY_LOCAL_JOBS`, else 3 while other machines help or a third of the CPU threads alone, within `$FLEET_LOAD_JOBS`). +### Harnesses that start the game + +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. + ### Other machines Harnesses can also run on the Mac mini and the ThinkPad (`tools/parity_hosts.json` describes them; its `default` list is empty, so pass `--hosts macmini,thinkpad` 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/`, 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 3 and only those under 800 MB. 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. diff --git a/tests/test_game_home.py b/tests/test_game_home.py new file mode 100644 index 000000000..b4d9e017f --- /dev/null +++ b/tests/test_game_home.py @@ -0,0 +1,102 @@ +"""A tool or harness that starts the game must not write into a real player's folders. + +The game logs into HORIZON_HOME and falls back to the player's own game folder when it is unset, so every +spawn goes through lab.game_env, which sets fresh scratch folders. This test fails for a script in tools/ +that starts the game (or horizon-ctl, the launcher or the dedicated server) without it.""" + +import ast +import os +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +from tools import lab + +TOOLS = Path(lab.ROOT) / "tools" + +# Strings that mean "this script starts the game": a binary a harness runs, or the audio switch every game run sets. +SPAWN_MARKERS = { + "HORIZON_NO_AUDIO", "horizon-ctl", "horizon-ctl.exe", "horizon-server", "horizon-server.exe", + "horizon-client.exe", "release/horizon-client", "fast/horizon-client", "debug/horizon-client", +} + +# Scripts that mention those strings without starting a game themselves, and why. +EXEMPT = { + "lab.py": "defines game_env", + "parity.py": "runs the harnesses and gives each its own HORIZON_HOME", + "package_native_client.py": "packs the binaries into an archive", + "build_release.py": "builds and packs the release", + "release_train.py": "release bookkeeping", + "pr_ready.py": "runs cargo checks and parity.py", + "port_harness.py": "ports Java code, starts no game", +} + + +def constants(tree): + """Every string in a script that is a whole literal or a keyword name (docstrings are longer text).""" + for node in ast.walk(tree): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + yield node.value + elif isinstance(node, ast.keyword) and node.arg: + yield node.arg + + +def uses_game_env(tree): + """The script calls lab.game_env, or starts its servers through test_dedicated_server_parity.Server, which does.""" + return any(isinstance(node, ast.Attribute) and node.attr == "game_env" + or isinstance(node, ast.Name) and node.id == "game_env" + or isinstance(node, ast.ImportFrom) and (node.module or "").endswith("test_dedicated_server_parity") + for node in ast.walk(tree)) + + +def sets_audio_by_hand(tree): + return "HORIZON_NO_AUDIO" in set(constants(tree)) + + +class GameEnvTests(unittest.TestCase): + def test_gives_every_game_scratch_folders(self): + with mock.patch.dict(os.environ, {}, clear=False): + for key in ("HORIZON_HOME", "HORIZON_LAUNCHER_HOME", "XDG_DATA_HOME", "XDG_CONFIG_HOME"): + os.environ.pop(key, None) + first, second = lab.game_env(), lab.game_env() + self.assertEqual(first["HORIZON_NO_AUDIO"], "1") + self.assertEqual(first["HORIZON_LAUNCHER_NO_SELF_UPDATE"], "1") + for key in ("HORIZON_HOME", "HORIZON_LAUNCHER_HOME", "XDG_DATA_HOME", "XDG_CONFIG_HOME"): + self.assertTrue(Path(first[key]).is_dir(), key) + self.assertNotEqual(first[key], second[key], key) + + def test_keeps_the_callers_folders(self): + with tempfile.TemporaryDirectory() as temp: + env = lab.game_env({"PATH": "x", "HORIZON_HOME": "/from/base"}, HORIZON_LAUNCHER_HOME=Path(temp), A=1) + self.assertEqual(env["HORIZON_HOME"], "/from/base") + self.assertEqual(env["HORIZON_LAUNCHER_HOME"], temp) + self.assertEqual((env["PATH"], env["A"]), ("x", "1")) + + def test_does_not_touch_the_process_environment(self): + before = dict(os.environ) + lab.game_env() + self.assertEqual(dict(os.environ), before) + + +class SpawnerTests(unittest.TestCase): + def test_every_script_that_starts_the_game_uses_game_env(self): + offenders = [] + for path in sorted(TOOLS.rglob("*.py")): + if path.name in EXEMPT: + continue + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + if SPAWN_MARKERS & set(constants(tree)) and not uses_game_env(tree): + offenders.append(str(path.relative_to(lab.ROOT))) + self.assertEqual(offenders, [], "these start the game without lab.game_env (HORIZON_HOME falls back to a " + "real player's folder); use it, or list the script in EXEMPT") + + def test_nobody_sets_the_audio_switch_by_hand(self): + offenders = [str(path.relative_to(lab.ROOT)) for path in sorted(TOOLS.rglob("*.py")) + if path.name not in EXEMPT + and sets_audio_by_hand(ast.parse(path.read_text(encoding="utf-8"), filename=str(path)))] + self.assertEqual(offenders, [], "set it through lab.game_env, which also gives the game scratch folders") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/agent_control_e2e.py b/tools/agent_control_e2e.py index 3fcf39553..7f0c52bce 100644 --- a/tools/agent_control_e2e.py +++ b/tools/agent_control_e2e.py @@ -67,17 +67,20 @@ class Failed(Exception): pass -def game_env(screen): +def game_env(screen, home=None): + """The environment of a game: lab.game_env with HORIZON_HOME `home` (a scratch folder when None), no live-count + ping, and on Linux the virtual screen `screen`.""" + extra = {'HORIZON_TELEMETRY_URL': 'off'} # no live-count ping from test games + if home is not None: + extra['HORIZON_HOME'] = home if sys.platform == 'darwin': - return dict(os.environ, HORIZON_NO_AUDIO='1', HORIZON_TELEMETRY_URL='off') - env = dict(os.environ, VSCREEN=screen) + return lab.game_env(**extra) + env = lab.game_env(VSCREEN=screen, **extra) subprocess.run(['vscreen', 'up'], env=env, check=False, capture_output=True) text = subprocess.run(['vscreen', 'env'], env=env, capture_output=True, text=True, check=True).stdout env.pop('DISPLAY', None) for match in re.finditer(r'(\w+)=(\S+)', text.split('unset')[0]): env[match.group(1)] = match.group(2) - env['HORIZON_NO_AUDIO'] = '1' - env['HORIZON_TELEMETRY_URL'] = 'off' # no live-count ping from test games if LAVAPIPE.exists(): env['VK_ICD_FILENAMES'] = str(LAVAPIPE) return env @@ -632,7 +635,7 @@ def scratch(mode, resources): (work / '.local/horizon-client').mkdir() os.symlink(resources, work / '.local/horizon-client' / lab.VERSION) # horizon-ctl reads No AI in the game home: this one, not the player's. - env = dict(game_env(os.environ.get('VSCREEN', 'agent-control')), HORIZON_HOME=str(work)) + env = game_env(os.environ.get('VSCREEN', 'agent-control'), home=work) return work, env diff --git a/tools/graphics_bench.py b/tools/graphics_bench.py index 0e42e94d5..f19165f62 100644 --- a/tools/graphics_bench.py +++ b/tools/graphics_bench.py @@ -53,15 +53,14 @@ guiScale:0 def vscreen_env(): out = subprocess.run(["vscreen", "env"], cwd=ROOT, capture_output=True, text=True, check=True) - env = dict(os.environ) + env = lab.game_env() for part in out.stdout.replace(";", " ").split(): if "=" in part and not part.startswith("export"): k, v = part.split("=", 1) env[k] = v env.pop("DISPLAY", None) - # Benchmark games never play sound on the machine's speakers. - env["HORIZON_NO_AUDIO"] = "1" - # Nor reach the telemetry server (docs/telemetry.md). + # lab.game_env: no sound on the machine's speakers, and scratch folders instead of the player's. + # Benchmark games do not reach the telemetry server (docs/telemetry.md). env["HORIZON_TELEMETRY_URL"] = "off" return env diff --git a/tools/lab.py b/tools/lab.py index a62a38f59..b7cc2f795 100644 --- a/tools/lab.py +++ b/tools/lab.py @@ -5,6 +5,7 @@ generated files stay in .local/. Also the small helpers the harnesses share (free ports, offline UUIDs, VarInts, float bits).""" import argparse +import atexit from collections import Counter import hashlib import json @@ -54,6 +55,38 @@ def cargo_target(): return ROOT / os.environ.get("CARGO_TARGET_DIR", "target") +_SCRATCH_DIRS = [] + + +def _scratch_dir(prefix): + path = tempfile.mkdtemp(prefix=prefix) + _SCRATCH_DIRS.append(path) + return path + + +@atexit.register +def _remove_scratch_dirs(): + for path in _SCRATCH_DIRS: + shutil.rmtree(path, ignore_errors=True) + + +def game_env(base=None, **extra): + """The environment for every run of the game, the launcher or horizon-ctl from a tool or a harness: no audio, + and fresh scratch folders for the game's home (logs, saves, options), the launcher's home and the XDG folders, + so a run never writes into a real player's folders (the game falls back to them when HORIZON_HOME is unset). + A folder the caller already has in `base` (default os.environ) or passes in `extra` is kept. The scratch folders + are removed when this process exits.""" + env = dict(os.environ if base is None else base) + env.update({key: str(value) for key, value in extra.items()}) + env["HORIZON_NO_AUDIO"] = "1" + env["HORIZON_LAUNCHER_NO_SELF_UPDATE"] = "1" + for key, prefix in (("HORIZON_HOME", "horizon-home-"), ("HORIZON_LAUNCHER_HOME", "horizon-launcher-home-"), + ("XDG_DATA_HOME", "horizon-xdg-data-"), ("XDG_CONFIG_HOME", "horizon-xdg-config-")): + if not env.get(key): + env[key] = _scratch_dir(prefix) + return env + + def write_json(path, data): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index a0fe0e181..80cd434c8 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -278,8 +278,7 @@ class Game: (self.work / '.local/horizon-client').mkdir(parents=True) os.symlink(resources, self.work / '.local/horizon-client' / lab.VERSION) self.socket = self.work / 'c.sock' - env = game_env(os.environ.get('VSCREEN', 'lan-e2e')) - env['HORIZON_HOME'] = str(self.work / 'home') + env = game_env(os.environ.get('VSCREEN', 'lan-e2e'), home=self.work / 'home') self.d = Cli(ctl, self.socket, self.work, env) self.d.name = name diff --git a/tools/launcher_mcp_e2e.py b/tools/launcher_mcp_e2e.py index 0b827d288..748acafc2 100644 --- a/tools/launcher_mcp_e2e.py +++ b/tools/launcher_mcp_e2e.py @@ -167,8 +167,7 @@ def main(): os.symlink(bins / name, version / name) os.symlink(resources, version / '.local/horizon-client' / lab.VERSION) (version / '.installed').write_text('') - env = dict(os.environ, HORIZON_LAUNCHER_HOME=str(work / 'launcher'), HORIZON_HOME=str(work / 'home'), - HORIZON_NO_AUDIO='1', HORIZON_TELEMETRY_URL='off') + env = lab.game_env(HORIZON_LAUNCHER_HOME=work / 'launcher', HORIZON_HOME=work / 'home', HORIZON_TELEMETRY_URL='off') m = Mcp(Path(args.launcher).resolve(), env) try: summary = scenario(m, out) diff --git a/tools/mod_browser_harness.py b/tools/mod_browser_harness.py index 4531570bc..cf33d85de 100755 --- a/tools/mod_browser_harness.py +++ b/tools/mod_browser_harness.py @@ -33,6 +33,11 @@ import tempfile import time from pathlib import Path +try: + from . import lab +except ImportError: + import lab + ROOT = Path(__file__).resolve().parents[1] @@ -154,8 +159,7 @@ def main(): args = ap.parse_args() home = Path(tempfile.mkdtemp(prefix="horizon-mod-browser-")) - env = dict(os.environ, HORIZON_HOME=str(home), HORIZON_NO_AUDIO="1", HORIZON_NO_HEARTBEAT="1", - HORIZON_CONTROL=str(home / "ctl.sock")) + env = lab.game_env(HORIZON_HOME=home, HORIZON_NO_HEARTBEAT="1", HORIZON_CONTROL=home / "ctl.sock") launcher = [args.launcher] game = Game(str(Path(args.bin) / "horizon-ctl"), str(Path(args.bin) / "horizon-client"), args.resources, home, env) diff --git a/tools/parity.py b/tools/parity.py index 2422a7049..503a5f3bf 100644 --- a/tools/parity.py +++ b/tools/parity.py @@ -39,6 +39,7 @@ import json import os from pathlib import Path, PurePosixPath import re +import shutil import socket import subprocess import sys @@ -812,6 +813,12 @@ class LocalExecutor: trace.unlink(missing_ok=True) log = (STATE_DIR / "logs" / f"{name}.log").open("w") environment = run_environment(reuse) + # A game the harness starts logs into HORIZON_HOME, which is a real player's folder when it is unset + # (lab.game_env keeps this one). + game_home = STATE_DIR / "game-home" / name + shutil.rmtree(game_home, ignore_errors=True) + game_home.mkdir(parents=True) + environment.setdefault("HORIZON_HOME", str(game_home)) environment["PARITY_SHIM_OUTPUTS"] = str(outputs_listing(name, fresh=True)) environment.pop("PARITY_SHARD", None) if shard: diff --git a/tools/perf_bench.py b/tools/perf_bench.py index 21aa99f85..a403e9b83 100644 --- a/tools/perf_bench.py +++ b/tools/perf_bench.py @@ -400,7 +400,7 @@ def run_horizon(args, distance): for carry in dev_mods(args): command += ["--dev-mod", str(carry)] # Benchmark runs stay out of the live count and send nothing (docs/telemetry.md). - env = dict(os.environ, HORIZON_NO_AUDIO="1", HORIZON_TELEMETRY_URL="off") + env = lab.game_env(HORIZON_TELEMETRY_URL="off") if args.warm: # A first start to the title screen builds what later starts reuse (the content-mod # root, the block behaviour snapshot). It is not measured: players see it once. diff --git a/tools/server_bench.py b/tools/server_bench.py index 3b97d4624..5ba2c7157 100755 --- a/tools/server_bench.py +++ b/tools/server_bench.py @@ -120,7 +120,7 @@ class Server: self.process = subprocess.Popen([str(c) for c in command], cwd=directory, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding='utf-8', errors='replace', - env=dict(os.environ, HORIZON_NO_AUDIO='1')) + env=lab.game_env()) threading.Thread(target=self._read, daemon=True).start() def _read(self): diff --git a/tools/server_pack_e2e.py b/tools/server_pack_e2e.py index 83e434435..160083d5e 100644 --- a/tools/server_pack_e2e.py +++ b/tools/server_pack_e2e.py @@ -206,9 +206,8 @@ class Game: self.home = work / 'home' for d in ('home', 'launcher', 'data', 'config'): (work / d).mkdir(parents=True) - self.env = dict(os.environ, HORIZON_HOME=str(self.home), HORIZON_LAUNCHER_HOME=str(work / 'launcher'), - XDG_DATA_HOME=str(work / 'data'), XDG_CONFIG_HOME=str(work / 'config'), - HORIZON_LAUNCHER_NO_SELF_UPDATE='1', HORIZON_NO_AUDIO='1', HORIZON_TELEMETRY_URL='off') + self.env = lab.game_env(HORIZON_HOME=self.home, HORIZON_LAUNCHER_HOME=work / 'launcher', + XDG_DATA_HOME=work / 'data', XDG_CONFIG_HOME=work / 'config', HORIZON_TELEMETRY_URL='off') self.base = [str(bins / 'horizon-ctl'), '--socket', str(work / 'c.sock')] self.log = work / 'c.log' reply = self('launch', '--game', bins / 'horizon-client', '--offscreen', '--resources', resources) @@ -343,7 +342,7 @@ class Run: ('XDG_CONFIG_HOME', 'config')): (work / 'servers' / sub).mkdir(parents=True) os.environ[var] = str(work / 'servers' / sub) - os.environ.update(HORIZON_LAUNCHER_NO_SELF_UPDATE='1', HORIZON_NO_AUDIO='1', HORIZON_TELEMETRY_URL='off') + os.environ.update(lab.game_env(HORIZON_TELEMETRY_URL='off')) game = self.game = Game(Path(self.args.bin), self.resources, work / 'game') servers_dat = game.home / 'servers.dat' downloads = game.home / 'downloads' diff --git a/tools/test_block_removal_parity.py b/tools/test_block_removal_parity.py index 6ebbbf1d3..3185bd4bb 100755 --- a/tools/test_block_removal_parity.py +++ b/tools/test_block_removal_parity.py @@ -183,7 +183,7 @@ def main(): d.run(['cargo', 'build', '-q', '--release', '-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() / 'release/horizon-client'), '--resources', str(resources)], cwd=work, - env={**os.environ, 'HORIZON_DISPENSE_FIXTURE': f'{work / "cases.jsonl"},{work / "rust.txt"}', 'HORIZON_NO_AUDIO': '1'}) + 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) diff --git a/tools/test_command_exec_parity.py b/tools/test_command_exec_parity.py index 9d57d3f69..877bd0b38 100644 --- a/tools/test_command_exec_parity.py +++ b/tools/test_command_exec_parity.py @@ -82,7 +82,7 @@ def main(): rust_dir = build / 'rust' shutil.rmtree(rust_dir, ignore_errors=True) rust_dir.mkdir() - env = dict(os.environ, HORIZON_NO_AUDIO='1', HORIZON_COMMAND_SCRIPT=str(script_path), HORIZON_COMMAND_OUTPUT=str(build / 'rust.jsonl')) + env = lab.game_env(HORIZON_COMMAND_SCRIPT=script_path, HORIZON_COMMAND_OUTPUT=build / 'rust.jsonl') run([binary, '--resources', lab.RESOURCES, '--world', 'harness', '--seed', SEED, '--radius', 4], 'rust', cwd=rust_dir, env=env) vanilla = [json.loads(l) for l in (build / 'vanilla.jsonl').open(encoding='utf-8')] diff --git a/tools/test_composter_parity.py b/tools/test_composter_parity.py index e632527ce..074b227a8 100644 --- a/tools/test_composter_parity.py +++ b/tools/test_composter_parity.py @@ -125,7 +125,7 @@ def main(): d.run(['cargo', 'build', '-q', '--release', '-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() / 'release/horizon-client'), '--resources', str(resources)], cwd=work, - env={**os.environ, 'HORIZON_DISPENSE_FIXTURE': f'{work / "cases.jsonl"},{work / "rust.txt"}', 'HORIZON_NO_AUDIO': '1'}) + env=lab.game_env(HORIZON_DISPENSE_FIXTURE=f'{work / "cases.jsonl"},{work / "rust.txt"}')) 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) diff --git a/tools/test_crafter_parity.py b/tools/test_crafter_parity.py index 1b9564f41..b9c343d4d 100644 --- a/tools/test_crafter_parity.py +++ b/tools/test_crafter_parity.py @@ -245,7 +245,7 @@ def main(): d.run(['cargo', 'build', '-q', '--release', '-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() / 'release/horizon-client'), '--resources', str(resources)], cwd=work, - env={**os.environ, 'HORIZON_DISPENSE_FIXTURE': f'{work / "cases.jsonl"},{work / "rust.txt"}', 'HORIZON_NO_AUDIO': '1'}) + env=lab.game_env(HORIZON_DISPENSE_FIXTURE=f'{work / "cases.jsonl"},{work / "rust.txt"}')) 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) diff --git a/tools/test_dedicated_server_parity.py b/tools/test_dedicated_server_parity.py index f45df9822..6a6d258d4 100644 --- a/tools/test_dedicated_server_parity.py +++ b/tools/test_dedicated_server_parity.py @@ -126,7 +126,7 @@ class Server: self.lines = [] self.process = subprocess.Popen(list(map(str, command)), cwd=directory, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, - encoding='utf-8', errors='replace') + encoding='utf-8', errors='replace', env=lab.game_env()) self.reader = threading.Thread(target=self._read, daemon=True) self.reader.start() @@ -352,7 +352,7 @@ def horizon_client_joins(vanilla_server, binary, world, build, reference): server = Server(vanilla_server, directory, label) try: server.wait_for(r'Done \(') - env = dict(os.environ, HORIZON_NO_AUDIO='1', HORIZON_NET_REPORT=str(report)) + env = lab.game_env(HORIZON_NET_REPORT=report) with (build / 'horizon-client-run.log').open('w') as log: subprocess.run([str(c) for c in [binary, '--resources', lab.RESOURCES, '--render-distance', 2, '--server', f'127.0.0.1:{port}', '--name', PLAYER]], diff --git a/tools/test_dispenser_parity.py b/tools/test_dispenser_parity.py index 844322aeb..a6d1f429a 100644 --- a/tools/test_dispenser_parity.py +++ b/tools/test_dispenser_parity.py @@ -417,7 +417,7 @@ def main(): run(['cargo', 'build', '-q', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT, env={**os.environ, 'CARGO_BUILD_JOBS': os.environ.get('CARGO_BUILD_JOBS', '3')}) run([str(lab.cargo_target() / 'release/horizon-client'), '--resources', str(resources)], cwd=work, - env={**os.environ, 'HORIZON_DISPENSE_FIXTURE': f'{work / "cases.jsonl"},{work / "rust.txt"}', 'HORIZON_NO_AUDIO': '1'}) + 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) diff --git a/tools/test_friends.py b/tools/test_friends.py index 8fa1a4fee..cc5d15b92 100644 --- a/tools/test_friends.py +++ b/tools/test_friends.py @@ -96,7 +96,7 @@ class Game: self.process = subprocess.Popen(Game.prefix + [ str(client), '--resources', str(resources), '--render-distance', '2', '--test-terrain', '--control-socket', str(self.endpoint), '--timeout-seconds', '600', - ] + Game.extra, cwd=self.home, env=dict(os.environ, HORIZON_HOME=str(self.home), HORIZON_NO_AUDIO='1', **env), + ] + Game.extra, cwd=self.home, env=lab.game_env(HORIZON_HOME=self.home, **env), stdout=self.log, stderr=subprocess.STDOUT) wait_for(self.endpoint, 'screen', lambda v: v['screen'] == 'title', 120, f'{name} title') diff --git a/tools/test_mace_parity.py b/tools/test_mace_parity.py index 123f19693..578e9e6ee 100644 --- a/tools/test_mace_parity.py +++ b/tools/test_mace_parity.py @@ -170,7 +170,7 @@ def main(): d.run(['cargo', 'build', '-q', '--release', '-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() / 'release/horizon-client'), '--resources', str(resources)], cwd=work, - env={**os.environ, 'HORIZON_DISPENSE_FIXTURE': f'{work / "cases.jsonl"},{work / "rust.txt"}', 'HORIZON_NO_AUDIO': '1'}) + env=lab.game_env(HORIZON_DISPENSE_FIXTURE=f'{work / "cases.jsonl"},{work / "rust.txt"}')) 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) diff --git a/tools/test_native_client.py b/tools/test_native_client.py index 73e6f3223..552ea8c42 100644 --- a/tools/test_native_client.py +++ b/tools/test_native_client.py @@ -71,7 +71,7 @@ def main(): str(executable), '--radius', '1', '--frames', '30', '--smoke-report', str(report), '--timeout-seconds', '60'] with (root / 'startup-smoke.log').open('w') as log: - subprocess.run(command, cwd=lab.ROOT, env=environment, check=True, + subprocess.run(command, cwd=lab.ROOT, env=lab.game_env(environment), check=True, stdout=log, stderr=subprocess.STDOUT, timeout=120) result = json.loads(report.read_text()) if (result.get('error') is not None or result.get('presented_frames') != 30 diff --git a/tools/test_online_mode_parity.py b/tools/test_online_mode_parity.py index d430c2a68..d03838bf3 100644 --- a/tools/test_online_mode_parity.py +++ b/tools/test_online_mode_parity.py @@ -179,7 +179,7 @@ def run_client(binary, build, port, label, token, discovery): shutil.rmtree(directory, ignore_errors=True) directory.mkdir() report = directory / 'report.json' - env = dict(os.environ, HORIZON_NO_AUDIO='1', HORIZON_NET_REPORT=str(report), HORIZON_API_DISCOVERY=discovery) + env = lab.game_env(HORIZON_NET_REPORT=report, HORIZON_API_DISCOVERY=discovery) command = [binary, '--resources', lab.RESOURCES, '--render-distance', 2, '--server', f'127.0.0.1:{port}', '--name', PLAYER] if token: @@ -246,7 +246,7 @@ def run_side(label, command, binary, build, services, discovery): props = dict(PROPERTIES, **{'server-port': str(port)}) (directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items())) (directory / 'eula.txt').write_text('eula=true\n') - os.environ.update(HORIZON_NO_AUDIO='1', HORIZON_API_DISCOVERY=discovery) + os.environ.update(lab.game_env(HORIZON_API_DISCOVERY=discovery)) server = Server(command, directory, f'{label}-online') checks = [] starts = [] diff --git a/tools/test_save_roundtrip_parity.py b/tools/test_save_roundtrip_parity.py index bd8b07a29..732429881 100644 --- a/tools/test_save_roundtrip_parity.py +++ b/tools/test_save_roundtrip_parity.py @@ -330,7 +330,7 @@ def entity_uuid_text(i): def vscreen_env(): text = subprocess.run(['vscreen', 'env'], capture_output=True, text=True, check=True).stdout - env = dict(os.environ) + env = lab.game_env() env.pop('DISPLAY', None) for match in re.finditer(r'(\w+)=(\S+)', text.split('unset')[0]): env[match.group(1)] = match.group(2) @@ -346,7 +346,7 @@ def horizon_resave(source, destination, bin_dir): ctl = [str(bin_dir / 'horizon-ctl'), '--socket', str(socket)] extra = [] if sys.platform == 'darwin': - env = dict(os.environ, HORIZON_NO_AUDIO='1') + env = lab.game_env() extra = ['--offscreen'] else: alsa = destination / 'null-asound.conf' diff --git a/tools/test_sculk_parity.py b/tools/test_sculk_parity.py index b9c7417a5..ae2c1c6aa 100644 --- a/tools/test_sculk_parity.py +++ b/tools/test_sculk_parity.py @@ -339,7 +339,7 @@ def main(): oracle_harness.run_logged(['cargo', 'build', '--locked', '--release', '-p', 'horizon-client', '--bin', 'horizon-client'], build, 'compile-rust') oracle_harness.run_logged([lab.cargo_target() / 'release/horizon-client', '--resources', resources], build, 'horizon', - env={**os.environ, 'HORIZON_SCULK_FIXTURE': f'{inputs},{build / "horizon.txt"}', 'HORIZON_NO_AUDIO': '1'}) + env=lab.game_env(HORIZON_SCULK_FIXTURE=f'{inputs},{build / "horizon.txt"}')) if args.java_only: return names = {s[0] for _, s in chosen} diff --git a/tools/test_secure_chat_parity.py b/tools/test_secure_chat_parity.py index c6c76a892..b9062568a 100644 --- a/tools/test_secure_chat_parity.py +++ b/tools/test_secure_chat_parity.py @@ -264,7 +264,7 @@ def main(): oh.run_logged([*java, 'keys', build], build, 'keys') services = Services(json.loads((build / 'publickeys.json').read_text())) discovery = services.url + '/minecraft/client' - os.environ.update(HORIZON_NO_AUDIO='1', HORIZON_API_DISCOVERY=discovery) + os.environ.update(lab.game_env(HORIZON_API_DISCOVERY=discovery)) servers = { 'vanilla': ['java', '-Xmx2g', '-Duser.timezone=UTC', f'-Dminecraft.api.discovery.host={discovery}', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui'], diff --git a/tools/test_use_item_on_parity.py b/tools/test_use_item_on_parity.py index d6e5f899f..89b463116 100644 --- a/tools/test_use_item_on_parity.py +++ b/tools/test_use_item_on_parity.py @@ -375,7 +375,7 @@ def main(): d.run(['cargo', 'build', '-q', '--release', '-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() / 'release/horizon-client'), '--resources', str(resources)], cwd=work, - env={**os.environ, 'HORIZON_DISPENSE_FIXTURE': f'{work / "cases.jsonl"},{work / "rust.txt"}', 'HORIZON_NO_AUDIO': '1'}) + env=lab.game_env(HORIZON_DISPENSE_FIXTURE=f'{work / "cases.jsonl"},{work / "rust.txt"}')) 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) diff --git a/tools/test_world_reopen_parity.py b/tools/test_world_reopen_parity.py index 381927013..4ef9a5e87 100644 --- a/tools/test_world_reopen_parity.py +++ b/tools/test_world_reopen_parity.py @@ -24,6 +24,9 @@ import sys import tempfile from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import lab # noqa: E402 + ROOT = Path(__file__).resolve().parent.parent RESOURCES = ROOT / '.local' / 'horizon-client' / '26.3' RUN = ROOT / '.local' / 'world-reopen' @@ -32,7 +35,7 @@ CYCLES = 3 def game_env(): """macOS renders offscreen; Linux uses the virtual screen (as test_save_roundtrip_parity).""" - env = dict(os.environ, HORIZON_NO_AUDIO='1') + env = lab.game_env() if sys.platform == 'darwin': return env, ['--offscreen'] subprocess.run(['vscreen', 'up'], check=False, capture_output=True) diff --git a/tools/test_world_spawn_parity.py b/tools/test_world_spawn_parity.py index 5daa38a45..00c390005 100644 --- a/tools/test_world_spawn_parity.py +++ b/tools/test_world_spawn_parity.py @@ -140,7 +140,7 @@ def main(): # One worker thread: the chunks decorated in a fixed order (see above). vanilla_server = ['java', '-Xmx2g', '-Dmax.bg.threads=1', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui'] horizon_server = [binary, '--resources', RESOURCES, '--dedicated', '--nogui'] - os.environ['HORIZON_NO_AUDIO'] = '1' + os.environ.update(lab.game_env()) def one(seed): t0 = time.time()