diff --git a/docs/GPU_GL_READBACK_REGIONS.md b/docs/GPU_GL_READBACK_REGIONS.md index c1e5c61f..e7cc6a6d 100644 --- a/docs/GPU_GL_READBACK_REGIONS.md +++ b/docs/GPU_GL_READBACK_REGIONS.md @@ -42,3 +42,9 @@ DuckStation's software readback path was consulted as an alternative. This corre authority and needs no title hook or controller policy change. Future work can reduce row submissions or use a more precise dirty set if measured demand justifies it. + +For Clang/MinGW builds, set `PSX_GL_READBACK_COMPILER_BIN` to a GCC/MinGW +bin directory containing both gcc.exe and g++.exe. CMake checks these files +before registering the hardware test. `gl_readback_runner_test` separately +checks strict result parsing without a GPU or compiler. A negative run passes +only for exactly one failure named single-pixel bounded transfer. diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 73d3b2d3..bfe92643 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -26,19 +26,27 @@ if(BUILD_TESTING) # Optional real-hardware regression. Configure its dependencies explicitly; # absence must remain visible, never counted as a passing GPU test. set(PSX_GL_READBACK_SDL_ROOT "" CACHE PATH "SDL3 static build root for GL readback test") - if(MINGW AND PSX_GL_READBACK_SDL_ROOT) + get_filename_component(_gl_test_default_bin "${CMAKE_C_COMPILER}" DIRECTORY) + set(PSX_GL_READBACK_COMPILER_BIN "${_gl_test_default_bin}" CACHE PATH "GCC/MinGW bin for GL fixture") + find_package(Python3 QUIET COMPONENTS Interpreter) + if(Python3_Interpreter_FOUND) + add_test(NAME gl_readback_runner_test COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_readback_runner.py") + endif() + if(MINGW AND PSX_GL_READBACK_SDL_ROOT + AND EXISTS "${PSX_GL_READBACK_COMPILER_BIN}/gcc.exe" + AND EXISTS "${PSX_GL_READBACK_COMPILER_BIN}/g++.exe") find_package(Python3 REQUIRED COMPONENTS Interpreter) - get_filename_component(_gl_test_compiler_bin "${CMAKE_C_COMPILER}" DIRECTORY) add_test(NAME gl_readback_region_test COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_readback_region.py" --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_readback_region.c" - --compiler-bin "${_gl_test_compiler_bin}" + --compiler-bin "${PSX_GL_READBACK_COMPILER_BIN}" --sdl-root "${PSX_GL_READBACK_SDL_ROOT}" --output "${CMAKE_CURRENT_BINARY_DIR}/gl-readback-results") set_tests_properties(gl_readback_region_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 180) else() - message(STATUS "gl_readback_region_test: not registered (requires MinGW and PSX_GL_READBACK_SDL_ROOT)") + message(STATUS "gl_readback_region_test: not registered (requires MinGW, PSX_GL_READBACK_SDL_ROOT and gcc.exe/g++.exe in PSX_GL_READBACK_COMPILER_BIN)") endif() add_executable(launcher_device_roundtrip_test tests/test_launcher_device_roundtrip.cpp) diff --git a/runtime/tests/run_gl_readback_region.py b/runtime/tests/run_gl_readback_region.py index 52af16fc..f07e3224 100644 --- a/runtime/tests/run_gl_readback_region.py +++ b/runtime/tests/run_gl_readback_region.py @@ -1,25 +1,84 @@ -import pathlib,subprocess,json,os,sys,argparse,shutil,tempfile -parser=argparse.ArgumentParser(description="Run hidden real-GL source-owned readback tests") -parser.add_argument('--compiler-bin',required=True);parser.add_argument('--sdl-root',required=True) -parser.add_argument('--output',required=True);parser.add_argument('--gl-source') -parser.add_argument('--fixture',type=pathlib.Path) -parser.add_argument('--expect-unbounded',action='store_true');args=parser.parse_args() -F=pathlib.Path(__file__).resolve().parents[2] -output=pathlib.Path(args.output).resolve();output.mkdir(parents=True,exist_ok=True) -D=pathlib.Path(tempfile.mkdtemp(prefix='readback-',dir=output)) -print('Evidence directory:',D) -old=pathlib.Path(args.sdl_root).resolve();T=pathlib.Path(args.compiler_bin).resolve();env=os.environ.copy();env['PATH']=str(T)+os.pathsep+env['PATH'] -if args.gl_source:shutil.copyfile(args.gl_source,D/'gpu_gl_renderer.c') -inc=['-I',str(D),'-I',str(F/'runtime/include'),'-I',str(F/'runtime/src'),'-I',str(old/'sdl3-src/include')];receipt=[] -def run(cmd): - p=subprocess.run([str(x) for x in cmd],cwd=D,capture_output=True,text=True,errors='replace',env=env);receipt.append({'cmd':[str(x) for x in cmd],'exit':p.returncode,'stdout':p.stdout,'stderr':p.stderr});(D/'receipt.json').write_text(json.dumps(receipt,indent=2));print(p.stdout[-600:],p.stderr[-2000:]);return p.returncode -for name,src in [('probe',args.fixture or F/'runtime/tests/test_gl_readback_region.c'),('sw',F/'runtime/src/gpu_sw_renderer.c')]: - if run([T/'gcc.exe','-std=c11','-O2','-flto','-DPSX_SDL3=1','-DPSX_NO_DEBUG_TOOLS=1',*inc,'-c',src,'-o',D/(name+'.o')]):sys.exit(2) -libs=['m','kernel32','user32','gdi32','winmm','imm32','ole32','oleaut32','version','uuid','advapi32','setupapi','shell32','dinput8','opengl32'] -if run([T/'g++.exe','-O2','-flto',D/'probe.o',D/'sw.o',old/'sdl3-build/libSDL3.a',*['-l'+x for x in libs],'-o',D/'probe.exe']):sys.exit(2) -# Test windows are hidden. No host-specific priority or affinity policy. -for scale in [1,4]: - code=run([D/'probe.exe',str(scale)]) - expected=1 if args.expect_unbounded else 0 - if code!=expected:sys.exit(1) - if args.expect_unbounded and 'failures=1' not in receipt[-1]['stdout']:sys.exit(1) +"""Run source-owned readback checks with a hidden real OpenGL context.""" +import argparse +import json +import os +import pathlib +import re +import shutil +import subprocess +import sys +import tempfile + + +def probe_result_ok(code, stdout, stderr, expect_unbounded=False): + lines = stdout.rstrip().splitlines() + summary = re.fullmatch(r"checks=(\d+) failures=(\d+)", lines[-1]) if lines else None + if not summary or int(summary[1]) == 0: + return False + failures = [line for line in stderr.splitlines() if line.startswith("FAIL ")] + if expect_unbounded: + return (code == 1 and int(summary[2]) == 1 and + failures == ["FAIL single-pixel bounded transfer"]) + return code == 0 and int(summary[2]) == 0 and not failures + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--compiler-bin", required=True) + parser.add_argument("--sdl-root", required=True) + parser.add_argument("--output", required=True) + parser.add_argument("--gl-source") + parser.add_argument("--fixture", type=pathlib.Path) + parser.add_argument("--expect-unbounded", action="store_true") + args = parser.parse_args() + framework = pathlib.Path(__file__).resolve().parents[2] + compiler = pathlib.Path(args.compiler_bin).resolve() + for name in ("gcc.exe", "g++.exe"): + if not (compiler / name).is_file(): + parser.error(f"--compiler-bin must contain {name}: {compiler}") + output = pathlib.Path(args.output).resolve() + output.mkdir(parents=True, exist_ok=True) + dest = pathlib.Path(tempfile.mkdtemp(prefix="readback-", dir=output)) + print("Evidence directory:", dest) + sdl = pathlib.Path(args.sdl_root).resolve() + env = os.environ.copy() + env["PATH"] = str(compiler) + os.pathsep + env.get("PATH", "") + if args.gl_source: + shutil.copyfile(args.gl_source, dest / "gpu_gl_renderer.c") + includes = ["-I", str(dest), "-I", str(framework / "runtime/include"), + "-I", str(framework / "runtime/src"), "-I", str(sdl / "sdl3-src/include")] + receipt = [] + + def run(command): + command = [str(value) for value in command] + result = subprocess.run(command, cwd=dest, capture_output=True, + text=True, encoding="utf-8", errors="replace", env=env) + receipt.append({"cmd": command, "exit": result.returncode, + "stdout": result.stdout, "stderr": result.stderr}) + (dest / "receipt.json").write_text(json.dumps(receipt, indent=2), encoding="utf-8") + print(result.stdout[-600:], result.stderr[-2000:]) + return result + + fixture = args.fixture or framework / "runtime/tests/test_gl_readback_region.c" + for name, source in [("probe", fixture), ("sw", framework / "runtime/src/gpu_sw_renderer.c")]: + if run([compiler / "gcc.exe", "-std=c11", "-O2", "-flto", "-DPSX_SDL3=1", + "-DPSX_NO_DEBUG_TOOLS=1", *includes, "-c", source, + "-o", dest / (name + ".o")]).returncode: + return 2 + libraries = ["m", "kernel32", "user32", "gdi32", "winmm", "imm32", "ole32", + "oleaut32", "version", "uuid", "advapi32", "setupapi", "shell32", + "dinput8", "opengl32"] + if run([compiler / "g++.exe", "-O2", "-flto", dest / "probe.o", dest / "sw.o", + sdl / "sdl3-build/libSDL3.a", *["-l" + name for name in libraries], + "-o", dest / "probe.exe"]).returncode: + return 2 + for scale in (1, 4): + result = run([dest / "probe.exe", scale]) + if not probe_result_ok(result.returncode, result.stdout, result.stderr, + args.expect_unbounded): + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/runtime/tests/test_gl_readback_runner.py b/runtime/tests/test_gl_readback_runner.py new file mode 100644 index 00000000..0a1ad740 --- /dev/null +++ b/runtime/tests/test_gl_readback_runner.py @@ -0,0 +1,36 @@ +import unittest +from run_gl_readback_region import probe_result_ok + + +class ProbeResultTest(unittest.TestCase): + def test_success(self): + self.assertTrue(probe_result_ok(0, "driver=GL\nchecks=67 failures=0\n", "")) + + def test_exact_negative(self): + self.assertTrue(probe_result_ok(1, "checks=67 failures=1\n", + "FAIL single-pixel bounded transfer\n", True)) + + def test_negative_rejects_other_counts(self): + for count in (0, 10, 11, 100, 199): + with self.subTest(count=count): + self.assertFalse(probe_result_ok(1, f"checks=67 failures={count}\n", + "FAIL single-pixel bounded transfer\n", True)) + + def test_negative_requires_expected_failure(self): + for error in ("", "FAIL GL error\n", "FAIL single-pixel bounded transfer\nFAIL other\n"): + self.assertFalse(probe_result_ok(1, "checks=67 failures=1\n", error, True)) + + def test_rejects_missing_or_malformed_summary(self): + for text in ("", "checks=67 failures=1 extra", "checks=0 failures=0", + "checks=67 failures=0\ntrailing text", "checks=67 failures=10"): + self.assertFalse(probe_result_ok(0, text, "")) + + def test_rejects_exit_and_error_mismatch(self): + self.assertFalse(probe_result_ok(1, "checks=67 failures=0", "")) + self.assertFalse(probe_result_ok(0, "checks=67 failures=0", "FAIL other")) + self.assertFalse(probe_result_ok(0, "checks=67 failures=1", + "FAIL single-pixel bounded transfer", True)) + + +if __name__ == "__main__": + unittest.main()