fix(tools): report an unreadable last frame and lock a title's work directory

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
LeandroLP23andClaude Opus 5.5 committed 2026-11-03 23:04:46 +01:00
1 parent 39b52ea6c0
commit e5b4e22aba
3 files changed
+51 -3

No files matched your search

+1 -1
View File
@@ -79,4 +79,4 @@ See [Build](BUILD.md#performance-trace) for the trace format, summary fields and
## Running titles repeatably
`python tools/title_run.py --games <directory> [ID ...]` relinks and runs extracted title packages found under `--games` (a directory holding `eboot.bin` and `sce_sys/param.json`) and writes `summary.json` and a fixed-format `summary.md` under `--out` (default `build/title-runs/<commit>-<time>/`). Each title runs `--runs` times (default 2): the first with empty AnyPS5 and Mesa or NVIDIA shader caches, the next ones keeping them. AMD's Windows `VkCache` is not isolated. On Windows the title's windows open behind every other window, without focus and without being minimized, and can be brought forward from the taskbar; elsewhere run it under a virtual display if that matters. A run ends as `relink-failed`, `crashed`, `exited`, `stalled` (no present in the last 20 s, or too few presents for a frame to be dumped), `blank` (the last frame is one colour) or `rendering`; the first, middle and last dumped frames are kept. A screen that presents only when its view changes stops after a few presents and is reported `stalled` although the title still answers input; pressing a button through `tools/pad_input.py` with `APS5_PAD_INPUT_FILE` set tells it apart from a stuck title. `--compare <summary.json>` lists regressions, improvements and missing titles against an earlier run, limited to the IDs given when there are any. Titles are prepared in `<out>/work/<ID>/`, the same path on every run, so a firewall prompt for a title that opens a port appears once; answering Cancel does not affect the run. Per-title relink flags, run length and environment live in `tools/titles.json`.
`python tools/title_run.py --games <directory> [ID ...]` relinks and runs extracted title packages found under `--games` (a directory holding `eboot.bin` and `sce_sys/param.json`) and writes `summary.json` and a fixed-format `summary.md` under `--out` (default `build/title-runs/<commit>-<time>/`). Each title runs `--runs` times (default 2): the first with empty AnyPS5 and Mesa or NVIDIA shader caches, the next ones keeping them. AMD's Windows `VkCache` is not isolated. On Windows the title's windows open behind every other window, without focus and without being minimized, and can be brought forward from the taskbar; elsewhere run it under a virtual display if that matters. A run ends as `relink-failed`, `crashed`, `exited`, `stalled` (no present in the last 20 s, or too few presents for a frame to be dumped), `frame-unreadable` (the last dumped frame is truncated or not a 24- or 32-bit BMP), `blank` (the last frame is one colour) or `rendering`; the first, middle and last dumped frames are kept. A screen that presents only when its view changes stops after a few presents and is reported `stalled` although the title still answers input; pressing a button through `tools/pad_input.py` with `APS5_PAD_INPUT_FILE` set tells it apart from a stuck title. `--compare <summary.json>` lists regressions, improvements and missing titles against an earlier run, limited to the IDs given when there are any. Titles are prepared in `<out>/work/<ID>/`, the same path on every run, so a firewall prompt for a title that opens a port appears once; answering Cancel does not affect the run. An invocation locks `<out>/work/<ID>.lock` for each title it runs before touching anything, and refuses to start while another invocation with the same `--out` holds one of them. Per-title relink flags, run length and environment live in `tools/titles.json`.
+26 -1
View File
@@ -116,6 +116,14 @@ class AnalysisTests(unittest.TestCase):
cut.write_bytes(noisy.read_bytes()[:-20])
self.assertIsNone(title_run.frame_is_uniform(cut))
def test_classify_reads_the_last_frame(self):
uniform = bmp(self.root / "uniform.bmp", 8, 4, lambda x, y: (9, 9, 9))
varied = bmp(self.root / "varied.bmp", 8, 4, lambda x, y: (x * 30, y * 60, 0))
truncated = self.root / "truncated.bmp"
truncated.write_bytes(varied.read_bytes()[:-20])
for frame, outcome in ((uniform, "blank"), (varied, "rendering"), (truncated, "frame-unreadable")):
self.assertEqual(title_run.classify(True, None, 5.0, True, title_run.frame_is_uniform(frame)), outcome, frame.name)
def test_classify(self):
cases = [
((False, None, None, False, None), "relink-failed"),
@@ -126,7 +134,7 @@ class AnalysisTests(unittest.TestCase):
((True, None, 5.0, False, None), "stalled"),
((True, None, 5.0, True, True), "blank"),
((True, None, 5.0, True, False), "rendering"),
((True, None, 5.0, True, None), "rendering"),
((True, None, 5.0, True, None), "frame-unreadable"),
]
for arguments, outcome in cases:
self.assertEqual(title_run.classify(*arguments), outcome, arguments)
@@ -199,6 +207,23 @@ class PreparationTests(unittest.TestCase):
self.addCleanup(directory.cleanup)
self.root = Path(directory.name)
def test_a_running_title_is_left_alone(self):
package(self.root / "games/PPSA00001", "PPSA00001")
out = self.root / "out"
marker = out / "work/PPSA00001/app0/eboot.bin"
marker.parent.mkdir(parents=True)
marker.write_bytes(b"running")
held = title_run.lock_work(out / "work/PPSA00001.lock")
self.addCleanup(held.close)
self.assertIsNotNone(held)
with self.assertRaisesRegex(SystemExit, "already running in .*: PPSA00001"):
title_run.main(["--games", str(self.root / "games"), "--out", str(out), "PPSA00001"])
self.assertEqual(marker.read_bytes(), b"running")
held.close()
again = title_run.lock_work(out / "work/PPSA00001.lock")
self.assertIsNotNone(again)
again.close()
def test_discover(self):
package(self.root / "a/src/PPSA00001", "PPSA00001", "01.000.110", "Light")
package(self.root / "b/PPSA00002", "PPSA00002")
+24 -1
View File
@@ -17,7 +17,7 @@ sys.path.insert(0, str(ROOT / "tools"))
import unwrap_self
from package_release import windows_runtime
OUTCOMES = ("relink-failed", "crashed", "exited", "stalled", "blank", "rendering")
OUTCOMES = ("relink-failed", "crashed", "exited", "stalled", "frame-unreadable", "blank", "rendering")
TITLE_ID = re.compile(r"[A-Z]{4}[0-9]{5}")
TITLE_FIELDS = {"relink": list, "fixed_base": str, "executables": list, "seconds": int, "env": dict}
FIXED_BASE_FLAGS = {"windows": "--windows-fixed-base", "linux": "--linux-fixed-base"}
@@ -107,6 +107,8 @@ def classify(relinked, exit_code, summary_age, framed, last_frame_uniform):
return "crashed" if exit_code != 0 else "exited"
if summary_age is None or summary_age > STALL_SECONDS or not framed:
return "stalled"
if last_frame_uniform is None:
return "frame-unreadable"
if last_frame_uniform:
return "blank"
return "rendering"
@@ -218,6 +220,23 @@ def link_tree(source, target, skip):
link_file(path, target / relative)
def lock_work(path):
path.parent.mkdir(parents=True, exist_ok=True)
handle = path.open("a+b")
try:
if os.name == "nt":
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
return None
return handle
def prepare(title_id, found, entry, platform, build, work):
package = found["package"]
shutil.rmtree(work, ignore_errors=True)
@@ -504,6 +523,10 @@ def main(argv=None):
missing = [title_id for title_id in ids if title_id not in found]
if missing:
raise SystemExit(f"not found under --games: {', '.join(missing)}")
locks = {title_id: lock_work(args.out.resolve() / "work" / f"{title_id}.lock") for title_id in ids}
busy = [title_id for title_id, handle in locks.items() if handle is None]
if busy:
raise SystemExit(f"already running in {args.out.resolve() / 'work'}: {', '.join(busy)}")
if previous and args.ids:
previous["titles"] = {title_id: title for title_id, title in previous["titles"].items() if title_id in ids}
source = source_root(args.build)