mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-01 07:33:45 -06:00
chore: merge region and coverage checks
This commit is contained in:
commit
a1d094c8b3
38 files changed
+5734
-526
No files matched your search
+361
-175
@@ -1,41 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the real-condition campaign unattended: cores, content, checks, rules.
|
||||
"""Run the real-condition campaign end to end: cores, content, checks, rules.
|
||||
|
||||
For each emulator profile in scope, the campaign takes the core (the libretro
|
||||
x86_64 Linux nightly, else a build of the profile's pinned commit), the test
|
||||
content of each of its systems (tests/campaign/content.yml, fetched through
|
||||
romfetch when it is not on disk), and runs core_probe.py:
|
||||
A maintainer starts each pass. For each emulator profile in scope, the
|
||||
campaign takes the core (the libretro x86_64 Linux nightly, else a build of
|
||||
the profile's pinned commit), the test content of each of its systems
|
||||
(tests/campaign/content.yml, fetched through romfetch when it is not on
|
||||
disk), and runs core_probe.py. The boot oracle of core_probe judges every
|
||||
run: boots, bios_error, crash, hang or unknown, against the reference frames
|
||||
and error patterns of tests/campaign/oracle.yml.
|
||||
|
||||
files every declared file the collection holds, staged, and which ones
|
||||
the core opens
|
||||
boot the content with no file against the content with the declared
|
||||
file, compared on the frames the core shows
|
||||
files every declared file the collection holds, staged: the full set,
|
||||
and which files the core opens
|
||||
none no BIOS at all
|
||||
negative per declared file of the content's region: the file alone, then
|
||||
zeros of its size, its first half and other regions' dumps
|
||||
staged under its name
|
||||
path a declared file staged where the core looked for it
|
||||
alias a declared file staged under a name the core looked for
|
||||
variants each .variants/ copy of a file the core opened
|
||||
bench the content timed, warm-up excluded, 5 repetitions
|
||||
bench with --bench only: the content timed, warm-up excluded
|
||||
|
||||
Every run is a record in measurements/<emulator>.jsonl. The fixed rule table
|
||||
(campaign_rules.py) turns records into verdicts. A verdict the table settles
|
||||
is written into the profile with the record ids as `evidence:`. Any other
|
||||
goes to measurements/findings.jsonl, where the site lists it as open. Each
|
||||
pass adds one line to measurements/campaign-runs.jsonl.
|
||||
(campaign_rules.py) reads the verdicts, never a comparison of frames. A
|
||||
verdict the table settles is written into the profile with the record ids
|
||||
as `evidence:`. Any other goes to measurements/findings.jsonl, where the
|
||||
site lists it as open. Each pass adds one line to
|
||||
measurements/campaign-runs.jsonl.
|
||||
|
||||
`references` runs the full set once per core and content with its frames
|
||||
saved as PNG, and lists the screens that stay on for several samples: the
|
||||
candidates a reviewer looks at before writing them into oracle.yml.
|
||||
|
||||
State is .cache/campaign/state.json of the main checkout: per check done,
|
||||
failed or pending with the reason, and the core sha256 and profile digest it
|
||||
ran against. A pass skips what is done unless the core build or the profile
|
||||
changed. The campaign holds the host-wide automation lock, waits for an idle
|
||||
host, stops cleanly on a busy host or a full disk, and resumes on the next
|
||||
pass. Under autorun (`campaign` job) the lock is the job's.
|
||||
failed or pending with the reason, and the core sha256, profile digest,
|
||||
oracle sha256 and record version it ran against. A pass skips what is done
|
||||
unless one of them changed. The campaign holds the host-wide automation
|
||||
lock, waits for an idle host, stops cleanly on a busy host or a full disk,
|
||||
and the next pass a maintainer starts resumes there.
|
||||
|
||||
Usage:
|
||||
python scripts/campaign.py run [--only CORE,...] [--system ID] [--budget 6h]
|
||||
python scripts/campaign.py run [--only CORE,...] [--system ID] [--budget 6h] [--bench]
|
||||
python scripts/campaign.py run --dry-run
|
||||
python scripts/campaign.py run --commit [--commit-every N]
|
||||
python scripts/campaign.py references --only CORE,... [--system ID]
|
||||
python scripts/campaign.py status [--json]
|
||||
python scripts/campaign.py findings [--all] [--json]
|
||||
python scripts/campaign.py gate
|
||||
python scripts/campaign.py install-timer [--enable]
|
||||
|
||||
install-timer is opt-in: a contributor who wants a schedule on their own
|
||||
machine installs the timer. It writes the units and enables nothing unless
|
||||
--enable is passed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -86,6 +101,7 @@ PENDING = "pending"
|
||||
CRASH_ATTEMPTS = 3
|
||||
NETWORK_DELAYS = (10, 60, 300)
|
||||
MAX_ALIAS_CANDIDATES = 6
|
||||
MAX_REGION_DUMPS = 2
|
||||
BENCH_WARMUP = 600
|
||||
BENCH_FRAMES = 1800
|
||||
BENCH_REPEAT = 5
|
||||
@@ -175,6 +191,11 @@ def region_matches(entry: dict, region: str | None) -> bool:
|
||||
return region in regions or "world" in regions
|
||||
|
||||
|
||||
def regions_of(entry: dict) -> list[str]:
|
||||
regions = entry.get("region") or []
|
||||
return [regions] if isinstance(regions, str) else list(regions)
|
||||
|
||||
|
||||
def is_bios(entry: dict) -> bool:
|
||||
return entry.get("category", "bios") == "bios" and entry.get("type") != "directory"
|
||||
|
||||
@@ -235,6 +256,8 @@ class Settings:
|
||||
bench_frames: int = BENCH_FRAMES
|
||||
romfetch: str = ""
|
||||
network: bool = True
|
||||
# Benchmarks only choose between variants: never part of a default pass.
|
||||
bench: bool = False
|
||||
delays: tuple[float, ...] = NETWORK_DELAYS
|
||||
echo: bool = True
|
||||
# Third-party code (a nightly core, a project's Makefile) runs only under
|
||||
@@ -252,6 +275,7 @@ class Paths:
|
||||
content: Path
|
||||
db: Path
|
||||
content_file: Path = CONTENT_FILE
|
||||
oracle: Path = core_probe.DEFAULT_ORACLE
|
||||
|
||||
@property
|
||||
def state(self) -> Path:
|
||||
@@ -279,9 +303,9 @@ def child_env(romfetch: bool = False) -> dict[str, str]:
|
||||
"""What a program the campaign starts inherits: autorun's allowlist,
|
||||
never a token or a password. romfetch also gets its own settings
|
||||
(ROMFETCH_*), its account staying in its config file."""
|
||||
names: list[str] = []
|
||||
names: list[str] = [core_probe.PROBE_LOCK_ENV, core_probe.PROBE_LOCK_HELD_ENV]
|
||||
if romfetch:
|
||||
names = [k for k in os.environ if k.startswith("ROMFETCH_")] + list(ROMFETCH_PASSED)
|
||||
names += [k for k in os.environ if k.startswith("ROMFETCH_")] + list(ROMFETCH_PASSED)
|
||||
return autorun.safe_env(names)
|
||||
|
||||
|
||||
@@ -470,14 +494,17 @@ def save_state(path: Path, state: dict) -> None:
|
||||
autorun.save_state(path, state)
|
||||
|
||||
|
||||
def should_run(previous: dict | None, core_sha: str, digest: str) -> bool:
|
||||
def should_run(previous: dict | None, core_sha: str, digest: str, oracle_sha: str = "") -> bool:
|
||||
"""A check runs again when it never ran, is pending, or ran against
|
||||
another core build or another version of the profile."""
|
||||
another core build, another version of the profile, another oracle, or
|
||||
before records carried a verdict."""
|
||||
if not previous:
|
||||
return True
|
||||
if previous.get("status") == PENDING:
|
||||
return True
|
||||
return previous.get("core_sha256") != core_sha or previous.get("profile_digest") != digest
|
||||
return (previous.get("core_sha256") != core_sha or previous.get("profile_digest") != digest
|
||||
or previous.get("record_version") != mr.RECORD_VERSION
|
||||
or previous.get("oracle_sha256", "") != oracle_sha)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -875,6 +902,8 @@ class Campaign:
|
||||
self.cores = Cores(self)
|
||||
self.store = ContentStore(self)
|
||||
self.content = load_content(paths.content_file)
|
||||
self.oracle_sha = file_sha256(paths.oracle) if paths.oracle.is_file() else ""
|
||||
self.oracle_data = core_probe.load_oracle(paths.oracle)[0]
|
||||
self.deadline = 0.0
|
||||
self.current: Pass | None = None
|
||||
self.dirty_at_start: set[str] = set()
|
||||
@@ -1004,6 +1033,8 @@ class Campaign:
|
||||
"--emulator", emulator, "--subject", subject,
|
||||
"--max-load", str(self.host.max_load), "--timeout", str(self.settings.probe_timeout),
|
||||
"--work-dir", str(self.paths.root / "tmp" / "core_probe")]
|
||||
if self.paths.oracle.is_file():
|
||||
argv += ["--oracle", str(self.paths.oracle)]
|
||||
if not self.settings.allow_unsandboxed:
|
||||
argv.append("--require-sandbox")
|
||||
if core.artifact:
|
||||
@@ -1025,8 +1056,14 @@ class Campaign:
|
||||
|
||||
def remember(self, emulator: str, key: str, result: ProbeResult, core: Core | None,
|
||||
digest: str) -> None:
|
||||
judged = None
|
||||
if result.record and result.record["inputs"].get("content"):
|
||||
judged = [result.record["inputs"]["content"]["sha1"], result.record["core"]["library_name"]]
|
||||
entry = {"status": result.status, "at": now_iso(), "profile_digest": digest,
|
||||
"core_sha256": core.sha256 if core else None}
|
||||
"core_sha256": core.sha256 if core else None,
|
||||
"record_version": mr.RECORD_VERSION, "oracle_sha256": self.oracle_digest(judged)}
|
||||
if judged:
|
||||
entry["oracle_key"] = judged
|
||||
if result.reason:
|
||||
entry["reason"] = result.reason
|
||||
if result.log:
|
||||
@@ -1042,6 +1079,38 @@ class Campaign:
|
||||
self.state["checks"].setdefault(emulator, {})[key] = {
|
||||
"status": PENDING, "reason": reason, "at": now_iso(), "profile_digest": digest}
|
||||
|
||||
def oracle_digest(self, key: list[str] | None) -> str:
|
||||
"""What a check's verdicts depend on in the oracle: the entries of its
|
||||
content and core, else the whole file. Another core's references
|
||||
never make a check run again."""
|
||||
if not key:
|
||||
return self.oracle_sha
|
||||
return mr.value_sha1(core_probe.oracle_for(self.oracle_data, *key))
|
||||
|
||||
def needed(self, emulator: str, key: str, core: Core, digest: str) -> bool:
|
||||
previous = self.state["checks"].get(emulator, {}).get(key)
|
||||
judged = (previous or {}).get("oracle_key")
|
||||
if previous and not judged and previous.get("record"):
|
||||
record = self.read_record(emulator, previous["record"]) or {}
|
||||
if (record.get("inputs") or {}).get("content"):
|
||||
judged = [record["inputs"]["content"]["sha1"], record["core"]["library_name"]]
|
||||
previous["oracle_key"] = judged
|
||||
# Judged against the entries the record kept, not the file it named.
|
||||
kept = {k: v for k, v in (record["inputs"].get("oracle") or {}).items()
|
||||
if k not in ("file", "sha256")}
|
||||
previous["oracle_sha256"] = mr.value_sha1({"bios_patterns": [], **kept})
|
||||
return should_run(previous, core.sha256, digest, self.oracle_digest(judged))
|
||||
|
||||
def check(self, emulator: str, key: str, core: Core, digest: str, argv: list[str],
|
||||
log_name: str) -> dict | None:
|
||||
"""The record of a check: run now when needed, else the one kept."""
|
||||
if not self.needed(emulator, key, core, digest):
|
||||
previous = self.state["checks"].get(emulator, {}).get(key) or {}
|
||||
return self.read_record(emulator, previous.get("record"))
|
||||
result = self.probe(argv, log_name)
|
||||
self.remember(emulator, key, result, core, digest)
|
||||
return result.record
|
||||
|
||||
def system_entries(self, profile: dict, system: str) -> list[tuple[int, dict]]:
|
||||
from validation import filter_files_by_mode
|
||||
|
||||
@@ -1060,84 +1129,148 @@ class Campaign:
|
||||
held.sort(key=lambda item: (item[1].get("priority", 1 << 30), item[0]))
|
||||
return held[0] if held else None
|
||||
|
||||
@staticmethod
|
||||
def other_regions(entry: dict, staged: dict[str, tuple[int, dict, Path]], local: Path) -> list[Path]:
|
||||
"""Held dumps of the same system for regions this entry does not
|
||||
serve, one per distinct file: the wrong-region variants."""
|
||||
mine = set(regions_of(entry))
|
||||
if not mine or "world" in mine:
|
||||
return []
|
||||
out: dict[str, Path] = {}
|
||||
for _, other, path in staged.values():
|
||||
theirs = set(regions_of(other))
|
||||
if theirs and "world" not in theirs and not theirs & mine and path.resolve() != local.resolve():
|
||||
out.setdefault(file_sha1(path), path)
|
||||
return sorted(out.values(), key=lambda p: p.name)[:MAX_REGION_DUMPS]
|
||||
|
||||
def run_unit_content(self, unit: Unit, core: Core, content: Content, digest: str) -> None:
|
||||
emulator, profile = unit.emulator, unit.profile
|
||||
checks = self.state["checks"].setdefault(emulator, {})
|
||||
entries = self.system_entries(profile, content.system)
|
||||
boot = self.boot_entry(entries, content)
|
||||
keys = {name: self.check_key(core, content, name) for name in ("files", "bench")}
|
||||
if boot:
|
||||
keys["boot"] = self.check_key(core, content, f"boot:{boot[1]['name']}")
|
||||
todo = [k for k in keys.values() if should_run(checks.get(k), core.sha256, digest)]
|
||||
if not todo:
|
||||
return
|
||||
try:
|
||||
folder = self.store.get(content)
|
||||
load = load_file(folder, content.load)
|
||||
except Unavailable as exc:
|
||||
for key in todo:
|
||||
self.pending(emulator, key, str(exc), digest)
|
||||
return
|
||||
frames = ["--frames", str(content.frames), "--frame-hash-every", str(content.frame_hash_every)]
|
||||
stem = f"{core.name}-{slug(content.name)}"
|
||||
|
||||
# files: every declared file the collection holds, at its place.
|
||||
staged: dict[str, tuple[int, dict, Path]] = {}
|
||||
for index, entry in entries:
|
||||
local = self.local_file(entry)
|
||||
if local and entry_dest(entry) not in staged:
|
||||
staged[entry_dest(entry)] = (index, entry, local)
|
||||
files_record = None
|
||||
if keys["files"] in todo:
|
||||
argv = self.probe_args("run", core, emulator, slug(f"files-{stem}"), content, load) + frames
|
||||
for dest, (_, _, local) in sorted(staged.items()):
|
||||
argv += ["--system-file", f"{dest}={local}"]
|
||||
result = self.probe(argv, f"{emulator}-{stem}-files")
|
||||
self.remember(emulator, keys["files"], result, core, digest)
|
||||
files_record = result.record
|
||||
elif checks.get(keys["files"], {}).get("status") == DONE:
|
||||
files_record = self.read_record(emulator, checks[keys["files"]].get("record"))
|
||||
tested = {dest: item for dest, item in staged.items() if region_matches(item[1], content.region)}
|
||||
keys = {"files": self.check_key(core, content, "files"),
|
||||
"none": self.check_key(core, content, "none")}
|
||||
for dest in tested:
|
||||
keys[f"negative:{dest}"] = self.check_key(core, content, f"negative:{dest}")
|
||||
if self.settings.bench:
|
||||
keys["bench"] = self.check_key(core, content, "bench")
|
||||
# The rules run on every pass, from the records kept when nothing
|
||||
# needs to run again: a change of the table applies without a run.
|
||||
try:
|
||||
folder = self.store.get(content)
|
||||
load = load_file(folder, content.load)
|
||||
except Unavailable as exc:
|
||||
for key in keys.values():
|
||||
if self.needed(emulator, key, core, digest):
|
||||
self.pending(emulator, key, str(exc), digest)
|
||||
return
|
||||
frames = ["--frames", str(content.frames), "--frame-hash-every", str(content.frame_hash_every)]
|
||||
repeat = ["--repeat", str(self.settings.check_repeat)]
|
||||
stem = f"{core.name}-{slug(content.name)}"
|
||||
|
||||
boot_record = None
|
||||
if boot and keys["boot"] in todo:
|
||||
index, entry = boot
|
||||
local = self.local_file(entry)
|
||||
dest = entry_dest(entry)
|
||||
argv = self.probe_args("check", core, emulator, slug(f"boot-{entry['name']}-{stem}"),
|
||||
content, load) + frames
|
||||
argv += ["--vary", f"{dest}=", "--vary", f"{dest}={local}", "--observable", "frame_hashes",
|
||||
"--repeat", str(self.settings.check_repeat),
|
||||
"--claim", f"{core.name} reaches the game on {content.name} without {entry['name']}",
|
||||
"--method", (f"Two variants, {entry['name']} absent and {entry['name']} as the "
|
||||
f"collection holds it, each run {self.settings.check_repeat} times "
|
||||
f"from a fresh process for {content.frames} frames with no button "
|
||||
f"pressed. Every {content.frame_hash_every}th frame is hashed. The "
|
||||
f"runs without the file are compared with the runs with it on the "
|
||||
f"frames that are not one colour.")]
|
||||
result = self.probe(argv, f"{emulator}-{stem}-boot")
|
||||
self.remember(emulator, keys["boot"], result, core, digest)
|
||||
boot_record = result.record
|
||||
if boot_record:
|
||||
absent, present = (v["label"] for v in boot_record["variants"])
|
||||
verdicts = rules.rule_bios_need(rules.boot_outcome(boot_record, absent, present),
|
||||
entry, boot_record["id"])
|
||||
# files: every declared file the collection holds, at its place.
|
||||
argv = self.probe_args("run", core, emulator, slug(f"files-{stem}"), content, load) + frames + repeat
|
||||
for dest, (_, _, local) in sorted(staged.items()):
|
||||
argv += ["--system-file", f"{dest}={local}"]
|
||||
files_record = self.check(emulator, keys["files"], core, digest, argv, f"{emulator}-{stem}-files")
|
||||
# none: no BIOS at all.
|
||||
argv = self.probe_args("run", core, emulator, slug(f"none-{stem}"), content, load) + frames + repeat
|
||||
none_record = self.check(emulator, keys["none"], core, digest, argv, f"{emulator}-{stem}-none")
|
||||
if not files_record or not none_record:
|
||||
return
|
||||
full = rules.run_of(files_record, files_record["variants"][0]["label"])
|
||||
none = rules.run_of(none_record, none_record["variants"][0]["label"])
|
||||
self.log(f"{emulator}: {core.name} on {content.name}: full set {full.verdict}, "
|
||||
f"no BIOS {none.verdict}")
|
||||
|
||||
for dest, (index, entry, local) in sorted(tested.items()):
|
||||
record = self.negative(unit, core, content, load, frames, digest, keys[f"negative:{dest}"],
|
||||
dest, entry, local, staged)
|
||||
if not record:
|
||||
continue
|
||||
labels = [v["label"] for v in record["variants"]]
|
||||
present = rules.run_of(record, labels[0])
|
||||
zeros, half = (rules.run_of(record, label) for label in labels[1:3])
|
||||
for label in labels[3:]:
|
||||
self.log(f"{emulator}: {rules.rule_region(rules.run_of(record, label), present, entry, content.region)}")
|
||||
if dest in self.opened(files_record, 0) or dest in self.opened(record, 0):
|
||||
verdicts = rules.rule_need(none, present, entry, rules.read(none, present, zeros, half))
|
||||
validation = rules.rule_validation(zeros, half, none, present, entry)
|
||||
if validation:
|
||||
verdicts.append(validation)
|
||||
self.settle(emulator, content, core, index, entry, verdicts)
|
||||
self.follow_aliases(unit, core, content, load, frames, digest, index, entry,
|
||||
local, boot_record, absent)
|
||||
variants = self.variant_files(entry, local)
|
||||
if variants:
|
||||
self.run_variants(unit, core, content, load, frames, digest, index, entry, dest,
|
||||
local, variants)
|
||||
else:
|
||||
self.moved(unit, core, content, load, frames, digest, index, entry, dest, local,
|
||||
files_record, record)
|
||||
|
||||
if files_record and keys["files"] in todo:
|
||||
self.read_files(unit, core, content, load, frames, digest, staged, files_record)
|
||||
boot = self.boot_entry(entries, content)
|
||||
if boot and entry_dest(boot[1]) in tested:
|
||||
index, entry = boot
|
||||
self.follow_aliases(unit, core, content, load, frames, digest, index, entry,
|
||||
tested[entry_dest(entry)][2], none_record)
|
||||
|
||||
if keys["bench"] in todo:
|
||||
if self.settings.bench and full.verdict == rules.BOOTS:
|
||||
argv = self.probe_args("bench", core, emulator, slug(f"bench-{stem}"), content, load)
|
||||
argv += ["--warmup", str(self.settings.bench_warmup), "--frames",
|
||||
str(self.settings.bench_frames), "--repeat", str(self.settings.bench_repeat),
|
||||
"--no-trace"]
|
||||
if boot:
|
||||
local = self.local_file(boot[1])
|
||||
argv += ["--system-file", f"{entry_dest(boot[1])}={local}"]
|
||||
result = self.probe(argv, f"{emulator}-{stem}-bench")
|
||||
self.remember(emulator, keys["bench"], result, core, digest)
|
||||
argv += ["--system-file", f"{entry_dest(boot[1])}={self.local_file(boot[1])}"]
|
||||
self.check(emulator, keys["bench"], core, digest, argv, f"{emulator}-{stem}-bench")
|
||||
|
||||
def negative(self, unit: Unit, core: Core, content: Content, load: Path, frames: list[str],
|
||||
digest: str, key: str, dest: str, entry: dict, local: Path,
|
||||
staged: dict[str, tuple[int, dict, Path]]) -> dict | None:
|
||||
"""The file alone, then wrong files under its name: zeros of its
|
||||
size, its first half, and other regions' dumps."""
|
||||
stem = f"{core.name}-{slug(content.name)}"
|
||||
size = local.stat().st_size
|
||||
others = self.other_regions(entry, staged, local)
|
||||
argv = self.probe_args("check", core, unit.emulator, slug(f"negative-{entry['name']}-{stem}"),
|
||||
content, load) + frames
|
||||
argv += ["--vary", f"{dest}={local}", "--vary", f"{dest}=@zeros:{size}",
|
||||
"--vary", f"{dest}=@head:{size // 2}:{local}"]
|
||||
for other in others:
|
||||
argv += ["--vary", f"{dest}={other}"]
|
||||
regions = f", then {', '.join(p.name for p in others)} staged under its name" if others else ""
|
||||
argv += ["--observable", "frame_hashes", "--repeat", str(self.settings.check_repeat),
|
||||
"--claim", (f"{core.name} boots {content.name} with {entry['name']} alone and "
|
||||
f"refuses a wrong file under its name"),
|
||||
"--method", (f"{entry['name']} alone as {dest}, then {size} zero bytes and its "
|
||||
f"first {size // 2} bytes{regions}, nothing else staged. Each run "
|
||||
f"{self.settings.check_repeat} times from a fresh process for "
|
||||
f"{content.frames} frames with no button pressed, every "
|
||||
f"{content.frame_hash_every}th frame hashed. The boot oracle judges "
|
||||
f"each variant against the reference frames of {content.name}.")]
|
||||
return self.check(unit.emulator, key, core, digest, argv, f"{unit.emulator}-{stem}-negative")
|
||||
|
||||
def moved(self, unit: Unit, core: Core, content: Content, load: Path, frames: list[str],
|
||||
digest: str, index: int, entry: dict, dest: str, local: Path, files_record: dict,
|
||||
alone: dict) -> None:
|
||||
"""A file the core opened neither in the full set nor alone: where
|
||||
else it looked for a file of that name, else flagged."""
|
||||
probed = [p for record in (files_record, alone) for r in record["variants"][0]["repetitions"]
|
||||
for p in r.get("files_probed_absent") or ()]
|
||||
others = [p for p in dict.fromkeys(probed) if p != dest
|
||||
and p.rsplit("/", 1)[-1].casefold() == dest.rsplit("/", 1)[-1].casefold()]
|
||||
for candidate in others[:MAX_ALIAS_CANDIDATES]:
|
||||
found = self.follow_up(unit, core, content, load, frames, digest, entry, local, candidate,
|
||||
f"path:{entry['name']}:{candidate}")
|
||||
if found and candidate in self.opened(found, 0):
|
||||
verdict = rules.rule_path(entry, dest, rules.Opened(candidate, found["id"]))
|
||||
if verdict:
|
||||
self.settle(unit.emulator, content, core, index, entry, [verdict])
|
||||
return
|
||||
verdict = rules.rule_never_opened(entry, [files_record["id"], alone["id"]], content.name)
|
||||
self.settle(unit.emulator, content, core, index, entry, [verdict])
|
||||
|
||||
def read_record(self, emulator: str, rid: str | None) -> dict | None:
|
||||
if not rid:
|
||||
@@ -1150,49 +1283,10 @@ class Campaign:
|
||||
return json.loads(line)
|
||||
return None
|
||||
|
||||
def read_files(self, unit: Unit, core: Core, content: Content, load: Path, frames: list[str],
|
||||
digest: str, staged: dict[str, tuple[int, dict, Path]], record: dict) -> None:
|
||||
"""Which staged files the core opened: path follow-ups, variants, flags."""
|
||||
emulator = unit.emulator
|
||||
rep = record["variants"][0]["repetitions"][0]
|
||||
if rep.get("files_opened") is None:
|
||||
return
|
||||
opened = {item["path"] for item in rep.get("files_opened") or ()}
|
||||
probed = list(rep.get("files_probed_absent") or ())
|
||||
for dest, (index, entry, local) in sorted(staged.items()):
|
||||
if dest in opened:
|
||||
variants = self.variant_files(entry, local)
|
||||
if variants:
|
||||
self.run_variants(unit, core, content, load, frames, digest, index, entry, dest,
|
||||
local, variants)
|
||||
continue
|
||||
if not region_matches(entry, content.region):
|
||||
continue
|
||||
# A fallback is read only when the files before it are absent:
|
||||
# staged alone, it shows whether the core reads it at all.
|
||||
alone = self.follow_up(unit, core, content, load, frames, digest, entry, local, dest,
|
||||
f"alone:{entry['name']}")
|
||||
if alone is None:
|
||||
continue
|
||||
if dest in self.opened(alone, 1):
|
||||
continue
|
||||
probed_alone = [p for r in alone["variants"][0]["repetitions"]
|
||||
for p in r.get("files_probed_absent") or ()]
|
||||
others = [p for p in dict.fromkeys(probed + probed_alone) if p != dest
|
||||
and p.rsplit("/", 1)[-1].casefold() == dest.rsplit("/", 1)[-1].casefold()]
|
||||
moved = False
|
||||
for candidate in others[:MAX_ALIAS_CANDIDATES]:
|
||||
record_c = self.follow_up(unit, core, content, load, frames, digest, entry, local,
|
||||
candidate, f"path:{entry['name']}:{candidate}")
|
||||
if record_c and candidate in self.opened(record_c, 1):
|
||||
verdict = rules.rule_path(entry, dest, rules.Opened(candidate, record_c["id"]))
|
||||
if verdict:
|
||||
self.settle(emulator, content, core, index, entry, [verdict])
|
||||
moved = True
|
||||
break
|
||||
if not moved:
|
||||
verdict = rules.rule_never_opened(entry, [record["id"], alone["id"]], content.name)
|
||||
self.settle(emulator, content, core, index, entry, [verdict])
|
||||
def judged(self, emulator: str, rid: str) -> bool:
|
||||
"""True for a record the boot oracle judged: only those prove a field."""
|
||||
record = self.read_record(emulator, rid)
|
||||
return bool(record) and record.get("record_version", 0) >= 4
|
||||
|
||||
@staticmethod
|
||||
def opened(record: dict, position: int) -> set[str]:
|
||||
@@ -1203,34 +1297,23 @@ class Campaign:
|
||||
|
||||
def follow_up(self, unit: Unit, core: Core, content: Content, load: Path, frames: list[str],
|
||||
digest: str, entry: dict, local: Path, dest: str, check: str) -> dict | None:
|
||||
"""The file staged at dest alone, against nothing there."""
|
||||
key = self.check_key(core, content, check)
|
||||
previous = self.state["checks"].get(unit.emulator, {}).get(key)
|
||||
if not should_run(previous, core.sha256, digest):
|
||||
return self.read_record(unit.emulator, (previous or {}).get("record"))
|
||||
"""The file staged at dest alone: does the core open it there."""
|
||||
stem = f"{core.name}-{slug(content.name)}"
|
||||
argv = self.probe_args("check", core, unit.emulator, slug(f"{check}-{stem}"), content, load)
|
||||
argv += frames + ["--vary", f"{dest}=", "--vary", f"{dest}={local}",
|
||||
"--observable", "frame_hashes", "--repeat", "1",
|
||||
"--claim", f"{core.name} opens {entry['name']} as {dest}",
|
||||
"--method", (f"{entry['name']} staged as {dest}, against nothing there, "
|
||||
f"{content.frames} frames of {content.name}. The files the "
|
||||
f"core opened are read from strace.")]
|
||||
result = self.probe(argv, f"{unit.emulator}-{stem}-{slug(check)}")
|
||||
self.remember(unit.emulator, key, result, core, digest)
|
||||
return result.record
|
||||
argv = self.probe_args("run", core, unit.emulator, slug(f"{check}-{stem}"), content, load)
|
||||
argv += frames + ["--system-file", f"{dest}={local}"]
|
||||
return self.check(unit.emulator, self.check_key(core, content, check), core, digest, argv,
|
||||
f"{unit.emulator}-{stem}-{slug(check)}")
|
||||
|
||||
def follow_aliases(self, unit: Unit, core: Core, content: Content, load: Path, frames: list[str],
|
||||
digest: str, index: int, entry: dict, local: Path, record: dict,
|
||||
absent_label: str) -> None:
|
||||
"""Names the core looked for, with the file absent, that no entry declares."""
|
||||
digest: str, index: int, entry: dict, local: Path, none_record: dict) -> None:
|
||||
"""Names the core looked for, with no BIOS, that no entry declares."""
|
||||
declared = {e["name"] for e in unit.profile.get("files") or ()}
|
||||
declared |= {a for e in unit.profile.get("files") or () for a in e.get("aliases") or ()}
|
||||
suffix = Path(entry["name"]).suffix.casefold()
|
||||
dest = entry_dest(entry)
|
||||
folder = dest.rsplit("/", 1)[0] if "/" in dest else ""
|
||||
probed: list[str] = []
|
||||
for rep in rules.variant(record, absent_label)["repetitions"]:
|
||||
for rep in none_record["variants"][0]["repetitions"]:
|
||||
for path in rep.get("files_probed_absent") or ():
|
||||
name = path.rsplit("/", 1)[-1]
|
||||
where = path.rsplit("/", 1)[0] if "/" in path else ""
|
||||
@@ -1240,7 +1323,7 @@ class Campaign:
|
||||
for candidate in probed[:MAX_ALIAS_CANDIDATES]:
|
||||
found = self.follow_up(unit, core, content, load, frames, digest, entry, local, candidate,
|
||||
f"alias:{entry['name']}:{candidate.rsplit('/', 1)[-1]}")
|
||||
if found and candidate in self.opened(found, 1):
|
||||
if found and candidate in self.opened(found, 0):
|
||||
verdict = rules.rule_alias(self.reload_entry(unit, index), rules.Opened(candidate, found["id"]))
|
||||
if verdict:
|
||||
self.settle(unit.emulator, content, core, index, self.reload_entry(unit, index), [verdict])
|
||||
@@ -1249,7 +1332,7 @@ class Campaign:
|
||||
digest: str, index: int, entry: dict, dest: str, primary: Path,
|
||||
variants: list[Path]) -> None:
|
||||
key = self.check_key(core, content, f"variants:{entry['name']}")
|
||||
if not should_run(self.state["checks"].get(unit.emulator, {}).get(key), core.sha256, digest):
|
||||
if not self.needed(unit.emulator, key, core, digest):
|
||||
return
|
||||
stem = f"{core.name}-{slug(content.name)}"
|
||||
argv = self.probe_args("check", core, unit.emulator, slug(f"variants-{entry['name']}-{stem}"),
|
||||
@@ -1257,16 +1340,14 @@ class Campaign:
|
||||
for path in [primary, *variants]:
|
||||
argv += ["--vary", f"{dest}={path}"]
|
||||
argv += ["--observable", "frame_hashes", "--repeat", str(self.settings.check_repeat),
|
||||
"--claim", f"the copies of {entry['name']} change what {core.name} shows on {content.name}",
|
||||
"--method", (f"Each copy the collection holds staged as {dest}, each run "
|
||||
f"{self.settings.check_repeat} times for {content.frames} frames, "
|
||||
f"compared on the sampled frame hashes.")]
|
||||
result = self.probe(argv, f"{unit.emulator}-{stem}-variants")
|
||||
self.remember(unit.emulator, key, result, core, digest)
|
||||
if result.record:
|
||||
status = result.record["check"]["result"]["status"]
|
||||
verdict = rules.rule_variants(status, [v["label"] for v in result.record["variants"]],
|
||||
entry, result.record["id"])
|
||||
"--claim", f"every copy of {entry['name']} boots {content.name} on {core.name}",
|
||||
"--method", (f"Each copy the collection holds staged alone as {dest}, each run "
|
||||
f"{self.settings.check_repeat} times for {content.frames} frames. The "
|
||||
f"boot oracle judges each copy.")]
|
||||
record = self.check(unit.emulator, key, core, digest, argv, f"{unit.emulator}-{stem}-variants")
|
||||
if record:
|
||||
verdict = rules.rule_variants([rules.run_of(record, v["label"]) for v in record["variants"]],
|
||||
entry)
|
||||
if verdict:
|
||||
self.settle(unit.emulator, content, core, index, entry, [verdict])
|
||||
|
||||
@@ -1291,9 +1372,10 @@ class Campaign:
|
||||
agrees = verdict.field in rules.SETTLED_FIELDS and rules.agrees(fresh, verdict)
|
||||
if verdict.apply:
|
||||
updates: dict[str, object] = {}
|
||||
if not agrees or verdict.field not in fresh:
|
||||
if not agrees or (verdict.field not in fresh and verdict.field != "validation"):
|
||||
updates[verdict.field] = verdict.value
|
||||
evidence = rules.merge_evidence(fresh.get("evidence"), verdict)
|
||||
evidence = rules.merge_evidence(fresh.get("evidence"), verdict,
|
||||
keep=lambda rid: self.judged(emulator, rid))
|
||||
if evidence != fresh.get("evidence"):
|
||||
updates["evidence"] = evidence
|
||||
if updates:
|
||||
@@ -1331,7 +1413,8 @@ class Campaign:
|
||||
key = self.finding_key(emulator, content, entry, verdict)
|
||||
previous = self.latest_findings().get(key)
|
||||
if previous and previous["status"] == status and previous["rule"] == verdict.rule \
|
||||
and previous["run_shows"] == verdict.value and previous["profile_says"] == says:
|
||||
and previous["run_shows"] == verdict.value and previous["profile_says"] == says \
|
||||
and previous["evidence"] == list(verdict.evidence):
|
||||
return
|
||||
line = mr.seal_line({
|
||||
"schema_version": 1, "found_at": now_iso(), "key": key, "emulator": emulator,
|
||||
@@ -1364,6 +1447,60 @@ class Campaign:
|
||||
return record["core"].get("library_version")
|
||||
return None
|
||||
|
||||
# -- reference frames -----------------------------------------------------------
|
||||
|
||||
def references(self, only: set[str], system: str | None, png_dir: Path) -> list[dict]:
|
||||
"""The full set run once per core and content, frames written as PNG,
|
||||
and the screens that stay on long enough to serve as references."""
|
||||
self.deadline = self.host.clock() + self.settings.budget
|
||||
listed = []
|
||||
for unit in self.units(only, system):
|
||||
contents = self.unit_contents(unit, system)
|
||||
if unit.pending or not contents:
|
||||
continue
|
||||
try:
|
||||
cores = self.unit_cores(unit.emulator, unit.profile, unit.buildbot_only)
|
||||
except Unavailable as exc:
|
||||
self.log(f"{unit.emulator}: {exc}")
|
||||
continue
|
||||
for core in cores:
|
||||
for content in contents:
|
||||
self.guard()
|
||||
self.store.hold(content, "references")
|
||||
try:
|
||||
load = load_file(self.store.get(content), content.load)
|
||||
except Unavailable as exc:
|
||||
self.log(f"{unit.emulator}: {exc}")
|
||||
continue
|
||||
stem = f"{core.name}-{slug(content.name)}"
|
||||
target = png_dir / slug(core.name) / slug(content.name)
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
argv = self.probe_args("run", core, unit.emulator, slug(f"references-{stem}"),
|
||||
content, load)
|
||||
argv += ["--frames", str(content.frames), "--frame-hash-every",
|
||||
str(content.frame_hash_every), "--repeat", str(self.settings.check_repeat),
|
||||
"--png-dir", str(target)]
|
||||
for index, entry in self.system_entries(unit.profile, content.system):
|
||||
local = self.local_file(entry)
|
||||
if local and f"{entry_dest(entry)}=" not in " ".join(argv):
|
||||
argv += ["--system-file", f"{entry_dest(entry)}={local}"]
|
||||
result = self.probe(argv, f"{unit.emulator}-{stem}-references")
|
||||
self.store.release(content, "references")
|
||||
if not result.record:
|
||||
self.log(f"{unit.emulator}: references of {content.name}: {result.reason}")
|
||||
continue
|
||||
record = result.record
|
||||
listed.append({
|
||||
"emulator": unit.emulator, "core": core.name,
|
||||
"library_name": record["core"]["library_name"], "content": content.name,
|
||||
"content_sha1": record["inputs"]["content"]["sha1"], "record": record["id"],
|
||||
"verdict": record["variants"][0]["verdict"],
|
||||
"png_dir": str(target / record["variants"][0]["label"]),
|
||||
"candidates": reference_candidates(record),
|
||||
})
|
||||
save_state(self.paths.state, self.state)
|
||||
return listed
|
||||
|
||||
# -- the pass -----------------------------------------------------------------
|
||||
|
||||
def units(self, only: set[str], system: str | None) -> list[Unit]:
|
||||
@@ -1567,6 +1704,32 @@ class Campaign:
|
||||
return made
|
||||
|
||||
|
||||
def reference_candidates(record: dict, min_samples: int = 3) -> list[dict]:
|
||||
"""Screens of a record's first variant that are not one colour and stay
|
||||
on for at least min_samples consecutive samples in every repetition:
|
||||
what a reviewer looks at to choose reference frames."""
|
||||
found: list[dict[str, tuple[int, int]]] = []
|
||||
for rep in record["variants"][0]["repetitions"]:
|
||||
observables = rep.get("observables") or {}
|
||||
uniform = set(observables.get("uniform_frames") or ())
|
||||
runs: dict[str, tuple[int, int]] = {}
|
||||
previous, start, length = None, 0, 0
|
||||
for frame, digest in [*(observables.get("frame_hashes") or ()), (None, None)]:
|
||||
if digest == previous:
|
||||
length += 1
|
||||
continue
|
||||
if previous and previous not in uniform and length >= min_samples:
|
||||
first, longest = runs.get(previous, (start, 0))
|
||||
runs[previous] = (min(first, start), max(longest, length))
|
||||
previous, start, length = digest, frame, 1
|
||||
found.append(runs)
|
||||
common = set(found[0]) if found else set()
|
||||
for runs in found[1:]:
|
||||
common &= set(runs)
|
||||
return sorted(({"sha1": digest, "first_frame": found[0][digest][0], "samples": found[0][digest][1]}
|
||||
for digest in common), key=lambda item: item["first_frame"])
|
||||
|
||||
|
||||
def reason_class(reason: str) -> str:
|
||||
"""A pending reason without its detail, to count them."""
|
||||
head = reason.split(":", 1)[0].strip() if ":" in reason else reason
|
||||
@@ -1661,9 +1824,17 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(allow_abbrev=False, description=__doc__.split("\n\n")[0],
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
run_p = sub.add_parser("run", help="one pass: cores, content, checks, rules")
|
||||
run_p.add_argument("--only", default="", help="emulator profiles or core names, comma-separated")
|
||||
run_p.add_argument("--system", help="one system id")
|
||||
scope = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
|
||||
scope.add_argument("--only", default="", help="emulator profiles or core names, comma-separated")
|
||||
scope.add_argument("--system", help="one system id")
|
||||
scope.add_argument("--romfetch", default="",
|
||||
help=f"romfetch command (default: ${ROMFETCH_ENV}, else romfetch on PATH)")
|
||||
scope.add_argument("--offline", action="store_true", help="no buildbot, no romfetch, no build")
|
||||
scope.add_argument("--content-file", default=str(CONTENT_FILE),
|
||||
help="the test content list (default: tests/campaign/content.yml)")
|
||||
scope.add_argument("--allow-unsandboxed", action="store_true",
|
||||
help="run cores and builds without bubblewrap when it is missing (refused by default)")
|
||||
run_p = sub.add_parser("run", parents=[scope], help="one pass: cores, content, checks, rules")
|
||||
run_p.add_argument("--budget", type=parse_budget, default=6 * 3600.0,
|
||||
help="stop starting checks after this long (6h, 90m, 3600s)")
|
||||
run_p.add_argument("--max-load-wait", type=float, default=3600,
|
||||
@@ -1671,16 +1842,17 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
run_p.add_argument("--min-free-gb", type=float, default=30, help="pause below this free space")
|
||||
run_p.add_argument("--cache-cap-gb", type=float, default=20,
|
||||
help="content the campaign fetched is evicted past this size, oldest use first")
|
||||
run_p.add_argument("--romfetch", default="",
|
||||
help=f"romfetch command (default: ${ROMFETCH_ENV}, else romfetch on PATH)")
|
||||
run_p.add_argument("--offline", action="store_true", help="no buildbot, no romfetch, no build")
|
||||
run_p.add_argument("--allow-unsandboxed", action="store_true",
|
||||
help="run cores and builds without bubblewrap when it is missing (refused by default)")
|
||||
run_p.add_argument("--bench", action="store_true",
|
||||
help="also time each content that boots (benchmarks only choose between variants)")
|
||||
run_p.add_argument("--dry-run", action="store_true", help="print the plan, run nothing")
|
||||
run_p.add_argument("--commit", action="store_true",
|
||||
help="commit records, findings and each changed profile behind the gate")
|
||||
run_p.add_argument("--commit-every", type=int, default=0, metavar="N",
|
||||
help="with --commit, also commit after every N emulators")
|
||||
refs_p = sub.add_parser("references", parents=[scope],
|
||||
help="run the full set with PNG frames and list reference candidates")
|
||||
refs_p.add_argument("--png-dir", default=str(ROOT / "tmp" / "campaign" / "references"),
|
||||
help="where the frames are written, one folder per core and content")
|
||||
status_p = sub.add_parser("status", help="done, failed and pending checks, and why")
|
||||
status_p.add_argument("--json", action="store_true", help="the report as JSON")
|
||||
findings_p = sub.add_parser("findings", help="profile claims the runs contradict, open for review")
|
||||
@@ -1688,9 +1860,9 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
findings_p.add_argument("--json", action="store_true", help="the findings as JSON")
|
||||
sub.add_parser("gate", help="tests, schemas, verify and pack of the changed profiles")
|
||||
timer_p = sub.add_parser("install-timer",
|
||||
help="write the autorun units, the campaign job's timer among them")
|
||||
help="opt-in: write the autorun units, the campaign job's timer among them")
|
||||
timer_p.add_argument("--enable", action="store_true",
|
||||
help="also reload systemd and enable the timers")
|
||||
help="also reload systemd and enable the timers (never by default)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
paths = Paths.default()
|
||||
@@ -1713,20 +1885,34 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
settings = Settings(budget=args.budget, max_load_wait=args.max_load_wait,
|
||||
min_free_gb=args.min_free_gb, cache_cap_gb=args.cache_cap_gb,
|
||||
romfetch=args.romfetch, network=not args.offline,
|
||||
allow_unsandboxed=args.allow_unsandboxed, bench=args.bench)
|
||||
elif args.command == "references":
|
||||
settings = Settings(romfetch=args.romfetch, network=not args.offline,
|
||||
allow_unsandboxed=args.allow_unsandboxed)
|
||||
if args.command in ("run", "references"):
|
||||
paths.content_file = Path(args.content_file)
|
||||
campaign = Campaign(paths, settings)
|
||||
if args.command == "status":
|
||||
report = status_report(campaign)
|
||||
print(json.dumps(report, indent=2) if args.json else render_status(report))
|
||||
return 0
|
||||
only = {item.strip() for item in args.only.split(",") if item.strip()}
|
||||
if args.command == "references":
|
||||
try:
|
||||
with host_lock(campaign.lock_path):
|
||||
listed = campaign.references(only, args.system, Path(args.png_dir))
|
||||
except autorun.LockBusy as exc:
|
||||
print(f"campaign: deferred, {exc}", file=sys.stderr)
|
||||
return 75
|
||||
print(json.dumps(listed, indent=2))
|
||||
return 0
|
||||
if args.dry_run:
|
||||
campaign.run(only, args.system, dry_run=True)
|
||||
return 0
|
||||
campaign.dirty_at_start = set(autorun.changed_files(paths.root)) if args.commit else set()
|
||||
|
||||
def stop(signum: int, frame: object) -> None:
|
||||
# A timer's TimeoutStartSec ends the pass like Ctrl-C: state saved, pass line written.
|
||||
# SIGTERM ends the pass like Ctrl-C: state saved, pass line written.
|
||||
raise KeyboardInterrupt
|
||||
signal.signal(signal.SIGTERM, stop)
|
||||
try:
|
||||
|
||||
@@ -23,6 +23,7 @@ ANCHORS = {
|
||||
"hle_fallback": ("required", "name"),
|
||||
"path": ("name",),
|
||||
"aliases": ("path", "name"),
|
||||
"validation": ("sha1", "md5", "crc32", "size", "name"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+181
-97
@@ -1,25 +1,36 @@
|
||||
"""The campaign's fixed rule table: which observable settles which profile field.
|
||||
"""The campaign's fixed rule table: which verdicts settle which profile field.
|
||||
|
||||
Each rule is a pure function of a core probe record (or what was read from
|
||||
one) and a profile entry. It returns verdicts. A verdict with `apply` set is
|
||||
settled by the table and written into the profile with the record ids as
|
||||
`evidence:`. Any other verdict goes to the review queue
|
||||
(measurements/findings.jsonl).
|
||||
Each rule is a pure function of verdicts read from core probe records (the
|
||||
boot oracle of core_probe.py: boots, bios_error, crash, hang, unknown) and
|
||||
a profile entry. It returns verdicts on profile fields. One with `apply` set
|
||||
is settled by the table and written into the profile with the record ids as
|
||||
`evidence:`. Any other goes to the review queue (measurements/findings.jsonl).
|
||||
|
||||
observable field settled
|
||||
boots to the game with no file hle_fallback true
|
||||
no game screen without it, the game with it required true
|
||||
hle_fallback false
|
||||
opens the staged file under another path path that path
|
||||
opens the staged file under another name aliases name added
|
||||
a staged file the content should use, never read - flagged only
|
||||
Three runs are compared for a file, all on the same content: `none` with no
|
||||
BIOS at all, `present` with the file alone, and wrong files staged under its
|
||||
name (zeros of its size, its first half, another region's dump).
|
||||
|
||||
"Boots to the game" is read from the sampled frames: the run without the
|
||||
file shows at least MIN_SHARED_SCREENS distinct screens that the run with
|
||||
the file also shows, single-colour frames excluded. "No game screen" is a
|
||||
run that did not load, or whose every sampled frame is a single colour. A
|
||||
run that shows screens of its own that the reference never shows settles
|
||||
nothing.
|
||||
runs field settled
|
||||
none boots, the file changes the run hle_fallback true
|
||||
none fails, present boots required true
|
||||
hle_fallback false
|
||||
zeros refused, present boots validation a content check
|
||||
zeros used, present boots validation no content check
|
||||
half refused, zeros used validation size
|
||||
half used validation no size check
|
||||
opens the staged file under another path path that path
|
||||
opens the staged file under another name aliases name added
|
||||
a staged file the content should use, never - flagged only
|
||||
read
|
||||
copies of one file that boot and fail - queued
|
||||
|
||||
"Fails" is bios_error, crash or hang. A wrong file is refused when the
|
||||
core reports a BIOS error, or when it boots without the BIOS screens the
|
||||
right file shows (it fell back to a built-in BIOS). It is used when it
|
||||
turns the run into a hang or a crash that the run without any file does not
|
||||
show. Anything else settles nothing. Of validation, the table writes only a
|
||||
missing `size`. A contradicted content check goes to review: which hash a
|
||||
core computes is read in its code.
|
||||
|
||||
Standard library only.
|
||||
"""
|
||||
@@ -29,23 +40,34 @@ from __future__ import annotations
|
||||
from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
MIN_SHARED_SCREENS = 2
|
||||
BOOTS = "boots"
|
||||
FAILS = "fails"
|
||||
INCONCLUSIVE = "inconclusive"
|
||||
BIOS_ERROR = "bios_error"
|
||||
CRASH = "crash"
|
||||
HANG = "hang"
|
||||
UNKNOWN = "unknown"
|
||||
FAILS = (BIOS_ERROR, CRASH, HANG)
|
||||
REFUSED = "refused"
|
||||
USED = "used"
|
||||
CONTENT_CHECKS = ("md5", "sha1", "sha256", "crc32", "adler32")
|
||||
|
||||
# A field an entry leaves out reads as this value.
|
||||
FIELD_DEFAULTS: dict[str, object] = {"hle_fallback": False, "path": None, "aliases": []}
|
||||
SETTLED_FIELDS = ("required", "hle_fallback", "path", "aliases")
|
||||
FIELD_DEFAULTS: dict[str, object] = {"hle_fallback": False, "path": None, "aliases": [],
|
||||
"validation": []}
|
||||
SETTLED_FIELDS = ("required", "hle_fallback", "path", "aliases", "validation")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Boot:
|
||||
"""What the runs without the file showed, against the runs with it."""
|
||||
class Run:
|
||||
"""The verdict of one variant of a record: what a rule reads."""
|
||||
|
||||
status: str
|
||||
detail: str
|
||||
shared: int = 0
|
||||
verdict: str
|
||||
record_id: str
|
||||
label: str
|
||||
stages: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def shows_bios(self) -> bool:
|
||||
return "bios" in self.stages
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -67,13 +89,6 @@ class Opened:
|
||||
extra: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
def screens(repetition: dict) -> set[str]:
|
||||
"""The distinct sampled frames of a repetition that are not one colour."""
|
||||
observables = repetition.get("observables") or {}
|
||||
uniform = set(observables.get("uniform_frames") or ())
|
||||
return {digest for _, digest in observables.get("frame_hashes") or () if digest not in uniform}
|
||||
|
||||
|
||||
def variant(record: dict, label: str) -> dict:
|
||||
for item in record.get("variants") or ():
|
||||
if item.get("label") == label:
|
||||
@@ -81,43 +96,16 @@ def variant(record: dict, label: str) -> dict:
|
||||
raise KeyError(f"record {record.get('id')}: no variant {label!r}")
|
||||
|
||||
|
||||
def boot_outcome(record: dict, absent_label: str, present_label: str) -> Boot:
|
||||
"""Whether the core reached the game without the file.
|
||||
def run_of(record: dict, label: str) -> Run:
|
||||
"""A variant's verdict as a rule reads it. A record from before the
|
||||
oracle has none, and reads as unknown."""
|
||||
item = variant(record, label)
|
||||
evidence = item.get("verdict_evidence") or {}
|
||||
return Run(item.get("verdict", UNKNOWN), record["id"], label, tuple(evidence.get("stages") or ()))
|
||||
|
||||
Every repetition must agree: one that boots and one that does not leave
|
||||
the question open.
|
||||
"""
|
||||
present = variant(record, present_label)["repetitions"]
|
||||
if any(not rep.get("loaded") for rep in present):
|
||||
return Boot(INCONCLUSIVE, "with the file, the core did not load the content")
|
||||
reference: set[str] = set()
|
||||
for rep in present:
|
||||
shown = screens(rep)
|
||||
if not shown:
|
||||
return Boot(INCONCLUSIVE, "with the file, the core showed no game screen")
|
||||
reference |= shown
|
||||
outcomes: list[str] = []
|
||||
shared_counts: list[int] = []
|
||||
for rep in variant(record, absent_label)["repetitions"]:
|
||||
if not rep.get("loaded"):
|
||||
outcomes.append(FAILS)
|
||||
continue
|
||||
shown = screens(rep)
|
||||
if not shown:
|
||||
outcomes.append(FAILS)
|
||||
continue
|
||||
shared = len(shown & reference)
|
||||
shared_counts.append(shared)
|
||||
outcomes.append(BOOTS if shared >= MIN_SHARED_SCREENS else INCONCLUSIVE)
|
||||
shared = min(shared_counts) if shared_counts else 0
|
||||
if all(o == BOOTS for o in outcomes):
|
||||
return Boot(BOOTS, f"without the file, every run showed {shared} or more screens the "
|
||||
f"run with the file shows", shared)
|
||||
if all(o == FAILS for o in outcomes):
|
||||
return Boot(FAILS, "without the file, no run loaded the content or showed more than "
|
||||
"one colour, while the run with the file reached the game")
|
||||
return Boot(INCONCLUSIVE, "without the file, the runs showed screens the run with the file "
|
||||
"never shows, or disagreed with each other", shared)
|
||||
|
||||
def ids(*runs: Run) -> tuple[str, ...]:
|
||||
return tuple(dict.fromkeys(run.record_id for run in runs))
|
||||
|
||||
|
||||
def current(entry: dict, name: str) -> object:
|
||||
@@ -130,32 +118,123 @@ def agrees(entry: dict, verdict: Verdict) -> bool:
|
||||
"""True when the profile already says what the run showed."""
|
||||
if verdict.field == "aliases":
|
||||
return set(verdict.value or ()) <= set(entry.get("aliases") or ())
|
||||
if verdict.field == "validation":
|
||||
return verdict.rule == "validation_agrees"
|
||||
return current(entry, verdict.field) == verdict.value
|
||||
|
||||
|
||||
def rule_bios_need(boot: Boot, entry: dict, record_id: str) -> list[Verdict]:
|
||||
"""Boot with no file against boot with the declared file."""
|
||||
def read(none: Run, *runs: Run) -> bool:
|
||||
"""True when the file changes the run: alone or as a wrong file, one
|
||||
variant ends otherwise than the run with no BIOS, or reaches other
|
||||
stages. A core can open a file without using it for this content (a
|
||||
scan that hashes every file of the folder)."""
|
||||
return any(run.verdict != none.verdict or run.stages != none.stages for run in runs if run)
|
||||
|
||||
|
||||
def rule_need(none: Run, present: Run, entry: dict, used: bool = True) -> list[Verdict]:
|
||||
"""No BIOS at all against the file alone: required and hle_fallback. A
|
||||
file the content never uses settles nothing."""
|
||||
name = entry["name"]
|
||||
evidence = (record_id,)
|
||||
if boot.status == BOOTS:
|
||||
if none.verdict == BOOTS and not used:
|
||||
return []
|
||||
if none.verdict == BOOTS:
|
||||
out = [Verdict("boots_without_file", "hle_fallback", True,
|
||||
f"the core reaches the game without {name}: {boot.detail}", evidence, True)]
|
||||
f"the core boots the content with no BIOS at all, {name} included",
|
||||
ids(none), True)]
|
||||
if entry.get("required") is True:
|
||||
out.append(Verdict(
|
||||
"boots_without_file", "required", False,
|
||||
f"the profile says {name} is required, and the core reaches the game "
|
||||
f"without it. The table settles hle_fallback from this run, not required",
|
||||
evidence))
|
||||
f"the profile says {name} is required, and the core boots without it. The table "
|
||||
f"settles hle_fallback from this run, not required", ids(none)))
|
||||
return out
|
||||
if boot.status == FAILS:
|
||||
if none.verdict in FAILS and present.verdict == BOOTS:
|
||||
return [
|
||||
Verdict("fails_without_file", "required", True,
|
||||
f"the core reaches the game only with {name}: {boot.detail}", evidence, True),
|
||||
f"with no BIOS the run ends in {none.verdict}, with {name} alone it boots",
|
||||
ids(none, present), True),
|
||||
Verdict("fails_without_file", "hle_fallback", False,
|
||||
f"no stand-in for {name}: {boot.detail}", evidence, True),
|
||||
f"no stand-in for {name}: with no BIOS the run ends in {none.verdict}",
|
||||
ids(none, present), True),
|
||||
]
|
||||
return [Verdict("inconclusive", "hle_fallback", None,
|
||||
f"the runs with and without {name} settle nothing: {boot.detail}", evidence)]
|
||||
return [Verdict("unsettled", "hle_fallback", None,
|
||||
f"with no BIOS the run is {none.verdict} and with {name} alone {present.verdict}: "
|
||||
f"no rule settles a field from that", ids(none, present))]
|
||||
|
||||
|
||||
def file_use(wrong: Run, none: Run, present: Run) -> str | None:
|
||||
"""Whether the core refused a wrong file or ran it, or None."""
|
||||
if present.verdict != BOOTS:
|
||||
return None
|
||||
if wrong.verdict == BIOS_ERROR:
|
||||
return REFUSED
|
||||
if wrong.verdict == BOOTS:
|
||||
if wrong.shows_bios:
|
||||
return USED
|
||||
return REFUSED if present.shows_bios else None
|
||||
if wrong.verdict in (HANG, CRASH) and none.verdict in (BOOTS, BIOS_ERROR):
|
||||
return USED
|
||||
return None
|
||||
|
||||
|
||||
def declared_checks(entry: dict) -> tuple[set[str], bool]:
|
||||
"""The checks the entry's validation names, and whether it is one list
|
||||
(a per-build dict is never edited by the table)."""
|
||||
value = entry.get("validation") or []
|
||||
if isinstance(value, dict):
|
||||
return {str(v) for items in value.values() for v in items or ()}, False
|
||||
return {str(v) for v in value}, True
|
||||
|
||||
|
||||
def rule_validation(zeros: Run | None, half: Run | None, none: Run, present: Run,
|
||||
entry: dict) -> Verdict | None:
|
||||
"""Same-size and short wrong files against the right one: validation."""
|
||||
name = entry["name"]
|
||||
content = {REFUSED: True, USED: False}.get(file_use(zeros, none, present)) if zeros else None
|
||||
half_use = file_use(half, none, present) if half else None
|
||||
size: bool | None = None
|
||||
if half_use == USED:
|
||||
size, content = False, False if content is None else content
|
||||
elif half_use == REFUSED and content is False:
|
||||
size = True
|
||||
if content is None and size is None:
|
||||
return None
|
||||
declared, editable = declared_checks(entry)
|
||||
evidence = ids(*(run for run in (none, present, zeros, half) if run))
|
||||
said = sorted(declared)
|
||||
shown = []
|
||||
problems = []
|
||||
if content is not None:
|
||||
shown.append("checks the content" if content else "does not check the content")
|
||||
if content != bool(declared & set(CONTENT_CHECKS)):
|
||||
problems.append("a same-size file of zeros is " + ("refused" if content else "run")
|
||||
+ f", the profile declares validation {said}")
|
||||
missing_size = size is True and "size" not in declared
|
||||
if size is not None:
|
||||
shown.append("checks the size" if size else "does not check the size")
|
||||
if size is False and "size" in declared:
|
||||
problems.append(f"the first half of {name} is run, the profile declares a size check")
|
||||
statement = f"the core {' and '.join(shown)} of {name}"
|
||||
# What the runs show, for a finding beside what the profile says.
|
||||
checks = {"content": content, "size": size}
|
||||
if problems:
|
||||
return Verdict("validation_contradicted", "validation", checks,
|
||||
f"{statement}: {'; '.join(problems)}", evidence)
|
||||
if missing_size:
|
||||
if not editable:
|
||||
return Verdict("validation_contradicted", "validation", checks,
|
||||
f"{statement}: the per-build validation has no size check", evidence)
|
||||
value = [*(entry.get("validation") or []), "size"]
|
||||
return Verdict("validation_size_added", "validation", value,
|
||||
f"{statement}: half of the file is refused while zeros of its size are run",
|
||||
evidence, True)
|
||||
return Verdict("validation_agrees", "validation", entry.get("validation") or [], statement,
|
||||
evidence, editable)
|
||||
|
||||
|
||||
def rule_region(dump: Run, present: Run, entry: dict, game_region: str | None) -> str:
|
||||
"""What the core did with another region's dump under this file's name."""
|
||||
return (f"{dump.label} staged as {entry['name']} with a {game_region or 'region-less'} game: "
|
||||
f"{dump.verdict}, {entry['name']} itself: {present.verdict}")
|
||||
|
||||
|
||||
def rule_path(entry: dict, declared: str, opened: Opened) -> Verdict | None:
|
||||
@@ -184,20 +263,25 @@ def rule_never_opened(entry: dict, record_ids: Sequence[str], content: str) -> V
|
||||
f"{content}", tuple(record_ids))
|
||||
|
||||
|
||||
def rule_variants(status: str, labels: Iterable[str], entry: dict, record_id: str) -> Verdict | None:
|
||||
"""Copies of one file that change what the core shows: a pack choice to review."""
|
||||
if status != "differs":
|
||||
def rule_variants(runs: Iterable[Run], entry: dict) -> Verdict | None:
|
||||
"""Copies of one file whose verdicts differ: a pack choice to review."""
|
||||
runs = list(runs)
|
||||
if len({run.verdict for run in runs}) < 2:
|
||||
return None
|
||||
return Verdict("variant_changes_output", "variants", sorted(labels),
|
||||
f"the copies of {entry['name']} change what the core shows",
|
||||
(record_id,))
|
||||
shown = ", ".join(f"{run.label} {run.verdict}" for run in runs)
|
||||
return Verdict("variant_changes_verdict", "variants", sorted(run.label for run in runs),
|
||||
f"the copies of {entry['name']} do not boot alike: {shown}", ids(*runs))
|
||||
|
||||
|
||||
def merge_evidence(existing: dict | None, verdict: Verdict) -> dict:
|
||||
"""The entry's evidence: map with the verdict's record ids added under its field."""
|
||||
def merge_evidence(existing: dict | None, verdict: Verdict,
|
||||
keep: object = lambda rid: True) -> dict:
|
||||
"""The entry's evidence: map with the verdict's record ids added under its
|
||||
field. An id `keep` refuses is dropped from that field: a record from
|
||||
before the oracle no longer proves anything."""
|
||||
out = {key: list(value) for key, value in (existing or {}).items()}
|
||||
ids = out.setdefault(verdict.field, [])
|
||||
ids_ = [rid for rid in out.get(verdict.field, []) if keep(rid)]
|
||||
for rid in verdict.evidence:
|
||||
if rid not in ids:
|
||||
ids.append(rid)
|
||||
return {key: out[key] for key in sorted(out)}
|
||||
if rid not in ids_:
|
||||
ids_.append(rid)
|
||||
out[verdict.field] = ids_
|
||||
return {key: out[key] for key in sorted(out) if out[key]}
|
||||
+326
-17
@@ -21,6 +21,7 @@ import argparse
|
||||
import contextlib
|
||||
import ctypes
|
||||
import datetime as dt
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -35,7 +36,7 @@ import sys
|
||||
import tempfile
|
||||
import time
|
||||
import zlib
|
||||
from collections.abc import Iterable, Sequence
|
||||
from collections.abc import Iterable, Iterator, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
@@ -73,6 +74,39 @@ PRINTF_SPEC = re.compile(r"%(?P<flags>[-+ #0]*)(?P<width>\*|\d+)?(?:\.(?P<prec>\
|
||||
r"(?P<len>hh|h|ll|l|j|z|t|L)?(?P<conv>[diouxXeEfFgGaAcspn%])")
|
||||
|
||||
|
||||
# One measurement at a time on the host: each core_probe execution takes this
|
||||
# lock itself, its load check included, and releases it when it ends. A pass
|
||||
# of many runs never holds it between them, so two passes interleave.
|
||||
PROBE_LOCK_ENV = "RETROBIOS_PROBE_LOCK"
|
||||
PROBE_LOCK_HELD_ENV = "RETROBIOS_PROBE_LOCK_HELD"
|
||||
DEFAULT_PROBE_LOCK = Path.home() / ".local" / "state" / "retrobios" / "probe.lock"
|
||||
|
||||
|
||||
def probe_lock_path() -> Path:
|
||||
return Path(os.environ.get(PROBE_LOCK_ENV) or DEFAULT_PROBE_LOCK)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def host_probe_lock() -> Iterator[None]:
|
||||
"""The host's probe lock for one execution. A caller that already holds
|
||||
it for this run says so with RETROBIOS_PROBE_LOCK_HELD."""
|
||||
if os.environ.get(PROBE_LOCK_HELD_ENV):
|
||||
yield
|
||||
return
|
||||
path = probe_lock_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a") as handle:
|
||||
try:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
print(f"waiting for {path}: another measurement is running", file=sys.stderr)
|
||||
fcntl.flock(handle, fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(handle, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
class ProbeError(RuntimeError):
|
||||
"""A run that cannot produce a trustworthy result."""
|
||||
|
||||
@@ -375,15 +409,25 @@ class Frontend:
|
||||
self.unanswered: dict[int, int] = {}
|
||||
self.input_queries = 0
|
||||
self.input_pressed = 0
|
||||
# What a run showed so far, one JSON line per event, flushed at once:
|
||||
# a core that kills the worker leaves its logs and frames behind.
|
||||
events = spec.get("events")
|
||||
self.events = open(events, "a", encoding="utf-8", buffering=1) if events else None # noqa: SIM115
|
||||
self.callbacks = (
|
||||
EnvFn(self.environment), VideoFn(self.video_refresh), AudioFn(self.audio_sample),
|
||||
AudioBatchFn(self.audio_batch), PollFn(lambda: None), InputFn(self.input_state),
|
||||
)
|
||||
self.log_callback = RetroLogCallback(LogFn(self.log))
|
||||
|
||||
def event(self, kind: str, **values: object) -> None:
|
||||
if self.events:
|
||||
self.events.write(json.dumps({"event": kind, **values}) + "\n")
|
||||
|
||||
def log(self, level: int, fmt: bytes, *args: int) -> None:
|
||||
name = LOG_LEVELS[level] if 0 <= level < len(LOG_LEVELS) else str(level)
|
||||
self.logs.append({"frame": self.frame, "level": name, "text": render_printf(fmt or b"", args)})
|
||||
line = {"frame": self.frame, "level": name, "text": render_printf(fmt or b"", args)}
|
||||
self.logs.append(line)
|
||||
self.event("log", **line)
|
||||
|
||||
def environment(self, cmd: int, data: int) -> bool:
|
||||
cmd &= ~ENV_EXPERIMENTAL
|
||||
@@ -446,8 +490,10 @@ class Frontend:
|
||||
if every and self.frame % every == 0:
|
||||
digest = hashlib.sha1(self.last_frame).hexdigest()
|
||||
self.frame_hashes.append([self.frame, digest])
|
||||
if digest not in self.uniform and uniform_frame(self.last_frame, PIXEL_BYTES[self.pixel_format]):
|
||||
flat = digest in self.uniform or uniform_frame(self.last_frame, PIXEL_BYTES[self.pixel_format])
|
||||
if flat:
|
||||
self.uniform.add(digest)
|
||||
self.event("frame", frame=self.frame, sha1=digest, uniform=flat)
|
||||
png_dir = self.spec.get("png_dir")
|
||||
if png_dir and self.last_frame:
|
||||
rgb = to_rgb(self.pixel_format, *self.last_size, self.last_frame)
|
||||
@@ -483,6 +529,7 @@ def run_worker(spec: dict) -> dict:
|
||||
getattr(core, name)(fn)
|
||||
info = RetroSystemInfo()
|
||||
core.retro_get_system_info(ctypes.byref(info))
|
||||
front.event("info", library_name=info.library_name.decode(), library_version=info.library_version.decode())
|
||||
core.retro_init()
|
||||
game = None
|
||||
if spec.get("content"):
|
||||
@@ -494,7 +541,9 @@ def run_worker(spec: dict) -> dict:
|
||||
front.option_buffers.append(holder)
|
||||
game.data, game.size = ctypes.cast(holder, ctypes.c_void_p), len(blob)
|
||||
core.retro_load_game.restype = ctypes.c_bool
|
||||
if not core.retro_load_game(ctypes.byref(game) if game else None):
|
||||
loaded = bool(core.retro_load_game(ctypes.byref(game) if game else None))
|
||||
front.event("loaded", value=loaded)
|
||||
if not loaded:
|
||||
return {"loaded": False, "logs": front.logs, "library_name": info.library_name.decode()}
|
||||
# A frontend plugs a joypad into each port after loading, as RetroArch does.
|
||||
for port in range(spec["joypad_ports"]):
|
||||
@@ -619,10 +668,41 @@ def core_identity(args: argparse.Namespace) -> dict:
|
||||
# Runs (parent side)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Synthetic:
|
||||
"""A wrong file made for a negative test: `@zeros:SIZE` is SIZE zero
|
||||
bytes, `@head:SIZE:PATH` the first SIZE bytes of PATH."""
|
||||
|
||||
kind: str
|
||||
size: int
|
||||
source: Path | None = None
|
||||
|
||||
@classmethod
|
||||
def parse(cls, text: str) -> Synthetic:
|
||||
kind, _, rest = text[1:].partition(":")
|
||||
size_text, _, source = rest.partition(":")
|
||||
if kind not in ("zeros", "head") or not size_text.isdigit() or (kind == "head") != bool(source):
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"{text!r}: a made file is @zeros:SIZE or @head:SIZE:PATH")
|
||||
return cls(kind, int(size_text), Path(source) if source else None)
|
||||
|
||||
def label(self, dest: str) -> str:
|
||||
if self.kind == "zeros":
|
||||
return f"{dest}-zeros-{self.size}"
|
||||
return f"{dest}-head-{self.size}-of-{self.source.name}"
|
||||
|
||||
def write(self, out: Path) -> None:
|
||||
if self.kind == "zeros":
|
||||
out.write_bytes(bytes(self.size))
|
||||
return
|
||||
with self.source.open("rb") as handle:
|
||||
out.write_bytes(handle.read(self.size))
|
||||
|
||||
|
||||
@dataclass
|
||||
class Variant:
|
||||
label: str
|
||||
files: dict[str, Path | None] = field(default_factory=dict)
|
||||
files: dict[str, Path | Synthetic | None] = field(default_factory=dict)
|
||||
|
||||
|
||||
def stage_system_dir(base: dict[str, Path], variant: Variant, target: Path) -> dict[str, str | None]:
|
||||
@@ -632,10 +712,14 @@ def stage_system_dir(base: dict[str, Path], variant: Variant, target: Path) -> d
|
||||
for dest, src in {**base, **variant.files}.items():
|
||||
out = target / dest
|
||||
if src is None:
|
||||
out.unlink(missing_ok=True)
|
||||
staged[dest] = None
|
||||
continue
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(src, out)
|
||||
if isinstance(src, Synthetic):
|
||||
src.write(out)
|
||||
else:
|
||||
shutil.copyfile(src, out)
|
||||
staged[dest] = file_digest(out)
|
||||
return staged
|
||||
|
||||
@@ -781,6 +865,45 @@ def run_group(command: list[str], timeout: float, env: dict[str, str]) -> subpro
|
||||
return subprocess.CompletedProcess(command, proc.returncode, out, err)
|
||||
|
||||
|
||||
WORKER_TRACEBACK = "Traceback (most recent call last)"
|
||||
|
||||
|
||||
def exit_description(returncode: int) -> dict:
|
||||
"""The status of a worker that died. A signal reaches the probe as a
|
||||
negative code, or as 128 + its number through bubblewrap's init."""
|
||||
number = -returncode if returncode < 0 else returncode - 128 if 128 < returncode < 160 else 0
|
||||
name = None
|
||||
if number:
|
||||
with contextlib.suppress(ValueError):
|
||||
name = signal.Signals(number).name
|
||||
return {"status": returncode, "signal": name}
|
||||
|
||||
|
||||
def partial_result(events: Path, spec: dict) -> dict:
|
||||
"""A repetition rebuilt from the events a worker wrote before it died."""
|
||||
lines = []
|
||||
if events.is_file():
|
||||
for line in events.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
with contextlib.suppress(ValueError):
|
||||
lines.append(json.loads(line))
|
||||
info = next((e for e in lines if e.get("event") == "info"), {})
|
||||
loaded = next((e["value"] for e in lines if e.get("event") == "loaded"), False)
|
||||
frames = [e for e in lines if e.get("event") == "frame"]
|
||||
result: dict = {
|
||||
"loaded": bool(loaded),
|
||||
"library_name": info.get("library_name", ""),
|
||||
"logs": [{k: e[k] for k in ("frame", "level", "text")} for e in lines if e.get("event") == "log"],
|
||||
}
|
||||
if info.get("library_version"):
|
||||
result["library_version"] = info["library_version"]
|
||||
if spec["frame_hash_every"]:
|
||||
result["observables"] = {
|
||||
"frame_hashes": [[e["frame"], e["sha1"]] for e in frames],
|
||||
"uniform_frames": sorted({e["sha1"] for e in frames if e.get("uniform")}),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def run_once(args: argparse.Namespace, system_dir: Path, scratch: Path, state_out: Path | None,
|
||||
png_dir: Path | None) -> dict:
|
||||
save_dir = scratch / "save"
|
||||
@@ -800,6 +923,7 @@ def run_once(args: argparse.Namespace, system_dir: Path, scratch: Path, state_ou
|
||||
"png_dir": str(png_dir) if png_dir else None,
|
||||
"state_out": str(state_out) if state_out else None,
|
||||
"env": dict(args.env),
|
||||
"events": str((scratch / "events.jsonl").resolve()),
|
||||
}
|
||||
spec_path, out_path, trace_path = scratch / "spec.json", scratch / "result.json", scratch / "strace.txt"
|
||||
spec_path.write_text(json.dumps(spec), encoding="utf-8")
|
||||
@@ -816,10 +940,20 @@ def run_once(args: argparse.Namespace, system_dir: Path, scratch: Path, state_ou
|
||||
if args.require_sandbox and isolation != SANDBOX:
|
||||
raise ProbeError(f"refused to run the core outside a sandbox: {isolation}")
|
||||
# The core sees the variables it was given and nothing of the host's.
|
||||
proc = run_group(prefix + command, args.timeout, dict(args.env))
|
||||
if proc.returncode != 0 or not out_path.exists():
|
||||
raise ProbeError(f"worker exited {proc.returncode}: {proc.stderr.strip()[-2000:]}")
|
||||
result = json.loads(out_path.read_text(encoding="utf-8"))
|
||||
try:
|
||||
proc = run_group(prefix + command, args.timeout, dict(args.env))
|
||||
except subprocess.TimeoutExpired:
|
||||
result = partial_result(scratch / "events.jsonl", spec)
|
||||
result["exit"] = {"status": None, "signal": "SIGKILL", "timeout_s": args.timeout}
|
||||
else:
|
||||
if proc.returncode == 0 and out_path.exists():
|
||||
result = json.loads(out_path.read_text(encoding="utf-8"))
|
||||
elif WORKER_TRACEBACK in proc.stderr:
|
||||
# The probe's own code failed: no verdict on the core is drawn.
|
||||
raise ProbeError(f"worker exited {proc.returncode}: {proc.stderr.strip()[-2000:]}")
|
||||
else:
|
||||
result = partial_result(scratch / "events.jsonl", spec)
|
||||
result["exit"] = exit_description(proc.returncode)
|
||||
places = [(str(system_dir.resolve()), "<system>"), (str(save_dir.resolve()), "<save>")]
|
||||
if args.content:
|
||||
places.append((str(Path(args.content).resolve().parent), "<content>"))
|
||||
@@ -872,6 +1006,10 @@ def run_variant(args: argparse.Namespace, base: dict[str, Path], variant: Varian
|
||||
if state_out:
|
||||
state_out.parent.mkdir(parents=True, exist_ok=True)
|
||||
rep = run_once(args, system_dir, scratch, state_out, png_dir)
|
||||
oracle = oracle_for(args.oracle_data, args.content_sha1, rep.get("library_name"))
|
||||
rep["verdict"], rep["verdict_evidence"] = judge(rep, oracle)
|
||||
if rep.get("exit"):
|
||||
flags.append(f"repetition {index}: {rep['verdict_evidence']['detail']}")
|
||||
if not rep.get("loaded"):
|
||||
flags.append(f"repetition {index}: retro_load_game returned false")
|
||||
for entry in rep.get("files_opened") or []:
|
||||
@@ -885,11 +1023,14 @@ def run_variant(args: argparse.Namespace, base: dict[str, Path], variant: Varian
|
||||
if rep.get("observables") != reference:
|
||||
differing = sorted(k for k in (reference or {}) if (rep.get("observables") or {}).get(k) != reference[k])
|
||||
flags.append(f"repetition {index} disagrees with repetition 0 on {', '.join(differing)}")
|
||||
verdict, evidence = variant_verdict(reps)
|
||||
return {
|
||||
"label": variant.label,
|
||||
"system_files": {dest: sha for dest, sha in sorted(staged.items())},
|
||||
"repetitions": reps,
|
||||
"flags": flags,
|
||||
"verdict": verdict,
|
||||
"verdict_evidence": evidence,
|
||||
}
|
||||
|
||||
|
||||
@@ -946,12 +1087,18 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
help="refuse to record when the 1-minute load average exceeds this "
|
||||
"(default: a quarter of the CPU count)")
|
||||
common.add_argument("--work-dir", default=str(DEFAULT_WORK_DIR), help="scratch directory")
|
||||
common.add_argument("--timeout", type=int, default=1800, help="seconds per repetition")
|
||||
common.add_argument("--timeout", type=int, default=1800,
|
||||
help="seconds per repetition, after which the run is killed and judged a hang")
|
||||
common.add_argument("--oracle", default=None, metavar="FILE",
|
||||
help="reference frames and error patterns the verdicts are judged against "
|
||||
"(default: tests/campaign/oracle.yml)")
|
||||
common.add_argument("--no-trace", action="store_true", help="do not run the worker under strace")
|
||||
common.add_argument("--require-sandbox", action="store_true",
|
||||
help="refuse to run the core unless bubblewrap can isolate it")
|
||||
|
||||
sub.add_parser("run", parents=[common], help="one execution")
|
||||
run = sub.add_parser("run", parents=[common], help="one execution")
|
||||
run.add_argument("--repeat", type=int, default=1,
|
||||
help="runs, each in a fresh process: the verdict is the one they all agree on")
|
||||
bench = sub.add_parser("bench", parents=[common], help="timed repetitions")
|
||||
bench.add_argument("--repeat", type=int, default=MIN_BENCH_REPETITIONS,
|
||||
help=f"timed repetitions, each in a fresh process (at least {MIN_BENCH_REPETITIONS} to record)")
|
||||
@@ -993,12 +1140,16 @@ def validate_args(args: argparse.Namespace) -> list[str]:
|
||||
for dest, src in args.system_file:
|
||||
if not Path(src).is_file():
|
||||
problems.append(f"--system-file {dest}={src}: not a file")
|
||||
if args.repeat < 1:
|
||||
problems.append("--repeat must be >= 1")
|
||||
if args.frames < 1 or args.warmup < 0:
|
||||
problems.append("--frames must be >= 1 and --warmup >= 0")
|
||||
if args.require_sandbox:
|
||||
isolation = sandbox_argv([])[1]
|
||||
if isolation != SANDBOX:
|
||||
problems.append(f"--require-sandbox: {isolation}")
|
||||
if args.oracle and not Path(args.oracle).is_file():
|
||||
problems.append(f"--oracle {args.oracle}: not a file")
|
||||
if args.png_dir and not args.frame_hash_every:
|
||||
problems.append("--png-dir needs --frame-hash-every")
|
||||
if args.record:
|
||||
@@ -1022,7 +1173,15 @@ def validate_args(args: argparse.Namespace) -> list[str]:
|
||||
if len(dests) != 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():
|
||||
if src.startswith("@"):
|
||||
try:
|
||||
made = Synthetic.parse(src)
|
||||
except argparse.ArgumentTypeError as exc:
|
||||
problems.append(f"--vary {dest}: {exc}")
|
||||
continue
|
||||
if made.source and not made.source.is_file():
|
||||
problems.append(f"--vary {dest}={src}: {made.source} is not a file")
|
||||
elif src and not Path(src).is_file():
|
||||
problems.append(f"--vary {dest}={src}: not a file")
|
||||
names = set(BASE_OBSERVABLES) | {f"state:{v}" for v in args.state_var}
|
||||
if args.frame_hash_every:
|
||||
@@ -1038,11 +1197,12 @@ def variants_for(args: argparse.Namespace) -> list[Variant]:
|
||||
out: list[Variant] = []
|
||||
seen: set[str] = set()
|
||||
for dest, src in args.vary:
|
||||
label = Path(src).name if src else f"{dest}-absent"
|
||||
made = Synthetic.parse(src) if src.startswith("@") else None
|
||||
label = made.label(dest) if made else Path(src).name if src else f"{dest}-absent"
|
||||
while label in seen:
|
||||
label += "+"
|
||||
seen.add(label)
|
||||
out.append(Variant(label, {dest: Path(src) if src else None}))
|
||||
out.append(Variant(label, {dest: made or (Path(src) if src else None)}))
|
||||
return out
|
||||
|
||||
|
||||
@@ -1088,6 +1248,144 @@ def check_result(variants: list[dict], observable: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Boot oracle: what a run reached, from positive evidence
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
BOOTS, BIOS_ERROR, CRASH, HANG, UNKNOWN = "boots", "bios_error", "crash", "hang", "unknown"
|
||||
VERDICTS = (BOOTS, BIOS_ERROR, CRASH, HANG, UNKNOWN)
|
||||
DEFAULT_ORACLE = ROOT / "tests" / "campaign" / "oracle.yml"
|
||||
|
||||
|
||||
def oracle_for(data: dict, content_sha1: str | None, library_name: str | None) -> dict:
|
||||
"""The oracle entries that judge one content on one core: its reference
|
||||
frames, the error patterns every core shares and the core's own, the
|
||||
core's known error screens, and the log lines by which it says it loaded
|
||||
the console's own BIOS."""
|
||||
core = (data.get("cores") or {}).get(library_name or "") or {}
|
||||
references: list[dict] = []
|
||||
for item in data.get("content") or ():
|
||||
if content_sha1 and item.get("sha1") == content_sha1:
|
||||
block = (item.get("references") or {}).get(library_name or "") or {}
|
||||
references = [{"sha1": f["sha1"], "stage": f["stage"], "shows": f["shows"]}
|
||||
for f in block.get("frames") or ()]
|
||||
patterns = [p["pattern"] for p in (data.get("error_patterns") or ())]
|
||||
patterns += [p["pattern"] for p in core.get("error_patterns") or ()]
|
||||
screens = [{"sha1": s["sha1"], "shows": s["shows"]} for s in core.get("error_screens") or ()]
|
||||
loaded = [p["pattern"] for p in core.get("bios_patterns") or ()]
|
||||
return {"references": references, "error_patterns": patterns, "error_screens": screens,
|
||||
"bios_patterns": loaded}
|
||||
|
||||
|
||||
def sampled(rep: dict) -> list[tuple[int, str]]:
|
||||
observables = rep.get("observables") or {}
|
||||
return [(int(frame), digest) for frame, digest in observables.get("frame_hashes") or ()]
|
||||
|
||||
|
||||
def judge(rep: dict, oracle: dict) -> tuple[str, dict]:
|
||||
"""The verdict of one repetition and the evidence that decided it.
|
||||
|
||||
In order: a worker killed after the timeout is a hang and one that died
|
||||
a crash, from the process status. A reference frame of the game reached
|
||||
is a boot: the console's own screens alone are not. Without one, an error pattern of the core log or a known error screen
|
||||
is a BIOS error. Every sampled frame of the second half one colour is a
|
||||
hang. Anything else is unknown: a run is never called a boot because
|
||||
its frames differ from another run's.
|
||||
"""
|
||||
ended = rep.get("exit")
|
||||
if ended and ended.get("timeout_s") is not None:
|
||||
return HANG, {"rule": "timeout", "detail": f"killed after {ended['timeout_s']} s",
|
||||
"exit_status": ended.get("status"), "signal": ended.get("signal")}
|
||||
if ended:
|
||||
how = f"signal {ended['signal']}" if ended.get("signal") else f"exit status {ended.get('status')}"
|
||||
return CRASH, {"rule": "exit_status", "detail": f"the worker died: {how}",
|
||||
"exit_status": ended.get("status"), "signal": ended.get("signal")}
|
||||
frames = sampled(rep)
|
||||
final = (rep.get("observables") or {}).get("frame_sha1")
|
||||
if final:
|
||||
frames.append((int(rep.get("frames", 0)) + int(rep.get("warmup", 0)), final))
|
||||
references = {r["sha1"]: r for r in oracle.get("references") or ()}
|
||||
reached = [(frame, digest) for frame, digest in frames if digest in references]
|
||||
stages = list(dict.fromkeys(references[d]["stage"] for _, d in reached))
|
||||
# A core that hides the BIOS screens by default still says it loaded one.
|
||||
if "bios" not in stages and any(re.search(pattern, line["text"])
|
||||
for pattern in oracle.get("bios_patterns") or ()
|
||||
for line in rep.get("logs") or ()):
|
||||
stages.insert(0, "bios")
|
||||
game = [(frame, digest) for frame, digest in reached if references[digest]["stage"] == "game"]
|
||||
if game:
|
||||
frame, digest = game[0]
|
||||
return BOOTS, {"rule": "reference_frame",
|
||||
"detail": f"frame {frame} shows {references[digest]['shows']}",
|
||||
"frame": frame, "sha1": digest, "stages": stages}
|
||||
verdict, evidence = _without_the_game(rep, oracle, frames)
|
||||
if stages:
|
||||
# The console's own screens without one of the game: never a boot.
|
||||
evidence["stages"] = stages
|
||||
evidence["detail"] += ", after the BIOS screens and no screen of the game"
|
||||
return verdict, evidence
|
||||
|
||||
|
||||
def _without_the_game(rep: dict, oracle: dict, frames: list[tuple[int, str]]) -> tuple[str, dict]:
|
||||
"""The verdict of a run that reached no screen of the game."""
|
||||
for pattern in oracle.get("error_patterns") or ():
|
||||
for line in rep.get("logs") or ():
|
||||
if re.search(pattern, line["text"]):
|
||||
return BIOS_ERROR, {"rule": "error_pattern",
|
||||
"detail": f"the core logged an error at frame {line['frame']}",
|
||||
"pattern": pattern, "log": line["text"], "frame": line["frame"]}
|
||||
screens = {s["sha1"]: s for s in oracle.get("error_screens") or ()}
|
||||
for frame, digest in frames:
|
||||
if digest in screens:
|
||||
return BIOS_ERROR, {"rule": "error_screen",
|
||||
"detail": f"frame {frame} shows {screens[digest]['shows']}",
|
||||
"frame": frame, "sha1": digest}
|
||||
if not rep.get("loaded"):
|
||||
return UNKNOWN, {"rule": "no_evidence",
|
||||
"detail": "retro_load_game returned false and no error pattern matched"}
|
||||
uniform = set((rep.get("observables") or {}).get("uniform_frames") or ())
|
||||
tail = [digest for frame, digest in sampled(rep)][len(sampled(rep)) // 2:]
|
||||
if tail and all(digest in uniform for digest in tail):
|
||||
return HANG, {"rule": "uniform_tail",
|
||||
"detail": f"the last {len(tail)} sampled frames are one colour each"}
|
||||
if not oracle.get("references"):
|
||||
return UNKNOWN, {"rule": "no_evidence", "detail": "no reference frame is known for this "
|
||||
"content on this core"}
|
||||
return UNKNOWN, {"rule": "no_evidence", "detail": "no reference frame reached, no error pattern "
|
||||
"and no error screen"}
|
||||
|
||||
|
||||
def variant_verdict(reps: Sequence[dict]) -> tuple[str, dict]:
|
||||
"""The verdict all repetitions agree on, else unknown."""
|
||||
verdicts = [rep["verdict"] for rep in reps]
|
||||
if len(set(verdicts)) == 1:
|
||||
first = reps[0]["verdict_evidence"]
|
||||
evidence = {"rule": "repetitions_agree",
|
||||
"detail": f"{len(reps)} of {len(reps)} repetitions: {first['detail']}",
|
||||
"repetitions": verdicts}
|
||||
for key in ("frame", "sha1", "stages", "pattern", "log", "exit_status", "signal"):
|
||||
if key in first:
|
||||
evidence[key] = first[key]
|
||||
return verdicts[0], evidence
|
||||
return UNKNOWN, {"rule": "repetitions_disagree",
|
||||
"detail": f"the repetitions disagree: {', '.join(verdicts)}",
|
||||
"repetitions": verdicts}
|
||||
|
||||
|
||||
def load_oracle(path: Path | None) -> tuple[dict, dict | None]:
|
||||
"""The oracle file's data and its identity, or nothing without a file."""
|
||||
if path is None or not path.is_file():
|
||||
return {}, None
|
||||
import yaml # noqa: PLC0415
|
||||
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
try:
|
||||
shown = path.resolve().relative_to(ROOT).as_posix()
|
||||
except ValueError:
|
||||
shown = path.name
|
||||
return data, {"file": shown, "sha256": file_digest(path, "sha256")}
|
||||
|
||||
|
||||
def default_emulator(core_file: Path, library_name: str | None) -> str:
|
||||
"""The profile whose cores: names the .so, else the library name as a slug."""
|
||||
stem = core_file.name.split(".")[0].removesuffix("_libretro")
|
||||
@@ -1143,14 +1441,21 @@ def probe(args: argparse.Namespace) -> int:
|
||||
for problem in problems:
|
||||
print(f"ERROR: {problem}", file=sys.stderr)
|
||||
return 2
|
||||
with host_probe_lock():
|
||||
return _probe(args)
|
||||
|
||||
|
||||
def _probe(args: argparse.Namespace) -> int:
|
||||
max_load = args.max_load if args.max_load is not None else (os.cpu_count() or 1) / 4
|
||||
load_before = 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 (raise --max-load to override)", file=sys.stderr)
|
||||
return 3
|
||||
repetitions = 1 if args.mode == "run" else args.repeat
|
||||
repetitions = args.repeat
|
||||
base = {dest: Path(src) for dest, src in args.system_file}
|
||||
args.oracle_data, oracle_identity = load_oracle(Path(args.oracle) if args.oracle else DEFAULT_ORACLE)
|
||||
args.content_sha1 = file_digest(Path(args.content)) if args.content else None
|
||||
work = Path(tempfile.mkdtemp(prefix=f"{args.mode}-", dir=_ensure(Path(args.work_dir))))
|
||||
try:
|
||||
variants = [run_variant(args, base, variant, work / f"v{i}", repetitions)
|
||||
@@ -1188,6 +1493,9 @@ def probe(args: argparse.Namespace) -> int:
|
||||
+ (["frame_hashes", "uniform_frames"] if args.frame_hash_every else []),
|
||||
"frame_hash_every": args.frame_hash_every,
|
||||
"joypad_ports": args.joypad_ports,
|
||||
"oracle": ({**oracle_identity, **oracle_for(args.oracle_data, args.content_sha1,
|
||||
first.get("library_name"))}
|
||||
if oracle_identity else None),
|
||||
},
|
||||
"variants": variants,
|
||||
"flags": [f"{v['label']}: {flag}" for v in variants for flag in v["flags"]],
|
||||
@@ -1202,7 +1510,8 @@ def probe(args: argparse.Namespace) -> int:
|
||||
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)}
|
||||
"result": {**check_result(variants, args.observable),
|
||||
"verdicts": {v["label"]: v["verdict"] for v in variants}}}
|
||||
try:
|
||||
record["emulator"] = args.emulator or default_emulator(Path(args.core), first.get("library_name"))
|
||||
except ProbeError as exc:
|
||||
|
||||
@@ -7180,6 +7180,11 @@ def system_pages(
|
||||
_section_label(manufacturer, console),
|
||||
_install_pairs(installs.get(console, []), lead, consumers))
|
||||
|
||||
def region_block(console: str, level: int = 3) -> list[str]:
|
||||
# Under the console's heading on a maker's page, a section of its
|
||||
# own on the console's page.
|
||||
return sitemeasurements.region_section(_REGION_RESULTS.get((slug, console), []), level)
|
||||
|
||||
def all_sections() -> dict[str, list[Card]]:
|
||||
anchors: set[str] = set()
|
||||
return {console: cards_of(console, anchors) for console in ordered}
|
||||
@@ -7221,6 +7226,7 @@ def system_pages(
|
||||
lines.append("")
|
||||
lines.extend(start_of(console))
|
||||
lines.extend(install_block(console))
|
||||
lines.extend(region_block(console))
|
||||
lines.extend(_cards_markdown(cards))
|
||||
lines.append("")
|
||||
lines.extend(_next_block([*_NEXT_FROM_SYSTEMS, json_next]))
|
||||
@@ -7283,6 +7289,7 @@ def system_pages(
|
||||
if console not in moved:
|
||||
summary.extend(start_of(console))
|
||||
summary.extend(install_block(console))
|
||||
summary.extend(region_block(console))
|
||||
summary.extend(_cards_markdown(cards_of(console, summary_anchors)))
|
||||
summary.append("")
|
||||
continue
|
||||
@@ -7367,6 +7374,8 @@ def system_pages(
|
||||
lines.extend(legend)
|
||||
lines.extend(_romset_note({console: consoles[console]}))
|
||||
lines.extend(install_block(console))
|
||||
if index == 0:
|
||||
lines.extend(region_block(console, 2))
|
||||
lines.extend(["## Files", ""])
|
||||
lines.extend(_cards_markdown(part))
|
||||
lines.append("")
|
||||
@@ -9097,6 +9106,9 @@ _DATA_MEMBERS: dict[str, dict[str, dict]] = {}
|
||||
_DATA_REVISIONS: dict[str, dict] = {}
|
||||
# The measurement index items of each emulator, for its Tested block.
|
||||
_MEASUREMENT_ITEMS: dict[str, list[dict]] = {}
|
||||
# (manufacturer slug, console) -> the region outcomes of its systems
|
||||
# (region_check.py), shown under the console on its system page.
|
||||
_REGION_RESULTS: dict[tuple[str, str], list[dict]] = {}
|
||||
# The emulator pages that list each data directory, by ref.
|
||||
_DATA_READERS: dict[str, list[tuple[str, str]]] = {}
|
||||
|
||||
@@ -11833,6 +11845,13 @@ def _generate(args: argparse.Namespace) -> None:
|
||||
_system_pages_from_data(manufacturers, coverages, db, profiles)
|
||||
)
|
||||
print(f" {len(_system_page_map)} system IDs mapped to pages")
|
||||
from siterender import _page_by_norm # noqa: PLC0415
|
||||
|
||||
_REGION_RESULTS.clear()
|
||||
for result in sitemeasurements.site_region_results(repo=REPO_URL):
|
||||
place = _system_page_map.get(result["system"]) or _page_by_norm(result["system"])
|
||||
if place:
|
||||
_REGION_RESULTS.setdefault(place, []).append(result)
|
||||
set_console_sections(manufacturers, program_names(profiles))
|
||||
from siterender import link_coverage, system_ids # noqa: PLC0415
|
||||
|
||||
@@ -11887,7 +11906,9 @@ def _generate(args: argparse.Namespace) -> None:
|
||||
_MEASUREMENT_ITEMS.update(sitemeasurements.by_emulator(measured.index))
|
||||
print(f" {measured.index['records']} records, {measured.index['count']} tests")
|
||||
write_if_changed(str(docs / sitemeasurements.EXPLORER_PAGE),
|
||||
sitemeasurements.generate_explorer_page(measured.index) + reference_next())
|
||||
sitemeasurements.generate_explorer_page(
|
||||
measured.index, {sys_id: system_name(sys_id) for sys_id in sorted(set(_system_page_map))})
|
||||
+ reference_next())
|
||||
|
||||
print("Generating static API and bulk data exports...")
|
||||
exports = generate_data_exports(
|
||||
|
||||
@@ -27,7 +27,9 @@ from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
RECORD_VERSION = 3
|
||||
RECORD_VERSION = 4
|
||||
# 3: before the boot oracle. 4: every repetition and variant carries a verdict.
|
||||
RECORD_VERSIONS = (3, 4)
|
||||
RECORDS_DIR = "measurements"
|
||||
ID_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}-[0-9a-f]{12}$")
|
||||
SLUG_PATTERN = re.compile(r"^[a-z0-9]+(?:[-_][a-z0-9]+)*$")
|
||||
@@ -137,8 +139,8 @@ def check_line(record: dict) -> list[str]:
|
||||
"""The structural rules a line holds, without a JSON Schema validator."""
|
||||
rid = record.get("id", "")
|
||||
problems = []
|
||||
if record.get("record_version") != RECORD_VERSION:
|
||||
problems.append(f"{rid}: record_version {record.get('record_version')} != {RECORD_VERSION}")
|
||||
if record.get("record_version") not in RECORD_VERSIONS:
|
||||
problems.append(f"{rid}: record_version {record.get('record_version')} not in {RECORD_VERSIONS}")
|
||||
if not ID_PATTERN.match(str(rid)):
|
||||
problems.append(f"{rid!r}: not an id of the form YYYY-MM-DD-<12 hex>")
|
||||
elif str(record.get("recorded_at", ""))[:10] != rid[:10]:
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Region checks: which regional BIOS a core reads for a game, and whether
|
||||
the game boots without it.
|
||||
|
||||
For a system whose profile marks its BIOS entries with `region:`, each test
|
||||
game of a region (tests/campaign/content.yml) runs through core_probe four
|
||||
times, each a record in measurements/<emulator>.jsonl:
|
||||
|
||||
full every regional BIOS the profile declares
|
||||
own only the entries whose `region:` holds the game's region
|
||||
others only the entries of the other regions
|
||||
pack the files of `generate_pack.py --emulator E --region R`, at
|
||||
the place the pack puts them
|
||||
|
||||
The outcome reads the records: the file the core opened in the full run
|
||||
(from strace) settles the `region:` claim, the verdicts of `own` and
|
||||
`others` say whether the BIOS is locked to the region, and `pack` says
|
||||
whether the pack filtered for the region boots the region's game.
|
||||
|
||||
run the four runs for each region game of one core and system
|
||||
report the outcome of every region record already in measurements/
|
||||
|
||||
A maintainer starts a run. Nothing here runs on a timer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from collections.abc import Callable, Iterable
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import yaml # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CONTENT_FILE = ROOT / "tests" / "campaign" / "content.yml"
|
||||
CORES = ROOT / ".cache" / "region" / "cores"
|
||||
NIGHTLY = "https://buildbot.libretro.com/nightly/linux/x86_64/latest/{core}_libretro.so.zip"
|
||||
VARIANTS = ("full", "own", "others", "pack")
|
||||
SUBJECT = re.compile(r"^region-(?P<core>[a-z0-9_]+)-(?P<game>[a-z0-9-]+)-(?P<variant>full|own|others|pack)$")
|
||||
# The territory a test game names, as generate_pack --region takes it.
|
||||
PACK_REGION = {"japan": "jp", "north-america": "us", "europe": "eu"}
|
||||
REGION_WORDS = {"japan": "Japanese", "north-america": "US", "europe": "European"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Pure rules
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def regions_of(entry: dict) -> list[str]:
|
||||
regions = entry.get("region") or []
|
||||
return [regions] if isinstance(regions, str) else list(regions)
|
||||
|
||||
|
||||
def entry_dest(entry: dict) -> str:
|
||||
path = entry.get("path")
|
||||
if isinstance(path, str) and path and not path.endswith("/"):
|
||||
return path
|
||||
return entry["name"]
|
||||
|
||||
|
||||
def region_entries(profile: dict, system: str) -> list[dict]:
|
||||
"""The BIOS entries of a system that a `region:` ties to territories."""
|
||||
out = []
|
||||
for entry in profile.get("files") or ():
|
||||
systems = entry.get("systems") or ([entry["system"]] if entry.get("system")
|
||||
else profile.get("systems") or [])
|
||||
if (system in systems and regions_of(entry) and "world" not in regions_of(entry)
|
||||
and entry.get("category", "bios") == "bios" and entry.get("mode") != "standalone"):
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def plan_variants(entries: Iterable[dict], region: str,
|
||||
local: Callable[[dict], Path | None]) -> dict[str, dict[str, Path]]:
|
||||
"""The staged files of `full`, `own` and `others`, dest -> file. An entry
|
||||
the collection does not hold is left out of every variant."""
|
||||
held = [(entry, local(entry)) for entry in entries]
|
||||
held = [(entry, path) for entry, path in held if path]
|
||||
full = {entry_dest(e): p for e, p in held}
|
||||
own = {entry_dest(e): p for e, p in held if region in regions_of(e)}
|
||||
others = {entry_dest(e): p for e, p in held if region not in regions_of(e)}
|
||||
return {"full": full, "own": own, "others": others}
|
||||
|
||||
|
||||
def subject(core: str, game: str, variant: str) -> str:
|
||||
words = re.findall(r"[a-z0-9]+", game.lower())
|
||||
return f"region-{core.lower().replace('-', '_')}-{'-'.join(words)[:60].strip('-')}-{variant}"
|
||||
|
||||
|
||||
def opened(record: dict) -> set[str]:
|
||||
return {item["path"] for variant in record["variants"] for rep in variant["repetitions"]
|
||||
for item in rep.get("files_opened") or ()}
|
||||
|
||||
|
||||
def frames(record: dict) -> list:
|
||||
rep = record["variants"][0]["repetitions"][0]
|
||||
return (rep.get("observables") or {}).get("frame_hashes") or []
|
||||
|
||||
|
||||
def verdict(record: dict) -> str:
|
||||
return record["variants"][0].get("verdict") or "unknown"
|
||||
|
||||
|
||||
def outcome(records: dict[str, dict], entries: list[dict], region: str) -> dict:
|
||||
"""What the four records of one game on one core settle.
|
||||
|
||||
`selected` is the regional file the core opened with every one staged.
|
||||
The `region:` claim is `confirmed` when every regional file it opened
|
||||
holds the game's region, `contradicted` when none does, `ambiguous`
|
||||
when it opened its own region's file and others too, `unread` when it
|
||||
opened none. Each variant's result is its oracle verdict. While no
|
||||
reference frame is known for the game, a variant that samples the same
|
||||
frames as the full run (`as_full`) boots, and one that does not stays
|
||||
without an answer: frames that differ never make a verdict.
|
||||
"""
|
||||
by_dest = {entry_dest(e): e for e in entries}
|
||||
full = records.get("full")
|
||||
selected = sorted(opened(full) & set(by_dest)) if full else []
|
||||
if not full:
|
||||
claim = "unrun"
|
||||
elif not selected:
|
||||
claim = "unread"
|
||||
elif all(region in regions_of(by_dest[d]) for d in selected):
|
||||
claim = "confirmed"
|
||||
elif any(region in regions_of(by_dest[d]) for d in selected):
|
||||
# Opened every region's file, to hash them or to choose: the open
|
||||
# alone does not say which one the core runs.
|
||||
claim = "ambiguous"
|
||||
else:
|
||||
claim = "contradicted"
|
||||
results: dict[str, dict] = {}
|
||||
for label in VARIANTS:
|
||||
record = records.get(label)
|
||||
if not record:
|
||||
continue
|
||||
loaded = all(rep.get("loaded") for v in record["variants"] for rep in v["repetitions"])
|
||||
item = {"record": record["id"], "verdict": verdict(record), "loaded": loaded,
|
||||
"staged": sorted(record["variants"][0]["system_files"])}
|
||||
if full and label != "full":
|
||||
item["as_full"] = loaded and frames(record) == frames(full)
|
||||
results[label] = item
|
||||
|
||||
full_verdict = verdict(full) if full else None
|
||||
|
||||
def boots(label: str) -> bool | None:
|
||||
item = results.get(label)
|
||||
if not item:
|
||||
return None
|
||||
if item["verdict"] != "unknown":
|
||||
return item["verdict"] == "boots"
|
||||
# No verdict: the interim rule. The same frames as the full run is a
|
||||
# boot, while the full run itself is not shown to fail. Other frames
|
||||
# say nothing: the US disc reaches its game on the Japanese and
|
||||
# European BIOS through another licence screen.
|
||||
if full_verdict in ("boots", "unknown") and item.get("as_full"):
|
||||
return True
|
||||
return None
|
||||
|
||||
return {
|
||||
"region": region,
|
||||
"selected": [by_dest[d]["name"] for d in selected],
|
||||
"claim": claim,
|
||||
"variants": results,
|
||||
"own_boots": boots("own"),
|
||||
"others_boot": boots("others"),
|
||||
"pack_boots": boots("pack"),
|
||||
"judged_by": "verdict" if full_verdict not in (None, "unknown") else "full_run_frames",
|
||||
}
|
||||
|
||||
|
||||
def sentence(game_region: str, result: dict) -> str:
|
||||
"""The outcome in words, for the site."""
|
||||
word = REGION_WORDS.get(game_region, game_region)
|
||||
own, others = result.get("own_boots"), result.get("others_boot")
|
||||
if own is None:
|
||||
return f"The {word} game has no run with its own BIOS yet."
|
||||
first = f"The {word} BIOS {'boots' if own else 'does not boot'} the {word} game"
|
||||
if others is None:
|
||||
return first + ". The other regions' BIOS have no verdict yet."
|
||||
if others:
|
||||
return first + ", and so do the other regions' BIOS."
|
||||
return first + ", the other regions' BIOS do not."
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Runs
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def load_content(path: Path = CONTENT_FILE) -> dict[str, list[dict]]:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
out: dict[str, list[dict]] = {}
|
||||
for system, spec in (data.get("systems") or {}).items():
|
||||
for item in spec.get("content") or ():
|
||||
out.setdefault(system, []).append({**item, "romfetch": spec["romfetch"]})
|
||||
return out
|
||||
|
||||
|
||||
def content_file(item: dict, content_dir: Path) -> Path:
|
||||
folder = content_dir / item["romfetch"] / item["name"]
|
||||
if item.get("load") == "cue":
|
||||
cues = sorted(folder.glob("*.cue"))
|
||||
if len(cues) != 1:
|
||||
raise FileNotFoundError(f"{folder}: {len(cues)} .cue files, one expected")
|
||||
return cues[0]
|
||||
if item.get("load"):
|
||||
return folder / item["load"]
|
||||
files = [p for p in sorted(folder.iterdir()) if p.is_file() and p.name != "identity.json"]
|
||||
if len(files) != 1:
|
||||
raise FileNotFoundError(f"{folder}: {len(files)} files, name one with load:")
|
||||
return files[0]
|
||||
|
||||
|
||||
def nightly_core(core: str) -> tuple[Path, str]:
|
||||
"""The buildbot nightly of a core, downloaded once, and its URL."""
|
||||
import zipfile # noqa: PLC0415
|
||||
|
||||
url = NIGHTLY.format(core=core)
|
||||
target = CORES / f"{core}_libretro.so"
|
||||
if not target.is_file():
|
||||
CORES.mkdir(parents=True, exist_ok=True)
|
||||
archive = CORES / f"{core}.zip"
|
||||
with urllib.request.urlopen(url, timeout=300) as response, archive.open("wb") as out:
|
||||
out.write(response.read())
|
||||
with zipfile.ZipFile(archive) as handle:
|
||||
member = f"{core}_libretro.so"
|
||||
if member not in handle.namelist():
|
||||
raise FileNotFoundError(f"{archive.name}: no {member} inside")
|
||||
partial = target.with_suffix(".part")
|
||||
partial.write_bytes(handle.read(member))
|
||||
partial.replace(target)
|
||||
archive.unlink()
|
||||
return target, url
|
||||
|
||||
|
||||
def pack_files(emulator: str, region: str, work: Path) -> dict[str, Path]:
|
||||
"""The files generate_pack puts in the emulator's pack for one region,
|
||||
extracted, by their path in the pack."""
|
||||
from ziptools import safe_extract_zip # noqa: PLC0415
|
||||
|
||||
out_dir = work / "pack"
|
||||
subprocess.run([sys.executable, str(ROOT / "scripts" / "generate_pack.py"), "--emulator", emulator,
|
||||
"--region", PACK_REGION[region], "--output-dir", str(out_dir)],
|
||||
cwd=ROOT, check=True, capture_output=True, text=True)
|
||||
zips = sorted(out_dir.glob("*.zip"))
|
||||
if len(zips) != 1:
|
||||
raise FileNotFoundError(f"{out_dir}: {len(zips)} packs, one expected")
|
||||
tree = work / "pack-tree"
|
||||
safe_extract_zip(str(zips[0]), str(tree))
|
||||
return {p.relative_to(tree).as_posix(): p for p in sorted(tree.rglob("*")) if p.is_file()
|
||||
and not p.name.upper().startswith("README")}
|
||||
|
||||
|
||||
def local_resolver() -> Callable[[dict], Path | None]:
|
||||
from common import load_database, resolve_local_file # noqa: PLC0415
|
||||
|
||||
db = load_database(str(ROOT / "database.json"))
|
||||
|
||||
def local(entry: dict) -> Path | None:
|
||||
hint = entry.get("path") if isinstance(entry.get("path"), str) else ""
|
||||
path, status = resolve_local_file(entry, db, dest_hint=hint or "")
|
||||
if not path or status in ("not_found", "hash_mismatch"):
|
||||
return None
|
||||
found = Path(path) if Path(path).is_absolute() else ROOT / path
|
||||
return found if found.is_file() else None
|
||||
|
||||
return local
|
||||
|
||||
|
||||
BUSY = 3 # core_probe's exit status when it refuses to record on a busy host
|
||||
POLL_S = 15
|
||||
|
||||
|
||||
def probe_when_idle(argv: list[str], max_wait: float,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
pause: Callable[[float], None] = time.sleep,
|
||||
runner: Callable[..., subprocess.CompletedProcess] = subprocess.run
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""core_probe, run again while it refuses a busy host, as the campaign
|
||||
does: every POLL_S seconds until max_wait seconds have passed. The load
|
||||
threshold stays core_probe's own."""
|
||||
started = clock()
|
||||
while True:
|
||||
proc = runner(argv, cwd=ROOT, capture_output=True, text=True)
|
||||
if proc.returncode != BUSY or clock() - started >= max_wait:
|
||||
return proc
|
||||
pause(POLL_S)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
from common import load_emulator_profiles # noqa: PLC0415
|
||||
|
||||
profile = load_emulator_profiles(str(ROOT / "emulators"))[args.emulator]
|
||||
entries = region_entries(profile, args.system)
|
||||
if not entries:
|
||||
print(f"ERROR: {args.emulator} declares no regional BIOS for {args.system}", file=sys.stderr)
|
||||
return 2
|
||||
games = [g for g in load_content().get(args.system, []) if g.get("region") in PACK_REGION]
|
||||
if not games:
|
||||
print(f"ERROR: content.yml has no regional game for {args.system}", file=sys.stderr)
|
||||
return 2
|
||||
core, url = (Path(args.core), args.core_artifact) if args.core else nightly_core(args.core_name)
|
||||
local = local_resolver()
|
||||
failures = 0
|
||||
for game in games:
|
||||
load = content_file(game, Path(args.content_dir))
|
||||
with tempfile.TemporaryDirectory(dir=ROOT / "tmp") as scratch:
|
||||
variants = plan_variants(entries, game["region"], local)
|
||||
variants["pack"] = pack_files(args.emulator, game["region"], Path(scratch))
|
||||
for label in args.variants or VARIANTS:
|
||||
argv = [sys.executable, str(ROOT / "scripts" / "core_probe.py"), "run",
|
||||
"--core", str(core), "--content", str(load), "--content-label", game["name"],
|
||||
"--frames", str(game.get("frames", 3600)),
|
||||
"--frame-hash-every", str(game.get("frame_hash_every", 10)),
|
||||
"--record", str(ROOT), "--emulator", args.emulator,
|
||||
"--subject", subject(core.name.removesuffix("_libretro.so"), game["name"], label),
|
||||
"--require-sandbox", "--timeout", str(args.timeout)]
|
||||
if url:
|
||||
argv += ["--core-artifact", url]
|
||||
for dest, src in sorted(variants[label].items()):
|
||||
argv += ["--system-file", f"{dest}={src}"]
|
||||
proc = probe_when_idle(argv, args.max_load_wait)
|
||||
status = "recorded" if proc.returncode == 0 else f"exit {proc.returncode}"
|
||||
print(json.dumps({"game": game["name"], "variant": label, "status": status,
|
||||
"error": proc.stderr.strip()[-400:] if proc.returncode else None}),
|
||||
flush=True)
|
||||
failures += proc.returncode != 0
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
def collect(root: Path = ROOT, stored_records: list | None = None) -> list[dict]:
|
||||
"""Every region outcome the records under measurements/ hold. A game
|
||||
listed under several systems (sega-megacd and mega-cd) is judged under
|
||||
the one whose profile ran it."""
|
||||
from common import load_emulator_profiles # noqa: PLC0415
|
||||
from measurement_records import live, read_records # noqa: PLC0415
|
||||
|
||||
content: dict[str, list[tuple[str, dict]]] = {}
|
||||
for system, items in load_content(root / "tests" / "campaign" / "content.yml").items():
|
||||
for item in items:
|
||||
content.setdefault(item["name"], []).append((system, item))
|
||||
profiles = load_emulator_profiles(str(root / "emulators"))
|
||||
groups: dict[tuple[str, str, str], dict[str, dict]] = {}
|
||||
for stored in read_records(root) if stored_records is None else stored_records:
|
||||
match = SUBJECT.match(stored.record.get("subject", ""))
|
||||
label = ((stored.record.get("inputs") or {}).get("content") or {}).get("label")
|
||||
if not match or label not in content:
|
||||
continue
|
||||
key = (stored.emulator, match["core"], label)
|
||||
groups.setdefault(key, {})[match["variant"]] = stored.record
|
||||
out = []
|
||||
for (emulator, core, game), records in sorted(groups.items()):
|
||||
records = {k: v for k, v in records.items() if v["id"] in {r["id"] for r in live(records.values())}}
|
||||
profile = profiles.get(emulator, {})
|
||||
system, item = next(((s, i) for s, i in content[game] if s in (profile.get("systems") or ())),
|
||||
content[game][0])
|
||||
entries = region_entries(profile, system)
|
||||
result = outcome(records, entries, item["region"])
|
||||
out.append({"emulator": emulator, "core": core, "system": system, "game": game, **result,
|
||||
"sentence": sentence(item["region"], result)})
|
||||
return out
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(allow_abbrev=False, description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
go = sub.add_parser("run", allow_abbrev=False, help="the four runs of each region game, recorded")
|
||||
go.add_argument("--emulator", required=True, help="the profile, e.g. beetle_psx")
|
||||
go.add_argument("--system", required=True, help="the system, e.g. sony-playstation")
|
||||
go.add_argument("--core-name", help="buildbot core name, e.g. mednafen_psx (downloads the nightly)")
|
||||
go.add_argument("--core", help="a core .so to use instead of the nightly")
|
||||
go.add_argument("--core-artifact", help="where --core was downloaded from")
|
||||
go.add_argument("--content-dir", default=str(ROOT / ".cache" / "test-content"),
|
||||
help="where romfetch keeps the test games (default: .cache/test-content)")
|
||||
go.add_argument("--variants", nargs="*", choices=VARIANTS, help="a subset of the four runs")
|
||||
go.add_argument("--timeout", type=int, default=900, help="seconds per run")
|
||||
go.add_argument("--max-load-wait", type=int, default=1800,
|
||||
help="seconds a run waits for a busy host to settle before it is given up (default 1800)")
|
||||
sub.add_parser("report", allow_abbrev=False, help="the outcomes the records hold, as JSON")
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == "run":
|
||||
if not (args.core or args.core_name):
|
||||
parser.error("run needs --core-name or --core")
|
||||
return run(args)
|
||||
print(json.dumps(collect(), indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from common import run_cli
|
||||
|
||||
run_cli(main)
|
||||
+630
-78
@@ -18,17 +18,16 @@ its line at the last commit that touched measurements/.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import measurement_records as mr
|
||||
import siteentries
|
||||
import sitemarkup as markup
|
||||
from artifacts import json_text, write_if_changed
|
||||
from siterender import _escape_tags
|
||||
@@ -41,7 +40,7 @@ HOSTS_MARKER = "<!-- measurements-hosts -->"
|
||||
INDEX_PATH = Path("data") / "measurements" / "index.json"
|
||||
SQLITE_PATH = Path("downloads") / "measurements.sqlite"
|
||||
PER_EMULATOR_DIR = Path("downloads") / "measurements"
|
||||
SQLITE_SCHEMA_VERSION = "2"
|
||||
SQLITE_SCHEMA_VERSION = "3"
|
||||
# Observables the variants table leaves out: one value per sampled frame.
|
||||
BULKY_OBSERVABLES = ("frame_hashes",)
|
||||
INDEX_SCHEMA_VERSION = 1
|
||||
@@ -76,6 +75,40 @@ def permalink(repo: str, commit: str, path: str, line: int | None = None) -> str
|
||||
return f"{repo}/blob/{commit}/{path}" + (f"#L{line}" if line else "")
|
||||
|
||||
|
||||
def verdicts_of(record: dict) -> list[dict]:
|
||||
"""Each variant's boot verdict, from record version 4 on."""
|
||||
return [{"variant": v["label"], "verdict": v["verdict"],
|
||||
"detail": (v.get("verdict_evidence") or {}).get("detail", "")}
|
||||
for v in record["variants"] if "verdict" in v]
|
||||
|
||||
|
||||
def result_of(record: dict) -> tuple[str, str]:
|
||||
"""The status and statement a page shows for a record.
|
||||
|
||||
A run, or a check compared on the sampled frames, is shown by its boot
|
||||
verdicts: one verdict when every variant has it, else `mixed`, each
|
||||
variant named in the statement. A check on another observable keeps
|
||||
its comparison. A frame comparison recorded before the boot oracle
|
||||
shows `no verdict`: frames that differ prove nothing about a boot.
|
||||
"""
|
||||
check = record.get("check") or {}
|
||||
result = check.get("result") or {}
|
||||
if record["kind"] == "bench":
|
||||
return "measured", bench_statement(record) if record.get("timing") else ""
|
||||
verdicts = verdicts_of(record)
|
||||
by_frames = record["kind"] == "run" or check.get("observable") == "frame_hashes"
|
||||
if verdicts and by_frames:
|
||||
values = {v["verdict"] for v in verdicts}
|
||||
status = values.pop() if len(values) == 1 else "mixed"
|
||||
if record["kind"] == "run":
|
||||
return status, verdicts[0]["detail"]
|
||||
return status, ". ".join(f"{v['variant']}: {v['verdict']}" for v in verdicts)
|
||||
if by_frames:
|
||||
return "no verdict", ("recorded before the boot oracle. Its frames were compared, which "
|
||||
"settles nothing about a boot")
|
||||
return result.get("status") or "ran", result.get("statement") or ""
|
||||
|
||||
|
||||
def index_item(entry: mr.Stored, runs: int, source: dict, profiles: dict,
|
||||
system_names: dict[str, str]) -> dict:
|
||||
record = entry.record
|
||||
@@ -95,9 +128,9 @@ def index_item(entry: mr.Stored, runs: int, source: dict, profiles: dict,
|
||||
"method": check.get("method"),
|
||||
"observable": check.get("observable"),
|
||||
"variable": check.get("variable"),
|
||||
"status": (check.get("result") or {}).get("status") or ("measured" if record["kind"] == "bench" else "ran"),
|
||||
"statement": (check.get("result") or {}).get("statement")
|
||||
or (bench_statement(record) if record.get("timing") else ""),
|
||||
"status": result_of(record)[0],
|
||||
"statement": result_of(record)[1],
|
||||
"verdicts": verdicts_of(record),
|
||||
"content": content.get("label"),
|
||||
"variants": len(record["variants"]),
|
||||
"repetitions": record["inputs"]["repetitions"],
|
||||
@@ -114,9 +147,21 @@ def index_item(entry: mr.Stored, runs: int, source: dict, profiles: dict,
|
||||
"runs": runs,
|
||||
"record_url": permalink(source["repo"], source["commit"], mr.records_path(entry.emulator), entry.line),
|
||||
"sqlite": f"downloads/measurements/{entry.emulator}.sqlite",
|
||||
"host_short": short_host(record["host"]),
|
||||
"setup": setup_of(record),
|
||||
"groups": variant_groups(record),
|
||||
"proves": proves(record["id"], profile),
|
||||
"timing": ({"ms_per_frame": record["timing"]["ms_per_frame"]["median"],
|
||||
"fps": record["timing"]["fps"]["median"],
|
||||
"stdev": record["timing"]["ms_per_frame"]["stdev"]}
|
||||
if record.get("timing") else None),
|
||||
}
|
||||
|
||||
|
||||
def with_title(item: dict) -> dict:
|
||||
return {**item, "title": short_title(item)}
|
||||
|
||||
|
||||
def build_index(stored: list[mr.Stored], source: dict | None, profiles: dict,
|
||||
system_names: dict[str, str] | None = None) -> dict:
|
||||
"""One item per (emulator, subject): its latest record not superseded."""
|
||||
@@ -129,7 +174,8 @@ def build_index(stored: list[mr.Stored], source: dict | None, profiles: dict,
|
||||
for key in sorted(by_subject):
|
||||
entries = by_subject[key]
|
||||
latest = max(entries, key=lambda s: (s.record["recorded_at"], s.line))
|
||||
items.append(index_item(latest, len(entries), source or {}, profiles, system_names or {}))
|
||||
items.append(with_title(index_item(latest, len(entries), source or {}, profiles,
|
||||
system_names or {})))
|
||||
items.sort(key=lambda item: (item["emulator"], item["date"], item["subject"]))
|
||||
return {
|
||||
"schema_version": INDEX_SCHEMA_VERSION,
|
||||
@@ -206,7 +252,8 @@ def campaign_section(summary: dict | None) -> list[str]:
|
||||
lines = [
|
||||
"## Campaign",
|
||||
"",
|
||||
f"Tests run by themselves every night on an idle computer ([how]({METHOD_PAGE})). "
|
||||
f"A maintainer starts a pass, on an idle computer, and nothing runs by itself "
|
||||
f"([how]({METHOD_PAGE})). "
|
||||
f"Last pass: {_plain(last['ended_at'][:10])}, {_plain(last['result'])}"
|
||||
+ (f" ({_plain(last['detail'])})" if last.get("detail") else "") + ". "
|
||||
f"{int(counts['done'])} tests done, {int(counts['failed'])} failed, {int(counts['pending'])} waiting. "
|
||||
@@ -241,6 +288,176 @@ def campaign_section(summary: dict | None) -> list[str]:
|
||||
return lines
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Coverage and region results
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
CONTENT_FILE = Path("tests") / "campaign" / "content.yml"
|
||||
|
||||
|
||||
def coverage(root: Path, stored: list[mr.Stored], profiles: dict | None = None) -> dict:
|
||||
"""Per system of the content list: its games, and which emulators ran
|
||||
each one. A record counts for a system only when its emulator's profile
|
||||
names that system: one catalog name can be a PlayStation disc and a
|
||||
Saturn disc. A system no catalog covers comes with the reason and the
|
||||
error romfetch returned."""
|
||||
import yaml # noqa: PLC0415
|
||||
|
||||
path = root / CONTENT_FILE
|
||||
if not path.is_file():
|
||||
return {"systems": [], "uncovered": []}
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
ran: dict[str, set[str]] = {}
|
||||
for entry in stored:
|
||||
label = ((entry.record.get("inputs") or {}).get("content") or {}).get("label")
|
||||
if label:
|
||||
ran.setdefault(label, set()).add(entry.emulator)
|
||||
systems = []
|
||||
for system, spec in (data.get("systems") or {}).items():
|
||||
games = [{"name": item["name"], "region": item.get("region"),
|
||||
"emulators": sorted(e for e in ran.get(item["name"], ())
|
||||
if profiles is None
|
||||
or system in ((profiles.get(e) or {}).get("systems") or ()))}
|
||||
for item in spec.get("content") or ()]
|
||||
listed = {item["name"] for item in spec.get("content") or ()}
|
||||
# Runs of this system's emulators on games the list does not name.
|
||||
other = sorted({e for label, emulators in ran.items() if label not in listed for e in emulators
|
||||
if profiles is not None
|
||||
and system in ((profiles.get(e) or {}).get("systems") or ())})
|
||||
systems.append({"system": system, "games": games, "other_games_by": other,
|
||||
"tested": any(game["emulators"] for game in games)})
|
||||
uncovered = [{"system": system, "want": item.get("want"), "reason": " ".join(str(item["reason"]).split()),
|
||||
"romfetch": item.get("romfetch") or {}}
|
||||
for system, item in (data.get("uncovered") or {}).items()]
|
||||
return {"systems": systems, "uncovered": uncovered}
|
||||
|
||||
|
||||
def coverage_section(cov: dict | None, system_names: dict[str, str] | None = None) -> list[str]:
|
||||
"""The Coverage block of the explorer page: tested or not, and why not."""
|
||||
if not cov or not (cov.get("systems") or cov.get("uncovered")):
|
||||
return []
|
||||
names = system_names or {}
|
||||
tested = [s for s in cov["systems"] if s["tested"]]
|
||||
lines = [
|
||||
"## Coverage",
|
||||
"",
|
||||
f"Test games come from [`{CONTENT_FILE.as_posix()}`]({REPO}/blob/main/{CONTENT_FILE.as_posix()}), "
|
||||
f"one per region where a BIOS depends on it. {len(tested)} of {len(cov['systems'])} listed "
|
||||
f"systems have a recorded run, and {len(cov['uncovered'])} "
|
||||
f"{'system has' if len(cov['uncovered']) == 1 else 'systems have'} no test game.",
|
||||
"",
|
||||
"| System | Games | Tested by |",
|
||||
"|--------|-------|-----------|",
|
||||
]
|
||||
# Two ids of one console (sega-megacd, mega-cd) share their list: one row.
|
||||
rows: dict[tuple[str, str], set[str]] = {}
|
||||
others: dict[tuple[str, str], set[str]] = {}
|
||||
for item in cov["systems"]:
|
||||
row = (_plain(names.get(item["system"], item["system"])),
|
||||
", ".join(_plain(game["name"]) for game in item["games"]))
|
||||
rows.setdefault(row, set()).update(e for game in item["games"] for e in game["emulators"])
|
||||
others.setdefault(row, set()).update(item.get("other_games_by") or ())
|
||||
for (name, games), by in rows.items():
|
||||
if by:
|
||||
status = ", ".join(_plain(e) for e in sorted(by))
|
||||
elif others[(name, games)]:
|
||||
status = ("not yet with these games, other games by "
|
||||
+ ", ".join(_plain(e) for e in sorted(others[(name, games)])))
|
||||
else:
|
||||
status = "not yet: no run recorded"
|
||||
lines.append(f"| {name} | {games} | {status} |")
|
||||
lines.append("")
|
||||
if cov["uncovered"]:
|
||||
lines += ["Systems without a test game:", "",
|
||||
"| System | Wanted | Why not | romfetch |",
|
||||
"|--------|--------|---------|----------|"]
|
||||
for item in cov["uncovered"]:
|
||||
code = item["romfetch"].get("code") or "no source"
|
||||
lines.append(f"| {_plain(names.get(item['system'], item['system']))} | {_plain(item['want'])} | "
|
||||
f"{_plain(item['reason'])} | exit {_plain(item['romfetch'].get('exit'))}, "
|
||||
f"{_code(code)} |")
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
|
||||
def region_results(root: Path, stored: list[mr.Stored], source: dict | None) -> list[dict]:
|
||||
"""The region outcomes the records hold (region_check.py), each record
|
||||
linked at the commit the explorer links."""
|
||||
import region_check # noqa: PLC0415
|
||||
|
||||
lines = {s.record["id"]: s for s in stored}
|
||||
results = region_check.collect(root, stored)
|
||||
for result in results:
|
||||
for item in result["variants"].values():
|
||||
found = lines.get(item["record"])
|
||||
item["url"] = (permalink(source["repo"], source["commit"], mr.records_path(found.emulator),
|
||||
found.line)
|
||||
if found and source and source.get("commit") else None)
|
||||
return results
|
||||
|
||||
|
||||
def site_region_results(root: Path = ROOT, repo: str = REPO) -> list[dict]:
|
||||
"""region_results with the records and commit the explorer uses."""
|
||||
stored = mr.read_records(root)
|
||||
source = {"repo": repo, "commit": records_commit(root) if stored else None}
|
||||
return region_results(root, stored, source)
|
||||
|
||||
|
||||
REGION_SUBJECT = re.compile(r"^region-[a-z0-9_]+-[a-z0-9-]+-(full|own|others|pack)$")
|
||||
|
||||
VARIANT_WORDS = {"full": "every regional BIOS", "own": "its own region's BIOS only",
|
||||
"others": "the other regions' BIOS only", "pack": "the region's pack"}
|
||||
|
||||
|
||||
REGION_SHORT = {"japan": "Japanese", "north-america": "US", "europe": "European"}
|
||||
OUTCOME_WORDS = {True: "boots", False: "does not boot", None: "no verdict"}
|
||||
|
||||
|
||||
def core_region_line(results: list[dict]) -> str:
|
||||
"""One core's outcome over its region games, in a sentence: each game
|
||||
on its own BIOS, then on the other regions' BIOS."""
|
||||
own = {r["own_boots"] for r in results}
|
||||
others = {r["others_boot"] for r in results}
|
||||
if own == {True} and others == {True}:
|
||||
return "Each region's game boots on its own BIOS and on the other regions' BIOS."
|
||||
if own == {True} and others == {False}:
|
||||
return "Each region's game boots on its own BIOS only."
|
||||
parts = [f"{REGION_SHORT.get(r['region'], r['region'])} game: {OUTCOME_WORDS[r['own_boots']]} "
|
||||
f"on its BIOS, {OUTCOME_WORDS[r['others_boot']]} on the others" for r in results]
|
||||
return ", ".join(parts) + "."
|
||||
|
||||
|
||||
def region_section(results: list[dict], level: int = 3) -> list[str]:
|
||||
"""What the region runs showed for one system: a line per core, the
|
||||
games and their records in a fold."""
|
||||
if not results:
|
||||
return []
|
||||
lines = ["#" * level + " Region check", "",
|
||||
f"Each game ran with {VARIANT_WORDS['own']}, then with {VARIANT_WORDS['others']} "
|
||||
f"([method](../{METHOD_PAGE})).", ""]
|
||||
by_core: dict[str, list[dict]] = {}
|
||||
for result in results:
|
||||
by_core.setdefault(result["core"], []).append(result)
|
||||
for core, items in by_core.items():
|
||||
lines.append(f"- **{_plain(core)}**: {_plain(core_region_line(items))}")
|
||||
lines.append("")
|
||||
rows = []
|
||||
for result in results:
|
||||
links = ", ".join(_link(item.get("url"), _cell(VARIANT_WORDS[label]))
|
||||
for label, item in result["variants"].items())
|
||||
names = ", ".join(f"<code>{_cell(name)}</code>" for name in result["selected"])
|
||||
read = (f" It reads {names} with every regional BIOS there "
|
||||
f"({_cell(result['claim'])} against the profile)." if result["selected"] else "")
|
||||
basis = ("" if result["judged_by"] == "verdict"
|
||||
else " Judged against the run with every regional BIOS until reference frames are recorded.")
|
||||
rows.append(f"<li><strong>{_cell(result['core'])}</strong>, {_cell(result['game'])}: "
|
||||
f"{_cell(result['sentence'])}{read}{basis} Records: {links}.</li>")
|
||||
# One line: MDX reads an HTML block split over lines as paragraphs.
|
||||
lines += ['<details class="rb-region-runs"><summary>Each game and its records</summary><ul>'
|
||||
+ "".join(rows) + "</ul></details>", ""]
|
||||
return lines
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# SQLite
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -294,7 +511,8 @@ SCHEMA_SQL = """
|
||||
run_id TEXT NOT NULL REFERENCES runs(id), position INTEGER NOT NULL,
|
||||
label TEXT NOT NULL, repetitions INTEGER NOT NULL, loaded INTEGER NOT NULL,
|
||||
flags TEXT NOT NULL, observables TEXT NOT NULL, files_opened TEXT,
|
||||
files_probed_absent TEXT, tracing TEXT, PRIMARY KEY (run_id, position)
|
||||
files_probed_absent TEXT, tracing TEXT, verdict TEXT, verdict_evidence TEXT,
|
||||
PRIMARY KEY (run_id, position)
|
||||
);
|
||||
CREATE TABLE system_files (
|
||||
run_id TEXT NOT NULL REFERENCES runs(id), variant_position INTEGER NOT NULL,
|
||||
@@ -405,11 +623,12 @@ def _insert_run(connection: sqlite3.Connection, entry: mr.Stored, source: dict,
|
||||
# The first repetition stands for the variant, its per-frame hashes left out.
|
||||
observables = {key: value for key, value in (first.get("observables") or {}).items()
|
||||
if key not in BULKY_OBSERVABLES}
|
||||
connection.execute("INSERT INTO variants VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (
|
||||
connection.execute("INSERT INTO variants VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (
|
||||
rid, position, variant["label"], len(reps), int(all(rep.get("loaded") for rep in reps)),
|
||||
json_text(variant["flags"]), json_text(observables),
|
||||
json_text(first.get("files_opened")), json_text(first.get("files_probed_absent")),
|
||||
json_text(first.get("tracing"))))
|
||||
json_text(first.get("tracing")), variant.get("verdict"),
|
||||
json_text(variant.get("verdict_evidence")) if "verdict_evidence" in variant else None))
|
||||
connection.executemany("INSERT INTO system_files VALUES (?, ?, ?, ?)", [
|
||||
(rid, position, dest, sha1) for dest, sha1 in sorted(variant["system_files"].items())])
|
||||
for metric, stats in sorted((r.get("timing") or {}).items()):
|
||||
@@ -462,6 +681,7 @@ def build(public: Path, root: Path = ROOT, emulators_dir: Path = ROOT / "emulato
|
||||
profiles = load_profiles(emulators_dir)
|
||||
index = build_index(stored, source, profiles, system_names)
|
||||
index["campaign"] = campaign_summary(root, stored, source)
|
||||
index["coverage"] = coverage(root, stored, profiles)
|
||||
paths = [public / INDEX_PATH]
|
||||
(public / INDEX_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
write_if_changed(str(public / INDEX_PATH),
|
||||
@@ -480,9 +700,14 @@ def build(public: Path, root: Path = ROOT, emulators_dir: Path = ROOT / "emulato
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def by_emulator(index: dict) -> dict[str, list[dict]]:
|
||||
"""Each emulator's items, with the count of its findings still open."""
|
||||
opened: dict[str, int] = {}
|
||||
for line in (index.get("campaign") or {}).get("open") or ():
|
||||
opened[line["emulator"]] = opened.get(line["emulator"], 0) + 1
|
||||
out: dict[str, list[dict]] = {}
|
||||
for item in index.get("items", []):
|
||||
out.setdefault(item["emulator"], []).append(item)
|
||||
out.setdefault(item["emulator"], []).append(
|
||||
{**item, "open_findings": opened.get(item["emulator"], 0)})
|
||||
return out
|
||||
|
||||
|
||||
@@ -498,92 +723,418 @@ def _short(commit: str | None) -> str:
|
||||
return f"`{commit[:12]}`" if commit else "unknown"
|
||||
|
||||
|
||||
# A result the probe could not settle is the one a reader should not take
|
||||
# at its word. The others describe what was seen, none of them is a fault.
|
||||
_STATUS_BADGE = {"inconclusive": "warning"}
|
||||
# A verdict or a comparison, as the block says it: words and a chip kind.
|
||||
RESULT_WORDS = {
|
||||
"boots": ("works", "success"),
|
||||
"bios_error": ("BIOS error", "danger"),
|
||||
"crash": ("crash", "danger"),
|
||||
"hang": ("hang", "warning"),
|
||||
"unknown": ("unknown", "muted"),
|
||||
"mixed": ("depends on the file", "info"),
|
||||
"same": ("same with every file", "muted"),
|
||||
"differs": ("depends on the file", "info"),
|
||||
"inconclusive": ("inconclusive", "warning"),
|
||||
"no verdict": ("no verdict", "muted"),
|
||||
"ran": ("ran", "muted"),
|
||||
}
|
||||
# Words a subject slug spells that read as one term.
|
||||
TERMS = {"bios": "BIOS", "vcd": "Video CD", "cd": "CD", "jp": "JP", "us": "US", "eu": "EU",
|
||||
"cr2": "CR2", "mpeg": "MPEG", "psx": "PSX", "hle": "HLE", "usa": "USA"}
|
||||
# The campaign's own check names, as the block says them.
|
||||
CHECK_NAMES = {"files": "full set", "none": "no BIOS", "negative": "wrong files",
|
||||
"references": "reference run", "variants": "copies", "alias": "other name",
|
||||
"path": "other path", "bench": "benchmark", "boot": "boot test", "alone": "file alone"}
|
||||
|
||||
|
||||
def _computer(host: str) -> str:
|
||||
"""The computer a host line names. The load average differs per run."""
|
||||
return host.split(", load ")[0]
|
||||
def _words(text: str) -> list[str]:
|
||||
return re.findall(r"[a-z0-9]+", str(text or "").lower())
|
||||
|
||||
|
||||
def _build(core: dict) -> str:
|
||||
return f"{_text(core['library'])} at {_short(core['commit'])}" if core.get("commit") \
|
||||
else _text(core["library"])
|
||||
def short_content(label: str | None) -> str:
|
||||
"""A content label cut to the game's name: no source prefix, no
|
||||
edition, region or identity detail."""
|
||||
text = str(label or "").strip()
|
||||
if ":" in text.split("(")[0]:
|
||||
text = text.split(":", 1)[1].strip()
|
||||
for cut in (" - ", " (", ", "):
|
||||
text = text.split(cut, 1)[0]
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _shared(items: list[dict], value: Callable[[dict], str]) -> str | None:
|
||||
"""The value every item gives, None when they differ."""
|
||||
values = {value(item) for item in items}
|
||||
return values.pop() if len(values) == 1 else None
|
||||
def short_title(item: dict) -> str:
|
||||
"""A test's name in a few words: the file it varies, the game, and what
|
||||
the subject adds to them (a build, a region, a check of the campaign)."""
|
||||
thing = Path(str(item.get("variable") or "")).name
|
||||
game = short_content(item.get("content"))
|
||||
known = set(_words(thing)) | set(_words(item.get("content"))) | set(_words(game))
|
||||
known |= set(_words(Path(str((item.get("core") or {}).get("file") or "")).name.split("_libretro")[0]))
|
||||
axis = []
|
||||
for word in _words(item.get("subject")):
|
||||
if word in known or re.fullmatch(r"[0-9a-f]{8}", word):
|
||||
continue
|
||||
issue = re.fullmatch(r"issue(\d+)", word)
|
||||
word = f"issue {issue[1]}" if issue else CHECK_NAMES.get(word, TERMS.get(word, word))
|
||||
if word not in axis:
|
||||
axis.append(word)
|
||||
head = ", ".join(part for part in (thing, game) if part)
|
||||
title = f"{head} ({' '.join(axis)})" if axis and head else head or " ".join(axis)
|
||||
return title or str(item.get("subject") or "")
|
||||
|
||||
|
||||
def _plural(count: int, noun: str) -> str:
|
||||
return f"{count} {noun}{'s' if count != 1 else ''}"
|
||||
OBSERVABLE_WORDS = {"video_sha1": "video", "frame_hashes": "screens", "audio_sha1": "audio",
|
||||
"state_sha1": "save state", "frame_sha1": "last frame"}
|
||||
|
||||
|
||||
def tested_row(item: dict, shared_build: bool) -> siteentries.Row:
|
||||
"""One test: its claim as the row, the result beside it, the method,
|
||||
the load and the record when the row opens."""
|
||||
title = item["claim"] or f"Benchmark of {item['content'] or 'the core alone'}"
|
||||
runs = "1 run" if item["runs"] == 1 else f"{item['runs']} runs, the latest shown"
|
||||
body = []
|
||||
if item["statement"]:
|
||||
body += [f"{_text(item['statement'])}.", ""]
|
||||
if item.get("method"):
|
||||
body += [f"**Method:** {_text(item['method'])}", ""]
|
||||
load = item["host"].partition(", load ")[2]
|
||||
counts = (f"{_plural(item['variants'], 'variant')}, "
|
||||
f"{_plural(item['repetitions'], 'repetition')} each"
|
||||
+ (f", {_plural(item['flags'], 'flag')} raised" if item["flags"] else "") + ".")
|
||||
body += [f"**Ran:** {counts}" + (f" Load {_text(load)}." if load else "")
|
||||
+ ("" if shared_build else f" Core build: {_build(item['core'])}."), ""]
|
||||
body += [f"[Full record]({item['record_url']}), {runs}."]
|
||||
tone = _STATUS_BADGE.get(item["status"], "muted")
|
||||
result = f'<span class="rb-badge rb-badge-{tone}">{_text(item["status"])}</span>'
|
||||
return siteentries.Row(_text(title), body, [_text(item["content"]), result])
|
||||
def setup_of(record: dict) -> dict:
|
||||
"""What told two runs of one test apart, each as the words a title
|
||||
would use: the campaign check, the core build, the core options, the
|
||||
clock, the number of files tried and what was compared."""
|
||||
build = str(record["core"].get("build_command") or "")
|
||||
patched = re.search(r"\b(patch|edit(?:ed|ing)?)\b", build, re.IGNORECASE)
|
||||
named = re.search(r"\b([A-Z]+[0-9]+)\b", build) if patched else None
|
||||
check = record.get("check") or {}
|
||||
first = (_words(record["subject"]) or [""])[0]
|
||||
options = {}
|
||||
for key, value in sorted(record["inputs"].get("options", {}).items()):
|
||||
options[key] = f"{value} region" if key.endswith("_region") else f"{key.rsplit('_', 1)[-1]} {value}"
|
||||
observable = str(check.get("observable") or "")
|
||||
return {
|
||||
"check": CHECK_NAMES.get(first, ""),
|
||||
"build": (f"{named[1]} patch" if named else "patched build") if patched else "published build",
|
||||
"options": options,
|
||||
"clock": "fixed clock" if "FAKETIME" in record["inputs"].get("env", {}) else "running clock",
|
||||
"files": (f"{len(record['variants'])} file{'s' if len(record['variants']) != 1 else ''}"
|
||||
if record["kind"] == "check" else ""),
|
||||
"observable": (observable.split(":", 1)[1] if observable.startswith("state:")
|
||||
else OBSERVABLE_WORDS.get(observable, observable)),
|
||||
}
|
||||
|
||||
|
||||
def _features(item: dict) -> dict[str, str]:
|
||||
"""A test's setup as named features, the options one by one."""
|
||||
setup = item.get("setup") or {}
|
||||
out = {name: str(setup.get(name) or "") for name in ("build", "clock", "files", "observable")}
|
||||
for key, value in (setup.get("options") or {}).items():
|
||||
out[f"option:{key}"] = value
|
||||
return out
|
||||
|
||||
|
||||
def distinct_titles(items: list[dict]) -> dict[str, str]:
|
||||
"""A title per item: its game and campaign check, then what sets it
|
||||
apart from the other tests of the same file, game and core.
|
||||
|
||||
A test whose setup is the common one of its group is named by its game.
|
||||
Another adds the features where it leaves the common setup (a patched
|
||||
build, a running clock, another region). Two titles still alike take
|
||||
every feature that varies in the group, then the subject's own words.
|
||||
Nothing is cut.
|
||||
"""
|
||||
out: dict[str, str] = {}
|
||||
peers: dict[tuple, list[dict]] = {}
|
||||
for item in items:
|
||||
game = short_content(item.get("content")) or short_title(item)
|
||||
peers.setdefault((item.get("variable") or "", game, item["core"].get("file")), []).append(item)
|
||||
for (_, game, _), group in peers.items():
|
||||
features = [_features(item) for item in group]
|
||||
names = sorted({name for f in features for name in f},
|
||||
key=lambda n: (n.startswith("option:"), ["build", "clock", "files", "observable"].index(n)
|
||||
if not n.startswith("option:") else 0, n))
|
||||
common = {}
|
||||
for name in names:
|
||||
values = [f.get(name, "") for f in features]
|
||||
ranked = sorted(set(values), key=lambda v: -values.count(v))
|
||||
# A tie has no common value: every test names its own.
|
||||
tied = len(ranked) > 1 and values.count(ranked[0]) == values.count(ranked[1])
|
||||
common[name] = None if tied else ranked[0]
|
||||
varying = [n for n in names if len({f.get(n, "") for f in features}) > 1]
|
||||
|
||||
def title(item: dict, own: dict[str, str], every: bool, varying: list[str] = varying,
|
||||
common: dict[str, str] = common, game: str = game) -> str:
|
||||
words = [(item.get("setup") or {}).get("check", "")]
|
||||
for name in varying:
|
||||
value = own.get(name, "")
|
||||
if every or value != common[name]:
|
||||
words.append(value or f"no {name.rsplit('_', 1)[-1]}")
|
||||
return ", ".join([game, *[w for w in words if w]])
|
||||
|
||||
for item, own in zip(group, features):
|
||||
out[item["id"]] = title(item, own, every=False)
|
||||
for every in (True, None):
|
||||
seen: dict[str, int] = {}
|
||||
for item in group:
|
||||
seen[out[item["id"]]] = seen.get(out[item["id"]], 0) + 1
|
||||
for item, own in zip(group, features):
|
||||
if seen[out[item["id"]]] > 1:
|
||||
if every:
|
||||
out[item["id"]] = title(item, own, every=True)
|
||||
else:
|
||||
axis = short_title({**item, "variable": None, "content": None})
|
||||
out[item["id"]] = f"{out[item['id']]}, {axis}"
|
||||
return out
|
||||
|
||||
|
||||
def short_host(host: dict) -> str:
|
||||
"""The computer as a reader names it: the CPU model and the OS."""
|
||||
cpu = str(host.get("cpu_model") or "unknown CPU")
|
||||
cpu = re.sub(r"\(R\)|\(TM\)|\bCPU\b|@.*$|^\d+(?:st|nd|rd|th) Gen ", "", cpu)
|
||||
cpu = " ".join(cpu.split())
|
||||
system = str(host.get("os") or "")
|
||||
name = "Linux" if "Linux" in system else system.split("-")[0] or "unknown OS"
|
||||
if "microsoft" in system.lower():
|
||||
name += " (WSL2)"
|
||||
return f"{cpu}, {name}"
|
||||
|
||||
|
||||
def variant_groups(record: dict) -> list[dict]:
|
||||
"""One entry per variant: the result group it falls in, and whether it
|
||||
staged no file. A run that carries verdicts is grouped by verdict, a
|
||||
comparison by the value its observable took."""
|
||||
check = record.get("check") or {}
|
||||
position: dict[str, int] = {}
|
||||
result: dict[str, str] = {}
|
||||
if all("verdict" in v for v in record["variants"]) and (
|
||||
record["kind"] == "run" or check.get("observable") == "frame_hashes"):
|
||||
for variant in record["variants"]:
|
||||
position.setdefault(variant["verdict"], len(position))
|
||||
result[variant["label"]] = RESULT_WORDS[variant["verdict"]][0]
|
||||
groups = {v["label"]: position[v["verdict"]] for v in record["variants"]}
|
||||
else:
|
||||
groups = {}
|
||||
for number, group in enumerate((check.get("result") or {}).get("groups") or ()):
|
||||
for label in group["variants"]:
|
||||
groups[label] = number
|
||||
result[label] = f"result {chr(ord('A') + number % 26)}"
|
||||
varied = check.get("variable")
|
||||
return [{"variant": v["label"], "group": groups.get(v["label"], 0),
|
||||
"result": result.get(v["label"], ""),
|
||||
"absent": bool(varied) and varied in v["system_files"] and v["system_files"][varied] is None}
|
||||
for v in record["variants"]]
|
||||
|
||||
|
||||
def proves(record_id: str, profile: dict) -> list[str]:
|
||||
"""The profile fields this record is evidence for, as `field of file`."""
|
||||
out = []
|
||||
for entry in profile.get("files") or ():
|
||||
for name, ids in sorted((entry.get("evidence") or {}).items()):
|
||||
if record_id in ids:
|
||||
out.append(f"{name} of {entry.get('name')}")
|
||||
return out
|
||||
|
||||
|
||||
# An MDX page reads a brace as the start of an expression, in text and in
|
||||
# an attribute alike.
|
||||
_BRACES = str.maketrans({"{": "{", "}": "}"})
|
||||
|
||||
|
||||
def _attr(value: object) -> str:
|
||||
return html.escape(" ".join(str(value or "").split()), quote=True).translate(_BRACES)
|
||||
|
||||
|
||||
def _cell(value: object) -> str:
|
||||
"""Record text placed in raw HTML: escaped as text, never read as
|
||||
Markdown (a run's output is no markup), braces made inert for MDX. A
|
||||
semicolon joining two clauses ends a sentence, as on the rest of the
|
||||
site."""
|
||||
text = " ".join(str(value or "").split())
|
||||
text = re.sub(r";\s+(\w)", lambda m: ". " + m[1].upper(), text)
|
||||
return html.escape(text, quote=True).translate(_BRACES)
|
||||
|
||||
|
||||
_SAFE_HREF = re.compile(r"^(?:https?://[^\s\"'<>]+|/[^\s\"'<>]*|\.{0,2}/?[\w.~-][^\s\"'<>:]*)$")
|
||||
|
||||
|
||||
def _href(url: object) -> str | None:
|
||||
"""A link target from a record: http(s) or relative only, else none."""
|
||||
text = str(url or "")
|
||||
return _attr(text) if text and _SAFE_HREF.match(text) and not text.lower().startswith(
|
||||
("javascript:", "data:", "vbscript:")) else None
|
||||
|
||||
|
||||
def _link(url: object, text: str) -> str:
|
||||
target = _href(url)
|
||||
return f'<a href="{target}">{text}</a>' if target else text
|
||||
|
||||
|
||||
def _dots(item: dict) -> str:
|
||||
groups = item.get("groups") or []
|
||||
names = ". ".join(f"{g['variant']}: {g['result']}" for g in groups)
|
||||
dots = "".join(
|
||||
f'<span class="rb-dot rb-dot-{g["group"] % 5}{" rb-dot-empty" if g["absent"] else ""}" '
|
||||
f'title="{_attr(g["variant"] + ": " + g["result"])}"></span>' for g in groups)
|
||||
return (f'<span class="rb-dots" role="img" aria-label="{_attr(f"{len(groups)} variants. {names}")}">'
|
||||
f"{dots}</span>")
|
||||
|
||||
|
||||
def _chip(item: dict) -> str:
|
||||
words, kind = RESULT_WORDS.get(item["status"], (item["status"], "muted"))
|
||||
return f'<span class="rb-badge rb-badge-{kind}">{_cell(words)}</span>'
|
||||
|
||||
|
||||
def _sentence_html(label: str, value: object, markup_ready: bool = False) -> str:
|
||||
"""One line of the details, or nothing: an empty value is never printed."""
|
||||
text = (str(value or "") if markup_ready else _cell(value)).strip().rstrip(".")
|
||||
return f"<p><strong>{label}:</strong> {text}.</p>" if text else ""
|
||||
|
||||
|
||||
def _details(item: dict) -> list[str]:
|
||||
core = item["core"]
|
||||
build = _cell(core["library"]) + (f" at <code>{_cell(core['commit'][:12])}</code>"
|
||||
if core.get("commit") else "")
|
||||
reps = f"{item['variants']} variant{'s' if item['variants'] != 1 else ''}, " \
|
||||
f"{item['repetitions']} repetition{'s' if item['repetitions'] != 1 else ''} each"
|
||||
if item["flags"]:
|
||||
reps += f", {item['flags']} flag{'s' if item['flags'] != 1 else ''} raised"
|
||||
runs = "" if item["runs"] == 1 else f", {item['runs']} runs, the latest shown"
|
||||
lines = [
|
||||
_sentence_html("Claim", item.get("claim")),
|
||||
_sentence_html("Result", item.get("statement")),
|
||||
_sentence_html("Method", item.get("method")),
|
||||
_sentence_html("Proves", ", ".join(item.get("proves") or [])),
|
||||
_sentence_html("Ran on", item.get("host_short") or item["host"]),
|
||||
_sentence_html("Core build", build and f"{build}. {reps}", markup_ready=True),
|
||||
f'<p>{_link(item["record_url"], "Full record")}{runs}.</p>',
|
||||
]
|
||||
return [line for line in lines if line]
|
||||
|
||||
|
||||
def tested_counts(items: list[dict]) -> dict:
|
||||
tests = [i for i in items if i["kind"] != "bench"]
|
||||
return {"tests": len(items), "confirmed": sum(1 for i in items if i.get("proves")),
|
||||
"open": max((i.get("open_findings", 0) for i in items), default=0),
|
||||
"benchmarks": len(items) - len(tests),
|
||||
"last": max((i["date"][:10] for i in items), default="")}
|
||||
|
||||
|
||||
def core_label(item: dict) -> str:
|
||||
return Path(str(item["core"].get("file") or "")).name.split("_libretro")[0]
|
||||
|
||||
|
||||
def _row(item: dict, title: str, page_host: str, main_core: str) -> list[str]:
|
||||
elsewhere = (item.get("host_short") or item["host"]) != page_host
|
||||
title = _cell(title)
|
||||
if core_label(item) != main_core:
|
||||
title += f' <small>{_cell(core_label(item))}</small>'
|
||||
return [
|
||||
'<details class="rb-tested-row">',
|
||||
("<summary>"
|
||||
f'<span class="rb-tested-test">{title}</span>'
|
||||
f'<span class="rb-tested-result">{_chip(item)}</span>'
|
||||
f'<span class="rb-tested-variants">{_dots(item)}</span>'
|
||||
f'<span class="rb-tested-date">{item["date"][:10]}'
|
||||
+ (f' <small>{_attr(item.get("host_short"))}</small>' if elsewhere else "")
|
||||
+ "</span></summary>"),
|
||||
'<div class="rb-tested-more">',
|
||||
*_details(item),
|
||||
"</div>",
|
||||
"</details>",
|
||||
]
|
||||
|
||||
|
||||
def by_file(items: list[dict]) -> list[tuple[str, list[dict]]]:
|
||||
"""The items under the file each varies, in order of first appearance;
|
||||
the runs that vary none last."""
|
||||
groups: dict[str, list[dict]] = {}
|
||||
for item in items:
|
||||
groups.setdefault(Path(str(item.get("variable") or "")).name, []).append(item)
|
||||
return sorted(groups.items(), key=lambda kv: kv[0] == "")
|
||||
|
||||
|
||||
def folded(item: dict) -> bool:
|
||||
"""A row the block folds away: a test from before the boot oracle, or a
|
||||
run that recorded reference frames for the oracle, which a reviewer
|
||||
reads and a visitor does not."""
|
||||
return item["status"] == "no verdict" or str(item.get("subject") or "").startswith("references-")
|
||||
|
||||
|
||||
def summary_line(items: list[dict], page_host: str) -> str:
|
||||
"""N tests, the confirmed and open counts when there are any, the last
|
||||
run and its computer."""
|
||||
counts = tested_counts(items)
|
||||
parts = [f"{counts['tests']} test{'s' if counts['tests'] != 1 else ''}"]
|
||||
parts += [f"{counts[key]} {key}" for key in ("confirmed", "open") if counts[key]]
|
||||
parts.append(f"last run {counts['last']} on {page_host}")
|
||||
return " · ".join(parts) + "."
|
||||
|
||||
|
||||
def tested_section(name: str, items: list[dict]) -> list[str]:
|
||||
"""The Tested block of an emulator page, empty when nothing was recorded.
|
||||
What every test shares is said once above the table."""
|
||||
|
||||
The answer first: one summary line, then the tests under the file each
|
||||
one varies, a row per test with its result chip, a dot per variant and
|
||||
its date. What proves it (method, claim, computer, core build, record)
|
||||
waits in each row's details. Tests recorded before the boot oracle fold
|
||||
under one line, and benchmarks get their own table at the end.
|
||||
"""
|
||||
if not items:
|
||||
return []
|
||||
computer = _shared(items, lambda i: _computer(i["host"]))
|
||||
build = _shared(items, lambda i: _build(i["core"]))
|
||||
content = _shared(items, lambda i: i["content"] or "")
|
||||
day = _shared(items, lambda i: i["date"][:10])
|
||||
context = []
|
||||
if content:
|
||||
context.append(f"Played: {_text(content)}.")
|
||||
if build:
|
||||
context.append(f"Core build: {build}.")
|
||||
if computer:
|
||||
context.append(f"Ran on {_text(computer)}" + (f", {day}." if day else "."))
|
||||
elif day:
|
||||
context.append(f"Ran on {day}.")
|
||||
rows = [tested_row(item, build is not None)
|
||||
for item in sorted(items, key=lambda i: (i["date"], i["subject"]), reverse=True)]
|
||||
if content:
|
||||
rows = [siteentries.Row(r.title, r.body, ["", r.cells[1]]) for r in rows]
|
||||
return [
|
||||
"## Tested",
|
||||
"",
|
||||
f"This emulator was run in real conditions by the [core probe](../{METHOD_PAGE}). "
|
||||
f"Each test below is the latest of its kind. "
|
||||
f"[All measurements](../{EXPLORER_PAGE}), "
|
||||
f"[these records as SQLite](../downloads/measurements/{name}.sqlite).",
|
||||
"",
|
||||
*([" ".join(context), ""] if context else []),
|
||||
*siteentries.entry_table(rows, ["Test", "Game", "Result"], f"rb-tested-{name}", "tests"),
|
||||
]
|
||||
hosts = [i.get("host_short") or i["host"] for i in items]
|
||||
page_host = max(sorted(set(hosts)), key=hosts.count)
|
||||
cores = [core_label(i) for i in items]
|
||||
main_core = max(sorted(set(cores)), key=cores.count)
|
||||
titles = distinct_titles(items)
|
||||
links = (f'<a href="{markup.site_href(METHOD_PAGE.removesuffix(".md") + "/")}">How</a> · '
|
||||
f'<a href="{markup.site_href(EXPLORER_PAGE.removesuffix(".md") + "/")}">All tests</a> · '
|
||||
f'<a href="{markup.site_href(f"downloads/measurements/{name}.sqlite")}">SQLite</a>')
|
||||
ordered = sorted(items, key=lambda i: (i["date"], i["subject"], core_label(i)), reverse=True)
|
||||
benches = [i for i in ordered if i["kind"] == "bench"]
|
||||
# The region runs (region_check.py) read as one outcome per game, on the
|
||||
# system page: listed here they would be a row per run.
|
||||
regional = [i for i in ordered if i["kind"] != "bench" and REGION_SUBJECT.match(i["subject"])]
|
||||
ordered = [i for i in ordered if i not in regional]
|
||||
earlier = [i for i in ordered if i["kind"] != "bench" and folded(i)]
|
||||
current = [i for i in ordered if i["kind"] != "bench" and not folded(i)]
|
||||
lines = ["## Tested", "", '<div class="rb-tested">',
|
||||
f'<p class="rb-tested-summary">{_cell(summary_line(items, page_host))} {links}</p>']
|
||||
if current or earlier:
|
||||
lines.append('<div class="rb-tested-head" aria-hidden="true"><span>Test</span>'
|
||||
"<span>Result</span><span>Variants</span><span>Date</span></div>")
|
||||
for file, group in by_file(current):
|
||||
label = f"<code>{_cell(file)}</code>" if file else "No file varied"
|
||||
lines.append(f'<p class="rb-tested-file">{label}</p>')
|
||||
for item in group:
|
||||
lines += _row(item, titles[item["id"]], page_host, main_core)
|
||||
if current:
|
||||
lines.append('<p class="rb-tested-legend"><span class="rb-dot rb-dot-0"></span> A dot per file tried, '
|
||||
'the same colour for the same result. <span class="rb-dot rb-dot-0 rb-dot-empty">'
|
||||
"</span> No file staged.</p>")
|
||||
if earlier:
|
||||
old = sum(1 for i in earlier if i["status"] == "no verdict")
|
||||
parts = [f"{old} test{'s' if old != 1 else ''} from before the boot oracle" if old else "",
|
||||
(f"{len(earlier) - old} reference run{'s' if len(earlier) - old != 1 else ''}"
|
||||
if len(earlier) - old else "")]
|
||||
lines += ['<details class="rb-tested-earlier">',
|
||||
f"<summary>{_cell(' and '.join(p for p in parts if p))}</summary>"]
|
||||
for item in earlier:
|
||||
file = Path(str(item.get("variable") or "")).name
|
||||
title = f"{file}, {titles[item['id']]}" if file else titles[item["id"]]
|
||||
lines += _row(item, title, page_host, main_core)
|
||||
lines.append("</details>")
|
||||
if regional:
|
||||
games = len({i["content"] for i in regional})
|
||||
lines.append(f'<p class="rb-tested-file">Region check: {len(regional)} runs of '
|
||||
f"{games} game{'s' if games != 1 else ''}, read on the system page.</p>")
|
||||
if benches:
|
||||
# One line: MDX reads a table split over lines as paragraphs.
|
||||
table = ['<table class="rb-tested-bench"><thead><tr><th>Benchmark</th><th>ms per frame</th>'
|
||||
"<th>fps</th><th>stdev</th><th>runs</th></tr></thead><tbody>"]
|
||||
for item in benches:
|
||||
timing = item.get("timing") or {}
|
||||
title = _cell(titles[item["id"]])
|
||||
if core_label(item) != main_core:
|
||||
title += f' <small>{_cell(core_label(item))}</small>'
|
||||
table.append(
|
||||
f'<tr><td>{_link(item["record_url"], title)}</td>'
|
||||
f'<td>{timing.get("ms_per_frame", 0):.3f}</td><td>{timing.get("fps", 0):.1f}</td>'
|
||||
f'<td>{timing.get("stdev", 0):.3f}</td><td>{item["repetitions"]}</td></tr>')
|
||||
lines.append("".join([*table, "</tbody></table>"]))
|
||||
return [*lines, "</div>", ""]
|
||||
|
||||
|
||||
def hosts_table(index: dict) -> list[str]:
|
||||
hosts: dict[str, int] = {}
|
||||
for item in index.get("items", []):
|
||||
computer = _computer(item["host"])
|
||||
# The load average differs per run: the computer is what comes before it.
|
||||
computer = item["host"].split(", load ")[0]
|
||||
hosts[computer] = hosts.get(computer, 0) + 1
|
||||
if not hosts:
|
||||
return ["No result is recorded yet.", ""]
|
||||
@@ -600,7 +1151,7 @@ def with_hosts(page: str, index: dict) -> str:
|
||||
return page.replace(HOSTS_MARKER, "\n".join(hosts_table(index)).rstrip("\n"))
|
||||
|
||||
|
||||
def generate_explorer_page(index: dict) -> str:
|
||||
def generate_explorer_page(index: dict, system_names: dict[str, str] | None = None) -> str:
|
||||
commit = index.get("commit")
|
||||
repo = index.get("repo") or ""
|
||||
source = (f"[`measurements/`]({repo}/tree/{commit}/{mr.RECORDS_DIR}) at {_short(commit)}"
|
||||
@@ -623,6 +1174,7 @@ def generate_explorer_page(index: dict) -> str:
|
||||
"<MeasurementsExplorer />",
|
||||
"",
|
||||
*campaign_section(index.get("campaign")),
|
||||
*coverage_section(index.get("coverage"), system_names),
|
||||
])
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,714 @@
|
||||
#!/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=("requires a PlayStation 2 BIOS in order to run",
|
||||
"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
|
||||
# One run on the host at a time, as core_probe: the lock is held for
|
||||
# this run, its load check included, and freed when it ends.
|
||||
with cp.host_probe_lock():
|
||||
return _probe(args, emu)
|
||||
|
||||
|
||||
def _probe(args: argparse.Namespace, emu: Emulator) -> int:
|
||||
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)
|
||||
Reference in new issue
Block a user