From b6b3578282a997e61cc74d35c3587043b5b77596 Mon Sep 17 00:00:00 2001 From: yacineolivier-maker <234833416+yacineolivier-maker@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:58:44 +0200 Subject: [PATCH 01/22] Debug: perf command, the cadence of real swaps (what the player sees) The GL swap records the interval between real swaps (ring of 4096, single writer). TCP {"cmd":"perf","window_ms":N,"reset":0|1} returns count, fps, mean / p50 / p95 / p99 / max interval and hitches (intervals over 1.5x the median) for the last N ms; independent of guest vblank counting, so frame generation, pacing and driver stalls are all visible. PSX_FPS_TELEMETRY adds the same summary to the window title. Used to measure R4 runs on a test bench (60.0 fps, p99 18 ms after the cadence fix of the video-standard switch). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Rkyma8rbyPiccs9n2bkyG6 --- runtime/src/debug_server.c | 10 ++++++ runtime/src/gpu_gl_renderer.c | 66 +++++++++++++++++++++++++++++++++++ runtime/src/main.cpp | 8 +++++ 3 files changed, 84 insertions(+) diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 553bcc1b..5385c739 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -9447,6 +9447,15 @@ static void handle_ws_aspect(int id, const char *json) } extern int psx_debug_display_aspect(int num, int den, int adaptive); +/* perf window_ms= reset=<0|1> — real swap cadence (what the player sees). */ +extern int gl_renderer_swap_stats(int window_ms, int reset, char *out, int cap); +static void handle_perf(int id, const char *json) { + char buf[512]; + gl_renderer_swap_stats(json_get_int(json, "window_ms", 0), json_get_int(json, "reset", 0), + buf, sizeof buf); + send_fmt("{\"id\":%d,\"ok\":true,%s}", id, buf); +} + static void handle_display_aspect(int id, const char *json) { int num=json_get_int(json,"num",-1), den=json_get_int(json,"den",-1); int adaptive=json_get_int(json,"adaptive",0); @@ -16315,6 +16324,7 @@ static const CmdEntry s_commands[] = { { "kernel_bless", handle_kernel_bless }, { "ws_aspect", handle_ws_aspect }, { "display_aspect", handle_display_aspect }, + { "perf", handle_perf }, { "window_size", handle_window_size }, { "ws_nw", handle_ws_nw }, { "debug_key", handle_debug_key }, diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 15a853a2..4a17576f 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -9764,6 +9764,7 @@ static int pt_cb_ctx(void *user, int current) { /* The one owned swap call: the composing thread's (direct) or the present * thread's. */ +static void gl_swap_stats_note(void); static void gl_window_swap(void) { #if defined(PSX_SDL3) psx_video_telemetry_present(SDL_GL_SwapWindow(s_win) ? 1 : 0); @@ -9771,6 +9772,71 @@ static void gl_window_swap(void) { SDL_GL_SwapWindow(s_win); psx_video_telemetry_present(-1); // SDL2 exposes no swap result. #endif + gl_swap_stats_note(); +} + +/* Swap timing (debug `perf`): intervals between real swaps, i.e. what reaches + * the screen, independent of guest vblank counting. Single writer (the swap + * thread); the reader tolerates a torn sample. */ +#define SWAP_RING 4096 +static uint32_t s_swap_us[SWAP_RING]; +static volatile uint32_t s_swap_n; +static uint64_t s_swap_last; + +static void gl_swap_stats_note(void) { + uint64_t now = SDL_GetPerformanceCounter(); + if (s_swap_last) { + uint64_t us = (now - s_swap_last) * 1000000ull / SDL_GetPerformanceFrequency(); + s_swap_us[s_swap_n % SWAP_RING] = us > 0xFFFFFFFFull ? 0xFFFFFFFFu : (uint32_t)us; + s_swap_n++; + } + s_swap_last = now; +} + +static int cmp_u32(const void *a, const void *b) { + uint32_t x = *(const uint32_t *)a, y = *(const uint32_t *)b; + return x < y ? -1 : x > y; +} + +/* Last `window_ms` of swaps (0 = whole ring): count, mean/p50/p95/p99/max + * interval (µs), and hitches = intervals > 1.5x the median. reset=1 drops the + * history. */ +typedef struct { uint32_t k, mean, p50, p95, p99, max, hitch; double span_ms, fps; } SwapStats; +static SwapStats swap_stats(int window_ms, int reset) { + static uint32_t tmp[SWAP_RING]; + SwapStats r = {0}; + uint32_t n = s_swap_n, k = 0; + uint64_t sum = 0; + if (n > SWAP_RING) n = SWAP_RING; + for (uint32_t i = 0; i < n; i++) { + uint32_t v = s_swap_us[(s_swap_n - 1 - i) % SWAP_RING]; + if (window_ms > 0 && sum + v > (uint64_t)window_ms * 1000u) break; + sum += v; + tmp[k++] = v; + } + if (reset) { s_swap_n = 0; s_swap_last = 0; } + if (!k) return r; + qsort(tmp, k, sizeof tmp[0], cmp_u32); + r.k = k; r.mean = (uint32_t)(sum / k); r.p50 = tmp[k / 2]; + r.p95 = tmp[(k - 1) * 95 / 100]; r.p99 = tmp[(k - 1) * 99 / 100]; r.max = tmp[k - 1]; + for (uint32_t i = 0; i < k; i++) if (tmp[i] * 2 > r.p50 * 3) r.hitch++; + r.span_ms = sum / 1000.0; + r.fps = sum ? k * 1e6 / (double)sum : 0.0; + return r; +} + +int gl_renderer_swap_stats(int window_ms, int reset, char *out, int cap) { + SwapStats r = swap_stats(window_ms, reset); + return snprintf(out, cap, + "\"swaps\":%u,\"span_ms\":%.1f,\"fps\":%.2f,\"mean_us\":%u,\"p50_us\":%u," + "\"p95_us\":%u,\"p99_us\":%u,\"max_us\":%u,\"hitches\":%u", + r.k, r.span_ms, r.fps, r.mean, r.p50, r.p95, r.p99, r.max, r.hitch); +} + +/* FPS telemetry title: what reached the screen over the last second. */ +void gl_renderer_swap_summary(double *fps, unsigned *hitches, unsigned *max_ms) { + SwapStats r = swap_stats(1000, 0); + *fps = r.fps; *hitches = r.hitch; *max_ms = r.max / 1000u; } static void *pt_cb_present(void *user, int slot, void *ready) { diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index fc425137..c0b8ace7 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -650,6 +650,7 @@ static void present_session_reset(void) { smooth_60_reset(); } +extern "C" void gl_renderer_swap_summary(double *fps, unsigned *hitches, unsigned *max_ms); static int fps_telemetry_enabled(void) { if (s_fps_telemetry_enabled < 0) { const char *e = std::getenv("PSX_FPS_TELEMETRY"); @@ -9262,6 +9263,13 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { snprintf(title, sizeof(title), "%s [Game %.0f fps %.2fx]", s_fps_base_title.c_str(), fps, speed); } + if (g_gl_active) { /* real swaps: what the player sees */ + double sfps = 0; unsigned hitch = 0, worst = 0; + gl_renderer_swap_summary(&sfps, &hitch, &worst); + size_t l = strlen(title); + snprintf(title + l, sizeof(title) - l, " [Screen %.1f fps, %u hitch, max %u ms]", + sfps, hitch, worst); + } SDL_SetWindowTitle(sdl_window, title); } if (!g_headless) { From 84ba1851761d571f825cc1d934cf77262a838bd4 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 08:32:29 -0700 Subject: [PATCH 02/22] Publish cadence through shared video telemetry without reader races Use a bounded atomic snapshot and presenter-owned reset across all backends. Readers sort their own snapshots; report host API completion cadence. Add deterministic and concurrent reader/reset coverage. --- docs/TCP_COMMANDS.md | 5 +- runtime/include/video_telemetry.h | 12 ++++ runtime/src/debug_server.c | 6 +- runtime/src/gpu_gl_renderer.c | 66 --------------------- runtime/src/main.cpp | 11 ++-- runtime/src/video_telemetry.cpp | 79 ++++++++++++++++++++++++++ runtime/tests/test_video_telemetry.cpp | 36 ++++++++++++ 7 files changed, 139 insertions(+), 76 deletions(-) diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index c81a2eeb..ec096bd6 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -493,9 +493,9 @@ between Play and TCP availability. ## Complete command index (generated) -**386 commands registered** — 373 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**387 commands registered** — 374 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -85 of 386 have prose above; **301 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +85 of 387 have prose above; **302 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. @@ -736,6 +736,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `pc_probe_arm` | ✓ | | | | `pc_probe_clear` | ✓ | | | | `pc_probe_dump` | ✓ | | | +| `perf` | ✓ | | | | `pgxp` | ✓ | | ✓ | | `pgxp_miss_ring` | ✓ | | | | `pgxp_shadow` | ✓ | | | diff --git a/runtime/include/video_telemetry.h b/runtime/include/video_telemetry.h index 55ae2753..4b9e6cc4 100644 --- a/runtime/include/video_telemetry.h +++ b/runtime/include/video_telemetry.h @@ -30,6 +30,18 @@ void psx_video_telemetry_present(int result); * Completion/error counters remain available in that case. */ void psx_video_telemetry_snapshot(PsxVideoTelemetry *out); +typedef struct PsxPresentCadence { + int valid; + uint32_t count, mean_us, p50_us, p95_us, p99_us, max_us, hitches; + double span_ms, fps; +} PsxPresentCadence; +/* Host API completion intervals, shared by all backends; no monitor-display + * guarantee. Bounded passive snapshot. reset is consumed by the presenter. */ +void psx_video_telemetry_cadence(int window_ms, int reset, PsxPresentCadence *out); +int psx_video_telemetry_cadence_json(int window_ms, int reset, char *out, int capacity); +/* Explicit monotonic host microseconds for adapters and deterministic tests. */ +void psx_video_telemetry_present_at(int result, uint64_t now_us); + /* Emulation-owner settings after runtime mod overrides, sampled every VBlank. * Actual allocated scale/dimensions come from the separate backend snapshot. */ typedef struct PsxVideoConfigTelemetry { diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 5385c739..211f7b9b 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -9447,11 +9447,11 @@ static void handle_ws_aspect(int id, const char *json) } extern int psx_debug_display_aspect(int num, int den, int adaptive); -/* perf window_ms= reset=<0|1> — real swap cadence (what the player sees). */ -extern int gl_renderer_swap_stats(int window_ms, int reset, char *out, int cap); +/* perf: host API completion cadence across the active presentation backend. */ +extern int psx_video_telemetry_cadence_json(int window_ms, int reset, char *out, int cap); static void handle_perf(int id, const char *json) { char buf[512]; - gl_renderer_swap_stats(json_get_int(json, "window_ms", 0), json_get_int(json, "reset", 0), + psx_video_telemetry_cadence_json(json_get_int(json, "window_ms", 0), json_get_int(json, "reset", 0), buf, sizeof buf); send_fmt("{\"id\":%d,\"ok\":true,%s}", id, buf); } diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 4a17576f..15a853a2 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -9764,7 +9764,6 @@ static int pt_cb_ctx(void *user, int current) { /* The one owned swap call: the composing thread's (direct) or the present * thread's. */ -static void gl_swap_stats_note(void); static void gl_window_swap(void) { #if defined(PSX_SDL3) psx_video_telemetry_present(SDL_GL_SwapWindow(s_win) ? 1 : 0); @@ -9772,71 +9771,6 @@ static void gl_window_swap(void) { SDL_GL_SwapWindow(s_win); psx_video_telemetry_present(-1); // SDL2 exposes no swap result. #endif - gl_swap_stats_note(); -} - -/* Swap timing (debug `perf`): intervals between real swaps, i.e. what reaches - * the screen, independent of guest vblank counting. Single writer (the swap - * thread); the reader tolerates a torn sample. */ -#define SWAP_RING 4096 -static uint32_t s_swap_us[SWAP_RING]; -static volatile uint32_t s_swap_n; -static uint64_t s_swap_last; - -static void gl_swap_stats_note(void) { - uint64_t now = SDL_GetPerformanceCounter(); - if (s_swap_last) { - uint64_t us = (now - s_swap_last) * 1000000ull / SDL_GetPerformanceFrequency(); - s_swap_us[s_swap_n % SWAP_RING] = us > 0xFFFFFFFFull ? 0xFFFFFFFFu : (uint32_t)us; - s_swap_n++; - } - s_swap_last = now; -} - -static int cmp_u32(const void *a, const void *b) { - uint32_t x = *(const uint32_t *)a, y = *(const uint32_t *)b; - return x < y ? -1 : x > y; -} - -/* Last `window_ms` of swaps (0 = whole ring): count, mean/p50/p95/p99/max - * interval (µs), and hitches = intervals > 1.5x the median. reset=1 drops the - * history. */ -typedef struct { uint32_t k, mean, p50, p95, p99, max, hitch; double span_ms, fps; } SwapStats; -static SwapStats swap_stats(int window_ms, int reset) { - static uint32_t tmp[SWAP_RING]; - SwapStats r = {0}; - uint32_t n = s_swap_n, k = 0; - uint64_t sum = 0; - if (n > SWAP_RING) n = SWAP_RING; - for (uint32_t i = 0; i < n; i++) { - uint32_t v = s_swap_us[(s_swap_n - 1 - i) % SWAP_RING]; - if (window_ms > 0 && sum + v > (uint64_t)window_ms * 1000u) break; - sum += v; - tmp[k++] = v; - } - if (reset) { s_swap_n = 0; s_swap_last = 0; } - if (!k) return r; - qsort(tmp, k, sizeof tmp[0], cmp_u32); - r.k = k; r.mean = (uint32_t)(sum / k); r.p50 = tmp[k / 2]; - r.p95 = tmp[(k - 1) * 95 / 100]; r.p99 = tmp[(k - 1) * 99 / 100]; r.max = tmp[k - 1]; - for (uint32_t i = 0; i < k; i++) if (tmp[i] * 2 > r.p50 * 3) r.hitch++; - r.span_ms = sum / 1000.0; - r.fps = sum ? k * 1e6 / (double)sum : 0.0; - return r; -} - -int gl_renderer_swap_stats(int window_ms, int reset, char *out, int cap) { - SwapStats r = swap_stats(window_ms, reset); - return snprintf(out, cap, - "\"swaps\":%u,\"span_ms\":%.1f,\"fps\":%.2f,\"mean_us\":%u,\"p50_us\":%u," - "\"p95_us\":%u,\"p99_us\":%u,\"max_us\":%u,\"hitches\":%u", - r.k, r.span_ms, r.fps, r.mean, r.p50, r.p95, r.p99, r.max, r.hitch); -} - -/* FPS telemetry title: what reached the screen over the last second. */ -void gl_renderer_swap_summary(double *fps, unsigned *hitches, unsigned *max_ms) { - SwapStats r = swap_stats(1000, 0); - *fps = r.fps; *hitches = r.hitch; *max_ms = r.max / 1000u; } static void *pt_cb_present(void *user, int slot, void *ready) { diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index c0b8ace7..84775750 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -650,7 +650,6 @@ static void present_session_reset(void) { smooth_60_reset(); } -extern "C" void gl_renderer_swap_summary(double *fps, unsigned *hitches, unsigned *max_ms); static int fps_telemetry_enabled(void) { if (s_fps_telemetry_enabled < 0) { const char *e = std::getenv("PSX_FPS_TELEMETRY"); @@ -9263,11 +9262,13 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { snprintf(title, sizeof(title), "%s [Game %.0f fps %.2fx]", s_fps_base_title.c_str(), fps, speed); } - if (g_gl_active) { /* real swaps: what the player sees */ - double sfps = 0; unsigned hitch = 0, worst = 0; - gl_renderer_swap_summary(&sfps, &hitch, &worst); + { + PsxPresentCadence cadence{}; + psx_video_telemetry_cadence(1000, 0, &cadence); + const double sfps = cadence.fps; + const unsigned hitch = cadence.hitches, worst = cadence.max_us / 1000; size_t l = strlen(title); - snprintf(title + l, sizeof(title) - l, " [Screen %.1f fps, %u hitch, max %u ms]", + snprintf(title + l, sizeof(title) - l, " [Present %.1f fps, %u hitch, max %u ms]", sfps, hitch, worst); } SDL_SetWindowTitle(sdl_window, title); diff --git a/runtime/src/video_telemetry.cpp b/runtime/src/video_telemetry.cpp index c430194d..4f97b468 100644 --- a/runtime/src/video_telemetry.cpp +++ b/runtime/src/video_telemetry.cpp @@ -1,11 +1,23 @@ #include "video_telemetry.h" #include +#include +#include +#include +#include namespace { std::atomic presents{0}, errors{0}; std::atomic status_available{0}; std::atomic generation{0}; std::atomic fields[8]{}; +constexpr unsigned cadence_capacity = 4096; +std::atomic intervals[cadence_capacity]{}; +std::atomic cadence_version{0}, cadence_count{0}; +std::atomic cadence_reset{false}; +// Only the current present owner touches these. Readers request reset rather +// than changing the producer's cursor or timestamp. +uint64_t last_present_us; +bool have_last_present; } extern "C" void psx_video_telemetry_compose(int renderer, int scale, int sw, int sh, @@ -20,8 +32,75 @@ extern "C" void psx_video_telemetry_compose(int renderer, int scale, int sw, int } extern "C" void psx_video_telemetry_present(int result) { + const auto now = std::chrono::steady_clock::now().time_since_epoch(); + psx_video_telemetry_present_at(result, + std::chrono::duration_cast(now).count()); +} + +extern "C" void psx_video_telemetry_present_at(int result, uint64_t now_us) { status_available.store(result >= 0, std::memory_order_relaxed); (result != 0 ? presents : errors).fetch_add(1, std::memory_order_relaxed); + if (result == 0) return; + cadence_version.fetch_add(1); + if (cadence_reset.exchange(false)) { + cadence_count.store(0); + have_last_present = false; + } + if (have_last_present && now_us > last_present_us) { + const uint64_t count = cadence_count.load(); + const auto delta = std::min(now_us - last_present_us, UINT32_MAX); + intervals[count % cadence_capacity].store(static_cast(delta)); + cadence_count.store(count + 1); + } + last_present_us = now_us; + have_last_present = true; + cadence_version.fetch_add(1); +} + +extern "C" void psx_video_telemetry_cadence(int window_ms, int reset, PsxPresentCadence *out) { + if (!out) return; + *out = {}; + uint32_t values[cadence_capacity]; // each reader owns its sorting storage + uint64_t sum = 0; + for (unsigned attempt = 0; attempt < 3; ++attempt) { + out->count = 0; sum = 0; + const uint64_t before = cadence_version.load(); + if (before & 1u) continue; + if (cadence_reset.load()) { out->valid = 1; break; } + const uint64_t count = cadence_count.load(); + const unsigned n = static_cast(std::min(count, cadence_capacity)); + for (unsigned i = 0; i < n; ++i) { + const uint32_t value = intervals[(count - 1 - i) % cadence_capacity].load(); + if (window_ms > 0 && sum + value > static_cast(window_ms) * 1000) break; + sum += value; values[out->count++] = value; + } + if (before == cadence_version.load()) { out->valid = 1; break; } + } + if (reset) cadence_reset.store(true); + if (!out->valid) { *out = {}; return; } + if (!out->count) return; + std::sort(values, values + out->count); + const auto k = out->count; + out->mean_us = static_cast(sum / k); + out->p50_us = values[k / 2]; + out->p95_us = values[(k - 1) * 95 / 100]; + out->p99_us = values[(k - 1) * 99 / 100]; + out->max_us = values[k - 1]; + for (unsigned i = 0; i < k; ++i) + if (static_cast(values[i]) * 2 > static_cast(out->p50_us) * 3) + ++out->hitches; + out->span_ms = sum / 1000.0; + out->fps = sum ? k * 1e6 / static_cast(sum) : 0; +} + +extern "C" int psx_video_telemetry_cadence_json(int window_ms, int reset, char *out, int capacity) { + PsxPresentCadence r{}; + psx_video_telemetry_cadence(window_ms, reset, &r); + return std::snprintf(out, capacity, + "\"valid\":%s,\"measurement\":\"host_api_completion\",\"swaps\":%u,\"span_ms\":%.1f," + "\"fps\":%.2f,\"mean_us\":%u,\"p50_us\":%u,\"p95_us\":%u,\"p99_us\":%u,\"max_us\":%u,\"hitches\":%u", + r.valid ? "true" : "false", r.count, r.span_ms, r.fps, r.mean_us, + r.p50_us, r.p95_us, r.p99_us, r.max_us, r.hitches); } extern "C" void psx_video_telemetry_snapshot(PsxVideoTelemetry *out) { diff --git a/runtime/tests/test_video_telemetry.cpp b/runtime/tests/test_video_telemetry.cpp index f89c5013..0abfb90a 100644 --- a/runtime/tests/test_video_telemetry.cpp +++ b/runtime/tests/test_video_telemetry.cpp @@ -52,6 +52,42 @@ int main() { CHECK(sample.valid && sample.internal_scale == 10000 && sample.presents == 10002); CHECK(sample.present_status_available); CHECK(sample.present_errors == 1); + PsxPresentCadence cadence{}; + psx_video_telemetry_cadence(0,1,&cadence); + psx_video_telemetry_present_at(1,1000); + psx_video_telemetry_present_at(1,11000); + psx_video_telemetry_present_at(-1,21000); + psx_video_telemetry_present_at(1,51000); + psx_video_telemetry_cadence(0,0,&cadence); + CHECK(cadence.valid && cadence.count==3 && cadence.mean_us==16666); + CHECK(cadence.p50_us==10000 && cadence.max_us==30000 && cadence.hitches==1); + psx_video_telemetry_cadence(35,0,&cadence); + CHECK(cadence.valid && cadence.count==1 && cadence.max_us==30000); + psx_video_telemetry_cadence(0,1,&cadence); + psx_video_telemetry_cadence(0,0,&cadence); + CHECK(cadence.valid && !cadence.count); + psx_video_telemetry_present_at(1,100000); + psx_video_telemetry_cadence(0,0,&cadence); + CHECK(cadence.valid && !cadence.count); // producer reset re-anchors timing + std::atomic cadence_done{false}; + std::atomic cadence_consistent{true}; + std::thread presenter([&] { + for(uint64_t i=1;i<=20000;++i) psx_video_telemetry_present_at(1,100000+i*10000); + cadence_done=true; + }); + auto reader=[&](bool resets) { + unsigned reads=0; + do { + PsxPresentCadence c{}; + psx_video_telemetry_cadence(0,resets && (++reads%17==0),&c); + if(c.valid && c.count && (c.mean_us!=10000 || c.max_us!=10000 || c.hitches!=0)) + cadence_consistent=false; + }while(!cadence_done.load()); + }; + std::thread other_reader([&]{reader(true);}); + reader(false); + presenter.join();other_reader.join(); + CHECK(cadence_consistent.load()); psx_video_telemetry_snapshot(nullptr); return 0; } From 3ad83c910bd0dc132a66a82599af40e311501a38 Mon Sep 17 00:00:00 2001 From: yacineolivier-maker <234833416+yacineolivier-maker@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:58:45 +0200 Subject: [PATCH 03/22] Post effects: depth and history inputs, u_time / u_proj, live uniforms For title-specific lighting presets ([video] post_shader): - `input depth`: view depth sz (GTE screen z) of the presented frame, R32F at window size, rebuilt from the surfaces' PGXP depth; 0 where nothing 3D drew. Only built while a loaded chain reads it. - `input history`: the pass's own target from the last real frame (trails, temporal filters); generated frames do not update it. - `u_time` (seconds since the chain loaded) and `u_proj` (latest GTE projection: 240/H, screen centre in uv, aspect) in every pass. - Live uniforms: psx_host_set_post_uniform(name, x, y, z, w) sets a preset `uniform` from game state (e.g. a light source projected to the screen); the preset value applies until the first call. - Debug: TCP {"cmd":"post_shader"} reports the loaded preset, its parse status and the live values; {"cmd":"post_shader","path":"..."} reloads a preset (or loads another) without restarting, for shader work. docs/POST_EFFECTS.md documents the inputs and the uniforms. Used by a lighting preset for R4 (sun and tunnel light shafts, lamp halos, speed streaks, paint reflections on the player's car from live car pose), about 0.7 ms of GPU at 1080p on an RTX 3060. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Rkyma8rbyPiccs9n2bkyG6 --- docs/POST_EFFECTS.md | 13 ++ runtime/include/gpu_gl_renderer.h | 2 + runtime/include/host_overlay.h | 7 + runtime/include/postfx_chain.h | 10 +- runtime/src/debug_server.c | 12 ++ runtime/src/gl_postfx.inc | 224 ++++++++++++++++++++++++++++-- runtime/src/gte.cpp | 5 + runtime/src/postfx_chain.c | 4 +- 8 files changed, 263 insertions(+), 14 deletions(-) diff --git a/docs/POST_EFFECTS.md b/docs/POST_EFFECTS.md index 33d7def6..22421d69 100644 --- a/docs/POST_EFFECTS.md +++ b/docs/POST_EFFECTS.md @@ -92,6 +92,19 @@ Bloom uses the mask at both ends: only lit pixels feed its bright pass, and only lit pixels take the glow back. HUD pixels are therefore never changed, even next to a lamp. +## Depth, history, time and live uniforms + +- `input depth`: view depth `sz` (GTE screen z) of the presented frame, R32F at window + size, rebuilt from the surfaces' PGXP depth (`zn = 1 - 512/(sz + 256)`); 0 where nothing 3D drew. + Only built while a loaded chain reads it. +- `input history`: this pass's own target from the last real frame (trails, temporal + filters). Generated frames do not update it. +- `u_time` (seconds since the chain loaded) and `u_proj` (latest GTE projection: `240/H`, screen + centre in uv, aspect) are declared in every pass. +- Live uniforms: a title sets a preset `uniform` from game state with + `psx_host_set_post_uniform(name, x, y, z, w)` (`host_overlay.h`), e.g. a light source projected + to the screen; the preset value is the default until the first call. + ## Tests - `postfx_chain_test`: the format, built-in presets, errors and file import. diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 45e79835..cfa24c22 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -199,6 +199,8 @@ float gl_renderer_get_bloom(void); * compile is logged and skipped. */ void gl_renderer_set_post_shader(const char *path); int gl_renderer_post_shader_loaded(void); +/* Debug: path, last load outcome and live uniforms of the user chain (JSON members). */ +int gl_renderer_post_shader_debug(char *out, int cap); typedef struct { float strength, threshold; uint64_t passes, us_n; diff --git a/runtime/include/host_overlay.h b/runtime/include/host_overlay.h index 9064e382..b736165b 100644 --- a/runtime/include/host_overlay.h +++ b/runtime/include/host_overlay.h @@ -56,6 +56,13 @@ void psx_host_overlay_draw(int width, int height); /* renderer side */ void psx_host_set_hud_draw_cb(PsxHostOverlayDrawFn fn, void *ctx); void psx_host_hud_draw(int width, int height); /* renderer side */ +/* ---- Post-effect live uniforms ------------------------------------------ + * Sets the value of a `uniform ...` line of the loaded post-effect chain + * ([video] post_shader, postfx_chain.h) from game state, e.g. a light source + * projected to the screen. Any thread; effective at the next present; the + * preset default applies until the first call. Up to 16 names. */ +void psx_host_set_post_uniform(const char *name, float x, float y, float z, float w); + /* ---- P3: input sink ----------------------------------------------------- * The sink sees every host SDL event first (main thread), in the normal event * drain and in the host pause loop. Returning non-zero consumes the event. diff --git a/runtime/include/postfx_chain.h b/runtime/include/postfx_chain.h index b19759a3..0c87a321 100644 --- a/runtime/include/postfx_chain.h +++ b/runtime/include/postfx_chain.h @@ -9,7 +9,10 @@ * pfx 1 # format version (required first) * pass # starts a pass; the last pass writes the window * shader # fragment body (see below) - * input > + * input > + * # depth: PGXP view depth sz (GTE screen z, R32F; + * # 0 where nothing 3D drew); history: this pass's + * # own target from the last real frame (not last pass) * # source_down: the image scaled to this pass's * # target (one bilinear blit, no full-size copy) * scale # target size, fraction of the game rect (default 1) @@ -27,6 +30,7 @@ * uniform vec2 u_rcp; // 1 / this pass's target size in px * uniform vec2 u_size; // this pass's target size in px * uniform ivec2 u_org; // window origin of the game rect (gl_FragCoord - u_org = px in it) + * uniform float u_time; // seconds since the chain loaded (real and generated frames) * plus one `uniform sampler2D ;` per input. v_uv is 0..1 over the * game rect, bottom-up like the window. */ #ifndef PSX_POSTFX_CHAIN_H @@ -38,7 +42,8 @@ extern "C" { enum { PFX_MAX_PASSES = 16, PFX_MAX_INPUTS = 4, PFX_MAX_UNIFORMS = 8, PFX_NAME = 32 }; /* Input sources; a value >= 0 is an earlier pass's index. */ -enum { PFX_IN_SOURCE = -1, PFX_IN_MASK = -2, PFX_IN_SOURCE_DOWN = -3 }; +enum { PFX_IN_SOURCE = -1, PFX_IN_MASK = -2, PFX_IN_SOURCE_DOWN = -3, PFX_IN_DEPTH = -4, + PFX_IN_HISTORY = -5 }; enum { PFX_BLEND_REPLACE = 0, PFX_BLEND_SCREEN = 1 }; enum { PFX_FMT_RGBA8 = 0, PFX_FMT_RGBA16F = 1 }; @@ -69,6 +74,7 @@ typedef struct { char name[PFX_NAME]; int npasses; int uses_mask; /* some pass reads `mask` */ + int uses_depth; /* some pass reads `depth` */ PfxPass pass[PFX_MAX_PASSES]; } PfxChain; diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 553bcc1b..09a5096a 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -8778,6 +8778,17 @@ static void handle_bloom(int id, const char *json) bi.gen_us_mean, (unsigned long long)bi.reused); } +/* post_shader: {"cmd":"post_shader"} reads the user chain (path, load outcome, live + * uniforms); "path" sets it, "" turns it off, the same path reloads it (shader edits). */ +static void handle_post_shader(int id, const char *json) +{ + char path[512]; + if (json_get_str(json, "path", path, sizeof path)) gl_renderer_set_post_shader(path); + char info[1536]; + gl_renderer_post_shader_debug(info, (int)sizeof info); + send_fmt("{\"id\":%d,\"ok\":true,\"loaded\":%d,%s}", id, gl_renderer_post_shader_loaded(), info); +} + static void handle_post_aa(int id, const char *json) { int mode = json_get_int(json, "mode", -1); @@ -16347,6 +16358,7 @@ static const CmdEntry s_commands[] = { { "frame_perf", handle_frame_perf }, { "batch_sites", handle_batch_sites }, { "post_aa", handle_post_aa }, + { "post_shader", handle_post_shader }, { "fmv_chroma", handle_fmv_chroma }, { "texture_lod", handle_texture_lod }, { "accurate_blending", handle_accurate_blending }, diff --git a/runtime/src/gl_postfx.inc b/runtime/src/gl_postfx.inc index 2b632835..d01e14d3 100644 --- a/runtime/src/gl_postfx.inc +++ b/runtime/src/gl_postfx.inc @@ -36,6 +36,7 @@ typedef struct { GLint fixed[PFX_LOC_FIXED]; GLint in[PFX_MAX_INPUTS]; GLint u[PFX_MAX_UNIFORMS]; + GLint time, proj; } PfxLocs; typedef struct { PfxChain chain; @@ -46,6 +47,8 @@ typedef struct { GLuint dtex[PFX_MAX_PASSES], dfbo[PFX_MAX_PASSES]; /* source_down copies */ int dw[PFX_MAX_PASSES], dh[PFX_MAX_PASSES]; int tw[PFX_MAX_PASSES], th[PFX_MAX_PASSES]; + GLuint htex[PFX_MAX_PASSES], hfbo[PFX_MAX_PASSES]; /* `history` copies */ + int hw[PFX_MAX_PASSES], hh[PFX_MAX_PASSES], hist[PFX_MAX_PASSES]; } PfxStage; enum { PFX_STAGE_BLOOM, PFX_STAGE_USER, PFX_STAGE_AA, PFX_STAGE_HQ, PFX_NSTAGES }; static PfxStage s_pfx[PFX_NSTAGES]; @@ -78,6 +81,49 @@ static GLint s_mmap_uUv = -1, s_mmap_uS = -1, s_mmap_uSrc = -1, s_mmap_uDst = - * depth-stencil blit is needed (one costs the CPU milliseconds on macOS). */ typedef struct { GLuint rb, tex, fbo; int w, h; } PfxSurfMask; static PfxSurfMask s_smask[2]; +/* `depth`: each surface's depth-stencil renderbuffer is blitted into a depth + * texture of its size (dtex/dfbo), then mapped to the window as view depth sz + * (GTE screen z; PGXP writes zn = 1 - 512/(sz + 256)) in an R32F texture. */ +typedef struct { GLuint rb, dtex, dfbo; int w, h; } PfxSurfDepth; +static PfxSurfDepth s_sdepth[2]; +static GLuint s_depth_tex = 0, s_depth_fbo = 0, s_depth_map_prog = 0; +static GLint s_dmap_uUv = -1, s_dmap_uS = -1, s_dmap_uSrc = -1, s_dmap_uDst = -1; +static int s_depth_w = 0, s_depth_h = 0, s_depth_ok = 0; +static int s_pfx_depth_on = 0; /* a loaded chain reads `depth` */ +static uint64_t s_pfx_t0 = 0; /* u_time origin */ +/* Live values for preset `uniform` lines, set by the title from game state + * (psx_host_set_post_uniform, host_overlay.h): a live value replaces the + * preset default of the same name in every pass that declares it. */ +enum { PFX_LIVE_MAX = 16 }; +typedef struct { char name[PFX_NAME]; float v[4]; } PfxLive; +static PfxLive s_pfx_live[PFX_LIVE_MAX]; +static int s_pfx_nlive = 0; +static SDL_SpinLock s_pfx_live_lock = 0; +void psx_host_set_post_uniform(const char *name, float x, float y, float z, float w) { + if (!name || !name[0] || strlen(name) >= PFX_NAME) return; + SDL_LockSpinlock(&s_pfx_live_lock); + int i = 0; + while (i < s_pfx_nlive && strcmp(s_pfx_live[i].name, name)) i++; + if (i == s_pfx_nlive && s_pfx_nlive < PFX_LIVE_MAX) { + snprintf(s_pfx_live[i].name, PFX_NAME, "%s", name); + s_pfx_nlive++; + } + if (i < s_pfx_nlive) { + s_pfx_live[i].v[0] = x; s_pfx_live[i].v[1] = y; s_pfx_live[i].v[2] = z; s_pfx_live[i].v[3] = w; + } + SDL_UnlockSpinlock(&s_pfx_live_lock); +} +/* Last load outcome of the user chain, for the debug server. */ +static char s_pfx_user_status[192] = ""; +static int pfx_live_get(const char *name, float out[4]) { + int found = 0; + SDL_LockSpinlock(&s_pfx_live_lock); + for (int i = 0; i < s_pfx_nlive; i++) + if (!strcmp(s_pfx_live[i].name, name)) { memcpy(out, s_pfx_live[i].v, sizeof s_pfx_live[i].v); found = 1; break; } + SDL_UnlockSpinlock(&s_pfx_live_lock); + return found; +} +extern volatile int32_t g_gte_last_h, g_gte_last_ofx, g_gte_last_ofy; /* gte.cpp */ static int s_mask_w = 0, s_mask_h = 0; static int pfx_stage_on(int i) { @@ -102,7 +148,10 @@ static void pfx_stage_unload(PfxStage *st) { if (st->tex[i]) glDeleteTextures(1, &st->tex[i]); if (st->dfbo[i]) p_glDeleteFramebuffers(1, &st->dfbo[i]); if (st->dtex[i]) glDeleteTextures(1, &st->dtex[i]); + if (st->hfbo[i]) p_glDeleteFramebuffers(1, &st->hfbo[i]); + if (st->htex[i]) glDeleteTextures(1, &st->htex[i]); } + st->htex[i] = st->hfbo[i] = 0; st->hw[i] = st->hh[i] = 0; st->hist[i] = 0; st->prog[i] = st->tex[i] = st->fbo[i] = 0; st->tw[i] = st->th[i] = 0; st->dtex[i] = st->dfbo[i] = 0; st->dw[i] = st->dh[i] = 0; } @@ -118,6 +167,9 @@ static void pfx_mask_wanted_update(void) { if (i == PFX_STAGE_BLOOM) want = 1; /* the bloom preset reads it */ else if (s_pfx[i].loaded && s_pfx[i].chain.uses_mask) want = 1; } + s_pfx_depth_on = 0; + for (int i = 0; i < PFX_NSTAGES; i++) + if (pfx_stage_on(i) && s_pfx[i].loaded && s_pfx[i].chain.uses_depth) s_pfx_depth_on = 1; if (want != s_bcls_on) { s_bcls_on = want; pgxp_render_wanted_update(); } } @@ -135,26 +187,41 @@ static int pfx_stage_load(int i) { if (!ok) { fprintf(stdout, "psxrecomp: post effect '%s' not loaded: %s\n", i == PFX_STAGE_USER ? s_pfx_user_path : "built-in", err); + if (i == PFX_STAGE_USER) snprintf(s_pfx_user_status, sizeof s_pfx_user_status, "not loaded: %s", err); st->failed = 1; return 0; } st->loaded = 1; - if (i == PFX_STAGE_USER) + for (int p = 0; p < st->chain.npasses; p++) + for (int k = 0; k < st->chain.pass[p].ninputs; k++) + if (st->chain.pass[p].in[k].from == PFX_IN_HISTORY) st->hist[p] = 1; + if (i == PFX_STAGE_USER) { fprintf(stdout, "psxrecomp: post shader '%s' (%d passes%s)\n", s_pfx_user_path, st->chain.npasses, st->chain.uses_mask ? ", mask" : ""); + snprintf(s_pfx_user_status, sizeof s_pfx_user_status, "parsed: %d passes", st->chain.npasses); + } return 1; } static uint64_t s_pfx_builds = 0; /* program builds (tests) */ static GLuint pfx_build_pass(const PfxPass *ps) { s_pfx_builds++; - size_t n = strlen(ps->shader) + 512 + (size_t)ps->ninputs * 64; + size_t n = strlen(ps->shader) + 512 + (size_t)ps->ninputs * 64 + (size_t)ps->nuniforms * 64; char *src = (char *)malloc(n); if (!src) return 0; int k = snprintf(src, n, "#version 330\nin vec2 v_uv; out vec4 o;\n" - "uniform vec2 u_rcp; uniform vec2 u_size; uniform ivec2 u_org;\n"); + "uniform vec2 u_rcp; uniform vec2 u_size; uniform ivec2 u_org;\n" + "uniform float u_time; uniform vec4 u_proj;\n"); for (int i = 0; i < ps->ninputs; i++) k += snprintf(src + k, n - (size_t)k, "uniform sampler2D %s;\n", ps->in[i].sampler); + /* The preset's `uniform` lines are declared here unless the body declares + * them itself (the built-in shaders do). */ + static const char *const kTypes[] = { "float", "vec2", "vec3", "vec4" }; + for (int i = 0; i < ps->nuniforms && (size_t)k + 96 < n; i++) { + char decl[PFX_NAME + 16]; + snprintf(decl, sizeof decl, "%s %s;", kTypes[ps->u[i].count - 1], ps->u[i].name); + if (!strstr(ps->shader, decl)) k += snprintf(src + k, n - (size_t)k, "uniform %s\n", decl); + } snprintf(src + k, n - (size_t)k, "%s", ps->shader); GLuint prog = build_program(PRESENT_VS, src); free(src); @@ -188,7 +255,7 @@ static void pfx_rect_px(GLuint tex, float tex_w, float tex_h, int x, int y, int static void postfx_note_center(GLuint fbo, GLuint tex, float tex_w, float tex_h, int x, int y, int w, int h, int off, int wide_w) { s_mask_ctr_valid = 0; - if (!s_bcls_on || !fbo || !tex || wide_w <= 0) return; + if ((!s_bcls_on && !s_pfx_depth_on) || !fbo || !tex || wide_w <= 0) return; s_mask_ctr_rb = pfx_surface_rb(fbo); if (!s_mask_ctr_rb) return; pfx_rect_px(tex, tex_w, tex_h, x, y, w, h, s_mask_ctr, &s_mask_ctr_tw, &s_mask_ctr_th); @@ -204,7 +271,7 @@ static void postfx_note_source(GLuint fbo, GLuint tex, float tex_w, float tex_h, int x, int y, int w, int h) { s_mask_src_valid = 0; s_mask_ctr_valid = 0; - if (!s_bcls_on || !fbo || !tex || tex_w <= 0.0f || tex_h <= 0.0f) return; + if ((!s_bcls_on && !s_pfx_depth_on) || !fbo || !tex || tex_w <= 0.0f || tex_h <= 0.0f) return; s_mask_src_rb = pfx_surface_rb(fbo); if (!s_mask_src_rb) return; pfx_rect_px(tex, tex_w, tex_h, x, y, w, h, s_mask_src, &s_mask_src_tw, &s_mask_src_th); @@ -217,6 +284,7 @@ static void pfx_tex_alloc(GLuint *t, int w, int h, int fmt, int nearest) { glBindTexture(GL_TEXTURE_2D, *t); if (fmt == PFX_FMT_RGBA16F) glTexImage2D(GL_TEXTURE_2D, 0, 0x881A /*RGBA16F*/, w, h, 0, GL_RGBA, GL_FLOAT, NULL); else if (fmt == -1) glTexImage2D(GL_TEXTURE_2D, 0, 0x8229 /*R8*/, w, h, 0, 0x1903 /*RED*/, GL_UNSIGNED_BYTE, NULL); + else if (fmt == -2) glTexImage2D(GL_TEXTURE_2D, 0, 0x822E /*R32F*/, w, h, 0, 0x1903 /*RED*/, GL_FLOAT, NULL); else glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); @@ -309,6 +377,81 @@ static int pfx_mask_build(int fresh, int lw, int lh) { return s_mask_ok; } +/* Slot k: surface rb (tw x th) blitted into its depth texture. */ +static int pfx_surf_depth(int k, GLuint fbo, GLuint rb, int tw, int th) { + PfxSurfDepth *d = &s_sdepth[k]; + if (d->rb != rb || d->w != tw || d->h != th) { + if (!d->dtex) glGenTextures(1, &d->dtex); + glBindTexture(GL_TEXTURE_2D, d->dtex); + glTexImage2D(GL_TEXTURE_2D, 0, PSXGL_DEPTH24_STENCIL8, tw, th, 0, + 0x84F9 /*DEPTH_STENCIL*/, 0x84FA /*UNSIGNED_INT_24_8*/, NULL); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glBindTexture(GL_TEXTURE_2D, 0); + if (!d->dfbo) p_glGenFramebuffers(1, &d->dfbo); + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, d->dfbo); + p_glFramebufferTexture2D(PSXGL_FRAMEBUFFER, PSXGL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, d->dtex, 0); + d->rb = rb; d->w = tw; d->h = th; + } + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, fbo); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, d->dfbo); + p_glBlitFramebuffer(0, 0, tw, th, 0, 0, tw, th, GL_DEPTH_BUFFER_BIT, GL_NEAREST); + return 1; +} +static void pfx_depth_map(int k, const int r[4], int x0, int x1, int lh) { + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, s_depth_fbo); + glViewport(x0, 0, x1 - x0, lh); + p_glUseProgram(s_depth_map_prog); + p_glActiveTexture(PSXGL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, s_sdepth[k].dtex); + p_glUniform1i(s_dmap_uS, 0); + p_glUniform4i(s_dmap_uSrc, r[0], r[1], r[2], r[3]); + p_glUniform4f(s_dmap_uDst, (float)x0, (float)(x1 - x0), (float)lh, 0.0f); + post_fullscreen(s_depth_map_prog, s_dmap_uUv); + glBindTexture(GL_TEXTURE_2D, 0); +} +/* Window-sized view depth from the presented surface (fresh) or the last. */ +static int pfx_depth_build(int fresh, int lw, int lh) { + if (!s_depth_map_prog) { + s_depth_map_prog = build_program(PRESENT_VS, + "#version 330\nin vec2 v_uv; out vec4 o; uniform sampler2D u_s;\n" + "uniform ivec4 u_src; uniform vec4 u_dst;\n" + "void main(){\n" + " vec2 f = gl_FragCoord.xy;\n" + " int sx = u_src.x + int(floor((f.x - u_dst.x) * float(u_src.z - u_src.x) / u_dst.y));\n" + " int sy = u_src.y + int(floor((u_dst.z - f.y) * float(u_src.w - u_src.y) / u_dst.z));\n" + " float d = texelFetch(u_s, ivec2(sx, sy), 0).r;\n" + " /* zn = 2d - 1 = 1 - 512/(sz + 256); d = 0.5 is the zn = 0 of 2D draws */\n" + " float sz = (d >= 0.999999 || abs(d - 0.5) < 1e-6) ? 0.0 : 256.0 / max(1.0 - d, 1e-6) - 256.0;\n" + " o = vec4(sz);\n" + "}\n"); + if (!s_depth_map_prog) return 0; + s_dmap_uUv = p_glGetUniformLocation(s_depth_map_prog, "u_uv_rect"); + s_dmap_uS = p_glGetUniformLocation(s_depth_map_prog, "u_s"); + s_dmap_uSrc = p_glGetUniformLocation(s_depth_map_prog, "u_src"); + s_dmap_uDst = p_glGetUniformLocation(s_depth_map_prog, "u_dst"); + } + if (s_depth_w != lw || s_depth_h != lh) { + pfx_tex_alloc(&s_depth_tex, lw, lh, -2, 1); + pfx_fbo_attach(&s_depth_fbo, s_depth_tex); + s_depth_w = lw; s_depth_h = lh; s_depth_ok = 0; + } + if (fresh && s_mask_src_valid) { + pfx_surf_depth(0, s_mask_src_fbo, s_mask_src_rb, s_mask_src_tw, s_mask_src_th); + if (s_mask_ctr_valid) + pfx_surf_depth(1, s_mask_ctr_fbo, s_mask_ctr_rb, s_mask_ctr_tw, s_mask_ctr_th); + pfx_depth_map(0, s_mask_src, 0, lw, lh); + if (s_mask_ctr_valid) { + const int c0 = (int)lrintf(s_mask_ctr_x0 * (float)lw), c1 = (int)lrintf(s_mask_ctr_x1 * (float)lw); + if (c1 > c0) pfx_depth_map(1, s_mask_ctr, c0, c1, lh); + } + s_depth_ok = 1; + } + return s_depth_ok; +} + /* Run one stage's chain over the window rect. 0 when it could not run. * generated: a Smooth motion in-between frame; passes marked `generated * reuse` keep the target the last real frame left (when it is there at this @@ -330,12 +473,16 @@ static int pfx_stage_run(PfxStage *st, int generated, int lx, int ly, int lw, in L->fixed[PFX_LOC_SIZE] = p_glGetUniformLocation(pg, "u_size"); L->fixed[PFX_LOC_ORG] = p_glGetUniformLocation(pg, "u_org"); L->fixed[PFX_LOC_UV] = p_glGetUniformLocation(pg, "u_uv_rect"); + L->time = p_glGetUniformLocation(pg, "u_time"); + L->proj = p_glGetUniformLocation(pg, "u_proj"); for (int k = 0; k < ps->ninputs; k++) L->in[k] = p_glGetUniformLocation(pg, ps->in[k].sampler); for (int k = 0; k < ps->nuniforms; k++) L->u[k] = p_glGetUniformLocation(pg, ps->u[k].name); } if (!st->prog[i]) { fprintf(stdout, "psxrecomp: post effect '%s' pass '%s' did not compile; effect off\n", c->name, ps->name); + if (st == &s_pfx[PFX_STAGE_USER]) + snprintf(s_pfx_user_status, sizeof s_pfx_user_status, "pass '%s' did not compile", ps->name); st->failed = 1; return 0; } @@ -381,7 +528,9 @@ static int pfx_stage_run(PfxStage *st, int generated, int lx, int ly, int lw, in const int from = ps->in[k].from; const GLuint t = from == PFX_IN_SOURCE ? s_post_copy_tex : from == PFX_IN_SOURCE_DOWN ? st->dtex[i] - : from == PFX_IN_MASK ? s_mask_tex : st->tex[from]; + : from == PFX_IN_MASK ? s_mask_tex + : from == PFX_IN_DEPTH ? s_depth_tex + : from == PFX_IN_HISTORY ? st->htex[i] : st->tex[from]; p_glActiveTexture(PSXGL_TEXTURE0 + (GLenum)k); glBindTexture(GL_TEXTURE_2D, t); p_glUniform1i(L->in[k], k); @@ -390,15 +539,30 @@ static int pfx_stage_run(PfxStage *st, int generated, int lx, int ly, int lw, in p_glUniform2f(L->fixed[PFX_LOC_RCP], 1.0f / (float)w, 1.0f / (float)h); p_glUniform2f(L->fixed[PFX_LOC_SIZE], (float)w, (float)h); p_glUniform2i(L->fixed[PFX_LOC_ORG], last ? lx : 0, last ? ly : 0); + if (!s_pfx_t0) s_pfx_t0 = SDL_GetPerformanceCounter(); + if (L->proj >= 0) { + /* u_proj: x/z = (uv.x - y) * w * x, y/z = (uv.y - z) * x for a view-space + * point at depth sz (240-line reference, same scale on both axes). */ + const float aspect = (float)lw / (float)(lh > 0 ? lh : 1); + const float H = g_gte_last_h > 0 ? (float)g_gte_last_h : 256.0f; + const float ofx = (float)g_gte_last_ofx / 65536.0f, ofy = (float)g_gte_last_ofy / 65536.0f; + p_glUniform4f(L->proj, 240.0f / H, 0.5f + (ofx - 160.0f) / (240.0f * aspect), + 1.0f - (ofy > 0.0f ? ofy : 120.0f) / 240.0f, aspect); + } + if (L->time >= 0) + p_glUniform1f(L->time, (float)((double)(SDL_GetPerformanceCounter() - s_pfx_t0) / + (double)SDL_GetPerformanceFrequency())); for (int k = 0; k < ps->nuniforms; k++) { const PfxUniform *u = &ps->u[k]; const GLint loc = L->u[k]; if (loc < 0) continue; + float live[4]; + const float *v = pfx_live_get(u->name, live) ? live : u->v; switch (u->count) { - case 1: p_glUniform1f(loc, u->v[0]); break; - case 2: p_glUniform2f(loc, u->v[0], u->v[1]); break; - case 3: p_glUniform3f(loc, u->v[0], u->v[1], u->v[2]); break; - default: p_glUniform4f(loc, u->v[0], u->v[1], u->v[2], u->v[3]); break; + case 1: p_glUniform1f(loc, v[0]); break; + case 2: p_glUniform2f(loc, v[0], v[1]); break; + case 3: p_glUniform3f(loc, v[0], v[1], v[2]); break; + default: p_glUniform4f(loc, v[0], v[1], v[2], v[3]); break; } } if (ps->blend == PFX_BLEND_SCREEN) { @@ -408,6 +572,17 @@ static int pfx_stage_run(PfxStage *st, int generated, int lx, int ly, int lw, in } post_fullscreen(prog, L->fixed[PFX_LOC_UV]); if (ps->blend != PFX_BLEND_REPLACE) glDisable(GL_BLEND); + if (!last && !generated && st->hist[i]) { + /* This pass's target becomes its `history` for the next real frame. */ + if (st->hw[i] != w || st->hh[i] != h) { + pfx_tex_alloc(&st->htex[i], w, h, ps->format, ps->nearest); + pfx_fbo_attach(&st->hfbo[i], st->htex[i]); + st->hw[i] = w; st->hh[i] = h; + } + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, st->fbo[i]); + p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, st->hfbo[i]); + p_glBlitFramebuffer(0, 0, w, h, 0, 0, w, h, GL_COLOR_BUFFER_BIT, GL_NEAREST); + } } for (int k = PFX_MAX_INPUTS - 1; k >= 0; k--) { p_glActiveTexture(PSXGL_TEXTURE0 + (GLenum)k); @@ -453,7 +628,7 @@ static void postfx_present(int fresh, int with_aa, int lx, int ly, int lw, int l PfxGlState saved; pfx_state_save(&saved); glDisable(GL_BLEND); glDisable(GL_DEPTH_TEST); glDisable(GL_SCISSOR_TEST); glDisable(GL_STENCIL_TEST); - int mask_done = 0, mask_ok = 0; + int mask_done = 0, mask_ok = 0, depth_done = 0, depth_ok = 0; for (int i = 0; i < PFX_NSTAGES; i++) { if (!pfx_stage_on(i)) continue; if ((i == PFX_STAGE_AA || i == PFX_STAGE_HQ) && !with_aa) continue; @@ -463,6 +638,10 @@ static void postfx_present(int fresh, int with_aa, int lx, int ly, int lw, int l if (!mask_done) { mask_ok = pfx_mask_build(src_fresh, lw, lh); mask_done = 1; } if (!mask_ok) continue; /* no marks yet */ } + if (st->chain.uses_depth) { + if (!depth_done) { depth_ok = pfx_depth_build(src_fresh, lw, lh); depth_done = 1; } + if (!depth_ok) continue; /* no depth yet */ + } if (i == PFX_STAGE_BLOOM) { const float k = s_bloom; pfx_chain_set_uniform(&st->chain, "u_strength", 1, &k); @@ -515,6 +694,12 @@ static void postfx_release(void) { } s_mask_tex = s_mask_fbo = s_mask_prog = s_mask_map_prog = 0; memset(s_smask, 0, sizeof s_smask); + s_depth_tex = s_depth_fbo = s_depth_map_prog = 0; s_depth_w = s_depth_h = 0; s_depth_ok = 0; + memset(s_sdepth, 0, sizeof s_sdepth); + for (int i = 0; i < PFX_NSTAGES; i++) + for (int k = 0; k < PFX_MAX_PASSES; k++) { + s_pfx[i].htex[k] = s_pfx[i].hfbo[k] = 0; s_pfx[i].hw[k] = s_pfx[i].hh[k] = 0; + } s_mask_w = s_mask_h = 0; s_mask_ok = 0; s_mask_src_valid = 0; } @@ -538,6 +723,23 @@ void gl_renderer_set_post_shader(const char *path) { } pfx_mask_wanted_update(); } +/* Debug: the user chain's last load outcome and the live uniforms, as JSON members. */ +int gl_renderer_post_shader_debug(char *out, int cap) { + char chemin[sizeof s_pfx_user_path]; + snprintf(chemin, sizeof chemin, "%s", s_pfx_user_path); + for (char *q = chemin; *q; q++) if (*q == 92 || *q == 34) *q = '/'; /* JSON-safe */ + int k = snprintf(out, (size_t)cap, "\"path\":\"%s\",\"status\":\"%s\",\"uniforms\":{", + chemin, s_pfx_user_status); + SDL_LockSpinlock(&s_pfx_live_lock); + for (int i = 0; i < s_pfx_nlive && k < cap - 96; i++) + k += snprintf(out + k, (size_t)(cap - k), "%s\"%s\":[%.4f,%.4f,%.4f,%.4f]", i ? "," : "", + s_pfx_live[i].name, s_pfx_live[i].v[0], s_pfx_live[i].v[1], s_pfx_live[i].v[2], + s_pfx_live[i].v[3]); + SDL_UnlockSpinlock(&s_pfx_live_lock); + if (k < cap - 2) k += snprintf(out + k, (size_t)(cap - k), "}"); + return k; +} + int gl_renderer_post_shader_loaded(void) { GL_RT_SYNC("post_shader"); return s_pfx[PFX_STAGE_USER].loaded && !s_pfx[PFX_STAGE_USER].failed; diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index 7c9c36ce..fce93bbf 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -20,6 +20,8 @@ extern "C" int gpu_ws_precise_nclip_enabled(void); * plus native replay of one candidate) is running. */ extern "C" int psx_overlay_shadow_diff_active(void); extern "C" uint64_t s_frame_count; /* debug_server.c */ +/* Latest projection (H, OFX, OFY in 16.16), read by the post-effect executor. */ +extern "C" { volatile int32_t g_gte_last_h = 0, g_gte_last_ofx = 0, g_gte_last_ofy = 0; } extern "C" uint32_t g_debug_last_store_pc; /* debug_server.c */ extern "C" { int (*g_psx_projection_command)(CPUState*, uint32_t) = nullptr; } @@ -1206,6 +1208,9 @@ void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0, uint32_t instr) } } dome_probe_note(gte->SZ[3]); /* locate the dome draw fn (far-vertex tally) */ + if (!s_gte_replay_sandbox) { /* projection of the latest 3D draw, for post effects (u_proj) */ + g_gte_last_h = gte->H; g_gte_last_ofx = gte->OFX; g_gte_last_ofy = gte->OFY; + } int64_t sx16 = gte->OFX + xterm; int64_t sy16 = gte->OFY + (int64_t)gte->IR2 * h_div_sz; /* Render-view projection override (psx_mod_render_view, projection=1). diff --git a/runtime/src/postfx_chain.c b/runtime/src/postfx_chain.c index 6c735552..76d06f04 100644 --- a/runtime/src/postfx_chain.c +++ b/runtime/src/postfx_chain.c @@ -238,13 +238,15 @@ int pfx_chain_parse(const char *text, const char *name, PfxShaderLoader load, vo if (!strcmp(f, "source")) from = PFX_IN_SOURCE; else if (!strcmp(f, "source_down")) from = PFX_IN_SOURCE_DOWN; else if (!strcmp(f, "mask")) { from = PFX_IN_MASK; out->uses_mask = 1; } + else if (!strcmp(f, "depth")) { from = PFX_IN_DEPTH; out->uses_depth = 1; } + else if (!strcmp(f, "history")) from = PFX_IN_HISTORY; else if (!strcmp(f, "previous")) { if (self == 0) { pfx_chain_free(out); return pfx_fail(err, errlen, line, "the first pass has no previous"); } from = self - 1; } else { from = -100; for (int i = 0; i < self; i++) if (!strcmp(out->pass[i].name, f)) from = i; - if (from == -100) { pfx_chain_free(out); return pfx_fail(err, errlen, line, "unknown input '%s' (source, mask, previous or an earlier pass)", f); } + if (from == -100) { pfx_chain_free(out); return pfx_fail(err, errlen, line, "unknown input '%s' (source, mask, depth, history, previous or an earlier pass)", f); } } snprintf(cur->in[cur->ninputs].sampler, PFX_NAME, "%s", s); cur->in[cur->ninputs++].from = from; From 4cbedf5579b1e5f180cdb2b5464ffc72c27bdf1c Mon Sep 17 00:00:00 2001 From: Henrique Guedes Date: Sat, 24 Oct 2026 09:18:57 -0700 Subject: [PATCH 04/22] Add function overrides with guarded registration and cycle credit on current runtime --- docs/MOD_PACKAGES.md | 16 + recompiler/src/full_function_emitter.cpp | 18 + runtime/CMakeLists.txt | 11 + runtime/include/func_override.h | 230 ++++++++++ runtime/include/mod_packages.h | 1 + runtime/include/mod_plugins.h | 31 ++ runtime/runtime.cmake | 1 + runtime/src/debug_server.c | 56 +++ runtime/src/dirty_ram_interp.c | 60 ++- runtime/src/func_override.c | 278 ++++++++++++ runtime/src/main.cpp | 2 + runtime/src/memory.c | 10 + runtime/src/mod_packages.cpp | 14 +- runtime/src/mod_runtime.cpp | 95 +++++ runtime/tests/test_func_override.c | 515 +++++++++++++++++++++++ runtime/tests/test_mod_runtime.cpp | 17 + 16 files changed, 1353 insertions(+), 2 deletions(-) create mode 100644 runtime/include/func_override.h create mode 100644 runtime/src/func_override.c create mode 100644 runtime/tests/test_func_override.c diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 8a3de9b8..6a75927f 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -538,6 +538,22 @@ frames. Trusted callbacks receive only the narrow C services exposed by `runtime/include/mod_plugins.h`. Games should continue to use declarative patches and overlays when those operations are sufficient. +An implementation may also register **function overrides** under a plugin id +(`psx_mod_register_function_override`): hand-written C that replaces or wraps +a guest function at a given address, with an optional prologue-word residency +guard. Registration only queues the override; it is ARMED into the dispatcher +tier when the resolved plan selects that plugin — the same gating as the other +callback kinds, so an override-only plugin id counts as available to the +resolver. Every registration states a required guest-cycle `credit` (a fixed +per-handled-call charge, `0` for a mod with no hardware analog, or +`FO_CREDIT_SELF` when the body — or a wrapped original — accounts for its own +time). The full execution contract (guest ABI, decline semantics, +`func_override_call_original` wrap primitive, `func_override_guest_call`, +determinism and cycle-accounting policy) is documented in +`runtime/include/func_override.h`. Overrides registered directly through +`func_override_add` (game `EXTRAS_SOURCES` constructors, the progressive- +decompilation idiom) bypass package gating and are always active; packages +are the right home for anything a player should be able to toggle. ### Session starts and mod-owned state What a plugin sets up should last only for a session whose resolved plan diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index 515ded2d..b51d524b 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -1839,6 +1839,12 @@ void FullFunctionEmitter::emit_dispatch( out += "extern int (*g_psx_bios_hle_hook)(CPUState* cpu, uint32_t phys);\n\n"; out += "extern uint32_t g_psx_mod_guest_functions;\n"; out += "extern int psx_mod_dispatch_guest_function(CPUState*, uint32_t);\n\n"; + out += "/* Game function-override tier (runtime/include/func_override.h):\n"; + out += " * hand-written C registered against a guest address. NULL (the\n"; + out += " * default) = no overrides, dispatch identical to a build without\n"; + out += " * the tier. Returns 1 when the override handled the call; the\n"; + out += " * guest resumes at $ra exactly as if the original ran jr $ra. */\n"; + out += "extern int (*g_psx_func_override_hook)(CPUState* cpu, uint32_t phys);\n\n"; out += "#ifdef PSX_HAS_GAME_DISPATCH\n"; out += "extern int psx_game_address_in_text(uint32_t addr);\n"; out += "#endif\n\n"; @@ -2285,6 +2291,18 @@ void FullFunctionEmitter::emit_dispatch( out += " /* Byte-guarded A0/B0/C0 call-vector tail stubs. */\n"; out += " if (!found && psx_bios_try_native_call_stub(cpu, addr))\n"; out += " found = 1;\n"; + out += " /* Function-override tier: AFTER the BIOS tiers (an override\n"; + out += " * can never shadow a kernel service vector), BEFORE every\n"; + out += " * game code backend, so one address-keyed hook covers the\n"; + out += " * static EXE, runtime-loaded overlays and dirty RAM alike.\n"; + out += " * Handled (rc 1) => the override completed against guest\n"; + out += " * state; resume at $ra via the trampoline's normal\n"; + out += " * return/tail contract. rc 0 => fall through untouched. */\n"; + out += " if (!found && g_psx_func_override_hook &&\n"; + out += " g_psx_func_override_hook(cpu, addr & 0x1FFFFFFFu)) {\n"; + out += " cpu->pc = cpu->gpr[31];\n"; + out += " found = 1;\n"; + out += " }\n"; out += "#ifdef PSX_HAS_GAME_DISPATCH\n"; out += " /* Game EXEs can overlap the BIOS shell copy window at\n"; out += " * physical 0x30000-0x5AFFF. If the target belongs to the\n"; diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 94028664..72edd072 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1282,6 +1282,16 @@ if(BUILD_TESTING) target_include_directories(bios_hle_plan_test PRIVATE include) add_test(NAME bios_hle_plan_test COMMAND bios_hle_plan_test) + # Every func_override failure mode is silent at runtime: a registration + # that is never consulted, a package override that survives clear-mods + # (a netplay divergence), a residency guard that corrupts instead of + # declining. Drives the real g_psx_func_override_hook pointer. + add_executable(func_override_test + tests/test_func_override.c + src/func_override.c) + target_include_directories(func_override_test PRIVATE include) + add_test(NAME func_override_test COMMAND func_override_test) + # A cue, conventional raw image, or Steam .car image is a valid pick; each # must mount the same disc and identify off the same data track. add_executable(sbi_setup_test tests/test_sbi_setup.cpp src/psx_sha256.c) @@ -1411,6 +1421,7 @@ if(BUILD_TESTING) src/mod_immediate.c src/host_overlay.c tests/test_mod_runtime.cpp + src/func_override.c src/iso_reader_c.cpp src/host_span_ring.c src/mod_runtime.cpp diff --git a/runtime/include/func_override.h b/runtime/include/func_override.h new file mode 100644 index 00000000..3e736259 --- /dev/null +++ b/runtime/include/func_override.h @@ -0,0 +1,230 @@ +/* func_override.h — replace a guest function with hand-written C. + * + * WHY THIS EXISTS + * + * A recomp turns the game into C, but that C is a build artifact: regenerated + * from the player's own disc, never hand-edited (CLAUDE.md rule 4). So there + * is no supported way to say "this function should do something else" — which + * is the single most important thing a port needs to grow beyond a faithful + * replay of the original. + * + * This is that mechanism. Register hand-written C against a guest address and + * the dispatcher calls yours instead of the recompiled original. + * + * NO SIZE LIMIT. This is not a ROM patch; there is no slot to fit inside, no + * code cave, nothing to shift. Your replacement is native code in a native + * binary — five instructions or five thousand lines. That freedom is the + * entire point of recompiling rather than hacking bytes. + * + * WHERE IT HOOKS, AND WHY IT MATTERS + * + * The hook is consulted in psx_dispatch_impl after the BIOS tiers (so an + * override can never shadow a kernel service vector) and BEFORE every game + * code backend. That placement is deliberate and load-bearing: one + * address-keyed hook covers the STATIC EXE, runtime-loaded OVERLAYS and + * DIRTY RAM alike. The interpreter's JAL/JALR call-resolution tiers consult + * it on the SAME terms — before enter-compiled, before overlay-native and + * before the local pc-chain (the reserved CRES_OVERRIDE slot) — so no call + * the interpreter resolves itself can bypass an override. Ordering there is + * not cosmetic: interp_enter_compiled calls psx_dispatch_game_compiled -> + * entry->fn(cpu) directly and never re-enters psx_dispatch_impl, so a hook + * placed after it is unreachable for any override on a statically-compiled + * function reached from interpreted code, while registration and the + * func_override inventory both still look healthy. + * + * SCOPE: overrides are CALL-site keyed. A guest tail transfer (j / jr) into + * an overridden address from the dirty-RAM block loop is a TRANSFER, not a + * call, and does not consult the hook — see dirty_ram_interp.c's compiled + * handoff sites. Registering on a function only ever entered by tail + * transfer will show calls == 0. + * + * THE CONTRACT + * + * Your function receives the CPUState and must obey the guest ABI: + * + * int my_impl(CPUState *cpu) { + * uint32_t a0 = cpu->gpr[4], a1 = cpu->gpr[5]; // arguments + * ... + * cpu->gpr[2] = result; // $v0 = return value + * return 1; + * } + * + * Return 1 and the guest resumes at $ra exactly as if the original had run + * `jr $ra`. Return 0 and the original recompiled or interpreted code runs + * untouched, so an override can decline case by case (a conditional + * pre-hook: mutate state, return 0, original runs). + * + * POINTERS MUST BE GUEST ADDRESSES. The caller is recompiled too: when it + * dereferences your return value it performs a guest load. A host pointer + * will read garbage. If you need to hand back a buffer, write it into guest + * RAM (psx_write_byte and friends, or psx_mod_alloc_guest_memory) and return + * that address. + * + * DETERMINISM. Keep ALL mutable state in guest RAM. Rollback/rewind and + * netplay snapshot guest state only; host-side statics in an override + * desync replays. Reading host config that never changes mid-session is + * fine. + * + * CYCLE ACCOUNTING — REQUIRED AT REGISTRATION. + * On hardware the replaced function took guest time, and every device in + * the machine schedules against that clock; a replacement that takes zero + * cycles shifts IRQ phase for the rest of the session. So every + * registration MUST state its timing intent via the `credit` argument — + * there is no default to inherit. Two forms: + * + * credit >= 0 The tier charges exactly `credit` guest cycles + * (psx_advance_cycles) on every HANDLED call, after + * the body runs. Declines charge nothing — the + * original runs and accrues its own cycles. 0 is + * legal and means "deliberately free": right for a + * player-toggled mod whose behavior has no hardware + * analog, indefensible for an always-on + * reimplementation claiming to be indistinguishable. + * + * FO_CREDIT_SELF The tier charges nothing; the BODY owns its timing + * and calls psx_advance_cycles() itself. Use this + * when the original's cost is data-dependent + * (charge base + per_iteration * n, the bios_hle.c + * TIMING NOTE pattern), and ALWAYS for wraps: the + * original re-dispatched by func_override_call_original + * accrues its exact cycles by executing, so a fixed + * nonzero credit on a wrap double-counts. Guest code + * invoked via func_override_guest_call likewise + * self-charges — credit only the code you REPLACED. + * + * Credits are approximations of the original's dynamic instruction count + * unless measured; LLE remains the timing oracle (run with the override + * declined and sample the cycle delta across the call boundary — the + * callret ring — to measure a real distribution). See FAITHFUL_TIMING_PLAN. + * + * WHAT IT DOES NOT DO + * + * It does not lift the game's own assumptions. If the caller copies your + * result into a fixed-size field, that field is still fixed. The override + * removes the code limit, not every limit. + */ + +#ifndef PSXRECOMP_FUNC_OVERRIDE_H +#define PSXRECOMP_FUNC_OVERRIDE_H + +#include +#include + +struct CPUState; + +#ifdef __cplusplus +extern "C" { +#endif + +#define FO_MAX_OVERRIDES 128 +#define FO_MAX_ID 64 +#define FO_MAX_GUARD_WORDS 4 + +/* `credit` value: the override body states its own timing by calling + * psx_advance_cycles() itself (mandatory for wraps — see CYCLE ACCOUNTING + * above). Any other negative value is FO_ERR_ARGS. */ +#define FO_CREDIT_SELF (-1) + +/* func_override_add return codes. */ +#define FO_OK 0 +#define FO_ERR_FULL 1 +#define FO_ERR_ARGS 2 +#define FO_ERR_DUPLICATE 3 + +/* An override implementation. Reads arguments from cpu->gpr[4..7], writes + * the return value to cpu->gpr[2]. Returning nonzero means "handled"; + * returning 0 declines this call and the original code runs instead. */ +typedef int (*FuncOverrideFn)(struct CPUState *cpu); + +/* Register `fn` for guest address `addr` (KSEG or physical — normalised). + * `id` is copied, used for diagnostics, and may be NULL. Registering the + * same address twice is an error rather than a silent last-wins, because a + * silent shadow is impossible to debug. `credit` is the guest-cycle policy + * (CYCLE ACCOUNTING above): >= 0 charged per handled call, or + * FO_CREDIT_SELF when the body charges its own. */ +int func_override_add(const char *id, uint32_t addr, FuncOverrideFn fn, + int32_t credit); + +/* Like func_override_add, plus a residency guard: before each consult the + * first `n_words` guest words at `addr` are compared against + * `expected_words`; on mismatch the override silently declines (the guard + * miss is counted, see func_override_get_ex). Use this when the address + * belongs to overlay/dirty RAM that other code may occupy at other times — + * the guard makes "wrong code resident" a decline instead of a corruption. + * n_words must be 1..FO_MAX_GUARD_WORDS. */ +int func_override_add_guarded(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *expected_words, int n_words, + int32_t credit); + +/* Call a guest function from inside an override (or any native code on the + * dispatch thread): args already placed in cpu->gpr[4..7], returns after the + * callee completes; result in cpu->gpr[2]. `site_ra` is the return address + * the callee should observe in $ra — pass the original call site's return + * address when re-creating a call the original code would have made, so + * callees, fntrace and crash forensics see authentic values. $ra is + * restored around the call. */ +void func_override_guest_call(struct CPUState *cpu, uint32_t target, + uint32_t site_ra); + +/* Run the ORIGINAL code of the function currently being overridden — the + * wrap primitive. Callable only from inside an executing override; arms a + * one-shot bypass for this address, re-dispatches it, and returns after the + * original completes (result in cpu->gpr[2]). Typical post-hook: + * + * int wrap(CPUState *cpu) { + * func_override_call_original(cpu); // original runs fully + * ...inspect / adjust results... + * return 1; // we handled the call + * } + * + * The bypass is one-shot per invocation: if the original recursively calls + * itself, the recursive calls consult the override again (matching what a + * guest-level wrap would observe). */ +void func_override_call_original(struct CPUState *cpu); + +/* Register on behalf of a mod package plan. Identical to the two calls above + * (pass n_words 0 for unguarded) except the entry is tagged package-armed, so + * func_override_reset_package_armed can drop it when the plan is cleared. + * The mod runtime uses this; game code registering its own always-on + * reimplementations should use func_override_add/_add_guarded. */ +int func_override_add_package(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *expected_words, int n_words, + int32_t credit); + +/* Install the dispatcher hook. Call once at startup AFTER registering (the + * mod runtime calls it again after arming package-gated overrides — safe). + * Cheap with nothing registered — it leaves the hook NULL, so dispatch is + * byte-identical to a build without the tier. */ +void func_override_install(void); + +/* Drop every package-armed override, keep direct registrations, re-install + * (so the hook goes back to NULL if the table empties). Returns how many were + * dropped. THIS IS REQUIRED FOR CORRECTNESS, not a convenience: a cleared mod + * plan stops activation/vblank callbacks simply by no longer being iterated, + * but an armed override lives in this module's own table, so without an + * explicit reset it keeps firing after the plan is gone — including after + * netplay's clear-mods announces a vanilla session, which is a peer + * divergence. Direct registrations are deliberately kept: they are the game's + * own always-on faithful reimplementations, present identically in both + * peers' builds and not player-selectable. */ +int func_override_reset_package_armed(void); + +/* Introspection, surfaced by the `func_override` TCP command. An override + * whose `calls` stays 0 was never reached: wrong address, or that code path + * never ran. `guard_misses` counts consults declined by the residency + * guard. `credit_out` reports the declared cycle policy (FO_CREDIT_SELF or + * the fixed per-call charge), so every armed override's timing intent is + * inspectable, not just documented. */ +int func_override_count(void); +int func_override_get(int index, char *id_out, size_t id_cap, + uint32_t *addr_out, uint64_t *calls_out); +int func_override_get_ex(int index, char *id_out, size_t id_cap, + uint32_t *addr_out, uint64_t *calls_out, + uint64_t *guard_misses_out, int *guarded_out, + int32_t *credit_out); + +#ifdef __cplusplus +} +#endif + +#endif /* PSXRECOMP_FUNC_OVERRIDE_H */ diff --git a/runtime/include/mod_packages.h b/runtime/include/mod_packages.h index 9f489169..2501ffac 100644 --- a/runtime/include/mod_packages.h +++ b/runtime/include/mod_packages.h @@ -627,6 +627,7 @@ bool mod_register_function_filter_plugin(const std::string& id, uint32_t address bool mod_register_guest_function_plugin(const std::string& id, uint32_t address, PSXModFunctionEntryCallback callback); bool mod_plugin_registered(const std::string& id); +bool mod_register_function_override_marker(const std::string& id); void mod_invoke_activation_plugin(const std::string& id); void mod_invoke_vblank_plugin(const std::string& id); struct ModFunctionEntryHook { diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index d77acc18..bd2cb9e9 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -2,6 +2,8 @@ #include +#include "func_override.h" /* FO_CREDIT_SELF for the credit argument below */ + #ifdef __cplusplus extern "C" { #endif @@ -135,6 +137,35 @@ int psx_mod_function_entry_active(void); void psx_mod_set_guest_cycle_scale_gate(int open); /* Live scale: the configured one while every gate is open, else 1. */ uint32_t psx_mod_guest_cycle_scale(void); + +/* + * Package-gated function override (see runtime/include/func_override.h for + * the full contract). Registration queues the override under the plugin id; + * it is ARMED into the dispatcher tier only when a resolved package plan + * selects that plugin — the same gating as vblank/activation callbacks. The + * callback obeys the guest ABI (args cpu->gpr[4..7], result cpu->gpr[2]); + * return 1 = handled (guest resumes at $ra), 0 = decline (original runs). + * Wrap semantics via func_override_call_original(); optional residency + * guard via expected_words (NULL/0 = unguarded). + * + * `id` may carry an optional ":label" suffix ("pkg.feature:aim") that names + * the override in the `func_override` TCP inventory; gating and manifest + * matching use only the part before the ':', so several overrides can sit + * under one [[plugin]] entry and still read apart in diagnostics. + * + * `credit` is the REQUIRED guest-cycle policy (func_override.h, CYCLE + * ACCOUNTING): >= 0 cycles charged per handled call, or FO_CREDIT_SELF when + * the body charges its own. For a mod whose behavior has no hardware analog + * an explicit 0 is the honest value; a wrap that runs the original via + * func_override_call_original must pass FO_CREDIT_SELF (the original + * self-charges by executing — a fixed credit would double-count). + */ +typedef int (*PSXModFunctionOverrideFn)(struct CPUState* cpu); +int psx_mod_register_function_override(const char* id, uint32_t address, + PSXModFunctionOverrideFn fn, + const uint32_t* expected_words, + int n_words, int32_t credit); + /* Narrow guest services available to trusted plugin callbacks. */ int psx_mod_game_started(void); /* Read an original mounted-disc file without changing guest CD state/timing. diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index bfaa49cb..29a49786 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -502,6 +502,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/autocompile.c ${PSXRECOMP_ROOT}/runtime/src/code_provider.c ${PSXRECOMP_ROOT}/runtime/src/event_ring.c + ${PSXRECOMP_ROOT}/runtime/src/func_override.c ${PSXRECOMP_ROOT}/runtime/src/game_options.c ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_speed.c ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_pgxp.c diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 553bcc1b..3dfc9c89 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -11216,6 +11216,61 @@ static void handle_wide_full(int id, const char *json) id, path, W, H); } +/* Function-override tier introspection (func_override.h): count + per-entry + * {id, addr, calls, guard_misses}. calls == 0 = never reached (wrong address + * or path never ran); guard_misses = consults declined by the residency + * guard. */ +static void handle_func_override(int id, const char *json) +{ + extern int func_override_count(void); + extern int func_override_get_ex(int index, char *id_out, size_t id_cap, + uint32_t *addr_out, uint64_t *calls_out, + uint64_t *guard_misses_out, + int *guarded_out, int32_t *credit_out); + (void)json; + char buf[16 * 1024]; + int n = snprintf(buf, sizeof(buf), + "{\"id\":%d,\"ok\":true,\"count\":%d,\"overrides\":[", + id, func_override_count()); + for (int i = 0; i < func_override_count(); i++) { + char oid[64]; + uint32_t addr = 0; + uint64_t calls = 0, misses = 0; + int guarded = 0; + int32_t credit = 0; + char creditstr[16]; + if (!func_override_get_ex(i, oid, sizeof(oid), &addr, &calls, + &misses, &guarded, &credit)) + break; + /* credit: the declared cycle policy — a number (fixed per-call + * charge) or the string "self" (the body charges its own). */ + if (credit < 0) + snprintf(creditstr, sizeof(creditstr), "\"self\""); + else + snprintf(creditstr, sizeof(creditstr), "%d", credit); + /* Clamp before advancing: snprintf returns the length it WOULD have + * written, so on truncation an unclamped n exceeds sizeof(buf) and + * the next sizeof(buf) - n underflows to a huge size_t. The 256-byte + * headroom check below keeps that unreachable at today's caps, but + * raising FO_MAX_ID or FO_MAX_OVERRIDES must not silently turn this + * into an overflow. */ + const int w = snprintf(buf + n, sizeof(buf) - (size_t)n, + "%s{\"id\":\"%s\",\"addr\":\"0x%08X\",\"calls\":%llu," + "\"guard_misses\":%llu,\"guarded\":%d,\"credit\":%s}", + i ? "," : "", oid, addr, + (unsigned long long)calls, (unsigned long long)misses, + guarded, creditstr); + if (w < 0) break; + n += w; + if ((size_t)n >= sizeof(buf) - 256) { + n = (int)(sizeof(buf) - 256); + break; + } + } + snprintf(buf + n, sizeof(buf) - (size_t)n, "]}"); + send_fmt("%s", buf); +} + static void handle_vram_peek(int id, const char *json) { int x = json_get_int(json, "x", 0); @@ -16339,6 +16394,7 @@ static const CmdEntry s_commands[] = { { "mmx6_freshfix", handle_mmx6_freshfix }, { "mem_words", handle_mem_words }, { "vram_peek", handle_vram_peek }, + { "func_override", handle_func_override }, { "gl_coh_ring", handle_gl_coh_ring }, { "gl_present_ring", handle_gl_present_ring }, { "present_ring", handle_present_ring }, diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 0d8856fa..8ee6ec0d 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -1137,7 +1137,13 @@ enum { XSITE_INTERP = 0, XSITE_DD = 1 }; /* XOP_RES path codes (in the `site` field). */ enum { XRES_EC_BAIL = 2, XRES_EC_PC = 3, XRES_EC_CONTRACT = 4, XRES_EC_RET = 5, XRES_OV_BAIL = 6, XRES_OV_PC = 7, XRES_OV_CONTRACT = 8, XRES_OV_RET = 9, - XRES_NONLOCAL = 10, XRES_PCCHAIN = 11, XRES_UNDECODABLE = 12 }; + XRES_NONLOCAL = 10, XRES_PCCHAIN = 11, XRES_UNDECODABLE = 12, + /* Appended, NOT slotted into consult order: unlike CRES (which had a + * reserved gap at 6) this enum is dense, so inserting would renumber + * the codes above and invalidate every previously captured xprobe + * trace. The override tier is consulted FIRST at both call sites; the + * wire value carries no ordering meaning. */ + XRES_OVERRIDE = 13 }; int g_mixed_depth = 0; /* best-effort interp→compiled nesting depth; reset per frame */ @@ -1786,6 +1792,37 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, cpu->pc = target; CRET(CRES_PCCHAIN, 1); } + /* Function-override tier (func_override.h): consulted BEFORE + * every game code backend, matching psx_dispatch_impl. It must + * precede interp_enter_compiled, not follow it: that tier calls + * psx_dispatch_game_compiled -> entry->fn(cpu) directly and + * never re-enters psx_dispatch_impl, so a hook consulted after + * it is unreachable for any override on a statically-compiled + * function called from interpreted code. Registration would + * still succeed and the func_override inventory would still + * list the entry, so the miss is invisible — the same + * registration-is-not-coverage failure the overlay-native and + * pc-chain tiers below already had. Handled => the override + * completed against guest state; same call contract as a + * compiled callee. */ + { + extern int (*g_psx_func_override_hook)(CPUState *cpu, + uint32_t phys); + if (g_psx_func_override_hook) { + cpu->pc = 0; + if (g_psx_func_override_hook(cpu, target & 0x1FFFFFFFu)) { + if (g_psx_call_bail) CRET(CRES_OVERRIDE, 1); + if (cpu->pc != 0) CRET(CRES_OVERRIDE, 1); + if (rd == 0 || rd == 31) { + if (psx_call_contract(cpu, return_pc, site_sp)) + CRET(CRES_OVERRIDE, 1); + } + { int _r = dirty_ram_finish_call_return(cpu, return_pc, + next_pc_out); + CRET(CRES_OVERRIDE | (_r ? 0x100u : 0u), _r); } + } + } + } #ifdef PSX_HAS_GAME_DISPATCH cpu->pc = 0; if (interp_enter_compiled(cpu, target)) { @@ -2017,6 +2054,27 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, /* slice / lockstep-replay / guest span: plain transfer, never execute the * callee (a span's runner calls it with the full trampoline). */ if (g_precise_mode || g_ls_replay_active || span_forced(pc & 0x1FFFFFFFu)) { cpu->pc = target; return 1; } + /* Function-override tier: same placement and contract as the JALR + * site above — BEFORE interp_enter_compiled, so an override on a + * statically-compiled callee is reachable from interpreted code + * (reserved CRES_OVERRIDE slot). */ + { + extern int (*g_psx_func_override_hook)(CPUState *cpu, + uint32_t phys); + if (g_psx_func_override_hook) { + cpu->pc = 0; + if (g_psx_func_override_hook(cpu, target & 0x1FFFFFFFu)) { + if (g_psx_call_bail) { XRES(XRES_OVERRIDE); return 1; } + if (cpu->pc != 0) { XRES(XRES_OVERRIDE); return 1; } + if (psx_call_contract(cpu, return_pc, site_sp)) { + XRES(XRES_OVERRIDE); return 1; + } + XRES(XRES_OVERRIDE); + return dirty_ram_finish_call_return(cpu, return_pc, + next_pc_out); + } + } + } #ifdef PSX_HAS_GAME_DISPATCH cpu->pc = 0; if (interp_enter_compiled(cpu, target)) { diff --git a/runtime/src/func_override.c b/runtime/src/func_override.c new file mode 100644 index 00000000..9575d04d --- /dev/null +++ b/runtime/src/func_override.c @@ -0,0 +1,278 @@ +/* func_override.c — replace a guest function with hand-written C. + * See runtime/include/func_override.h. + * + * CLAUDE.md rule 3 (no printf, no logs): this module prints nothing. State + * is exposed through the accessors and surfaced by the `func_override` TCP + * command. Registration errors are returned as codes for the caller to + * report. + */ + +#include "func_override.h" + +#include + +#include "cpu_state.h" +#include "psx_cycles.h" + +/* Residency-guard reads go through the UNTRACED peek, never psx_read_word. + * psx_read_word is a traced accessor (memory.c): under lockstep it feeds + * ls_read_hook, under lockstep replay it RETURNS the replayed value instead + * of RAM, and under DuckStation recording it calls ds_note_read. The guard + * is a host-side residency check, not a guest memory access — routing it + * through the traced path injects phantom reads the oracle side never + * performs (dirtying every divergence comparison) and makes the guard + * compare replayed data rather than resident bytes. The peek keeps the full + * address decode, so a guard on a non-RAM address stays correct. */ +extern uint32_t psx_peek_word_untraced(uint32_t addr); + +/* Set by us, read at the top of every psx_dispatch_impl (emitted by + * recompiler/src/full_function_emitter.cpp) and at the interpreter's + * JAL/JALR call-resolution tiers (dirty_ram_interp.c). NULL until install, + * so a build with no overrides dispatches exactly as before. */ +int (*g_psx_func_override_hook)(CPUState *cpu, uint32_t phys) = NULL; + +typedef struct { + char id[FO_MAX_ID]; + uint32_t phys; /* normalised: KSEG bits masked off */ + FuncOverrideFn fn; + uint64_t calls; + uint64_t guard_misses; + uint32_t guard[FO_MAX_GUARD_WORDS]; + int n_guard; /* 0 = unguarded */ + int package; /* 1 = armed by a mod package plan */ + int32_t credit; /* >= 0 charged per handled call; + FO_CREDIT_SELF = body self-charges */ +} Entry; + +static Entry s_entries[FO_MAX_OVERRIDES]; +static int s_count = 0; + +/* call_original support: the address whose hook is currently executing + * (for redial) and a one-shot bypass armed by func_override_call_original. + * Dispatch is single-threaded; a nested override (an override whose + * guest_call reaches another overridden function) saves/restores around + * the inner consult, so plain statics are correct here. */ +static uint32_t s_active_phys = 0; +static uint32_t s_bypass_phys = 0; + +static uint32_t normalise(uint32_t addr) { return addr & 0x1FFFFFFFu; } + +static Entry *find(uint32_t phys) +{ + for (int i = 0; i < s_count; i++) + if (s_entries[i].phys == phys) + return &s_entries[i]; + return NULL; +} + +static int add_common(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *guard, int n_guard, int package, + int32_t credit) +{ + if (!fn) return FO_ERR_ARGS; + if (n_guard < 0 || n_guard > FO_MAX_GUARD_WORDS) return FO_ERR_ARGS; + if (n_guard > 0 && !guard) return FO_ERR_ARGS; + /* The credit is a required statement of timing intent (header, CYCLE + * ACCOUNTING): a fixed per-call charge, or FO_CREDIT_SELF. Any other + * negative value is a typo, not a policy. */ + if (credit < FO_CREDIT_SELF) return FO_ERR_ARGS; + if (s_count >= FO_MAX_OVERRIDES) return FO_ERR_FULL; + + const uint32_t phys = normalise(addr); + /* phys 0 is the "not inside an override" sentinel for s_active_phys and + * the "no bypass armed" sentinel for s_bypass_phys, so an override there + * would silently break func_override_call_original. No real function + * lives at guest 0 anyway. */ + if (phys == 0) return FO_ERR_ARGS; + /* Two overrides on one address is always a mistake, and a silent + * last-wins would be untraceable. Refuse it. */ + if (find(phys)) return FO_ERR_DUPLICATE; + + Entry *e = &s_entries[s_count]; + memset(e, 0, sizeof(*e)); + if (id) { + strncpy(e->id, id, FO_MAX_ID - 1); + e->id[FO_MAX_ID - 1] = '\0'; + } + e->phys = phys; + e->fn = fn; + for (int i = 0; i < n_guard; i++) e->guard[i] = guard[i]; + e->n_guard = n_guard; + e->package = package ? 1 : 0; + e->credit = credit; + s_count++; + return FO_OK; +} + +int func_override_add(const char *id, uint32_t addr, FuncOverrideFn fn, + int32_t credit) +{ + return add_common(id, addr, fn, NULL, 0, 0, credit); +} + +int func_override_add_guarded(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *expected_words, int n_words, + int32_t credit) +{ + if (n_words < 1) return FO_ERR_ARGS; + return add_common(id, addr, fn, expected_words, n_words, 0, credit); +} + +int func_override_add_package(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *expected_words, int n_words, + int32_t credit) +{ + if (n_words < 0 || n_words > FO_MAX_GUARD_WORDS) return FO_ERR_ARGS; + return add_common(id, addr, fn, n_words ? expected_words : NULL, n_words, + 1, credit); +} + +/* The hook. Runs on EVERY dispatch, so the common path — nothing registered + * for this address — must stay cheap. A linear scan over a handful of + * entries beats a hash for realistic counts and keeps the code honest; + * revisit if a game ever registers hundreds. */ +static int hook(CPUState *cpu, uint32_t phys) +{ + for (int i = 0; i < s_count; i++) { + Entry *e = &s_entries[i]; + if (e->phys != phys) continue; + /* One-shot bypass: func_override_call_original re-dispatched this + * address; let the original backend take it exactly once. */ + if (s_bypass_phys == phys) { + s_bypass_phys = 0; + return 0; + } + /* Residency guard: wrong bytes at the address (overlay swapped, + * page reused) means the function this override targets is not + * resident — decline, never corrupt. */ + if (e->n_guard) { + const uint32_t base = 0x80000000u | phys; + int miss = 0; + for (int w = 0; w < e->n_guard; w++) + if (psx_peek_word_untraced(base + (uint32_t)(w * 4)) != + e->guard[w]) { + miss = 1; + break; + } + if (miss) { + e->guard_misses++; + return 0; + } + } + /* Count the CONSULT, not just the handled case: an override that + * declines still proves the address was reached through a hooked + * path, which is exactly what a diagnostic probe needs (calls == 0 + * means "this call never crosses a hook", the bypass class of bug + * that hid the interp local-chain gap). */ + e->calls++; + { + const uint32_t saved_active = s_active_phys; + s_active_phys = phys; + const int handled = e->fn(cpu) ? 1 : 0; + s_active_phys = saved_active; + /* Charge the declared credit only when HANDLED: a decline runs + * the original, which accrues its exact cycles by executing. + * FO_CREDIT_SELF (< 0) and 0 charge nothing here — SELF bodies + * called psx_advance_cycles themselves, and guest work the body + * ran via func_override_guest_call / call_original has already + * self-charged through the normal backends. */ + if (handled && e->credit > 0) + psx_advance_cycles((uint32_t)e->credit); + return handled; + } + } + return 0; +} + +void func_override_guest_call(CPUState *cpu, uint32_t target, uint32_t site_ra) +{ + /* Spoof $ra to the original call site so callees, fntrace and crash + * forensics see authentic values, then restore. psx_dispatch_call runs + * the callee to completion (pc == site_ra with the caller's $sp). */ + extern void psx_dispatch_call(CPUState *cpu, uint32_t addr, + uint32_t return_addr); + const uint32_t saved_ra = cpu->gpr[31]; + cpu->gpr[31] = site_ra; + psx_dispatch_call(cpu, target, site_ra); + cpu->gpr[31] = saved_ra; +} + +void func_override_call_original(CPUState *cpu) +{ + if (!s_active_phys) return; /* not inside an override — nothing to do */ + const uint32_t phys = s_active_phys; + const uint32_t saved = s_bypass_phys; + s_bypass_phys = phys; + /* KSEG0 form: game text dispatches with the 0x8000_0000 view; the hook + * itself keys on the normalised address either way. Return to the + * override's caller frame: the original must observe the same $ra the + * override was entered with. */ + func_override_guest_call(cpu, 0x80000000u | phys, cpu->gpr[31]); + s_bypass_phys = saved; +} + +void func_override_install(void) +{ + /* Leave the hook NULL when empty: dispatch then matches a build without + * the tier exactly, which keeps "overrides cost nothing" literally + * true. */ + g_psx_func_override_hook = (s_count > 0) ? hook : NULL; +} + +int func_override_reset_package_armed(void) +{ + /* Drop every package-armed entry and compact, keeping direct + * registrations. Direct ones are the game's own always-on faithful + * reimplementations: not player-toggleable, identical in both peers' + * builds, so netplay has no reason to disarm them. Package-armed ones + * ARE player-selected, so a cleared plan must leave no trace of them — + * without this the entry survives in the table with the hook still + * installed and keeps firing after the vanilla-session banner prints. + * + * Any in-flight bypass/active marker refers to an entry that may be + * disappearing, so clear both rather than leave a dangling address. */ + int removed = 0; + int w = 0; + for (int i = 0; i < s_count; i++) { + if (s_entries[i].package) { removed++; continue; } + if (w != i) s_entries[w] = s_entries[i]; + w++; + } + s_count = w; + if (removed) { + s_active_phys = 0; + s_bypass_phys = 0; + func_override_install(); /* follows s_count back to NULL if empty */ + } + return removed; +} + +int func_override_count(void) { return s_count; } + +int func_override_get(int index, char *id_out, size_t id_cap, + uint32_t *addr_out, uint64_t *calls_out) +{ + return func_override_get_ex(index, id_out, id_cap, addr_out, calls_out, + NULL, NULL, NULL); +} + +int func_override_get_ex(int index, char *id_out, size_t id_cap, + uint32_t *addr_out, uint64_t *calls_out, + uint64_t *guard_misses_out, int *guarded_out, + int32_t *credit_out) +{ + if (index < 0 || index >= s_count) return 0; + const Entry *e = &s_entries[index]; + /* Bounded copy: the caller states its buffer size rather than being + * silently required to supply FO_MAX_ID bytes. Always NUL-terminated. */ + if (id_out && id_cap) { + strncpy(id_out, e->id, id_cap - 1); + id_out[id_cap - 1] = '\0'; + } + if (addr_out) *addr_out = e->phys; + if (calls_out) *calls_out = e->calls; + if (guard_misses_out) *guard_misses_out = e->guard_misses; + if (guarded_out) *guarded_out = e->n_guard; + if (credit_out) *credit_out = e->credit; + return 1; +} diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index fc425137..bf442910 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -133,6 +133,7 @@ extern "C" uint32_t g_guest_last_store_addr, g_guest_last_store_val; #include "mod_plugins.h" #include "mod_local_input_policy.h" #include "mod_session_baseline.h" +#include "func_override.h" #include "mod_runtime.h" #include "video_texture_filter.h" #include "video_telemetry.h" @@ -20411,6 +20412,7 @@ int main(int argc, char** argv) { netplay && PSXRecompV4::mod_runtime_netplay_view_active() ? 1 : 0); psx_mod_immediate_begin_session(netplay ? 1 : 0); mod_runtime_activate_plugins(); + func_override_install(); if (!psx_mod_immediate_session_valid()) { std::fprintf(stderr, "psxrecomp: immediate-selector enhancements are offline-only\n"); return false; diff --git a/runtime/src/memory.c b/runtime/src/memory.c index 53b3c460..2aa2dfbe 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -1687,6 +1687,16 @@ uint32_t psx_read_word(uint32_t addr) { if (!psx_get_in_exception()) ls_read_hook(addr, 4, v); return v; } +/* Untraced word peek for HOST-SIDE inspection: same address decode as a guest + * read, but no lockstep record, no lockstep-replay substitution, and no + * data-shard/DuckStation capture. Use this for anything the guest did not + * actually execute — residency guards, host-side checks, diagnostics — so a + * host inspection can never appear in a guest read stream (which would both + * dirty every divergence comparison and, under replay, return replayed data + * instead of resident bytes). Never use it to service a guest load. */ +uint32_t psx_peek_word_untraced(uint32_t addr) { + return psx_read_word_raw(addr); +} /* Physical address of a CPU/DMA main-RAM access. Fold KUSEG/KSEG0/KSEG1 first * (0x1FFFFFFF), then fold the 2nd-4th main-RAM mirrors: real hardware mirrors the * 2 MB DRAM across the WHOLE 0..0x7FFFFF physical window (PSX-SPX memorymap, diff --git a/runtime/src/mod_packages.cpp b/runtime/src/mod_packages.cpp index 3409c467..a189b6eb 100644 --- a/runtime/src/mod_packages.cpp +++ b/runtime/src/mod_packages.cpp @@ -50,6 +50,10 @@ struct RegisteredPlugin { std::vector function_entries; std::vector guest_functions; std::vector instructions; + /* Set when a function override is queued under this id (the callback + * itself lives in mod_runtime's pending list) — participates in + * availability so a manifest can gate an override-only plugin. */ + bool function_override = false; }; std::map& registered_plugins() { @@ -1535,12 +1539,20 @@ bool mod_register_function_filter_plugin(const std::string& id, uint32_t address return true; } +bool mod_register_function_override_marker(const std::string& id) { + if (!valid_id(id)) return false; + RegisteredPlugin& plugin = registered_plugins()[id]; + if (plugin.function_override) return false; + plugin.function_override = true; + return true; +} + bool mod_plugin_registered(const std::string& id) { const auto found = registered_plugins().find(id); return found != registered_plugins().end() && (found->second.activation || found->second.session_reset || found->second.vblank || found->second.savestate || !found->second.function_entries.empty() || !found->second.guest_functions.empty() || - !found->second.instructions.empty()); + !found->second.instructions.empty() || found->second.function_override); } bool mod_register_guest_function_plugin(const std::string& id, uint32_t address, diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index f44d43ab..baac2634 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -5,6 +5,7 @@ #include "disc_path.h" #include "host_file_identity.h" +#include "func_override.h" #include "iso_reader.h" #include "mod_packages.h" #include "mod_plugins.h" @@ -368,6 +369,27 @@ void build_function_entry_hooks(const RuntimeMods& s) { g_psx_mod_instruction_hooks = (uint32_t)instructions.size(); } +/* Function overrides queue here at constructor time and are ARMED into the + * func_override tier only for plugins the resolved package plan selects — + * the same gating as vblank/activation callbacks. `id` is the full + * registered name (may carry a ":label" suffix, kept for diagnostics); + * `plugin` is the manifest-facing part gating matches on. */ +struct FunctionOverridePlugin { + std::string id; + std::string plugin; + uint32_t address = 0; + PSXModFunctionOverrideFn fn = nullptr; + uint32_t guard[FO_MAX_GUARD_WORDS] = {0, 0, 0, 0}; + int n_guard = 0; + int32_t credit = 0; + bool armed = false; +}; + +std::vector& function_override_plugins() { + static std::vector value; + return value; +} + const ModPackage* selected_package(const std::string& id) { return state().manager.selected_package(id); } @@ -1663,6 +1685,20 @@ bool mod_runtime_clear_for_netplay(std::string* error) { s.disc_guard_failed = false; s.netplay_view_plan = false; s.error.clear(); + /* Clearing s.plan is enough for activation/vblank callbacks — they only + * run while something iterates the plan. Function overrides are armed + * into func_override.c's own table with the dispatcher hook installed, + * so they survive a cleared plan and keep firing unless explicitly + * disarmed. That matters most on the rematch path (main.cpp jumps to + * session_reboot, which is PAST mod_runtime_activate_plugins and + * func_override_install), where a modded session entering netplay would + * otherwise print the vanilla banner and still run its overrides — + * diverging from a peer without the mod. Also re-arm-able: dropping the + * armed flag lets a later plan register the same override again. */ + const int disarmed = func_override_reset_package_armed(); + if (disarmed > 0) + for (FunctionOverridePlugin& pending : function_override_plugins()) + pending.armed = false; if (error) error->clear(); std::fprintf(stdout, "psxrecomp: mods cleared for netplay (vanilla session)\n"); return true; @@ -2988,6 +3024,26 @@ extern "C" void mod_runtime_activate_plugins(void) { mod_invoke_activation_plugin(plugin.id); plugin_timing.success(); } + /* Arm the package-gated function overrides for plan-selected plugin + * ids, then (re)install the dispatcher hook. func_override_add refuses + * duplicate addresses; a refusal here means two active plugins claim + * one function, which the resolver should have prevented — the armed + * flag stays false and the `func_override` TCP command shows the gap. */ + for (FunctionOverridePlugin& pending : function_override_plugins()) { + if (pending.armed) continue; + const bool selected = std::any_of( + s.plan.plugins.begin(), s.plan.plugins.end(), + [&](const ModResolution::Plugin& plugin) { + return plugin.id == pending.plugin; + }); + if (!selected) continue; + const int rc = func_override_add_package( + pending.id.c_str(), pending.address, pending.fn, + pending.n_guard ? pending.guard : nullptr, pending.n_guard, + pending.credit); + pending.armed = (rc == FO_OK); + } + func_override_install(); psx_mod_immediate_end_activation(); s.activating = false; build_function_entry_hooks(s); @@ -3471,6 +3527,45 @@ static int mod_plan_hooks_parked(void) { return PSXRecompV4::state().netplay_view_plan && !psx_mod_local_view_scope(); } +extern "C" int psx_mod_register_function_override( + const char* id, uint32_t address, PSXModFunctionOverrideFn fn, + const uint32_t* expected_words, int n_words, int32_t credit) { + using namespace PSXRecompV4; + if (!id || !*id || !address || !fn) return 0; + if (n_words < 0 || n_words > FO_MAX_GUARD_WORDS) return 0; + if (n_words > 0 && !expected_words) return 0; + if (credit < FO_CREDIT_SELF) return 0; + /* An optional ":label" suffix names this override in diagnostics (the + * `func_override` TCP command) without multiplying manifest plugin ids — + * gating and resolver availability use only the part before the ':'. + * "pkg.feature:aim" and "pkg.feature:fire" are two overrides under the + * one manifest plugin "pkg.feature". */ + const char* colon = strchr(id, ':'); + const std::string plugin_id = colon ? std::string(id, colon - id) + : std::string(id); + if (plugin_id.empty() || (colon && !colon[1])) return 0; + auto& plugins = function_override_plugins(); + const auto duplicate = std::find_if( + plugins.begin(), plugins.end(), + [&](const FunctionOverridePlugin& item) { + return item.id == id && item.address == address; + }); + if (duplicate != plugins.end()) return 0; + FunctionOverridePlugin plugin; + plugin.id = id; + plugin.plugin = plugin_id; + plugin.address = address; + plugin.fn = fn; + for (int i = 0; i < n_words; ++i) plugin.guard[i] = expected_words[i]; + plugin.n_guard = n_words; + plugin.credit = credit; + plugins.push_back(plugin); + /* Mark the plugin id available to the package resolver so a manifest can + * gate an override-only plugin (multiple overrides may share one). */ + mod_register_function_override_marker(plugin_id); + return 1; +} + extern "C" int psx_mod_dispatch_guest_function(CPUState* cpu, uint32_t address) { using namespace PSXRecompV4; if (!g_psx_mod_guest_functions || !cpu || address >= 0xC0000000u) return 0; diff --git a/runtime/tests/test_func_override.c b/runtime/tests/test_func_override.c new file mode 100644 index 00000000..c48fbc38 --- /dev/null +++ b/runtime/tests/test_func_override.c @@ -0,0 +1,515 @@ +/* test_func_override.c — pins the func_override tier's contract. + * + * The tier's failure modes are all SILENT: a registration that never gets + * consulted, a package override that survives clear-mods, a guard that + * corrupts instead of declining. None of those announce themselves at + * runtime, so they need pinning here rather than field observation. + * + * The tests drive the real dispatcher entry point — g_psx_func_override_hook, + * the same pointer the generated dispatch and the interpreter call through — + * so nothing here exercises a test-only path. + */ + +#include +#include + +#include "cpu_state.h" +#include "func_override.h" + +extern int (*g_psx_func_override_hook)(CPUState *cpu, uint32_t phys); + +static int g_fail = 0; + +#define CHECK(cond, ...) \ + do { \ + if (!(cond)) { \ + printf("FAIL %s:%d: ", __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + g_fail = 1; \ + } \ + } while (0) + +/* ---- test doubles ------------------------------------------------------- */ + +/* Guest RAM the residency guard peeks at. func_override.c reads through + * psx_peek_word_untraced specifically so this stays a plain host read with no + * lockstep/oracle side effects — that is the property under test in + * guard_declines_on_mismatch, and the reason this double is trivial. */ +#define FAKE_RAM_WORDS 64 +static uint32_t s_ram[FAKE_RAM_WORDS]; + +uint32_t psx_peek_word_untraced(uint32_t addr) +{ + const uint32_t idx = (addr & 0x1FFFFFFFu) >> 2; + return (idx < FAKE_RAM_WORDS) ? s_ram[idx] : 0xDEADBEEFu; +} + +/* Cycle-core doubles: func_override.c charges fixed credits through the + * psx_advance_cycles inline (psx_cycles.h), which reads these globals. The + * doubles keep it a plain counter bump so the credit tests can assert + * against psx_cycle_count deltas directly. */ +uint64_t psx_cycle_count = 0; +uint64_t psx_next_service_cycle = 0; +int psx_in_device_service = 0; +int g_event_step_conservative = 0; +int g_ls_replay_active = 0; +uint32_t g_psx_cyc_batch = 0; +uint32_t g_psx_cyc_batch_limit = 0; +void psx_devices_service_to_now(void) {} +void psx_advance_cycles_slow(uint32_t cycles) { psx_cycle_count += cycles; } + +/* Stands in for the recompiled dispatcher. Re-consults the hook exactly as + * psx_dispatch_impl does, so func_override_call_original's one-shot bypass is + * exercised through its real mechanism rather than assumed. */ +static int s_original_runs = 0; +static int s_dispatch_depth = 0; + +/* When set, the "original" body at this address calls ITSELF once — modelling + * a self-recursive guest function, the case where the one-shot bypass must + * already have been consumed so the recursive call re-consults the override + * (what a guest-level wrap would observe). */ +static uint32_t s_recursive_original_at = 0; +static int s_recursion_budget = 0; + +void psx_dispatch_call(CPUState *cpu, uint32_t addr, uint32_t return_addr) +{ + (void)return_addr; + if (s_dispatch_depth > 8) return; /* runaway guard, not part of the contract */ + s_dispatch_depth++; + if (!g_psx_func_override_hook || + !g_psx_func_override_hook(cpu, addr & 0x1FFFFFFFu)) { + /* Nothing handled it: this is the original body running. */ + s_original_runs++; + cpu->gpr[2] = 0xAAAAu; + if (s_recursive_original_at && + (addr & 0x1FFFFFFFu) == (s_recursive_original_at & 0x1FFFFFFFu) && + s_recursion_budget > 0) { + s_recursion_budget--; + psx_dispatch_call(cpu, addr, return_addr); + } + } + s_dispatch_depth--; +} + +/* ---- override implementations ------------------------------------------- */ + +static int s_impl_calls, s_decline_calls, s_wrap_calls; + +static int impl_handles(CPUState *cpu) +{ + s_impl_calls++; + cpu->gpr[2] = 0x1234u; + return 1; +} + +static int impl_declines(CPUState *cpu) +{ + (void)cpu; + s_decline_calls++; + return 0; /* the decline-only probe idiom the TCP inventory relies on */ +} + +static int impl_wraps(CPUState *cpu) +{ + s_wrap_calls++; + func_override_call_original(cpu); + cpu->gpr[2] += 1u; /* prove we ran after the original */ + return 1; +} + +/* ---- helpers ------------------------------------------------------------ */ + +static void reset_all(void) +{ + /* No teardown for direct registrations exists by design (they are + * always-on), so each test group runs in a fresh process section by + * using distinct addresses instead of clearing. Counters do reset. */ + s_impl_calls = s_decline_calls = s_wrap_calls = 0; + s_original_runs = 0; + s_recursive_original_at = 0; + s_recursion_budget = 0; + memset(s_ram, 0, sizeof(s_ram)); +} + +static int consult(CPUState *cpu, uint32_t addr) +{ + if (!g_psx_func_override_hook) return 0; + return g_psx_func_override_hook(cpu, addr & 0x1FFFFFFFu); +} + +/* ---- tests -------------------------------------------------------------- */ + +static void test_install_is_null_until_registered(void) +{ + /* The whole "costs nothing when unused" claim rests on this: with nothing + * registered the hook pointer must stay NULL so dispatch matches a build + * without the tier. */ + func_override_install(); + CHECK(g_psx_func_override_hook == NULL, + "hook must stay NULL with nothing registered"); +} + +static void test_add_rejects_bad_input(void) +{ + CHECK(func_override_add("t.null_fn", 0x80001000u, NULL, 0) == FO_ERR_ARGS, + "NULL fn must be refused"); + /* phys 0 collides with the "not inside an override" sentinel. */ + CHECK(func_override_add("t.zero", 0x80000000u, impl_handles, 0) == FO_ERR_ARGS, + "address normalising to phys 0 must be refused"); + CHECK(func_override_add("t.zero2", 0x00000000u, impl_handles, 0) == FO_ERR_ARGS, + "raw address 0 must be refused"); + static const uint32_t g[1] = {0}; + CHECK(func_override_add_guarded("t.g0", 0x80001000u, impl_handles, g, 0, 0) + == FO_ERR_ARGS, + "guarded with n_words 0 must be refused"); + CHECK(func_override_add_guarded("t.gmax", 0x80001000u, impl_handles, g, + FO_MAX_GUARD_WORDS + 1, 0) == FO_ERR_ARGS, + "guard word count over the cap must be refused"); + CHECK(func_override_add_guarded("t.gnull", 0x80001000u, impl_handles, NULL, + 2, 0) == FO_ERR_ARGS, + "guarded with NULL words must be refused"); +} + +static void test_duplicate_address_refused(void) +{ + const uint32_t addr = 0x80002000u; + CHECK(func_override_add("t.first", addr, impl_handles, 0) == FO_OK, + "first registration must succeed"); + /* A silent last-wins here would be untraceable, which is why this is an + * error and not an overwrite. KSEG vs physical must not defeat it. */ + CHECK(func_override_add("t.second", addr, impl_declines, 0) == FO_ERR_DUPLICATE, + "same address twice must be refused"); + CHECK(func_override_add("t.second_phys", addr & 0x1FFFFFFFu, impl_declines, 0) + == FO_ERR_DUPLICATE, + "same address in physical form must also be refused"); +} + +static void test_handled_and_declined_both_count_as_consults(void) +{ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + + const uint32_t handled_at = 0x80003000u; + const uint32_t declined_at = 0x80003100u; + CHECK(func_override_add("t.handled", handled_at, impl_handles, 0) == FO_OK, "add handled"); + CHECK(func_override_add("t.declined", declined_at, impl_declines, 0) == FO_OK, "add declined"); + func_override_install(); + CHECK(g_psx_func_override_hook != NULL, "hook must install once populated"); + + CHECK(consult(&cpu, handled_at) == 1, "handled override must report handled"); + CHECK(cpu.gpr[2] == 0x1234u, "handled override must write $v0"); + CHECK(s_impl_calls == 1, "handled impl must run once"); + + CHECK(consult(&cpu, declined_at) == 0, "declining override must report not-handled"); + CHECK(s_decline_calls == 1, "declining impl must still run"); + + /* An unregistered address must not be claimed. */ + CHECK(consult(&cpu, 0x8000FF00u) == 0, "unregistered address must not be handled"); + + /* calls counts CONSULTS, so a decline-only probe proves reachability. */ + int found_handled = 0, found_declined = 0; + for (int i = 0; i < func_override_count(); i++) { + char id[FO_MAX_ID]; + uint32_t addr = 0; + uint64_t calls = 0, misses = 0; + int guarded = -1; + if (!func_override_get_ex(i, id, sizeof(id), &addr, &calls, &misses, + &guarded, NULL)) + continue; + if (addr == (handled_at & 0x1FFFFFFFu)) { + found_handled = 1; + CHECK(calls == 1, "handled entry calls==1, got %llu", + (unsigned long long)calls); + CHECK(misses == 0, "handled entry must have no guard misses"); + CHECK(guarded == 0, "handled entry is unguarded"); + CHECK(strcmp(id, "t.handled") == 0, "id round-trips, got '%s'", id); + } + if (addr == (declined_at & 0x1FFFFFFFu)) { + found_declined = 1; + CHECK(calls == 1, "DECLINED entry must still count a consult, got %llu", + (unsigned long long)calls); + } + } + CHECK(found_handled && found_declined, "both entries must be enumerable"); +} + +static void test_guard_declines_on_mismatch(void) +{ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + + /* Address inside the fake RAM window so the guard can be satisfied. */ + const uint32_t addr = 0x80000040u; /* phys 0x40 -> word index 16 */ + static const uint32_t prologue[2] = {0x27BDFFE0u, 0xAFB20018u}; + CHECK(func_override_add_guarded("t.guarded", addr, impl_handles, prologue, 2, 0) + == FO_OK, "guarded add must succeed"); + func_override_install(); + + /* Wrong code resident: must DECLINE and never run the impl. Corrupting + * here instead of declining is the failure the guard exists to prevent. */ + s_ram[16] = 0xFFFFFFFFu; + s_ram[17] = 0xFFFFFFFFu; + CHECK(consult(&cpu, addr) == 0, "guard mismatch must decline"); + CHECK(s_impl_calls == 0, "guard mismatch must NOT run the override body"); + + /* Right code resident: must fire. */ + s_ram[16] = prologue[0]; + s_ram[17] = prologue[1]; + CHECK(consult(&cpu, addr) == 1, "guard match must handle"); + CHECK(s_impl_calls == 1, "guard match must run the override body once"); + + /* Partial match is still a miss. */ + s_ram[17] = 0u; + CHECK(consult(&cpu, addr) == 0, "partial guard match must decline"); + CHECK(s_impl_calls == 1, "partial match must not run the body"); + + for (int i = 0; i < func_override_count(); i++) { + char id[FO_MAX_ID]; + uint32_t a = 0; + uint64_t calls = 0, misses = 0; + int guarded = 0; + if (!func_override_get_ex(i, id, sizeof(id), &a, &calls, &misses, + &guarded, NULL)) + continue; + if (a != (addr & 0x1FFFFFFFu)) continue; + CHECK(guarded == 2, "guard word count must be reported, got %d", guarded); + CHECK(misses == 2, "two guard misses expected, got %llu", + (unsigned long long)misses); + } +} + +static void test_call_original_is_one_shot(void) +{ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + + const uint32_t addr = 0x80004000u; + CHECK(func_override_add("t.wrap", addr, impl_wraps, FO_CREDIT_SELF) == FO_OK, "add wrap"); + func_override_install(); + + CHECK(consult(&cpu, addr) == 1, "wrap must handle"); + CHECK(s_wrap_calls == 1, "wrap body runs once"); + /* The bypass must let the ORIGINAL through exactly once — not zero times + * (wrap becomes replace) and not unboundedly (infinite re-entry). */ + CHECK(s_original_runs == 1, "original must run exactly once, ran %d", + s_original_runs); + CHECK(cpu.gpr[2] == 0xAAAAu + 1u, + "wrap must observe then adjust the original's $v0, got 0x%X", + cpu.gpr[2]); + + /* Bypass must not persist: a second call re-consults the override. */ + CHECK(consult(&cpu, addr) == 1, "second call must still be handled"); + CHECK(s_wrap_calls == 2, "wrap body runs again on the second call"); + CHECK(s_original_runs == 2, "original runs once more, total %d", + s_original_runs); +} + +static void test_bypass_is_consumed_so_recursion_reconsults(void) +{ + /* The header promises: "if the original recursively calls itself, the + * recursive calls consult the override again." That requires the one-shot + * bypass to be CONSUMED (cleared) at the moment it is honoured, not merely + * restored when call_original returns — restoring alone looks correct for + * a non-recursive wrap and hides the bug. */ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + + const uint32_t addr = 0x80004100u; + CHECK(func_override_add("t.wrap_rec", addr, impl_wraps, FO_CREDIT_SELF) == FO_OK, + "add recursive wrap"); + func_override_install(); + + s_recursive_original_at = addr; + s_recursion_budget = 1; /* the original self-calls exactly once */ + + CHECK(consult(&cpu, addr) == 1, "recursive wrap must handle"); + + /* Outer override runs, calls the original; the original self-calls, and + * that inner call must land on the OVERRIDE again (wrap body twice), not + * on the original a second time. */ + CHECK(s_wrap_calls == 2, + "override must be re-consulted on the original's self-call, wrap ran %d", + s_wrap_calls); + CHECK(s_original_runs == 2, + "each override invocation runs the original once, original ran %d", + s_original_runs); +} + +static void test_call_original_outside_override_is_a_noop(void) +{ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + func_override_call_original(&cpu); /* must not dispatch anything */ + CHECK(s_original_runs == 0, + "call_original outside an override must do nothing"); +} + +static void test_package_reset_drops_only_package_entries(void) +{ + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + reset_all(); + + const uint32_t direct_at = 0x80005000u; + const uint32_t pkg_at = 0x80005100u; + CHECK(func_override_add("t.direct", direct_at, impl_handles, 0) == FO_OK, + "direct add"); + CHECK(func_override_add_package("pkg.feature:a", pkg_at, impl_handles, NULL, 0, 0) + == FO_OK, "package add"); + func_override_install(); + + CHECK(consult(&cpu, direct_at) == 1, "direct override armed"); + CHECK(consult(&cpu, pkg_at) == 1, "package override armed"); + + const int before = func_override_count(); + const int dropped = func_override_reset_package_armed(); + CHECK(dropped >= 1, "reset must report dropping the package entry"); + CHECK(func_override_count() == before - dropped, + "count must shrink by exactly the dropped entries"); + + /* This is the netplay-divergence regression: after clear-mods the package + * override must be gone, while the game's own always-on reimplementation + * survives. */ + CHECK(consult(&cpu, pkg_at) == 0, + "package override must NOT fire after reset (netplay divergence)"); + CHECK(consult(&cpu, direct_at) == 1, + "direct override must survive reset"); + + /* Compaction must not corrupt surviving entries. */ + int seen_direct = 0; + for (int i = 0; i < func_override_count(); i++) { + char id[FO_MAX_ID]; + uint32_t a = 0; + uint64_t calls = 0; + if (!func_override_get_ex(i, id, sizeof(id), &a, &calls, NULL, NULL, + NULL)) + continue; + if (a == (direct_at & 0x1FFFFFFFu)) { + seen_direct = 1; + CHECK(strcmp(id, "t.direct") == 0, + "surviving id intact after compaction, got '%s'", id); + } + CHECK(a != (pkg_at & 0x1FFFFFFFu), + "dropped package entry must not remain enumerable"); + } + CHECK(seen_direct, "direct entry must remain enumerable"); + + /* The address is free again, so a later plan can re-arm it. */ + CHECK(func_override_add_package("pkg.feature:a", pkg_at, impl_handles, NULL, 0, 0) + == FO_OK, "re-arming after reset must succeed"); + CHECK(func_override_reset_package_armed() >= 1, "and drop again"); +} + +static void test_get_ex_bounded_id_copy(void) +{ + /* The id buffer size is the caller's to state; a short buffer must + * truncate with termination, never overrun. */ + int checked = 0; + for (int i = 0; i < func_override_count(); i++) { + char small[4]; + uint32_t a = 0; + memset(small, 0x7F, sizeof(small)); + if (!func_override_get_ex(i, small, sizeof(small), &a, NULL, NULL, NULL, + NULL)) + continue; + CHECK(small[sizeof(small) - 1] == '\0', + "short id buffer must be NUL-terminated"); + CHECK(strlen(small) <= sizeof(small) - 1, "short id must be truncated"); + checked = 1; + } + CHECK(checked, "expected at least one entry to enumerate"); + + CHECK(func_override_get_ex(-1, NULL, 0, NULL, NULL, NULL, NULL, NULL) == 0, + "negative index must fail"); + CHECK(func_override_get_ex(func_override_count(), NULL, 0, NULL, NULL, NULL, + NULL, NULL) == 0, + "out-of-range index must fail"); +} + +static void test_credit_policy(void) +{ + reset_all(); + CPUState cpu; + memset(&cpu, 0, sizeof(cpu)); + + /* The credit is a required statement: FO_CREDIT_SELF or >= 0. Any other + * negative value is a typo, not a policy. */ + CHECK(func_override_add("t.badcredit", 0x80006000u, impl_handles, -2) + == FO_ERR_ARGS, + "credit below FO_CREDIT_SELF must be refused"); + + CHECK(func_override_add("t.credit40", 0x80006100u, impl_handles, 40) + == FO_OK, "fixed-credit add"); + CHECK(func_override_add("t.credit40d", 0x80006200u, impl_declines, 40) + == FO_OK, "fixed-credit decline probe add"); + CHECK(func_override_add("t.creditself", 0x80006300u, impl_handles, + FO_CREDIT_SELF) + == FO_OK, "self-credit add"); + CHECK(func_override_add("t.credit0", 0x80006400u, impl_handles, 0) + == FO_OK, "zero-credit add"); + func_override_install(); + + /* A handled call charges exactly the declared credit... */ + uint64_t before = psx_cycle_count; + CHECK(consult(&cpu, 0x80006100u) == 1, "fixed-credit override handles"); + CHECK(psx_cycle_count - before == 40u, + "handled call must charge the declared credit"); + + /* ...a DECLINE charges nothing (the original runs and self-charges + through the normal backends, so a tier-side charge would double). */ + before = psx_cycle_count; + CHECK(consult(&cpu, 0x80006200u) == 0, "decline probe declines"); + CHECK(psx_cycle_count == before, "a decline must charge nothing"); + + /* FO_CREDIT_SELF and 0 charge nothing from the tier. */ + before = psx_cycle_count; + CHECK(consult(&cpu, 0x80006300u) == 1, "self-credit override handles"); + CHECK(psx_cycle_count == before, "SELF must not be charged by the tier"); + before = psx_cycle_count; + CHECK(consult(&cpu, 0x80006400u) == 1, "zero-credit override handles"); + CHECK(psx_cycle_count == before, "credit 0 must charge nothing"); + + /* The declared policy is inspectable, not just documented. */ + int seen40 = 0, seenself = 0; + for (int i = 0; i < func_override_count(); i++) { + char id[FO_MAX_ID]; + uint32_t a = 0; + int32_t credit = -99; + if (!func_override_get_ex(i, id, sizeof(id), &a, NULL, NULL, NULL, + &credit)) + continue; + if (a == 0x00006100u) { seen40 = (credit == 40); } + if (a == 0x00006300u) { seenself = (credit == FO_CREDIT_SELF); } + } + CHECK(seen40, "get_ex must report the fixed credit"); + CHECK(seenself, "get_ex must report FO_CREDIT_SELF"); +} + +int main(void) +{ + test_install_is_null_until_registered(); + test_add_rejects_bad_input(); + test_duplicate_address_refused(); + test_handled_and_declined_both_count_as_consults(); + test_guard_declines_on_mismatch(); + test_call_original_is_one_shot(); + test_bypass_is_consumed_so_recursion_reconsults(); + test_call_original_outside_override_is_a_noop(); + test_package_reset_drops_only_package_entries(); + test_get_ex_bounded_id_copy(); + test_credit_policy(); + + if (g_fail) { + printf("func_override: FAILURES\n"); + return 1; + } + printf("func_override: all checks passed (%d entries registered)\n", + func_override_count()); + return 0; +} diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index bd095287..bdf564a9 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -153,6 +153,23 @@ extern "C" void gpu_get_display_info(GpuDisplayInfo* out) { *out = g_test_display; } +/* func_override.c doubles: linked in so mod_runtime's package arming/reset + * paths run for real. The tier's own behavior is pinned by + * test_func_override.c; here the doubles only need to satisfy the link. */ +extern "C" uint32_t psx_peek_word_untraced(uint32_t) { return 0; } +extern "C" void psx_dispatch_call(struct CPUState*, uint32_t, uint32_t) {} +extern "C" { +uint64_t psx_cycle_count = 0; +uint64_t psx_next_service_cycle = 0; +int psx_in_device_service = 0; +int g_event_step_conservative = 0; +int g_ls_replay_active = 0; +uint32_t g_psx_cyc_batch = 0; +uint32_t g_psx_cyc_batch_limit = 0; +void psx_devices_service_to_now(void) {} +void psx_advance_cycles_slow(uint32_t cycles) { psx_cycle_count += cycles; } +} + extern "C" void dirty_ram_mark_executable_range(uint32_t, uint32_t) {} static int test_game_started = 1; extern "C" int fntrace_is_game_started(void) { return test_game_started; } From e706b9d84fd0f716515bd075e4c02e9f420e7b82 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 09:26:47 -0700 Subject: [PATCH 05/22] Publish coherent postfx projection and escape bounded diagnostic JSON --- runtime/CMakeLists.txt | 3 ++ runtime/include/gte_postfx_projection.h | 12 ++++++ .../include/gte_projection_publication.hpp | 31 +++++++++++++ runtime/include/postfx_debug_json.h | 43 +++++++++++++++++++ runtime/src/debug_server.c | 4 +- runtime/src/gl_postfx.inc | 34 +++++++-------- runtime/src/gte.cpp | 9 +++- runtime/tests/test_postfx_publication.cpp | 40 +++++++++++++++++ 8 files changed, 156 insertions(+), 20 deletions(-) create mode 100644 runtime/include/gte_postfx_projection.h create mode 100644 runtime/include/gte_projection_publication.hpp create mode 100644 runtime/include/postfx_debug_json.h create mode 100644 runtime/tests/test_postfx_publication.cpp diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 94028664..6e2d73f6 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -23,6 +23,9 @@ psxrecomp_add_runtime_target(psx-runtime # (docs/ENHANCEMENTS.md G1.10). Titles get their _pgxp the same way. if(BUILD_TESTING) + add_executable(postfx_publication_test tests/test_postfx_publication.cpp) + target_include_directories(postfx_publication_test PRIVATE include) + add_test(NAME postfx_publication_test COMMAND postfx_publication_test) # Windows UTF-8 text boundary (issue #371): the helpers legacy Windows # relies on, and — with the shipped manifest — a UTF-8 process code page. if(WIN32) diff --git a/runtime/include/gte_postfx_projection.h b/runtime/include/gte_postfx_projection.h new file mode 100644 index 00000000..1ff42d9f --- /dev/null +++ b/runtime/include/gte_postfx_projection.h @@ -0,0 +1,12 @@ +#pragma once +#include +#ifdef __cplusplus +extern "C" { +#endif +typedef struct GtePostfxProjection { int32_t h, ofx, ofy; } GtePostfxProjection; +/* Coherent latest CPU projection, not a frame/view identity. One CPU producer. */ +void gte_postfx_projection_enable(int enabled); +int gte_postfx_projection_read(GtePostfxProjection *out); +#ifdef __cplusplus +} +#endif diff --git a/runtime/include/gte_projection_publication.hpp b/runtime/include/gte_projection_publication.hpp new file mode 100644 index 00000000..b51907bd --- /dev/null +++ b/runtime/include/gte_projection_publication.hpp @@ -0,0 +1,31 @@ +#pragma once +#include "gte_postfx_projection.h" +#include +/* CPU owns cached fields; renderer owns enable(). Atomic payloads plus an SC + * version guard make concurrent reads defined and reject mixed tuples. */ +class GteProjectionPublication { + std::atomic control{0}, version{0}, published{0}; + std::atomic h{0}, x{0}, y{0}; + uint32_t seen=0; GtePostfxProjection cached{}; +public: + void enable(bool on) { + const auto c=control.load(); + if(bool(c&1)!=on) control.store(((c+2)&~1u)|uint32_t(on)); + } + void publish(int32_t ph,int32_t px,int32_t py) { + const auto c=control.load(std::memory_order_relaxed); + if(!(c&1))return; + if(c==seen && ph==cached.h && px==cached.ofx && py==cached.ofy)return; + version.fetch_add(1); h.store(ph); x.store(px); y.store(py); published.store(c); + version.fetch_add(1); seen=c; cached={ph,px,py}; + } + bool read(GtePostfxProjection& out) const { + const auto c=control.load(); if(!(c&1))return false; + for(unsigned i=0;i<3;++i) { + const auto v=version.load(); if(v&1)continue; + const auto p=published.load(); GtePostfxProjection value{h.load(),x.load(),y.load()}; + if(v==version.load() && p==c && c==control.load()) {out=value;return true;} + } + return false; + } +}; diff --git a/runtime/include/postfx_debug_json.h b/runtime/include/postfx_debug_json.h new file mode 100644 index 00000000..e63f0e28 --- /dev/null +++ b/runtime/include/postfx_debug_json.h @@ -0,0 +1,43 @@ +#pragma once +#include +#include +#include +typedef struct PfxDebugLive { char name[32]; float v[4]; } PfxDebugLive; +typedef struct PfxJsonWriter { char *out; size_t cap,used; int ok; } PfxJsonWriter; +static inline void pfx_json_raw(PfxJsonWriter *w,const char *s) { + const size_t n=strlen(s); + if(!w->ok || n>=w->cap-w->used){w->ok=0;return;} + memcpy(w->out+w->used,s,n+1);w->used+=n; +} +static inline void pfx_json_quote(PfxJsonWriter *w,const char *s) { + pfx_json_raw(w,"\""); + for(const unsigned char *p=(const unsigned char*)s;*p;++p) { + char b[7]; + if(*p=='"'||*p=='\\'){b[0]='\\';b[1]=(char)*p;b[2]=0;} + else if(*p<32)snprintf(b,sizeof b,"\\u%04x",(unsigned)*p); + else {b[0]=(char)*p;b[1]=0;} + pfx_json_raw(w,b); + } + pfx_json_raw(w,"\""); +} +/* Returns 0 with an empty buffer on insufficient space, never partial JSON. */ +static inline int pfx_debug_json(char *out,int cap,const char *path,const char *status, + const PfxDebugLive *live,int count) { + if(!out||cap<=0)return 0; + out[0]=0;PfxJsonWriter w={out,(size_t)cap,0,1}; + pfx_json_raw(&w,"\"path\":");pfx_json_quote(&w,path); + pfx_json_raw(&w,",\"status\":");pfx_json_quote(&w,status); + pfx_json_raw(&w,",\"uniforms\":{"); + for(int i=0;i= PFX_NAME) return; + if (!name || !name[0] || strlen(name) >= sizeof s_pfx_live[0].name || + !isfinite(x) || !isfinite(y) || !isfinite(z) || !isfinite(w)) return; SDL_LockSpinlock(&s_pfx_live_lock); int i = 0; while (i < s_pfx_nlive && strcmp(s_pfx_live[i].name, name)) i++; if (i == s_pfx_nlive && s_pfx_nlive < PFX_LIVE_MAX) { - snprintf(s_pfx_live[i].name, PFX_NAME, "%s", name); + snprintf(s_pfx_live[i].name, sizeof s_pfx_live[i].name, "%s", name); s_pfx_nlive++; } if (i < s_pfx_nlive) { @@ -123,7 +126,7 @@ static int pfx_live_get(const char *name, float out[4]) { SDL_UnlockSpinlock(&s_pfx_live_lock); return found; } -extern volatile int32_t g_gte_last_h, g_gte_last_ofx, g_gte_last_ofy; /* gte.cpp */ + static int s_mask_w = 0, s_mask_h = 0; static int pfx_stage_on(int i) { @@ -161,6 +164,7 @@ static void pfx_stage_unload(PfxStage *st) { /* Which stages read the mask decides whether draws keep it. */ static void pfx_mask_wanted_update(void) { + gte_postfx_projection_enable(postfx_active()); int want = 0; for (int i = 0; i < PFX_NSTAGES; i++) { if (!pfx_stage_on(i)) continue; @@ -544,8 +548,10 @@ static int pfx_stage_run(PfxStage *st, int generated, int lx, int ly, int lw, in /* u_proj: x/z = (uv.x - y) * w * x, y/z = (uv.y - z) * x for a view-space * point at depth sz (240-line reference, same scale on both axes). */ const float aspect = (float)lw / (float)(lh > 0 ? lh : 1); - const float H = g_gte_last_h > 0 ? (float)g_gte_last_h : 256.0f; - const float ofx = (float)g_gte_last_ofx / 65536.0f, ofy = (float)g_gte_last_ofy / 65536.0f; + GtePostfxProjection projection = {256, 160 * 65536, 120 * 65536}; + (void)gte_postfx_projection_read(&projection); + const float H = projection.h > 0 ? (float)projection.h : 256.0f; + const float ofx = (float)projection.ofx / 65536.0f, ofy = (float)projection.ofy / 65536.0f; p_glUniform4f(L->proj, 240.0f / H, 0.5f + (ofx - 160.0f) / (240.0f * aspect), 1.0f - (ofy > 0.0f ? ofy : 120.0f) / 240.0f, aspect); } @@ -684,6 +690,7 @@ static void postfx_present(int fresh, int with_aa, int lx, int ly, int lw, int l /* Objects died with the context. */ static void postfx_release(void) { + gte_postfx_projection_enable(0); for (int i = 0; i < PFX_NSTAGES; i++) { for (int k = 0; k < PFX_MAX_PASSES; k++) { s_pfx[i].prog[k] = s_pfx[i].tex[k] = s_pfx[i].fbo[k] = 0; @@ -725,19 +732,12 @@ void gl_renderer_set_post_shader(const char *path) { } /* Debug: the user chain's last load outcome and the live uniforms, as JSON members. */ int gl_renderer_post_shader_debug(char *out, int cap) { - char chemin[sizeof s_pfx_user_path]; - snprintf(chemin, sizeof chemin, "%s", s_pfx_user_path); - for (char *q = chemin; *q; q++) if (*q == 92 || *q == 34) *q = '/'; /* JSON-safe */ - int k = snprintf(out, (size_t)cap, "\"path\":\"%s\",\"status\":\"%s\",\"uniforms\":{", - chemin, s_pfx_user_status); + GL_RT_SYNC("post_shader_debug"); + PfxLive live[PFX_LIVE_MAX]; int count; SDL_LockSpinlock(&s_pfx_live_lock); - for (int i = 0; i < s_pfx_nlive && k < cap - 96; i++) - k += snprintf(out + k, (size_t)(cap - k), "%s\"%s\":[%.4f,%.4f,%.4f,%.4f]", i ? "," : "", - s_pfx_live[i].name, s_pfx_live[i].v[0], s_pfx_live[i].v[1], s_pfx_live[i].v[2], - s_pfx_live[i].v[3]); + count = s_pfx_nlive; memcpy(live,s_pfx_live,sizeof live); SDL_UnlockSpinlock(&s_pfx_live_lock); - if (k < cap - 2) k += snprintf(out + k, (size_t)(cap - k), "}"); - return k; + return pfx_debug_json(out,cap,s_pfx_user_path,s_pfx_user_status,live,count); } int gl_renderer_post_shader_loaded(void) { diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index fce93bbf..5d60ef36 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -1,5 +1,6 @@ #include #include "gte.h" +#include "gte_projection_publication.hpp" #include "gte_view.h" #include "cpu_state.h" #include "execution_profile.h" @@ -21,7 +22,11 @@ extern "C" int gpu_ws_precise_nclip_enabled(void); extern "C" int psx_overlay_shadow_diff_active(void); extern "C" uint64_t s_frame_count; /* debug_server.c */ /* Latest projection (H, OFX, OFY in 16.16), read by the post-effect executor. */ -extern "C" { volatile int32_t g_gte_last_h = 0, g_gte_last_ofx = 0, g_gte_last_ofy = 0; } +static GteProjectionPublication s_postfx_projection; +extern "C" void gte_postfx_projection_enable(int enabled) { s_postfx_projection.enable(enabled!=0); } +extern "C" int gte_postfx_projection_read(GtePostfxProjection* out) { + return out && s_postfx_projection.read(*out); +} extern "C" uint32_t g_debug_last_store_pc; /* debug_server.c */ extern "C" { int (*g_psx_projection_command)(CPUState*, uint32_t) = nullptr; } @@ -1209,7 +1214,7 @@ void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0, uint32_t instr) } dome_probe_note(gte->SZ[3]); /* locate the dome draw fn (far-vertex tally) */ if (!s_gte_replay_sandbox) { /* projection of the latest 3D draw, for post effects (u_proj) */ - g_gte_last_h = gte->H; g_gte_last_ofx = gte->OFX; g_gte_last_ofy = gte->OFY; + s_postfx_projection.publish(gte->H,gte->OFX,gte->OFY); } int64_t sx16 = gte->OFX + xterm; int64_t sy16 = gte->OFY + (int64_t)gte->IR2 * h_div_sz; diff --git a/runtime/tests/test_postfx_publication.cpp b/runtime/tests/test_postfx_publication.cpp new file mode 100644 index 00000000..5c55b20c --- /dev/null +++ b/runtime/tests/test_postfx_publication.cpp @@ -0,0 +1,40 @@ +#include "gte_projection_publication.hpp" +#include "postfx_debug_json.h" +#include +#include +#include +#include +#include +static int failures; +static void check(bool ok,const char* why) { + if(!ok){std::fprintf(stderr,"%s\n",why);++failures;} +} +int main() { + GteProjectionPublication state; GtePostfxProjection out{}; + state.publish(1,2,3); check(!state.read(out),"disabled projection is unavailable"); + state.enable(true); state.publish(1,2,3); + check(state.read(out)&&out.h==1&&out.ofx==2&&out.ofy==3,"first enabled tuple"); + state.enable(false);state.enable(true); + check(!state.read(out),"re-enabled session cannot reuse a stale tuple"); + state.publish(1,2,3);check(state.read(out),"unchanged values publish in the new epoch"); + std::atomic done{false},bad{false}; + std::thread writer([&]{for(int i=1;i<200000;++i)state.publish(i,i*2,i*3);done=true;}); + auto read=[&]{while(!done.load()){GtePostfxProjection p{}; + if(state.read(p)&&(p.ofx!=p.h*2||p.ofy!=p.h*3))bad=true;}}; + std::thread a(read),b(read);writer.join();a.join();b.join(); + check(!bad,"concurrent readers never see mixed projection fields"); + PfxDebugLive live{};std::strcpy(live.name,"u_\"x\n");live.v[0]=1.25f; + char json[512];check(pfx_debug_json(json,sizeof json,"C:\\a\"b\n","bad\tpreset",&live,1)>0,"serialize escaped diagnostics"); + check(std::strstr(json,R"("path":"C:\\a\"b\u000a")")!=nullptr,"path round trips without mutation"); + check(std::strstr(json,R"("u_\"x\u000a":[1.25,0,0,0])")!=nullptr,"uniform names are escaped"); + std::printf("{%s}\n",json); + for(int cap=0;cap<160;++cap) { + char guarded[192];std::memset(guarded,'X',sizeof guarded); + const int n=pfx_debug_json(guarded+1,cap,"C:\\a\"b\n","bad\tpreset",&live,1); + check(guarded[0]=='X'&&guarded[cap+1]=='X',"small output buffer stays within bounds"); + if(!n&&cap)check(guarded[1]==0,"failed JSON output is empty"); + } + live.v[0]=std::numeric_limits::infinity(); + check(!pfx_debug_json(json,sizeof json,"","",&live,1)&&!json[0],"nonfinite values fail without invalid JSON"); + return failures?1:0; +} From 56b6839602873d8cccd93248540ee8a7050195fe Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 09:31:11 -0700 Subject: [PATCH 06/22] Reject partial function override activation before session callbacks --- docs/MOD_PACKAGES.md | 7 ++++ docs/TCP_COMMANDS.md | 5 +-- runtime/include/mod_runtime.h | 2 ++ runtime/src/main.cpp | 4 +++ runtime/src/mod_runtime.cpp | 55 +++++++++++++++++------------- runtime/tests/test_mod_runtime.cpp | 49 ++++++++++++++++++++++++++ 6 files changed, 97 insertions(+), 25 deletions(-) diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 6a75927f..e8ca6019 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -1182,3 +1182,10 @@ existing post-commit path, not the preparation worker. The `prepare_resources` method is separate from read-only `resolve`, so editing launcher settings does not trigger conversions. Runtime activation still sees only fully verified immutable resources. + +Function-override package arming is transactional and runs before activation +callbacks. Address collisions or capacity failures reject the launch through +`mod_runtime_activation_error()` and remove all package entries, retaining +always-on direct implementations. Every session rearms its selected plan. +Replacement overrides cannot be selected as netplay local-view plugins: they +change shared execution rather than only sandboxed presentation. diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index c81a2eeb..9a686e77 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -493,9 +493,9 @@ between Play and TCP availability. ## Complete command index (generated) -**386 commands registered** — 373 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**387 commands registered** — 374 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -85 of 386 have prose above; **301 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +85 of 387 have prose above; **302 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. @@ -630,6 +630,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `frame_trace` | ✓ | | | | `frame_wall_ring` | ✓ | | | | `freeze_check` | ✓ | | | +| `func_override` | ✓ | | | | `game_options` | ✓ | | | | `gcp_ring` | ✓ | | ✓ | | `geom_correction` | ✓ | | ✓ | diff --git a/runtime/include/mod_runtime.h b/runtime/include/mod_runtime.h index f6af2899..52d60175 100644 --- a/runtime/include/mod_runtime.h +++ b/runtime/include/mod_runtime.h @@ -113,6 +113,8 @@ void mod_runtime_set_progress_hook(void (*hook)(void)); /* Invokes activation callbacks for the committed plan. Call after the final * launcher commit and before renderer/window initialization. */ void mod_runtime_activate_plugins(void); +/* NULL on success; a non-NULL arming failure requires refusing this launch. */ +const char* mod_runtime_activation_error(void); void mod_runtime_on_vblank(void); /* Host VBlank query for the active title's optional disc-request provider. */ int mod_runtime_disc_request(int *ready); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index bf442910..9ecd7977 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -20412,6 +20412,10 @@ int main(int argc, char** argv) { netplay && PSXRecompV4::mod_runtime_netplay_view_active() ? 1 : 0); psx_mod_immediate_begin_session(netplay ? 1 : 0); mod_runtime_activate_plugins(); + if (const char* error = mod_runtime_activation_error()) { + std::fprintf(stderr,"psxrecomp: %s\n",error); + return false; + } func_override_install(); if (!psx_mod_immediate_session_valid()) { std::fprintf(stderr, "psxrecomp: immediate-selector enhancements are offline-only\n"); diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index baac2634..b6b7bf8d 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1696,8 +1696,8 @@ bool mod_runtime_clear_for_netplay(std::string* error) { * diverging from a peer without the mod. Also re-arm-able: dropping the * armed flag lets a later plan register the same override again. */ const int disarmed = func_override_reset_package_armed(); - if (disarmed > 0) - for (FunctionOverridePlugin& pending : function_override_plugins()) + (void)disarmed; + for (FunctionOverridePlugin& pending : function_override_plugins()) pending.armed = false; if (error) error->clear(); std::fprintf(stdout, "psxrecomp: mods cleared for netplay (vanilla session)\n"); @@ -2998,6 +2998,11 @@ bool mod_runtime_read_disc_file_sectors(const std::string& path, uint32_t max_by } } // namespace PSXRecompV4 +static std::string s_override_activation_error; +extern "C" const char* mod_runtime_activation_error(void) { + return s_override_activation_error.empty() ? nullptr : s_override_activation_error.c_str(); +} + extern "C" void mod_runtime_activate_plugins(void) { using namespace PSXRecompV4; HostLaunchTimingScope timing(HOST_LAUNCH_PLUGIN_ACTIVATION); @@ -3005,6 +3010,9 @@ extern "C" void mod_runtime_activate_plugins(void) { psx_host_set_hud_draw_cb(nullptr, nullptr); mod_reset_plugin_sessions(); RuntimeMods& s = state(); + s_override_activation_error.clear(); + func_override_reset_package_armed(); + for (auto& pending : function_override_plugins()) pending.armed = false; s.ticket_ready = false; psx_mod_set_disc_request_provider(nullptr); psx_projection_reset_session(); @@ -3015,6 +3023,27 @@ extern "C" void mod_runtime_activate_plugins(void) { s.disc_extents.clear(); s.audio_tracks.clear(); s.extent_start = 0; + /* Arm the entire tier before activation callbacks change host state. */ + for (auto& pending : function_override_plugins()) { + const bool selected = std::any_of(s.plan.plugins.begin(),s.plan.plugins.end(), + [&](const ModResolution::Plugin& p) { return p.id == pending.plugin; }); + if (!selected) continue; + const int rc = s.netplay_view_plan ? FO_ERR_ARGS : func_override_add_package( + pending.id.c_str(),pending.address,pending.fn, + pending.n_guard ? pending.guard : nullptr,pending.n_guard,pending.credit); + if (rc != FO_OK) { + s_override_activation_error = "Cannot arm function override " + pending.id + + " (registration error " + std::to_string(rc) + ")"; + s.error = s_override_activation_error; + s.plan.ok = false; + func_override_reset_package_armed(); + for (auto& item : function_override_plugins()) item.armed = false; + clear_function_entry_hooks(); + return; + } + pending.armed = true; + } + func_override_install(); s.activating = true; psx_mod_immediate_begin_activation(); for (const ModResolution::Plugin& plugin : s.plan.plugins) { @@ -3024,26 +3053,6 @@ extern "C" void mod_runtime_activate_plugins(void) { mod_invoke_activation_plugin(plugin.id); plugin_timing.success(); } - /* Arm the package-gated function overrides for plan-selected plugin - * ids, then (re)install the dispatcher hook. func_override_add refuses - * duplicate addresses; a refusal here means two active plugins claim - * one function, which the resolver should have prevented — the armed - * flag stays false and the `func_override` TCP command shows the gap. */ - for (FunctionOverridePlugin& pending : function_override_plugins()) { - if (pending.armed) continue; - const bool selected = std::any_of( - s.plan.plugins.begin(), s.plan.plugins.end(), - [&](const ModResolution::Plugin& plugin) { - return plugin.id == pending.plugin; - }); - if (!selected) continue; - const int rc = func_override_add_package( - pending.id.c_str(), pending.address, pending.fn, - pending.n_guard ? pending.guard : nullptr, pending.n_guard, - pending.credit); - pending.armed = (rc == FO_OK); - } - func_override_install(); psx_mod_immediate_end_activation(); s.activating = false; build_function_entry_hooks(s); @@ -3531,7 +3540,7 @@ extern "C" int psx_mod_register_function_override( const char* id, uint32_t address, PSXModFunctionOverrideFn fn, const uint32_t* expected_words, int n_words, int32_t credit) { using namespace PSXRecompV4; - if (!id || !*id || !address || !fn) return 0; + if (!id || !*id || !(address & 0x1fffffffu) || (address & 3u) || !fn) return 0; if (n_words < 0 || n_words > FO_MAX_GUARD_WORDS) return 0; if (n_words > 0 && !expected_words) return 0; if (credit < FO_CREDIT_SELF) return 0; diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index bdf564a9..33a55313 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -1,4 +1,5 @@ #include "mod_runtime.h" +#include "func_override.h" #include "mod_netplay.h" #include "host_launch_timing.h" #include "host_overlay.h" @@ -32,6 +33,9 @@ namespace fs = std::filesystem; static std::array ram; static int failures; static int activation_calls; +static int override_activation_calls; +static void override_activate() { ++override_activation_calls; } +static int override_body(CPUState*) { return 1; } static int plugin_calls; static int restore_calls; static int option_renderer_activations; @@ -540,6 +544,7 @@ int main(int argc, char** argv) { const fs::path root = fs::temp_directory_path() / "psxrecomp-mod-runtime-test"; #endif std::error_code ec; + fs::remove_all(root, ec); const std::vector stock(8 * 2352, 0); std::vector overlay(3000); @@ -2439,6 +2444,50 @@ int main(int argc, char** argv) { "resolver preserves missing selection for existing downstream validation"); } + { + const auto base = root/"function-overrides"; + write_text(base/"bundled/override.test/1.0.0/manifest.toml", R"toml( +format_version = 5 +id = "override.test" +version = "1.0.0" +name = "Overrides" +resolver = "declarative" +save_compatibility = "shared" +[[target]] +game_id = "SLUS-OVERRIDE" +[[feature]] +id = "replace" +name = "Replace" +default_enabled = true +[[plugin]] +feature = "replace" +id = "override.test" +)toml"); + check(PSXRecompV4::mod_register_activation_plugin("override.test",override_activate),"register override activation"); + check(psx_mod_register_function_override("override.test:first",0x80006004,override_body,nullptr,0,0),"queue first override"); + check(psx_mod_register_function_override("override.test:second",0x80006000,override_body,nullptr,0,0),"queue second override"); + check(!psx_mod_register_function_override("override.test:bad",0x80006001,override_body,nullptr,0,0),"reject misaligned override"); + check(PSXRecompV4::mod_runtime_initialize(base,"SLUS-OVERRIDE",0x80002000,{},&error),error.c_str()); + check(PSXRecompV4::mod_runtime_commit({},&error),error.c_str()); + mod_runtime_activate_plugins(); + check(!mod_runtime_activation_error() && func_override_count()==2 && override_activation_calls==1,"selected overrides arm together"); + mod_runtime_activate_plugins(); + check(!mod_runtime_activation_error() && func_override_count()==2 && override_activation_calls==2,"session reactivation replaces entries without duplicates"); + check(PSXRecompV4::mod_runtime_clear_for_netplay(&error),error.c_str()); + check(func_override_count()==0,"cleared plan disarms all package entries"); + override_activation_calls=0; + check(func_override_add("direct",0x80006000,override_body,0)==FO_OK,"register colliding direct override"); + check(PSXRecompV4::mod_runtime_initialize(base,"SLUS-OVERRIDE",0x80002000,{},&error),error.c_str()); + check(PSXRecompV4::mod_runtime_commit({},&error),error.c_str()); + mod_runtime_activate_plugins(); + check(mod_runtime_activation_error()!=nullptr && override_activation_calls==0, + "late collision rejects launch before callbacks"); + check(func_override_count()==1,"collision rolls back all package entries and preserves direct entry"); + check(PSXRecompV4::mod_runtime_clear_for_netplay(&error),error.c_str()); + mod_runtime_activate_plugins(); + check(!mod_runtime_activation_error() && func_override_count()==1,"vanilla session clears failure and retains direct tier"); + } + fs::remove_all(root, ec); if (failures) return 1; std::cout << "mod runtime tests passed\n"; From dcde68f98fc86ceb763d8a67da32e0d501be0450 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 09:56:17 -0700 Subject: [PATCH 07/22] Keep isolated OpenGL fixtures linked with optional feature API doubles --- runtime/tests/gl_postfx_projection_fixture.h | 6 ++++++ runtime/tests/test_gl_frame_gen.c | 1 + runtime/tests/test_gl_readback_region.c | 1 + runtime/tests/test_gl_render_thread.c | 1 + runtime/tests/test_gl_scale_invariance.c | 1 + 5 files changed, 10 insertions(+) create mode 100644 runtime/tests/gl_postfx_projection_fixture.h diff --git a/runtime/tests/gl_postfx_projection_fixture.h b/runtime/tests/gl_postfx_projection_fixture.h new file mode 100644 index 00000000..7dfa91f4 --- /dev/null +++ b/runtime/tests/gl_postfx_projection_fixture.h @@ -0,0 +1,6 @@ +#pragma once +/* Renderer fixtures have no guest CPU; exercise the neutral unavailable projection. + * Atomic publication is covered independently by postfx_publication_test. */ +#include "gte_postfx_projection.h" +void gte_postfx_projection_enable(int enabled) {(void)enabled;} +int gte_postfx_projection_read(GtePostfxProjection *out) {(void)out;return 0;} diff --git a/runtime/tests/test_gl_frame_gen.c b/runtime/tests/test_gl_frame_gen.c index f3f65c3e..03821090 100644 --- a/runtime/tests/test_gl_frame_gen.c +++ b/runtime/tests/test_gl_frame_gen.c @@ -34,6 +34,7 @@ static void fixture_present(int generated); static void fixture_window(int slot); #define GL_PRESENT_THREAD_TEST_HOOK(slot) fixture_window(slot) #include "gpu_gl_renderer.c" +#include "gl_postfx_projection_fixture.h" #include "gpu_hd_texture_stubs.inc" #include "mod_texture_banks.c" #include "gl_batch_policy_lle.c" diff --git a/runtime/tests/test_gl_readback_region.c b/runtime/tests/test_gl_readback_region.c index 9a5e8350..9b245733 100644 --- a/runtime/tests/test_gl_readback_region.c +++ b/runtime/tests/test_gl_readback_region.c @@ -1,5 +1,6 @@ /* Original source-owned GL readback-coherence regression. No retail payload. */ #include "gpu_gl_renderer.c" +#include "gl_postfx_projection_fixture.h" #include "gpu_hd_texture_stubs.inc" #include "mod_texture_banks.c" #include "gl_batch_policy_lle.c" diff --git a/runtime/tests/test_gl_render_thread.c b/runtime/tests/test_gl_render_thread.c index 0d095002..d15bbe73 100644 --- a/runtime/tests/test_gl_render_thread.c +++ b/runtime/tests/test_gl_render_thread.c @@ -30,6 +30,7 @@ * surface), and with the thread on its record/acquire counters. * Original source-owned scene; no retail payload. */ #include "gpu_gl_renderer.c" +#include "gl_postfx_projection_fixture.h" #include "gpu_hd_texture_stubs.inc" #include "mod_texture_banks.c" #include "gl_batch_policy_lle.c" diff --git a/runtime/tests/test_gl_scale_invariance.c b/runtime/tests/test_gl_scale_invariance.c index e89d304c..51e6a867 100644 --- a/runtime/tests/test_gl_scale_invariance.c +++ b/runtime/tests/test_gl_scale_invariance.c @@ -81,6 +81,7 @@ * 3x as the nearest rescale of all their pixels, before any redraw. * Original source-owned scene; no retail payload. */ #include "gpu_gl_renderer.c" +#include "gl_postfx_projection_fixture.h" #include "gpu_hd_texture_stubs.inc" #include "mod_texture_banks.c" #include "gl_batch_policy_lle.c" From ab400a3953c5ac4190e30f5a1c941db0e84e4d2d Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 10:08:15 -0700 Subject: [PATCH 08/22] Regenerate OpenBIOS dispatch with the function override tier --- generated/OpenBIOS.emitter.sha | 2 +- generated/OpenBIOS_dispatch.c | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index 8979439c..ddefaefb 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -86f7329c2692e76133c40fa11cc8cfac214302d8238e05369dc8b263f4dc76fd +6b3ba0804aeca3d52f6e44dd26ec7a8e82f40edce68ecf01ff14705373aef847 diff --git a/generated/OpenBIOS_dispatch.c b/generated/OpenBIOS_dispatch.c index 53da932f..67f5443a 100644 --- a/generated/OpenBIOS_dispatch.c +++ b/generated/OpenBIOS_dispatch.c @@ -36,6 +36,13 @@ extern int (*g_psx_bios_hle_hook)(CPUState* cpu, uint32_t phys); extern uint32_t g_psx_mod_guest_functions; extern int psx_mod_dispatch_guest_function(CPUState*, uint32_t); +/* Game function-override tier (runtime/include/func_override.h): + * hand-written C registered against a guest address. NULL (the + * default) = no overrides, dispatch identical to a build without + * the tier. Returns 1 when the override handled the call; the + * guest resumes at $ra exactly as if the original ran jr $ra. */ +extern int (*g_psx_func_override_hook)(CPUState* cpu, uint32_t phys); + #ifdef PSX_HAS_GAME_DISPATCH extern int psx_game_address_in_text(uint32_t addr); #endif @@ -9111,6 +9118,18 @@ static void psx_dispatch_impl(CPUState* cpu, uint32_t addr, uint32_t stop_addr) /* Byte-guarded A0/B0/C0 call-vector tail stubs. */ if (!found && psx_bios_try_native_call_stub(cpu, addr)) found = 1; + /* Function-override tier: AFTER the BIOS tiers (an override + * can never shadow a kernel service vector), BEFORE every + * game code backend, so one address-keyed hook covers the + * static EXE, runtime-loaded overlays and dirty RAM alike. + * Handled (rc 1) => the override completed against guest + * state; resume at $ra via the trampoline's normal + * return/tail contract. rc 0 => fall through untouched. */ + if (!found && g_psx_func_override_hook && + g_psx_func_override_hook(cpu, addr & 0x1FFFFFFFu)) { + cpu->pc = cpu->gpr[31]; + found = 1; + } #ifdef PSX_HAS_GAME_DISPATCH /* Game EXEs can overlap the BIOS shell copy window at * physical 0x30000-0x5AFFF. If the target belongs to the From 81dc805e8df9d66c4960eaf1c008b0aac06d1e95 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 10:25:45 -0700 Subject: [PATCH 09/22] Preserve final-pass history and reset each shader chain clock on reload --- docs/POST_EFFECTS.md | 8 ++- runtime/src/gl_postfx.inc | 35 +++++++---- runtime/tests/test_gl_bloom.c | 79 +++++++++++++++++++++++- runtime/tests/test_gl_scale_invariance.c | 9 +++ 4 files changed, 115 insertions(+), 16 deletions(-) diff --git a/docs/POST_EFFECTS.md b/docs/POST_EFFECTS.md index 22421d69..7cc42d94 100644 --- a/docs/POST_EFFECTS.md +++ b/docs/POST_EFFECTS.md @@ -98,8 +98,8 @@ even next to a lamp. size, rebuilt from the surfaces' PGXP depth (`zn = 1 - 512/(sz + 256)`); 0 where nothing 3D drew. Only built while a loaded chain reads it. - `input history`: this pass's own target from the last real frame (trails, temporal - filters). Generated frames do not update it. -- `u_time` (seconds since the chain loaded) and `u_proj` (latest GTE projection: `240/H`, screen + filters), including the final window pass. Generated frames do not update it. History starts empty after loading or resizing. +- `u_time` (host seconds since the chain first drew, reset on reload) and `u_proj` (latest GTE projection: `240/H`, screen centre in uv, aspect) are declared in every pass. - Live uniforms: a title sets a preset `uniform` from game state with `psx_host_set_post_uniform(name, x, y, z, w)` (`host_overlay.h`), e.g. a light source projected @@ -115,3 +115,7 @@ even next to a lamp. thread and on the render thread. - `gl_post_aa_test`: FXAA on the pipeline. Its output is byte-identical to the former fixed pass. + +The projection is a coherent latest CPU value. It is not associated with a +queued frame or an individual replay view; shaders requiring an exact camera +for a delayed or stereo image need corresponding frame/view metadata. diff --git a/runtime/src/gl_postfx.inc b/runtime/src/gl_postfx.inc index b6e07e02..1e652462 100644 --- a/runtime/src/gl_postfx.inc +++ b/runtime/src/gl_postfx.inc @@ -1,5 +1,8 @@ #include "gte_postfx_projection.h" #include "postfx_debug_json.h" +#ifndef PFX_NOW_TICKS +#define PFX_NOW_TICKS() SDL_GetPerformanceCounter() +#endif /* ---- Post-effect pipeline (docs/POST_EFFECTS.md) --------------------------- * One executor for every post effect on the composed game image: chains of * full-screen fragment passes (postfx_chain.h). Three stages, in order, each @@ -43,6 +46,7 @@ typedef struct { typedef struct { PfxChain chain; int loaded, failed; + uint64_t started_at; GLuint prog[PFX_MAX_PASSES]; PfxLocs loc[PFX_MAX_PASSES]; GLuint tex[PFX_MAX_PASSES], fbo[PFX_MAX_PASSES]; @@ -92,7 +96,6 @@ static GLuint s_depth_tex = 0, s_depth_fbo = 0, s_depth_map_prog = 0; static GLint s_dmap_uUv = -1, s_dmap_uS = -1, s_dmap_uSrc = -1, s_dmap_uDst = -1; static int s_depth_w = 0, s_depth_h = 0, s_depth_ok = 0; static int s_pfx_depth_on = 0; /* a loaded chain reads `depth` */ -static uint64_t s_pfx_t0 = 0; /* u_time origin */ /* Live values for preset `uniform` lines, set by the title from game state * (psx_host_set_post_uniform, host_overlay.h): a live value replaces the * preset default of the same name in every pass that declares it. */ @@ -160,6 +163,7 @@ static void pfx_stage_unload(PfxStage *st) { } pfx_chain_free(&st->chain); st->loaded = st->failed = 0; + st->started_at = 0; } /* Which stages read the mask decides whether draws keep it. */ @@ -465,6 +469,7 @@ static int pfx_depth_build(int fresh, int lw, int lh) { static int pfx_stage_run(PfxStage *st, int generated, int lx, int ly, int lw, int lh) { PfxChain *c = &st->chain; int copied = 0; + if (!st->started_at) st->started_at = PFX_NOW_TICKS(); for (int i = 0; i < c->npasses; i++) { const PfxPass *ps = &c->pass[i]; const int last = i == c->npasses - 1; @@ -523,6 +528,17 @@ static int pfx_stage_run(PfxStage *st, int generated, int lx, int ly, int lw, in p_glBlitFramebuffer(lx, ly, lx + lw, ly + lh, 0, 0, w, h, GL_COLOR_BUFFER_BIT, GL_LINEAR); } } + /* Allocate/reset before sampling, including the final window pass. + * A resize starts an empty history rather than sampling the old size. */ + if (st->hist[i] && (st->hw[i] != w || st->hh[i] != h)) { + pfx_tex_alloc(&st->htex[i], w, h, ps->format, ps->nearest); + pfx_fbo_attach(&st->hfbo[i], st->htex[i]); + st->hw[i] = w; st->hh[i] = h; + p_glBindFramebuffer(PSXGL_FRAMEBUFFER, st->hfbo[i]); + GLfloat clear[4]; glGetFloatv(GL_COLOR_CLEAR_VALUE, clear); + glClearColor(0,0,0,0); glClear(GL_COLOR_BUFFER_BIT); + glClearColor(clear[0],clear[1],clear[2],clear[3]); + } if (last) { p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); glViewport(lx, ly, lw, lh); } else { p_glBindFramebuffer(PSXGL_FRAMEBUFFER, st->fbo[i]); glViewport(0, 0, w, h); } const GLuint prog = st->prog[i]; @@ -543,7 +559,6 @@ static int pfx_stage_run(PfxStage *st, int generated, int lx, int ly, int lw, in p_glUniform2f(L->fixed[PFX_LOC_RCP], 1.0f / (float)w, 1.0f / (float)h); p_glUniform2f(L->fixed[PFX_LOC_SIZE], (float)w, (float)h); p_glUniform2i(L->fixed[PFX_LOC_ORG], last ? lx : 0, last ? ly : 0); - if (!s_pfx_t0) s_pfx_t0 = SDL_GetPerformanceCounter(); if (L->proj >= 0) { /* u_proj: x/z = (uv.x - y) * w * x, y/z = (uv.y - z) * x for a view-space * point at depth sz (240-line reference, same scale on both axes). */ @@ -556,7 +571,7 @@ static int pfx_stage_run(PfxStage *st, int generated, int lx, int ly, int lw, in 1.0f - (ofy > 0.0f ? ofy : 120.0f) / 240.0f, aspect); } if (L->time >= 0) - p_glUniform1f(L->time, (float)((double)(SDL_GetPerformanceCounter() - s_pfx_t0) / + p_glUniform1f(L->time, (float)((double)(PFX_NOW_TICKS() - st->started_at) / (double)SDL_GetPerformanceFrequency())); for (int k = 0; k < ps->nuniforms; k++) { const PfxUniform *u = &ps->u[k]; @@ -578,16 +593,12 @@ static int pfx_stage_run(PfxStage *st, int generated, int lx, int ly, int lw, in } post_fullscreen(prog, L->fixed[PFX_LOC_UV]); if (ps->blend != PFX_BLEND_REPLACE) glDisable(GL_BLEND); - if (!last && !generated && st->hist[i]) { - /* This pass's target becomes its `history` for the next real frame. */ - if (st->hw[i] != w || st->hh[i] != h) { - pfx_tex_alloc(&st->htex[i], w, h, ps->format, ps->nearest); - pfx_fbo_attach(&st->hfbo[i], st->htex[i]); - st->hw[i] = w; st->hh[i] = h; - } - p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, st->fbo[i]); + if (!generated && st->hist[i]) { + /* Capture the final window target as well as intermediate FBOs. */ + p_glBindFramebuffer(PSXGL_READ_FRAMEBUFFER, last ? 0 : st->fbo[i]); p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, st->hfbo[i]); - p_glBlitFramebuffer(0, 0, w, h, 0, 0, w, h, GL_COLOR_BUFFER_BIT, GL_NEAREST); + const int x = last ? lx : 0, y = last ? ly : 0; + p_glBlitFramebuffer(x, y, x+w, y+h, 0, 0, w, h, GL_COLOR_BUFFER_BIT, GL_NEAREST); } } for (int k = PFX_MAX_INPUTS - 1; k >= 0; k--) { diff --git a/runtime/tests/test_gl_bloom.c b/runtime/tests/test_gl_bloom.c index 1ca26cc4..9ae618fb 100644 --- a/runtime/tests/test_gl_bloom.c +++ b/runtime/tests/test_gl_bloom.c @@ -11,10 +11,17 @@ * bottom-up) so a run against another tree can be compared byte for byte * (BLOOM_FIXTURE_NO_BLOOM builds it without the bloom API). */ #define GL_PRESENT_TEST_HOOK(gen) bloom_capture_hook(gen) +#include +static uint64_t pfx_fixture_clock(void); +#define PFX_NOW_TICKS() pfx_fixture_clock() static void bloom_capture_hook(int gen); #define main scale_fixture_main #include "test_gl_scale_invariance.c" #undef main +static uint64_t s_pfx_test_clock; +static uint64_t pfx_fixture_clock(void) { + return s_pfx_test_clock ? s_pfx_test_clock : SDL_GetPerformanceCounter(); +} void gpu_hd_textures_begin_upload(int x,int y,int w,int h){(void)x;(void)y;(void)w;(void)h;} void gpu_hd_textures_track_upload(int x,int y,int w,int h,const uint16_t* p){(void)x;(void)y;(void)w;(void)h;(void)p;} void gpu_hd_textures_invalidate(int x,int y,int w,int h){(void)x;(void)y;(void)w;(void)h;} @@ -54,6 +61,7 @@ static void bloom_capture_hook(int gen) { } enum { SC_2D, SC_ADD, SC_3D, SC_HUD_OVER_3D, SC_PARTIAL, SC_N }; +enum { SC_DEPTH_ONLY = 99 }; static const char *k_scene[SC_N] = { "2d", "additive", "3d", "hud-over-3d", "partial" }; /* SC_PARTIAL's grey HUD panels (native x, y, w, h): one beside the lamp, one * covering part of it. */ @@ -77,8 +85,10 @@ static uint8_t *frame(int scene, float bloom, uint64_t *vram_hash) { glb_set_mask_bits(0, 0); glb_set_semi_transparency(0, 0); glb_fill_rect(0, 0, FRAME_W, FRAME_H, 0x0000); /* A dark 3D ground everywhere: lit (may take a halo), below the threshold. */ - tri3d(0, 0, FRAME_W, 0, 0, FRAME_H, 0x0842); - tri3d(FRAME_W, 0, FRAME_W, FRAME_H, 0, FRAME_H, 0x0842); + if (scene != SC_DEPTH_ONLY) { + tri3d(0, 0, FRAME_W, 0, 0, FRAME_H, 0x0842); + tri3d(FRAME_W, 0, FRAME_W, FRAME_H, 0, FRAME_H, 0x0842); + } switch (scene) { case SC_2D: /* a white HUD panel */ glb_draw_flat_rect(120, 90, 80, 60, 0x7fff); @@ -88,6 +98,7 @@ static uint8_t *frame(int scene, float bloom, uint64_t *vram_hash) { glb_draw_flat_triangle(120, 150, 200, 150, 160, 80, 0x7fff); glb_set_semi_transparency(0, 0); break; + case SC_DEPTH_ONLY: case SC_3D: /* a lit 3D polygon */ tri3d(120, 150, 200, 150, 160, 80, 0x7fff); break; @@ -234,6 +245,7 @@ int main(int argc, char **argv) { fputs("void main(){ o = this is not glsl; }\n", f); fclose(f); } + gl_renderer_render_thread_stop(); for (int pass = 0; pass < 2; pass++) { /* 0: this thread, 1: render thread */ uint64_t h = 0; gl_renderer_set_post_shader("pfx_invert.pfx"); @@ -261,6 +273,69 @@ int main(int argc, char **argv) { check(lit > s_cap_w * s_cap_h / 2, "mask input: the 3D ground and lamp are lit"); free(m); + /* A history input also works on the final window pass. The blue + * channel observes the shader clock, so reload must reset its origin. */ + FILE *history_file=fopen("pfx_history.glsl","w"); + check(history_file!=NULL,"open history shader"); + if(history_file) { + fputs("void main(){ float r=texelFetch(u_prev,ivec2(gl_FragCoord.xy)-u_org,0).r;" + "o=vec4(r*0.5+u_add,0.0,clamp(u_time/4.0,0.0,1.0),1.0); }\n",history_file); + fclose(history_file); + } + history_file=fopen("pfx_history.pfx","w"); + if(history_file) { + fputs("pfx 1\npass history\n shader pfx_history.glsl\n input u_prev history\n uniform u_add 0.25\n",history_file); + fclose(history_file); + } + psx_host_set_post_uniform("u_add",0.25f,0,0,0); + s_pfx_test_clock=100*SDL_GetPerformanceFrequency(); + gl_renderer_set_post_shader("pfx_history.pfx"); + uint8_t *hist=frame(SC_PARTIAL,0,&h); + size_t center=((size_t)(s_cap_h/2)*s_cap_w+s_cap_w/2)*3; + check(abs(hist[center]-64)<=1 && hist[center+2]<=1,"first history frame is cleared and starts at time zero"); + free(hist); + s_pfx_test_clock=102*SDL_GetPerformanceFrequency(); + hist=frame(SC_PARTIAL,0,&h); + check(abs(hist[center]-96)<=1 && abs(hist[center+2]-128)<=1,"final pass samples the previous real image and its own elapsed time"); + free(hist); + if(!s_rth_on) { + for(int repeat=0;repeat<2;++repeat) { + postfx_present(0,0,0,0,s_cap_w,s_cap_h); + bloom_capture_hook(1); + check(abs(s_cap[center]-112)<=1,"generated presents do not advance history"); + } + } + psx_host_set_post_uniform("u_add",0.125f,0,0,0); + hist=frame(SC_PARTIAL,0,&h); + check(abs(hist[center]-80)<=1,"live uniform update reaches the actual shader"); + free(hist); + s_pfx_test_clock=104*SDL_GetPerformanceFrequency(); + gl_renderer_set_post_shader("pfx_history.pfx"); + hist=frame(SC_PARTIAL,0,&h); + check(abs(hist[center]-32)<=1 && hist[center+2]<=1,"reload clears history and resets shader time while retaining live values"); + free(hist); + s_pfx_test_clock=0; + + FILE *depth_file=fopen("pfx_depth.glsl","w"); + if(depth_file) { + fputs("void main(){ float d=texelFetch(u_depth,ivec2(gl_FragCoord.xy)-u_org,0).r;" + "o=vec4(clamp(d/4000.0,0.0,1.0),0.0,0.0,1.0); }\n",depth_file); + fclose(depth_file); + } + depth_file=fopen("pfx_depth.pfx","w"); + if(depth_file) { + fputs("pfx 1\npass depth\n shader pfx_depth.glsl\n input u_depth depth\n",depth_file); + fclose(depth_file); + } + gl_renderer_set_pgxp_depth(1); + gl_renderer_set_post_shader("pfx_depth.pfx"); + uint8_t *depth=frame(SC_DEPTH_ONLY,0,&h); + size_t outside=((size_t)(s_cap_h-1-20)*s_cap_w+20)*3; + check(abs(depth[center]-128)<=3,"depth input reconstructs the triangle's GTE screen depth"); + check(depth[outside]<=1,"depth input is zero where no 3D primitive drew"); + free(depth); + gl_renderer_set_pgxp_depth(0); + gl_renderer_set_post_shader("pfx_screen.pfx"); check(gl_renderer_post_shader_loaded(), "source_down + blend screen chain loads"); uint8_t *sc = frame(SC_PARTIAL, 0.0f, &h); diff --git a/runtime/tests/test_gl_scale_invariance.c b/runtime/tests/test_gl_scale_invariance.c index 51e6a867..4c53b8e9 100644 --- a/runtime/tests/test_gl_scale_invariance.c +++ b/runtime/tests/test_gl_scale_invariance.c @@ -103,6 +103,15 @@ int psx_netplay_active(void){return 0;} GrBackend gr_backend(void){return GR_BACKEND_OPENGL;} void gr_refresh_backend(void){} #endif +/* These renderer-only fixtures have no frontend telemetry/menu consumer. */ +void psx_video_telemetry_compose(int r,int s,int sw,int sh,int dw,int dh,int vw,int vh) { + (void)r;(void)s;(void)sw;(void)sh;(void)dw;(void)dh;(void)vw;(void)vh; +} +void psx_video_telemetry_present(int result) {(void)result;} +int psx_host_menu_overlay_image(const uint32_t **p,int *w,int *h) { + *p=NULL;*w=*h=0;return 0; +} + 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;} int psx_ws_prim_in_backdrop(void){return 0;} From d486a78acf0fcccbedbae9479551abd28a9802cf Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 11:16:39 -0700 Subject: [PATCH 10/22] Scope package overrides and defer snapshots across their native stacks --- runtime/include/func_override.h | 11 +++++++++++ runtime/include/mod_plugins.h | 4 +++- runtime/src/func_override.c | 20 +++++++++++++++++++- runtime/src/mod_runtime.cpp | 17 ++++++++++++----- runtime/tests/test_func_override.c | 6 +++++- runtime/tests/test_mod_runtime.cpp | 30 +++++++++++++++++++++++++++++- 6 files changed, 79 insertions(+), 9 deletions(-) diff --git a/runtime/include/func_override.h b/runtime/include/func_override.h index 3e736259..2e114219 100644 --- a/runtime/include/func_override.h +++ b/runtime/include/func_override.h @@ -190,6 +190,17 @@ void func_override_call_original(struct CPUState *cpu); int func_override_add_package(const char *id, uint32_t addr, FuncOverrideFn fn, const uint32_t *expected_words, int n_words, int32_t credit); +/* Scoped package variant. The owner supplies a stable plan context and + * invokes fn inside that plugin's resource/completion context. The plan must + * disarm package entries before replacing that context. */ +typedef int (*FuncOverrideInvoker)(struct CPUState *cpu, FuncOverrideFn fn, + const void *context); +int func_override_add_package_scoped(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *expected_words, int n_words, int32_t credit, + FuncOverrideInvoker invoke, const void *context); +/* Nonzero while a native override stack is live, including nested guest + * calls. Save/load and rewind must defer until this returns to zero. */ +int func_override_active(void); /* Install the dispatcher hook. Call once at startup AFTER registering (the * mod runtime calls it again after arming package-gated overrides — safe). diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index bd2cb9e9..671b41fe 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -145,7 +145,9 @@ uint32_t psx_mod_guest_cycle_scale(void); * selects that plugin — the same gating as vblank/activation callbacks. The * callback obeys the guest ABI (args cpu->gpr[4..7], result cpu->gpr[2]); * return 1 = handled (guest resumes at $ra), 0 = decline (original runs). - * Wrap semantics via func_override_call_original(); optional residency + * The owning plugin resource context is active throughout the callback and + * restored across nested callbacks. Snapshots wait until its native stack + * unwinds. Wrap semantics via func_override_call_original(); optional residency * guard via expected_words (NULL/0 = unguarded). * * `id` may carry an optional ":label" suffix ("pkg.feature:aim") that names diff --git a/runtime/src/func_override.c b/runtime/src/func_override.c index 9575d04d..97985f6b 100644 --- a/runtime/src/func_override.c +++ b/runtime/src/func_override.c @@ -35,6 +35,8 @@ typedef struct { char id[FO_MAX_ID]; uint32_t phys; /* normalised: KSEG bits masked off */ FuncOverrideFn fn; + FuncOverrideInvoker invoke; + const void *context; uint64_t calls; uint64_t guard_misses; uint32_t guard[FO_MAX_GUARD_WORDS]; @@ -127,6 +129,22 @@ int func_override_add_package(const char *id, uint32_t addr, FuncOverrideFn fn, 1, credit); } +int func_override_add_package_scoped(const char *id, uint32_t addr, FuncOverrideFn fn, + const uint32_t *expected_words, int n_words, int32_t credit, + FuncOverrideInvoker invoke, const void *context) +{ + if (!invoke || !context) return FO_ERR_ARGS; + const int rc = func_override_add_package(id,addr,fn,expected_words,n_words,credit); + if (rc == FO_OK) { + Entry *entry = &s_entries[s_count-1]; + entry->invoke = invoke; + entry->context = context; + } + return rc; +} + +int func_override_active(void) { return s_active_phys != 0; } + /* The hook. Runs on EVERY dispatch, so the common path — nothing registered * for this address — must stay cheap. A linear scan over a handful of * entries beats a hash for realistic counts and keeps the code honest; @@ -168,7 +186,7 @@ static int hook(CPUState *cpu, uint32_t phys) { const uint32_t saved_active = s_active_phys; s_active_phys = phys; - const int handled = e->fn(cpu) ? 1 : 0; + const int handled = (e->invoke ? e->invoke(cpu,e->fn,e->context) : e->fn(cpu)) ? 1 : 0; s_active_phys = saved_active; /* Charge the declared credit only when HANDLED: a decline runs * the original, which accrues its exact cycles by executing. diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index b6b7bf8d..e2e09ba2 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -3003,6 +3003,12 @@ extern "C" const char* mod_runtime_activation_error(void) { return s_override_activation_error.empty() ? nullptr : s_override_activation_error.c_str(); } +static int invoke_package_override(CPUState* cpu, FuncOverrideFn fn, const void* context) { + using namespace PSXRecompV4; + PluginCallbackScope scope(state(),static_cast(context),cpu); + return fn(cpu); +} + extern "C" void mod_runtime_activate_plugins(void) { using namespace PSXRecompV4; HostLaunchTimingScope timing(HOST_LAUNCH_PLUGIN_ACTIVATION); @@ -3025,12 +3031,13 @@ extern "C" void mod_runtime_activate_plugins(void) { s.extent_start = 0; /* Arm the entire tier before activation callbacks change host state. */ for (auto& pending : function_override_plugins()) { - const bool selected = std::any_of(s.plan.plugins.begin(),s.plan.plugins.end(), + const auto owner = std::find_if(s.plan.plugins.begin(),s.plan.plugins.end(), [&](const ModResolution::Plugin& p) { return p.id == pending.plugin; }); - if (!selected) continue; - const int rc = s.netplay_view_plan ? FO_ERR_ARGS : func_override_add_package( + if (owner == s.plan.plugins.end()) continue; + const int rc = s.netplay_view_plan ? FO_ERR_ARGS : func_override_add_package_scoped( pending.id.c_str(),pending.address,pending.fn, - pending.n_guard ? pending.guard : nullptr,pending.n_guard,pending.credit); + pending.n_guard ? pending.guard : nullptr,pending.n_guard,pending.credit, + invoke_package_override,&*owner); if (rc != FO_OK) { s_override_activation_error = "Cannot arm function override " + pending.id + " (registration error " + std::to_string(rc) + ")"; @@ -3777,7 +3784,7 @@ static int game_netplay_function_entry(CPUState* cpu, uint32_t address) { } extern "C" int psx_mod_function_entry_active(void) { - return PSXRecompV4::function_entry_depth != 0; + return PSXRecompV4::function_entry_depth != 0 || func_override_active(); } extern "C" void mod_runtime_function_entry_context_save(ModFunctionEntryContext *out) { diff --git a/runtime/tests/test_func_override.c b/runtime/tests/test_func_override.c index c48fbc38..3ad9a6a3 100644 --- a/runtime/tests/test_func_override.c +++ b/runtime/tests/test_func_override.c @@ -98,6 +98,7 @@ static int s_impl_calls, s_decline_calls, s_wrap_calls; static int impl_handles(CPUState *cpu) { + CHECK(func_override_active(),"handled native override stack is active"); s_impl_calls++; cpu->gpr[2] = 0x1234u; return 1; @@ -106,6 +107,7 @@ static int impl_handles(CPUState *cpu) static int impl_declines(CPUState *cpu) { (void)cpu; + CHECK(func_override_active(),"declining native override stack is active"); s_decline_calls++; return 0; /* the decline-only probe idiom the TCP inventory relies on */ } @@ -135,7 +137,9 @@ static void reset_all(void) static int consult(CPUState *cpu, uint32_t addr) { if (!g_psx_func_override_hook) return 0; - return g_psx_func_override_hook(cpu, addr & 0x1FFFFFFFu); + const int handled = g_psx_func_override_hook(cpu, addr & 0x1FFFFFFFu); + CHECK(!func_override_active(),"top-level native override stack has unwound"); + return handled; } /* ---- tests -------------------------------------------------------------- */ diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 33a55313..5e5e7b08 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -31,11 +31,21 @@ namespace fs = std::filesystem; static std::array ram; +extern "C" int (*g_psx_func_override_hook)(CPUState*, uint32_t); static int failures; static int activation_calls; static int override_activation_calls; static void override_activate() { ++override_activation_calls; } -static int override_body(CPUState*) { return 1; } +static int override_scope_checks; +static int override_body(CPUState*) { + char option[16]{}; + if (psx_mod_current_option_value("scope",option,sizeof option) && + !std::strcmp(option,"owned") && psx_mod_function_entry_active()) ++override_scope_checks; + mod_runtime_on_vblank(); + if (psx_mod_current_option_value("scope",option,sizeof option) && + !std::strcmp(option,"owned") && psx_mod_function_entry_active()) ++override_scope_checks; + return 1; +} static int plugin_calls; static int restore_calls; static int option_renderer_activations; @@ -2459,6 +2469,18 @@ game_id = "SLUS-OVERRIDE" id = "replace" name = "Replace" default_enabled = true +[[option]] +feature = "replace" +id = "scope" +label = "Scope" +type = "choice" +default = "owned" +[[option.choice]] +value = "owned" +label = "Owned" +[[option.choice]] +value = "other" +label = "Other" [[plugin]] feature = "replace" id = "override.test" @@ -2471,6 +2493,12 @@ id = "override.test" check(PSXRecompV4::mod_runtime_commit({},&error),error.c_str()); mod_runtime_activate_plugins(); check(!mod_runtime_activation_error() && func_override_count()==2 && override_activation_calls==1,"selected overrides arm together"); + CPUState override_cpu{}; + check(g_psx_func_override_hook && g_psx_func_override_hook(&override_cpu,0x6004),"invoke package override"); + check(override_scope_checks==2 && !psx_mod_function_entry_active(), + "override owns its resource context across VBlank and blocks mid-stack snapshots"); + char no_scope[16]{}; + check(!psx_mod_current_option_value("scope",no_scope,sizeof no_scope),"override restores its caller's context"); mod_runtime_activate_plugins(); check(!mod_runtime_activation_error() && func_override_count()==2 && override_activation_calls==2,"session reactivation replaces entries without duplicates"); check(PSXRecompV4::mod_runtime_clear_for_netplay(&error),error.c_str()); From 1c4731bb143b5adec964160864bd5527c7626b36 Mon Sep 17 00:00:00 2001 From: Aliet Exposito Garcia Date: Sat, 24 Oct 2026 02:29:52 -0400 Subject: [PATCH 11/22] feat: add configurable widescreen sxy culling support Some PlayStation 1 titles implement trivial rejection by folding the sign bits of projected vertex screen coordinates across triangle or quad primitives with a chain of AND instructions. Without adjusting these checks during widescreen presentation, geometry in the newly revealed horizontal area is still rejected by the original 4:3 bounds. Rather than embedding title specific instruction patterns directly into the recompiler, this introduces a generalized `[[widescreen.cull.sxy]]` configuration schema within `game.toml`. Game configurations can now declare multi vertex trivial rejection sequences by their exact AND instruction words, vertex registers and the branch that consumes the result. The loader derives the expected chain from those words and rejects any other shape. Each AND keeps its original value except for the horizontal sign bit, which survives only while that vertex lies beyond the revealed left edge, so vertical and right side rejection are preserved and 4:3 output is bit identical. Shifted coordinate lower edge comparisons reuse the existing `screen_x_sites` handling through a new `slti rt, x, 0` form instead of adding a separate site family. Full parity is maintained between recompiled code and the dirty RAM interpreter by sharing the same runtime helper and validating every fold and consumer instruction word on both paths, leaving mismatching overlay variants untouched. This eliminates hardcoded game logic from the recompiler while giving titles clean geometry retention in expanded aspect ratios. --- docs/WIDESCREEN.md | 53 +++- docs/config_schema.md | 23 ++ generated/OpenBIOS.emitter.sha | 2 +- recompiler/include/code_generator.h | 2 + recompiler/src/code_generator.cpp | 62 +++- recompiler/src/config_loader.cpp | 138 +++++++++ recompiler/src/config_loader.h | 17 ++ recompiler/src/main_psx.cpp | 28 ++ recompiler/src/segment_variants.cpp | 4 +- .../tests/ws_cull_edge_codegen_test.cpp | 276 ++++++++++++++++++ .../ws_cull_screen_x_hi_codegen_test.cpp | 30 ++ runtime/include/gpu.h | 19 ++ .../include/overlay_dispatch_preamble.c.inc | 4 + runtime/include/ws_cull_edge.h | 31 ++ runtime/src/dirty_ram_interp.c | 20 +- runtime/src/gpu.c | 57 ++++ runtime/src/main.cpp | 20 ++ runtime/tests/test_ws_cull_edge_interp.c | 94 ++++++ 18 files changed, 872 insertions(+), 8 deletions(-) diff --git a/docs/WIDESCREEN.md b/docs/WIDESCREEN.md index 7f7d35b5..710624cb 100644 --- a/docs/WIDESCREEN.md +++ b/docs/WIDESCREEN.md @@ -299,13 +299,14 @@ are non-empty, so titles that do not use them keep their caches. `screen_x_sites` is the explicit form of the `auto_screen_x` compare, for a title whose width is not in `screen_w_imms` or whose function has no paired -height compare. It takes four instructions: +height compare. It takes five instructions: | Instruction at the site | While widened | |---|---| | `sltiu rt, SX, W` | `rt = -m <= SX < W + m` | | `lui rt, W` (a screen edge kept as `W << 16`) | `rt = (W + m) << 16`; a negative `W` moves left instead | | `bltz x, outside` where `x` is `SX << 16` | taken while `SX < -m` | +| `slti rt, x, 0` where `x` is `SX << 16` | `rt = SX < -m` (the `bltz` test as a flag) | | `sltu rd, SX, W` with `W` in a register | `rd = -4m <= (int16)SX < W` | The `sltu` form covers a renderer that compares a 16-bit screen X @@ -343,11 +344,57 @@ screen_x_sites = [ `screen_x_sites` is applied when code is generated (native code and overlay shards) and, at runtime, by the dirty-RAM interpreter: code that runs from RAM through the interpreter (including under `PSX_FORCE_INTERP=1`) widens the -same four instruction forms at the listed addresses, with the same helpers and +same five instruction forms at the listed addresses, with the same helpers and the same identity at 4:3. A listed `bltz` follows the precedence `bltz_sites`, then `screen_x_sites`, then the `auto_screen_x` classifier. In the main EXE a listed address holding another non-branch instruction is a hard build error; -a branch other than `bltz` at a listed address is left as it is. +a branch other than `bltz` at a listed address is left as it is, and a +`slti` with a nonzero immediate is a build error (it is not this form). + +### Packed-SXY all-vertices reject (`[[widescreen.cull.sxy]]`) + +Some renderers trivially reject a whole triangle or quad by ANDing the packed +GTE `SXY` words of its vertices (Y in the high half, X in the low half) with a +sign mask and branching on the result: + +``` +and at, t0, s1 ; s1 = 0x80008000 +and at, s4, at +and at, t3, at ; (and at, t1, at for a quad) +bne at, zero, reject ; bit 31: all above, bit 15: all left of the screen +``` + +`screen_x_sites`, `bltz_sites` and the packed-coordinate helpers test one +value at a time and cannot widen this fold. Listing it widens only the left +reject: each AND of the fold becomes `psx_ws_cull_sxy_and(acc, sxy)`, the +vanilla AND except that bit 15 also needs this vertex's `X < -m`. Bit 31 (the +vertical reject) and every other bit are the vanilla AND, the guest's own +branch decides, and right/bottom rejects (a separate chain) are untouched. +At 4:3 (`m = 0`) every AND produces the vanilla word. + +```toml +[[widescreen.cull.sxy]] +kind = "tri" # or "quad" (4 vertices) +vertex_regs = [8, 20, 11] # the SXY operand of each AND +fold_addresses = ["0x8001A7F0", "0x8001A7F8", "0x8001A800"] +fold_expected = ["0x01110824", "0x02810824", "0x01610824"] +consumer_address = "0x8001A808" +consumer_expected = "0x14200034" # bne at, zero, reject +``` + +The loader derives the shape from the words and refuses anything else: one +AND per vertex, in increasing address order within 16 instructions, all into +one register `r`; the first combines `vertex_regs[0]` with a separate mask +register, each later one combines its vertex with `r`; the consumer is +`beq`/`bne r, zero` within four instructions after the last AND. Every word +of the fold and the consumer is part of the identity: the main-EXE build +fails on any mismatch (checked against the image up front and again when the +AND is emitted), and a captured overlay or interpreted variant with any other +word in the fold is left vanilla. Native code, overlay shards and the +dirty-RAM interpreter use the same helper and the same whole-fold check. +Changing a site changes the overlay-cache identity; regenerate after a change. +Wild 9 (SLUS-00425) is the first user: its triangle and quad rejects use this +shape, and its shifted-X lower edge is the `slti` form of `screen_x_sites`. --- diff --git a/docs/config_schema.md b/docs/config_schema.md index b2989de5..1abc6908 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -262,6 +262,29 @@ result = 1 semantics. - Regenerate main/overlay native code after changing the list. +### Packed-SXY all-vertices rejects + +```toml +[[widescreen.cull.sxy]] +kind = "quad" +vertex_regs = [8, 20, 11, 9] +fold_addresses = ["0x8001A9B4", "0x8001A9B8", "0x8001A9BC", "0x8001A9C0"] +fold_expected = ["0x01110824", "0x02810824", "0x01610824", "0x01210824"] +consumer_address = "0x8001A9C4" +consumer_expected = "0x14200049" +``` + +- `kind` is `"tri"` (3 vertices) or `"quad"` (4); `vertex_regs`, + `fold_addresses` and `fold_expected` have one entry per vertex. +- Every `fold_expected` is `and r, ...` into the same `r != $zero`; the first + reads `vertex_regs[0]` and a separate mask register, each later one reads + its vertex and `r`. `consumer_expected` is `beq`/`bne r, zero`. +- Addresses are word-aligned, in one segment, strictly increasing, the fold + within 16 instructions and the consumer within 4 after it; no address is + shared with another sxy site. At most 64 sites. +- Identity at 4:3; while widened only the X-sign bit of each AND changes. See + `docs/WIDESCREEN.md`. + Prefer an aspect-derived cone over `keep` when the original predicate is a camera-frustum test. `keep` has no queue policy and is appropriate only for a separately proven binary verdict. diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index ddefaefb..230331ac 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -6b3ba0804aeca3d52f6e44dd26ec7a8e82f40edce68ecf01ff14705373aef847 +d887bd825dee12170e8bf22577543114787a3c7f5f2c9bf5e62b14c990561564 diff --git a/recompiler/include/code_generator.h b/recompiler/include/code_generator.h index 7a824f34..0896727a 100644 --- a/recompiler/include/code_generator.h +++ b/recompiler/include/code_generator.h @@ -177,6 +177,8 @@ struct CodeGenConfig { // Exact, full-word-guarded comparison sites whose result is forced only // while widescreen reveals extra world. 4:3 evaluates the original compare. std::vector ws_cull_keep_sites; + // [[widescreen.cull.sxy]] packed-SXY AND folds (left edge widened). + std::vector ws_cull_sxy_sites; std::vector ws_cull_masked_reject_sites; std::vector ws_cull_scale_sites; diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 19833abc..6fa5a161 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -1143,6 +1143,55 @@ std::string CodeGenerator::translate_instruction_core(uint32_t addr, uint32_t in site.y_reg, config_.ws_aspect_cone.y_reg)), comment); } + // [[widescreen.cull.sxy]]: one AND of a packed-SXY all-vertices reject + // fold. Emitted only when every AND word of the fold and the consumer + // branch word read back exactly from this image (in this instruction's + // segment); the AND keeps its vanilla value except that the X-sign bit + // also needs this vertex left of the revealed edge (identity at 4:3). + // An overlay variant with other words anywhere in the fold is untouched. + if (opcode == 0x00 && funct == 0x24 && !config_.ws_cull_sxy_sites.empty()) { + for (const auto& site : config_.ws_cull_sxy_sites) { + size_t step = site.vertices; + for (size_t i = 0; i < site.vertices; i++) + if ((site.fold_addresses[i] & 0x1FFFFFFFu) == (addr & 0x1FFFFFFFu)) + step = i; + if (step == site.vertices) continue; + const uint32_t segment = addr & ~0x1FFFFFFFu; + uint32_t bad_addr = 0, bad_want = 0, bad_got = 0; + bool ok = true; + auto verify = [&](uint32_t site_addr, uint32_t want) { + if (!ok) return; + const uint32_t a = segment | (site_addr & 0x1FFFFFFFu); + uint32_t got = ~want; + if (a == addr) got = instr; + else if (const auto w = exe_.read_word(a)) got = *w; + if (got != want) { + ok = false; + bad_addr = a; bad_want = want; bad_got = got; + } + }; + for (size_t i = 0; i < site.vertices; i++) + verify(site.fold_addresses[i], site.fold_expected[i]); + verify(site.consumer_address, site.consumer_expected); + if (!ok) { + if (config_.overlay_mode) continue; + fmt::print(stderr, + "ERROR: [[widescreen.cull.sxy]] expected 0x{:08X} at " + "0x{:08X}, found 0x{:08X}\n", + bad_want, bad_addr, bad_got); + std::exit(1); + } + const uint32_t vertex = site.vertex_regs[step]; + const uint32_t other = + get_rs(instr) == vertex ? get_rt(instr) : get_rs(instr); + return fmt::format( + "{} = psx_ws_cull_sxy_and({}, {});" + " /* ws SXY fold (left edge widened) */{}", + reg_name(get_rd(instr)), reg_name(other), reg_name(vertex), + comment); + } + } + // Full-word-guarded object/model participation compares. At 4:3 the // helper returns the comparison's vanilla value; when widescreen reveals // extra world it returns the configured keep verdict. Overlay variants @@ -1507,9 +1556,17 @@ std::string CodeGenerator::translate_instruction_core(uint32_t addr, uint32_t in return fmt::format("{} = psx_ws_cull_lui_hi(0x{:04X}u);" " /* ws explicit screen-x edge (high half) */{}", reg_name(rt), imm, comment); + } else if (opcode == 0x0A && get_imm16_u(instr) == 0 && get_rt(instr) != 0) { + // slti rt,x,0 on `SX << 16`: the bltz sign test as a flag. Set + // only while SX < -margin, exactly like the bltz form. + return fmt::format("{} = (uint32_t)psx_ws_cull_bltz_hi({});" + " /* ws explicit screen-x left edge (high half) */{}", + reg_name(get_rt(instr)), reg_name(get_rs(instr)), + comment); } else if (!config_.overlay_mode) { fmt::print(stderr, "ERROR: [widescreen.cull] screen_x site 0x{:08X} " - "is not sltiu, sltu, lui or bltz (0x{:08X})\n", addr, instr); + "is not sltiu, sltu, lui, slti rt,rs,0 or bltz (0x{:08X})\n", + addr, instr); std::exit(1); } // Overlay variant: the configured main-EXE PC holds different code. @@ -3702,6 +3759,9 @@ void CodeGenerator::emit_runtime_externs(std::ostream& ss) const { ss << "extern int psx_ws_cull_bltz_hi(uint32_t v); /* ws cull left edge, X in the high half (gpu.c) */\n"; ss << "extern int psx_ws_cull_sltu_band(uint32_t sx, uint32_t bound); /* ws screen-x band, W in a register (gpu.c) */\n"; } + // Only for a config that lists [[widescreen.cull.sxy]] sites. + if (!config_.ws_cull_sxy_sites.empty()) + ss << "extern uint32_t psx_ws_cull_sxy_and(uint32_t acc, uint32_t sxy); /* ws SXY reject fold, left edge (gpu.c) */\n"; ss << "extern int psx_ws_cull_vxrange(uint32_t x, uint32_t imm); /* ws masked-u16 X window */\n"; ss << "extern int32_t psx_ws_depth_bound(int32_t imm); /* ws aspect-scaled far bound */\n"; ss << "extern int32_t psx_ws_plane_nx(int32_t nx); /* ws side-plane normal-X scale (gpu.c) */\n"; diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 3524e13a..dd69e470 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -166,6 +166,27 @@ uint32_t overlay_codegen_config_hash(const GameConfig& c) { h.u32(site.result); } + // Appended only when used, so every other title's hash is unchanged. + if (!c.ws_cull_sxy_sites.empty()) { + std::vector sxy_sites = c.ws_cull_sxy_sites; + std::sort(sxy_sites.begin(), sxy_sites.end(), + [](const auto& a, const auto& b) { + return a.fold_addresses.front() < b.fold_addresses.front(); + }); + h.tag("cull_sxy"); + h.u32((uint32_t)sxy_sites.size()); + for (const auto& site : sxy_sites) { + h.u32(site.vertices); + for (size_t i = 0; i < site.vertices; i++) { + h.u32(site.fold_addresses[i]); + h.u32(site.fold_expected[i]); + h.u32(site.vertex_regs[i]); + } + h.u32(site.consumer_address); + h.u32(site.consumer_expected); + } + } + std::vector scale_sites = c.ws_cull_scale_sites; std::sort(scale_sites.begin(), scale_sites.end(), [](const auto& a, const auto& b) { return a.address < b.address; }); @@ -313,6 +334,110 @@ static uint32_t parse_hex(const std::string& s, const std::string& field) { } } +// One [[widescreen.cull.sxy]] entry. The fold and its consumer are identified +// by their exact instruction words; everything the runtime relies on is +// derived from those words and must agree with the declared registers: +// fold[0] = and r, v0, mask (operands in either order) +// fold[k] = and r, vk, r (k >= 1, operands in either order) +// consumer = beq/bne r, zero (within 4 instructions after the last AND) +// `seen` holds the physical addresses claimed by earlier entries. +static WidescreenSxyCullSite parse_sxy_cull_site(const toml::value& item, + const std::string& path, + std::set& seen) { + auto fail = [&](const std::string& why) { + return std::runtime_error(fmt::format( + "{}: [[widescreen.cull.sxy]] {}", path, why)); + }; + WidescreenSxyCullSite site; + const std::string kind = toml::find(item, "kind"); + if (kind == "tri") site.vertices = 3; + else if (kind == "quad") site.vertices = 4; + else throw fail("kind must be \"tri\" or \"quad\""); + + for (int reg : toml::find>(item, "vertex_regs")) { + if (reg < 1 || reg > 31) + throw fail(fmt::format("vertex_regs: {} is not a GPR 1..31", reg)); + site.vertex_regs.push_back((uint32_t)reg); + } + for (const auto& a : toml::find>(item, "fold_addresses")) + site.fold_addresses.push_back(parse_hex(a, "widescreen.cull.sxy.fold_addresses")); + for (const auto& e : toml::find>(item, "fold_expected")) + site.fold_expected.push_back(parse_hex(e, "widescreen.cull.sxy.fold_expected")); + site.consumer_address = parse_hex(toml::find(item, "consumer_address"), + "widescreen.cull.sxy.consumer_address"); + site.consumer_expected = parse_hex(toml::find(item, "consumer_expected"), + "widescreen.cull.sxy.consumer_expected"); + + const size_t n = site.vertices; + if (site.vertex_regs.size() != n || site.fold_addresses.size() != n || + site.fold_expected.size() != n) + throw fail(fmt::format("{} needs exactly {} vertex_regs, fold_addresses " + "and fold_expected", kind, n)); + for (size_t i = 0; i < n; i++) + for (size_t j = i + 1; j < n; j++) + if (site.vertex_regs[i] == site.vertex_regs[j]) + throw fail("vertex_regs must be distinct"); + + // Addresses: aligned, one segment, strictly increasing, and short. + const uint32_t segment = site.fold_addresses[0] & 0xE0000000u; + uint32_t prev = 0; + for (size_t i = 0; i <= n; i++) { + const uint32_t a = i < n ? site.fold_addresses[i] : site.consumer_address; + if ((a & 3u) != 0 || (a & 0xE0000000u) != segment) + throw fail(fmt::format("0x{:08X} must be word-aligned and in the " + "segment of the first AND", a)); + if (i > 0 && a <= prev) + throw fail(fmt::format("0x{:08X} must follow 0x{:08X}", a, prev)); + if (!seen.insert(a & 0x1FFFFFFFu).second) + throw fail(fmt::format("address 0x{:08X} is already used by " + "another sxy site", a)); + prev = a; + } + if (site.fold_addresses[n - 1] - site.fold_addresses[0] > 4u * 15u) + throw fail("the AND fold spans more than 16 instructions"); + if (site.consumer_address - site.fold_addresses[n - 1] > 16u) + throw fail("consumer_address must be within 4 instructions of the last AND"); + + // The words: one AND chain into a single register r. + auto rs_of = [](uint32_t w) { return (w >> 21) & 31u; }; + auto rt_of = [](uint32_t w) { return (w >> 16) & 31u; }; + const uint32_t r = (site.fold_expected[0] >> 11) & 31u; + if (r == 0) throw fail("the AND fold must not write $zero"); + for (size_t i = 0; i < n; i++) { + const uint32_t w = site.fold_expected[i]; + const uint32_t v = site.vertex_regs[i]; + if ((w >> 26) != 0u || (w & 0x7FFu) != 0x24u || ((w >> 11) & 31u) != r) + throw fail(fmt::format("fold_expected 0x{:08X} must be `and r, ...` " + "with the same destination as the first AND", w)); + if (v == r) + throw fail(fmt::format("vertex register {} is the fold register", v)); + const uint32_t other = rs_of(w) == v ? rt_of(w) + : rt_of(w) == v ? rs_of(w) : 32u; + if (other == 32u) + throw fail(fmt::format("fold_expected 0x{:08X} does not read " + "vertex register {}", w, v)); + if (i == 0) { + // The seed operand is the sign mask: a register of its own. + if (other == 0 || other == r || + std::find(site.vertex_regs.begin(), site.vertex_regs.end(), + other) != site.vertex_regs.end()) + throw fail(fmt::format("first AND 0x{:08X} must combine vertex " + "register {} with a separate mask " + "register", w, v)); + } else if (other != r) { + throw fail(fmt::format("fold_expected 0x{:08X} must combine vertex " + "register {} with the fold register {}", w, v, r)); + } + } + const uint32_t c = site.consumer_expected; + const uint32_t c_op = c >> 26; + if ((c_op != 0x04u && c_op != 0x05u) || + !((rs_of(c) == r && rt_of(c) == 0) || (rs_of(c) == 0 && rt_of(c) == r))) + throw fail(fmt::format("consumer_expected 0x{:08X} must be beq/bne of the " + "fold register {} against $zero", c, r)); + return site; +} + // Parse a display aspect "W:H" string (e.g. "4:3", "16:9", "21:9"). Accepts // only aspects between native 4:3 and 32:9 — narrower than 4:3 has no // widescreen meaning and ultra-extreme values would squash the GTE X axis @@ -2362,6 +2487,7 @@ GameConfig load_game_config(const fs::path& config_path_in) { std::vector ws_cull_clip_edge_x_load_sites; uint32_t ws_cull_clip_edge_width = 0; std::vector ws_cull_keep_sites; + std::vector ws_cull_sxy_sites; std::vector ws_cull_masked_reject_sites; std::vector ws_cull_scale_sites; std::vector ws_cull_angle_sites; @@ -2462,6 +2588,17 @@ GameConfig load_game_config(const fs::path& config_path_in) { ws_cull_keep_sites.push_back(site); } } + if (cull.contains("sxy")) { + std::set seen; + for (const auto& item : toml::find(cull, "sxy")) { + ws_cull_sxy_sites.push_back(parse_sxy_cull_site( + item, config_path.string(), seen)); + if (ws_cull_sxy_sites.size() > 64) + throw std::runtime_error(fmt::format( + "{}: [[widescreen.cull.sxy]] supports at most 64 sites", + config_path.string())); + } + } if (cull.contains("scale")) { std::set seen; for (const auto& item : toml::find(cull, "scale")) { @@ -2904,6 +3041,7 @@ GameConfig load_game_config(const fs::path& config_path_in) { /*ws_cull_nclip_exact_sites*/ ws_cull_nclip_exact_sites, /*ws_cull_branch_keep_sites*/ ws_cull_branch_keep_sites, /*ws_cull_keep_sites*/ ws_cull_keep_sites, + /*ws_cull_sxy_sites*/ ws_cull_sxy_sites, /*ws_cull_scale_sites*/ ws_cull_scale_sites, /*ws_cull_angle_sites*/ ws_cull_angle_sites, /*ws_aspect_cone*/ ws_aspect_cone, diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 626ceb65..584a08a4 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -99,6 +99,21 @@ struct WidescreenCullKeepSite { uint32_t result = 0; // forced comparison result (0 or 1) }; +// One packed-SXY all-vertices reject ([[widescreen.cull.sxy]]): an AND fold +// over the projected SXY of every vertex of a triangle or quad, seeded with a +// sign mask and consumed by `beq/bne r, zero`. Each AND is widened only in +// its X-sign bit (ws_cull_edge.h psx_ws_sxy_and_value); the guest's own +// branch still decides. Every word of the fold and of the consumer is part of +// the identity: an overlay variant whose words differ anywhere is untouched. +struct WidescreenSxyCullSite { + uint32_t vertices = 0; // 3 (kind = "tri") or 4 ("quad") + std::vector fold_addresses; // one AND per vertex, chain order + std::vector fold_expected; // the exact AND words + std::vector vertex_regs; // the SXY operand of each AND + uint32_t consumer_address = 0; // beq/bne r, zero after the fold + uint32_t consumer_expected = 0; +}; + // Aspect-scaled 12-bit angular half-extent. These sites load a positive angle // constant with `addi[u] rt,zero,imm`; the runtime widens tan(angle) by the // live horizontal reveal factor. Full-word guards prevent overlay-address @@ -1276,6 +1291,8 @@ struct GameConfig { // where maximal overdraw is preferable to range guessing. Each entry is // guarded by the complete MIPS word; 4:3 executes the vanilla comparison. std::vector ws_cull_keep_sites; + // Packed-SXY all-vertices rejects widened only on the left edge. + std::vector ws_cull_sxy_sites; // Register-register frustum-edge compares whose bound operand scales // with the live reveal ([[widescreen.cull.scale]]). std::vector ws_cull_scale_sites; diff --git a/recompiler/src/main_psx.cpp b/recompiler/src/main_psx.cpp index f7b87e7c..e5a4d36c 100644 --- a/recompiler/src/main_psx.cpp +++ b/recompiler/src/main_psx.cpp @@ -245,6 +245,7 @@ static int psxrecomp_game_main(int argc, char** argv) { std::set ws_cull_clip_edge_x_load; // [widescreen.cull] clip_edge_x_load_sites uint32_t ws_cull_clip_edge_width = 0; // [widescreen.cull] clip_edge_width std::vector ws_cull_keep; + std::vector ws_cull_sxy; std::vector ws_cull_scale; std::vector ws_cull_angle; std::vector draw_distance_clamps; // [[draw_distance.clamp]] @@ -324,6 +325,7 @@ static int psxrecomp_game_main(int argc, char** argv) { if (!cfg.ws_cull_clip_edge_x_load_sites.empty()) ws_cull_clip_edge_width = PSXRecompV4::ws_cull_clip_edge_width(cfg); ws_cull_keep = cfg.ws_cull_keep_sites; + ws_cull_sxy = cfg.ws_cull_sxy_sites; ws_cull_masked_reject = cfg.ws_cull_masked_reject_sites; ws_cull_scale = cfg.ws_cull_scale_sites; ws_cull_angle = cfg.ws_cull_angle_sites; @@ -442,6 +444,7 @@ static int psxrecomp_game_main(int argc, char** argv) { if (!wscfg.ws_cull_clip_edge_x_load_sites.empty() && !ws_cull_clip_edge_width) ws_cull_clip_edge_width = PSXRecompV4::ws_cull_clip_edge_width(wscfg); if (ws_cull_keep.empty()) ws_cull_keep = wscfg.ws_cull_keep_sites; + if (ws_cull_sxy.empty()) ws_cull_sxy = wscfg.ws_cull_sxy_sites; if (ws_cull_masked_reject.empty()) ws_cull_masked_reject = wscfg.ws_cull_masked_reject_sites; if (ws_cull_scale.empty()) ws_cull_scale = wscfg.ws_cull_scale_sites; if (ws_cull_angle.empty()) ws_cull_angle = wscfg.ws_cull_angle_sites; @@ -672,6 +675,30 @@ static int psxrecomp_game_main(int argc, char** argv) { check("widescreen.bg2d.bufbase_site", ws_bg2d_bufbase_site); check("widescreen.bg2d.cap_site", ws_bg2d_cap_site); check("widescreen.bg2d.init_func", ws_bg2d_init_func); + // [[widescreen.cull.sxy]] sites are matched physically, and every + // word of each fold and its consumer is part of the identity: check + // them all against the image up front, so a stale address fails the + // build even when no discovered function reaches the fold. + for (const auto& site : ws_cull_sxy) { + for (size_t i = 0; i <= site.vertices; i++) { + const bool consumer = i == site.vertices; + const uint32_t phys = (consumer ? site.consumer_address + : site.fold_addresses[i]) & + PSXRecomp::kPhysMask; + const uint32_t want = consumer ? site.consumer_expected + : site.fold_expected[i]; + const uint32_t a = exe->link_segment() | phys; + if (!exe->contains_phys(a)) continue; + const auto got = exe->read_word(a); + if (!got || *got != want) { + fmt::print(stderr, + "ERROR: [[widescreen.cull.sxy]] expected 0x{:08X} at " + "0x{:08X}, found 0x{:08X}\n", + want, a, got ? *got : 0u); + return 1; + } + } + } if (!foreign.empty()) { fmt::print(stderr, "ERROR: {} config code site(s) name this image's bytes in another " @@ -1550,6 +1577,7 @@ static int psxrecomp_game_main(int argc, char** argv) { else if (!ws_cull_w_imms.empty()) codegen_config.ws_cull_clip_edge_width = ws_cull_w_imms.front(); codegen_config.ws_cull_keep_sites = ws_cull_keep; + codegen_config.ws_cull_sxy_sites = ws_cull_sxy; codegen_config.ws_cull_scale_sites = ws_cull_scale; codegen_config.ws_cull_angle_sites = ws_cull_angle; codegen_config.draw_distance_clamp_sites = draw_distance_clamps; diff --git a/recompiler/src/segment_variants.cpp b/recompiler/src/segment_variants.cpp index 39a7ed0c..f7b9158e 100644 --- a/recompiler/src/segment_variants.cpp +++ b/recompiler/src/segment_variants.cpp @@ -190,8 +190,8 @@ CodeGenConfig move_exact_config_sites(const CodeGenConfig& cfg, At at) { v.ws_bg2d_bufbase_site = at(v.ws_bg2d_bufbase_site); v.ws_bg2d_cap_site = at(v.ws_bg2d_cap_site); // Physically matched, kept as written: ws_signed_x_bound_sites, - // ws_cull_keep_sites, ws_cull_angle_sites, ws_aspect_cone.sites, - // draw_distance_clamp_sites. + // ws_cull_keep_sites, ws_cull_sxy_sites, ws_cull_angle_sites, + // ws_aspect_cone.sites, draw_distance_clamp_sites. return v; } diff --git a/recompiler/tests/ws_cull_edge_codegen_test.cpp b/recompiler/tests/ws_cull_edge_codegen_test.cpp index c0655eec..a3e6c937 100644 --- a/recompiler/tests/ws_cull_edge_codegen_test.cpp +++ b/recompiler/tests/ws_cull_edge_codegen_test.cpp @@ -415,6 +415,278 @@ reject_mask = "0xFFFF0000" "overlay with another instruction stays vanilla"); } +// ---- [[widescreen.cull.sxy]] ---------------------------------------------- +// The Wild 9 (SLUS-00425) shapes, relocated to kBase. Tri: the fold has GTE +// reads and stores between its ANDs and one instruction before the consumer. +// +00 and at,t0,s1 +04 mfc2 t3,$14 +08 and at,s4,at +0C sw t0,8(a0) +// +10 and at,t3,at +14 sub t0,s0,t0 +18 bne at,zero,+1 ; nop ; jr ra ; nop +// Quad: four contiguous ANDs, then the consumer. +const std::vector kTriWords = { + 0x01110824u, 0x480B7000u, 0x02810824u, 0xAC880008u, 0x01610824u, + 0x02084022u, 0x14200001u, 0x00000000u, 0x03E00008u, 0x00000000u}; +const std::vector kQuadWords = { + 0x01110824u, 0x02810824u, 0x01610824u, 0x01210824u, 0x14200001u, + 0x00000000u, 0x03E00008u, 0x00000000u}; + +PSXRecomp::GeneratedFunction generate_words(const std::vector& words, + const PSXRecomp::CodeGenConfig& config) { + PSXRecomp::PS1Executable exe{}; + exe.header.load_address = kBase; + exe.header.initial_pc = kBase; + exe.header.file_size = (uint32_t)(4 * words.size()); + for (uint32_t w : words) append_word(exe.code_data, w); + PSXRecomp::Function function{}; + function.start_addr = kBase; + function.end_addr = kBase + 4u * (uint32_t)words.size(); + function.size = 4u * (uint32_t)words.size(); + function.name = "ws_cull_sxy_test"; + PSXRecomp::ControlFlowAnalyzer analyzer(exe); + const auto cfg = analyzer.analyze_function(function); + PSXRecomp::CodeGenerator generator(exe, config); + return generator.generate_function(function, cfg); +} + +struct SxyToml { + std::string kind = "tri"; + std::string vertex_regs = "[8, 20, 11]"; + std::string fold_addresses = R"(["0x80010000", "0x80010008", "0x80010010"])"; + std::string fold_expected = R"(["0x01110824", "0x02810824", "0x01610824"])"; + std::string consumer_address = "0x80010018"; + std::string consumer_expected = "0x14200001"; + std::string str() const { + return "\n[[widescreen.cull.sxy]]\nkind = \"" + kind + "\"\n" + + "vertex_regs = " + vertex_regs + "\n" + + "fold_addresses = " + fold_addresses + "\n" + + "fold_expected = " + fold_expected + "\n" + + "consumer_address = \"" + consumer_address + "\"\n" + + "consumer_expected = \"" + consumer_expected + "\"\n"; + } +}; + +SxyToml quad_toml() { + SxyToml q; + q.kind = "quad"; + q.vertex_regs = "[8, 20, 11, 9]"; + q.fold_addresses = R"(["0x80010000", "0x80010004", "0x80010008", "0x8001000C"])"; + q.fold_expected = R"(["0x01110824", "0x02810824", "0x01610824", "0x01210824"])"; + q.consumer_address = "0x80010010"; + return q; +} + +void sxy_loader() { + const auto tri = load(base_config() + SxyToml{}.str()); + check(tri.ws_cull_sxy_sites.size() == 1, "loader reads a tri sxy site"); + const auto& t = tri.ws_cull_sxy_sites.at(0); + check(t.vertices == 3 && t.vertex_regs == std::vector({8, 20, 11}) && + t.fold_addresses == std::vector({0x80010000u, 0x80010008u, 0x80010010u}) && + t.fold_expected == std::vector({0x01110824u, 0x02810824u, 0x01610824u}) && + t.consumer_address == 0x80010018u && t.consumer_expected == 0x14200001u, + "tri sxy site fields"); + const auto quad = load(base_config() + quad_toml().str()); + check(quad.ws_cull_sxy_sites.size() == 1 && quad.ws_cull_sxy_sites[0].vertices == 4, + "loader reads a quad sxy site"); + check(load(base_config()).ws_cull_sxy_sites.empty(), "sxy defaults to empty"); + + // Operands may be written in either order; beq is a consumer as well. + SxyToml swapped; + swapped.fold_expected = R"(["0x02280824", "0x00340824", "0x002B0824"])"; + swapped.consumer_expected = "0x10010001"; + check(load(base_config() + swapped.str()).ws_cull_sxy_sites.size() == 1, + "swapped AND operands and a beq zero,at consumer are accepted"); + + auto bad = [&](SxyToml site, const char* needle, const char* what) { + check(load_throws_with(base_config() + site.str(), needle), what); + }; + SxyToml s; + s = {}; s.kind = "pent"; + bad(s, "kind must be", "unknown kind"); + s = {}; s.vertex_regs = "[8, 20]"; + bad(s, "needs exactly 3", "tri with two vertex_regs"); + s = quad_toml(); s.fold_expected = R"(["0x01110824", "0x02810824", "0x01610824"])"; + bad(s, "needs exactly 4", "quad with three AND words"); + s = {}; s.vertex_regs = "[8, 8, 11]"; + bad(s, "must be distinct", "repeated vertex register"); + s = {}; s.vertex_regs = "[8, 20, 0]"; + bad(s, "not a GPR", "$zero vertex register"); + s = {}; s.vertex_regs = "[8, 20, 1]"; + bad(s, "is the fold register", "vertex register equal to the fold register"); + s = {}; s.vertex_regs = "[8, 20, 12]"; + bad(s, "does not read vertex register 12", "AND does not read its vertex"); + s = {}; s.vertex_regs = "[8, 20, 17]"; + s.fold_expected = R"(["0x01140824", "0x02810824", "0x02210824"])"; + bad(s, "separate mask register", "first AND combines two vertices"); + s = {}; s.fold_expected = R"(["0x01110824", "0x02811024", "0x01610824"])"; + bad(s, "same destination", "AND into another register"); + s = {}; s.fold_expected = R"(["0x01110825", "0x02810824", "0x01610824"])"; + bad(s, "must be `and r", "OR in the fold"); + s = {}; s.fold_expected = R"(["0x01110824", "0x02910824", "0x01610824"])"; + bad(s, "with the fold register", "later AND not chained through the fold register"); + s = {}; s.fold_expected = R"(["0x01100024", "0x02800024", "0x01600024"])"; + bad(s, "must not write $zero", "fold into $zero"); + s = {}; s.consumer_expected = "0x14220001"; + bad(s, "beq/bne of the fold register", "consumer compares with another register"); + s = {}; s.consumer_expected = "0x04200001"; + bad(s, "beq/bne of the fold register", "consumer is bltz"); + s = {}; s.consumer_address = "0x8001002C"; + bad(s, "within 4 instructions", "consumer too far from the fold"); + s = {}; s.fold_addresses = R"(["0x80010008", "0x80010000", "0x80010010"])"; + bad(s, "must follow", "fold addresses out of order"); + s = {}; s.consumer_address = "0x80010010"; + bad(s, "must follow", "consumer on the last AND"); + s = {}; s.fold_addresses = R"(["0x80010000", "0xA0010008", "0x80010010"])"; + bad(s, "segment of the first AND", "fold crossing segments"); + s = {}; s.fold_addresses = R"(["0x80010000", "0x8001000A", "0x80010010"])"; + bad(s, "word-aligned", "misaligned AND"); + check(load_throws_with(base_config() + SxyToml{}.str() + quad_toml().str(), + "already used by another sxy site"), + "two sites sharing a physical address"); +} + +void sxy_hash_identity() { + const auto plain = load(base_config()); + const auto with = load(base_config() + SxyToml{}.str()); + check(PSXRecompV4::overlay_codegen_config_hash(plain) != + PSXRecompV4::overlay_codegen_config_hash(with), + "an sxy site changes the overlay-cache identity"); + auto changed = with; + changed.ws_cull_sxy_sites[0].consumer_expected = 0x10200001u; + check(PSXRecompV4::overlay_codegen_config_hash(changed) != + PSXRecompV4::overlay_codegen_config_hash(with), + "the consumer word is part of the overlay-cache identity"); + changed = with; + changed.ws_cull_sxy_sites[0].vertex_regs[2] = 12u; + check(PSXRecompV4::overlay_codegen_config_hash(changed) != + PSXRecompV4::overlay_codegen_config_hash(with), + "the vertex registers are part of the overlay-cache identity"); +} + +bool has_text(const std::string& code, const char* needle) { + return code.find(needle) != std::string::npos; +} + +void sxy_codegen() { + PSXRecomp::CodeGenConfig config{}; + const auto vanilla_tri = generate_words(kTriWords, config).full_code; + check(!has_text(vanilla_tri, "psx_ws_cull_sxy_and"), "no sxy site: vanilla ANDs"); + + config.ws_cull_sxy_sites = load(base_config() + SxyToml{}.str()).ws_cull_sxy_sites; + const auto tri = generate_words(kTriWords, config).full_code; + check(has_text(tri, "cpu->gpr[1] = psx_ws_cull_sxy_and(cpu->gpr[17], cpu->gpr[8]);"), + "tri AND 0 seeds from the mask with vertex t0"); + check(has_text(tri, "cpu->gpr[1] = psx_ws_cull_sxy_and(cpu->gpr[1], cpu->gpr[20]);"), + "tri AND 1 folds vertex s4"); + check(has_text(tri, "cpu->gpr[1] = psx_ws_cull_sxy_and(cpu->gpr[1], cpu->gpr[11]);"), + "tri AND 2 folds vertex t3"); + check(!has_text(tri, "cpu->gpr[1] = cpu->gpr[8] & cpu->gpr[17];"), + "tri vanilla AND is replaced"); + check(!exits_with_failure([&] { generate_words(kTriWords, config); }), + "a matching tri fold generates"); + + PSXRecomp::CodeGenConfig qconfig{}; + qconfig.ws_cull_sxy_sites = load(base_config() + quad_toml().str()).ws_cull_sxy_sites; + const auto quad = generate_words(kQuadWords, qconfig).full_code; + check(has_text(quad, "cpu->gpr[1] = psx_ws_cull_sxy_and(cpu->gpr[17], cpu->gpr[8]);") && + has_text(quad, "cpu->gpr[1] = psx_ws_cull_sxy_and(cpu->gpr[1], cpu->gpr[20]);") && + has_text(quad, "cpu->gpr[1] = psx_ws_cull_sxy_and(cpu->gpr[1], cpu->gpr[11]);") && + has_text(quad, "cpu->gpr[1] = psx_ws_cull_sxy_and(cpu->gpr[1], cpu->gpr[9]);"), + "quad folds all four vertices"); + + // The guest's own consumer branch is untouched. + check(has_text(quad, "cpu->gpr[1] != cpu->gpr[0]") || has_text(quad, "cpu->gpr[1] != 0"), + "the consumer bne is the vanilla branch"); + + // Any word of the fold or the consumer that differs: main EXE fails, + // an overlay variant keeps every AND vanilla. + for (size_t i : {size_t(1), size_t(2), size_t(6)}) { + auto words = kTriWords; + if (i == 1) words[2] = 0x02810825u; // the second AND is an OR + if (i == 2) words[4] = 0x01710824u; // the last AND reads s1 + if (i == 6) words[6] = 0x10200001u; // beq instead of bne + check(exits_with_failure([&] { generate_words(words, config); }), + "a mismatching tri fold fails main-EXE generation"); + auto overlay = config; + overlay.overlay_mode = true; + const auto code = generate_words(words, overlay).full_code; + check(!has_text(code, "psx_ws_cull_sxy_and"), + "an overlay variant with any mismatching word stays vanilla"); + } + auto overlay = config; + overlay.overlay_mode = true; + check(has_text(generate_words(kTriWords, overlay).full_code, + "psx_ws_cull_sxy_and(cpu->gpr[17], cpu->gpr[8])"), + "an overlay with the exact fold emits the helper"); + + // Declared only for a config that lists sxy sites. + PSXRecomp::PS1Executable exe{}; + exe.header.load_address = kBase; + std::vector functions; + check(!has_text(PSXRecomp::CodeGenerator(exe).build_shared_decls_header(functions), + "psx_ws_cull_sxy_and"), + "a config without sxy sites does not declare the helper"); + check(has_text(PSXRecomp::CodeGenerator(exe, config).build_shared_decls_header(functions), + "extern uint32_t psx_ws_cull_sxy_and(uint32_t acc, uint32_t sxy);"), + "a config with sxy sites declares the helper"); +} + +uint32_t pack_sxy(int x, int y) { + return ((uint32_t)(uint16_t)(int16_t)y << 16) | (uint16_t)(int16_t)x; +} + +uint32_t sxy_fold(const uint32_t* v, int n, uint32_t mask, int32_t m) { + uint32_t r = psx_ws_sxy_and_value(mask, v[0], m); + for (int k = 1; k < n; k++) r = psx_ws_sxy_and_value(r, v[k], m); + return r; +} + +// Every tri (and a quad sweep) over the edge values: at margin 0 the fold is +// the vanilla AND word for any mask; widened, it differs from vanilla only in +// bit 15, which then means "every X < -m". Bit 31 (all Y above the screen) +// and the right/bottom side (a separate chain in the guest) are unchanged. +void sxy_runtime_math() { + const int xs[] = {-1024, -200, -54, -53, -52, -1, 0, 1, 160, 319, 320, 1023}; + const int ys[] = {-1024, -1, 0, 1, 239, 240}; + std::vector pts; + for (int x : xs) for (int y : ys) pts.push_back(pack_sxy(x, y)); + const uint32_t masks[] = {0x80008000u, 0xFFFFFFFFu, 0x00008000u, 0x80000000u}; + int bad0 = 0, badm = 0; + for (uint32_t mask : masks) + for (uint32_t a : pts) for (uint32_t b : pts) for (uint32_t c : pts) { + const uint32_t v[3] = {a, b, c}; + const uint32_t vanilla = mask & a & b & c; + if (sxy_fold(v, 3, mask, 0) != vanilla) bad0++; + for (int32_t m : {53, 192}) { + bool all_left = true; + for (uint32_t p : v) all_left &= (int16_t)(p & 0xFFFFu) < -m; + const uint32_t want = (vanilla & ~0x8000u) | + (all_left ? (vanilla & 0x8000u) : 0u); + if (sxy_fold(v, 3, mask, m) != want) badm++; + } + } + for (uint32_t a : pts) for (uint32_t d : pts) { + const uint32_t v[4] = {a, pack_sxy(-300, 10), pack_sxy(-60, -5), d}; + const uint32_t vanilla = 0x80008000u & v[0] & v[1] & v[2] & v[3]; + if (sxy_fold(v, 4, 0x80008000u, 0) != vanilla) bad0++; + bool all_left = true; + for (uint32_t p : v) all_left &= (int16_t)(p & 0xFFFFu) < -53; + const uint32_t want = (vanilla & ~0x8000u) | (all_left ? (vanilla & 0x8000u) : 0u); + if (sxy_fold(v, 4, 0x80008000u, 53) != want) badm++; + } + check(bad0 == 0, "the sxy fold is the vanilla AND at margin 0"); + check(badm == 0, "widened, the sxy fold differs only in bit 15 = every X < -m"); + + // Spot checks at 16:9 on 320 wide (margin 53), mask 0x80008000. + const uint32_t mask = 0x80008000u; + uint32_t v[3] = {pack_sxy(-60, 10), pack_sxy(-54, 20), pack_sxy(-100, 30)}; + check(sxy_fold(v, 3, mask, 53) == 0x8000u, "all vertices left of -m: rejected"); + v[1] = pack_sxy(-53, 20); + check(sxy_fold(v, 3, mask, 53) == 0u, "one vertex in the revealed band: kept"); + check(sxy_fold(v, 3, mask, 0) == 0x8000u, "the same triangle is rejected at 4:3"); + uint32_t up[3] = {pack_sxy(-10, -1), pack_sxy(100, -5), pack_sxy(5, -9)}; + check(sxy_fold(up, 3, mask, 53) == 0x80000000u, "all above the screen: still rejected"); + check(psx_ws_sxy_and_value(0x8000u, pack_sxy(-5, 0), -7) == 0x8000u, + "a negative margin is treated as 0"); +} + void runtime_math() { // Identity at 4:3 (margin 0) for every input. for (int32_t v : {-1000, -54, -53, -1, 0, 1, 98, 222, 319, 320, 321, 1000}) { @@ -460,6 +732,10 @@ int main(int argc, char** argv) { shared_decls_include_new_helpers(); runtime_math(); masked_reject_sites(); + sxy_loader(); + sxy_hash_identity(); + sxy_codegen(); + sxy_runtime_math(); if (g_exit_probe >= 0) return 3; // no call had that probe index if (failures != 0) { diff --git a/recompiler/tests/ws_cull_screen_x_hi_codegen_test.cpp b/recompiler/tests/ws_cull_screen_x_hi_codegen_test.cpp index bae857d2..5ea46ef0 100644 --- a/recompiler/tests/ws_cull_screen_x_hi_codegen_test.cpp +++ b/recompiler/tests/ws_cull_screen_x_hi_codegen_test.cpp @@ -117,6 +117,8 @@ constexpr uint32_t kBltzT0 = 0x05000002u; // bltz t0, +2 constexpr uint32_t kBgezT0 = 0x05010002u; // bgez t0, +2 constexpr uint32_t kBltzalT0 = 0x05100002u; // bltzal t0, +2 constexpr uint32_t kAddiuV0 = 0x24820070u; // addiu v0, a0, 0x70 +constexpr uint32_t kSltiAtS6 = 0x2AC10000u; // slti at, s6, 0 (s6 = SX << 16) +constexpr uint32_t kSltiAtS6Imm = 0x2AC10010u; // slti at, s6, 0x10 const char* const kLuiHelper = "cpu->gpr[22] = psx_ws_cull_lui_hi(0x0200u); " @@ -182,6 +184,33 @@ void listed_sltu_emits_the_band_helper() { "a screen_x site on sltu generates"); } +// slti rt,x,0 on `SX << 16` is the bltz sign test as a flag: set only while +// SX < -margin through the same helper. Any other slti immediate is not this +// form and fails the main-EXE build; an overlay variant keeps its code. +void listed_slti_zero_emits_the_high_half_test() { + PSXRecomp::CodeGenConfig config{}; + config.emit_comments = true; + const auto plain = generate_first_instruction(kSltiAtS6, config); + check(!has(plain, "psx_ws_cull_bltz_hi"), "an unlisted slti stays vanilla"); + config.ws_cull_screen_x_sites.insert(kBase); + check(has(generate_first_instruction(kSltiAtS6, config), + "cpu->gpr[1] = (uint32_t)psx_ws_cull_bltz_hi(cpu->gpr[22]); " + "/* ws explicit screen-x left edge (high half) */"), + "a screen_x site on slti rt,rs,0 routes through psx_ws_cull_bltz_hi"); + check(!exits_with_failure([&] { generate_first_instruction(kSltiAtS6, config); }), + "a screen_x site on slti rt,rs,0 generates"); + check(exits_with_failure([&] { generate_first_instruction(kSltiAtS6Imm, config); }), + "a screen_x site on slti with a nonzero immediate fails main-EXE generation"); + config.overlay_mode = true; + check(!has(generate_first_instruction(kSltiAtS6Imm, config), "psx_ws_cull_"), + "an overlay slti with a nonzero immediate at a screen_x site stays vanilla"); + for (int32_t x : {-1000, -54, -53, -1, 0, 1, 319}) { + const uint32_t v = (uint32_t)x << 16; + check(psx_ws_cull_bltz_hi_value(v, 0) == (((int32_t)v < 0) ? 1 : 0), + "the slti form is the vanilla slti at margin 0"); + } +} + // bltz_sites is the low-half form and keeps its meaning when an address is in // both lists. void bltz_sites_wins_over_screen_x_sites() { @@ -301,6 +330,7 @@ int main(int argc, char** argv) { unlisted_instructions_stay_vanilla(); listed_sites_emit_the_helpers(); listed_sltu_emits_the_band_helper(); + listed_slti_zero_emits_the_high_half_test(); bltz_sites_wins_over_screen_x_sites(); main_exe_guard_and_overlay_variants(); declarations_only_when_the_key_is_used(); diff --git a/runtime/include/gpu.h b/runtime/include/gpu.h index 9e6289b2..08550135 100644 --- a/runtime/include/gpu.h +++ b/runtime/include/gpu.h @@ -346,6 +346,17 @@ void gpu_ws_set_clip_edge_x_load_sites(const uint32_t *sites, int nsites, void gpu_ws_set_cull_keep_sites(const uint32_t *addresses, const uint32_t *expected, const uint32_t *results, int nsites); +/* [[widescreen.cull.sxy]]: packed-SXY all-vertices reject folds. Per site i, + * vertices[i] (3 or 4) ANDs at fold_addresses[i*4 + k] with words + * fold_expected[i*4 + k] and SXY operand vertex_regs[i*4 + k], then the + * beq/bne consumer. The recompiler config loader has validated the shape. */ +#define PSX_WS_SXY_CULL_SITES_MAX 64 +void gpu_ws_set_sxy_cull_sites(const uint32_t *vertices, + const uint32_t *fold_addresses, + const uint32_t *fold_expected, + const uint32_t *vertex_regs, + const uint32_t *consumer_addresses, + const uint32_t *consumer_expected, int nsites); void gpu_ws_set_angle_sites(const uint32_t *addresses, const uint32_t *expected, int nsites); /* [[widescreen.cull.scale]]: full-word-guarded SLT/SLTU sites whose rs (0) or @@ -399,6 +410,14 @@ uint32_t psx_ws_clip_edge_width(void); uint32_t psx_ws_cull_keep_result(uint32_t vanilla, uint32_t forced); int psx_ws_cull_keep_site(uint32_t pc, uint32_t instr, uint32_t vanilla, uint32_t *out); +/* One AND of a [[widescreen.cull.sxy]] fold (ws_cull_edge.h); identity at 4:3. */ +uint32_t psx_ws_cull_sxy_and(uint32_t acc, uint32_t sxy); +/* Interpreter lookup: the SXY operand register (1..31) when `instr` at `pc` + * is an AND of a configured fold and every fold and consumer word, read + * through `read_word` (physical address), matches; else 0. */ +extern int g_psx_ws_sxy_cull_n; +uint32_t psx_ws_sxy_cull_vertex_reg(uint32_t pc, uint32_t instr, + uint32_t (*read_word)(uint32_t phys)); uint32_t psx_ws_angle_widen(uint32_t vanilla); int psx_ws_angle_site(uint32_t pc, uint32_t instr, uint32_t *out); /* bound * (half_extent + margin) / half_extent; identity at 4:3. */ diff --git a/runtime/include/overlay_dispatch_preamble.c.inc b/runtime/include/overlay_dispatch_preamble.c.inc index 0473b4bf..16e056d6 100644 --- a/runtime/include/overlay_dispatch_preamble.c.inc +++ b/runtime/include/overlay_dispatch_preamble.c.inc @@ -293,6 +293,10 @@ int32_t psx_ws_plane_nx(int32_t nx) { uint32_t psx_ws_xclip_bound(uint32_t vanilla) { return g_cbs.ws_xclip_bound ? g_cbs.ws_xclip_bound(vanilla) : vanilla; } +/* [[widescreen.cull.sxy]] fold AND (ws_cull_edge.h). MUST match gpu.c. */ +uint32_t psx_ws_cull_sxy_and(uint32_t acc, uint32_t sxy) { + return psx_ws_sxy_and_value(acc, sxy, psx_ws_x_margin()); +} uint32_t psx_ws_cull_keep_result(uint32_t vanilla, uint32_t forced) { return g_cbs.ws_cull_keep_result ? g_cbs.ws_cull_keep_result(vanilla, forced) diff --git a/runtime/include/ws_cull_edge.h b/runtime/include/ws_cull_edge.h index 43ea28cb..0307f1ab 100644 --- a/runtime/include/ws_cull_edge.h +++ b/runtime/include/ws_cull_edge.h @@ -47,6 +47,26 @@ * The `lui` moves to (W + m) << 16 and the `bltz` is taken only while * SX < -m. bltz_sites does not fit this `bltz`: it compares the whole * register with -m, and every negative `SX << 16` is far below that. + * `slti rt, x, 0` on the same `SX << 16` value is the same sign test as a + * flag instead of a branch, and screen_x_sites widens it identically. + * + * [[widescreen.cull.sxy]] -- the all-vertices trivial reject over packed GTE + * SXY words (Y in the high half, X in the low half), folded with AND: + * + * and r, v0, mask ; mask holds the sign bits 0x80008000 + * and r, v1, r + * and r, v2, r ; (and r, v3, r for a quad) + * bne r, zero, reject + * + * Bit 31 of r survives only if every Y is negative (all above the screen) + * and bit 15 only if every X is negative (all left of it). Each AND of the + * chain becomes psx_ws_sxy_and_value(acc, v, m): the vanilla AND, except that + * bit 15 also needs this vertex's X < -m. The fold then rejects to the left + * only when every vertex is left of the revealed edge. Every other bit, + * including the vertical bit 31, is the vanilla AND, and at m == 0 the whole + * word is the vanilla AND. Each step reads its operands when the guest's + * own AND does, so the chain stays exact whatever the guest does between + * steps. * ========================================================================== */ #include @@ -92,6 +112,17 @@ static inline int psx_ws_cull_bltz_hi_value(uint32_t v, int32_t m) return (int32_t)v < -((m > 0 ? m : 0) * 65536); } +/* One AND of a [[widescreen.cull.sxy]] fold: `acc & sxy`, keeping bit 15 (X + * sign) only while this vertex's X (low half of `sxy`) is left of -m. */ +static inline uint32_t psx_ws_sxy_and_value(uint32_t acc, uint32_t sxy, + int32_t m) +{ + const uint32_t r = acc & sxy; + if (m <= 0 || !(r & 0x8000u)) return r; + if ((int32_t)(int16_t)(uint16_t)sxy < -m) return r; + return r & ~0x8000u; +} + #ifdef __cplusplus } #endif diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 8ee6ec0d..22179754 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -576,6 +576,12 @@ static inline uint32_t fetch_word(uint32_t phys) { | ((uint32_t)ram[phys + 3] << 24); } +/* Word reader for the [[widescreen.cull.sxy]] whole-fold check: the fold + * words around an interpreted PC are in main RAM like the PC itself. */ +static uint32_t sxy_fold_word(uint32_t phys) { + return phys < 0x00800000u ? fetch_word(phys) : 0xFFFFFFFFu; +} + /* Widescreen render-funnel cull detection for the interpreter ([widescreen.cull] * auto_screen_x). A `sltiu …,0x140/0x141` is widened ONLY when its enclosing * render function also carries a `sltiu …,0xE0/0xF1` (the GTE per-vertex trivial- @@ -1967,7 +1973,15 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, } case 0x24: { /* AND */ uint32_t a = cpu->gpr[rs], b = cpu->gpr[rt]; - cpu->gpr[rd] = a & b; + /* [[widescreen.cull.sxy]] fold AND: the same helper and the same + * whole-fold word check as the recompiled emit. */ + const uint32_t sxy_v = g_psx_ws_sxy_cull_n > 0 + ? psx_ws_sxy_cull_vertex_reg(pc, insn, sxy_fold_word) : 0u; + if (sxy_v != 0u) + cpu->gpr[rd] = psx_ws_cull_sxy_and(rs == sxy_v ? b : a, + cpu->gpr[sxy_v]); + else + cpu->gpr[rd] = a & b; psx_pgxp_alu(cpu, insn, cpu->gpr[rd], a, b); cpu->gpr[0] = 0; return 0; @@ -2227,6 +2241,10 @@ static int exec_one_fetched_inner(CPUState *cpu, uint32_t pc, uint32_t insn, cpu->gpr[rt] = kept; else if (psx_ws_is_cull_depth_site(pc)) cpu->gpr[rt] = ((int32_t)cpu->gpr[rs] < psx_ws_depth_bound(simm)) ? 1u : 0u; + /* screen_x_sites on `slti rt, x, 0` with `x = SX << 16`: the bltz + * sign test as a flag (same order as the recompiled emit). */ + else if (imm == 0u && psx_ws_is_cull_screen_x_site(pc)) + cpu->gpr[rt] = (uint32_t)psx_ws_cull_bltz_hi(cpu->gpr[rs]); /* Widescreen render-funnel RIGHT-edge widen (auto_screen_x) for the * signed min/max funnel idiom (`slti v, minSX, W`) — the paired left * edge is the bltz above. Identity at 4:3 (margin 0). */ diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 0e50a7cd..2b1deaac 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -1086,6 +1086,63 @@ int psx_ws_cull_keep_site(uint32_t pc, uint32_t instr, uint32_t vanilla, return 0; } +typedef struct { + uint32_t vertices; + uint32_t fold_address[4]; /* physical */ + uint32_t fold_expected[4]; + uint32_t vertex_reg[4]; + uint32_t consumer_address; /* physical */ + uint32_t consumer_expected; +} WsSxyCullSite; +static WsSxyCullSite ws_sxy_cull_sites[PSX_WS_SXY_CULL_SITES_MAX]; +int g_psx_ws_sxy_cull_n = 0; +void gpu_ws_set_sxy_cull_sites(const uint32_t *vertices, + const uint32_t *fold_addresses, + const uint32_t *fold_expected, + const uint32_t *vertex_regs, + const uint32_t *consumer_addresses, + const uint32_t *consumer_expected, int nsites) { + int n = 0; + if (nsites > PSX_WS_SXY_CULL_SITES_MAX) nsites = PSX_WS_SXY_CULL_SITES_MAX; + for (int i = 0; i < nsites; i++) { + WsSxyCullSite *site = &ws_sxy_cull_sites[n]; + if (vertices[i] != 3u && vertices[i] != 4u) continue; + site->vertices = vertices[i]; + for (uint32_t k = 0; k < site->vertices; k++) { + site->fold_address[k] = fold_addresses[i * 4 + k] & 0x1FFFFFFFu; + site->fold_expected[k] = fold_expected[i * 4 + k]; + site->vertex_reg[k] = vertex_regs[i * 4 + k] & 31u; + } + site->consumer_address = consumer_addresses[i] & 0x1FFFFFFFu; + site->consumer_expected = consumer_expected[i]; + n++; + } + g_psx_ws_sxy_cull_n = n; +} +uint32_t psx_ws_cull_sxy_and(uint32_t acc, uint32_t sxy) { + return psx_ws_sxy_and_value(acc, sxy, psx_ws_x_margin()); +} +uint32_t psx_ws_sxy_cull_vertex_reg(uint32_t pc, uint32_t instr, + uint32_t (*read_word)(uint32_t phys)) { + const uint32_t phys = pc & 0x1FFFFFFFu; + for (int i = 0; i < g_psx_ws_sxy_cull_n; i++) { + const WsSxyCullSite *site = &ws_sxy_cull_sites[i]; + uint32_t step = site->vertices; + for (uint32_t k = 0; k < site->vertices; k++) + if (site->fold_address[k] == phys && site->fold_expected[k] == instr) + step = k; + if (step == site->vertices) continue; + /* The rest of the fold and the consumer are part of the identity, + * exactly as the recompiler checks them before emitting the helper. */ + int ok = read_word(site->consumer_address) == site->consumer_expected; + for (uint32_t k = 0; ok && k < site->vertices; k++) + if (k != step && read_word(site->fold_address[k]) != site->fold_expected[k]) + ok = 0; + if (ok) return site->vertex_reg[step]; + } + return 0; +} + typedef struct { uint32_t address; uint32_t expected; diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 9ecd7977..cb5e61c7 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -18354,6 +18354,26 @@ int main(int argc, char** argv) { addresses.data(), expected.data(), results.data(), (int)addresses.size()); } + { + // [[widescreen.cull.sxy]]: four slots per site. + std::vector vertices, fold_addresses, fold_expected, + vertex_regs, consumer_addresses, consumer_expected; + for (const auto& site : gc.ws_cull_sxy_sites) { + vertices.push_back(site.vertices); + for (size_t k = 0; k < 4; k++) { + const bool used = k < site.vertices; + fold_addresses.push_back(used ? site.fold_addresses[k] : 0u); + fold_expected.push_back(used ? site.fold_expected[k] : 0u); + vertex_regs.push_back(used ? site.vertex_regs[k] : 0u); + } + consumer_addresses.push_back(site.consumer_address); + consumer_expected.push_back(site.consumer_expected); + } + gpu_ws_set_sxy_cull_sites( + vertices.data(), fold_addresses.data(), fold_expected.data(), + vertex_regs.data(), consumer_addresses.data(), + consumer_expected.data(), (int)vertices.size()); + } { std::vector addresses, expected, masks; for (const auto& site : gc.ws_cull_masked_reject_sites) { diff --git a/runtime/tests/test_ws_cull_edge_interp.c b/runtime/tests/test_ws_cull_edge_interp.c index 3b946d3b..9222fc63 100644 --- a/runtime/tests/test_ws_cull_edge_interp.c +++ b/runtime/tests/test_ws_cull_edge_interp.c @@ -41,6 +41,9 @@ uint32_t g_debug_last_store_pc; uint32_t g_dirty_ram_code_gen; uint64_t g_dispatch_static_hits; uint32_t g_psx_mod_instruction_hooks; +/* The interpreter consults the optional override tier even in this isolated + * culling fixture. No overrides are registered here. */ +int (*g_psx_func_override_hook)(CPUState *, uint32_t); uint32_t psx_mod_memory_address(const CPUState *cpu, uint32_t pc, uint32_t word, uint32_t address) { (void)cpu; (void)pc; (void)word; return address; } @@ -531,6 +534,96 @@ static void test_screen_x_precedence(void) { gpu_ws_set_signed_x_bound_sites(NULL, NULL, 0); } +/* [[widescreen.cull.sxy]] and the screen_x `slti rt, x, 0` form through + * real interpreted instructions. The fold is Wild 9's triangle reject (with + * its interleaved mfc2/sw/sub left in RAM); the interpreter must produce the + * same word as the helper chain the recompiler emits, and treat a variant + * with any other fold or consumer word as vanilla code. */ +static uint32_t sxy(int x, int y) { + return ((uint32_t)(uint16_t)(int16_t)y << 16) | (uint16_t)(int16_t)x; +} +static void test_sxy_fold(void) { + CPUState cpu; + memset(&cpu, 0, sizeof cpu); + memset(test_ram, 0, sizeof test_ram); + enum { AT = 1, T0 = 8, T3 = 11, S1 = 17, S4 = 20, S6 = 22 }; + const uint32_t base = CODE + 0x300; + const uint32_t words[] = { 0x01110824u, 0x480B7000u, 0x02810824u, 0xAC880008u, + 0x01610824u, 0x02084022u, 0x14200001u }; + for (uint32_t i = 0; i < 7; i++) put_word(base + 4u * i, words[i]); + const uint32_t vertices[] = { 3 }; + const uint32_t fold_a[] = { base, base + 8u, base + 0x10u, 0 }; + const uint32_t fold_e[] = { words[0], words[2], words[4], 0 }; + const uint32_t regs[] = { T0, S4, T3, 0 }; + const uint32_t cons_a[] = { base + 0x18u }, cons_e[] = { words[6] }; + gpu_ws_set_sxy_cull_sites(vertices, fold_a, fold_e, regs, cons_a, cons_e, 1); + const uint32_t screen_x[] = { base + 0x40u }; + gpu_ws_set_cull_screen_x_sites(screen_x, 1); + const uint32_t slti_at_s6 = i_type(0x0A, S6, AT, 0); + + for (int pass = 0; pass < 2; pass++) { + const int m = pass ? 53 : 0; + gpu_ws_set_margin_override(m); + const uint32_t cases[][3] = { + { sxy(-60, 10), sxy(-53, 20), sxy(-100, 30) }, /* one in the band */ + { sxy(-60, 10), sxy(-54, 20), sxy(-100, 30) }, /* all left of -m */ + { sxy(-10, -1), sxy(100, -5), sxy(5, -9) }, /* all above */ + { sxy(10, 10), sxy(400, 20), sxy(-5, 30) }, /* on screen */ + }; + for (int c = 0; c < 4; c++) { + cpu.gpr[S1] = 0x80008000u; + cpu.gpr[T0] = cases[c][0]; + cpu.gpr[S4] = cases[c][1]; + cpu.gpr[T3] = cases[c][2]; + (void)step(&cpu, base, words[0]); + (void)step(&cpu, base + 8u, words[2]); + (void)step(&cpu, base + 0x10u, words[4]); + const uint32_t native = psx_ws_cull_sxy_and(psx_ws_cull_sxy_and( + psx_ws_cull_sxy_and(0x80008000u, cases[c][0]), cases[c][1]), + cases[c][2]); + const uint32_t vanilla = 0x80008000u & cases[c][0] & cases[c][1] & cases[c][2]; + CHECK(cpu.gpr[AT] == native, "interp sxy fold == emitted helper chain, case %d m=%d", c, m); + if (m == 0) + CHECK(cpu.gpr[AT] == vanilla, "sxy fold vanilla at 4:3, case %d", c); + } + CHECK(m == 0 || psx_ws_cull_sxy_and(psx_ws_cull_sxy_and(psx_ws_cull_sxy_and( + 0x80008000u, sxy(-60, 10)), sxy(-53, 20)), sxy(-100, 30)) == 0u, + "the band vertex keeps the triangle while widened"); + + /* A variant whose consumer differs is vanilla code. */ + put_word(base + 0x18u, 0x10200001u); + cpu.gpr[S1] = 0x80008000u; + cpu.gpr[T0] = sxy(-60, 10); + cpu.gpr[S4] = sxy(-53, 20); + cpu.gpr[T3] = sxy(-100, 30); + (void)step(&cpu, base, words[0]); + (void)step(&cpu, base + 8u, words[2]); + (void)step(&cpu, base + 0x10u, words[4]); + CHECK(cpu.gpr[AT] == 0x8000u, "sxy variant with another consumer stays vanilla, m=%d", m); + put_word(base + 0x18u, words[6]); + + /* An unlisted AND is vanilla. */ + cpu.gpr[S1] = 0x80008000u; + (void)step(&cpu, base + 0x100u, words[0]); + CHECK(cpu.gpr[AT] == (0x80008000u & cpu.gpr[T0]), "unlisted AND vanilla, m=%d", m); + + /* screen_x slti rt,x,0 with x = SX << 16. */ + cpu.gpr[S6] = (uint32_t)-5 << 16; + (void)step(&cpu, base + 0x40u, slti_at_s6); + CHECK(cpu.gpr[AT] == (m ? 0u : 1u), "screen_x slti keeps the band, m=%d", m); + cpu.gpr[S6] = (uint32_t)-54 << 16; + (void)step(&cpu, base + 0x40u, slti_at_s6); + CHECK(cpu.gpr[AT] == 1u, "screen_x slti rejects x < -m, m=%d", m); + cpu.gpr[S6] = (uint32_t)-5 << 16; + (void)step(&cpu, base + 0x140u, slti_at_s6); + CHECK(cpu.gpr[AT] == 1u, "unlisted slti vanilla, m=%d", m); + } + gpu_ws_set_margin_override(-1); + gpu_ws_set_sxy_cull_sites(NULL, NULL, NULL, NULL, NULL, NULL, 0); + gpu_ws_set_cull_screen_x_sites(NULL, 0); + CHECK(g_psx_ws_sxy_cull_n == 0, "sxy sites cleared"); +} + static void test_masked_reject(void) { CPUState cpu = {0}; memset(test_ram, 0, sizeof test_ram); @@ -601,6 +694,7 @@ int main(int argc, char **argv) { test_interpreter(); test_screen_x_precedence(); test_masked_reject(); + test_sxy_fold(); test_wide_nclip_branches(); if (failures) { fprintf(stderr, "%d check(s) failed\n", failures); From f7fed074f1df5d700a316ee259015c8d45287fcc Mon Sep 17 00:00:00 2001 From: yacineolivier-maker <234833416+yacineolivier-maker@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:59:04 +0200 Subject: [PATCH 12/22] HD textures: filtered sampling, a cache sized for a whole pack, indexed keys - Filtered replacement sampling: with any texture filter but nearest, the replacement is sampled through its mip chain (textureGrad), the footprint clamped to the primitive's own UV bounds so atlas neighbours cannot bleed in. Texture windows (repeat) and UVs outside the page keep hd_texel. - Edge clamp: an out-of-bounds sample clamped to the primitive's UV bounds takes the edge's sub-texel position too (keeping the outside fraction read a 1 px sliver of the next glyph at sprite borders). - GL cache: 4096 slots and a PSX_HD_GL_CACHE_MIB budget (default 2048 MiB) with a direct-mapped hint table; a 4x pack of one race scene is several hundred textures, and the old 256 slot / 128 MiB cache re-uploaded and re-mipped them every frame. - Invalidation: duck_texture_pack_invalidate returns early when a native write touches no residency or cache entry (almost every draw under HD authority lands on the frame buffer), and retire_unused walks the residency lists once instead of once per source. - Indexed replacements: a TTTTTTTT-index.png key resolves through the palette in use at draw time, for packs whose textures are shared by several CLUTs. - Debug: TCP {"cmd":"hd_uploads","x":..,"y":..} lists the texture uploads covering a VRAM point (pack authoring). Measured with a full HD HUD and course pack for R4: steady 60 fps in races where the old cache dropped to the high 20s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Rkyma8rbyPiccs9n2bkyG6 --- runtime/include/hd_texture_pack.h | 9 ++ runtime/src/debug_server.c | 11 +++ runtime/src/duckstation_texture_pack.cpp | 23 +++++ runtime/src/gpu_gl_renderer.c | 118 +++++++++++++++++++---- runtime/src/gpu_hd_textures.cpp | 6 +- runtime/src/hd_texture_pack.cpp | 37 ++++++- 6 files changed, 181 insertions(+), 23 deletions(-) diff --git a/runtime/include/hd_texture_pack.h b/runtime/include/hd_texture_pack.h index af389ffe..d15da23f 100644 --- a/runtime/include/hd_texture_pack.h +++ b/runtime/include/hd_texture_pack.h @@ -25,6 +25,13 @@ enum HdTextureLookupStatus { HD_TEXTURE_LOOKUP_AMBIGUOUS = 2 }; +/* Palette identity of an indexed replacement, named TTTTTTTT-index.png: its + * red channel holds CLUT indices (alpha 0 = transparent) and the renderer + * resolves them through the live CLUT of each draw, so one image serves every + * palette the game draws the texture with (highlights, fades). An exact + * TTTTTTTT-PPPPPPPP image still wins for its own palette. */ +#define HD_TEXTURE_PALETTE_INDEXED 0xFFFFFFFFu + typedef struct HdTexturePackInfo { const char* asset_root; /* valid until hd_texture_pack_destroy */ const char* replacement_root; /* valid until hd_texture_pack_destroy */ @@ -109,6 +116,8 @@ uint32_t hd_texture_hash_clut(const uint16_t* vram, /* Upload bytes are row-major logical upload words, independent of destination * wrapping. Tracking invalidates/splits every intersected older residency. * Width <= 1024 and height <= 512; X/Y may wrap. */ +int hd_texture_pack_debug_uploads_at(const HdTexturePack* pack, unsigned x, unsigned y, + char* out, size_t cap); int hd_texture_pack_track_upload(HdTexturePack* pack, uint16_t x, uint16_t y, diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index e61094f5..abca84bd 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -9469,6 +9469,16 @@ static void handle_perf(int id, const char *json) { send_fmt("{\"id\":%d,\"ok\":true,%s}", id, buf); } +/* hd_uploads x= y= -- texture uploads covering that VRAM + * point (newest first): hash, size, fragment, whether the pack replaces it. */ +extern int gpu_hd_textures_debug_uploads_at(int x, int y, char *out, size_t cap); +static void handle_hd_uploads(int id, const char *json) { + static char buf[16384]; + int n = gpu_hd_textures_debug_uploads_at(json_get_int(json, "x", 0), json_get_int(json, "y", 0), + buf, sizeof buf); + send_fmt("{\"id\":%d,\"ok\":true,\"count\":%d,\"uploads\":[%s]}", id, n, buf); +} + static void handle_display_aspect(int id, const char *json) { int num=json_get_int(json,"num",-1), den=json_get_int(json,"den",-1); int adaptive=json_get_int(json,"adaptive",0); @@ -16393,6 +16403,7 @@ static const CmdEntry s_commands[] = { { "ws_aspect", handle_ws_aspect }, { "display_aspect", handle_display_aspect }, { "perf", handle_perf }, + { "hd_uploads", handle_hd_uploads }, { "window_size", handle_window_size }, { "ws_nw", handle_ws_nw }, { "debug_key", handle_debug_key }, diff --git a/runtime/src/duckstation_texture_pack.cpp b/runtime/src/duckstation_texture_pack.cpp index e577638f..ce0fb119 100644 --- a/runtime/src/duckstation_texture_pack.cpp +++ b/runtime/src/duckstation_texture_pack.cpp @@ -827,6 +827,29 @@ void duck_texture_pack_invalidate(DuckTexturePack* p, uint16_t x, uint16_t y, ui const unsigned w0 = std::min(unsigned(w), 1024 - px), h0 = std::min(unsigned(h), 512 - py); const std::array regions{{{px,py,w0,h0},{0,py,unsigned(w)-w0,h0}, {px,0,w0,unsigned(h)-h0},{0,0,unsigned(w)-w0,unsigned(h)-h0}}}; + /* Under HD authority every native draw lands here, almost always on the + * frame buffer, which no tracked texture overlaps: leave without touching + * (or allocating) anything when no residency or cache entry intersects. */ + const auto touches = [&](const Rect& x) { + for (const auto& r : regions) if (r.w && r.h && intersects(x,r)) return true; + return false; + }; + bool hit = false; + for (const auto& upload : p->uploads) if (touches(upload.active)) { hit = true; break; } + if (!hit) for (const auto& page : p->pages) { + if (touches(page.source->rect)) { hit = true; break; } + for (const auto& record : page.source->records) + if (record.depth < 2 && touches({record.clut_x,record.clut_y,record.palette_size,1})) { hit = true; break; } + if (hit) break; + } + if (!hit) for (const auto& v : p->rect_cache) if (touches(v.rect)) { hit = true; break; } + if (!hit) for (const auto& c : p->match_cache) { + const auto& q = c.query; + const Rect page{q.page_x,q.page_y,256 / expansion(q.depth),256}; + const Rect clut{q.clut_x,q.clut_y,std::min(q.depth == 0 ? 16u : 256u,1024u-q.clut_x),1}; + if (touches(page) || (q.depth < 2 && touches(clut))) { hit = true; break; } + } + if (!hit) return; ++p->revision; bool released = false; /* a residency record dropped or split */ for (const auto& r : regions) if (r.w && r.h) { diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 15a853a2..2b6fc641 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -198,6 +198,7 @@ typedef void (APIENTRY *PFN_glBlendEquationSeparate)(GLenum, GLenum); typedef void (APIENTRY *PFN_glGenVertexArrays)(GLsizei, GLuint *); typedef void (APIENTRY *PFN_glBindVertexArray)(GLuint); typedef void (APIENTRY *PFN_glActiveTexture)(GLenum); +typedef void (APIENTRY *PFN_glGenerateMipmap)(GLenum); typedef void (APIENTRY *PFN_glGenBuffers)(GLsizei, GLuint *); typedef void (APIENTRY *PFN_glDeleteBuffers)(GLsizei, const GLuint *); typedef void (APIENTRY *PFN_glDeleteVertexArrays)(GLsizei, const GLuint *); @@ -271,6 +272,7 @@ static PFN_glBlendEquationSeparate p_glBlendEquationSeparate; static PFN_glGenVertexArrays p_glGenVertexArrays; static PFN_glBindVertexArray p_glBindVertexArray; static PFN_glActiveTexture p_glActiveTexture; +static PFN_glGenerateMipmap p_glGenerateMipmap; static PFN_glGenBuffers p_glGenBuffers; static PFN_glDeleteBuffers p_glDeleteBuffers; static PFN_glDeleteVertexArrays p_glDeleteVertexArrays; @@ -352,6 +354,7 @@ static int load_modern_gl(void) { LOAD(p_glBlendEquationSeparate, "glBlendEquationSeparate"); LOAD(p_glGenVertexArrays, "glGenVertexArrays"); LOAD(p_glBindVertexArray, "glBindVertexArray"); LOAD(p_glActiveTexture, "glActiveTexture"); LOAD(p_glGenBuffers, "glGenBuffers"); + LOAD(p_glGenerateMipmap, "glGenerateMipmap"); LOAD(p_glDeleteBuffers, "glDeleteBuffers"); LOAD(p_glDeleteVertexArrays, "glDeleteVertexArrays"); LOAD(p_glBindBuffer, "glBindBuffer"); LOAD(p_glBufferData, "glBufferData"); LOAD(p_glVertexAttribPointer, "glVertexAttribPointer"); @@ -2011,12 +2014,32 @@ static const char *TEX_FS = " p &= ivec2(255);\n" " ivec4 tw=ivec4(v_twin&31,(v_twin>>5)&31,(v_twin>>10)&31,(v_twin>>15)&31);\n" " if((tw.x|tw.y)!=0) p=(p & ~(tw.xy*8)) | ((tw.zw & tw.xy)*8);\n" - " else p=clamp(p,v_limits.xy,v_limits.zw);\n" + " else {\n" + /* Clamped to the primitive's UV bounds: the sub-texel position goes to + * the clamped edge too. Keeping the fraction of an outside sample + * (u=39.99 -> texel 40 at 0.99) reads the replacement deep inside the + * edge texel, a 1px sliver of the next glyph at the sprite border. */ + " ivec2 q=clamp(p,v_limits.xy,v_limits.zw);\n" + " f=vec2(q.x>p.x?0.0:(q.xp.y?0.0:(q.y>15)&1; ++hit; }\n" + " }\n" + " if(hit*2 16384) mib = 16384; + budget = (size_t)mib * 1024u * 1024u; + } + return budget; +} +#define HD_GL_CACHE_BUDGET hd_gl_cache_budget() typedef struct HdGlTexture { uint64_t generation, key; uint64_t last_use; @@ -3509,6 +3566,24 @@ typedef struct HdGlTexture { static HdGlTexture s_hd_gl_cache[HD_GL_CACHE_CAP]; static size_t s_hd_gl_cache_bytes; static uint64_t s_hd_gl_cache_clock; +static int16_t s_hd_gl_hint[HD_GL_HINTS]; /* slot + 1, 0 = none */ +static unsigned hd_gl_hint_of(uint64_t key, uint64_t generation) { + uint64_t h = (key ^ (generation * 0x9E3779B97F4A7C15ull)) * 0xD6E8FEB86659FD93ull; + return (unsigned)(h >> 51) & (HD_GL_HINTS - 1); +} +static int hd_gl_find(uint64_t key, uint64_t generation) { + const unsigned h = hd_gl_hint_of(key, generation); + const int hinted = s_hd_gl_hint[h] - 1; + if (hinted >= 0 && s_hd_gl_cache[hinted].texture && s_hd_gl_cache[hinted].key == key && + s_hd_gl_cache[hinted].generation == generation) return hinted; + for (int i = 0; i < HD_GL_CACHE_CAP; ++i) { + const HdGlTexture* texture = &s_hd_gl_cache[i]; + if (texture->texture && texture->key == key && texture->generation == generation) { + s_hd_gl_hint[h] = (int16_t)(i + 1); return i; + } + } + return -1; +} void gl_renderer_clear_hd_texture_cache(void) { /* Pack (re)configuration: the render thread must be idle before the @@ -3520,28 +3595,23 @@ void gl_renderer_clear_hd_texture_cache(void) { glDeleteTextures(1, &s_hd_gl_cache[i].texture); } memset(s_hd_gl_cache, 0, sizeof(s_hd_gl_cache)); + memset(s_hd_gl_hint, 0, sizeof(s_hd_gl_hint)); s_hd_gl_cache_bytes = 0; s_tb_hd_tex = 0; } int gl_renderer_hd_texture_cached(uint64_t key, uint64_t generation, uint32_t* width, uint32_t* height) { - for (int i = 0; i < HD_GL_CACHE_CAP; ++i) { - const HdGlTexture* texture = &s_hd_gl_cache[i]; - if (texture->texture && texture->key == key && texture->generation == generation) { - *width = texture->width; *height = texture->height; return 1; - } - } - return 0; + const int i = hd_gl_find(key, generation); + if (i < 0) return 0; + *width = s_hd_gl_cache[i].width; *height = s_hd_gl_cache[i].height; return 1; } static GLuint hd_gl_texture(const GpuHdTextureImage* image) { int free_slot = -1; - for (int i = 0; i < HD_GL_CACHE_CAP; ++i) { - HdGlTexture* texture = &s_hd_gl_cache[i]; - if (texture->texture && texture->key == image->cache_key && - texture->generation == image->generation) { - texture->last_use = ++s_hd_gl_cache_clock; return texture->texture; - } - if (!texture->texture && free_slot < 0) free_slot = i; + const int found = hd_gl_find(image->cache_key, image->generation); + if (found >= 0) { + s_hd_gl_cache[found].last_use = ++s_hd_gl_cache_clock; return s_hd_gl_cache[found].texture; } + for (int i = 0; i < HD_GL_CACHE_CAP && free_slot < 0; ++i) + if (!s_hd_gl_cache[i].texture) free_slot = i; if (!image->rgba || !image->width || !image->height || !image->source_width || !image->source_height || image->stride != (uint64_t)image->width * 4u || image->width > (uint32_t)s_gl_max_dim || image->height > (uint32_t)s_gl_max_dim) @@ -3567,19 +3637,25 @@ static GLuint hd_gl_texture(const GpuHdTextureImage* image) { glGenTextures(1, &texture->texture); p_glActiveTexture(PSXGL_TEXTURE0 + 2); glBindTexture(GL_TEXTURE_2D, texture->texture); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + /* Mip chain + linear filters: the shader samples them only when a texture + * filter is selected (hd_texel_filtered); nearest keeps texelFetch. */ + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + if (s_tex_aniso > 1) + glTexParameterf(GL_TEXTURE_2D, 0x84FE /* GL_TEXTURE_MAX_ANISOTROPY */, (float)s_tex_aniso); glPixelStorei(PSXGL_UNPACK_ROW_LENGTH, 0); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image->width, image->height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image->rgba); + if (p_glGenerateMipmap) p_glGenerateMipmap(GL_TEXTURE_2D); p_glActiveTexture(PSXGL_TEXTURE0); if (glGetError() != GL_NO_ERROR) { glDeleteTextures(1, &texture->texture); texture->texture = 0; return 0; } texture->key = image->cache_key; texture->generation = image->generation; texture->last_use = ++s_hd_gl_cache_clock; + s_hd_gl_hint[hd_gl_hint_of(texture->key, texture->generation)] = (int16_t)(free_slot + 1); texture->bytes = bytes; s_hd_gl_cache_bytes += bytes; texture->width = image->width; texture->height = image->height; gpu_hd_textures_note_gl_cache(0); diff --git a/runtime/src/gpu_hd_textures.cpp b/runtime/src/gpu_hd_textures.cpp index 62811dd9..85db9a97 100644 --- a/runtime/src/gpu_hd_textures.cpp +++ b/runtime/src/gpu_hd_textures.cpp @@ -396,6 +396,10 @@ extern "C" void gpu_hd_textures_get_diag(GpuHdTextureDiag* out) { session->diagnostics.push_back(std::move(diagnostic)); out->diagnostic = session->diagnostics.back().c_str(); } +extern "C" int gpu_hd_textures_debug_uploads_at(int x, int y, char* out, size_t cap) { + if (out && cap) out[0] = ''; + return session ? hd_texture_pack_debug_uploads_at(session->beetle, (unsigned)x, (unsigned)y, out, cap) : 0; +} extern "C" void gpu_hd_textures_note_applied(void) { if (session) ++session->diag.applied_draws; } extern "C" void gpu_hd_textures_note_gl_cache(int eviction) { if (session) { @@ -557,7 +561,7 @@ static int acquire_draw(uint16_t tp, uint16_t cx, uint16_t cy, image->origin_u = query.u_first - (match.source_word_x * pixels_per_word + (query.u_first % pixels_per_word)); image->origin_v = query.v_first - match.source_y; image->cache_key = (static_cast(match.entry.texture_hash) << 32) | match.entry.palette_hash; - image->alpha_mode = 1; + image->alpha_mode = match.entry.palette_hash == HD_TEXTURE_PALETTE_INDEXED ? 5 : 1; } else { try { std::array matches{}; size_t count = 0; diff --git a/runtime/src/hd_texture_pack.cpp b/runtime/src/hd_texture_pack.cpp index 720b6794..ce913671 100644 --- a/runtime/src/hd_texture_pack.cpp +++ b/runtime/src/hd_texture_pack.cpp @@ -113,6 +113,10 @@ bool parse_key_text(const std::string& text, const size_t dash = text.find('-'); if (dash == std::string::npos || text.find('-', dash + 1) != std::string::npos) return false; + if (ascii_lower(text.substr(dash + 1)) == "index") { + *palette_hash = HD_TEXTURE_PALETTE_INDEXED; + return parse_hex_component(text.substr(0, dash), texture_hash); + } return parse_hex_component(text.substr(0, dash), texture_hash) && parse_hex_component(text.substr(dash + 1), palette_hash); } @@ -1070,6 +1074,35 @@ int hd_texture_pack_track_upload(HdTexturePack* pack, return 1; } +/* Debug: uploads whose live fragments cover VRAM (x, y), newest first, as + * JSON objects: serial, texture hash, size, fragment rect and source offset, + * and whether the pack holds any replacement for that hash. */ +int hd_texture_pack_debug_uploads_at(const HdTexturePack* pack, unsigned x, unsigned y, + char* out, size_t cap) { + if (!out || cap == 0) return 0; + size_t pos = 0; + int n = 0; + out[0] = ''; + if (!pack) return 0; + for (auto it = pack->uploads.rbegin(); it != pack->uploads.rend(); ++it) { + for (const Fragment& f : it->fragments) { + if (x < f.rect.x || x >= f.rect.x + f.rect.width || + y < f.rect.y || y >= f.rect.y + f.rect.height) continue; + const int w = std::snprintf(out + pos, cap - pos, + "%s{\"serial\":%llu,\"hash\":\"%08x\",\"w\":%u,\"h\":%u," + "\"rect\":[%u,%u,%u,%u],\"src\":[%u,%u],\"in_pack\":%d}", + n ? "," : "", (unsigned long long)it->serial, it->hash, + it->width, it->height, f.rect.x, f.rect.y, f.rect.width, f.rect.height, + f.source_x, f.source_y, pack->texture_hashes.count(it->hash) ? 1 : 0); + if (w < 0 || (size_t)w >= cap - pos) return n; + pos += (size_t)w; + ++n; + break; + } + } + return n; +} + void hd_texture_pack_reset_tracking(HdTexturePack* pack) { if (pack) { pack->uploads.clear(); @@ -1212,7 +1245,9 @@ int hd_texture_pack_match(HdTexturePack* pack, if (indexed == pack->upload_by_serial.end() || !indexed->second) continue; const Upload& upload = *indexed->second; - const auto record = pack->entries.find(make_key(upload.hash, palette_hash)); + auto record = pack->entries.find(make_key(upload.hash, palette_hash)); + if (record == pack->entries.end() && query->depth != HD_TEXTURE_DEPTH_16BPP) + record = pack->entries.find(make_key(upload.hash, HD_TEXTURE_PALETTE_INDEXED)); if (record == pack->entries.end()) continue; if (!covered_by_upload(upload, wanted)) continue; if (record->second.ambiguous) return HD_TEXTURE_LOOKUP_AMBIGUOUS; From c78a9dd4a18d930a51016b8ab7e06285e5c118b9 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 09:26:46 -0700 Subject: [PATCH 13/22] Cover source retirement and indexed texture fallback on current master --- runtime/src/gpu_hd_textures.cpp | 2 +- runtime/src/hd_texture_pack.cpp | 2 +- .../tests/test_duckstation_texture_pack.cpp | 22 ++++++++++++++++++- runtime/tests/test_hd_texture_pack.cpp | 19 ++++++++++++++++ 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/runtime/src/gpu_hd_textures.cpp b/runtime/src/gpu_hd_textures.cpp index 85db9a97..3cdc2045 100644 --- a/runtime/src/gpu_hd_textures.cpp +++ b/runtime/src/gpu_hd_textures.cpp @@ -397,7 +397,7 @@ extern "C" void gpu_hd_textures_get_diag(GpuHdTextureDiag* out) { out->diagnostic = session->diagnostics.back().c_str(); } extern "C" int gpu_hd_textures_debug_uploads_at(int x, int y, char* out, size_t cap) { - if (out && cap) out[0] = ''; + if (out && cap) out[0] = '\0'; return session ? hd_texture_pack_debug_uploads_at(session->beetle, (unsigned)x, (unsigned)y, out, cap) : 0; } extern "C" void gpu_hd_textures_note_applied(void) { if (session) ++session->diag.applied_draws; } diff --git a/runtime/src/hd_texture_pack.cpp b/runtime/src/hd_texture_pack.cpp index ce913671..5ae5dd1e 100644 --- a/runtime/src/hd_texture_pack.cpp +++ b/runtime/src/hd_texture_pack.cpp @@ -1082,7 +1082,7 @@ int hd_texture_pack_debug_uploads_at(const HdTexturePack* pack, unsigned x, unsi if (!out || cap == 0) return 0; size_t pos = 0; int n = 0; - out[0] = ''; + out[0] = '\0'; if (!pack) return 0; for (auto it = pack->uploads.rbegin(); it != pack->uploads.rend(); ++it) { for (const Fragment& f : it->fragments) { diff --git a/runtime/tests/test_duckstation_texture_pack.cpp b/runtime/tests/test_duckstation_texture_pack.cpp index f7f57639..9103f8c1 100644 --- a/runtime/tests/test_duckstation_texture_pack.cpp +++ b/runtime/tests/test_duckstation_texture_pack.cpp @@ -538,6 +538,26 @@ void split_coalesce_copy_parts(const fs::path& root) { check(duck_texture_pack_match_draw(pack.get(),&q,0,&match) == HD_TEXTURE_LOOKUP_NONE,"single-cover API preserves native fallback for composed draw"); } +void release_while_another_source_splits(const fs::path& root) { + config(root,"MaxVRAMWriteSplits: 4\nDumpC16Textures: true\nDumpTextureWidthThreshold: 1\nDumpTextureHeightThreshold: 1\n"); + std::vector vram(1024*512); + place(vram,0,0,4,words(16)); place(vram,8,0,8,words(64)); + auto pack=create(root); char error[512]{}; + duck_texture_pack_track_upload(pack.get(),0,0,4,4,vram.data(),vram.size()); + duck_texture_pack_track_upload(pack.get(),8,0,8,8,vram.data(),vram.size()); + auto first=query(vram,0,0,2,0,3,0,3); + auto second=query(vram,0,0,2,8,15,0,7); + duck_texture_pack_dump_draw(pack.get(),&first,0,error,sizeof error); + duck_texture_pack_dump_draw(pack.get(),&second,0,error,sizeof error); + DuckTexturePackInfo before{},after{}; duck_texture_pack_get_info(pack.get(),&before); + check(before.pending_dump_sources==2,"both independent source observations are pending"); + // Retires the first upload entirely, while the second keeps right and lower clips. + duck_texture_pack_invalidate(pack.get(),0,0,12,4); + duck_texture_pack_get_info(pack.get(),&after); + check(after.pending_dump_sources==1 && after.queued_dump_count==1, + "released source finalizes even when surviving upload splits keep the total count steady"); +} + void resident_checkpoint(const fs::path& root) { std::vector vram(1024*512); place(vram,0,0,8,std::vector(8*16,0x3210)); @@ -604,7 +624,7 @@ int main(int argc, char** argv) { palettes(root/"palettes"); st_alias_duplicates(root/"keys"); failed_payloads(root/"invalid"); dumping(root/"dump"); official_binary_oracle(root/"official-oracle"); defaults_and_options(root/"defaults"); immutable_palettes_and_pages(root/"snapshots"); split_coalesce_copy_parts(root/"lifecycle"); copied_pack_ownership(root/"ownership"); - resident_checkpoint(root/"resident"); + resident_checkpoint(root/"resident"); release_while_another_source_splits(root/"release-split"); /* Every artifact is created beneath this fresh, explicitly named test * root. No user-provided paths participate in recursive cleanup. */ if (argc > 1 && !std::strcmp(argv[1],"--keep-artifacts")) std::printf("ARTIFACT_ROOT: %s\n",root.u8string().c_str()); diff --git a/runtime/tests/test_hd_texture_pack.cpp b/runtime/tests/test_hd_texture_pack.cpp index 6d952041..a3695756 100644 --- a/runtime/tests/test_hd_texture_pack.cpp +++ b/runtime/tests/test_hd_texture_pack.cpp @@ -211,6 +211,24 @@ HdTextureDrawQuery query4(const std::vector& vram, return query; } +void test_indexed_palette_fallback() { + Fixture f; + std::ostringstream stem; stem< Date: Sat, 24 Oct 2026 11:02:59 -0700 Subject: [PATCH 14/22] Assert native texture STP preservation in HD renderer fixtures --- runtime/tests/test_gl_hd_texture_pack.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/runtime/tests/test_gl_hd_texture_pack.c b/runtime/tests/test_gl_hd_texture_pack.c index 2f6a9e3c..0a22c665 100644 --- a/runtime/tests/test_gl_hd_texture_pack.c +++ b/runtime/tests/test_gl_hd_texture_pack.c @@ -359,8 +359,9 @@ static void composition_scene(const char* root) { check(sample(20,8)[0]>200 && sample(20,8)[2]<20,"composed all-zero ST cutout retains destination"); check(sample(24,8)[0]>200 && sample(24,8)[1]<20,"native transparent hole remains cutout"); check(sample(32,8)[0]<20 && sample(32,8)[2]>200,"uncovered native opaque texel retains native color"); + /* Textured draws preserve the source STP bit in canonical guest VRAM. */ uint16_t output[8];gr_vram_transfer_out(2,2,8,1,output); - check(output[0]==0x03e0 && output[2]==0x03ff && output[6]==0x7c00, + check(output[0]==0x03e0 && output[2]==0x83ff && output[6]==0x7c00, "composition does not change guest native draw or STP behavior"); GpuHdTextureImage first={0},again={0}; check(gpu_hd_textures_acquire_draw(tp,0,0,bounds,0,1,&first),"composed cache acquire"); @@ -602,7 +603,7 @@ int main(int argc,char** argv) { gr_fill_rect(0,0,16,16,0x03e0); gr_draw_textured_rect(4,4,2,4,5,0,0,0,texture_page); capture(); check(sample(16,16)[2]>200 && sample(16,16)[0]<20,"Beetle partial draw keeps upload-relative UV origin"); - check(vram[4*1024+4]==0x03e0 && vram[517]==0x83e0, + check(vram[4*1024+4]==0x83e0 && vram[517]==0x83e0, "Beetle image preserves native draw RGB and source STP"); gr_draw_textured_rect(8,4,4,4,4,0,0,0,texture_page); capture(); check(sample(44,16)[1]>200,"Beetle native transparent zero still cuts out replacement"); From 8ff16e24b3b46e50823cea3120540254331c902a Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 11:50:50 -0700 Subject: [PATCH 15/22] Exercise indexed replacement sampling and live CLUT changes on real GL --- runtime/tests/test_gl_hd_texture_pack.c | 35 +++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/runtime/tests/test_gl_hd_texture_pack.c b/runtime/tests/test_gl_hd_texture_pack.c index 0a22c665..4a2a0870 100644 --- a/runtime/tests/test_gl_hd_texture_pack.c +++ b/runtime/tests/test_gl_hd_texture_pack.c @@ -469,6 +469,40 @@ static void cache_eviction_scene(const char* root) { pack_config(root,0);check(gpu_hd_textures_reload(error,sizeof(error)),"eviction fixture cleanup reload"); state();gr_vram_transfer_in(512,0,4,4,source_words);wait_ready(0);wait_ready(1); } + +/* Indexed replacements choose a live CLUT entry rather than a baked RGB + * palette. Exercise the real upload/match/decode/shader path. */ +static void indexed_palette_scene(const char* root) { + char dir[2048],path[2304],error[256]={0}; + snprintf(dir,sizeof dir,"%s/beetle",root); + uint16_t words[16],palette[16]={0}; + for(int n=0;n<16;++n) words[n]=0x1111; + palette[1]=0x03e0; palette[2]=0x7c00; + snprintf(path,sizeof path,"%s/demo-texture-replacements/%x-index.png",dir, + hd_texture_crc32_words_le(words,16)); + uint8_t rgba[64*16*4]; + for(int n=0;n<64*16;++n) { + rgba[n*4]=2; rgba[n*4+1]=rgba[n*4+2]=0; rgba[n*4+3]=255; + } + FILE* f=fopen(path,"wb");check(f!=NULL,"indexed GPU fixture opens"); + if(f){check(png_write_rgba(f,rgba,64,16),"indexed GPU fixture writes");fclose(f);} + check(gpu_hd_textures_configure(dir,1,0,error,sizeof error),"indexed GPU session opens"); + state();gr_vram_transfer_in(0,300,16,1,palette);gr_vram_transfer_in(512,0,4,4,words); + const int bounds[4]={0,0,7,3};GpuHdTextureImage image={0};int ready=0; + for(int n=0;n<2000&&!ready;++n) { + ready=gpu_hd_textures_acquire_draw(8u,0,300,bounds,0,0,&image) && image.alpha_mode==5; + gpu_hd_textures_release_image(&image);if(!ready)SDL_Delay(1); + } + check(ready,"indexed GPU path selects decoded palette-independent replacement"); + gr_fill_rect(0,0,16,16,0x001f);gr_draw_textured_rect(2,2,8,4,0,0,0,300,8u);capture(); + check(sample(8,8)[2]>200&&sample(8,8)[1]<20,"indexed shader uses replacement index and live blue CLUT entry"); + gr_vram_write(2,300,0x001f); + gr_draw_textured_rect(2,2,8,4,0,0,0,300,8u);capture(); + check(sample(8,8)[0]>200&&sample(8,8)[2]<20,"indexed shader follows live CLUT change without changing replacement"); + uint16_t native[8];gr_vram_transfer_out(2,2,8,1,native); + check(native[0]==0x03e0,"indexed presentation preserves native source-index RGB"); +} + int main(int argc,char** argv) { if(argc==2 && !strcmp(argv[1],"--native-baseline")) { char* native_args[]={"hd-baseline","4","twin","1"}; @@ -863,6 +897,7 @@ int main(int argc,char** argv) { check(fg_gen_hud_hd>0 && fg_gen_hud_bad==0,"Smooth motion + HD: generated HUD is the HD replacement"); } } + indexed_palette_scene(argv[1]); memcpy(reference,vram,sizeof(vram)); gl_renderer_set_cpu_auth_dual(1); gpu_hd_textures_shutdown(); From 541a9b067f55b6959301844e72b1629cfb125d60 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 12:12:36 -0700 Subject: [PATCH 16/22] Save SYS03 outgoing context for a closed current thread CloseTh(current); ChangeTh(other) still stores the outgoing registers, as the BIOS exception handler does. Fixes both the HLE scheduler and the LLE fiber bridge. Reported by michaelm-afk. Co-Authored-By: Claude Opus 5.5 (1M context) --- recompiler/CMakeLists.txt | 5 ++ runtime/src/traps.c | 19 +++++-- .../test_syscall_closed_thread_context.c | 52 +++++++++++++++++++ .../test_syscall_closed_thread_context.py | 21 ++++++++ 4 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 runtime/tests/test_syscall_closed_thread_context.c create mode 100644 runtime/tests/test_syscall_closed_thread_context.py diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index 41abcd0b..09cd598e 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -1477,6 +1477,11 @@ if(BUILD_TESTING AND Python3_Interpreter_FOUND AND CMAKE_C_COMPILER_ID STREQUAL COMMAND ${Python3_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/../runtime/tests/test_syscall_change_thread_self.py" --cc ${CMAKE_C_COMPILER}) + # CloseTh(current); ChangeTh(other) still saves the outgoing registers. + add_test(NAME syscall_closed_thread_context + COMMAND ${Python3_EXECUTABLE} + "${CMAKE_CURRENT_SOURCE_DIR}/../runtime/tests/test_syscall_closed_thread_context.py" + --cc ${CMAKE_C_COMPILER}) endif() # Presentation capture ring: distinct-presentation dedupe and bounded slots. diff --git a/runtime/src/traps.c b/runtime/src/traps.c index 3f9180d9..46074364 100644 --- a/runtime/src/traps.c +++ b/runtime/src/traps.c @@ -572,8 +572,15 @@ static int psx_change_thread_fiber(CPUState* cpu, uint32_t target_tcb) HostThreadFiber* current = psx_bind_current_host_thread(cpu, current_tcb); int saved_current_context = 0; - if (psx_tcb_state(cpu, current_tcb) == 0x4000u) { + /* SYS03 saves the outgoing register context before changing the PCB, + * including CloseTh(current); ChangeTh(other): CloseTh only marks the TCB + * free and does not suppress the exception handler's register stores. + * OpenTh initializes PC/SP/FP/GP but keeps the other saved registers when + * it reuses the TCB. Saving never reopens the closed TCB. */ + const uint32_t outgoing_state = psx_tcb_state(cpu, current_tcb); + if (outgoing_state == 0x4000u || outgoing_state == 0x1000u) psx_save_context_to_tcb(cpu, current_tcb, cpu->gpr[31]); + if (outgoing_state == 0x4000u) { saved_current_context = 1; } else { /* 0x1000 is the BIOS closed/free state and must retire the host fiber. @@ -681,9 +688,13 @@ static int psx_request_thread_switch(CPUState* cpu, uint32_t target_tcb) } /* Commit the yielding thread's context so it can be re-dispatched later. * resume PC = its return address (resume right after the ChangeThread call). - * Only a still-running (0x4000) thread keeps a live context; a closed/free - * (0x1000) thread is being torn down and must not be saved. */ - if (psx_tcb_state(cpu, current_tcb) == 0x4000u) { + * A closed/free (0x1000) thread is still the current thread until SYS03 + * switches the PCB, and the BIOS exception handler stores its registers + * regardless: CloseTh(current); ChangeTh(other) keeps those stores. OpenTh + * initializes PC/SP/FP/GP but keeps the other saved registers when it + * reuses the TCB. Saving never reopens the closed TCB. */ + const uint32_t outgoing_state = psx_tcb_state(cpu, current_tcb); + if (outgoing_state == 0x4000u || outgoing_state == 0x1000u) { psx_save_context_to_tcb(cpu, current_tcb, cpu->gpr[31]); } psx_set_current_tcb(cpu, target_tcb); diff --git a/runtime/tests/test_syscall_closed_thread_context.c b/runtime/tests/test_syscall_closed_thread_context.c new file mode 100644 index 00000000..c6caa2ec --- /dev/null +++ b/runtime/tests/test_syscall_closed_thread_context.c @@ -0,0 +1,52 @@ +/* SYS03 must preserve register RAM even after CloseTh marks the outgoing TCB + * free. Generic register patterns only: no game addresses. */ +#include +#include +#include +#include +#include "cpu_state.h" +#include "psx_scheduler.h" + +static uint32_t ram[0x10000 / 4]; +static CPUState cpu; +uint64_t s_frame_count = 0; + +static uint32_t rd(uint32_t a) { assert(a < sizeof ram && !(a & 3)); return ram[a / 4]; } +static void wr(uint32_t a, uint32_t v) { assert(a < sizeof ram && !(a & 3)); ram[a / 4] = v; } +int psx_get_in_exception(void) { return 0; } +int overlay_loader_shadow_native_thread_switch_bail(void) { return 0; } +int parity_trace_is_armed(void) { return 0; } +void debug_server_log_thread_event(uint32_t k, CPUState* c, uint32_t a, uint32_t b, uint32_t p) +{ (void)k; (void)c; (void)a; (void)b; (void)p; } +int psx_syscall(CPUState*, uint32_t); + +int main(void) +{ + const uint32_t pcb = 0xe000, old = 0xe1f4, target = old + 0xc0; + ram[0x108 / 4] = pcb; + ram[0x110 / 4] = old; + ram[0x114 / 4] = 4 * 0xc0; + ram[pcb / 4] = old; + ram[old / 4] = 0x1000; /* Closed, but still the current thread until SYS03. */ + ram[target / 4] = 0x4000; + cpu.read_word = rd; + cpu.write_word = wr; + for (unsigned i = 1; i < 32; i++) cpu.gpr[i] = 0x16160000u + i; + cpu.gpr[4] = 3; + cpu.gpr[5] = target; + cpu.gpr[31] = 0x2104; + cpu.cop0[12] = 0x401; + cpu.hi = 0xaaaa; + cpu.lo = 0xbbbb; + if (setjmp(g_scheduler_jmpbuf) == 0) { + psx_syscall(&cpu, 0); + assert(!"must yield to scheduler"); + } + assert(ram[old / 4] == 0x1000); /* Saving never reopens the retired thread. */ + assert(ram[pcb / 4] == target); + assert(ram[(old + 8 + 22 * 4) / 4] == 0x16160016u); + assert(ram[(old + 8 + 128) / 4] == 0x2104); + assert(ram[(old + 8 + 132) / 4] == 0xaaaa && ram[(old + 8 + 136) / 4] == 0xbbbb); + assert(g_sched_escape.target_tcb == target); + return 0; +} diff --git a/runtime/tests/test_syscall_closed_thread_context.py b/runtime/tests/test_syscall_closed_thread_context.py new file mode 100644 index 00000000..dcc57a06 --- /dev/null +++ b/runtime/tests/test_syscall_closed_thread_context.py @@ -0,0 +1,21 @@ +"""CloseTh(current); ChangeTh(other) must still save the outgoing registers. + +Links the production traps.c into test_syscall_closed_thread_context.c at -O0 +and -O2; every other runtime seam is an abort() stub (fixture_link.py). +""" +import argparse +import tempfile +from pathlib import Path + +from fixture_link import build_and_run + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--cc', default='gcc') + args = parser.parse_args() + here = Path(__file__).resolve().parent + with tempfile.TemporaryDirectory() as root: + for opt in ('-O0', '-O2'): + build_and_run(args.cc, here, here.parent, opt, Path(root), + ['traps.c'], 'test_syscall_closed_thread_context.c') + print('PASS: closed outgoing thread keeps its context without reopening (O0/O2)') From 0a9fb8600abad43dae9d6e2b7898747a88745d5f Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:37:53 -0700 Subject: [PATCH 17/22] HD textures: decode the whole pack into RAM before the game runs Replacement images were decoded on demand by a single low-priority worker, so a texture first seen mid-race appeared late and could be evicted again. When replacements are on, configuring a pack now decodes every unambiguous image (DuckStation and Beetle formats) across all cores and pins it in RAM before the game draws; pinned images are never evicted by the decode budget. Logs the count, size and time. Co-Authored-By: Claude Opus 5.5 --- runtime/include/duckstation_texture_pack.h | 4 ++ runtime/include/hd_texture_pack.h | 3 + runtime/src/duckstation_texture_pack.cpp | 54 ++++++++++++++- runtime/src/gpu_hd_textures.cpp | 16 +++++ runtime/src/hd_texture_pack.cpp | 65 ++++++++++++++++++- .../tests/test_duckstation_texture_pack.cpp | 23 ++++++- 6 files changed, 161 insertions(+), 4 deletions(-) diff --git a/runtime/include/duckstation_texture_pack.h b/runtime/include/duckstation_texture_pack.h index eb0cab4e..b552e914 100644 --- a/runtime/include/duckstation_texture_pack.h +++ b/runtime/include/duckstation_texture_pack.h @@ -170,6 +170,10 @@ int duck_texture_pack_match_parts(DuckTexturePack* pack, * Release the lease even after pack destruction. */ void duck_texture_pack_set_decode_budget(DuckTexturePack* pack, size_t bytes); int duck_texture_pack_request_decode(DuckTexturePack* pack, uint64_t entry_id); +/* Decode every unambiguous replacement now (all cores) and pin it in RAM, so + * no draw waits for or evicts an image. Returns the count loaded, -1 on error; + * bytes_out gets the decoded RGBA total. */ +int duck_texture_pack_preload(DuckTexturePack* pack, size_t* bytes_out); int duck_texture_pack_acquire_decoded(DuckTexturePack* pack, uint64_t entry_id, DuckTexturePixels* out_pixels); void duck_texture_pixels_release(DuckTexturePixels* pixels); diff --git a/runtime/include/hd_texture_pack.h b/runtime/include/hd_texture_pack.h index d15da23f..da8ad462 100644 --- a/runtime/include/hd_texture_pack.h +++ b/runtime/include/hd_texture_pack.h @@ -189,6 +189,9 @@ typedef struct HdTexturePixels { void hd_texture_pack_set_decode_budget(HdTexturePack* pack, size_t budget_bytes); +/* Decode every unambiguous replacement now (all cores) and pin it in RAM. + * Returns the count loaded, -1 on error; bytes_out gets the RGBA total. */ +int hd_texture_pack_preload(HdTexturePack* pack, size_t* bytes_out); int hd_texture_pack_request_decode(HdTexturePack* pack, uint32_t texture_hash, uint32_t palette_hash); diff --git a/runtime/src/duckstation_texture_pack.cpp b/runtime/src/duckstation_texture_pack.cpp index ce0fb119..9aa8206f 100644 --- a/runtime/src/duckstation_texture_pack.cpp +++ b/runtime/src/duckstation_texture_pack.cpp @@ -183,6 +183,7 @@ struct Decode { std::string path; std::shared_ptr image; uint64_t use = 0; + bool pinned = false; /* preloaded: never evicted */ }; struct Dump { fs::path path; std::shared_ptr image; }; struct DumpHistory { std::unordered_set names; std::deque order; }; @@ -243,7 +244,7 @@ struct Worker { while (used + incoming > budget) { auto candidate = decoded.end(); for (auto it = decoded.begin(); it != decoded.end(); ++it) - if (it->second.status == DecodeStatus::Ready && + if (it->second.status == DecodeStatus::Ready && !it->second.pinned && (candidate == decoded.end() || it->second.use < candidate->second.use)) candidate = it; if (candidate == decoded.end()) break; used -= candidate->second.image->rgba.size(); @@ -1145,7 +1146,7 @@ int duck_texture_pack_request_decode(DuckTexturePack* p, uint64_t id) { auto victim = w.decoded.end(); for (auto item = w.decoded.begin(); item != w.decoded.end(); ++item) if ((item->second.status == DecodeStatus::Ready || item->second.status == DecodeStatus::Failed) && - (victim == w.decoded.end() || item->second.use < victim->second.use)) victim = item; + !item->second.pinned && (victim == w.decoded.end() || item->second.use < victim->second.use)) victim = item; if (victim == w.decoded.end()) return HD_TEXTURE_LOOKUP_NONE; if (victim->second.image) w.used -= victim->second.image->rgba.size(); w.decoded.erase(victim); @@ -1155,6 +1156,55 @@ int duck_texture_pack_request_decode(DuckTexturePack* p, uint64_t id) { w.wake.notify_one(); return HD_TEXTURE_LOOKUP_NONE; } catch (...) { return HD_TEXTURE_LOOKUP_ERROR; } } +int duck_texture_pack_preload(DuckTexturePack* p, size_t* bytes_out) { + if (bytes_out) *bytes_out = 0; + if (!p) return -1; + try { + /* Decode every replacement before the game runs, across all cores, + * so no draw ever waits for (or misses) an image mid-game. */ + std::vector ids; + for (size_t i = 0; i < p->entries.size(); ++i) + if (!p->entries[i].ambiguous) ids.push_back(i + 1); + std::vector> images(ids.size()); + std::atomic next{0}; + auto work = [&] { + for (size_t k; (k = next++) < ids.size();) { + try { + const std::string& path = p->entries[ids[k] - 1].path; + std::ifstream input(fs::u8path(path), std::ios::binary | std::ios::ate); + const auto length = input ? input.tellg() : std::streampos(-1); + if (length <= 0 || static_cast(length) > kMaxEncoded) continue; + std::vector encoded(static_cast(length)); + input.seekg(0); + DecodedTextureImage decoded; std::string error; + if (!input.read(reinterpret_cast(encoded.data()), length) || + !texture_image_decode(encoded.data(), encoded.size(), SIZE_MAX, decoded, error)) continue; + auto image = std::make_shared(); image->w = decoded.width; image->h = decoded.height; + image->rgba = std::move(decoded.rgba); images[k] = std::move(image); + } catch (...) {} + } + }; + unsigned threads = std::max(1u, std::min(std::thread::hardware_concurrency(), 16u)); + std::vector pool; + for (unsigned t = 1; t < threads && t < ids.size(); ++t) pool.emplace_back(work); + work(); + for (auto& t : pool) t.join(); + Worker& w = p->worker; std::lock_guard lock(w.mutex); + int loaded = 0; size_t bytes = 0; + for (size_t k = 0; k < ids.size(); ++k) { + Decode& d = w.decoded[ids[k]]; + d.path = p->entries[ids[k] - 1].path; + if (!images[k]) { d.status = DecodeStatus::Failed; continue; } + if (d.image) w.used -= d.image->rgba.size(); + bytes += images[k]->rgba.size(); w.used += images[k]->rgba.size(); + d.image = std::move(images[k]); d.status = DecodeStatus::Ready; d.pinned = true; ++loaded; + } + if (w.budget < w.used) w.budget = w.used; + w.decoded_images += loaded; + if (bytes_out) *bytes_out = bytes; + return loaded; + } catch (...) { return -1; } +} int duck_texture_pack_acquire_decoded(DuckTexturePack* p, uint64_t id, DuckTexturePixels* out) { if (out) *out = {}; if (!p || !out) return HD_TEXTURE_LOOKUP_ERROR; diff --git a/runtime/src/gpu_hd_textures.cpp b/runtime/src/gpu_hd_textures.cpp index 3cdc2045..036da2c4 100644 --- a/runtime/src/gpu_hd_textures.cpp +++ b/runtime/src/gpu_hd_textures.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -289,6 +290,21 @@ extern "C" int gpu_hd_textures_configure(const char* root, int replacements, std::filesystem::create_directories(directory / "dumps"); } if (!duck_texture_pack_create(root, &next->duck, error, capacity)) return 0; + if (next->replacements) { + /* Every replacement decoded and resident before the game draws. */ + const auto t0 = std::chrono::steady_clock::now(); + size_t bytes = 0, beetle_bytes = 0; + int loaded = duck_texture_pack_preload(next->duck, &bytes); + if (next->beetle) { + const int beetle_loaded = hd_texture_pack_preload(next->beetle, &beetle_bytes); + if (beetle_loaded > 0) { loaded = (loaded > 0 ? loaded : 0) + beetle_loaded; bytes += beetle_bytes; } + } + const auto ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + if (loaded > 0) + std::fprintf(stdout, "psxrecomp: texture pack: %d images preloaded (%.1f MiB) in %lld ms\n", + loaded, bytes / 1048576.0, static_cast(ms)); + } /* Quiesce renderer users (GL render thread: dump_draw appends to the * old session's sources) before flushing its dumps or reading its * residency; the cache is rebuilt from the new session. */ diff --git a/runtime/src/hd_texture_pack.cpp b/runtime/src/hd_texture_pack.cpp index 5ae5dd1e..062069dc 100644 --- a/runtime/src/hd_texture_pack.cpp +++ b/runtime/src/hd_texture_pack.cpp @@ -5,6 +5,7 @@ #endif #include +#include #include #include #include @@ -452,6 +453,7 @@ struct DecodeItem { std::shared_ptr image; size_t bytes = 0; uint64_t last_used = 0; + bool pinned = false; /* preloaded: never evicted */ }; struct DecodeCache { @@ -484,7 +486,7 @@ void evict_decode_cache(DecodeCache& cache, size_t incoming = 0) { while (cache.used + incoming > cache.budget) { auto victim = cache.items.end(); for (auto it = cache.items.begin(); it != cache.items.end(); ++it) { - if (it->second.state != DecodeState::Ready) continue; + if (it->second.state != DecodeState::Ready || it->second.pinned) continue; if (victim == cache.items.end() || it->second.last_used < victim->second.last_used) victim = it; @@ -1317,6 +1319,67 @@ void hd_texture_pack_set_decode_budget(HdTexturePack* pack, evict_decode_cache(pack->decode); } +int hd_texture_pack_preload(HdTexturePack* pack, size_t* bytes_out) { + if (bytes_out) *bytes_out = 0; +#ifdef HD_TEXTURE_PACK_DISABLE_PNG_DECODE + (void)pack; + return -1; +#else + if (!pack) return -1; + try { + /* Decode every replacement before the game runs, across all cores. */ + std::vector> work_items; + for (const auto& entry : pack->entries) + if (!entry.second.ambiguous) + work_items.emplace_back(entry.first, entry.second.replacement_path); + std::vector> images(work_items.size()); + std::atomic next{0}; + auto work = [&] { + for (size_t k; (k = next++) < work_items.size();) { + std::ifstream input(work_items[k].second, std::ios::binary | std::ios::ate); + const std::streamoff length = input ? static_cast(input.tellg()) : -1; + if (length <= 0 || static_cast(length) > kMaxPngFileBytes) continue; + std::vector encoded(static_cast(length)); + input.seekg(0); + if (!input.read(reinterpret_cast(encoded.data()), length)) continue; + int width = 0, height = 0, components = 0; + stbi_uc* loaded = stbi_load_from_memory(encoded.data(), static_cast(encoded.size()), + &width, &height, &components, 4); + if (!loaded) continue; + if (width > 0 && height > 0) { + auto image = std::make_shared(); + image->width = static_cast(width); + image->height = static_cast(height); + image->rgba.assign(loaded, loaded + static_cast(width) * height * 4); + images[k] = std::move(image); + } + stbi_image_free(loaded); + } + }; + const unsigned threads = std::max(1u, std::min(std::thread::hardware_concurrency(), 16u)); + std::vector pool; + for (unsigned t = 1; t < threads && t < work_items.size(); ++t) pool.emplace_back(work); + work(); + for (auto& t : pool) t.join(); + std::lock_guard lock(pack->decode.mutex); + int loaded = 0; size_t bytes = 0; + for (size_t k = 0; k < work_items.size(); ++k) { + DecodeItem& item = pack->decode.items[work_items[k].first]; + item.path = work_items[k].second; + if (!images[k]) { item.state = DecodeState::Failed; continue; } + if (item.state == DecodeState::Ready) pack->decode.used -= item.bytes; + item.bytes = images[k]->rgba.size(); + item.image = std::move(images[k]); + item.state = DecodeState::Ready; item.pinned = true; + pack->decode.used += item.bytes; bytes += item.bytes; ++loaded; + } + if (pack->decode.budget < pack->decode.used) pack->decode.budget = pack->decode.used; + if (bytes_out) *bytes_out = bytes; + return loaded; + } catch (...) { return -1; } +#endif +} + int hd_texture_pack_request_decode(HdTexturePack* pack, uint32_t texture_hash, uint32_t palette_hash) { diff --git a/runtime/tests/test_duckstation_texture_pack.cpp b/runtime/tests/test_duckstation_texture_pack.cpp index 9103f8c1..a67daae3 100644 --- a/runtime/tests/test_duckstation_texture_pack.cpp +++ b/runtime/tests/test_duckstation_texture_pack.cpp @@ -109,6 +109,26 @@ bool decode_fails(DuckTexturePack* pack, uint64_t id) { return status == HD_TEXTURE_LOOKUP_ERROR; } +void preload(const fs::path& root) { + const char* name = "texpage-P4-3631D2A04EC85311-9CBCDEA9305D7528-64x256-4-2-8x2-P0-15.png"; + png(root/"replacements"/name,16,4); + std::vector vram(1024*512); + place(vram,65,258,2,words(4)); place(vram,512,400,16,words(16)); + auto pack = create(root); auto q = query(vram,64,256,0,5,10,2,3); + size_t bytes = 0; + check(duck_texture_pack_preload(pack.get(),&bytes) == 1 && bytes == 16*4*4,"preload decodes the pack"); + DuckTextureMatch match{}; + check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND,"preloaded fixture matches"); + DuckTexturePixels pixels{}; + check(duck_texture_pack_acquire_decoded(pack.get(),match.entry_id,&pixels) == HD_TEXTURE_LOOKUP_FOUND && + pixels.width == 16,"preloaded image is resident without a decode request"); + duck_texture_pixels_release(&pixels); + duck_texture_pack_set_decode_budget(pack.get(),1); + check(duck_texture_pack_acquire_decoded(pack.get(),match.entry_id,&pixels) == HD_TEXTURE_LOOKUP_FOUND, + "a preloaded image survives budget eviction"); + duck_texture_pixels_release(&pixels); +} + void fixed_hash_vectors() { /* Independently produced by Python xxhash 3.6.0's native wheel. These * literal expectations cover XXH3's short/medium/long-input boundaries. */ @@ -624,7 +644,8 @@ int main(int argc, char** argv) { palettes(root/"palettes"); st_alias_duplicates(root/"keys"); failed_payloads(root/"invalid"); dumping(root/"dump"); official_binary_oracle(root/"official-oracle"); defaults_and_options(root/"defaults"); immutable_palettes_and_pages(root/"snapshots"); split_coalesce_copy_parts(root/"lifecycle"); copied_pack_ownership(root/"ownership"); - resident_checkpoint(root/"resident"); release_while_another_source_splits(root/"release-split"); + resident_checkpoint(root/"resident"); preload(root/"preload"); + release_while_another_source_splits(root/"release-split"); /* Every artifact is created beneath this fresh, explicitly named test * root. No user-provided paths participate in recursive cleanup. */ if (argc > 1 && !std::strcmp(argv[1],"--keep-artifacts")) std::printf("ARTIFACT_ROOT: %s\n",root.u8string().c_str()); From 954e28b051b039b6a7bbc4f9fe0c7faef9da3bf9 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:27:29 -0700 Subject: [PATCH 18/22] HD preload: bounded contract, safe threads, explicit partial result Both preload functions now take an aggregate decoded-byte cap and a thread cap (runtime: 512 MiB, 4 threads). Each image is probed and its RGBA reserved against the cap before decoding; per-image limits follow the pack's decode budget, which is no longer grown to usage (pinned images sit outside it, and outside the Duck 512-entry cap). The pool (texture_pack_ preload.hpp) joins every started thread on unwinding, survives thread creation failure, and catches worker exceptions. Images not preloaded are left to the asynchronous budgeted path instead of being marked failed, and HdTexturePreloadResult reports loaded / over-cap / failed / worker errors with a complete-or-partial status. The Beetle async worker no longer lets allocation failures escape its thread entry. Tests cover both formats: complete preload, over-cap skip with on-demand fallback, malformed images, injected worker and thread-creation failures, and budget reservation bounds. Co-Authored-By: Claude Opus 5.5 --- runtime/include/duckstation_texture_pack.h | 10 +- runtime/include/hd_texture_pack.h | 21 ++- runtime/include/texture_pack_preload.hpp | 96 ++++++++++ runtime/src/duckstation_texture_pack.cpp | 106 ++++++----- runtime/src/gpu_hd_textures.cpp | 32 +++- runtime/src/hd_texture_pack.cpp | 171 +++++++++++------- .../tests/test_duckstation_texture_pack.cpp | 86 +++++++-- runtime/tests/test_hd_texture_pack.cpp | 50 +++++ 8 files changed, 433 insertions(+), 139 deletions(-) create mode 100644 runtime/include/texture_pack_preload.hpp diff --git a/runtime/include/duckstation_texture_pack.h b/runtime/include/duckstation_texture_pack.h index b552e914..d0569139 100644 --- a/runtime/include/duckstation_texture_pack.h +++ b/runtime/include/duckstation_texture_pack.h @@ -170,10 +170,12 @@ int duck_texture_pack_match_parts(DuckTexturePack* pack, * Release the lease even after pack destruction. */ void duck_texture_pack_set_decode_budget(DuckTexturePack* pack, size_t bytes); int duck_texture_pack_request_decode(DuckTexturePack* pack, uint64_t entry_id); -/* Decode every unambiguous replacement now (all cores) and pin it in RAM, so - * no draw waits for or evicts an image. Returns the count loaded, -1 on error; - * bytes_out gets the decoded RGBA total. */ -int duck_texture_pack_preload(DuckTexturePack* pack, size_t* bytes_out); +/* Elective full preload with the hd_texture_pack_preload contract: bounded + * aggregate decoded bytes and concurrency, pinned images outside the decode + * budget, everything not preloaded left to the asynchronous decode path. + * Returns 1 complete, 0 partial (see out), -1 on error. */ +int duck_texture_pack_preload(DuckTexturePack* pack, size_t max_bytes, + unsigned max_threads, HdTexturePreloadResult* out); int duck_texture_pack_acquire_decoded(DuckTexturePack* pack, uint64_t entry_id, DuckTexturePixels* out_pixels); void duck_texture_pixels_release(DuckTexturePixels* pixels); diff --git a/runtime/include/hd_texture_pack.h b/runtime/include/hd_texture_pack.h index da8ad462..165adb63 100644 --- a/runtime/include/hd_texture_pack.h +++ b/runtime/include/hd_texture_pack.h @@ -189,9 +189,24 @@ typedef struct HdTexturePixels { void hd_texture_pack_set_decode_budget(HdTexturePack* pack, size_t budget_bytes); -/* Decode every unambiguous replacement now (all cores) and pin it in RAM. - * Returns the count loaded, -1 on error; bytes_out gets the RGBA total. */ -int hd_texture_pack_preload(HdTexturePack* pack, size_t* bytes_out); +/* Elective full preload: decode unambiguous replacements up front and pin + * them, bounded by max_bytes of decoded RGBA in aggregate and by max_threads + * concurrent decodes (the caller's thread included). Pinned images are kept + * outside the on-demand decode budget, which stays as configured. Images not + * preloaded (over the cap, unreadable, malformed, a worker failure) are left + * to the normal asynchronous, budgeted decode path; nothing is marked failed + * by the preload. Returns 1 when every image was preloaded, 0 for a partial + * preload (see out), -1 on error. */ +typedef struct HdTexturePreloadResult { + int requested; /* unambiguous replacements considered */ + int loaded; /* decoded and pinned */ + int over_budget; /* skipped: the aggregate max_bytes was reached */ + int failed; /* unreadable, malformed, too large, or worker error */ + int worker_errors; /* of failed: a decode that threw (caught) */ + size_t bytes; /* decoded RGBA pinned */ +} HdTexturePreloadResult; +int hd_texture_pack_preload(HdTexturePack* pack, size_t max_bytes, + unsigned max_threads, HdTexturePreloadResult* out); int hd_texture_pack_request_decode(HdTexturePack* pack, uint32_t texture_hash, uint32_t palette_hash); diff --git a/runtime/include/texture_pack_preload.hpp b/runtime/include/texture_pack_preload.hpp new file mode 100644 index 00000000..6517802b --- /dev/null +++ b/runtime/include/texture_pack_preload.hpp @@ -0,0 +1,96 @@ +#ifndef PSX_TEXTURE_PACK_PRELOAD_HPP +#define PSX_TEXTURE_PACK_PRELOAD_HPP + +/* Shared machinery for the elective full texture-pack preload + * (hd_texture_pack_preload, duck_texture_pack_preload): an aggregate + * decoded-byte reservation and a bounded worker pool that can neither leak a + * joinable std::thread nor let an exception escape a thread entry. */ + +#include +#include +#include +#include +#include +#include +#include + +namespace psx_texpreload { + +/* Test-only fault injection. 1: the first item's decode throws + * std::bad_alloc. 2: creating the second pool thread throws std::system_error. */ +inline std::atomic& fault_mode() { + static std::atomic mode{0}; + return mode; +} + +/* Aggregate decoded bytes, reserved before an image is decoded. */ +class Budget { +public: + explicit Budget(size_t cap) : cap_(cap) {} + bool reserve(size_t bytes) { + size_t cur = used_.load(); + do { + if (bytes > cap_ || cur > cap_ - bytes) return false; + } while (!used_.compare_exchange_weak(cur, cur + bytes)); + return true; + } + void release(size_t bytes) { used_.fetch_sub(bytes); } +private: + size_t cap_; + std::atomic used_{0}; +}; + +/* Per-item outcome, written by exactly one worker. */ +enum ItemStatus : unsigned char { kPending = 0, kLoaded, kFailed, kOverBudget }; + +/* Joins every started thread when the pool goes out of scope, including while + * unwinding from a failed std::thread construction. */ +struct Joiner { + std::vector& threads; + ~Joiner() { + for (auto& t : threads) + if (t.joinable()) t.join(); + } +}; + +/* Runs item(k) for every k < n on at most max_threads threads, the caller + * being one of them. An exception from item(k) is caught and counted, never + * propagated out of a thread entry. A failure to start a thread only lowers + * concurrency. Returns the number of items whose item(k) threw. */ +template +size_t run(size_t n, unsigned max_threads, F&& item) { + std::atomic next{0}, errors{0}; + auto work = [&]() noexcept { + for (size_t k; (k = next.fetch_add(1)) < n;) { + try { + if (k == 0 && fault_mode().load() == 1) throw std::bad_alloc(); + item(k); + } catch (...) { + errors.fetch_add(1); + } + } + }; + unsigned hw = std::thread::hardware_concurrency(); + if (hw == 0) hw = 1; + const unsigned threads = std::max(1u, std::min(max_threads ? max_threads : 1u, hw)); + std::vector pool; + { + Joiner joiner{pool}; + for (unsigned t = 1; t < threads && t < n; ++t) { + try { + if (t == 1 && fault_mode().load() == 2) + throw std::system_error( + std::make_error_code(std::errc::resource_unavailable_try_again)); + pool.emplace_back(work); + } catch (...) { + break; + } + } + work(); + } + return errors.load(); +} + +} // namespace psx_texpreload + +#endif diff --git a/runtime/src/duckstation_texture_pack.cpp b/runtime/src/duckstation_texture_pack.cpp index 9aa8206f..ceb138ee 100644 --- a/runtime/src/duckstation_texture_pack.cpp +++ b/runtime/src/duckstation_texture_pack.cpp @@ -1,5 +1,6 @@ #include "duckstation_texture_pack.h" #include "texture_image_decode.h" +#include "texture_pack_preload.hpp" #define XXH_INLINE_ALL #include "../third_party/xxhash.h" #include "png_write.h" @@ -232,6 +233,7 @@ struct Worker { size_t dump_bytes = 0; uint64_t tick = 0; uint64_t decoded_images = 0, decode_evictions = 0; + size_t pinned_count = 0; /* preloaded entries: outside the 512-entry cap */ bool stop = false; bool dump_busy = false; std::string dump_error; @@ -1142,7 +1144,7 @@ int duck_texture_pack_request_decode(DuckTexturePack* p, uint64_t id) { } if (!w.budget) return HD_TEXTURE_LOOKUP_ERROR; if (w.decode_queue.size() >= kMaxDecodeQueue) return HD_TEXTURE_LOOKUP_NONE; - if (w.decoded.size() >= 512) { + if (w.decoded.size() - w.pinned_count >= 512) { auto victim = w.decoded.end(); for (auto item = w.decoded.begin(); item != w.decoded.end(); ++item) if ((item->second.status == DecodeStatus::Ready || item->second.status == DecodeStatus::Failed) && @@ -1156,53 +1158,73 @@ int duck_texture_pack_request_decode(DuckTexturePack* p, uint64_t id) { w.wake.notify_one(); return HD_TEXTURE_LOOKUP_NONE; } catch (...) { return HD_TEXTURE_LOOKUP_ERROR; } } -int duck_texture_pack_preload(DuckTexturePack* p, size_t* bytes_out) { - if (bytes_out) *bytes_out = 0; +int duck_texture_pack_preload(DuckTexturePack* p, size_t max_bytes, unsigned max_threads, + HdTexturePreloadResult* out) { + HdTexturePreloadResult r{}; + if (out) *out = r; if (!p) return -1; try { - /* Decode every replacement before the game runs, across all cores, - * so no draw ever waits for (or misses) an image mid-game. */ + namespace tp = psx_texpreload; std::vector ids; for (size_t i = 0; i < p->entries.size(); ++i) if (!p->entries[i].ambiguous) ids.push_back(i + 1); - std::vector> images(ids.size()); - std::atomic next{0}; - auto work = [&] { - for (size_t k; (k = next++) < ids.size();) { - try { - const std::string& path = p->entries[ids[k] - 1].path; - std::ifstream input(fs::u8path(path), std::ios::binary | std::ios::ate); - const auto length = input ? input.tellg() : std::streampos(-1); - if (length <= 0 || static_cast(length) > kMaxEncoded) continue; - std::vector encoded(static_cast(length)); - input.seekg(0); - DecodedTextureImage decoded; std::string error; - if (!input.read(reinterpret_cast(encoded.data()), length) || - !texture_image_decode(encoded.data(), encoded.size(), SIZE_MAX, decoded, error)) continue; - auto image = std::make_shared(); image->w = decoded.width; image->h = decoded.height; - image->rgba = std::move(decoded.rgba); images[k] = std::move(image); - } catch (...) {} - } - }; - unsigned threads = std::max(1u, std::min(std::thread::hardware_concurrency(), 16u)); - std::vector pool; - for (unsigned t = 1; t < threads && t < ids.size(); ++t) pool.emplace_back(work); - work(); - for (auto& t : pool) t.join(); - Worker& w = p->worker; std::lock_guard lock(w.mutex); - int loaded = 0; size_t bytes = 0; - for (size_t k = 0; k < ids.size(); ++k) { - Decode& d = w.decoded[ids[k]]; - d.path = p->entries[ids[k] - 1].path; - if (!images[k]) { d.status = DecodeStatus::Failed; continue; } - if (d.image) w.used -= d.image->rgba.size(); - bytes += images[k]->rgba.size(); w.used += images[k]->rgba.size(); - d.image = std::move(images[k]); d.status = DecodeStatus::Ready; d.pinned = true; ++loaded; + size_t per_image = 0; + { + std::lock_guard lock(p->worker.mutex); + per_image = p->worker.budget; } - if (w.budget < w.used) w.budget = w.used; - w.decoded_images += loaded; - if (bytes_out) *bytes_out = bytes; - return loaded; + std::vector> images(ids.size()); + std::vector status(ids.size(), tp::kPending); + tp::Budget budget(max_bytes); + r.worker_errors = static_cast(tp::run(ids.size(), max_threads, [&](size_t k) { + status[k] = tp::kFailed; + const std::string& path = p->entries[ids[k] - 1].path; + std::ifstream input(fs::u8path(path), std::ios::binary | std::ios::ate); + const auto length = input ? input.tellg() : std::streampos(-1); + if (length <= 0 || static_cast(length) > kMaxEncoded) return; + std::vector encoded(static_cast(length)); + input.seekg(0); + if (!input.read(reinterpret_cast(encoded.data()), length)) return; + TextureImageProbe probe; std::string error; + if (!texture_image_probe(encoded.data(), encoded.size(), per_image, probe, error)) return; + const size_t need = static_cast(probe.width) * probe.height * 4u; + if (!budget.reserve(need)) { status[k] = tp::kOverBudget; return; } + struct Reservation { + tp::Budget& b; size_t n; bool keep = false; + ~Reservation() { if (!keep) b.release(n); } + } reservation{budget, need}; + DecodedTextureImage decoded; + if (!texture_image_decode(encoded.data(), encoded.size(), need, decoded, error) || + decoded.rgba.size() != need) return; + auto image = std::make_shared(); image->w = decoded.width; image->h = decoded.height; + image->rgba = std::move(decoded.rgba); + images[k] = std::move(image); + reservation.keep = true; + status[k] = tp::kLoaded; + })); + r.requested = static_cast(ids.size()); + Worker& w = p->worker; std::lock_guard lock(w.mutex); + for (size_t k = 0; k < ids.size(); ++k) { + if (status[k] == tp::kOverBudget) { ++r.over_budget; continue; } + if (status[k] != tp::kLoaded) continue; + auto it = w.decoded.find(ids[k]); + if (it != w.decoded.end()) { + /* Already pinned, or an on-demand decode in flight: leave it. */ + if (it->second.pinned || it->second.status == DecodeStatus::Queued || + it->second.status == DecodeStatus::Loading) continue; + if (it->second.image) w.used -= it->second.image->rgba.size(); + } + Decode& entry = w.decoded[ids[k]]; + entry.path = p->entries[ids[k] - 1].path; + r.bytes += images[k]->rgba.size(); + entry.image = std::move(images[k]); entry.status = DecodeStatus::Ready; + entry.pinned = true; entry.use = ++w.tick; + ++w.pinned_count; ++r.loaded; + } + w.decoded_images += static_cast(r.loaded); + r.failed = r.requested - r.loaded - r.over_budget; + if (out) *out = r; + return r.loaded == r.requested ? 1 : 0; } catch (...) { return -1; } } int duck_texture_pack_acquire_decoded(DuckTexturePack* p, uint64_t id, DuckTexturePixels* out) { diff --git a/runtime/src/gpu_hd_textures.cpp b/runtime/src/gpu_hd_textures.cpp index 036da2c4..5430dfb3 100644 --- a/runtime/src/gpu_hd_textures.cpp +++ b/runtime/src/gpu_hd_textures.cpp @@ -26,6 +26,11 @@ constexpr size_t kCompositionBudget = 64u * 1024u * 1024u; constexpr size_t kCompositionCount = 256; constexpr uint32_t kCompositionDimension = 8192; constexpr size_t kMaxParts = 64; +/* Elective full preload of an installed pack: at most this much decoded RGBA + * pinned across both formats, decoded by at most this many threads. Anything + * beyond decodes on demand under the normal budget. */ +constexpr size_t kPreloadMaxBytes = size_t{512} * 1024u * 1024u; +constexpr unsigned kPreloadThreads = 4; struct Composition { std::vector signature; std::vector rgba; @@ -291,19 +296,30 @@ extern "C" int gpu_hd_textures_configure(const char* root, int replacements, } if (!duck_texture_pack_create(root, &next->duck, error, capacity)) return 0; if (next->replacements) { - /* Every replacement decoded and resident before the game draws. */ + /* Replacements decoded and resident before the game draws, within + * kPreloadMaxBytes; the rest stays on the budgeted async path. */ const auto t0 = std::chrono::steady_clock::now(); - size_t bytes = 0, beetle_bytes = 0; - int loaded = duck_texture_pack_preload(next->duck, &bytes); + HdTexturePreloadResult dr{}, br{}; + const int dstatus = duck_texture_pack_preload(next->duck, kPreloadMaxBytes, + kPreloadThreads, &dr); + int bstatus = 1; if (next->beetle) { - const int beetle_loaded = hd_texture_pack_preload(next->beetle, &beetle_bytes); - if (beetle_loaded > 0) { loaded = (loaded > 0 ? loaded : 0) + beetle_loaded; bytes += beetle_bytes; } + const size_t left = dr.bytes < kPreloadMaxBytes ? kPreloadMaxBytes - dr.bytes : 0; + bstatus = hd_texture_pack_preload(next->beetle, left, kPreloadThreads, &br); } const auto ms = std::chrono::duration_cast( std::chrono::steady_clock::now() - t0).count(); - if (loaded > 0) - std::fprintf(stdout, "psxrecomp: texture pack: %d images preloaded (%.1f MiB) in %lld ms\n", - loaded, bytes / 1048576.0, static_cast(ms)); + const int requested = dr.requested + br.requested; + if (requested > 0 || dstatus < 0 || bstatus < 0) + std::fprintf(stdout, "psxrecomp: texture pack: preloaded %d/%d images (%.1f MiB) in %lld ms" + "%s; %d over the %u MiB preload cap, %d failed (%d worker errors);" + " the rest decode on demand\n", + dr.loaded + br.loaded, requested, (dr.bytes + br.bytes) / 1048576.0, + static_cast(ms), + (dstatus < 0 || bstatus < 0) ? " (preload error)" : + (dstatus == 1 && bstatus == 1) ? " (complete)" : " (partial)", + dr.over_budget + br.over_budget, static_cast(kPreloadMaxBytes / (1024u * 1024u)), + dr.failed + br.failed, dr.worker_errors + br.worker_errors); } /* Quiesce renderer users (GL render thread: dump_draw appends to the * old session's sources) before flushing its dumps or reading its diff --git a/runtime/src/hd_texture_pack.cpp b/runtime/src/hd_texture_pack.cpp index 062069dc..af7b6874 100644 --- a/runtime/src/hd_texture_pack.cpp +++ b/runtime/src/hd_texture_pack.cpp @@ -1,4 +1,5 @@ #include "hd_texture_pack.h" +#include "texture_pack_preload.hpp" #ifndef HD_TEXTURE_PACK_DISABLE_PNG_DECODE #include "../third_party/stb_image.h" /* declarations only; implementation is shared */ @@ -517,38 +518,46 @@ void decode_worker(DecodeCache* cache) { path = it->second.path; } - std::vector encoded; - { - std::ifstream input(path, std::ios::binary | std::ios::ate); - std::streamoff length = -1; - if (input) length = static_cast(input.tellg()); - if (length > 0 && static_cast(length) <= kMaxPngFileBytes && - static_cast(length) <= - static_cast(std::numeric_limits::max())) { - encoded.resize(static_cast(length)); - input.seekg(0); - if (!input.read(reinterpret_cast(encoded.data()), length)) - encoded.clear(); - } - } - int width = 0; - int height = 0; - int components = 0; - stbi_uc* loaded = encoded.empty() ? nullptr : stbi_load_from_memory( - encoded.data(), static_cast(encoded.size()), - &width, &height, &components, 4); std::shared_ptr decoded; size_t bytes = 0; - if (loaded && width > 0 && height > 0 && - static_cast(width) * static_cast(height) <= - std::numeric_limits::max() / 4) { - bytes = static_cast(width) * static_cast(height) * 4; - decoded = std::make_shared(); - decoded->width = static_cast(width); - decoded->height = static_cast(height); - decoded->rgba.assign(loaded, loaded + bytes); + /* Allocation failure (encoded buffer, RGBA copy) must not escape the + * thread entry: the item fails like an undecodable image. */ + try { + std::vector encoded; + { + std::ifstream input(path, std::ios::binary | std::ios::ate); + std::streamoff length = -1; + if (input) length = static_cast(input.tellg()); + if (length > 0 && static_cast(length) <= kMaxPngFileBytes && + static_cast(length) <= + static_cast(std::numeric_limits::max())) { + encoded.resize(static_cast(length)); + input.seekg(0); + if (!input.read(reinterpret_cast(encoded.data()), length)) + encoded.clear(); + } + } + int width = 0; + int height = 0; + int components = 0; + std::unique_ptr loaded( + encoded.empty() ? nullptr : stbi_load_from_memory( + encoded.data(), static_cast(encoded.size()), + &width, &height, &components, 4), + stbi_image_free); + if (loaded && width > 0 && height > 0 && + static_cast(width) * static_cast(height) <= + std::numeric_limits::max() / 4) { + bytes = static_cast(width) * static_cast(height) * 4; + decoded = std::make_shared(); + decoded->width = static_cast(width); + decoded->height = static_cast(height); + decoded->rgba.assign(loaded.get(), loaded.get() + bytes); + } + } catch (...) { + decoded.reset(); + bytes = 0; } - if (loaded) stbi_image_free(loaded); std::lock_guard lock(cache->mutex); auto it = cache->items.find(key); @@ -1319,63 +1328,89 @@ void hd_texture_pack_set_decode_budget(HdTexturePack* pack, evict_decode_cache(pack->decode); } -int hd_texture_pack_preload(HdTexturePack* pack, size_t* bytes_out) { - if (bytes_out) *bytes_out = 0; +int hd_texture_pack_preload(HdTexturePack* pack, size_t max_bytes, unsigned max_threads, + HdTexturePreloadResult* out) { + HdTexturePreloadResult r{}; + if (out) *out = r; #ifdef HD_TEXTURE_PACK_DISABLE_PNG_DECODE - (void)pack; + (void)pack; (void)max_bytes; (void)max_threads; return -1; #else if (!pack) return -1; try { - /* Decode every replacement before the game runs, across all cores. */ + namespace tp = psx_texpreload; std::vector> work_items; for (const auto& entry : pack->entries) if (!entry.second.ambiguous) work_items.emplace_back(entry.first, entry.second.replacement_path); + size_t per_image = 0; + { + std::lock_guard lock(pack->decode.mutex); + per_image = pack->decode.budget; + } std::vector> images(work_items.size()); - std::atomic next{0}; - auto work = [&] { - for (size_t k; (k = next++) < work_items.size();) { - std::ifstream input(work_items[k].second, std::ios::binary | std::ios::ate); - const std::streamoff length = input ? static_cast(input.tellg()) : -1; - if (length <= 0 || static_cast(length) > kMaxPngFileBytes) continue; - std::vector encoded(static_cast(length)); - input.seekg(0); - if (!input.read(reinterpret_cast(encoded.data()), length)) continue; - int width = 0, height = 0, components = 0; - stbi_uc* loaded = stbi_load_from_memory(encoded.data(), static_cast(encoded.size()), - &width, &height, &components, 4); - if (!loaded) continue; - if (width > 0 && height > 0) { - auto image = std::make_shared(); - image->width = static_cast(width); - image->height = static_cast(height); - image->rgba.assign(loaded, loaded + static_cast(width) * height * 4); - images[k] = std::move(image); - } - stbi_image_free(loaded); - } - }; - const unsigned threads = std::max(1u, std::min(std::thread::hardware_concurrency(), 16u)); - std::vector pool; - for (unsigned t = 1; t < threads && t < work_items.size(); ++t) pool.emplace_back(work); - work(); - for (auto& t : pool) t.join(); + std::vector status(work_items.size(), tp::kPending); + tp::Budget budget(max_bytes); + r.worker_errors = static_cast(tp::run(work_items.size(), max_threads, [&](size_t k) { + status[k] = tp::kFailed; + std::ifstream input(work_items[k].second, std::ios::binary | std::ios::ate); + const std::streamoff length = input ? static_cast(input.tellg()) : -1; + if (length <= 0 || static_cast(length) > kMaxPngFileBytes || + static_cast(length) > static_cast(std::numeric_limits::max())) + return; + std::vector encoded(static_cast(length)); + input.seekg(0); + if (!input.read(reinterpret_cast(encoded.data()), length)) return; + int width = 0, height = 0, components = 0; + if (!stbi_info_from_memory(encoded.data(), static_cast(encoded.size()), + &width, &height, &components) || + width <= 0 || height <= 0 || + static_cast(width) * static_cast(height) > + static_cast(per_image) / 4) + return; + const size_t need = static_cast(width) * static_cast(height) * 4; + if (!budget.reserve(need)) { status[k] = tp::kOverBudget; return; } + struct Reservation { + tp::Budget& b; size_t n; bool keep = false; + ~Reservation() { if (!keep) b.release(n); } + } reservation{budget, need}; + int lw = 0, lh = 0, lc = 0; + std::unique_ptr loaded( + stbi_load_from_memory(encoded.data(), static_cast(encoded.size()), + &lw, &lh, &lc, 4), + stbi_image_free); + if (!loaded || lw != width || lh != height) return; + auto image = std::make_shared(); + image->width = static_cast(width); + image->height = static_cast(height); + image->rgba.assign(loaded.get(), loaded.get() + need); + images[k] = std::move(image); + reservation.keep = true; + status[k] = tp::kLoaded; + })); + r.requested = static_cast(work_items.size()); std::lock_guard lock(pack->decode.mutex); - int loaded = 0; size_t bytes = 0; for (size_t k = 0; k < work_items.size(); ++k) { + if (status[k] == tp::kOverBudget) { ++r.over_budget; continue; } + if (status[k] != tp::kLoaded) continue; + auto it = pack->decode.items.find(work_items[k].first); + if (it != pack->decode.items.end()) { + /* Already pinned, or an on-demand decode in flight: leave it. */ + if (it->second.pinned || it->second.state == DecodeState::Queued || + it->second.state == DecodeState::Loading) continue; + if (it->second.state == DecodeState::Ready) pack->decode.used -= it->second.bytes; + } DecodeItem& item = pack->decode.items[work_items[k].first]; item.path = work_items[k].second; - if (!images[k]) { item.state = DecodeState::Failed; continue; } - if (item.state == DecodeState::Ready) pack->decode.used -= item.bytes; item.bytes = images[k]->rgba.size(); item.image = std::move(images[k]); item.state = DecodeState::Ready; item.pinned = true; - pack->decode.used += item.bytes; bytes += item.bytes; ++loaded; + item.last_used = ++pack->decode.tick; + r.bytes += item.bytes; ++r.loaded; } - if (pack->decode.budget < pack->decode.used) pack->decode.budget = pack->decode.used; - if (bytes_out) *bytes_out = bytes; - return loaded; + r.failed = r.requested - r.loaded - r.over_budget; + if (out) *out = r; + return r.loaded == r.requested ? 1 : 0; } catch (...) { return -1; } #endif } diff --git a/runtime/tests/test_duckstation_texture_pack.cpp b/runtime/tests/test_duckstation_texture_pack.cpp index a67daae3..7e894cc2 100644 --- a/runtime/tests/test_duckstation_texture_pack.cpp +++ b/runtime/tests/test_duckstation_texture_pack.cpp @@ -1,6 +1,9 @@ #define STB_IMAGE_IMPLEMENTATION #include "../third_party/stb_image.h" #include "duckstation_texture_pack.h" +#include "texture_pack_preload.hpp" +#include +#include #include "../src/png_write.h" #include @@ -110,23 +113,78 @@ bool decode_fails(DuckTexturePack* pack, uint64_t id) { } void preload(const fs::path& root) { + namespace tp = psx_texpreload; const char* name = "texpage-P4-3631D2A04EC85311-9CBCDEA9305D7528-64x256-4-2-8x2-P0-15.png"; png(root/"replacements"/name,16,4); std::vector vram(1024*512); place(vram,65,258,2,words(4)); place(vram,512,400,16,words(16)); - auto pack = create(root); auto q = query(vram,64,256,0,5,10,2,3); - size_t bytes = 0; - check(duck_texture_pack_preload(pack.get(),&bytes) == 1 && bytes == 16*4*4,"preload decodes the pack"); - DuckTextureMatch match{}; - check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND,"preloaded fixture matches"); - DuckTexturePixels pixels{}; - check(duck_texture_pack_acquire_decoded(pack.get(),match.entry_id,&pixels) == HD_TEXTURE_LOOKUP_FOUND && - pixels.width == 16,"preloaded image is resident without a decode request"); - duck_texture_pixels_release(&pixels); - duck_texture_pack_set_decode_budget(pack.get(),1); - check(duck_texture_pack_acquire_decoded(pack.get(),match.entry_id,&pixels) == HD_TEXTURE_LOOKUP_FOUND, - "a preloaded image survives budget eviction"); - duck_texture_pixels_release(&pixels); + { + auto pack = create(root); auto q = query(vram,64,256,0,5,10,2,3); + HdTexturePreloadResult r{}; + check(duck_texture_pack_preload(pack.get(),size_t(64)<<20,4,&r) == 1 && r.loaded == 1 && + r.requested == 1 && r.bytes == 16*4*4 && r.failed == 0,"preload decodes the pack"); + DuckTextureMatch match{}; + check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND,"preloaded fixture matches"); + DuckTexturePixels pixels{}; + check(duck_texture_pack_acquire_decoded(pack.get(),match.entry_id,&pixels) == HD_TEXTURE_LOOKUP_FOUND && + pixels.width == 16,"preloaded image is resident without a decode request"); + duck_texture_pixels_release(&pixels); + duck_texture_pack_set_decode_budget(pack.get(),1); + check(duck_texture_pack_acquire_decoded(pack.get(),match.entry_id,&pixels) == HD_TEXTURE_LOOKUP_FOUND, + "a preloaded image survives budget eviction"); + duck_texture_pixels_release(&pixels); + } + { /* Over the aggregate cap: partial, nothing pinned, async path still decodes. */ + auto pack = create(root); auto q = query(vram,64,256,0,5,10,2,3); + HdTexturePreloadResult r{}; + check(duck_texture_pack_preload(pack.get(),16,4,&r) == 0 && r.loaded == 0 && r.over_budget == 1 && + r.bytes == 0,"oversized image is skipped by the preload cap"); + DuckTextureMatch match{}; + check(duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND,"capped fixture matches"); + DuckTexturePixels pixels = decoded(pack.get(),match.entry_id); + check(pixels.width == 16,"an image the preload skipped decodes on demand"); + duck_texture_pixels_release(&pixels); + } + { /* Injected worker failure: caught, reported, image left to the async path. */ + auto pack = create(root); + tp::fault_mode() = 1; + HdTexturePreloadResult r{}; + const int status = duck_texture_pack_preload(pack.get(),size_t(64)<<20,4,&r); + tp::fault_mode() = 0; + check(status == 0 && r.worker_errors == 1 && r.failed == 1 && r.loaded == 0, + "a throwing decode is caught and reported as a partial preload"); + } + { /* Malformed image: failed, not pinned, async decode reports failure. */ + std::ofstream(root/"replacements"/name,std::ios::binary|std::ios::trunc) << "not a png"; + auto pack = create(root); auto q = query(vram,64,256,0,5,10,2,3); + HdTexturePreloadResult r{}; + check(duck_texture_pack_preload(pack.get(),size_t(64)<<20,4,&r) == 0 && r.failed == 1 && + r.worker_errors == 0,"malformed image fails the preload item"); + DuckTextureMatch match{}; + if (duck_texture_pack_match(pack.get(),&q,&match) == HD_TEXTURE_LOOKUP_FOUND) + check(decode_fails(pack.get(),match.entry_id),"malformed image still fails on demand"); + } +} + +void preload_pool() { + namespace tp = psx_texpreload; + std::atomic ran{0}; + tp::fault_mode() = 2; /* the second thread cannot be created */ + size_t errors = tp::run(32,8,[&](size_t){ ++ran; }); + check(errors == 0 && ran == 32,"thread creation failure lowers concurrency, all items still run"); + tp::fault_mode() = 1; /* the first item throws bad_alloc */ + ran = 0; + errors = tp::run(32,8,[&](size_t){ ++ran; }); + tp::fault_mode() = 0; + check(errors == 1 && ran == 31,"a throwing item is caught and counted"); + ran = 0; + errors = tp::run(32,8,[&](size_t k){ if (k % 4 == 0) throw std::runtime_error("x"); ++ran; }); + check(errors == 8 && ran == 24,"item exceptions never escape a pool thread"); + tp::Budget budget(100); + check(budget.reserve(60) && !budget.reserve(41) && budget.reserve(40) && !budget.reserve(1), + "aggregate budget reservations are bounded"); + budget.release(50); + check(budget.reserve(50),"released bytes are reusable"); } void fixed_hash_vectors() { @@ -644,7 +702,7 @@ int main(int argc, char** argv) { palettes(root/"palettes"); st_alias_duplicates(root/"keys"); failed_payloads(root/"invalid"); dumping(root/"dump"); official_binary_oracle(root/"official-oracle"); defaults_and_options(root/"defaults"); immutable_palettes_and_pages(root/"snapshots"); split_coalesce_copy_parts(root/"lifecycle"); copied_pack_ownership(root/"ownership"); - resident_checkpoint(root/"resident"); preload(root/"preload"); + resident_checkpoint(root/"resident"); preload(root/"preload"); preload_pool(); release_while_another_source_splits(root/"release-split"); /* Every artifact is created beneath this fresh, explicitly named test * root. No user-provided paths participate in recursive cleanup. */ diff --git a/runtime/tests/test_hd_texture_pack.cpp b/runtime/tests/test_hd_texture_pack.cpp index a3695756..ab581aee 100644 --- a/runtime/tests/test_hd_texture_pack.cpp +++ b/runtime/tests/test_hd_texture_pack.cpp @@ -1,6 +1,7 @@ #define STB_IMAGE_IMPLEMENTATION #include "../third_party/stb_image.h" #include "hd_texture_pack.h" +#include "texture_pack_preload.hpp" #include #include @@ -530,6 +531,54 @@ void test_bounded_async_decode(Fixture& fixture) { hd_texture_pixels_release(&pixels); } +void test_bounded_preload(Fixture& fixture) { + namespace tp = psx_texpreload; + { + HdTexturePack* pack = open_pack(fixture.root); + if (!pack) return; + HdTexturePreloadResult r{}; + /* One valid 64x8 PNG; the other keys are empty (malformed) files. */ + const int status = hd_texture_pack_preload(pack, size_t{64} << 20, 4, &r); + check(status >= 0 && r.loaded == 1 && r.bytes == 64u * 8u * 4u, + "preload pins the valid replacement"); + check(r.requested == r.loaded + r.failed + r.over_budget && + (r.requested == 1 ? status == 1 : (status == 0 && r.failed >= 1)), + "malformed replacements are reported, not counted as preloaded"); + hd_texture_pack_set_decode_budget(pack, 1); + check(hd_texture_pack_track_upload(pack, 10, 20, 8, 4, + fixture.upload_words.data(), + fixture.upload_words.size(), nullptr) == 1, + "preload fixture upload tracks"); + HdTexturePixels pixels{}; + check(hd_texture_pack_acquire_decoded(pack, fixture.upload_hash, fixture.palette4_hash, + &pixels) == HD_TEXTURE_LOOKUP_FOUND && + pixels.width == 64, + "a preloaded image is resident and survives budget eviction"); + hd_texture_pixels_release(&pixels); + hd_texture_pack_destroy(pack); + } + { + HdTexturePack* pack = open_pack(fixture.root); + if (!pack) return; + HdTexturePreloadResult r{}; + check(hd_texture_pack_preload(pack, 64, 4, &r) == 0 && r.loaded == 0 && + r.over_budget == 1 && r.bytes == 0, + "the aggregate cap skips an oversized image"); + hd_texture_pack_destroy(pack); + } + { + HdTexturePack* pack = open_pack(fixture.root); + if (!pack) return; + tp::fault_mode() = 1; + HdTexturePreloadResult r{}; + const int status = hd_texture_pack_preload(pack, size_t{64} << 20, 4, &r); + tp::fault_mode() = 0; + check(status == 0 && r.worker_errors == 1 && r.loaded + 1 + r.over_budget <= r.requested, + "an injected worker failure is caught and reported as partial"); + hd_texture_pack_destroy(pack); + } +} + } // namespace int main() { @@ -542,6 +591,7 @@ int main() { test_wrapping(fixture); test_ambiguity_fallback(); test_bounded_async_decode(fixture); + test_bounded_preload(fixture); if (failures) { std::fprintf(stderr, "test_hd_texture_pack: %d failure(s)\n", failures); From 338bf846cb7b30576631dd45af4d564c3df90a43 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 22 Oct 2026 17:03:10 -0700 Subject: [PATCH 19/22] Account retained preload bytes after publication failure --- runtime/include/texture_pack_preload.hpp | 6 +++++- runtime/src/duckstation_texture_pack.cpp | 21 ++++++++++++++----- runtime/src/hd_texture_pack.cpp | 20 ++++++++++++++---- .../tests/test_duckstation_texture_pack.cpp | 9 ++++++++ runtime/tests/test_hd_texture_pack.cpp | 11 ++++++++++ 5 files changed, 57 insertions(+), 10 deletions(-) diff --git a/runtime/include/texture_pack_preload.hpp b/runtime/include/texture_pack_preload.hpp index 6517802b..38b355ef 100644 --- a/runtime/include/texture_pack_preload.hpp +++ b/runtime/include/texture_pack_preload.hpp @@ -17,11 +17,15 @@ namespace psx_texpreload { /* Test-only fault injection. 1: the first item's decode throws - * std::bad_alloc. 2: creating the second pool thread throws std::system_error. */ + * std::bad_alloc. 2: creating the second pool thread throws std::system_error. + * 3: publication fails after the first pinned image. */ inline std::atomic& fault_mode() { static std::atomic mode{0}; return mode; } +inline void after_publish(size_t count) { + if (count == 1 && fault_mode().load() == 3) throw std::bad_alloc(); +} /* Aggregate decoded bytes, reserved before an image is decoded. */ class Budget { diff --git a/runtime/src/duckstation_texture_pack.cpp b/runtime/src/duckstation_texture_pack.cpp index ceb138ee..2c93fa18 100644 --- a/runtime/src/duckstation_texture_pack.cpp +++ b/runtime/src/duckstation_texture_pack.cpp @@ -1212,20 +1212,31 @@ int duck_texture_pack_preload(DuckTexturePack* p, size_t max_bytes, unsigned max /* Already pinned, or an on-demand decode in flight: leave it. */ if (it->second.pinned || it->second.status == DecodeStatus::Queued || it->second.status == DecodeStatus::Loading) continue; - if (it->second.image) w.used -= it->second.image->rgba.size(); } - Decode& entry = w.decoded[ids[k]]; + const size_t previous_bytes = it != w.decoded.end() && it->second.image + ? it->second.image->rgba.size() : 0; + Decode entry; + /* Prepare throwing metadata before touching an existing entry. */ entry.path = p->entries[ids[k] - 1].path; - r.bytes += images[k]->rgba.size(); entry.image = std::move(images[k]); entry.status = DecodeStatus::Ready; entry.pinned = true; entry.use = ++w.tick; + const size_t bytes = entry.image->rgba.size(); + if (it == w.decoded.end()) w.decoded.emplace(ids[k], std::move(entry)); + else it->second = std::move(entry); + w.used -= previous_bytes; + r.bytes += bytes; ++w.pinned_count; ++r.loaded; + ++w.decoded_images; + tp::after_publish(r.loaded); } - w.decoded_images += static_cast(r.loaded); r.failed = r.requested - r.loaded - r.over_budget; if (out) *out = r; return r.loaded == r.requested ? 1 : 0; - } catch (...) { return -1; } + } catch (...) { + r.failed = r.requested - r.loaded - r.over_budget; + if (out) *out = r; /* Account the pinned prefix even on publication failure. */ + return -1; + } } int duck_texture_pack_acquire_decoded(DuckTexturePack* p, uint64_t id, DuckTexturePixels* out) { if (out) *out = {}; diff --git a/runtime/src/hd_texture_pack.cpp b/runtime/src/hd_texture_pack.cpp index af7b6874..a27c8747 100644 --- a/runtime/src/hd_texture_pack.cpp +++ b/runtime/src/hd_texture_pack.cpp @@ -1398,20 +1398,32 @@ int hd_texture_pack_preload(HdTexturePack* pack, size_t max_bytes, unsigned max_ /* Already pinned, or an on-demand decode in flight: leave it. */ if (it->second.pinned || it->second.state == DecodeState::Queued || it->second.state == DecodeState::Loading) continue; - if (it->second.state == DecodeState::Ready) pack->decode.used -= it->second.bytes; } - DecodeItem& item = pack->decode.items[work_items[k].first]; + const size_t previous_bytes = it != pack->decode.items.end() && + it->second.state == DecodeState::Ready ? it->second.bytes : 0; + DecodeItem item; + /* Prepare throwing metadata before touching an existing entry. */ item.path = work_items[k].second; item.bytes = images[k]->rgba.size(); item.image = std::move(images[k]); item.state = DecodeState::Ready; item.pinned = true; item.last_used = ++pack->decode.tick; - r.bytes += item.bytes; ++r.loaded; + const size_t bytes = item.bytes; + if (it == pack->decode.items.end()) + pack->decode.items.emplace(work_items[k].first, std::move(item)); + else it->second = std::move(item); + pack->decode.used -= previous_bytes; + r.bytes += bytes; ++r.loaded; + tp::after_publish(r.loaded); } r.failed = r.requested - r.loaded - r.over_budget; if (out) *out = r; return r.loaded == r.requested ? 1 : 0; - } catch (...) { return -1; } + } catch (...) { + r.failed = r.requested - r.loaded - r.over_budget; + if (out) *out = r; /* Account the pinned prefix even on publication failure. */ + return -1; + } #endif } diff --git a/runtime/tests/test_duckstation_texture_pack.cpp b/runtime/tests/test_duckstation_texture_pack.cpp index 7e894cc2..1a4b6019 100644 --- a/runtime/tests/test_duckstation_texture_pack.cpp +++ b/runtime/tests/test_duckstation_texture_pack.cpp @@ -154,6 +154,15 @@ void preload(const fs::path& root) { check(status == 0 && r.worker_errors == 1 && r.failed == 1 && r.loaded == 0, "a throwing decode is caught and reported as a partial preload"); } + { /* Publication can fail after retaining a pinned prefix. */ + auto pack = create(root); + tp::fault_mode() = 3; + HdTexturePreloadResult r{}; + const int status = duck_texture_pack_preload(pack.get(),size_t(64)<<20,4,&r); + tp::fault_mode() = 0; + check(status == -1 && r.loaded == 1 && r.bytes == 16*4*4, + "publication failure still reports every retained byte"); + } { /* Malformed image: failed, not pinned, async decode reports failure. */ std::ofstream(root/"replacements"/name,std::ios::binary|std::ios::trunc) << "not a png"; auto pack = create(root); auto q = query(vram,64,256,0,5,10,2,3); diff --git a/runtime/tests/test_hd_texture_pack.cpp b/runtime/tests/test_hd_texture_pack.cpp index ab581aee..15e09347 100644 --- a/runtime/tests/test_hd_texture_pack.cpp +++ b/runtime/tests/test_hd_texture_pack.cpp @@ -577,6 +577,17 @@ void test_bounded_preload(Fixture& fixture) { "an injected worker failure is caught and reported as partial"); hd_texture_pack_destroy(pack); } + { + HdTexturePack* pack = open_pack(fixture.root); + if (!pack) return; + tp::fault_mode() = 3; + HdTexturePreloadResult r{}; + const int status = hd_texture_pack_preload(pack, size_t{64} << 20, 4, &r); + tp::fault_mode() = 0; + check(status == -1 && r.loaded == 1 && r.bytes == 64u * 8u * 4u, + "publication failure still accounts the retained pinned image"); + hd_texture_pack_destroy(pack); + } } } // namespace From c3ff07814cfa722c393fb26c10e27d1d1c4ac2bd Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 08:31:16 -0700 Subject: [PATCH 20/22] Account existing pinned textures across preload retries Bound reservations against total retained residency, skip decoding pinned images, and report complete repeated loads truthfully. Cover cap reductions and publication retry in both formats. Tracking: beads-eio.3.386. --- runtime/include/hd_texture_pack.h | 9 +++-- runtime/src/duckstation_texture_pack.cpp | 33 +++++++++++++++---- runtime/src/hd_texture_pack.cpp | 25 +++++++++++--- .../tests/test_duckstation_texture_pack.cpp | 8 +++++ runtime/tests/test_hd_texture_pack.cpp | 11 +++++++ 5 files changed, 71 insertions(+), 15 deletions(-) diff --git a/runtime/include/hd_texture_pack.h b/runtime/include/hd_texture_pack.h index 165adb63..25591505 100644 --- a/runtime/include/hd_texture_pack.h +++ b/runtime/include/hd_texture_pack.h @@ -196,14 +196,17 @@ void hd_texture_pack_set_decode_budget(HdTexturePack* pack, * preloaded (over the cap, unreadable, malformed, a worker failure) are left * to the normal asynchronous, budgeted decode path; nothing is marked failed * by the preload. Returns 1 when every image was preloaded, 0 for a partial - * preload (see out), -1 on error. */ + * preload (see out), -1 on error. Repeated calls count already pinned images + * without reading/decoding them again; max_bytes caps the pack's total pinned + * residency. A cap smaller than existing residency is rejected with -1 and + * the existing residency reported; it does not discard live pinned images. */ typedef struct HdTexturePreloadResult { int requested; /* unambiguous replacements considered */ - int loaded; /* decoded and pinned */ + int loaded; /* resident and pinned, including earlier calls */ int over_budget; /* skipped: the aggregate max_bytes was reached */ int failed; /* unreadable, malformed, too large, or worker error */ int worker_errors; /* of failed: a decode that threw (caught) */ - size_t bytes; /* decoded RGBA pinned */ + size_t bytes; /* total decoded RGBA pinned by this pack */ } HdTexturePreloadResult; int hd_texture_pack_preload(HdTexturePack* pack, size_t max_bytes, unsigned max_threads, HdTexturePreloadResult* out); diff --git a/runtime/src/duckstation_texture_pack.cpp b/runtime/src/duckstation_texture_pack.cpp index 2c93fa18..3ca48b1b 100644 --- a/runtime/src/duckstation_texture_pack.cpp +++ b/runtime/src/duckstation_texture_pack.cpp @@ -1165,17 +1165,37 @@ int duck_texture_pack_preload(DuckTexturePack* p, size_t max_bytes, unsigned max if (!p) return -1; try { namespace tp = psx_texpreload; + Worker& w = p->worker; std::vector ids; - for (size_t i = 0; i < p->entries.size(); ++i) - if (!p->entries[i].ambiguous) ids.push_back(i + 1); size_t per_image = 0; { - std::lock_guard lock(p->worker.mutex); - per_image = p->worker.budget; + std::lock_guard lock(w.mutex); + per_image = w.budget; + /* The cap and result describe the pack's total pinned residency, + * including earlier successful calls or publication prefixes. */ + for (const auto& item : w.decoded) + if (item.second.pinned && item.second.image) + r.bytes += item.second.image->rgba.size(); + for (size_t i = 0; i < p->entries.size(); ++i) { + if (p->entries[i].ambiguous) continue; + ++r.requested; + const auto it = w.decoded.find(i + 1); + if (it != w.decoded.end() && it->second.pinned) ++r.loaded; + } + if (r.bytes > max_bytes) { + r.failed = r.requested - r.loaded; + if (out) *out = r; + return -1; /* A smaller cap cannot discard live pinned data. */ + } + for (size_t i = 0; i < p->entries.size(); ++i) { + if (p->entries[i].ambiguous) continue; + const auto it = w.decoded.find(i + 1); + if (it == w.decoded.end() || !it->second.pinned) ids.push_back(i + 1); + } } std::vector> images(ids.size()); std::vector status(ids.size(), tp::kPending); - tp::Budget budget(max_bytes); + tp::Budget budget(max_bytes - r.bytes); r.worker_errors = static_cast(tp::run(ids.size(), max_threads, [&](size_t k) { status[k] = tp::kFailed; const std::string& path = p->entries[ids[k] - 1].path; @@ -1202,8 +1222,7 @@ int duck_texture_pack_preload(DuckTexturePack* p, size_t max_bytes, unsigned max reservation.keep = true; status[k] = tp::kLoaded; })); - r.requested = static_cast(ids.size()); - Worker& w = p->worker; std::lock_guard lock(w.mutex); + std::lock_guard lock(w.mutex); for (size_t k = 0; k < ids.size(); ++k) { if (status[k] == tp::kOverBudget) { ++r.over_budget; continue; } if (status[k] != tp::kLoaded) continue; diff --git a/runtime/src/hd_texture_pack.cpp b/runtime/src/hd_texture_pack.cpp index a27c8747..10e2af2a 100644 --- a/runtime/src/hd_texture_pack.cpp +++ b/runtime/src/hd_texture_pack.cpp @@ -1340,17 +1340,33 @@ int hd_texture_pack_preload(HdTexturePack* pack, size_t max_bytes, unsigned max_ try { namespace tp = psx_texpreload; std::vector> work_items; - for (const auto& entry : pack->entries) - if (!entry.second.ambiguous) - work_items.emplace_back(entry.first, entry.second.replacement_path); size_t per_image = 0; { std::lock_guard lock(pack->decode.mutex); per_image = pack->decode.budget; + for (const auto& item : pack->decode.items) + if (item.second.pinned) r.bytes += item.second.bytes; + for (const auto& entry : pack->entries) { + if (entry.second.ambiguous) continue; + ++r.requested; + const auto it = pack->decode.items.find(entry.first); + if (it != pack->decode.items.end() && it->second.pinned) ++r.loaded; + } + if (r.bytes > max_bytes) { + r.failed = r.requested - r.loaded; + if (out) *out = r; + return -1; + } + for (const auto& entry : pack->entries) { + if (entry.second.ambiguous) continue; + const auto it = pack->decode.items.find(entry.first); + if (it == pack->decode.items.end() || !it->second.pinned) + work_items.emplace_back(entry.first, entry.second.replacement_path); + } } std::vector> images(work_items.size()); std::vector status(work_items.size(), tp::kPending); - tp::Budget budget(max_bytes); + tp::Budget budget(max_bytes - r.bytes); r.worker_errors = static_cast(tp::run(work_items.size(), max_threads, [&](size_t k) { status[k] = tp::kFailed; std::ifstream input(work_items[k].second, std::ios::binary | std::ios::ate); @@ -1388,7 +1404,6 @@ int hd_texture_pack_preload(HdTexturePack* pack, size_t max_bytes, unsigned max_ reservation.keep = true; status[k] = tp::kLoaded; })); - r.requested = static_cast(work_items.size()); std::lock_guard lock(pack->decode.mutex); for (size_t k = 0; k < work_items.size(); ++k) { if (status[k] == tp::kOverBudget) { ++r.over_budget; continue; } diff --git a/runtime/tests/test_duckstation_texture_pack.cpp b/runtime/tests/test_duckstation_texture_pack.cpp index 1a4b6019..52eb9b24 100644 --- a/runtime/tests/test_duckstation_texture_pack.cpp +++ b/runtime/tests/test_duckstation_texture_pack.cpp @@ -130,6 +130,11 @@ void preload(const fs::path& root) { pixels.width == 16,"preloaded image is resident without a decode request"); duck_texture_pixels_release(&pixels); duck_texture_pack_set_decode_budget(pack.get(),1); + check(duck_texture_pack_preload(pack.get(),256,1,&r) == 1 && r.loaded == 1 && + r.failed == 0 && r.bytes == 256 && r.worker_errors == 0, + "repeat counts existing residency without decoding under the reduced budget"); + check(duck_texture_pack_preload(pack.get(),255,1,&r) == -1 && r.bytes == 256, + "a smaller lifetime cap rejects without hiding pinned residency"); check(duck_texture_pack_acquire_decoded(pack.get(),match.entry_id,&pixels) == HD_TEXTURE_LOOKUP_FOUND, "a preloaded image survives budget eviction"); duck_texture_pixels_release(&pixels); @@ -162,6 +167,9 @@ void preload(const fs::path& root) { tp::fault_mode() = 0; check(status == -1 && r.loaded == 1 && r.bytes == 16*4*4, "publication failure still reports every retained byte"); + check(duck_texture_pack_preload(pack.get(),256,1,&r) == 1 && + r.loaded == 1 && r.failed == 0 && r.bytes == 256, + "publication retry accounts the retained prefix without decoding again"); } { /* Malformed image: failed, not pinned, async decode reports failure. */ std::ofstream(root/"replacements"/name,std::ios::binary|std::ios::trunc) << "not a png"; diff --git a/runtime/tests/test_hd_texture_pack.cpp b/runtime/tests/test_hd_texture_pack.cpp index 15e09347..79c867eb 100644 --- a/runtime/tests/test_hd_texture_pack.cpp +++ b/runtime/tests/test_hd_texture_pack.cpp @@ -545,6 +545,14 @@ void test_bounded_preload(Fixture& fixture) { (r.requested == 1 ? status == 1 : (status == 0 && r.failed >= 1)), "malformed replacements are reported, not counted as preloaded"); hd_texture_pack_set_decode_budget(pack, 1); + HdTexturePreloadResult repeat{}; + check(hd_texture_pack_preload(pack,64u*8u*4u,1,&repeat) >= 0 && + repeat.loaded == 1 && repeat.bytes == 64u*8u*4u && + repeat.failed == repeat.requested - 1, + "repeat counts pinned images without decoding them under a reduced budget"); + check(hd_texture_pack_preload(pack,64u*8u*4u-1,1,&repeat) == -1 && + repeat.bytes == 64u*8u*4u, + "a smaller lifetime cap rejects and reports retained residency"); check(hd_texture_pack_track_upload(pack, 10, 20, 8, 4, fixture.upload_words.data(), fixture.upload_words.size(), nullptr) == 1, @@ -586,6 +594,9 @@ void test_bounded_preload(Fixture& fixture) { tp::fault_mode() = 0; check(status == -1 && r.loaded == 1 && r.bytes == 64u * 8u * 4u, "publication failure still accounts the retained pinned image"); + check(hd_texture_pack_preload(pack,64u*8u*4u,1,&r) >= 0 && + r.loaded == 1 && r.bytes == 64u*8u*4u && r.failed == r.requested-1, + "publication retry accounts existing pinned residency"); hd_texture_pack_destroy(pack); } } From 1553af8c52ba26f4ded08d0488e624c975089f17 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 11:02:58 -0700 Subject: [PATCH 21/22] Verify pinned preload residency in the real GL cache pressure fixture --- runtime/tests/test_gl_hd_texture_pack.c | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/runtime/tests/test_gl_hd_texture_pack.c b/runtime/tests/test_gl_hd_texture_pack.c index 4a2a0870..d354717e 100644 --- a/runtime/tests/test_gl_hd_texture_pack.c +++ b/runtime/tests/test_gl_hd_texture_pack.c @@ -425,7 +425,7 @@ static void composition_scene(const char* root) { } } -static void cache_eviction_scene(const char* root) { +static void cache_pressure_scene(const char* root) { uint16_t pressure_words[16]; for(int i=0;i<16;++i) pressure_words[i]=0x1234; DuckTextureKey key={0}; key.kind=DUCK_TEXTURE_UPLOAD;key.depth=HD_TEXTURE_DEPTH_16BPP; key.source_hash=duck_texture_hash_words_le(pressure_words,16); @@ -446,7 +446,7 @@ static void cache_eviction_scene(const char* root) { composed_key=image.cache_key; gpu_hd_textures_release_image(&image);if(!composed_key) SDL_Delay(1); } - check(composed_key!=0,"complete composition ready before decoder eviction"); + check(composed_key!=0,"complete composition ready before cache pressure"); gr_fill_rect(0,0,16,16,0x03e0);gr_draw_textured_rect(2,2,4,4,0,0,0,0,texture_page);capture(); GpuHdTextureDiag before,after;gpu_hd_textures_get_diag(&before); gr_vram_transfer_in(640,64,16,1,pressure_words);int ready=0; @@ -455,17 +455,22 @@ static void cache_eviction_scene(const char* root) { gpu_hd_textures_release_image(&image);if(!ready) SDL_Delay(1); } check(ready,"full CPU budget pressure image decoded");gpu_hd_textures_get_diag(&after); - check(after.decoded_bytes==1024*1024 && after.decode_evictions>before.decode_evictions, - "pressure image evicts original decoded replacements"); + /* All fixture images fit in the bounded preload and remain pinned. + * The configured 1 MiB on-demand budget must not evict that preload. */ + check(before.decoded_bytes>1024*1024 && + after.decoded_bytes==before.decoded_bytes && + after.decoded_images==before.decoded_images && + after.decode_evictions==before.decode_evictions, + "cache pressure retains pinned preloaded replacements"); check(gpu_hd_textures_acquire_gl_draw(texture_page,0,0,bounds,0,0,&image) && !image.rgba, - "GPU-resident replacement remains ready after CPU eviction");gpu_hd_textures_release_image(&image); + "GPU-resident replacement remains ready under cache pressure");gpu_hd_textures_release_image(&image); check(gpu_hd_textures_acquire_draw(tp,0,0,parts,0,1,&image) && image.cache_key==composed_key, - "complete composition survives eviction of all source PNGs");gpu_hd_textures_release_image(&image); + "complete composition remains warm under cache pressure");gpu_hd_textures_release_image(&image); gr_draw_textured_rect(2,2,4,4,0,0,0,0,texture_page);capture(); check(sample(8,8)[0]>200,"GPU-resident replacement still draws original HD detail"); SDL_Delay(20);gpu_hd_textures_get_diag(&before); check(before.decoded_images==after.decoded_images && before.gl_uploads==after.gl_uploads, - "warm draws do not decode or upload again after CPU eviction"); + "warm draws do not decode or upload again under cache pressure"); pack_config(root,0);check(gpu_hd_textures_reload(error,sizeof(error)),"eviction fixture cleanup reload"); state();gr_vram_transfer_in(512,0,4,4,source_words);wait_ready(0);wait_ready(1); } @@ -571,7 +576,7 @@ int main(int argc,char** argv) { "ST black alpha127 remains occupied semitransparent black"); gr_set_semi_transparency(0,0); composition_scene(argv[1]);state();wait_ready(0);wait_ready(1); - cache_eviction_scene(argv[1]); + cache_pressure_scene(argv[1]); /* A queued draw must consume its source before a later source overwrite. * The next draw falls back because upload identity has been invalidated. */ From cf015086fab3e7b2c5fa025d655727842896c513 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 11:06:02 -0700 Subject: [PATCH 22/22] Check the separate pinned and on-demand cache accounting --- runtime/tests/test_gl_hd_texture_pack.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/runtime/tests/test_gl_hd_texture_pack.c b/runtime/tests/test_gl_hd_texture_pack.c index d354717e..3f7a7a2f 100644 --- a/runtime/tests/test_gl_hd_texture_pack.c +++ b/runtime/tests/test_gl_hd_texture_pack.c @@ -456,8 +456,10 @@ static void cache_pressure_scene(const char* root) { } check(ready,"full CPU budget pressure image decoded");gpu_hd_textures_get_diag(&after); /* All fixture images fit in the bounded preload and remain pinned. - * The configured 1 MiB on-demand budget must not evict that preload. */ - check(before.decoded_bytes>1024*1024 && + * The configured 1 MiB on-demand budget must not evict that preload. + * decoded_bytes accounts only the on-demand cache; pinned bytes are + * separately bounded and are not charged against this budget. */ + check(before.decoded_images>=7 && before.decoded_bytes==0 && after.decoded_bytes==before.decoded_bytes && after.decoded_images==before.decoded_images && after.decode_evictions==before.decode_evictions,