#!/usr/bin/env python
"""Framedump rendering test runner for PPSSPPHeadless.
Runs GE frame dumps (".ppdmp", possibly zipped) through PPSSPPHeadless with a
set of rendering variants (command line option sets), generating reference
images when missing and comparing against existing ones when present. Produces
a self-contained HTML report and returns a nonzero exit code on failure.
This script is test-set agnostic: it reads its configuration from a JSON file
(similar to frametests/frametests.json) that points at the test data tree,
so it can be used against different test sets (the CI one, or bigger private
ones on custom machines).
Usage:
frametests.py [OPTIONS] [CONFIG.json]
Example:
python3 frametests.py frametests/frametests.json
"""
import argparse
import base64
import glob
import html
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
import time
from pathlib import Path
# test.py-style candidate paths for the headless binary, relative to the
# current working directory, in preference order.
HEADLESS_CANDIDATES = [
"Windows/x64/Debug/PPSSPPHeadless.exe",
"Windows/Debug/PPSSPPHeadless.exe",
"Windows/x64/Release/PPSSPPHeadless.exe",
"Windows/Release/PPSSPPHeadless.exe",
"build/PPSSPPHeadless",
"build-headless/PPSSPPHeadless",
"build*/PPSSPPHeadless",
"PPSSPPHeadless",
"ppsspp/PPSSPPHeadless",
]
MSE_RE = re.compile(r"Screenshot MSE: ([0-9.eE+-]+)")
LOG_EMBED_LIMIT = 64 * 1024
STATUS_PASS = "PASS"
STATUS_FAIL = "FAIL"
STATUS_NEW = "NEW"
STATUS_ERROR = "ERROR"
def find_headless(config_dir, config_path):
"""Locate the PPSSPPHeadless binary. The config headlessPath wins, then
the PPSSPP_HEADLESS env var, then candidate paths (newest by mtime)."""
candidates = []
if config_path:
p = Path(config_path)
candidates.append(p if p.is_absolute() else config_dir / p)
env_path = os.environ.get("PPSSPP_HEADLESS")
if env_path:
candidates.append(Path(env_path))
for pattern in HEADLESS_CANDIDATES:
candidates.extend(Path(m) for m in glob.glob(pattern))
found = [c.resolve() for c in candidates if c.is_file()]
if not found:
return None
return max(found, key=lambda p: p.stat().st_mtime)
def strip_dump_extensions(name):
"""Derive the base name for a dump, stripping .zip and .ppdmp extensions."""
base = name
lower = base.lower()
if lower.endswith(".zip"):
base = base[:-4]
lower = base.lower()
if lower.endswith(".ppdmp"):
base = base[:-6]
return base
def collect_dumps(test_root):
"""Recursively collect frame dumps (.ppdmp files and .zip wrappers)."""
dumps = []
for dirpath, dirnames, filenames in os.walk(test_root):
dirnames[:] = [d for d in sorted(dirnames) if not d.startswith(".")]
for filename in sorted(filenames):
lower = filename.lower()
if lower.endswith(".ppdmp") or lower.endswith(".zip"):
dumps.append(Path(dirpath) / filename)
return sorted(dumps)
def run_test(headless, dump_path, variant_args, ref_path, actual_path, diff_path, max_mse, timeout, output_dir):
"""Run one dump with one variant's args. Returns (status, mse, output, timed_out)."""
generate = not ref_path.exists()
args = [str(headless)] + list(variant_args)
if generate:
args.append("--screenshot-save=" + str(ref_path))
else:
args.extend([
"--screenshot-save=" + str(actual_path),
"--screenshot=" + str(ref_path),
"--max-mse=" + str(max_mse),
"--screenshot-diff=" + str(diff_path),
])
args.append(str(dump_path))
timed_out = False
try:
proc = subprocess.Popen(
args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
cwd=str(output_dir), text=True, encoding="utf-8", errors="replace")
output, _ = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
output, _ = proc.communicate()
timed_out = True
except OSError as e:
# e.g. the binary couldn't be launched at all.
return STATUS_ERROR, None, "Failed to launch headless binary: %s\n" % e, False
returncode = proc.returncode
# Headless may drop these in the cwd on failure (outside GITHUB_ACTIONS).
for stray in ("__testfailure.bmp", "__testcompare.png"):
try:
(output_dir / stray).unlink()
except OSError:
pass
match = MSE_RE.search(output)
mse = float(match.group(1)) if match else None
if timed_out:
return STATUS_FAIL, mse, output, True
if generate:
if returncode != 0 or not ref_path.exists():
return STATUS_ERROR, mse, output, False
return STATUS_NEW, mse, output, False
if returncode != 0:
return STATUS_FAIL, mse, output, False
return STATUS_PASS, mse, output, False
def image_data_uri(path):
"""Read an image file as a PNG data URI, or None if missing."""
try:
data = path.read_bytes()
except OSError:
return None
return "data:image/png;base64," + base64.b64encode(data).decode("ascii")
def write_report(report_path, results, variants):
rows = []
for res in results:
cls = "failed" if res["status"] in (STATUS_FAIL, STATUS_ERROR) else ("new" if res["status"] == STATUS_NEW else "")
mse = ('MSE: %.6f' % res["mse"]) if res["mse"] is not None else ""
detail = '%s' % html.escape(res["detail"]) if res["detail"] else ""
ref_img = '
' % res["ref_uri"] if res["ref_uri"] else ""
actual_img = '
' % res["actual_uri"] if res["actual_uri"] else ""
diff_img = '
' % res["diff_uri"] if res["diff_uri"] else ""
log = ('log
%s
' % html.escape(res["log"])) if res["log"] else ""
rows.append("""
{status}
{variant}
{name}
{mse}{detail}
{ref_img}{actual_img}{diff_img}
{log}
""".format(
cls=cls,
status=html.escape(res["status"]),
variant=html.escape(res["variant"]),
name=html.escape(res["dump_name"]),
mse=mse,
detail=detail,
ref_img=ref_img,
actual_img=actual_img,
diff_img=diff_img,
log=log,
))
counts = {}
for res in results:
counts[res["status"]] = counts.get(res["status"], 0) + 1
summary = ", ".join("%s: %d" % (k, v) for k, v in sorted(counts.items()))
variant_rows = ""
for variant in variants:
var_counts = {}
for res in results:
if res["variant"] == variant:
var_counts[res["status"]] = var_counts.get(res["status"], 0) + 1
variant_rows += "| %s | %s |
" % (
html.escape(variant), html.escape(", ".join("%s: %d" % (k, v) for k, v in sorted(var_counts.items()))))
report = """
PPSSPP frametests report
PPSSPP frametests report
{summary}
Variants
| variant | results |
{variant_rows}
Results
{rows}
""".format(summary=html.escape(summary), variant_rows=variant_rows, rows="\n".join(rows))
report_path.write_text(report, encoding="utf-8")
return report_path
def main():
parser = argparse.ArgumentParser(description="Run PPSSPP framedump rendering tests.")
parser.add_argument("config", nargs="?", default="frametests/frametests.json",
help="Path to the JSON configuration file (default: frametests/frametests.json)")
parser.add_argument("--filter", default="", help="Only run dumps whose path contains this substring (case-insensitive)")
parser.add_argument("--strict", action="store_true",
help="Treat missing reference images as failures (configuration error)")
parser.add_argument("--out-mode", choices=["all", "failures"], default="all",
help="What to keep in the output directory: everything, or only failures (default: all)")
args = parser.parse_args()
strict = args.strict or bool(os.environ.get("GITHUB_ACTIONS"))
config_path = Path(args.config)
if not config_path.is_file():
print("ERROR: config file not found: %s" % config_path, file=sys.stderr)
return 2
config_dir = config_path.parent
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
test_root = (config_dir / config["testRoot"]).resolve()
ref_root = (config_dir / config["refRoot"]).resolve()
output_root = (config_dir / config.get("outputRoot", "out")).resolve()
timeout = float(config.get("timeout", 60))
max_mse = float(config.get("maxMse", 0.0))
raw_variants = config.get("variants", {})
if not raw_variants:
print("ERROR: no variants defined in %s" % config_path, file=sys.stderr)
return 2
# Each variant has a suffix (used for its output images) and a compare-suffix
# (the reference image it compares against). Reference images are only
# generated for variants whose suffix matches their compare-suffix, so that
# other variants can share a reference (e.g. gl comparing against the soft
# reference). A plain string entry means args only, with both suffixes
# defaulting to the variant name.
variants = {}
for key, entry in raw_variants.items():
if isinstance(entry, str):
args_str = entry
suffix = key
else:
args_str = entry.get("args", "")
suffix = entry.get("suffix", key)
compare_suffix = entry.get("compare-suffix", suffix) if not isinstance(entry, str) else suffix
variants[key] = {"args": shlex.split(args_str), "suffix": suffix, "compare_suffix": compare_suffix}
headless = find_headless(config_dir, config.get("headlessPath", ""))
if headless is None:
print("ERROR: PPSSPPHeadless binary not found. Set 'headlessPath' in %s or PPSSPP_HEADLESS, or run from the repo root." % config_path, file=sys.stderr)
return 2
if not test_root.is_dir():
print("ERROR: test root not found: %s (check 'testRoot' in %s)" % (test_root, config_path), file=sys.stderr)
return 2
dumps = collect_dumps(test_root)
if not dumps:
print("ERROR: no frame dumps found under %s" % test_root, file=sys.stderr)
return 2
if args.filter:
filter_lower = args.filter.lower()
dumps = [d for d in dumps if filter_lower in str(d.relative_to(test_root)).lower()]
log_dir = output_root / "logs"
diff_dir = output_root / "diffs"
actual_dir = output_root / "actuals"
generated_dir = output_root / "generated"
for d in (log_dir, diff_dir, actual_dir, generated_dir):
d.mkdir(parents=True, exist_ok=True)
print("Headless: %s" % headless)
print("Test root: %s" % test_root)
variant_desc = []
for key, v in variants.items():
if v["compare_suffix"] == v["suffix"]:
variant_desc.append(key)
else:
variant_desc.append("%s (ref: %s)" % (key, v["compare_suffix"]))
print("Variants: %s" % ", ".join(variant_desc))
print("Running %d dumps..." % len(dumps))
results = []
failures = 0
errors = 0
new_refs = 0
start_time = time.time()
for dump in dumps:
rel = dump.relative_to(test_root)
base = strip_dump_extensions(dump.name)
rel_dir = rel.parent
for variant, vinfo in variants.items():
variant_args = vinfo["args"]
generate_refs = vinfo["suffix"] == vinfo["compare_suffix"]
ref_path = ref_root / rel_dir / ("%s-%s.png" % (base, vinfo["compare_suffix"]))
actual_path = actual_dir / rel_dir / ("%s-%s.png" % (base, variant))
diff_path = diff_dir / rel_dir / ("%s-%s.png" % (base, variant))
log_path = log_dir / rel_dir / ("%s-%s.log" % (base, variant))
for p in (ref_path, actual_path, diff_path, log_path):
p.parent.mkdir(parents=True, exist_ok=True)
if not ref_path.exists() and not generate_refs:
status = STATUS_ERROR
mse = None
output = ""
timed_out = False
else:
status, mse, output, timed_out = run_test(
headless, dump, variant_args, ref_path, actual_path, diff_path,
max_mse, timeout, output_root)
detail = ""
if status == STATUS_ERROR and not ref_path.exists() and not generate_refs:
detail = "no reference image '%s' available for this variant (references are only generated for variants whose suffix matches their compare-suffix)" % vinfo["compare_suffix"]
elif status == STATUS_FAIL:
if timed_out:
detail = "timed out"
elif mse is None:
if "Unable to read screenshot" in output:
status = STATUS_ERROR
detail = "reference image could not be loaded (corrupt or unreadable)"
else:
detail = "no screenshot MSE reported"
else:
detail = "MSE %.6f exceeds maximum %.6f" % (mse, max_mse)
if status == STATUS_ERROR and not detail:
detail = "headless failed or produced no reference image"
if status == STATUS_NEW:
detail = "reference image generated"
# Track counters and emit GitHub Actions annotations.
if status == STATUS_FAIL:
failures += 1
if os.environ.get("GITHUB_ACTIONS"):
print("::error file=%s::%s failed (%s)" % (rel, variant, detail))
elif status == STATUS_ERROR:
errors += 1
if os.environ.get("GITHUB_ACTIONS"):
print("::error file=%s::%s errored (%s)" % (rel, variant, detail))
elif status == STATUS_NEW:
new_refs += 1
gen_path = generated_dir / rel_dir / ("%s-%s.png" % (base, vinfo["compare_suffix"]))
gen_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(ref_path, gen_path)
if strict:
failures += 1
if os.environ.get("GITHUB_ACTIONS"):
print("::error file=%s::%s missing reference image (config error; generated one saved to %s)" % (rel, variant, gen_path))
# Keep artifacts (log, actual, diff) for everything in "all" mode,
# or only for failures/errors otherwise.
keep_artifacts = args.out_mode == "all" or status in (STATUS_FAIL, STATUS_ERROR)
if keep_artifacts:
log_path.write_text(output, encoding="utf-8", errors="replace")
else:
for p in (log_path, actual_path, diff_path):
try:
p.unlink()
except OSError:
pass
if status != STATUS_PASS:
print("[%s] %s (%s)%s" % (status, rel, variant, " - " + detail if detail else ""))
# In "failures" mode, only embed images for non-passing tests to
# keep the report (and thus the CI artifact) small.
ref_uri = None
if args.out_mode == "all" or status != STATUS_PASS:
if "could not be loaded" not in detail:
ref_uri = image_data_uri(ref_path)
results.append({
"dump_name": str(rel),
"variant": variant,
"status": status,
"mse": mse,
"detail": detail,
"log": output if keep_artifacts and len(output) < LOG_EMBED_LIMIT else "",
"ref_uri": ref_uri,
"actual_uri": image_data_uri(actual_path) if actual_path.exists() else None,
"diff_uri": image_data_uri(diff_path) if diff_path.exists() else None,
})
elapsed = time.time() - start_time
print("Done in %.1fs: %d tests, %d failures, %d errors, %d new references." % (
elapsed, len(results), failures, errors, new_refs))
report_path = output_root / "report.html"
write_report(report_path, results, list(variants.keys()))
print("Report written to: %s" % report_path)
if failures or errors:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())