mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 02:11:36 -04:00
291 lines
16 KiB
Python
291 lines
16 KiB
Python
"""Build and run test_gl_frame_gen.c on a hidden real OpenGL context.
|
|
|
|
Frame generation ([video] frame_generation, docs/FRAME_GENERATION.md): per
|
|
scale and present path (VRAM, native-wide), the fixture runs with the render
|
|
thread on and generation off, then on (forced). The real presented images, in
|
|
order, must be identical; the run with generation on must have presented
|
|
generated frames and passes its own endpoint checks (phase 1 = the newer real
|
|
image, phase 0 = the older one; in redraw, the default, nothing of
|
|
reprojection ran or was allocated). Then with reprojection
|
|
(FG_METHOD=reprojection, with and without PGXP depth): the warp's pixel checks
|
|
pass and the real images are those of the run with generation off. Build
|
|
flags and skips as
|
|
run_gl_render_thread.py.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import platform
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
MAC_FRAMEWORKS = ["Cocoa", "OpenGL", "IOKit", "CoreVideo", "CoreAudio", "AudioToolbox",
|
|
"Carbon", "ForceFeedback", "GameController", "Metal", "QuartzCore",
|
|
"CoreMedia", "AVFoundation", "Foundation", "CoreHaptics",
|
|
"UniformTypeIdentifiers"]
|
|
# The static SDL3's Win32 dependencies (as run_gl_texture_filter.py links them).
|
|
WIN_LIBS = ["opengl32", "kernel32", "user32", "gdi32", "winmm", "imm32", "ole32",
|
|
"oleaut32", "version", "uuid", "advapi32", "setupapi", "shell32", "dinput8"]
|
|
SKIP_EXIT = 77
|
|
WINDOWS = os.name == "nt" or platform.system().startswith(("MINGW", "MSYS", "CYGWIN"))
|
|
KEYS = ("real",)
|
|
# FG_PGXP values: the scene without PGXP renderer features first.
|
|
PGXP_FEATURES = ("none", "depth", "color", "depth,color", "seam", "depth,seam")
|
|
# Seam expansion applies only above 1x and only to depth-tested triangles:
|
|
# these feature sets must draw exactly like the named one (scale 1: all
|
|
# scales), every other one must differ from "none".
|
|
PGXP_SAME = {("seam", None): "none", ("depth,seam", 1): "depth"}
|
|
|
|
|
|
def parse(stdout):
|
|
out = {}
|
|
for k in KEYS:
|
|
m = re.search(r"^" + k + r"=([0-9a-f]{16})$", stdout, re.M)
|
|
out[k] = m[1] if m else None
|
|
m = re.search(r"^checks=(\d+) failures=(\d+)$", stdout, re.M)
|
|
out["failures"] = int(m[2]) if m else None
|
|
return out
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--cc", default="cc")
|
|
ap.add_argument("--sdl-include", required=True)
|
|
ap.add_argument("--sdl-library", required=True)
|
|
ap.add_argument("--output", required=True)
|
|
ap.add_argument("--fixture", type=pathlib.Path)
|
|
ap.add_argument("--scales", default="1,3")
|
|
ap.add_argument("--frames", default="24")
|
|
args = ap.parse_args()
|
|
sdl_includes = [str(pathlib.Path(d).resolve()) for d in args.sdl_include.split(";") if d]
|
|
args.sdl_library = str(pathlib.Path(args.sdl_library).resolve())
|
|
framework = pathlib.Path(__file__).resolve().parents[2]
|
|
fixture = args.fixture or framework / "runtime/tests/test_gl_frame_gen.c"
|
|
out_root = pathlib.Path(args.output).resolve()
|
|
out_root.mkdir(parents=True, exist_ok=True)
|
|
dest = pathlib.Path(tempfile.mkdtemp(prefix="fg-", dir=out_root))
|
|
print("Evidence directory:", dest)
|
|
receipt = []
|
|
|
|
def run(command):
|
|
command = [str(c) for c in command]
|
|
r = subprocess.run(command, cwd=dest, capture_output=True, text=True,
|
|
encoding="utf-8", errors="replace")
|
|
receipt.append({"cmd": command, "exit": r.returncode,
|
|
"stdout": r.stdout, "stderr": r.stderr[-4000:]})
|
|
(dest / "receipt.json").write_text(json.dumps(receipt, indent=2), encoding="utf-8")
|
|
return r
|
|
|
|
includes = ["-I", framework / "runtime/include", "-I", framework / "runtime/src"]
|
|
for d in sdl_includes:
|
|
includes += ["-I", d]
|
|
src = framework / "runtime/src"
|
|
# The fixture #includes gpu_gl_renderer.c and drives it through the real
|
|
# facade (gpu_render.c) and render thread (render_thread.c).
|
|
sources = [("probe", fixture), ("sw", src / "gpu_sw_renderer.c"),
|
|
("fi", src / "frame_interpolation.c"), ("rp", src / "render_pass_plan.c"),
|
|
("xr", src / "psx_openxr.c"), ("rth", src / "render_thread.c"), ("pth", src / "present_thread.c"),
|
|
("facade", src / "gpu_render.c"), ("fg", src / "frame_gen.c")]
|
|
sections = [] if platform.system() == "Darwin" else ["-ffunction-sections", "-fdata-sections"]
|
|
# MinGW's PE linker reports undefined references from sections it later
|
|
# collects, so there LTO drops them first (as run_gl_scale_invariance.py).
|
|
if WINDOWS:
|
|
sections.append("-flto")
|
|
objs = []
|
|
# The renderer calls the in-game overlay hook (host_overlay.c) at present.
|
|
if (src / "host_overlay.c").exists():
|
|
sources.append(("hov", src / "host_overlay.c"))
|
|
for name, path in sources:
|
|
o = dest / (name + ".o")
|
|
r = run([args.cc, "-std=gnu11", "-O1", "-DPSX_SDL3=1", "-DPSX_NO_DEBUG_TOOLS=1",
|
|
"-DGL_SILENCE_DEPRECATION=1", "-w", *sections, *includes, "-c", path, "-o", o])
|
|
if r.returncode:
|
|
print(r.stderr[-3000:])
|
|
return 2
|
|
objs.append(o)
|
|
probe = dest / ("probe.exe" if WINDOWS else "probe")
|
|
link = [args.cc, *objs, args.sdl_library, "-o", probe]
|
|
if platform.system() == "Darwin":
|
|
for f in MAC_FRAMEWORKS:
|
|
link += ["-framework", f]
|
|
link += ["-liconv", "-lm", "-lpthread", "-Wl,-dead_strip"]
|
|
elif WINDOWS:
|
|
# -static: no libwinpthread/libgcc DLLs needed next to probe.exe.
|
|
link += ["-flto", "-static", *["-l" + x for x in WIN_LIBS], "-lm", "-Wl,--gc-sections"]
|
|
else:
|
|
link += ["-lGL", "-lm", "-ldl", "-lpthread", "-Wl,--gc-sections"]
|
|
r = run(link)
|
|
if r.returncode:
|
|
print(r.stderr[-3000:])
|
|
return 2
|
|
|
|
ok = True
|
|
first = True
|
|
for s in [int(v) for v in args.scales.split(",") if v]:
|
|
for path, timing in (("vram", "flip"), ("wide", "flip"), ("vram", "late"), ("wide", "late")):
|
|
runs = {}
|
|
for fg in (0, 1):
|
|
r = run([probe, s, fg, path, args.frames, timing])
|
|
if first and r.returncode == SKIP_EXIT:
|
|
print("SKIP:", r.stderr.strip()[-600:])
|
|
return SKIP_EXIT
|
|
first = False
|
|
p = parse(r.stdout)
|
|
tail = r.stdout.strip().splitlines()[-6:]
|
|
print(f"scale {s} {path} {timing} frame_generation={fg}: exit={r.returncode}", tail,
|
|
r.stderr.strip()[-800:])
|
|
if r.returncode or p["failures"] != 0 or p["real"] is None:
|
|
ok = False
|
|
m = re.search(r"^presents=(\d+) generated=(\d+)$", r.stdout, re.M)
|
|
p["presents"] = int(m[1]) if m else -1
|
|
p["generated"] = int(m[2]) if m else -1
|
|
runs[fg] = p
|
|
if runs[0]["real"] != runs[1]["real"] or runs[0]["presents"] != runs[1]["presents"]:
|
|
print(f"FAIL scale {s} {path} {timing}: the real frames differ with generation on "
|
|
f"({runs[0]['real']}/{runs[0]['presents']} off, "
|
|
f"{runs[1]['real']}/{runs[1]['presents']} on)")
|
|
ok = False
|
|
if runs[0]["generated"] != 0 or runs[1]["generated"] <= 0:
|
|
print(f"FAIL scale {s} {path} {timing}: generated frames off={runs[0]['generated']} "
|
|
f"on={runs[1]['generated']}")
|
|
ok = False
|
|
# Reprojection ([video] frame_generation_method = "reprojection",
|
|
# opt-in): the fixture checks the warp's pixels (and with PGXP
|
|
# depth, that the warp leaves no depth for the cars); the real
|
|
# frames stay those of the run with generation off.
|
|
# pan 1 (the scene above, with PGXP depth): its real frames must be
|
|
# the generation-off run's; pan 6: a camera move the warp must follow.
|
|
for pgxp, pan in (("1", "1"), ("0", "6")):
|
|
env = dict(os.environ, FG_METHOD="reprojection", FG_RP_DEPTH=pgxp, FG_PAN=pan)
|
|
command = [str(c) for c in (probe, s, 1, path, args.frames, timing)]
|
|
r = subprocess.run(command, cwd=dest, capture_output=True, text=True,
|
|
encoding="utf-8", errors="replace", env=env)
|
|
receipt.append({"cmd": command, "env": {"FG_METHOD": "reprojection", "FG_RP_DEPTH": pgxp, "FG_PAN": pan},
|
|
"exit": r.returncode, "stdout": r.stdout, "stderr": r.stderr[-4000:]})
|
|
(dest / "receipt.json").write_text(json.dumps(receipt, indent=2), encoding="utf-8")
|
|
p = parse(r.stdout)
|
|
info = [l for l in r.stdout.splitlines()
|
|
if l.startswith(("reproject", "pgxp", "presents"))]
|
|
print(f"scale {s} {path} {timing} reprojection pgxp={pgxp} pan={pan}: exit={r.returncode}", info,
|
|
r.stderr.strip()[-800:])
|
|
m = re.search(r"^presents=(\d+) generated=(\d+)$", r.stdout, re.M)
|
|
if (r.returncode or p["failures"] != 0 or not m or int(m[2]) <= 0
|
|
or (pan == "1" and p["real"] != runs[0]["real"])):
|
|
print(f"FAIL scale {s} {path} {timing} reprojection pgxp={pgxp} pan={pan}")
|
|
ok = False
|
|
# Occlusion (FG_OCCLUDE=1): a far quad drawn after a near wall,
|
|
# hidden in the real frames by PGXP depth; no reprojected frame
|
|
# may show it through the wall (the depth image takes the nearest
|
|
# world depth, not painter order).
|
|
if path == "vram": # the occlusion scene draws through VRAM only
|
|
env = dict(os.environ, FG_METHOD="reprojection", FG_OCCLUDE="1")
|
|
command = [str(c) for c in (probe, s, 1, path, args.frames, timing)]
|
|
r = subprocess.run(command, cwd=dest, capture_output=True, text=True,
|
|
encoding="utf-8", errors="replace", env=env)
|
|
receipt.append({"cmd": command, "env": {"FG_METHOD": "reprojection", "FG_OCCLUDE": "1"},
|
|
"exit": r.returncode, "stdout": r.stdout, "stderr": r.stderr[-4000:]})
|
|
occl = [l for l in r.stdout.splitlines() if l.startswith("occlusion")]
|
|
print(f"scale {s} {path} {timing} reprojection occlusion: exit={r.returncode}", occl,
|
|
r.stderr.strip()[-800:])
|
|
if r.returncode or not occl:
|
|
print(f"FAIL scale {s} {path} {timing} reprojection occlusion")
|
|
ok = False
|
|
# Present thread ([video] present_thread): the fixture checks the
|
|
# window shows every composed image bit for bit, in order. The real
|
|
# images match the direct-swap run: bit for bit, except that
|
|
# rasterizing into a texture instead of the window's drawable may
|
|
# round a filtered present pixel differently (seen on Apple's
|
|
# GL-on-Metal: 1 LSB, a pixel or two per frame); allowed up to 1 LSB
|
|
# on at most 0.05 % of the pixels, and reported.
|
|
for fg in (0, 1):
|
|
dirs = {}
|
|
for mode in ("direct", "pt"):
|
|
sub = dest / f"pt-{s}-{path}-{timing}-{fg}-{mode}"
|
|
sub.mkdir(exist_ok=True)
|
|
cmd = [str(c) for c in (probe, s, fg, path, args.frames, timing)]
|
|
if mode == "pt":
|
|
cmd.append("pt")
|
|
env = dict(os.environ, FG_DUMP="1")
|
|
r = subprocess.run(cmd, cwd=sub, capture_output=True, text=True,
|
|
encoding="utf-8", errors="replace", env=env)
|
|
p = parse(r.stdout)
|
|
if r.returncode or p["failures"] != 0:
|
|
print(f"FAIL scale {s} {path} {timing} fg={fg} {mode}: exit={r.returncode}",
|
|
r.stdout.strip().splitlines()[-4:], r.stderr.strip()[-800:])
|
|
ok = False
|
|
dirs[mode] = sub
|
|
files = sorted(f.name for f in dirs["direct"].glob("real*.rgba"))
|
|
other = sorted(f.name for f in dirs["pt"].glob("real*.rgba"))
|
|
if files != other or not files:
|
|
print(f"FAIL scale {s} {path} {timing} fg={fg}: present thread real frames "
|
|
f"{len(other)} vs {len(files)}")
|
|
ok = False
|
|
continue
|
|
exact, worst, worst_px = 0, 0, 0.0
|
|
for name in files:
|
|
a = (dirs["direct"] / name).read_bytes()
|
|
b = (dirs["pt"] / name).read_bytes()
|
|
if a == b:
|
|
exact += 1
|
|
continue
|
|
d = [abs(x - y) for x, y in zip(a, b) if x != y]
|
|
px = len({i // 4 for i, (x, y) in enumerate(zip(a, b)) if x != y})
|
|
worst = max(worst, max(d) if d else 0)
|
|
worst_px = max(worst_px, px / (len(a) / 4))
|
|
print(f"scale {s} {path} {timing} frame_generation={fg} present_thread=1: "
|
|
f"{exact}/{len(files)} real frames bit-identical, max diff {worst} LSB "
|
|
f"on {worst_px * 100:.3f} % of pixels")
|
|
if worst > 1 or worst_px > 0.0005:
|
|
print(f"FAIL scale {s} {path} {timing} fg={fg}: present thread changed real frames")
|
|
ok = False
|
|
# PGXP renderer features (docs/ENHANCEMENTS.md G1.14): the scene as PGXP
|
|
# 3D triangles that overlap out of painter order. Per feature set, the
|
|
# real frames are the same with generation off and on, and the fixture's
|
|
# own check holds: the generated frame at phase 1 equals the real one
|
|
# (generated frames honour the features). Each feature changes the
|
|
# real image (it is visible in this scene), so the comparison means
|
|
# something.
|
|
for s in [int(v) for v in args.scales.split(",") if v]:
|
|
digests = {}
|
|
for feats in PGXP_FEATURES:
|
|
runs = {}
|
|
for fg in (0, 1):
|
|
sub = dest / f"pgxp-{s}-{feats}-{fg}"
|
|
sub.mkdir(exist_ok=True)
|
|
cmd = [str(c) for c in (probe, s, fg, "vram", args.frames, "flip")]
|
|
r = subprocess.run(cmd, cwd=sub, capture_output=True, text=True, encoding="utf-8",
|
|
errors="replace", env=dict(os.environ, FG_PGXP=feats))
|
|
p = parse(r.stdout)
|
|
tail = r.stdout.strip().splitlines()[-5:]
|
|
print(f"scale {s} pgxp={feats} frame_generation={fg}: exit={r.returncode}", tail,
|
|
r.stderr.strip()[-800:])
|
|
if r.returncode or p["failures"] != 0 or p["real"] is None:
|
|
ok = False
|
|
runs[fg] = p["real"]
|
|
if runs[0] != runs[1]:
|
|
print(f"FAIL scale {s} pgxp={feats}: the real frames differ with generation on")
|
|
ok = False
|
|
digests[feats] = runs[0]
|
|
for feats in PGXP_FEATURES[1:]:
|
|
same = PGXP_SAME.get((feats, None)) or PGXP_SAME.get((feats, s))
|
|
if same:
|
|
if digests[feats] != digests[same]:
|
|
print(f"FAIL scale {s} pgxp={feats}: differs from pgxp={same}")
|
|
ok = False
|
|
continue
|
|
if digests[feats] == digests[PGXP_FEATURES[0]]:
|
|
print(f"FAIL scale {s} pgxp={feats}: the feature does not change the real image")
|
|
ok = False
|
|
if s > 1 and digests["depth,seam"] == digests["depth"]:
|
|
print(f"FAIL scale {s}: seam expansion does not change the depth-tested image")
|
|
ok = False
|
|
print("PASS" if ok else "FAIL")
|
|
return 0 if ok else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|