mirror of
https://github.com/boykopovar/AnyPS5.git
synced 2026-11-06 13:30:00 +00:00
576 lines
26 KiB
Python
576 lines
26 KiB
Python
import argparse
|
|
import datetime
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(ROOT / "tools"))
|
|
|
|
import unwrap_self
|
|
from package_release import windows_runtime
|
|
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"}
|
|
|
|
|
|
def load_titles(path):
|
|
titles = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
for title_id, entry in titles.items():
|
|
if not TITLE_ID.fullmatch(title_id):
|
|
raise ValueError(f"{path}: {title_id} is not a title ID")
|
|
for key, value in entry.items():
|
|
if key not in TITLE_FIELDS:
|
|
raise ValueError(f"{path}: {title_id}: unknown field {key}")
|
|
if not isinstance(value, TITLE_FIELDS[key]):
|
|
raise ValueError(f"{path}: {title_id}: {key} must be {TITLE_FIELDS[key].__name__}")
|
|
items = value.values() if isinstance(value, dict) else value if isinstance(value, list) else []
|
|
if not all(isinstance(item, str) for item in items):
|
|
raise ValueError(f"{path}: {title_id}: {key} must hold strings")
|
|
return titles
|
|
|
|
|
|
def relink_args(entry, platform):
|
|
args = ["--windows"] if platform == "windows" else []
|
|
args += entry.get("relink", [])
|
|
if "fixed_base" in entry:
|
|
if platform not in FIXED_BASE_FLAGS:
|
|
raise ValueError(f"fixed_base is not supported on {platform}")
|
|
args += [FIXED_BASE_FLAGS[platform], entry["fixed_base"]]
|
|
for executable in entry.get("executables", []):
|
|
args += ["--executable", executable]
|
|
return args
|
|
|
|
|
|
ERROR = re.compile(r"what\(\):|FAIL:|FATAL|not implemented|unhandled exception|DEVICE_LOST|has not reached submission")
|
|
WARNINGS = ("destroyed with GPU results pending", "guest memory changed while GPU results were pending")
|
|
STALL_SECONDS = 20
|
|
|
|
|
|
def first_error(text):
|
|
return next((line.strip() for line in text.splitlines() if ERROR.search(line)), "")
|
|
|
|
|
|
def warning_counts(text):
|
|
return {warning: text.count(warning) for warning in WARNINGS}
|
|
|
|
|
|
def read_summary(path):
|
|
lines = []
|
|
for line in path.read_text(encoding="utf-8", errors="replace").splitlines() if path.is_file() else []:
|
|
try:
|
|
decoded = json.loads(line)
|
|
except ValueError:
|
|
continue
|
|
if isinstance(decoded, dict):
|
|
lines.append(decoded)
|
|
presents = sum(line.get("presents", 0) for line in lines)
|
|
seconds = sum(line.get("window", {}).get("ms", 0) for line in lines) / 1000
|
|
last = lines[-1] if lines else {}
|
|
return {"presents": presents, "fps": round(presents / seconds, 1) if seconds else 0.0,
|
|
"gpu": last.get("gpu", ""), "driver": last.get("driver", ""), "os": last.get("os", "")}
|
|
|
|
|
|
def frame_is_uniform(path, tolerance=8):
|
|
data = path.read_bytes()
|
|
try:
|
|
offset = struct.unpack_from("<I", data, 10)[0]
|
|
width, height = struct.unpack_from("<ii", data, 18)
|
|
channels = struct.unpack_from("<H", data, 28)[0] // 8
|
|
except struct.error:
|
|
return None
|
|
stride = (width * channels + 3) & ~3
|
|
if channels < 3 or len(data) < offset + stride * abs(height) - (stride - width * channels):
|
|
return None
|
|
first = data[offset:offset + 3]
|
|
for row in range(abs(height)):
|
|
pixels = data[offset + row * stride:offset + row * stride + width * channels]
|
|
for index in range(0, len(pixels), channels):
|
|
if max(abs(pixels[index + channel] - first[channel]) for channel in range(3)) > tolerance:
|
|
return False
|
|
return True
|
|
|
|
|
|
def classify(relinked, exit_code, summary_age, framed, last_frame_uniform):
|
|
if not relinked:
|
|
return "relink-failed"
|
|
if exit_code is not None:
|
|
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"
|
|
|
|
|
|
def cell(runs, cache):
|
|
outcomes = [entry["outcome"] for entry in runs if entry["cache"] == cache]
|
|
if not outcomes:
|
|
return "-"
|
|
if len(outcomes) == 1:
|
|
return outcomes[0]
|
|
return ", ".join(f"{outcome} {outcomes.count(outcome)}/{len(outcomes)}" for outcome in sorted(set(outcomes), key=OUTCOMES.index))
|
|
|
|
|
|
def worst(runs):
|
|
return min((entry["outcome"] for entry in runs), key=OUTCOMES.index)
|
|
|
|
|
|
def compare(summary, previous):
|
|
regressions, improvements = [], []
|
|
for title_id, title in summary["titles"].items():
|
|
if title_id not in previous["titles"]:
|
|
continue
|
|
before, now = worst(previous["titles"][title_id]["runs"]), worst(title["runs"])
|
|
if OUTCOMES.index(now) < OUTCOMES.index(before):
|
|
regressions.append((title_id, before, now))
|
|
elif OUTCOMES.index(now) > OUTCOMES.index(before):
|
|
improvements.append((title_id, before, now))
|
|
return regressions, improvements
|
|
|
|
|
|
def render_markdown(summary, previous=None):
|
|
build, machine = summary["build"], summary["machine"]
|
|
header = f"AnyPS5 `{build['commit']}`" + (" (dirty)" if build["dirty"] else "") + (f", {build['note']}" if build["note"] else "")
|
|
parts = [machine["os"], machine["gpu"], machine["driver"] and f"driver {machine['driver']}", machine["cpu"] and f"CPU {machine['cpu']}"]
|
|
known = ", ".join(part for part in parts if part)
|
|
header += f". {known}." if known else "."
|
|
header += f" {summary['runs']} run{'' if summary['runs'] == 1 else 's'} per title, {summary['seconds']} s each."
|
|
lines = [header, ""]
|
|
columns = ["Title", "ID", "Version", "Cold", "Warm", "FPS", "First error"] + (["Before"] if previous else [])
|
|
if previous:
|
|
regressions, improvements = compare(summary, previous)
|
|
for label, changes in (("Regressions", regressions), ("Improvements", improvements)):
|
|
if changes:
|
|
names = ", ".join(f"{summary['titles'][title_id]['name']} ({title_id}) {before} -> {now}" for title_id, before, now in changes)
|
|
lines += [f"{label}: {names}", ""]
|
|
missing = sorted(title_id for title_id in previous["titles"] if title_id not in summary["titles"])
|
|
if missing:
|
|
names = ", ".join(f"{previous['titles'][title_id]['name']} ({title_id})" for title_id in missing)
|
|
lines += [f"Missing: {names}", ""]
|
|
lines += ["| " + " | ".join(columns) + " |", "|" + "---|" * len(columns)]
|
|
for title_id, title in sorted(summary["titles"].items()):
|
|
runs = title["runs"]
|
|
error = next((entry["first_error"] for entry in runs if entry["outcome"] != "rendering" and entry["first_error"]), "")
|
|
row = [title["name"], title_id, title["version"], cell(runs, "cold"), cell(runs, "warm"), f"{runs[-1]['fps']:.1f}",
|
|
error[:120].replace("|", "\\|")]
|
|
if previous:
|
|
old = previous["titles"].get(title_id)
|
|
row.append(worst(old["runs"]) if old else "new")
|
|
lines.append("| " + " | ".join(row) + " |")
|
|
return "\n".join(lines + [""])
|
|
|
|
|
|
def discover(directories):
|
|
found = {}
|
|
for directory in directories:
|
|
for param in sorted(Path(directory).glob("**/sce_sys/param.json")):
|
|
package = param.parent.parent
|
|
if not (package / "eboot.bin").is_file():
|
|
continue
|
|
data = json.loads(param.read_text(encoding="utf-8-sig"))
|
|
title_id = data.get("titleId")
|
|
if not isinstance(title_id, str):
|
|
raise ValueError(f"{param}: no titleId")
|
|
if not TITLE_ID.fullmatch(title_id):
|
|
raise ValueError(f"{param}: titleId {title_id!r} is not a title ID")
|
|
if title_id in found:
|
|
raise ValueError(f"{title_id} found more than once: {found[title_id]['package']} and {package}")
|
|
localized = data.get("localizedParameters", {})
|
|
name = localized.get(localized.get("defaultLanguage", "en-US"), {}).get("titleName", title_id)
|
|
found[title_id] = {"package": package, "version": data.get("contentVersion", ""), "name": name}
|
|
return found
|
|
|
|
|
|
def unwrap_or_copy(source, target):
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
if source.read_bytes()[:4] == b"\x7fELF":
|
|
shutil.copyfile(source, target)
|
|
else:
|
|
unwrap_self.unwrap(source, target)
|
|
|
|
|
|
def link_file(source, target):
|
|
try:
|
|
os.link(source, target)
|
|
except OSError:
|
|
shutil.copy2(source, target)
|
|
|
|
|
|
def link_tree(source, target, skip):
|
|
for path in sorted(source.rglob("*")):
|
|
relative = path.relative_to(source)
|
|
if relative.parts[0] in skip:
|
|
continue
|
|
if path.is_dir():
|
|
(target / relative).mkdir(parents=True, exist_ok=True)
|
|
else:
|
|
(target / relative).parent.mkdir(parents=True, exist_ok=True)
|
|
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)
|
|
source = work / "input"
|
|
(source / "sce_module").mkdir(parents=True)
|
|
unwrap_or_copy(package / "eboot.bin", source / "eboot.elf")
|
|
for module in sorted((package / "sce_module").glob("*.prx")):
|
|
unwrap_or_copy(module, source / "sce_module" / module.name)
|
|
for executable in entry.get("executables", []):
|
|
unwrap_or_copy(package / executable, source / executable)
|
|
executable = work / (title_id + (".exe" if platform == "windows" else ""))
|
|
relinker = build / "core/relinker" / ("relinker.exe" if platform == "windows" else "relinker")
|
|
with (work / "relink.log").open("w", encoding="utf-8") as log:
|
|
relinked = subprocess.run([str(relinker), *relink_args(entry, platform), str(source / "eboot.elf"), str(executable)],
|
|
stdout=log, stderr=subprocess.STDOUT)
|
|
if relinked.returncode != 0:
|
|
return None
|
|
link_tree(package, work / "app0", {"sce_module"})
|
|
libs = work / "libs"
|
|
libs.mkdir()
|
|
for library in sorted((build / "core/libs/libs").glob("*.prx")):
|
|
link_file(library, libs / library.name)
|
|
if platform == "windows":
|
|
runtime = windows_runtime(build)
|
|
for name in ("libgcc_s_seh-1.dll", "libstdc++-6.dll", "libwinpthread-1.dll"):
|
|
link_file(runtime / name, libs / name)
|
|
return executable
|
|
|
|
|
|
if os.name == "nt":
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
|
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
|
kernel32.CreateJobObjectW.restype = wintypes.HANDLE
|
|
kernel32.CreateJobObjectW.argtypes = (ctypes.c_void_p, wintypes.LPCWSTR)
|
|
kernel32.AssignProcessToJobObject.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
|
|
kernel32.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT)
|
|
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
|
|
kernel32.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
|
|
kernel32.QueryFullProcessImageNameW.argtypes = (wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD))
|
|
kernel32.GetProcessTimes.argtypes = (wintypes.HANDLE, *(ctypes.POINTER(ctypes.c_ulonglong),) * 4)
|
|
kernel32.QueryInformationJobObject.argtypes = (wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ctypes.c_void_p)
|
|
WindowCallback = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
|
user32.EnumWindows.argtypes = (WindowCallback, wintypes.LPARAM)
|
|
user32.GetWindowThreadProcessId.argtypes = (wintypes.HWND, ctypes.POINTER(wintypes.DWORD))
|
|
user32.IsWindowVisible.argtypes = (wintypes.HWND,)
|
|
user32.SetWindowPos.argtypes = (wintypes.HWND, wintypes.HWND, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, wintypes.UINT)
|
|
user32.GetSystemMetrics.argtypes = (ctypes.c_int,)
|
|
user32.PostMessageW.argtypes = (wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM)
|
|
user32.GetWindowRect.argtypes = (wintypes.HWND, ctypes.POINTER(wintypes.RECT))
|
|
user32.GetAncestor.restype = wintypes.HWND
|
|
user32.GetAncestor.argtypes = (wintypes.HWND, wintypes.UINT)
|
|
WinEventCallback = ctypes.WINFUNCTYPE(None, wintypes.HANDLE, wintypes.DWORD, wintypes.HWND, wintypes.LONG, wintypes.LONG, wintypes.DWORD, wintypes.DWORD)
|
|
user32.SetWinEventHook.restype = wintypes.HANDLE
|
|
user32.SetWinEventHook.argtypes = (wintypes.DWORD, wintypes.DWORD, wintypes.HMODULE, WinEventCallback, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD)
|
|
user32.UnhookWinEvent.argtypes = (wintypes.HANDLE,)
|
|
user32.GetMessageW.argtypes = (ctypes.POINTER(wintypes.MSG), wintypes.HWND, wintypes.UINT, wintypes.UINT)
|
|
user32.PostThreadMessageW.argtypes = (wintypes.DWORD, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM)
|
|
|
|
def send_to_back(window):
|
|
user32.SetWindowPos(window, wintypes.HWND(1), 0, 0, 0, 0, 0x4013)
|
|
|
|
class ProcessIdList(ctypes.Structure):
|
|
_fields_ = [("assigned", wintypes.DWORD), ("listed", wintypes.DWORD), ("ids", ctypes.c_size_t * 256)]
|
|
|
|
|
|
class Session:
|
|
def __init__(self, command, cwd, env, stdout, stderr, owned=None):
|
|
self.owned = os.path.normcase(os.path.abspath(owned or Path(command[0]).parent))
|
|
if os.name == "nt":
|
|
self.process = subprocess.Popen(command, cwd=cwd, env=env, stdout=stdout, stderr=stderr, creationflags=subprocess.CREATE_NO_WINDOW)
|
|
self.handles, self.images = {}, {}
|
|
self.job = kernel32.CreateJobObjectW(None, None)
|
|
if not self.job or (not kernel32.AssignProcessToJobObject(self.job, int(self.process._handle)) and self.process.poll() is None):
|
|
error = ctypes.get_last_error()
|
|
self.process.kill()
|
|
raise ctypes.WinError(error)
|
|
self.placed = set()
|
|
ready = threading.Event()
|
|
threading.Thread(target=self.watch_windows, args=(ready,), daemon=True).start()
|
|
ready.wait()
|
|
else:
|
|
self.process = subprocess.Popen(command, cwd=cwd, env=env, stdout=stdout, stderr=stderr, start_new_session=True)
|
|
|
|
def process_ids(self):
|
|
listing = ProcessIdList()
|
|
kernel32.QueryInformationJobObject(self.job, 3, ctypes.byref(listing), ctypes.sizeof(listing), None)
|
|
ids = set(listing.ids[:listing.listed])
|
|
for pid in ids - self.handles.keys():
|
|
self.handles[pid] = kernel32.OpenProcess(0x1000, False, pid)
|
|
self.images[pid] = self.image_path(self.handles[pid]) if self.handles[pid] else ""
|
|
return ids
|
|
|
|
def windows(self):
|
|
ids, found = self.process_ids(), []
|
|
|
|
def visit(window, _):
|
|
owner = wintypes.DWORD()
|
|
user32.GetWindowThreadProcessId(window, ctypes.byref(owner))
|
|
if owner.value in ids and user32.IsWindowVisible(window):
|
|
found.append(window)
|
|
return True
|
|
|
|
user32.EnumWindows(WindowCallback(visit), 0)
|
|
return found
|
|
|
|
def watch_windows(self, ready):
|
|
def created(hook, event, window, kind, child, thread, at):
|
|
if kind == 0 and child == 0 and user32.GetAncestor(window, 2) == window:
|
|
send_to_back(window)
|
|
|
|
callback = WinEventCallback(created)
|
|
hook = user32.SetWinEventHook(0x8000, 0x8002, None, callback, self.process.pid, 0, 0)
|
|
self.watcher = kernel32.GetCurrentThreadId()
|
|
ready.set()
|
|
message = wintypes.MSG()
|
|
while user32.GetMessageW(ctypes.byref(message), None, 0, 0) > 0:
|
|
pass
|
|
user32.UnhookWinEvent(hook)
|
|
|
|
def alive(self):
|
|
self.process.poll()
|
|
if os.name == "nt":
|
|
return bool(self.process_ids())
|
|
try:
|
|
os.killpg(self.process.pid, 0)
|
|
return True
|
|
except PermissionError:
|
|
return True
|
|
except ProcessLookupError:
|
|
return False
|
|
|
|
def image_path(self, handle):
|
|
size = wintypes.DWORD(32768)
|
|
buffer = ctypes.create_unicode_buffer(size.value)
|
|
return os.path.normcase(os.path.abspath(buffer.value)) if kernel32.QueryFullProcessImageNameW(handle, 0, buffer, ctypes.byref(size)) else ""
|
|
|
|
def exit_code(self):
|
|
if os.name == "nt" and not self.process_ids():
|
|
last = None
|
|
for pid, handle in self.handles.items():
|
|
code, times = wintypes.DWORD(), [ctypes.c_ulonglong() for _ in range(4)]
|
|
if (handle and self.images[pid].startswith(self.owned) and kernel32.GetExitCodeProcess(handle, ctypes.byref(code)) and code.value != 259
|
|
and kernel32.GetProcessTimes(handle, *map(ctypes.byref, times)) and (last is None or times[0].value >= last[0])):
|
|
last = (times[0].value, code.value)
|
|
if last:
|
|
return last[1]
|
|
return self.process.poll()
|
|
|
|
def send_new_windows_to_back(self):
|
|
if os.name == "nt":
|
|
for window in set(self.windows()) - self.placed:
|
|
send_to_back(window)
|
|
self.placed.add(window)
|
|
|
|
def close(self):
|
|
if os.name == "nt":
|
|
for window in self.windows():
|
|
user32.PostMessageW(window, 0x10, 0, 0)
|
|
else:
|
|
try:
|
|
os.killpg(self.process.pid, signal.SIGTERM)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
|
|
def kill(self):
|
|
if os.name == "nt":
|
|
kernel32.TerminateJobObject(self.job, 1)
|
|
user32.PostThreadMessageW(self.watcher, 0x12, 0, 0)
|
|
else:
|
|
try:
|
|
os.killpg(self.process.pid, signal.SIGKILL)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
self.process.wait()
|
|
|
|
|
|
CLOSE_SECONDS = 15
|
|
FRAME_ENV = {"APS5_DUMP_FRAMES": "100000", "APS5_DUMP_FRAMES_EVERY": "30", "APS5_DUMP_SCALE": "8"}
|
|
|
|
|
|
def collect_frames(work, destination):
|
|
frames = sorted(work.glob("frame_*.bmp"), key=lambda path: int(path.stem.split("_")[1]))
|
|
keep = {frames[0], frames[len(frames) // 2], frames[-1]} if frames else set()
|
|
destination.mkdir(parents=True, exist_ok=True)
|
|
kept = []
|
|
for frame in frames:
|
|
if frame in keep:
|
|
shutil.move(frame, destination / frame.name)
|
|
kept.append(frame.name)
|
|
else:
|
|
frame.unlink()
|
|
return kept
|
|
|
|
|
|
def run_title(executable, work, run_dir, seconds, extra_env):
|
|
run_dir.mkdir(parents=True)
|
|
inherited = {key: value for key, value in os.environ.items() if key not in ("ANYPS5_SHADER_CACHE_DIR", "ANYPS5_NO_SHADER_CACHE")}
|
|
env = {**inherited, "SDL_AUDIODRIVER": "dummy", "SDL_WINDOW_NO_ACTIVATION_WHEN_SHOWN": "1",
|
|
"APS5_TRACE_SUMMARY_EVERY": "10", "APS5_TRACE_DIR": str(run_dir), **FRAME_ENV,
|
|
"MESA_SHADER_CACHE_DIR": str(work / "driver-cache"), "__GL_SHADER_DISK_CACHE_PATH": str(work / "driver-cache"), **extra_env}
|
|
started = time.monotonic()
|
|
with (run_dir / "stdout.log").open("wb") as stdout, (run_dir / "stderr.log").open("wb") as stderr:
|
|
session = Session([str(executable)], work, env, stdout, stderr, owned=work)
|
|
try:
|
|
while session.alive() and time.monotonic() - started < seconds:
|
|
session.send_new_windows_to_back()
|
|
time.sleep(0.1 if time.monotonic() - started < 10 else 0.5)
|
|
elapsed = round(time.monotonic() - started, 1)
|
|
alive, ended = session.alive(), time.time()
|
|
exit_code = None if alive else session.exit_code()
|
|
if alive:
|
|
session.close()
|
|
deadline = time.monotonic() + CLOSE_SECONDS
|
|
while session.alive() and time.monotonic() < deadline:
|
|
time.sleep(0.5)
|
|
finally:
|
|
session.kill()
|
|
text = "".join((run_dir / name).read_text(encoding="utf-8", errors="replace") for name in ("stdout.log", "stderr.log"))
|
|
summary_path = run_dir / "aps5-summary.jsonl"
|
|
summary = read_summary(summary_path)
|
|
frames = collect_frames(work, run_dir / "frames")
|
|
uniform = frame_is_uniform(run_dir / "frames" / frames[-1]) if frames else None
|
|
age = ended - summary_path.stat().st_mtime if summary_path.is_file() else None
|
|
cpu = re.search(r"host CPU (.+?): has", text)
|
|
return {"outcome": classify(True, exit_code, age, bool(frames), uniform), "exit_code": exit_code, "seconds": elapsed,
|
|
"presents": summary["presents"], "fps": summary["fps"], "first_error": first_error(text),
|
|
"warnings": warning_counts(text), "frames": frames, "gpu": summary["gpu"], "driver": summary["driver"],
|
|
"os": summary["os"], "cpu": cpu.group(1) if cpu else ""}
|
|
|
|
|
|
def source_root(build):
|
|
cache = Path(build) / "CMakeCache.txt"
|
|
if cache.is_file():
|
|
for line in cache.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
if line.startswith("CMAKE_HOME_DIRECTORY:"):
|
|
return Path(line.split("=", 1)[1])
|
|
return ROOT
|
|
|
|
|
|
def git(root, *args):
|
|
return subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True).stdout.strip()
|
|
|
|
|
|
def positive(text):
|
|
try:
|
|
value = int(text)
|
|
except ValueError:
|
|
value = 0
|
|
if value < 1:
|
|
raise argparse.ArgumentTypeError(f"{text!r} is not a positive integer")
|
|
return value
|
|
|
|
|
|
def last_line(text):
|
|
return next((line.strip() for line in reversed(text.splitlines()) if line.strip()), "")
|
|
|
|
|
|
def write_summary(output, summary):
|
|
(output / "summary.json").write_text(json.dumps(summary, indent=2, default=str), encoding="utf-8")
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description="Run titles repeatably and report each run's outcome.")
|
|
parser.add_argument("ids", nargs="*", help="title IDs to run (default: every title found)")
|
|
parser.add_argument("--games", action="append", type=Path, required=True, help="directory searched for extracted packages")
|
|
parser.add_argument("--build", type=Path, default=ROOT / "build")
|
|
parser.add_argument("--out", type=Path)
|
|
parser.add_argument("--runs", type=positive, default=2)
|
|
parser.add_argument("--seconds", type=positive, help="run length for every title (default: the title's, else 60)")
|
|
parser.add_argument("--note", default="", help="free text for the report, e.g. 'main + #6814'")
|
|
parser.add_argument("--compare", type=Path, help="summary.json of an earlier run")
|
|
parser.add_argument("--titles", type=Path, default=ROOT / "tools/titles.json")
|
|
args = parser.parse_args(argv)
|
|
args.out = args.out or args.build / "title-runs"
|
|
previous = json.loads(args.compare.read_text(encoding="utf-8")) if args.compare else None
|
|
platform = "windows" if os.name == "nt" else "macos" if sys.platform == "darwin" else "linux"
|
|
titles = load_titles(args.titles)
|
|
found = discover(args.games)
|
|
ids = args.ids or sorted(found)
|
|
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)
|
|
commit = git(source, "rev-parse", "--short", "HEAD")
|
|
output = args.out.resolve() / f"{commit}-{datetime.datetime.now():%Y%m%d-%H%M%S}"
|
|
summary = {"build": {"commit": commit, "dirty": bool(git(source, "status", "--porcelain", "--untracked-files=no")), "note": args.note},
|
|
"machine": {"os": "", "gpu": "", "driver": "", "cpu": ""}, "runs": args.runs, "seconds": args.seconds or 60,
|
|
"driver_cache": "isolated" if platform == "linux" else "shared (not isolated)", "titles": {}}
|
|
for title_id in ids:
|
|
entry = titles.get(title_id, {})
|
|
seconds = args.seconds or entry.get("seconds", 60)
|
|
work = args.out.resolve() / "work" / title_id
|
|
try:
|
|
executable = prepare(title_id, found[title_id], entry, platform, args.build, work)
|
|
except (unwrap_self.Refused, OSError, RuntimeError, TypeError, ValueError) as error:
|
|
work.mkdir(parents=True, exist_ok=True)
|
|
(work / "relink.log").write_text(f"{type(error).__name__}: {error}\n", encoding="utf-8")
|
|
executable = None
|
|
(output / title_id).mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(work / "relink.log", output / title_id / "relink.log")
|
|
runs = []
|
|
for number in range(1, args.runs + 1):
|
|
if executable is None:
|
|
log = (work / "relink.log").read_text(encoding="utf-8", errors="replace")
|
|
result = {"outcome": "relink-failed", "exit_code": None, "seconds": 0, "presents": 0, "fps": 0.0,
|
|
"first_error": first_error(log) or last_line(log),
|
|
"warnings": {}, "frames": []}
|
|
else:
|
|
result = run_title(executable, work, output / title_id / f"run{number}", seconds, entry.get("env", {}))
|
|
for key in ("os", "gpu", "driver", "cpu"):
|
|
value = result.pop(key)
|
|
summary["machine"][key] = summary["machine"][key] or value
|
|
runs.append({"run": number, "cache": "cold" if number == 1 else "warm", **result})
|
|
print(f"{title_id} run{number}: {result['outcome']}", flush=True)
|
|
summary["titles"][title_id] = {"name": found[title_id]["name"], "version": found[title_id]["version"], "seconds": seconds, "runs": runs}
|
|
write_summary(output, summary)
|
|
write_summary(output, summary)
|
|
report = render_markdown(summary, previous)
|
|
(output / "summary.md").write_text(report, encoding="utf-8")
|
|
print(report)
|
|
print(f"written to {output}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|