diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 5a8f2693..3903652f 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -211,6 +211,19 @@ if(BUILD_TESTING) --output "${CMAKE_CURRENT_BINARY_DIR}/gl-scale-results") set_tests_properties(gl_scale_invariance_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 300 SKIP_RETURN_CODE 77) + # Render thread ([video] render_thread) on vs off: the values the + # guest reads back, native VRAM, the frame at S and the native-wide + # surface are identical for a scripted stream with readbacks, mask + # bits, uploads, ring wraps and per-primitive widescreen tags. + add_test(NAME gl_render_thread_test + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_render_thread.py" + --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_render_thread.c" + --cc "${CMAKE_C_COMPILER}" + --sdl-include "${_gl_scale_sdl_inc}" + --sdl-library "$" + --output "${CMAKE_CURRENT_BINARY_DIR}/gl-render-thread-results") + set_tests_properties(gl_render_thread_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 300) unset(_gl_scale_sdl_inc) else() message(STATUS "gl_scale_invariance_test: not registered (needs macOS, Linux or MinGW, Python 3 and SDL3)") diff --git a/runtime/tests/run_gl_render_thread.py b/runtime/tests/run_gl_render_thread.py new file mode 100644 index 00000000..c6dc7c82 --- /dev/null +++ b/runtime/tests/run_gl_render_thread.py @@ -0,0 +1,124 @@ +"""Build and run test_gl_render_thread.c on a hidden real OpenGL context. + +Each scale runs twice, render thread off and on ([video] render_thread). The +values the scripted guest read back (rb), the native VRAM (digest), the frame +at internal resolution (hires) and the native-wide surface (wide) must be the +same both ways, and the threaded run must actually have recorded and replayed +the stream (its own checks). A third run per scale with one frame in flight +(PSX_TEST_RT_FRAMES is not needed: the fixture takes the bound as argv) is not +required; the in-flight bound itself is covered by render_thread_test. + +macOS/Linux: pass the SDL3 include directory and static library and a C +compiler, as for run_gl_scale_invariance.py. Evidence (commands, output) is +written to receipt.json under --output. +""" +import argparse +import json +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"] +KEYS = ("rb", "digest", "hires", "wide") + + +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,4") + ap.add_argument("--frames", default="80") + 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_render_thread.c" + out_root = pathlib.Path(args.output).resolve() + out_root.mkdir(parents=True, exist_ok=True) + dest = pathlib.Path(tempfile.mkdtemp(prefix="rth-", 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"), + ("facade", src / "gpu_render.c")] + sections = [] if platform.system() == "Darwin" else ["-ffunction-sections", "-fdata-sections"] + objs = [] + 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) + link = [args.cc, *objs, args.sdl_library, "-o", dest / "probe"] + if platform.system() == "Darwin": + for f in MAC_FRAMEWORKS: + link += ["-framework", f] + link += ["-liconv", "-lm", "-lpthread", "-Wl,-dead_strip"] + else: + link += ["-lGL", "-lm", "-ldl", "-lpthread", "-Wl,--gc-sections"] + r = run(link) + if r.returncode: + print(r.stderr[-3000:]) + return 2 + + ok = True + for s in [int(v) for v in args.scales.split(",") if v]: + runs = {} + for threaded in (0, 1): + r = run([dest / "probe", s, threaded, args.frames]) + p = parse(r.stdout) + tail = r.stdout.strip().splitlines()[-6:] + print(f"scale {s} render_thread={threaded}: exit={r.returncode}", tail, + r.stderr.strip()[-800:]) + if r.returncode or p["failures"] != 0 or any(p[k] is None for k in KEYS): + ok = False + runs[threaded] = p + for k in KEYS: + if runs[0][k] != runs[1][k]: + print(f"FAIL scale {s}: {k} differs with the render thread on " + f"({runs[0][k]} off, {runs[1][k]} on)") + ok = False + print("PASS" if ok else "FAIL") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/runtime/tests/test_gl_render_thread.c b/runtime/tests/test_gl_render_thread.c new file mode 100644 index 00000000..e2bb7035 --- /dev/null +++ b/runtime/tests/test_gl_render_thread.c @@ -0,0 +1,281 @@ +/* Render thread equivalence on a real, hidden OpenGL context. + * + * Run twice per scale by run_gl_render_thread.py, once with the render thread + * off (argv[2] == 0) and once on (1); the runner requires every digest to + * match. The scripted stream goes through the renderer facade (gr_*), exactly + * as gpu.c drives it, over many frames: + * - CPU->VRAM uploads written the way gpu.c writes them (the guest-visible + * array first, then the facade with a copy), including the same rect + * re-uploaded with new data before the render thread can have drawn the + * first (the render thread must stage each payload, not the live array); + * full-VRAM uploads push the ring past its end (wrap); + * - GP0(E6) mask set / check around draws, and a mask-checked CPU upload + * whose per-pixel mask test reads VRAM back (a sync point mid-frame); + * - fills, VRAM copies, render-to-texture, every semi-transparency mode, + * lines, textured and shaded primitives, draw area / offset changes; + * - native-wide: margin draws, wide clears, and per-primitive widescreen + * tags that change with every primitive (captured at record time) plus a + * stream latch that flips mid-frame (sent as state); + * - presents and frame boundaries every frame, with readbacks (GPUREAD + * style gr_vram_read, VRAM->CPU transfer_out) in some frames only. + * Printed: rb (digest of every value the "guest" read back), digest (native + * VRAM at the end), hires (frame at internal resolution), wide (native-wide + * surface), and with the thread on its record/acquire counters. + * Original source-owned scene; no retail payload. */ +#include "gpu_gl_renderer.c" +#include "mod_texture_banks.c" +uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t n,uint32_t a){(void)n;(void)a;return 0;} +uint32_t psx_mod_read_word(uint32_t a){(void)a;return 0;} +int g_psx_vram_dirty_tracking=0; +uint64_t s_frame_count=0; +void gpu_vram_dirty_mark_row_impl(uint32_t y){(void)y;} +void gpu_vram_dirty_mark_rect(int x,int y,int w,int h){(void)x;(void)y;(void)w;(void)h;} +void gpu_vram_dirty_mark_all(void){} +int psx_netplay_active(void){return 0;} +const GpuRenderBackend *vk_backend_get(void){return NULL;} +int gpu_display_is_depth24(void){return 0;} +void gpu_get_display_info(GpuDisplayInfo *out){memset(out,0,sizeof(*out));out->width=320;out->height=240;} +/* Guest state the backend reads per primitive. It changes with every + * primitive here, so a replay that read it live (instead of the value + * captured when the primitive was recorded) would draw a different image. */ +static int g_prim_id; +static int g_bg_full; +int psx_ws_prim_in_backdrop(void){return (g_prim_id % 3) == 0;} +int psx_ws_prim_is_tagged(void){return (g_prim_id % 5) == 0;} +int gpu_ws_nw_flat_backdrop_enabled(void){return 0;} +int gpu_ws_background_requires_full_composite(void){return g_bg_full;} +int g_ws_tex_edge_pct=0; +void psx_ws_dbg_gate_frame_snapshot(void){} +void gpu_depth24_upload_span_reset(void){} +int gpu_ws_netplay_local_viewport_width(void){return 0;} +int render_pass_netplay_enabled(void){return 0;} +uint64_t psx_cycle_count=0; +uint32_t g_psx_vblank_cycles=564480u; +/* Frontend hooks of the present path (host overlays, pacing, rings). */ +int host_osd_image(const uint32_t **p,int *w,int *h){*p=NULL;*w=*h=0;return 0;} +int host_osd_volume_image(const uint32_t **p,int *w,int *h){*p=NULL;*w=*h=0;return 0;} +int host_osd_needs_present(void){return 0;} +void host_osd_present_done(void){} +int psx_rewind_overlay_image(const uint32_t **p,int *w,int *h){*p=NULL;*w=*h=0;return 0;} +float psx_rewind_slide(void){return 0.0f;} +int psx_savestate_menu_overlay_image(const uint32_t **p,int *w,int *h){*p=NULL;*w=*h=0;return 0;} +int present_shot_take(char *out,int n){(void)out;(void)n;return 0;} +void present_shot_done(int ok){(void)ok;} +void psx_host_sleep_ms(uint32_t ms){(void)ms;} +/* Called on every present. On the render thread it also stands in for a slow + * swap, so replay runs well behind recording and anything it read live from + * the "guest" (instead of what was recorded) would be a later value. */ +int psx_present_vsync_owns_cadence(void){ + if (rt_on_render_thread()) SDL_Delay(4); + return 0; +} +void latency_ring_mark(LatencyStage s){(void)s;} +void gpu_timeline_note(uint8_t k,uint32_t a,uint32_t b){(void)k;(void)a;(void)b;} + +static uint16_t vram[1024*512]; +static int checks, failures; +static void check(int ok, const char *label) { + checks++; + if (!ok) { fprintf(stderr, "FAIL %s\n", label); failures++; } +} +static uint64_t fnv(const void *pp, size_t n, uint64_t h) { + const uint8_t *p = (const uint8_t *)pp; + for (size_t i = 0; i < n; i++) { h ^= p[i]; h *= 0x100000001b3ull; } + return h; +} +static uint64_t rb_digest = 0xcbf29ce484222325ull; +static uint32_t rng = 0x2468ace1u; +static uint32_t rnd(void) { rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5; return rng; } + +/* gpu.c's CPU->VRAM path: write the guest-visible array (honouring the mask + * test, which reads VRAM through the facade), then hand the facade a copy. */ +static uint16_t staging[1024*512]; +static int s_set_mask, s_check_mask; +static void cpu_upload_ex(int x, int y, int w, int h, const uint16_t *src, int catch_up) { + for (int r = 0; r < h; r++) + for (int c = 0; c < w; c++) { + int vx = (x + c) & 1023, vy = (y + r) & 511; + uint16_t px = src[r * w + c]; + if (s_check_mask && (gr_vram_read(vx, vy) & 0x8000)) continue; + if (s_set_mask) px |= 0x8000; + vram[vy * 1024 + vx] = px; + } + /* Worst case for a render thread that staged uploads from this array: + * it replays an earlier upload of the same rect right here, between + * gpu.c's pixel writes and its commit (which re-reads the array). */ + if (catch_up && gl_renderer_render_thread_active()) rt_drain(); + for (int r = 0; r < h; r++) + for (int c = 0; c < w; c++) + staging[r * w + c] = vram[((y + r) & 511) * 1024 + ((x + c) & 1023)]; + gr_vram_transfer_in(x, y, w, h, staging); +} +static void cpu_upload(int x, int y, int w, int h, const uint16_t *src) { + cpu_upload_ex(x, y, w, h, src, 0); +} +static void mask_bits(int set, int chk) { + s_set_mask = set; s_check_mask = chk; + gr_set_mask_bits(set, chk); +} + +#define FRAME_W 320 +#define FRAME_H 240 + +static void textures(void) { + static uint16_t page[64*64], clut[16]; + for (int i = 0; i < 64*64; i++) page[i] = (uint16_t)((i * 0x1357u) ^ (i >> 3)); + for (int i = 0; i < 16; i++) clut[i] = (uint16_t)(i ? (0x0421u * (uint16_t)i) | ((i & 1) << 15) : 0); + cpu_upload(512, 0, 64, 64, page); + cpu_upload(512, 256, 16, 1, clut); +} + +static void frame(int f, int buf_x) { + gr_set_draw_area(buf_x, 0, buf_x + FRAME_W - 1, FRAME_H - 1); + gr_set_draw_offset(buf_x, 0); + gr_wide_set_target(buf_x); + gr_fill_rect(buf_x, 0, FRAME_W, FRAME_H, (uint16_t)(0x0842 * (f & 7))); + gr_wide_clear(buf_x, 0, FRAME_H, (uint16_t)(0x0842 * (f & 7))); + gr_set_color_modulation(128, 128, 128, 0); + mask_bits(0, 0); + /* Many primitives with tags that change per primitive. */ + for (int i = 0; i < 600; i++) { + g_prim_id = f * 1000 + i; + if (i == 300) g_bg_full = (f & 1); /* stream latch flips mid-frame */ + int x = buf_x + (int)(rnd() % 420) - 50, y = (int)(rnd() % 240); + int w = 4 + (int)(rnd() % 40), h = 4 + (int)(rnd() % 30); + uint16_t c = (uint16_t)(rnd() & 0x7fff); + gr_set_semi_transparency((int)(rnd() % 4 == 0), (int)(rnd() % 4)); + switch (i % 7) { + case 0: gr_draw_flat_rect(x, y, w, h, c); break; + case 1: gr_draw_gouraud_triangle(x, y, c, x + w, y + 3, (uint16_t)(c ^ 0x1f), x + 2, y + h, (uint16_t)(c ^ 0x3e0)); break; + case 2: gr_draw_textured_rect(x, y, w, h, (int)(rnd() % 32), (int)(rnd() % 32), 512, 256, 0x0008); break; + case 3: gr_draw_shaded_textured_triangle(x, y, 0, 0, 0x808080, x + w, y, 63, 4, 0x6090b0, + x, y + h, 8, 63, 0xb09060, 512, 256, 0x0008, (int)(i & 1)); break; + case 4: gr_draw_line(x, y, x + w * 2, y + h, c); break; + case 5: gr_draw_shaded_line(x, y, c, x + 3, y + h, (uint16_t)~c); break; + default: gr_draw_flat_triangle(x, y, x + w, y + h, x - 5, y + h / 2, c); break; + } + g_prim_id = -1; /* the next GP0 command is in flight: no tags */ + } + gr_set_semi_transparency(0, 0); + /* Mask set, then mask check over it. */ + mask_bits(1, 0); + gr_draw_flat_rect(buf_x + 40, 40, 30, 30, 0x4210); + mask_bits(0, 1); + gr_draw_flat_rect(buf_x + 30, 30, 60, 60, 0x7fff); + /* A mask-checked CPU upload over the masked rect: the per-pixel test + * reads VRAM back (sync point), in some frames only. */ + if (f % 4 == 1) { + static uint16_t patch[24 * 24]; + for (int i = 0; i < 24 * 24; i++) patch[i] = (uint16_t)(0x1234 + i * 7 + f); + cpu_upload(buf_x + 35, 35, 24, 24, patch); + } + mask_bits(0, 0); + /* The same rect uploaded twice with different data back to back. */ + { + static uint16_t a[32 * 16], b[32 * 16]; + for (int i = 0; i < 32 * 16; i++) { a[i] = (uint16_t)(0x0300 + i + f); b[i] = (uint16_t)(0x6000 ^ (i * 3 + f)); } + cpu_upload(600, 100, 32, 16, a); + gr_draw_textured_rect(buf_x + 200, 180, 32, 16, 88, 100, 512, 256, 0x0009); /* samples a */ + cpu_upload_ex(600, 100, 32, 16, b, 1); + gr_draw_textured_rect(buf_x + 240, 180, 32, 16, 88, 100, 512, 256, 0x0009); /* samples b */ + /* The guest keeps writing its array (as gpu.c does for the next + * GP0(A0) before that upload completes); nothing may read this. */ + for (int i = 0; i < 32 * 16; i++) vram[(100 + i / 32) * 1024 + 600 + i % 32] = 0x7c1f; + } + /* Render to texture, copies. */ + gr_draw_flat_rect(576, 64, 32, 32, (uint16_t)(0x03e0 + f)); + gr_draw_textured_rect(buf_x + 100, 200, 32, 32, 0, 64, 512, 256, 0x0009); + gr_copy_rect(buf_x + 10, 10, buf_x + 14, 13, 80, 50); + /* A half-VRAM upload every frame pushes the ring past its end. */ + { + static uint16_t big[1024 * 256]; + for (int i = 0; i < 1024 * 256; i++) big[i] = (uint16_t)(rnd() & 0x7fff); + cpu_upload(0, 256, 1024, 256, big); + } + /* Readbacks in some frames (GPUREAD-style and VRAM->CPU). */ + if (f % 5 == 3) { + for (int i = 0; i < 64; i++) { + uint16_t v = gr_vram_read(buf_x + (int)(rnd() % FRAME_W), (int)(rnd() % FRAME_H)); + rb_digest = fnv(&v, 2, rb_digest); + } + static uint16_t out[64 * 32]; + gr_vram_transfer_out(buf_x + 50, 50, 64, 32, out); + rb_digest = fnv(out, sizeof out, rb_digest); + } + gl_renderer_present_vram(buf_x, 0, FRAME_W, FRAME_H, 0, 0); + gl_renderer_render_thread_frame_boundary(); +} + +int main(int argc, char **argv) { + int scale = argc > 1 ? atoi(argv[1]) : 1; + int threaded = argc > 2 && argv[2][0] == '1'; + int frames = argc > 3 ? atoi(argv[3]) : 80; + if (SDL_Init(SDL_INIT_VIDEO) != 0) return 2; + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); +#if defined(__APPLE__) + SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, SDL_GL_CONTEXT_FORWARD_COMPATIBLE_FLAG); +#endif + SDL_Window *win = SDL_CreateWindow("Render thread hidden test", 0, 0, 320, 240, + SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); + if (!win) return 2; + gr_set_backend(GR_BACKEND_OPENGL); + gr_init(vram); + gr_set_scale(scale); + gl_renderer_set_swap_interval(0); + if (!gl_renderer_init_context(win)) { fprintf(stderr, "FAIL context\n"); return 2; } + check(gr_backend() == GR_BACKEND_OPENGL, "OpenGL backend"); + textures(); + gr_wide_configure(426, 53); + if (threaded) { + check(gl_renderer_render_thread_start(2) == 1, "render thread started"); + check(gl_renderer_render_thread_active(), "render thread active"); + } + for (int f = 0; f < frames; f++) frame(f, (f & 1) ? 512 : 0); + + /* Guest-visible VRAM, the frame at internal resolution, the wide surface. */ + static uint16_t native[1024 * 512]; + gr_vram_transfer_out(0, 0, 1024, 512, native); + uint64_t digest = fnv(native, sizeof native, 0xcbf29ce484222325ull); + check(memcmp(native, vram, sizeof native) == 0, "readback landed in the guest-visible array"); + int fw = FRAME_W * scale, fh = FRAME_H * scale, ow = 0, oh = 0; + uint32_t *img = (uint32_t *)malloc((size_t)fw * fh * 4); + int n = img ? gl_renderer_read_display_hires(512, 0, FRAME_W, FRAME_H, img, fw * fh, &ow, &oh) : 0; + check(n == fw * fh, "hires readback"); + uint64_t hires = n ? fnv(img, (size_t)fw * fh * 4, 0xcbf29ce484222325ull) : 0; + free(img); + uint64_t wide = 0; + { + int ww = 426 * scale, wh = 512 * scale, gw = 0, gh = 0; + uint32_t *wb = (uint32_t *)malloc((size_t)ww * wh * 4); + int got = wb ? gr_wide_dump_full(wb, ww * wh, &gw, &gh, 512) : 0; + check(got == ww * wh, "wide dump"); + if (got) wide = fnv(wb, (size_t)ww * wh * 4, 0xcbf29ce484222325ull); + free(wb); + } + if (threaded) { + RtStats st; + rt_get_stats(&st); + printf("rt_records=%llu rt_acquires=%llu rt_frames=%llu rt_high_water=%llu presents=%llu stale=%llu\n", + (unsigned long long)st.records, (unsigned long long)st.acquires, + (unsigned long long)st.frames_produced, (unsigned long long)st.ring_high_water, + (unsigned long long)s_rth_presents, (unsigned long long)s_rth_presents_stale); + check(st.records > (uint64_t)frames * 600, "draws were recorded"); + /* Sync points: the mask-checked uploads, the readback frames and the + * final readbacks; never more than one per frame plus the tail. */ + check(st.acquires >= 2 && st.acquires <= (uint64_t)frames + 4, "acquires bounded"); + check(st.bytes > ((uint64_t)32 << 20), "ring wrapped (more than its size recorded)"); + } + check(glGetError() == GL_NO_ERROR, "GL error"); + printf("rb=%016llx\n", (unsigned long long)rb_digest); + printf("digest=%016llx\n", (unsigned long long)digest); + printf("hires=%016llx\n", (unsigned long long)hires); + printf("wide=%016llx\n", (unsigned long long)wide); + printf("checks=%d failures=%d\n", checks, failures); + gl_renderer_shutdown(); + check(!gl_renderer_render_thread_active(), "stopped by shutdown"); + SDL_DestroyWindow(win); + SDL_Quit(); + return failures ? 1 : 0; +}