mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-01 15:43:45 -06:00
PCSX2 writes its NVRAM and MEC files beside the BIOS and reported an error on the read-only folder, and both emulators opened an error dialog over the screen for want of a sound device. The BIOS folder is now writable and staged again for each repetition, and both use the null audio backend.
707 lines
33 KiB
Python
707 lines
33 KiB
Python
#!/usr/bin/env python3
|
|
"""Run a platform's standalone emulator on a game and record what it did.
|
|
|
|
The libretro counterpart is core_probe.py: this script records the same way,
|
|
in the same measurements/<emulator>.jsonl, for the release binaries the
|
|
platforms install beside RetroArch.
|
|
|
|
resolve the release asset a platform's installer picks, by its own rule
|
|
fetch download it into .cache/standalone/, by sha256
|
|
run one execution, printed as JSON
|
|
check the same execution under several variants of one BIOS file
|
|
|
|
The binary is the asset the platform downloads, unpacked from its AppImage
|
|
without running it. Each repetition starts a private Xvfb, runs the emulator
|
|
in core_probe's allowlist sandbox under strace, and samples the screen at a
|
|
fixed interval from the X server's framebuffer file. The BIOS files are
|
|
staged in a directory the emulator's settings name as its BIOS folder, the
|
|
way the platform configures it, and the files it opened there come from
|
|
strace.
|
|
|
|
The verdict of a repetition is one of boots, bios_error, crash, hang or
|
|
unknown, by the oracle's rules: a reference screen reached, an error string
|
|
of the emulator in its log, the process dying, a uniform screen to the end.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import contextlib
|
|
import datetime as dt
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import urllib.request
|
|
from collections.abc import Sequence
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import IO
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
import core_probe as cp
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
CACHE = ROOT / ".cache" / "standalone"
|
|
DEFAULT_WORK_DIR = ROOT / "tmp" / "standalone_probe"
|
|
RECORD_VERSION = 4
|
|
SCREEN = (1280, 720)
|
|
OBSERVABLES = ("verdict", "screens", "frame_hashes", "uniform_frames")
|
|
SCRIPT = "scripts/standalone_probe.py"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# What each emulator is, how the platform installs it, how it is driven
|
|
# --------------------------------------------------------------------------
|
|
|
|
@dataclass(frozen=True)
|
|
class Emulator:
|
|
"""A standalone emulator as one platform installs it.
|
|
|
|
`asset_*` reproduce the installer's choice: EmuDeck's getReleaseURLGH
|
|
takes the first asset, over GitHub's release list in its own order
|
|
(pre-releases included), whose name contains `asset_contains` and ends
|
|
with `asset_endswith`.
|
|
"""
|
|
|
|
name: str
|
|
platform: str
|
|
installer_ref: str
|
|
repo: str
|
|
asset_endswith: str
|
|
asset_contains: str
|
|
binary: str
|
|
settings_file: str
|
|
error_patterns: tuple[str, ...]
|
|
settings: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
bios_setting: tuple[str, str] = ("", "")
|
|
args: tuple[str, ...] = ()
|
|
|
|
|
|
EMULATORS: dict[str, Emulator] = {
|
|
"duckstation": Emulator(
|
|
name="duckstation",
|
|
platform="emudeck",
|
|
installer_ref="dragoonDorise/EmuDeck functions/EmuScripts/emuDeckDuckStation.sh "
|
|
"(getReleaseURLGH \"stenzek/duckstation\" \"AppImage\" \"x64.\")",
|
|
repo="stenzek/duckstation",
|
|
asset_endswith="AppImage",
|
|
asset_contains="x64.",
|
|
binary="usr/bin/duckstation-qt",
|
|
# Core::SetDataRoot (src/core/core.cpp): $XDG_CONFIG_HOME/duckstation on Linux.
|
|
settings_file="data/duckstation/settings.ini",
|
|
bios_setting=("BIOS", "SearchDirectory"),
|
|
settings={
|
|
"Main": {"SetupWizardIncomplete": "false", "ConfirmPowerOff": "false",
|
|
"StartFullscreen": "true"},
|
|
"AutoUpdater": {"CheckAtStartup": "false"},
|
|
"GPU": {"Renderer": "Software"},
|
|
# No sound device in the sandbox: the null backend, not an error dialog.
|
|
"Audio": {"Backend": "Null"},
|
|
"Logging": {"LogLevel": "Info", "LogToConsole": "true", "LogToFile": "false"},
|
|
"Console": {"Region": "Auto"},
|
|
},
|
|
args=("-batch", "-nogui", "-fullscreen", "--"),
|
|
# Strings of src/core/bios.cpp and src/core/system.cpp, as the
|
|
# release binary carries them.
|
|
error_patterns=("No BIOS image found for", "No BIOS images found",
|
|
"No BIOS images were found", "Failed to open BIOS"),
|
|
),
|
|
"pcsx2": Emulator(
|
|
name="pcsx2",
|
|
platform="emudeck",
|
|
installer_ref="dragoonDorise/EmuDeck functions/EmuScripts/emuDeckPCSX2QT.sh "
|
|
"(getReleaseURLGH \"PCSX2/pcsx2\" \"Qt.AppImage\" \"\")",
|
|
repo="PCSX2/pcsx2",
|
|
asset_endswith="Qt.AppImage",
|
|
asset_contains="",
|
|
binary="usr/bin/pcsx2-qt",
|
|
# EmuFolders::SetDataDirectory (pcsx2/Pcsx2Config.cpp): $XDG_CONFIG_HOME/PCSX2 on Linux,
|
|
# its settings in inis/.
|
|
settings_file="data/PCSX2/inis/PCSX2.ini",
|
|
bios_setting=("Folders", "Bios"),
|
|
settings={
|
|
# SETTINGS_VERSION of pcsx2/VMManager.cpp: another value resets every setting.
|
|
"UI": {"SettingsVersion": "1", "SetupWizardIncomplete": "false", "ConfirmShutdown": "false",
|
|
"StartFullscreen": "true"},
|
|
"AutoUpdater": {"CheckAtStartup": "false"},
|
|
# GSRendererType::SW
|
|
"EmuCore/GS": {"Renderer": "13"},
|
|
# No sound device in the sandbox: the null backend, not an error dialog
|
|
# over the screen.
|
|
"SPU2/Output": {"Backend": "Null"},
|
|
"Logging": {"EnableSystemConsole": "true", "EnableVerbose": "false"},
|
|
},
|
|
args=("-batch", "-nogui", "-fullscreen", "-earlyconsolelog", "--"),
|
|
error_patterns=("Unable to auto locate a BIOS image", "BIOS image has not been selected",
|
|
"could not be read (permission denied?)"),
|
|
),
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Pure helpers
|
|
# --------------------------------------------------------------------------
|
|
|
|
def select_asset(releases: Sequence[dict], endswith: str, contains: str) -> dict | None:
|
|
"""The asset EmuDeck's getReleaseURLGH returns: the first, over the
|
|
releases in the API's order, whose name contains and ends as asked."""
|
|
for release in releases:
|
|
for asset in release.get("assets") or ():
|
|
name = asset.get("name", "")
|
|
if contains in name and name.endswith(endswith):
|
|
return {"tag": release.get("tag_name"), "name": name,
|
|
"url": asset.get("browser_download_url"),
|
|
"prerelease": bool(release.get("prerelease"))}
|
|
return None
|
|
|
|
|
|
def appimage_offset(head: bytes) -> int:
|
|
"""Where an AppImage's squashfs starts: the end of its ELF runtime,
|
|
read from the section header table, as the runtime computes it."""
|
|
if head[:4] != b"\x7fELF":
|
|
raise cp.ProbeError("not an ELF file, not an AppImage")
|
|
if head[4] == 2:
|
|
shoff = struct.unpack_from("<Q", head, 0x28)[0]
|
|
shentsize, shnum = struct.unpack_from("<HH", head, 0x3A)
|
|
else:
|
|
shoff = struct.unpack_from("<I", head, 0x20)[0]
|
|
shentsize, shnum = struct.unpack_from("<HH", head, 0x2E)
|
|
return shoff + shentsize * shnum
|
|
|
|
|
|
def ini_text(sections: dict[str, dict[str, str]]) -> str:
|
|
out = []
|
|
for section, values in sections.items():
|
|
out.append(f"[{section}]")
|
|
out.extend(f"{key} = {value}" for key, value in values.items())
|
|
out.append("")
|
|
return "\n".join(out)
|
|
|
|
|
|
def emulator_settings(emu: Emulator, bios_dir: Path, overrides: Sequence[tuple[str, str]]
|
|
) -> dict[str, dict[str, str]]:
|
|
"""The settings written before a run: the emulator's own, the BIOS
|
|
folder, then each SECTION.KEY=VALUE override."""
|
|
sections = {name: dict(values) for name, values in emu.settings.items()}
|
|
section, key = emu.bios_setting
|
|
sections.setdefault(section, {})[key] = str(bios_dir)
|
|
for name, value in overrides:
|
|
sec, _, k = name.rpartition(".")
|
|
if not sec or not k:
|
|
raise cp.ProbeError(f"--setting {name}: SECTION.KEY expected")
|
|
sections.setdefault(sec, {})[k] = value
|
|
return sections
|
|
|
|
|
|
def parse_xwd(data: bytes) -> tuple[int, int, bytes]:
|
|
"""Width, height and RGB pixels of an XWD image, the file Xvfb keeps
|
|
its screen in: 32 bits per pixel, least significant byte first."""
|
|
if len(data) < 100:
|
|
raise cp.ProbeError("framebuffer file shorter than an XWD header")
|
|
fields = struct.unpack_from(">25I", data, 0)
|
|
header_size, version = fields[0], fields[1]
|
|
width, height = fields[4], fields[5]
|
|
byte_order, bits_per_pixel, bytes_per_line, ncolors = fields[7], fields[11], fields[12], fields[19]
|
|
if version != 7 or bits_per_pixel != 32 or byte_order != 0:
|
|
raise cp.ProbeError(f"XWD version {version}, {bits_per_pixel} bpp, byte order "
|
|
f"{byte_order}: only version 7, 32 bpp, LSB first are read")
|
|
start = header_size + ncolors * 12
|
|
rows = [data[start + y * bytes_per_line:start + y * bytes_per_line + width * 4] for y in range(height)]
|
|
packed = b"".join(rows)
|
|
if len(packed) != width * height * 4:
|
|
raise cp.ProbeError("framebuffer file shorter than its header says")
|
|
return width, height, cp.to_rgb(1, width, height, packed)
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Release binaries
|
|
# --------------------------------------------------------------------------
|
|
|
|
def github_releases(repo: str) -> list[dict]:
|
|
from scraper.base_scraper import github_headers # noqa: PLC0415
|
|
|
|
request = urllib.request.Request(f"https://api.github.com/repos/{repo}/releases",
|
|
headers=github_headers())
|
|
with urllib.request.urlopen(request, timeout=60) as response:
|
|
return json.load(response)
|
|
|
|
|
|
def fetch(emu: Emulator, cache: Path = CACHE) -> dict:
|
|
"""Download the asset the platform picks, unpack it, and describe it."""
|
|
asset = select_asset(github_releases(emu.repo), emu.asset_endswith, emu.asset_contains)
|
|
if not asset:
|
|
raise cp.ProbeError(f"{emu.repo}: no release asset matches the {emu.platform} rule")
|
|
folder = cache / emu.name
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
partial = folder / f"{asset['name']}.part"
|
|
with urllib.request.urlopen(asset["url"], timeout=600) as response, partial.open("wb") as out:
|
|
shutil.copyfileobj(response, out)
|
|
sha256 = cp.file_digest(partial, "sha256")
|
|
target = folder / sha256 / asset["name"]
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
partial.replace(target)
|
|
unpack(target)
|
|
meta = {**asset, "sha256": sha256, "repo": emu.repo, "platform": emu.platform,
|
|
"installer_ref": emu.installer_ref, "fetched_at": now_iso()}
|
|
(target.parent / "asset.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
|
return meta
|
|
|
|
|
|
def unpack(appimage: Path) -> Path:
|
|
"""The AppImage's tree, unpacked with unsquashfs: the runtime is never run."""
|
|
tree = appimage.parent / "tree"
|
|
if tree.is_dir():
|
|
return tree
|
|
unsquashfs = shutil.which("unsquashfs")
|
|
if not unsquashfs:
|
|
raise cp.ProbeError("unsquashfs not found (squashfs-tools)")
|
|
with appimage.open("rb") as handle:
|
|
offset = appimage_offset(handle.read(64))
|
|
scratch = appimage.parent / "tree.part"
|
|
shutil.rmtree(scratch, ignore_errors=True)
|
|
subprocess.run([unsquashfs, "-q", "-n", "-o", str(offset), "-d", str(scratch), str(appimage)],
|
|
check=True, stdout=subprocess.DEVNULL)
|
|
scratch.replace(tree)
|
|
return tree
|
|
|
|
|
|
def installed(emu: Emulator, sha256: str | None, cache: Path = CACHE) -> tuple[dict, Path]:
|
|
"""The fetched asset to run: the one named by sha256, else the newest."""
|
|
folder = cache / emu.name
|
|
metas = sorted(folder.glob("*/asset.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
for path in metas:
|
|
meta = json.loads(path.read_text(encoding="utf-8"))
|
|
if sha256 in (None, meta["sha256"]):
|
|
return meta, unpack(path.parent / meta["name"])
|
|
raise cp.ProbeError(f"{emu.name}: no fetched asset{' ' + sha256 if sha256 else ''}, "
|
|
f"run `standalone_probe.py fetch {emu.name}`")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# One repetition
|
|
# --------------------------------------------------------------------------
|
|
|
|
def now_iso() -> str:
|
|
return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat()
|
|
|
|
|
|
# The X server runs inside the sandbox, on the sandbox's own /tmp: the
|
|
# display is private to the run, and the host's /tmp/.X11-unix (read-only
|
|
# under WSLg) is never touched. Its fbdir option keeps the screen as an XWD
|
|
# file in the run's scratch folder, which the probe reads from outside.
|
|
DISPLAY_NUMBER = 1
|
|
XVFB_WRAPPER = (
|
|
f'Xvfb ":{DISPLAY_NUMBER}" -screen 0 {SCREEN[0]}x{SCREEN[1]}x24 -nolisten tcp -fbdir "$1" '
|
|
'-noreset >/dev/null 2>&1 &\n'
|
|
'i=0\n'
|
|
f'while [ ! -S /tmp/.X11-unix/X{DISPLAY_NUMBER} ]; do\n'
|
|
' i=$((i + 1)); [ "$i" -gt 400 ] && exit 97; sleep 0.05\n'
|
|
'done\n'
|
|
'shift\n'
|
|
'exec "$@"\n'
|
|
)
|
|
|
|
|
|
def with_display(argv: list[str], fbdir: Path) -> list[str]:
|
|
"""The emulator's command, started once the sandbox's Xvfb listens."""
|
|
return ["/bin/sh", "-c", XVFB_WRAPPER, "xvfb", str(fbdir), *argv]
|
|
|
|
|
|
def stop(proc: subprocess.Popen) -> None:
|
|
if proc.poll() is None:
|
|
with contextlib.suppress(ProcessLookupError):
|
|
os.killpg(proc.pid, signal.SIGKILL)
|
|
proc.wait()
|
|
|
|
|
|
ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
|
|
|
|
|
def read_lines(stream: IO[bytes], out: list[dict], samples: list) -> None:
|
|
"""The emulator's console, line by line, each with the sample count
|
|
reached when it was written, colour escapes removed."""
|
|
for raw in iter(stream.readline, b""):
|
|
text = ANSI.sub("", raw.decode("utf-8", errors="replace")).rstrip()
|
|
if text:
|
|
out.append({"frame": len(samples), "level": "info", "text": text})
|
|
|
|
|
|
def run_once(args: argparse.Namespace, emu: Emulator, tree: Path, system_dir: Path, scratch: Path,
|
|
png_dir: Path | None) -> dict:
|
|
home = scratch / "home"
|
|
data = scratch / "data"
|
|
for folder in (home, data):
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
settings = emulator_settings(emu, system_dir.resolve(), args.setting)
|
|
settings_path = scratch / emu.settings_file
|
|
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
settings_path.write_text(ini_text(settings), encoding="utf-8")
|
|
content = Path(args.content).resolve()
|
|
argv = [str(tree / emu.binary)] + [a.format(data=data.resolve()) for a in emu.args] + [str(content)]
|
|
trace = scratch / "strace.txt"
|
|
strace = shutil.which("strace") if not args.no_trace else None
|
|
if strace:
|
|
argv = [strace, "-f", "-qq", "-s", "4096", "-e", "trace=openat,open", "-o", str(trace)] + argv
|
|
samples: list[tuple[float, str, bool]] = []
|
|
logs: list[dict] = []
|
|
if not shutil.which("Xvfb"):
|
|
raise cp.ProbeError("Xvfb not found")
|
|
fbdir = scratch / "fb"
|
|
fbdir.mkdir(parents=True, exist_ok=True)
|
|
screen_file = fbdir / "Xvfb_screen0"
|
|
env = {"HOME": str(home.resolve()), "XDG_CONFIG_HOME": str(data.resolve()),
|
|
"XDG_DATA_HOME": str(data.resolve()), "XDG_CACHE_HOME": str((home / ".cache").resolve()),
|
|
"DISPLAY": f":{DISPLAY_NUMBER}", "QT_QPA_PLATFORM": "xcb", "LIBGL_ALWAYS_SOFTWARE": "1",
|
|
"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8"}
|
|
# The BIOS folder is writable, as the platform's is: PCSX2 creates its
|
|
# NVRAM and MEC files there.
|
|
prefix, isolation = cp.sandbox_argv([scratch, system_dir], [tree, content.parent])
|
|
if args.require_sandbox and isolation != cp.SANDBOX:
|
|
raise cp.ProbeError(f"refused to run the emulator outside a sandbox: {isolation}")
|
|
proc = subprocess.Popen(prefix + with_display(argv, fbdir.resolve()), stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT, env=env, start_new_session=True)
|
|
reader = threading.Thread(target=read_lines, args=(proc.stdout, logs, samples), daemon=True)
|
|
reader.start()
|
|
try:
|
|
deadline = time.monotonic() + 30
|
|
while not screen_file.exists():
|
|
if proc.poll() is not None or time.monotonic() > deadline:
|
|
raise cp.ProbeError(f"Xvfb did not start in the sandbox (exit {proc.poll()}): "
|
|
f"{' / '.join(line['text'] for line in logs[-5:])}")
|
|
time.sleep(0.05)
|
|
started = time.monotonic()
|
|
ended = None
|
|
for index in range(args.samples):
|
|
target = started + (index + 1) * args.interval
|
|
while time.monotonic() < target and proc.poll() is None:
|
|
time.sleep(min(0.05, max(0.0, target - time.monotonic())))
|
|
if proc.poll() is not None:
|
|
code = proc.returncode
|
|
ended = {"status": code if code >= 0 else None,
|
|
"signal": signal.Signals(-code).name if code < 0 else None}
|
|
break
|
|
width, height, rgb = parse_xwd(screen_file.read_bytes())
|
|
sha1 = hashlib.sha1(rgb).hexdigest()
|
|
samples.append((round(time.monotonic() - started, 3), sha1, cp.uniform_frame(rgb, 3)))
|
|
if png_dir and not any(p.name.endswith(f"{sha1[:12]}.png") for p in png_dir.iterdir()):
|
|
(png_dir / f"{index:04d}-{sha1[:12]}.png").write_bytes(cp.png_bytes(width, height, rgb))
|
|
wall = time.monotonic() - started
|
|
finally:
|
|
stop(proc)
|
|
reader.join(5)
|
|
places = [(str(system_dir.resolve()), "<bios>"), (str(scratch.resolve()), "<scratch>"),
|
|
(str(content.parent), "<content>"), (str(tree), "<emulator>"), (str(ROOT), "<repo>")]
|
|
for line in logs:
|
|
line["text"] = cp.host_paths_hidden(line["text"], places)
|
|
rep: dict = {
|
|
"loaded": ended is None,
|
|
"library_name": emu.name,
|
|
"frames": max(len(samples), 1),
|
|
"wall_s": round(wall, 3),
|
|
"sample_interval_s": args.interval,
|
|
"sample_times_s": [t for t, _, _ in samples],
|
|
# The shapes core_probe gives them: [sample, sha1] pairs, and the
|
|
# digests of the samples that are one colour.
|
|
"observables": {
|
|
"frame_hashes": [[index, h] for index, (_, h, _) in enumerate(samples)],
|
|
"uniform_frames": sorted({h for _, h, u in samples if u}),
|
|
"screens": sorted({h for _, h, u in samples if not u}),
|
|
},
|
|
"logs": logs,
|
|
"isolation": isolation,
|
|
}
|
|
if ended:
|
|
rep["exit"] = ended
|
|
if strace and trace.is_file():
|
|
opened, probed = cp.files_under(cp.parse_strace(trace.read_text(encoding="utf-8", errors="replace")),
|
|
system_dir)
|
|
rep["files_opened"] = [{"path": rel, "sha1_after": cp.file_digest(system_dir / rel)}
|
|
for rel in opened if (system_dir / rel).is_file()]
|
|
rep["files_probed_absent"] = probed
|
|
rep["tracing"] = "strace"
|
|
else:
|
|
rep["files_opened"] = None
|
|
rep["files_probed_absent"] = None
|
|
rep["tracing"] = "disabled: --no-trace" if args.no_trace else "unavailable: strace not found"
|
|
return rep
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Variants and the record
|
|
# --------------------------------------------------------------------------
|
|
|
|
def run_variant(args: argparse.Namespace, emu: Emulator, tree: Path, oracle: dict,
|
|
base: dict[str, Path], variant: cp.Variant, work: Path, repetitions: int) -> dict:
|
|
reps: list[dict] = []
|
|
flags: list[str] = []
|
|
for index in range(repetitions):
|
|
# A fresh BIOS folder each time: PCSX2 writes its NVRAM and MEC files
|
|
# beside the BIOS, as on a user's folder, and a repetition must not
|
|
# start from what the last one wrote.
|
|
system_dir = work / f"bios{index}"
|
|
staged = cp.stage_system_dir(base, variant, system_dir)
|
|
png_dir = None
|
|
if args.png_dir and index == 0:
|
|
png_dir = Path(args.png_dir) / variant.label
|
|
png_dir.mkdir(parents=True, exist_ok=True)
|
|
rep = run_once(args, emu, tree, system_dir, work / f"rep{index}", png_dir)
|
|
rep["verdict"], rep["verdict_evidence"] = cp.judge(rep, oracle)
|
|
rep["observables"]["verdict"] = rep["verdict"]
|
|
for entry in rep.get("files_opened") or []:
|
|
expected = staged.get(entry["path"])
|
|
entry["sha1_staged"] = expected
|
|
if expected is not None and expected != entry["sha1_after"]:
|
|
flags.append(f"repetition {index}: {entry['path']} sha1 changed during the run")
|
|
reps.append(rep)
|
|
verdict, evidence = cp.variant_verdict(reps)
|
|
return {"label": variant.label, "system_files": dict(sorted(staged.items())), "repetitions": reps,
|
|
"flags": flags, "verdict": verdict, "verdict_evidence": evidence}
|
|
|
|
|
|
def frontend_identity() -> dict:
|
|
script = Path(__file__).resolve()
|
|
commit = dirty = None
|
|
try:
|
|
commit = subprocess.run(["git", "rev-parse", "HEAD"], cwd=ROOT, capture_output=True, text=True,
|
|
check=True).stdout.strip()
|
|
dirty = bool(subprocess.run(["git", "status", "--porcelain", "--", str(script)], cwd=ROOT,
|
|
capture_output=True, text=True, check=True).stdout.strip())
|
|
except (OSError, subprocess.CalledProcessError) as exc:
|
|
print(f"WARNING: frontend commit unknown, git failed: {exc}", file=sys.stderr)
|
|
return {"script": SCRIPT, "commit": commit, "script_modified": dirty,
|
|
"script_sha256": cp.file_digest(script, "sha256")}
|
|
|
|
|
|
def emulator_oracle(emu: Emulator, path: Path, content_sha1: str) -> dict:
|
|
"""The oracle core_probe reads (tests/campaign/oracle.yml), looked up by
|
|
the content's sha1 and the emulator's name, with the emulator's own
|
|
error strings of this script added as patterns. Without the file the
|
|
oracle names this script, which then holds every pattern."""
|
|
data, identity = cp.load_oracle(path)
|
|
entries = cp.oracle_for(data, content_sha1, emu.name)
|
|
own = [re.escape(text) for text in emu.error_patterns]
|
|
entries["error_patterns"] = list(dict.fromkeys(entries["error_patterns"] + own))
|
|
if identity is None:
|
|
identity = {"file": SCRIPT, "sha256": cp.file_digest(Path(__file__).resolve(), "sha256")}
|
|
return {**identity, **entries}
|
|
|
|
|
|
def check_result(variants: list[dict], observable: str) -> dict:
|
|
"""core_probe's comparison, with each variant's verdict beside it."""
|
|
return {**cp.check_result(variants, observable),
|
|
"verdicts": {v["label"]: v["verdict"] for v in variants}}
|
|
|
|
|
|
def probe(args: argparse.Namespace) -> int:
|
|
emu = EMULATORS[args.emulator]
|
|
problems = validate(args)
|
|
if problems:
|
|
for problem in problems:
|
|
print(f"ERROR: {problem}", file=sys.stderr)
|
|
return 2
|
|
meta, tree = installed(emu, args.sha256)
|
|
max_load = args.max_load if args.max_load is not None else (os.cpu_count() or 1) / 4
|
|
load_before = cp.load_average()
|
|
if args.record and load_before and load_before[0] > max_load:
|
|
print(f"ERROR: load average {load_before[0]} exceeds {max_load}: the machine is busy, "
|
|
f"nothing recorded", file=sys.stderr)
|
|
return 3
|
|
oracle = emulator_oracle(emu, Path(args.oracle) if args.oracle else cp.DEFAULT_ORACLE,
|
|
cp.file_digest(Path(args.content)))
|
|
repetitions = 1 if args.mode == "run" else args.repeat
|
|
base = {dest: Path(src) for dest, src in args.bios_file}
|
|
if args.mode == "check":
|
|
variants_in = []
|
|
seen: set[str] = set()
|
|
for dest, src in args.vary:
|
|
label = Path(src).name if src else f"{dest}-absent"
|
|
while label in seen:
|
|
label += "+"
|
|
seen.add(label)
|
|
variants_in.append(cp.Variant(label, {dest: Path(src) if src else None}))
|
|
else:
|
|
variants_in = [cp.Variant("default")]
|
|
work = Path(tempfile.mkdtemp(prefix=f"{args.mode}-", dir=cp._ensure(Path(args.work_dir))))
|
|
try:
|
|
variants = [run_variant(args, emu, tree, oracle, base, variant, work / f"v{i}", repetitions)
|
|
for i, variant in enumerate(variants_in)]
|
|
except (cp.ProbeError, OSError, subprocess.SubprocessError) as exc:
|
|
print(f"ERROR: {exc}", file=sys.stderr)
|
|
return 1
|
|
finally:
|
|
shutil.rmtree(work, ignore_errors=True)
|
|
load_after = cp.load_average()
|
|
record: dict = {
|
|
"record_version": RECORD_VERSION,
|
|
"emulator": emu.name,
|
|
"subject": None,
|
|
"supersedes": args.supersedes,
|
|
"kind": args.mode,
|
|
"recorded_at": now_iso(),
|
|
"core": {"file": meta["name"], "sha256": meta["sha256"], "repo": f"https://github.com/{meta['repo']}",
|
|
"commit": None, "build_command": None, "compiler": None, "library_name": emu.name,
|
|
"library_version": meta["tag"], "artifact": meta["url"]},
|
|
"frontend": frontend_identity(),
|
|
"host": {**cp.host_identity(), "load_before": load_before, "load_after": load_after,
|
|
"max_load": max_load},
|
|
"inputs": {
|
|
"content": cp.content_identity(Path(args.content), args.content_label),
|
|
"options": {f"{s}.{k}": v for s, values in
|
|
emulator_settings(emu, Path("<bios>"), args.setting).items() for k, v in values.items()},
|
|
"env": {},
|
|
"frames": args.samples,
|
|
"warmup": 0,
|
|
"repetitions": repetitions,
|
|
"presses": [],
|
|
"observables": list(OBSERVABLES),
|
|
"frame_hash_every": 1,
|
|
"joypad_ports": 0,
|
|
"oracle": oracle,
|
|
},
|
|
"variants": variants,
|
|
"flags": [f"{v['label']}: {flag}" for v in variants for flag in v["flags"]],
|
|
}
|
|
if load_before and load_after and max(load_before[0], load_after[0]) > max_load:
|
|
record["flags"].append(f"load average reached {max(load_before[0], load_after[0])} "
|
|
f"(threshold {max_load})")
|
|
if args.mode == "check":
|
|
record["check"] = {"claim": args.claim, "method": args.method, "observable": args.observable,
|
|
"variable": args.vary[0][0], "result": check_result(variants, args.observable)}
|
|
from measurement_records import canonical, sha256_hex, slugify
|
|
|
|
if args.subject:
|
|
record["subject"] = args.subject
|
|
elif args.mode == "check":
|
|
digest = sha256_hex(canonical([args.claim, args.method, args.observable, args.vary[0][0]]))[:8]
|
|
record["subject"] = f"{slugify(args.claim)}-{digest}"
|
|
else:
|
|
digest = sha256_hex(canonical([meta["sha256"], record["inputs"]["content"],
|
|
record["inputs"]["options"], args.bios_file]))[:8]
|
|
record["subject"] = f"run-{slugify(args.content_label or Path(args.content).name, 30)}-{digest}"
|
|
if args.record:
|
|
try:
|
|
lines, record = cp.write_record(Path(args.record), record)
|
|
except cp.ProbeError as exc:
|
|
print(f"ERROR: {exc}", file=sys.stderr)
|
|
return 2
|
|
print(f"recorded {lines}", file=sys.stderr)
|
|
print(json.dumps(record, indent=2))
|
|
return 0
|
|
|
|
|
|
def validate(args: argparse.Namespace) -> list[str]:
|
|
problems = []
|
|
if not Path(args.content).is_file():
|
|
problems.append(f"--content {args.content}: not a file")
|
|
for dest, src in args.bios_file:
|
|
if not Path(src).is_file():
|
|
problems.append(f"--bios-file {dest}={src}: not a file")
|
|
if args.oracle and not Path(args.oracle).is_file():
|
|
problems.append(f"--oracle {args.oracle}: not a file")
|
|
if args.samples < 2 or args.interval <= 0:
|
|
problems.append("--samples must be >= 2 and --interval > 0")
|
|
if args.require_sandbox:
|
|
isolation = cp.sandbox_argv([])[1]
|
|
if isolation != cp.SANDBOX:
|
|
problems.append(f"--require-sandbox: {isolation}")
|
|
if args.record:
|
|
wrong = cp.not_a_checkout_root(Path(args.record))
|
|
if wrong:
|
|
problems.append(wrong)
|
|
if args.subject and not re.fullmatch(r"[a-z0-9]+(?:[-_][a-z0-9]+)*", args.subject):
|
|
problems.append(f"--subject {args.subject!r}: lowercase letters, digits, - and _ only")
|
|
if args.mode == "check":
|
|
if len({dest for dest, _ in args.vary}) != 1:
|
|
problems.append("--vary must name the same DEST in every occurrence")
|
|
for dest, src in args.vary:
|
|
if src and not Path(src).is_file():
|
|
problems.append(f"--vary {dest}={src}: not a file")
|
|
if args.observable not in OBSERVABLES:
|
|
problems.append(f"--observable {args.observable}: not one of {', '.join(OBSERVABLES)}")
|
|
return problems
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Command line
|
|
# --------------------------------------------------------------------------
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(allow_abbrev=False, description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
sub = parser.add_subparsers(dest="mode", required=True)
|
|
for name, text in (("resolve", "print the asset the platform's installer picks"),
|
|
("fetch", "download and unpack it into .cache/standalone/")):
|
|
p = sub.add_parser(name, help=text)
|
|
p.add_argument("emulator", choices=sorted(EMULATORS), help="the emulator, by its profile name")
|
|
common = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
|
|
common.add_argument("emulator", choices=sorted(EMULATORS), help="the emulator, by its profile name")
|
|
common.add_argument("--content", required=True, help="the game: a .cue, .iso or .chd")
|
|
common.add_argument("--content-label", help="identity of the content, its Redump or No-Intro name")
|
|
common.add_argument("--bios-file", action="append", default=[], metavar="DEST=SRC",
|
|
type=lambda t: cp.parse_assignment(t, "--bios-file"),
|
|
help="copy SRC into the staged BIOS folder as DEST")
|
|
common.add_argument("--setting", action="append", default=[], metavar="SECTION.KEY=VALUE",
|
|
type=lambda t: cp.parse_assignment(t, "--setting"),
|
|
help="an emulator setting written before the run")
|
|
common.add_argument("--sha256", help="the fetched asset to run (default: the newest fetched)")
|
|
common.add_argument("--samples", type=int, default=60, help="screen samples per repetition")
|
|
common.add_argument("--interval", type=float, default=1.0, help="seconds between two samples")
|
|
common.add_argument("--oracle", metavar="FILE",
|
|
help="the boot oracle (default: tests/campaign/oracle.yml, as core_probe)")
|
|
common.add_argument("--png-dir", help="write each distinct sampled screen of repetition 0 as PNG")
|
|
common.add_argument("--record", metavar="DIR", nargs="?", const=str(ROOT),
|
|
help="append the record to measurements/<emulator>.jsonl in the checkout DIR")
|
|
common.add_argument("--subject", help="slug naming what the record tests")
|
|
common.add_argument("--supersedes", metavar="ID", help="id of an earlier record this one corrects")
|
|
common.add_argument("--max-load", type=float, default=None,
|
|
help="refuse to record above this 1-minute load average (default: CPUs / 4)")
|
|
common.add_argument("--work-dir", default=str(DEFAULT_WORK_DIR), help="scratch directory")
|
|
common.add_argument("--no-trace", action="store_true", help="do not run under strace")
|
|
common.add_argument("--require-sandbox", action="store_true",
|
|
help="refuse to run unless bubblewrap can isolate the emulator")
|
|
sub.add_parser("run", parents=[common], help="one execution")
|
|
check = sub.add_parser("check", parents=[common], help="compare variants of one BIOS file")
|
|
check.add_argument("--vary", action="append", required=True, metavar="DEST=SRC",
|
|
type=lambda t: cp.parse_assignment(t, "--vary"),
|
|
help="one variant per occurrence; an empty SRC leaves DEST absent")
|
|
check.add_argument("--observable", default="verdict", help="compared across variants")
|
|
check.add_argument("--claim", required=True, help="the statement under test")
|
|
check.add_argument("--method", required=True, help="how the run tests it")
|
|
check.add_argument("--repeat", type=int, default=2, help="runs of each variant")
|
|
return parser
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
args = build_parser().parse_args(argv)
|
|
emu = EMULATORS[args.emulator]
|
|
if args.mode == "resolve":
|
|
asset = select_asset(github_releases(emu.repo), emu.asset_endswith, emu.asset_contains)
|
|
print(json.dumps({"emulator": emu.name, "platform": emu.platform,
|
|
"installer_ref": emu.installer_ref, "asset": asset}, indent=2))
|
|
return 0 if asset else 1
|
|
if args.mode == "fetch":
|
|
try:
|
|
print(json.dumps(fetch(emu), indent=2))
|
|
except (cp.ProbeError, OSError, subprocess.CalledProcessError) as exc:
|
|
print(f"ERROR: {exc}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
return probe(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
from common import run_cli
|
|
|
|
run_cli(main)
|