From 249057f93f38e8605cd917ed69d90b127abd7b30 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:30:17 -0700 Subject: [PATCH 01/27] Widescreen precision: exact-sign NCLIP + GTE clamp rescue; backend-invariant entry hooks Three changes that ride the existing PGXP dataflow engine: - Arm the PGXP engine whenever a precision consumer needs it, not only when a correction feature is on. gpu_pgxp_rederive_enable() derives the arm from texture correction OR geometry correction OR the widescreen precise-NCLIP opt-in, and every setter re-derives. Previously disabling both corrections silently disabled the shadows precise_nclip depends on. - gte_nclip: when the sub-pixel shadow of all three SXY FIFO entries is live, take the winding SIGN from the exact double cross product and keep the hardware integer magnitude. The X-squash scales areas by a positive factor, so only integer rounding ever flips a sign; this removes both the vanished trackside signs and the per-frame tile flicker at wide aspects without any epsilon tolerance. The producer clamps shadow coords to +/-4096 px so a projection blowup cannot wrap the 16.16 int32. - Clamp rescue (prepare_precise_triangle): a vertex parked exactly on a GTE saturation rail (+/-1024) is hardware-mangled; when the validated dataflow shadow knows its true position, rasterize with that instead. Kills the "polygon spike" streak class that 4:3 framing plus CRT overscan hid. Every in-range vertex stays native, so normal geometry is bit-identical. - dirty_ram_interp: fire psx_mod_function_entry on interp dispatch so function-entry mod hooks do not depend on which backend executes a mod-patched page. Co-Authored-By: Claude Fable 5 --- runtime/src/dirty_ram_interp.c | 11 +++++ runtime/src/gpu.c | 75 +++++++++++++++++++++++++++++++--- runtime/src/gte.cpp | 52 ++++++++++++++++------- 3 files changed, 118 insertions(+), 20 deletions(-) diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 641b3d73..82213e5d 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -2811,6 +2811,17 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ * pages overwritten by a runtime overlay (Tomba 2), not just [FLOOR, RAM). */ int allow_local_dirty_flow = phys_is_overlay_flow_region(phys); + /* Backend-invariant mod_function_entry hooks: generated code fires + * psx_mod_function_entry at listed function entries, but a mod-patched + * (dirty) page runs HERE instead and would silently skip them. Fire the + * same hook on interp dispatch so the contract does not depend on which + * backend executes the page. The runtime filters by exact address, so + * this is a short scan over the (tiny) registered-plugin list. */ + { + extern void psx_mod_function_entry(CPUState* cpu, uint32_t address); + psx_mod_function_entry(cpu, addr); + } + /* Per-PC entry counter (visible via dirty_ram_stats). */ DirtyRamPcEntry *pc_entry = pc_table_get_or_insert(phys); if (pc_entry) pc_entry->hits++; diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 9f2dc6fc..d3a7ab12 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -219,7 +219,13 @@ static int ws_gameplay_state_value_count = 0; * this many consecutive frames (save/options/memory-card) — reverts to 4:3. */ #define WS_GTE_GAME_MODE_HYSTERESIS 45u void gpu_ws_set_gte_game_mode(int on) { ws_gte_game_mode_cfg = on ? 1 : 0; } -void gpu_ws_set_precise_nclip(int on) { ws_precise_nclip_cfg = on ? 1 : 0; } +void gpu_pgxp_rederive_enable(void); +void gpu_ws_set_precise_nclip(int on) { + ws_precise_nclip_cfg = on ? 1 : 0; + /* Precise NCLIP and clamp rescue consume PGXP dataflow shadows even when + * both correction features are off — re-derive the engine arm. */ + gpu_pgxp_rederive_enable(); +} int gpu_ws_precise_nclip_enabled(void) { return ws_precise_nclip_cfg && ws_active(); } void gpu_ws_set_gameplay_state_gate(uint32_t addr, const uint32_t *values, int nvalues) { @@ -3386,12 +3392,19 @@ static int s_texture_correction_enabled = 0; extern int gte_precision_load_word(uint32_t addr, uint32_t packed, int32_t *x16, int32_t *y16, uint16_t *z); +/* The PGXP dataflow engine feeds the two correction features AND the + * widescreen precision consumers (precise NCLIP, clamp rescue). Arm it while + * any of them is on. Every setter re-derives through here so init order and + * runtime toggles (debug server) can never leave the engine stale. */ +void gpu_pgxp_rederive_enable(void) { + pgxp_set_enabled(s_texture_correction_enabled || + gte_geometry_correction_enabled() || + ws_precise_nclip_cfg); +} + void gpu_texture_correction_set(int enabled) { s_texture_correction_enabled = enabled ? 1 : 0; - /* The PGXP dataflow engine feeds BOTH corrections; arm it while either - * is on (geometry correction is toggled in gte.cpp, so re-derive here). */ - pgxp_set_enabled(s_texture_correction_enabled || - gte_geometry_correction_enabled()); + gpu_pgxp_rederive_enable(); } int gpu_texture_correction_enabled(void) { @@ -3436,10 +3449,62 @@ int gpu_pgxp_census_dump(uint32_t *frame_out, uint32_t *tris, uint32_t *clean, return n; } +/* Clamp rescue (widescreen precision, rides the precise-NCLIP opt-in): a + * vertex parked exactly on a GTE saturation rail (+/-1024 screen units) is + * hardware-mangled — its true projection is somewhere far off-frame, and + * rasterizing the clamped position folds that error INTO the frame as the + * classic PS1 "polygon spike" (a sliver streaking across the screen). 4:3 + * framing plus CRT overscan hid most of these; a wide aspect stares right at + * them. When the address-keyed dataflow shadow knows the true sub-pixel + * position (clamped to +/-4096 px at the producer), substitute it for railed + * vertices only — every in-range vertex stays native, so normal geometry is + * bit-identical and shared edges cannot crack. */ +static int ws_clamp_rescue_vertex(uint32_t addr, uint32_t word, + int32_t raw_x, int32_t raw_y, + int32_t *px, int32_t *py) { + if (raw_x != -1024 && raw_x != 1023 && raw_y != -1024 && raw_y != 1023) + return 0; + uint16_t sz; + if (pgxp_get_precise_vertex(addr, word, raw_x, raw_y, px, py, &sz) + != PGXP_SRC_DATAFLOW) + return 0; + /* Only substitute when the shadow actually differs — a legit vertex that + * genuinely sits on the rail keeps its native position. */ + return ((*px >> 16) != raw_x) || ((*py >> 16) != raw_y); +} + static void prepare_precise_triangle(int i0, int i1, int i2, const int32_t vx[3], const int32_t vy[3]) { gr_set_perspective_triangle(0, 0.0f, 0.0f, 0.0f); if (!gte_geometry_correction_enabled()) { + if (gpu_ws_precise_nclip_enabled() && + gp0_cmd_source_addr != 0xFFFFFFFFu) { + const int idx[3] = { i0, i1, i2 }; + int32_t fx[3], fy[3]; + int any_rescued = 0; + for (int i = 0; i < 3; i++) { + uint32_t word = gp0_cmd_buf[idx[i]]; + int32_t raw_x, raw_y; + parse_vertex(word, &raw_x, &raw_y); + uint32_t addr = gp0_cmd_source_addr + (uint32_t)idx[i] * 4u; + int32_t px, py; + if (ws_clamp_rescue_vertex(addr, word, raw_x, raw_y, &px, &py)) { + any_rescued = 1; + fx[i] = (int32_t)((int64_t)px + + (int64_t)(vx[i] - raw_x) * 65536); + fy[i] = (int32_t)((int64_t)py + + (int64_t)(vy[i] - raw_y) * 65536); + } else { + fx[i] = vx[i] << 16; + fy[i] = vy[i] << 16; + } + } + if (any_rescued) { + gr_set_precise_triangle(1, fx[0],fy[0], fx[1],fy[1], + fx[2],fy[2]); + return; + } + } gr_set_precise_triangle(0, 0,0, 0,0, 0,0); return; } diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index 4ef73575..351078ea 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -312,6 +312,8 @@ static inline int64_t geom_slot(uint32_t packed) { return (int64_t)(y + GEOM_BIAS) * GEOM_AXIS + (x + GEOM_BIAS); } +extern "C" void gpu_pgxp_rederive_enable(void); + extern "C" void gte_geometry_correction_set(int enabled) { s_geom_enabled = enabled ? 1 : 0; s_geom_hits = 0; @@ -324,6 +326,7 @@ extern "C" void gte_geometry_correction_set(int enabled) { if (!s_geom_cache) s_geom_enabled = 0; /* fail closed: stay faithful */ } gte_geom_generation_advance(); + gpu_pgxp_rederive_enable(); } /* Lookup census for the debug server: attempted, hit, and the two miss classes. @@ -888,9 +891,17 @@ void gte_rtps_internal(GTEState* gte, int16_t* V, bool setMac0, uint32_t instr) int64_t sx = sx16 >> 16; int64_t sy = sy16 >> 16; gte->push_sxy(sx, sy); - if (!s_gte_replay_sandbox) - pgxp_gte_push_sxy((int32_t)sx16, (int32_t)sy16, gte->SZ[3], + if (!s_gte_replay_sandbox) { + /* Shadow the pre-truncation position, clamped to +/-4096 px so an + * extreme projection blowup can't wrap the 16.16 int32 and land a + * garbage shadow back inside the frame. Well past the +/-1024 + * hardware rails either way, so direction survives for clamp rescue. */ + const int64_t kLim = (int64_t)4096 << 16; + int64_t cx16 = sx16 < -kLim ? -kLim : (sx16 > kLim - 1 ? kLim - 1 : sx16); + int64_t cy16 = sy16 < -kLim ? -kLim : (sy16 > kLim - 1 ? kLim - 1 : sy16); + pgxp_gte_push_sxy((int32_t)cx16, (int32_t)cy16, gte->SZ[3], (uint32_t)gte->SXY[2]); + } geom_note((uint32_t)gte->SXY[2], sx16, sy16); // Step 5: Depth cueing (MAC0/IR0) — only for last vertex of RTPT or RTPS @@ -929,23 +940,34 @@ void gte_nclip(GTEState* gte, uint32_t instr) { int32_t sy1 = static_cast(gte->SXY[1] >> 16); int32_t sx2 = static_cast(gte->SXY[2] & 0xFFFF); int32_t sy2 = static_cast(gte->SXY[2] >> 16); - int32_t px0 = 0, py0 = 0, px1 = 0, py1 = 0, px2 = 0, py2 = 0; - if (gpu_ws_precise_nclip_enabled() && - pgxp_get_gte_sxy(0, &px0, &py0) && - pgxp_get_gte_sxy(1, &px1, &py1) && - pgxp_get_gte_sxy(2, &px2, &py2)) { - sx0 = px0 >> 16; - sy0 = py0 >> 16; - sx1 = px1 >> 16; - sy1 = py1 >> 16; - sx2 = px2 >> 16; - sy2 = py2 >> 16; - } int64_t mac0 = (int64_t)sx0 * (sy1 - sy2) + (int64_t)sx1 * (sy2 - sy0) + (int64_t)sx2 * (sy0 - sy1); gte->check_mac0_overflow(mac0); - gte->MAC0 = static_cast(mac0); + int32_t out = static_cast(mac0); + /* Widescreen precise NCLIP: the X-squash scales winding areas by a + * positive factor, but integer SXY rounding flips the sign of thin + * glancing faces (trackside signs vanishing / flickering only at wide + * aspects). When the full sub-pixel shadow of all three vertices is + * live, take the SIGN from the exact cross product; keep the hardware + * magnitude so NCLIP-magnitude consumers (LOD heuristics) are untouched. + * A sign disagreement replaces a near-zero hardware value, so the +/-1 + * substitute is honest about the face's true orientation. */ + int32_t px0, py0, px1, py1, px2, py2; + if (gpu_ws_precise_nclip_enabled() && + pgxp_get_gte_sxy(0, &px0, &py0) && + pgxp_get_gte_sxy(1, &px1, &py1) && + pgxp_get_gte_sxy(2, &px2, &py2)) { + const double fx0 = px0 / 65536.0, fy0 = py0 / 65536.0; + const double fx1 = px1 / 65536.0, fy1 = py1 / 65536.0; + const double fx2 = px2 / 65536.0, fy2 = py2 / 65536.0; + const double cross = fx0 * (fy1 - fy2) + + fx1 * (fy2 - fy0) + + fx2 * (fy0 - fy1); + if (cross > 0.0 && out <= 0) out = 1; + else if (cross < 0.0 && out >= 0) out = -1; + } + gte->MAC0 = out; gte->set_error_flag(); } From d31953fe6bb8750ab30602587f2b461cec79b330 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:30:29 -0700 Subject: [PATCH 02/27] Fix overlay autocompile: missing cull-widen ABI symbols; PGXP hooks swallowed by #endif Two bugs that together kept every mod-patched text page interpreted forever (the WipEout 3 framerate mod's 74 divergent pages -> ~0.5M interpreted block dispatches/sec in play): - The [[widescreen.cull.widen]] emitter writes psx_ws_cull_slt_widen / psx_ws_cull_slti_lower at configured sites, including in overlay-resident code, but the overlay dispatch preamble never defined them: every shard containing a widen site failed to link (undefined reference) and the autocompile pipeline reported compile-error for the whole region. Mirror both helpers into the preamble following the documented self-contained, byte-identical convention. - append_pgxp_hooks joins the hook onto the translated statement with a space; the block-cycles MULT/DIV/MFLO/MFHI translations end in `#endif`, and the preprocessor discards tokens after #endif (the "extra tokens" warning class) - so those hooks silently never ran. Give the hook its own line when the translation ends in a directive. Co-Authored-By: Claude Fable 5 --- recompiler/src/pgxp_hook_emitter.cpp | 7 +++++++ runtime/include/overlay_dispatch_preamble.c.inc | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/recompiler/src/pgxp_hook_emitter.cpp b/recompiler/src/pgxp_hook_emitter.cpp index 1455983c..5c54b8f5 100644 --- a/recompiler/src/pgxp_hook_emitter.cpp +++ b/recompiler/src/pgxp_hook_emitter.cpp @@ -30,6 +30,13 @@ inline int16_t get_imm16(uint32_t i) { return (int16_t)(i & 0xFFFF); } * return early above translate_instruction's main dispatch and are likewise * unhooked (they are cull compares, not vertex moves; validation covers). */ void append_pgxp_hooks(uint32_t instr, std::string& code) { + /* A translation ending in a preprocessor directive (the block-cycles + * muldiv wrappers end in `#endif`) would swallow an appended hook: the + * preprocessor discards tokens after #endif (the "extra tokens at end of + * #endif" warning class), so the MULT/DIV/MFLO/MFHI hooks silently never + * ran. Give the hook its own line. */ + if (code.size() >= 6 && code.compare(code.size() - 6, 6, "#endif") == 0) + code += "\n "; const uint32_t opcode = (instr >> 26) & 0x3F; const uint32_t rs = get_rs(instr); const uint32_t rt = get_rt(instr); diff --git a/runtime/include/overlay_dispatch_preamble.c.inc b/runtime/include/overlay_dispatch_preamble.c.inc index c2acb8e7..eee589b7 100644 --- a/runtime/include/overlay_dispatch_preamble.c.inc +++ b/runtime/include/overlay_dispatch_preamble.c.inc @@ -211,6 +211,23 @@ int psx_ws_cull_vxrange(uint32_t x, uint32_t imm) { return (((x + (uint32_t)margin) & 0xFFFFu) < (bound + 2u * (uint32_t)margin)) ? 1 : 0; } +/* [[widescreen.cull.widen]] SLT-family helpers. The emitter writes these at + * explicitly configured cull sites, INCLUDING in overlay-resident code, so + * the DLL must define them or the shard fails to link (undefined reference -> + * no native shard -> that mod-patched page runs interpreted forever). Same + * contract as the funnel helpers above: self-contained on psx_ws_x_margin(), + * identity at margin 0 (4:3), MUST stay byte-for-byte identical to the gpu.c + * implementations. */ +int psx_ws_cull_slti_lower(uint32_t sx, uint32_t imm) { + int32_t bound = (int32_t)(int16_t)(uint16_t)imm; + return ((int32_t)sx < bound - psx_ws_x_margin()) ? 1 : 0; +} +int psx_ws_cull_slt_widen(uint32_t rs, uint32_t rt, int bound_is_rt) { + const int32_t m = psx_ws_x_margin(); + const int32_t a = (int32_t)rs, b = (int32_t)rt; + return bound_is_rt ? ((a < b + m) ? 1 : 0) + : ((a + m < b) ? 1 : 0); +} void psx_ws_sprite_tag(CPUState *cpu) { if (g_cbs.ws_sprite_tag) g_cbs.ws_sprite_tag(cpu); } From c6ad1a074a693e367d07a01c899e94d318c6148f Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:47:34 -0700 Subject: [PATCH 03/27] Bezel: fill letterbox bands (top/bottom margins), not just pillarbox columns present_bezel only drew the left/right margins and explicitly ignored the vertical geometry, so a window taller than the content aspect (32:9 content on a 16:9 display) framed the game with bezel art on the sides and raw black bars above and below. Add bezel_draw_band -- the transpose of bezel_draw_rect: one logo spans the band height with the same air factor, repeating across at authored aspect -- and clear/draw the top and bottom bands between the side columns so no pixel is painted twice. Co-Authored-By: Claude Fable 5 --- runtime/src/gpu_gl_renderer.c | 93 ++++++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 29 deletions(-) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 12ff547f..ee27d00b 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -426,30 +426,30 @@ static GLint s_repl_uXscale = -1, s_repl_uXcenter = -1, s_repl_uShift = -1; * depth test stays disabled, and the buffer is never cleared. */ static int s_pgxp_depth = 0; /* configured */ static int s_depth_armed = 0; /* this batch/prim may depth-test */ -static int s_zdebug = 0; /* paint depth as greyscale */ +static int s_zdebug = 0; /* paint depth as greyscale */ #define ZC_BUCKETS 20 static float s_zc_min = 1e30f, s_zc_max = 0.0f; static double s_zc_sum = 0.0; static uint64_t s_zc_n = 0, s_zc_hist[ZC_BUCKETS]; -static int s_depth_needs_clear = 1; /* armed at Swap; see hr_begin */ +static int s_depth_needs_clear = 1; /* armed at Swap; see hr_begin */ static int s_wide_depth_clear = 1; /* same, for the native-wide mirror */ /* Diagnostic: GL_ALWAYS keeps depth WRITES but never rejects. Separates "the * test is discarding this geometry" from "this geometry never got drawn", * which no amount of staring at a depth readout settles. */ static int s_depth_always = 0; -/* Near plane in GTE Z units for the reciprocal depth map. - * - * MUST sit at or below the smallest Z the scene produces, because the map - * clamps with max(a_z, zn): anything nearer collapses onto the near plane and - * then occludes everything drawn after it. At 128 that swallowed the track - * under the camera, which in turn hid the ship completely. - * - * A sweep that scored 'distinct depth levels used' picked 128 and was - * exactly backwards -- clamping the near field concentrates the remainder - * into more visible levels, so the broken value scored best. 1 clamps - * nothing (Z is at least 1) and only costs far-field precision, which a - * 24-bit buffer still resolves. */ -static float s_pgxp_zscale = 1.0f; +/* Near plane in GTE Z units for the reciprocal depth map. + * + * MUST sit at or below the smallest Z the scene produces, because the map + * clamps with max(a_z, zn): anything nearer collapses onto the near plane and + * then occludes everything drawn after it. At 128 that swallowed the track + * under the camera, which in turn hid the ship completely. + * + * A sweep that scored 'distinct depth levels used' picked 128 and was + * exactly backwards -- clamping the near field concentrates the remainder + * into more visible levels, so the broken value scored best. 1 clamps + * nothing (Z is at least 1) and only costs far-field precision, which a + * 24-bit buffer still resolves. */ +static float s_pgxp_zscale = 1.0f; static GLint s_tex_uZscale = -1, s_repl_uZscale = -1; static GLint s_tex_uZdebug = -1, s_repl_uZdebug = -1; static GLint s_tex_uDepthOn = -1, s_repl_uDepthOn = -1; @@ -814,10 +814,10 @@ static void hold_ensure_tex(int w, int h) { /* Snapshot the just-drawn default backbuffer (letterbox + content) before Swap. * Hold-last can then redraw this exact image without touching guest VRAM. */ -/* MEASURED: this costs nothing. Disabling the capture entirely moves present - * by less than noise (18.86ms off vs 18.49ms on, 8K/7680-wide), so the - * earlier claim that throttling it cut present 18.2ms -> 3.9ms was wrong -- - * that comparison was between two different screens, not two settings. +/* MEASURED: this costs nothing. Disabling the capture entirely moves present + * by less than noise (18.86ms off vs 18.49ms on, 8K/7680-wide), so the + * earlier claim that throttling it cut present 18.2ms -> 3.9ms was wrong -- + * that comparison was between two different screens, not two settings. * Capture every frame; the held frame is only ever shown while the * emulator is STALLED or PAUSED (see gl_renderer_present_hold_last callers: * the stall tick and the rewind pause), where it is a still image. Capturing @@ -1223,7 +1223,7 @@ static const char *REPL_FS = "uniform int u_semipass;\n" "uniform int u_semimode;\n" "uniform int u_maskset;\n" - "uniform int u_zdebug; /* 1 = paint depth as greyscale */\n" + "uniform int u_zdebug; /* 1 = paint depth as greyscale */\n" "uniform usampler2D u_vram;\n" "uniform int u_recolour; /* 1 = take colour from the live CLUT */\n" "uniform int u_ink; /* dominant ink index; valid iff u_recolour */\n" @@ -1361,7 +1361,7 @@ static const char *TEX_FS = "uniform int u_semimode; /* PS1 blend mode; drives dual-source factors */\n" "uniform ivec4 u_twin; /* texture window: mask_x, mask_y, off_x, off_y */\n" "uniform int u_maskset; /* GP0(E6h) set-mask: OR bit15 into output */\n" - "uniform int u_zdebug; /* 1 = paint depth as greyscale */\n" + "uniform int u_zdebug; /* 1 = paint depth as greyscale */\n" "uniform int u_filter; /* 1 = bilinear */\n" "uniform float u_shift;\n" "int vram_at(int x, int y){\n" @@ -2240,7 +2240,7 @@ static void flush_tex_batch(void) { p_glUniform1i(s_uMaskset, s_tb_mask); p_glUniform1i(s_uFilter, s_tb_filter); p_glUniform1i(s_tex_uDepthOn, s_tb_depth); - p_glUniform1i(s_tex_uZdebug, s_zdebug); + p_glUniform1i(s_tex_uZdebug, s_zdebug); p_glUniform1f(s_tex_uZscale, s_pgxp_zscale); s_depth_armed = s_tb_depth; p_glBindVertexArray(s_tex_vao); @@ -2356,7 +2356,7 @@ static void draw_repl_prim(const float *verts, int semi) { p_glUniform1f(s_uReplRefMax, s_repl_ref_max); s_depth_armed = s_pgxp_depth && s_pz_valid; p_glUniform1i(s_repl_uDepthOn, s_depth_armed); - p_glUniform1i(s_repl_uZdebug, s_zdebug); + p_glUniform1i(s_repl_uZdebug, s_zdebug); p_glUniform1f(s_repl_uZscale, s_pgxp_zscale); p_glBindVertexArray(s_tex_vao); @@ -3530,7 +3530,7 @@ static int init_gpu_raster(void) { s_repl_uXcenter = p_glGetUniformLocation(s_repl_prog, "u_xcenter"); s_repl_uShift = p_glGetUniformLocation(s_repl_prog, "u_shift"); s_repl_uDepthOn = p_glGetUniformLocation(s_repl_prog, "u_depth_on"); - s_repl_uZdebug = p_glGetUniformLocation(s_repl_prog, "u_zdebug"); + s_repl_uZdebug = p_glGetUniformLocation(s_repl_prog, "u_zdebug"); s_repl_uZscale = p_glGetUniformLocation(s_repl_prog, "u_zscale"); s_conv = (uint32_t *)malloc((size_t)VRAM_W * VRAM_H * sizeof(uint32_t)); @@ -3593,7 +3593,7 @@ static int init_gpu_raster(void) { s_tex_uXscale = p_glGetUniformLocation(s_tex_prog, "u_xscale"); s_tex_uXcenter = p_glGetUniformLocation(s_tex_prog, "u_xcenter"); s_tex_uDepthOn = p_glGetUniformLocation(s_tex_prog, "u_depth_on"); - s_tex_uZdebug = p_glGetUniformLocation(s_tex_prog, "u_zdebug"); + s_tex_uZdebug = p_glGetUniformLocation(s_tex_prog, "u_zdebug"); s_tex_uZscale = p_glGetUniformLocation(s_tex_prog, "u_zscale"); /* Default the new uniforms to the no-op (1.0 scale, 0 centre) -- GLSL would * otherwise zero them, collapsing all x to 0. */ @@ -3987,8 +3987,8 @@ void gl_renderer_present(const uint32_t *pixels, int src_w, int src_h, int linea s_last_present_path = GL_PRES_CPU; } -void gl_renderer_set_pgxp_zscale(float s) { if (s > 0.0f) s_pgxp_zscale = s; } -float gl_renderer_pgxp_zscale(void) { return s_pgxp_zscale; } +void gl_renderer_set_pgxp_zscale(float s) { if (s > 0.0f) s_pgxp_zscale = s; } +float gl_renderer_pgxp_zscale(void) { return s_pgxp_zscale; } void gl_renderer_set_zdebug(int on) { s_zdebug = on ? 1 : 0; } int gl_renderer_zdebug(void) { return s_zdebug; } void gl_renderer_set_pgxp_depth(int enabled) { @@ -5271,16 +5271,47 @@ static void bezel_draw_rect(int vx, int vy, int vw, int vh) { glDrawArrays(GL_TRIANGLES, 0, 3); } +/* Tile the bezel along a letterbox BAND (top/bottom margin): the transpose of + * bezel_draw_rect. A band is short and wide, so one logo spans the band + * HEIGHT (same air factor as the margins use for width) and repeats across, + * each copy at its authored aspect -- the marks read at the same size in the + * bands as in the side margins, which is what makes the frame look like one + * bezel instead of two different treatments. */ +static void bezel_draw_band(int vx, int vy, int vw, int vh) { + if (vw <= 0 || vh <= 0 || s_bezel_w <= 0 || s_bezel_h <= 0) return; + const float img = (float)s_bezel_w / (float)s_bezel_h; + /* One logo spans the band height, with a little air above and below. */ + const float tile_h = 1.0f / 0.78f; + /* Horizontal repeats so each tile keeps the logo's own aspect: a tile is + * (vh/tile_h) tall on screen, so it must be (vh/tile_h)*img wide, and vw + * divided by that is the count. */ + const float tile_w = tile_h * (float)vw / ((float)vh * img); + glViewport(vx, vy, vw, vh); + p_glUniform4f(s_present_uUvRect, + 0.0f, -(tile_h - 1.0f) * 0.5f, + tile_w, (tile_h + 1.0f) * 0.5f); + glDrawArrays(GL_TRIANGLES, 0, 3); +} + /* Bezel into the left and right margins, one copy each, before the game quad. * Per-margin rather than one image behind everything: the margins are tall and * narrow, so portrait artwork fills them naturally, and each side gets the * whole picture instead of one picture cut in half by the frame. */ static void present_bezel(int ww, int wh, int lx, int ly, int lw, int lh) { - (void)ly; (void)lh; if (!s_bezel_tex || ww <= 0 || wh <= 0) return; const int right_x = lx + lw; const int right_w = ww - right_x; - if (lx <= 0 && right_w <= 0) return; /* no margins to fill */ + /* Letterbox bands (top/bottom margins): same treatment as the side + * margins, so a window taller than the content aspect (e.g. 32:9 content + * in a 16:9 window) gets the bezel instead of black bars. The bands span + * only the width between the side columns so no pixel is drawn twice. */ + const int top_y = ly + lh; + const int top_h = wh - top_y; + const int bot_h = ly; + const int band_x = lx > 0 ? lx : 0; + const int band_w = (right_w > 0 ? right_x : ww) - band_x; + if (lx <= 0 && right_w <= 0 && top_h <= 0 && bot_h <= 0) + return; /* no margins to fill */ p_glBindFramebuffer(PSXGL_FRAMEBUFFER, 0); glDisable(GL_SCISSOR_TEST); glDisable(GL_BLEND); @@ -5296,6 +5327,8 @@ static void present_bezel(int ww, int wh, int lx, int ly, int lw, int lh) { glClearColor(s_bezel_bg[0], s_bezel_bg[1], s_bezel_bg[2], 1.0f); if (lx > 0) { glScissor(0, 0, lx, wh); glClear(GL_COLOR_BUFFER_BIT); } if (right_w > 0) { glScissor(right_x, 0, right_w, wh); glClear(GL_COLOR_BUFFER_BIT); } + if (top_h > 0) { glScissor(band_x, top_y, band_w, top_h); glClear(GL_COLOR_BUFFER_BIT); } + if (bot_h > 0) { glScissor(band_x, 0, band_w, bot_h); glClear(GL_COLOR_BUFFER_BIT); } glDisable(GL_SCISSOR_TEST); glEnable(GL_BLEND); p_glBlendEquationSeparate(GL_FUNC_ADD, GL_FUNC_ADD); @@ -5303,6 +5336,8 @@ static void present_bezel(int ww, int wh, int lx, int ly, int lw, int lh) { GL_ONE, GL_ONE_MINUS_SRC_ALPHA); if (lx > 0) bezel_draw_rect(0, 0, lx, wh); if (right_w > 0) bezel_draw_rect(right_x, 0, right_w, wh); + if (top_h > 0) bezel_draw_band(band_x, top_y, band_w, top_h); + if (bot_h > 0) bezel_draw_band(band_x, 0, band_w, bot_h); glDisable(GL_BLEND); p_glBindVertexArray(0); p_glUseProgram(0); From dcb649d5559ac1f062f2d72fcc9b80bf4a2d41cc Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:09:17 -0700 Subject: [PATCH 04/27] Bezel: procedural padding shade + viewport-transition fade-in Two refinements that keep the game image the focal point and make the padding read as environmental texture instead of a second UI layer: - Padding shade: a per-rect gradient quad (new tiny fragment program) drawn over the bezel backdrop+tiles, derived from the live viewport rectangle. The padding is near-black (alpha 0.94) immediately beside the game and eases out to a faint pattern (alpha 0.72) at the outer screen edges, smoothstepped across the full padding span - wide and subtle, never a narrow shadow. Columns shade along X, bands along Y, so it holds for any viewport aspect. The game rect itself is never touched. - Transition fade: when the viewport rect moves (menu aspect contraction, window resize) the padding restarts from solid black and reveals the resting gradient over ~200 ms (smoothstepped), so the eye follows the contracting game image instead of a full-intensity pattern popping in on frame one. The fade folds into the gradient endpoints (affine), so it is one draw either way. Co-Authored-By: Claude Fable 5 --- runtime/src/gpu_gl_renderer.c | 88 +++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index ee27d00b..09c989bc 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -5293,6 +5293,49 @@ static void bezel_draw_band(int vx, int vy, int vw, int vh) { glDrawArrays(GL_TRIANGLES, 0, 3); } +/* Padding shade: a procedural darkening gradient drawn OVER the bezel layer, + * derived from the live viewport rectangle. The area adjacent to the game is + * near-black and low-contrast; the pattern re-emerges gradually toward the + * outer screen edges, so the bezel reads as environmental texture instead of + * a second UI layer competing with gameplay. The game framebuffer itself is + * never touched — the shade exists only in the padding rects, which also + * makes it aspect-agnostic (columns shade along X, bands along Y). + * + * The same quads implement the transition fade: on any viewport-rect change + * (aspect contraction into a menu, a window resize) the padding starts fully + * black and reveals the pattern over ~200 ms, so the eye follows the moving + * game image rather than a full-intensity pattern popping in on frame one. */ +static const char *BEZEL_SHADE_FS = + "#version 330\n" + "in vec2 v_uv; out vec4 frag;\n" + "uniform int u_axis;\n" /* 0: gradient along X, 1: along Y */ + "uniform vec2 u_a;\n" /* black alpha at t=0 / t=1 */ + "void main(){\n" + " float t = (u_axis == 1) ? v_uv.y : v_uv.x;\n" + " float a = mix(u_a.x, u_a.y, smoothstep(0.0, 1.0, t));\n" + " frag = vec4(0.0, 0.0, 0.0, a);\n" + "}\n"; +static GLuint s_bezel_shade_prog = 0; +static GLint s_bezel_shade_uAxis = -1, s_bezel_shade_uA = -1, + s_bezel_shade_uRect = -1; +/* Fade-in state: restarted whenever the viewport rect moves. */ +static int s_bezel_prev_rect[4] = { -1, -1, -1, -1 }; +static uint64_t s_bezel_fade_start_ms = 0; +#define BEZEL_FADE_MS 200.0f +#define BEZEL_SHADE_NEAR 0.94f /* padding beside the game: near-black */ +#define BEZEL_SHADE_FAR 0.72f /* outer screen edge: pattern stays faint */ + +extern uint64_t psx_host_mono_ms(void); + +static void bezel_shade_rect(int vx, int vy, int vw, int vh, + int axis, float a0, float a1) { + if (vw <= 0 || vh <= 0) return; + glViewport(vx, vy, vw, vh); + p_glUniform1i(s_bezel_shade_uAxis, axis); + p_glUniform2f(s_bezel_shade_uA, a0, a1); + glDrawArrays(GL_TRIANGLES, 0, 3); +} + /* Bezel into the left and right margins, one copy each, before the game quad. * Per-margin rather than one image behind everything: the margins are tall and * narrow, so portrait artwork fills them naturally, and each side gets the @@ -5338,6 +5381,51 @@ static void present_bezel(int ww, int wh, int lx, int ly, int lw, int lh) { if (right_w > 0) bezel_draw_rect(right_x, 0, right_w, wh); if (top_h > 0) bezel_draw_band(band_x, top_y, band_w, top_h); if (bot_h > 0) bezel_draw_band(band_x, 0, band_w, bot_h); + + /* Shade the padding: near-black beside the viewport, easing out to a + * faint pattern at the screen edges; the whole layer fades in from black + * for ~200 ms after the viewport rect moves. Drawn over backdrop+tiles, + * never over the game rect. */ + if (!s_bezel_shade_prog) { + s_bezel_shade_prog = build_program(PRESENT_VS, BEZEL_SHADE_FS); + if (s_bezel_shade_prog) { + s_bezel_shade_uAxis = + p_glGetUniformLocation(s_bezel_shade_prog, "u_axis"); + s_bezel_shade_uA = + p_glGetUniformLocation(s_bezel_shade_prog, "u_a"); + s_bezel_shade_uRect = + p_glGetUniformLocation(s_bezel_shade_prog, "u_uv_rect"); + } + } + if (s_bezel_shade_prog) { + const uint64_t now = psx_host_mono_ms(); + if (s_bezel_prev_rect[0] != lx || s_bezel_prev_rect[1] != ly || + s_bezel_prev_rect[2] != lw || s_bezel_prev_rect[3] != lh) { + s_bezel_prev_rect[0] = lx; s_bezel_prev_rect[1] = ly; + s_bezel_prev_rect[2] = lw; s_bezel_prev_rect[3] = lh; + s_bezel_fade_start_ms = now; + } + float fade = (float)(now - s_bezel_fade_start_ms) / BEZEL_FADE_MS; + if (fade < 0.0f) fade = 0.0f; + if (fade > 1.0f) fade = 1.0f; + fade = fade * fade * (3.0f - 2.0f * fade); + /* At fade=0 the padding is solid black; at fade=1 the resting + * gradient (near/far) stands. Affine per endpoint, so folding the + * fade into the endpoints is exact. */ + const float a_near = 1.0f - fade * (1.0f - BEZEL_SHADE_NEAR); + const float a_far = 1.0f - fade * (1.0f - BEZEL_SHADE_FAR); + p_glUseProgram(s_bezel_shade_prog); + p_glUniform4f(s_bezel_shade_uRect, 0.0f, 0.0f, 1.0f, 1.0f); + /* v_uv.y is 1 at the BOTTOM of the drawn rect (PRESENT_VS V-flip). */ + if (lx > 0) /* near edge = right side (t=1) */ + bezel_shade_rect(0, 0, lx, wh, 0, a_far, a_near); + if (right_w > 0) /* near edge = left side (t=0) */ + bezel_shade_rect(right_x, 0, right_w, wh, 0, a_near, a_far); + if (top_h > 0) /* near edge = band bottom (t=1) */ + bezel_shade_rect(band_x, top_y, band_w, top_h, 1, a_far, a_near); + if (bot_h > 0) /* near edge = band top (t=0) */ + bezel_shade_rect(band_x, 0, band_w, bot_h, 1, a_near, a_far); + } glDisable(GL_BLEND); p_glBindVertexArray(0); p_glUseProgram(0); From 548737941f0f0f0286793f7db874301ee7f1c110 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:09:45 -0700 Subject: [PATCH 05/27] Log pacing-mode transitions to stdout Manual fast-forward engage/release and turbo-loads engage/release now print one [pace] line each with the host timestamp. Any unpaced or present-skip mode flipping mid-play is exactly the class users report as "the speed is busted", and the existing transition ring is only reachable through the debug server - absent from play builds, which is where players actually live. One line per transition self-documents the next repro. Co-Authored-By: Claude Fable 5 --- runtime/src/main.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 3f5e7b4d..3dedde14 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -5664,6 +5664,14 @@ static void load_transition_note(int read_active, int load_active, e->read_active = (uint8_t)(read_active != 0); e->load_active = (uint8_t)(load_active != 0); e->turbo_active = (uint8_t)(turbo_active != 0); + /* Pacing-state audit trail: any unpaced/present-skip mode engaging or + * releasing mid-play is exactly the class of bug users report as "the + * speed is busted", and the ring above is only reachable through the + * debug server. One line per transition is cheap and self-documents. */ + if (turbo_active != prev_turbo) + std::fprintf(stdout, "[pace] turbo_loads %s (frame %u, host %ums)\n", + turbo_active ? "ENGAGED" : "released", + e->frame, e->host_ms); prev_read = read_active; prev_load = load_active; prev_turbo = turbo_active; @@ -6702,6 +6710,9 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { else snprintf(msg, sizeof(msg), "Fast forward: %dx", mult); host_osd_push(msg, 900); + std::fprintf(stdout, + "[pace] manual fast-forward ENGAGED (%s, host %ums)\n", + msg, (unsigned)SDL_GetTicks()); /* Release the vsync ceiling for the duration of the hold. */ g_turbo_vsync_forced_off = 1; apply_present_cadence(); @@ -6726,6 +6737,9 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { /* Restore the configured present cadence on release. */ g_turbo_vsync_forced_off = 0; apply_present_cadence(); + std::fprintf(stdout, + "[pace] manual fast-forward released (host %ums)\n", + (unsigned)SDL_GetTicks()); } turbo_was_down = 0; } From 708ae95ae1e34a7ce50d0c0cdd7bc1b98c2e4f28 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:27:59 -0700 Subject: [PATCH 06/27] Present: never skip the swap when vsync owns cadence; bezel "mounted screen" treatment Pacing: when driver vsync owns the frame cadence the blocking Swap IS the game's clock, but the unchanged-frame present skip bypassed it - every skipped swap silently unpaced that frame. WipEout 3 at 30 Hz sim renders every other vblank, so half the frames ran unpaced: the game sped to a fluctuating ~2x with pitched-up CD audio and visible frameskip, EXCEPT while the FPS readout was on, whose persistent OSD line forced a swap per frame and masked the hole ("the moment I do fps readout off it goes stupid fast"). gl_renderer_set_swap_paces(), derived in apply_present_cadence(), disables the skip whenever the swap paces. Real hardware scans out every vblank regardless of change. Bezel: replace the flat tiling with a mounted-screen treatment - deep-tinted ground, the tiled marks at watermark subtlety (0.22), a distance-field shade (rounded-rect SDF against the live viewport rect) running near-black beside the game to a faint pattern at the outer edges, and a resolution-scaled hairline seam terminating the viewport. The 200 ms fade-in from black on viewport-rect changes folds into the shade endpoints. Game framebuffer untouched; all of it derives procedurally from the viewport rect. Co-Authored-By: Claude Fable 5 --- runtime/include/gpu_gl_renderer.h | 4 + runtime/src/gpu_gl_renderer.c | 244 +++++++++++++++++++----------- runtime/src/main.cpp | 10 +- 3 files changed, 165 insertions(+), 93 deletions(-) diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 75eaa4dd..2ccbf040 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -22,6 +22,10 @@ int gl_renderer_init_context(struct SDL_Window *win); /* Set the GL swap interval / vsync mode (1=vsync, 0=immediate, -1=adaptive). * Safe before or after context creation; applies live when a context exists. */ void gl_renderer_set_swap_interval(int interval); +/* Nonzero while the blocking Swap is the game's frame clock (driver vsync + * owns cadence): unchanged-frame present skips are disabled, because a + * skipped swap would silently unpace that frame. */ +void gl_renderer_set_swap_paces(int on); /* Presentation-only frame interpolation. High-refresh sub-presents blend the * two most recent stable display images; guest simulation timing is unchanged. */ diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 09c989bc..918dbfe3 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -4062,6 +4062,16 @@ int gl_renderer_cpu_auth_dual(void) { return s_cpu_auth_dual; } +/* When driver vsync owns the frame cadence, the blocking Swap IS the game's + * clock: skipping the swap on an unchanged frame silently unpaces that frame + * and the sim sprints (WipEout 3 at 30 Hz sim: every other vblank renders + * nothing new, so "fps readout off" ran the game at a fluctuating ~2x — the + * readout's persistent OSD line forced a swap per frame and masked it). + * Real hardware scans out every vblank regardless of change; when the swap + * paces, so do we. Set from apply_present_cadence(). */ +static int s_swap_paces = 0; +void gl_renderer_set_swap_paces(int on) { s_swap_paces = on ? 1 : 0; } + void gl_renderer_present_probe_reset(void) { s_probe_skip = 0; s_probe_swap = 0; @@ -5240,59 +5250,6 @@ int gl_renderer_set_bezel(const void *rgba, int w, int h) { int gl_renderer_has_bezel(void) { return s_bezel_tex != 0; } -/* Draw the bezel into ONE viewport, cover-cropped to that viewport's aspect. - * - * Cover, not stretch: scale to fill and crop the overflow, centred. Stretching - * distorts any artwork whose aspect does not match, and a margin rarely does -- - * a portrait poster squeezed across a 7680-wide window is unrecognisable, while - * the same poster cover-cropped into a tall narrow margin fits it naturally. */ -/* Tile the bezel down one margin, preserving the logo's aspect. - * - * A margin is tall and narrow; a single logo stretched to fill it would be - * grotesque and cover-cropping one would show a sliver. Tiling keeps every - * copy at its authored proportions and fills any margin size, which is what a - * repeating mark is for. The count is derived from the margin width so the - * logo reads at a consistent size regardless of resolution or window shape. */ -static void bezel_draw_rect(int vx, int vy, int vw, int vh) { - if (vw <= 0 || vh <= 0 || s_bezel_w <= 0 || s_bezel_h <= 0) return; - const float img = (float)s_bezel_w / (float)s_bezel_h; - /* One logo spans the margin width, with a little air either side. */ - const float tile_w = 1.0f / 0.78f; - /* Repeats needed vertically for each tile to keep the logo's own aspect: - * a tile is (vw/tile_w) wide on screen, so it must be (vw/tile_w)/img tall, - * and vh divided by that is the count. Previously this DIVIDED by img - * instead of multiplying, squashing every mark by img^2 -- 1.7x on the - * 1024x778 marks, which is the stretched look. */ - const float tile_h = img * tile_w * (float)vh / (float)vw; - glViewport(vx, vy, vw, vh); - p_glUniform4f(s_present_uUvRect, - -(tile_w - 1.0f) * 0.5f, 0.0f, - (tile_w + 1.0f) * 0.5f, tile_h); - glDrawArrays(GL_TRIANGLES, 0, 3); -} - -/* Tile the bezel along a letterbox BAND (top/bottom margin): the transpose of - * bezel_draw_rect. A band is short and wide, so one logo spans the band - * HEIGHT (same air factor as the margins use for width) and repeats across, - * each copy at its authored aspect -- the marks read at the same size in the - * bands as in the side margins, which is what makes the frame look like one - * bezel instead of two different treatments. */ -static void bezel_draw_band(int vx, int vy, int vw, int vh) { - if (vw <= 0 || vh <= 0 || s_bezel_w <= 0 || s_bezel_h <= 0) return; - const float img = (float)s_bezel_w / (float)s_bezel_h; - /* One logo spans the band height, with a little air above and below. */ - const float tile_h = 1.0f / 0.78f; - /* Horizontal repeats so each tile keeps the logo's own aspect: a tile is - * (vh/tile_h) tall on screen, so it must be (vh/tile_h)*img wide, and vw - * divided by that is the count. */ - const float tile_w = tile_h * (float)vw / ((float)vh * img); - glViewport(vx, vy, vw, vh); - p_glUniform4f(s_present_uUvRect, - 0.0f, -(tile_h - 1.0f) * 0.5f, - tile_w, (tile_h + 1.0f) * 0.5f); - glDrawArrays(GL_TRIANGLES, 0, 3); -} - /* Padding shade: a procedural darkening gradient drawn OVER the bezel layer, * derived from the live viewport rectangle. The area adjacent to the game is * near-black and low-contrast; the pattern re-emerges gradually toward the @@ -5308,31 +5265,85 @@ static void bezel_draw_band(int vx, int vy, int vw, int vh) { static const char *BEZEL_SHADE_FS = "#version 330\n" "in vec2 v_uv; out vec4 frag;\n" - "uniform int u_axis;\n" /* 0: gradient along X, 1: along Y */ - "uniform vec2 u_a;\n" /* black alpha at t=0 / t=1 */ + "uniform sampler2D u_tex;\n" + "uniform int u_tex_on;\n" /* 1: dimmed artwork; 0: SDF black shade */ + "uniform vec4 u_view;\n" /* game viewport rect, window px: x,y,w,h */ + "uniform vec4 u_rgba;\n" /* shade color (rgb); .a = art dim factor */ + "uniform vec2 u_a;\n" /* alpha at dist 0 / at dist >= u_fall */ + "uniform float u_fall;\n" /* falloff length in px */ "void main(){\n" - " float t = (u_axis == 1) ? v_uv.y : v_uv.x;\n" - " float a = mix(u_a.x, u_a.y, smoothstep(0.0, 1.0, t));\n" - " frag = vec4(0.0, 0.0, 0.0, a);\n" + " if (u_tex_on == 1) {\n" + " vec4 t = texture(u_tex, v_uv);\n" + " frag = vec4(t.rgb, t.a * u_rgba.a);\n" + " return;\n" + " }\n" + " vec2 p = gl_FragCoord.xy;\n" + " vec2 mn = u_view.xy;\n" + " vec2 mx = u_view.xy + u_view.zw;\n" + " vec2 dv = max(vec2(0.0), max(mn - p, p - mx));\n" + " float d = clamp(length(dv) / max(u_fall, 1.0), 0.0, 1.0);\n" + " float a = mix(u_a.x, u_a.y, smoothstep(0.0, 1.0, d));\n" + " frag = vec4(u_rgba.rgb, a);\n" "}\n"; static GLuint s_bezel_shade_prog = 0; -static GLint s_bezel_shade_uAxis = -1, s_bezel_shade_uA = -1, +static GLint s_bezel_shade_uTex = -1, s_bezel_shade_uTexOn = -1, + s_bezel_shade_uView = -1, s_bezel_shade_uRgba = -1, + s_bezel_shade_uA = -1, s_bezel_shade_uFall = -1, s_bezel_shade_uRect = -1; /* Fade-in state: restarted whenever the viewport rect moves. */ static int s_bezel_prev_rect[4] = { -1, -1, -1, -1 }; static uint64_t s_bezel_fade_start_ms = 0; -#define BEZEL_FADE_MS 200.0f -#define BEZEL_SHADE_NEAR 0.94f /* padding beside the game: near-black */ -#define BEZEL_SHADE_FAR 0.72f /* outer screen edge: pattern stays faint */ +#define BEZEL_FADE_MS 200.0f +#define BEZEL_SHADE_NEAR 0.92f /* beside the game: near-black ground */ +#define BEZEL_SHADE_FAR 0.40f /* outer screen edge: ground shows most */ +#define BEZEL_WATERMARK_A 0.22f /* single mark, pre-shade */ +#define BEZEL_SEAM_A 0.32f /* hairline at the viewport boundary */ extern uint64_t psx_host_mono_ms(void); -static void bezel_shade_rect(int vx, int vy, int vw, int vh, - int axis, float a0, float a1) { +/* SDF shade / seam quad: alpha follows distance-to-viewport (u_tex_on=0). */ +static void bezel_sdf_rect(int vx, int vy, int vw, int vh, + float r, float g, float b, + float a0, float a1, + const float view[4], float fall) { if (vw <= 0 || vh <= 0) return; glViewport(vx, vy, vw, vh); - p_glUniform1i(s_bezel_shade_uAxis, axis); + p_glUniform1i(s_bezel_shade_uTexOn, 0); + p_glUniform4f(s_bezel_shade_uView, view[0], view[1], view[2], view[3]); + p_glUniform4f(s_bezel_shade_uRgba, r, g, b, 0.0f); p_glUniform2f(s_bezel_shade_uA, a0, a1); + p_glUniform1f(s_bezel_shade_uFall, fall); + glDrawArrays(GL_TRIANGLES, 0, 3); +} + +/* Tiled marks down a column / across a band, drawn through the shade + * program's texture mode so the whole pattern carries one dim factor. Same + * authored-aspect tiling math as the original bezel: one mark spans the + * margin's short dimension (with a little air), repeats along the long one. */ +static void bezel_tiles_col(int vx, int vy, int vw, int vh, float alpha) { + if (vw <= 0 || vh <= 0 || s_bezel_w <= 0 || s_bezel_h <= 0) return; + const float img = (float)s_bezel_w / (float)s_bezel_h; + const float tile_w = 1.0f / 0.78f; + const float tile_h = img * tile_w * (float)vh / (float)vw; + glViewport(vx, vy, vw, vh); + p_glUniform1i(s_bezel_shade_uTexOn, 1); + p_glUniform4f(s_bezel_shade_uRgba, 0.0f, 0.0f, 0.0f, alpha); + p_glUniform4f(s_bezel_shade_uRect, + -(tile_w - 1.0f) * 0.5f, 0.0f, + (tile_w + 1.0f) * 0.5f, tile_h); + glDrawArrays(GL_TRIANGLES, 0, 3); +} +static void bezel_tiles_band(int vx, int vy, int vw, int vh, float alpha) { + if (vw <= 0 || vh <= 0 || s_bezel_w <= 0 || s_bezel_h <= 0) return; + const float img = (float)s_bezel_w / (float)s_bezel_h; + const float tile_h = 1.0f / 0.78f; + const float tile_w = tile_h * (float)vw / ((float)vh * img); + glViewport(vx, vy, vw, vh); + p_glUniform1i(s_bezel_shade_uTexOn, 1); + p_glUniform4f(s_bezel_shade_uRgba, 0.0f, 0.0f, 0.0f, alpha); + p_glUniform4f(s_bezel_shade_uRect, + 0.0f, -(tile_h - 1.0f) * 0.5f, + tile_w, (tile_h + 1.0f) * 0.5f); glDrawArrays(GL_TRIANGLES, 0, 3); } @@ -5377,22 +5388,28 @@ static void present_bezel(int ww, int wh, int lx, int ly, int lw, int lh) { p_glBlendEquationSeparate(GL_FUNC_ADD, GL_FUNC_ADD); p_glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA); - if (lx > 0) bezel_draw_rect(0, 0, lx, wh); - if (right_w > 0) bezel_draw_rect(right_x, 0, right_w, wh); - if (top_h > 0) bezel_draw_band(band_x, top_y, band_w, top_h); - if (bot_h > 0) bezel_draw_band(band_x, 0, band_w, bot_h); - - /* Shade the padding: near-black beside the viewport, easing out to a - * faint pattern at the screen edges; the whole layer fades in from black - * for ~200 ms after the viewport rect moves. Drawn over backdrop+tiles, - * never over the game rect. */ + /* "Mounted screen" treatment: the game sits on a deep-tinted ground (the + * scissor clears above already laid s_bezel_bg over every padding rect). + * One large watermark mark per padding region — no tiling, so the + * padding can never turn into wallpaper — then a distance-field shade + * that darkens toward the viewport from every direction at once, and a + * hairline seam that terminates the game rect cleanly. The whole layer + * fades in from black for ~200 ms after the viewport rect moves. */ if (!s_bezel_shade_prog) { s_bezel_shade_prog = build_program(PRESENT_VS, BEZEL_SHADE_FS); if (s_bezel_shade_prog) { - s_bezel_shade_uAxis = - p_glGetUniformLocation(s_bezel_shade_prog, "u_axis"); + s_bezel_shade_uTex = + p_glGetUniformLocation(s_bezel_shade_prog, "u_tex"); + s_bezel_shade_uTexOn = + p_glGetUniformLocation(s_bezel_shade_prog, "u_tex_on"); + s_bezel_shade_uView = + p_glGetUniformLocation(s_bezel_shade_prog, "u_view"); + s_bezel_shade_uRgba = + p_glGetUniformLocation(s_bezel_shade_prog, "u_rgba"); s_bezel_shade_uA = p_glGetUniformLocation(s_bezel_shade_prog, "u_a"); + s_bezel_shade_uFall = + p_glGetUniformLocation(s_bezel_shade_prog, "u_fall"); s_bezel_shade_uRect = p_glGetUniformLocation(s_bezel_shade_prog, "u_uv_rect"); } @@ -5409,22 +5426,63 @@ static void present_bezel(int ww, int wh, int lx, int ly, int lw, int lh) { if (fade < 0.0f) fade = 0.0f; if (fade > 1.0f) fade = 1.0f; fade = fade * fade * (3.0f - 2.0f * fade); - /* At fade=0 the padding is solid black; at fade=1 the resting - * gradient (near/far) stands. Affine per endpoint, so folding the - * fade into the endpoints is exact. */ - const float a_near = 1.0f - fade * (1.0f - BEZEL_SHADE_NEAR); - const float a_far = 1.0f - fade * (1.0f - BEZEL_SHADE_FAR); + p_glUseProgram(s_bezel_shade_prog); p_glUniform4f(s_bezel_shade_uRect, 0.0f, 0.0f, 1.0f, 1.0f); - /* v_uv.y is 1 at the BOTTOM of the drawn rect (PRESENT_VS V-flip). */ - if (lx > 0) /* near edge = right side (t=1) */ - bezel_shade_rect(0, 0, lx, wh, 0, a_far, a_near); - if (right_w > 0) /* near edge = left side (t=0) */ - bezel_shade_rect(right_x, 0, right_w, wh, 0, a_near, a_far); - if (top_h > 0) /* near edge = band bottom (t=1) */ - bezel_shade_rect(band_x, top_y, band_w, top_h, 1, a_far, a_near); - if (bot_h > 0) /* near edge = band top (t=0) */ - bezel_shade_rect(band_x, 0, band_w, bot_h, 1, a_near, a_far); + p_glUniform1i(s_bezel_shade_uTex, 0); + + /* Tiled pattern per padding region, drawn at watermark subtlety — + * the SDF shade below then recedes it further toward the game. */ + const float wm_a = BEZEL_WATERMARK_A * fade; + const float min_dim = (float)(ww < wh ? ww : wh); + if (wm_a > 0.003f) { + if (lx > 0) bezel_tiles_col(0, 0, lx, wh, wm_a); + if (right_w > 0) bezel_tiles_col(right_x, 0, right_w, wh, wm_a); + if (top_h > 0) bezel_tiles_band(band_x, top_y, band_w, top_h, + wm_a); + if (bot_h > 0) bezel_tiles_band(band_x, 0, band_w, bot_h, wm_a); + } + + /* Distance-field shade over ground + watermark: near-black beside + * the game, easing out over a wide falloff. At fade=0 both endpoints + * are 1.0 (solid black) — the transition state. */ + const float a_near = 1.0f - fade * (1.0f - BEZEL_SHADE_NEAR); + const float a_far = 1.0f - fade * (1.0f - BEZEL_SHADE_FAR); + const float fall = min_dim * 0.30f; + const float view[4] = { (float)lx, (float)ly, (float)lw, (float)lh }; + if (lx > 0) + bezel_sdf_rect(0, 0, lx, wh, 0.0f, 0.0f, 0.0f, + a_near, a_far, view, fall); + if (right_w > 0) + bezel_sdf_rect(right_x, 0, right_w, wh, 0.0f, 0.0f, 0.0f, + a_near, a_far, view, fall); + if (top_h > 0) + bezel_sdf_rect(band_x, top_y, band_w, top_h, 0.0f, 0.0f, 0.0f, + a_near, a_far, view, fall); + if (bot_h > 0) + bezel_sdf_rect(band_x, 0, band_w, bot_h, 0.0f, 0.0f, 0.0f, + a_near, a_far, view, fall); + + /* Hairline seam: a light terminator where the game meets the + * padding, so the viewport reads as mounted. Constant alpha (both + * endpoints equal), scaled with resolution so it survives 4K/8K. */ + const float seam_a = BEZEL_SEAM_A * fade; + if (seam_a > 0.003f) { + int t = (int)(min_dim / 720.0f + 0.5f); + if (t < 1) t = 1; + if (lx > 0) + bezel_sdf_rect(lx - t, ly, t, lh, 0.62f, 0.62f, 0.66f, + seam_a, seam_a, view, 1.0f); + if (right_w > 0) + bezel_sdf_rect(right_x, ly, t, lh, 0.62f, 0.62f, 0.66f, + seam_a, seam_a, view, 1.0f); + if (top_h > 0) + bezel_sdf_rect(lx - t, top_y, lw + 2 * t, t, + 0.62f, 0.62f, 0.66f, seam_a, seam_a, view, 1.0f); + if (bot_h > 0) + bezel_sdf_rect(lx - t, ly - t, lw + 2 * t, t, + 0.62f, 0.62f, 0.66f, seam_a, seam_a, view, 1.0f); + } } glDisable(GL_BLEND); p_glBindVertexArray(0); @@ -5534,7 +5592,8 @@ void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, flush_flat_batch(); flush_tex_batch(); flush_cpu_upload(); - if (s_force_present_remaining <= 0 && + if (!s_swap_paces && + s_force_present_remaining <= 0 && s_last_present_path == GL_PRES_VRAM && s_last_dx == disp_x && s_last_dy == disp_y && s_last_dw == w && s_last_dh == h && @@ -5645,7 +5704,8 @@ int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) flush_flat_batch(); flush_tex_batch(); flush_cpu_upload(); - if (s_force_present_remaining <= 0 && + if (!s_swap_paces && + s_force_present_remaining <= 0 && s_last_present_path == GL_PRES_WIDE && s_last_dx == disp_x && s_last_dy == disp_y && s_last_dw == g_wide_w && s_last_dh == disp_h && diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 3dedde14..9c378fe2 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -2820,8 +2820,16 @@ static int present_should_wall_pace(void) { static void apply_present_cadence(void) { #ifndef PSX_SDL_NO_RENDER const int interval = present_effective_swap_interval(); - if (g_gl_active) + if (g_gl_active) { gl_renderer_set_swap_interval(interval); + /* Swap-is-the-clock: with a nonzero interval and vsync owning + * cadence, every vblank MUST swap — an unchanged-frame present skip + * would silently unpace that frame (the "fps readout off makes it + * stupid fast" bug: the readout's OSD line forced per-frame swaps + * and masked the hole). */ + gl_renderer_set_swap_paces(interval != 0 && + present_vsync_owns_cadence()); + } if (g_vk_active) vk_renderer_set_present_mode(interval); if (sdl_renderer) From 4fc71ddf68b7d95679a5fee597a042440c97b2b2 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:37:45 -0700 Subject: [PATCH 07/27] Present-skip pacing gate: query live instead of caching; floor bezel mark size The swap-paces gate from the previous commit was a flag cached from apply_present_cadence(), which can run before the GL renderer is live - the gate never engaged and the unpaced-skip hole survived. Replace it with psx_present_swap_paces(), evaluated by the renderer at every skip decision, so no cached copy can go stale across init order or cadence changes. The setter is gone. Bezel: floor the tiled mark size at 20% of the window's short dimension. A thin letterbox band no longer shrinks the marks into busy little rows - they stay window-proportioned and the band crops them symmetrically, so bands read like the side columns do. Co-Authored-By: Claude Fable 5 --- runtime/include/gpu_gl_renderer.h | 5 --- runtime/src/gpu_gl_renderer.c | 52 ++++++++++++++++++++----------- runtime/src/main.cpp | 20 ++++++------ 3 files changed, 44 insertions(+), 33 deletions(-) diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 2ccbf040..c03cb6b0 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -22,11 +22,6 @@ int gl_renderer_init_context(struct SDL_Window *win); /* Set the GL swap interval / vsync mode (1=vsync, 0=immediate, -1=adaptive). * Safe before or after context creation; applies live when a context exists. */ void gl_renderer_set_swap_interval(int interval); -/* Nonzero while the blocking Swap is the game's frame clock (driver vsync - * owns cadence): unchanged-frame present skips are disabled, because a - * skipped swap would silently unpace that frame. */ -void gl_renderer_set_swap_paces(int on); - /* Presentation-only frame interpolation. High-refresh sub-presents blend the * two most recent stable display images; guest simulation timing is unchanged. */ void gl_renderer_set_interpolation(int enabled, double host_hz, double target_hz, diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 918dbfe3..afa645ad 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -4068,9 +4068,9 @@ int gl_renderer_cpu_auth_dual(void) { * nothing new, so "fps readout off" ran the game at a fluctuating ~2x — the * readout's persistent OSD line forced a swap per frame and masked it). * Real hardware scans out every vblank regardless of change; when the swap - * paces, so do we. Set from apply_present_cadence(). */ -static int s_swap_paces = 0; -void gl_renderer_set_swap_paces(int on) { s_swap_paces = on ? 1 : 0; } + * paces, so do we. Queried LIVE at every skip decision (frontend + * psx_present_swap_paces) so no cached copy can go stale. */ +extern int psx_present_swap_paces(void); void gl_renderer_present_probe_reset(void) { s_probe_skip = 0; @@ -5320,30 +5320,41 @@ static void bezel_sdf_rect(int vx, int vy, int vw, int vh, * program's texture mode so the whole pattern carries one dim factor. Same * authored-aspect tiling math as the original bezel: one mark spans the * margin's short dimension (with a little air), repeats along the long one. */ -static void bezel_tiles_col(int vx, int vy, int vw, int vh, float alpha) { +/* mark_px floors the drawn mark size: a thin margin no longer shrinks the + * marks into busy little rows — they stay window-proportioned and the band + * crops them symmetrically instead (texture wrap handles the overflow). */ +static void bezel_tiles_col(int vx, int vy, int vw, int vh, float alpha, + float mark_px) { if (vw <= 0 || vh <= 0 || s_bezel_w <= 0 || s_bezel_h <= 0) return; const float img = (float)s_bezel_w / (float)s_bezel_h; - const float tile_w = 1.0f / 0.78f; - const float tile_h = img * tile_w * (float)vh / (float)vw; + float mark_w = (float)vw * 0.78f; + if (mark_w < mark_px) mark_w = mark_px; + const float tile_px_w = mark_w / 0.78f; /* mark + air */ + const float u_count = (float)vw / tile_px_w; + const float v_count = (float)vh / (tile_px_w / img); glViewport(vx, vy, vw, vh); p_glUniform1i(s_bezel_shade_uTexOn, 1); p_glUniform4f(s_bezel_shade_uRgba, 0.0f, 0.0f, 0.0f, alpha); p_glUniform4f(s_bezel_shade_uRect, - -(tile_w - 1.0f) * 0.5f, 0.0f, - (tile_w + 1.0f) * 0.5f, tile_h); + 0.5f - u_count * 0.5f, 0.0f, + 0.5f + u_count * 0.5f, v_count); glDrawArrays(GL_TRIANGLES, 0, 3); } -static void bezel_tiles_band(int vx, int vy, int vw, int vh, float alpha) { +static void bezel_tiles_band(int vx, int vy, int vw, int vh, float alpha, + float mark_px) { if (vw <= 0 || vh <= 0 || s_bezel_w <= 0 || s_bezel_h <= 0) return; const float img = (float)s_bezel_w / (float)s_bezel_h; - const float tile_h = 1.0f / 0.78f; - const float tile_w = tile_h * (float)vw / ((float)vh * img); + float mark_h = (float)vh * 0.78f; + if (mark_h < mark_px) mark_h = mark_px; + const float tile_px_h = mark_h / 0.78f; /* mark + air */ + const float v_count = (float)vh / tile_px_h; + const float u_count = (float)vw / (tile_px_h * img); glViewport(vx, vy, vw, vh); p_glUniform1i(s_bezel_shade_uTexOn, 1); p_glUniform4f(s_bezel_shade_uRgba, 0.0f, 0.0f, 0.0f, alpha); p_glUniform4f(s_bezel_shade_uRect, - 0.0f, -(tile_h - 1.0f) * 0.5f, - tile_w, (tile_h + 1.0f) * 0.5f); + 0.5f - u_count * 0.5f, 0.5f - v_count * 0.5f, + 0.5f + u_count * 0.5f, 0.5f + v_count * 0.5f); glDrawArrays(GL_TRIANGLES, 0, 3); } @@ -5436,11 +5447,14 @@ static void present_bezel(int ww, int wh, int lx, int ly, int lw, int lh) { const float wm_a = BEZEL_WATERMARK_A * fade; const float min_dim = (float)(ww < wh ? ww : wh); if (wm_a > 0.003f) { - if (lx > 0) bezel_tiles_col(0, 0, lx, wh, wm_a); - if (right_w > 0) bezel_tiles_col(right_x, 0, right_w, wh, wm_a); + const float mark_px = min_dim * 0.20f; + if (lx > 0) bezel_tiles_col(0, 0, lx, wh, wm_a, mark_px); + if (right_w > 0) bezel_tiles_col(right_x, 0, right_w, wh, wm_a, + mark_px); if (top_h > 0) bezel_tiles_band(band_x, top_y, band_w, top_h, - wm_a); - if (bot_h > 0) bezel_tiles_band(band_x, 0, band_w, bot_h, wm_a); + wm_a, mark_px); + if (bot_h > 0) bezel_tiles_band(band_x, 0, band_w, bot_h, wm_a, + mark_px); } /* Distance-field shade over ground + watermark: near-black beside @@ -5592,7 +5606,7 @@ void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear, flush_flat_batch(); flush_tex_batch(); flush_cpu_upload(); - if (!s_swap_paces && + if (!psx_present_swap_paces() && s_force_present_remaining <= 0 && s_last_present_path == GL_PRES_VRAM && s_last_dx == disp_x && s_last_dy == disp_y && @@ -5704,7 +5718,7 @@ int gl_renderer_present_wide_fbo(int disp_x, int disp_y, int disp_h, int linear) flush_flat_batch(); flush_tex_batch(); flush_cpu_upload(); - if (!s_swap_paces && + if (!psx_present_swap_paces() && s_force_present_remaining <= 0 && s_last_present_path == GL_PRES_WIDE && s_last_dx == disp_x && s_last_dy == disp_y && diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 9c378fe2..28844d35 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -2817,19 +2817,21 @@ static int present_should_wall_pace(void) { return g_frame_period_ms > 0.0 && !present_vsync_owns_cadence(); } +/* Live query for the GL presenter: nonzero while the blocking Swap is the + * game's frame clock. While true, unchanged-frame present skips must be + * disabled — a skipped swap is an unpaced frame (the "fps readout off makes + * it stupid fast" bug). Evaluated at each skip decision so no cached copy + * can go stale across init order or cadence changes. */ +extern "C" int psx_present_swap_paces(void) { + return present_vsync_owns_cadence() && + present_effective_swap_interval() != 0; +} + static void apply_present_cadence(void) { #ifndef PSX_SDL_NO_RENDER const int interval = present_effective_swap_interval(); - if (g_gl_active) { + if (g_gl_active) gl_renderer_set_swap_interval(interval); - /* Swap-is-the-clock: with a nonzero interval and vsync owning - * cadence, every vblank MUST swap — an unchanged-frame present skip - * would silently unpace that frame (the "fps readout off makes it - * stupid fast" bug: the readout's OSD line forced per-frame swaps - * and masked the hole). */ - gl_renderer_set_swap_paces(interval != 0 && - present_vsync_owns_cadence()); - } if (g_vk_active) vk_renderer_set_present_mode(interval); if (sdl_renderer) From ae7925c1b598d2ab52b56224bed72982245067b4 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:18:27 -0700 Subject: [PATCH 08/27] sio: make the Ape Escape card-IRQ unstick pump opt-in ([runtime] ape_card_unstick) The unstick pump - a nest repair for Ape Escape LOAD that force-re-edges I_MASK.7/IRQ7 after a LOAD-style probe abort - ran enabled by default for every game. On WipEout 3 SE under the NTSC x2 CRTC its arming detector misfires during the boot memcard check, and the forced IRQ storm keeps the kernel card ISR acking idle bytes forever (traced: 40k+ card_ack 0x00 at ~1.3k-cycle spacing, all Sw/HwCARD completion events stuck ACTIVE, zero delivered), so the game waits on the memcard screen indefinitely. A/B with PSX_APE_CARD_UNSTICK=0 boots straight through. Per-game hacks are config-owned: default OFF, enable with [runtime] ape_card_unstick = true (Ape Escape's config should set it), and the PSX_APE_CARD_UNSTICK env var still overrides in either direction. Co-Authored-By: Claude Fable 5 --- recompiler/src/config_loader.cpp | 3 +++ recompiler/src/config_loader.h | 8 ++++++++ runtime/include/sio.h | 2 ++ runtime/src/main.cpp | 1 + runtime/src/sio.c | 17 +++++++++++++---- 5 files changed, 27 insertions(+), 4 deletions(-) diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index a7a3c460..395f6328 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -466,6 +466,9 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& if (runtime.contains("fast_boot")) { rt.fast_boot = toml::find(runtime, "fast_boot"); } + if (runtime.contains("ape_card_unstick")) { + rt.ape_card_unstick = toml::find(runtime, "ape_card_unstick"); + } if (runtime.contains("openbios")) { rt.openbios = toml::find(runtime, "openbios"); } diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 1d98ff75..f71e5361 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -254,6 +254,14 @@ struct RuntimeConfig { // Kept so existing game.toml/settings.toml keep working. bool fast_boot = false; + // Ape Escape LOAD card-IRQ unstick pump ([runtime] ape_card_unstick). + // A per-game kernel-nest repair that force-re-edges I_MASK.7/IRQ7 after a + // LOAD-style probe abort. OPT-IN: on WipEout 3 under the x2 CRTC the + // arming detector misfires and the forced IRQ storm wedges the kernel + // card driver (completion events never deliver -> memcard screen hangs). + // Env PSX_APE_CARD_UNSTICK=0/1 still overrides either way. + bool ape_card_unstick = false; + // bios_hle: High-Level Emulation tier for BIOS kernel services // (CLAUDE.md §0 amendment 2026-07-02, the gbarecomp model). DEFAULT ON as of // 2026-07-06 (user-directed player default: instant boot-skip for every diff --git a/runtime/include/sio.h b/runtime/include/sio.h index 6ca5b2a6..78b8af14 100644 --- a/runtime/include/sio.h +++ b/runtime/include/sio.h @@ -75,6 +75,8 @@ uint64_t sio_get_advance_with_work(void); * Bulk 0x80 responses follow the real TAP/REQ latch (psx-spx): REQ=1 in the * third command byte arms the *next* transfer; empty tap slots are fine. */ void sio_set_multitap(int enabled); +/* [runtime] ape_card_unstick config default (env PSX_APE_CARD_UNSTICK overrides). */ +void sio_set_ape_card_unstick(int on); int sio_get_multitap(void); /* phys_port: 0 = console Port 1, 1 = console Port 2. Default 0. */ void sio_set_multitap_port(int phys_port); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 28844d35..64b517bf 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -11654,6 +11654,7 @@ int main(int argc, char** argv) { if (e && e[0] && e[0] != '0') g_gl_fbo_present = 0; } game_entry_pc = gc.entry_pc; fast_boot = gc.runtime.fast_boot; + sio_set_ape_card_unstick(gc.runtime.ape_card_unstick ? 1 : 0); bios_hle = gc.runtime.bios_hle; bios_hle_keep_intro = gc.runtime.bios_hle_keep_intro; /* Developer compatibility finding, applied before BIOS selection. diff --git a/runtime/src/sio.c b/runtime/src/sio.c index f5f6676b..003c4c4a 100644 --- a/runtime/src/sio.c +++ b/runtime/src/sio.c @@ -495,15 +495,24 @@ static int s_ape_unstick_pending = 0; static uint64_t s_ape_unstick_cool_cyc = 0; static int s_ape_torn_pulses = 0; +/* Config default ([runtime] ape_card_unstick), set at boot. OFF unless the + * game opts in: this pump force-re-edges I_MASK.7/IRQ7, and a misfired arm + * (WipEout 3 under the x2 CRTC) storms the kernel card ISR so completion + * events never deliver and the memcard screen hangs forever. */ +static int s_ape_unstick_cfg = 0; +void sio_set_ape_card_unstick(int on) { s_ape_unstick_cfg = on ? 1 : 0; } + static int ape_unstick_enabled(void) { if (s_ape_unstick_env < 0) { const char *e = getenv("PSX_APE_CARD_UNSTICK"); - if (e && e[0] == '0') - s_ape_unstick_env = 0; + if (e && e[0]) + s_ape_unstick_env = (e[0] == '0') ? 0 : 1; else - s_ape_unstick_env = 1; + s_ape_unstick_env = -2; /* no env: fall through to config */ } - return s_ape_unstick_env; + if (s_ape_unstick_env >= 0) + return s_ape_unstick_env; + return s_ape_unstick_cfg; } /* Defined later; sio_tick calls the pump. */ From 45f9d0f866eb86ac0be5574d0cba9feac4536221 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:56:27 -0700 Subject: [PATCH 09/27] autocompile: pin the overlay flavor through the spawn env (PSX_OVERLAY_FLAVOR) The runtime pins the cache dir and captures path into the spawned compile's environment so the cache can never drift from where the loader reads - but not the FLAVOR. compile_overlays.py's --flavor defaults to 0 (play), so an instrumented (flavor 2) runtime spawned compiles whose shards its own loader then rejected: the overlay_loader_status message said it plainly - "this build reads ..._f2 but shards exist under ..._f0 -> ALL overlays run INTERPRETED". On mod-heavy titles (WipEout 3's framerate packages patch most of the game text) that is the difference between full speed and ~0.2x, misread as general slowness for weeks. The runtime now exports PSX_OVERLAY_FLAVOR alongside the other pins, and the tool honors it over --flavor, so each build population self-heals. Measured after the fix (instrumented, WipEout 3 ntsc120full8): shard loads 0 -> 8 (45 funcs), interpreted insns 6.6M/s -> 1.0M/s. Co-Authored-By: Claude Fable 5 --- runtime/src/autocompile.c | 10 ++++++++++ tools/compile_overlays.py | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/runtime/src/autocompile.c b/runtime/src/autocompile.c index 2018428e..33641732 100644 --- a/runtime/src/autocompile.c +++ b/runtime/src/autocompile.c @@ -855,6 +855,16 @@ int autocompile_request(void) { if (s_cache_dir[0]) SetEnvironmentVariableA("PSX_OVERLAY_CACHE_DIR", s_cache_dir); if (s_captures[0]) SetEnvironmentVariableA("PSX_OVERLAY_CAPTURES", s_captures); SetEnvironmentVariableA("PSX_OVERLAY_LIVE_AUTOCOMPILE", "1"); + /* Pin the FLAVOR the same way: the tool's --flavor defaults to 0 (play), + * so an instrumented (flavor 2) runtime spawned a compile whose shards it + * could never load — the loader's flavor guard rejected them and EVERY + * overlay ran interpreted, forever, on debug builds. The child must + * always write the flavor this runtime reads. */ + { + char flavor_buf[16]; + snprintf(flavor_buf, sizeof flavor_buf, "%d", (int)PSX_OVERLAY_FLAVOR); + SetEnvironmentVariableA("PSX_OVERLAY_FLAVOR", flavor_buf); + } /* cmd.exe /C resolves the command via PATH and supports the relative * paths in the configured line (cwd = project root). The WHOLE command is diff --git a/tools/compile_overlays.py b/tools/compile_overlays.py index 8697f3bb..14709f49 100644 --- a/tools/compile_overlays.py +++ b/tools/compile_overlays.py @@ -5262,6 +5262,19 @@ def main(): if _env_cap != args.captures: print(f'[cache] PSX_OVERLAY_CAPTURES overrides --captures: {_env_cap}') args.captures = _env_cap + # Flavor is pinned by the spawning runtime the same way as the cache + # locations: --flavor defaults to 0 (play), so an instrumented (flavor 2) + # runtime used to spawn compiles whose shards its loader then rejected — + # every overlay ran interpreted on debug builds, silently. + _env_flavor = os.environ.get('PSX_OVERLAY_FLAVOR') + if _env_flavor: + try: + _fl = int(_env_flavor, 0) + except ValueError: + _fl = None + if _fl is not None and _fl != args.flavor: + print(f'[cache] PSX_OVERLAY_FLAVOR overrides --flavor: {_fl}') + args.flavor = _fl if not args.captures: ap.error('no captures file: set PSX_OVERLAY_CAPTURES (runtime injects it) ' 'or pass --captures for manual/offline use') From 8b323ffe927124cd5c2f91e72db1c56a0280561d Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 03:41:22 -0700 Subject: [PATCH 10/27] coverage_vault: 8 MB dev-RAM aware bounds Titles running the 8MB enhancement stream code into the extended banks (0x200000..0x7FFFFF). The vault's 2 MB mask corrupted those capture addresses and its bound rejected legitimate regions ("capture exceeds PSX RAM"), which blocked compacting WipEout 3's merged capture history. Co-Authored-By: Claude Fable 5 --- tools/coverage_vault.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tools/coverage_vault.py b/tools/coverage_vault.py index d49c444e..079140cf 100644 --- a/tools/coverage_vault.py +++ b/tools/coverage_vault.py @@ -162,9 +162,12 @@ def _compact_region(region, drop_invalid_evidence=False): if len(image) != size: raise ValueError("capture size mismatch at 0x%08X: size=%d bytes=%d" % (load, size, len(image))) - phys_lo = load & 0x1FFFFF + # 8 MB dev-RAM aware: titles running the 8MB enhancement stream code into + # the extended banks (0x200000..0x7FFFFF); the old 2 MB mask corrupted + # their capture addresses and the bound rejected legitimate regions. + phys_lo = load & 0x7FFFFF phys_hi = phys_lo + size - if phys_hi > 2 * 1024 * 1024: + if phys_hi > 8 * 1024 * 1024: raise ValueError("capture exceeds PSX RAM at 0x%08X" % load) parsed = {} @@ -178,7 +181,7 @@ def _compact_region(region, drop_invalid_evidence=False): entries = [] for value in values: address = _address(value, field) - phys = address & 0x1FFFFF + phys = address & 0x7FFFFF if phys < phys_lo or phys >= phys_hi or (phys & 3): if not drop_invalid_evidence: raise ValueError( From 58c1f4027628a661a4cac5774431497fde4f5546 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:03:26 -0700 Subject: [PATCH 11/27] Normalize wall-time windows for CRTC multiplier != 1 (x2 audit findings) A sweep of vblank-period-derived windows under the NTSC x2 CRTC (audit 2026-08-19) confirmed the high-stakes paths - SPU/XA/CDDA pitch and cadence, guest timers, rumble, pacing - are guest-cycle-anchored and correct at x2. Two windows that cover CYCLE-fixed guest work were period-relative and silently halved: - sio.c card ChangeThread-defer tail ("~2 VBlank periods" over the libcard A6C10/B4E38 handshake): now stock-period relative. - cdrom.c savestate CD boost window ("~3s at 60Hz", counted per sim vblank): now scaled by the multiplier. Remaining audited-but-unchanged exposures are opt-in QoL paths (FMV auto-skip hold, turbo-load hysteresis, audio mute hangover, rewind interval, pacer catch-up window) documented in the audit; and one latent trap: the non-block-cycles fire_vblank_edge path steps timers/cdrom per vblank edge and would run devices at 2x under the multiplier (shipping builds compile it out). mdec.c's "cycles_per_frame = 338688" comment is mislabeled (that is 10 ms, not a frame) - pre-existing, cycle-anchored, unaffected by the multiplier. Co-Authored-By: Claude Fable 5 --- runtime/src/cdrom.c | 8 +++++++- runtime/src/sio.c | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/runtime/src/cdrom.c b/runtime/src/cdrom.c index fd9cdd42..380626b6 100644 --- a/runtime/src/cdrom.c +++ b/runtime/src/cdrom.c @@ -2900,7 +2900,13 @@ static void cdrom_apply_savestate_delay_caps(int log_clamps) { void cdrom_accelerate_after_savestate(void) { const char *probe = getenv("PSX_POST_LOAD_PROBE"); const int log_probe = (probe && probe[0] == '1'); - s_savestate_cd_boost_vblanks = CDROM_SAVESTATE_BOOST_VBLANKS; + /* Counted per SIM vblank: scale by the CRTC multiplier so the boost + * window keeps its intended ~3s of wall time at NTSC x2. */ + { + extern uint32_t gpu_get_crtc_refresh_multiplier(void); + s_savestate_cd_boost_vblanks = CDROM_SAVESTATE_BOOST_VBLANKS * + (int)gpu_get_crtc_refresh_multiplier(); + } cdrom_apply_savestate_delay_caps(/*log_clamps*/log_probe); if (log_probe) fprintf(stderr, diff --git a/runtime/src/sio.c b/runtime/src/sio.c index 003c4c4a..4967923c 100644 --- a/runtime/src/sio.c +++ b/runtime/src/sio.c @@ -425,7 +425,13 @@ static int sio_txn_open = 0; static void sio_arm_card_ct_defer_guard(void) { extern uint64_t psx_get_cycle_count(void); uint64_t now = psx_get_cycle_count(); - uint64_t until = now + (uint64_t)gpu_vblank_period_cycles() * 2ull; + /* The libcard A6C10/B4E38 handshake this covers is CYCLE-fixed, so the + * window must be stock-period relative: at CRTC x2 the raw period halves + * and "~2 VBlank periods" would silently mean half the intended cover + * (audited 2026-08-19, finding #6). */ + extern uint32_t gpu_get_crtc_refresh_multiplier(void); + uint64_t until = now + (uint64_t)gpu_vblank_period_cycles() * + (uint64_t)gpu_get_crtc_refresh_multiplier() * 2ull; if (until > s_card_ct_defer_until_cyc) s_card_ct_defer_until_cyc = until; } From 2edce1b66fdaf3d548c847909d45211c36bd70ff Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:03:48 -0700 Subject: [PATCH 12/27] interrupts: never publish pc=0 from same-thread restore at top-level depth; exit-reg diagnostics pc=0 from the same-thread-restore epilogue means "the interrupted native frame is still on the host stack below - return and it continues". At dispatch depth <= 0 no such frame can exist and the 0 reaches the main loop as a bogus GUEST_EXIT, so prefer the compiled interrupt resume PC there (the same remedy the scheduler top-level-resume case already uses). Honest scope note: this closes a real hole by construction, but does NOT fix the observed WipEout 3 static-bake boot exit (deterministic "execution completed, PC=0", ra=0x800D7FC0, epc=0x8015FAEC) - that death reproduces unchanged, so its escape route is elsewhere; investigation continues. Also: the top-level exit path now prints the exit registers (ra/sp/epc/sr/func/last_store_pc/v0/a0/t9), which is how the crash was attributed on play builds without debug tools. Co-Authored-By: Claude Fable 5 --- runtime/src/interrupts.c | 13 ++++++++++++- runtime/src/main.cpp | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/runtime/src/interrupts.c b/runtime/src/interrupts.c index 9825b456..4a4e03c8 100644 --- a/runtime/src/interrupts.c +++ b/runtime/src/interrupts.c @@ -1844,7 +1844,18 @@ irq_deliver_eval: same_thread_resume = 1; { extern int psx_scheduler_top_level_resume_active(void); - if (psx_scheduler_top_level_resume_active()) { + /* pc=0 means "the interrupted native frame is still on the host + * stack below us — return and it continues". That contract also + * breaks whenever dispatch has already collapsed to TOP LEVEL + * (depth 0): statically-baked overlay functions cross into AOT + * text via CPS returns, so the interrupted chain may hold no + * host frames at all, and a published 0 reaches the main loop + * as a bogus GUEST_EXIT (WipEout 3 ntsc120full8 static bake: + * deterministic "execution completed, PC=0" ~10 s into boot, + * ra=0x800D7FC0 epc=0x8015FAEC). Same remedy as the scheduler + * case: prefer the compiled interrupt resume PC. */ + if (psx_scheduler_top_level_resume_active() || + g_psx_dispatch_depth <= 0) { uint32_t resume = s_compiled_interrupt_resume_pc; if (resume == 0u) resume = s_last_interrupt_check_pc; diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 64b517bf..436295e4 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -14380,6 +14380,20 @@ session_reboot: } std::fprintf(stdout, "psxrecomp runtime: execution completed, PC=0x%08X\n", cpu.pc); + /* The PC=0 top-level exit is an abnormal-boot class (see interrupts.c: + * approximate exception-resume PCs severing the live native chain). The + * registers at exit name the culprit without needing a debug-tools build: + * ra = who returned/jumped here, epc = the last exception context. */ + { + extern uint32_t g_debug_current_func_addr, g_debug_last_store_pc; + std::fprintf(stdout, + "psxrecomp runtime: [exit regs] ra=0x%08X sp=0x%08X " + "epc=0x%08X sr=0x%08X func=0x%08X last_store_pc=0x%08X " + "v0=0x%08X a0=0x%08X t9=0x%08X\n", + cpu.gpr[31], cpu.gpr[29], cpu.cop0[14], cpu.cop0[12], + g_debug_current_func_addr, g_debug_last_store_pc, + cpu.gpr[2], cpu.gpr[4], cpu.gpr[25]); + } { extern uint64_t g_slice_fired, g_slice_irq_taken, g_dirty_ram_insns_run; extern uint32_t g_slice_exit_pc, g_slice_exit_reason, g_slice_exit_iter; extern uint32_t g_slice_exit_dispatchable, g_slice_exit_dirty, g_slice_exit_in_text, g_slice_exit_want; From d9f31e1efc54c485616234b359e866169abc4d86 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:07:30 -0700 Subject: [PATCH 13/27] tex_pack: config-owned exclusions ([video] hd_texture_exclude) The pack directory's exclude.txt is game content (gitignored), so it silently disappears whenever the pack is regenerated - which re-enabled HD substitution of WipEout 3's four font atlases and produced the doubled-glyph defect (sub-texel HD strokes under the game's two-pass shadow draw; coverage.json proved atlas 4cb2ec4e substituting). The config now carries the exclusions durably; they merge with any pack-local exclude.txt, and semantics are unchanged (texel-source exclusion only, applied at tex_pack_lookup_replacement). Co-Authored-By: Claude Fable 5 --- recompiler/src/config_loader.cpp | 4 ++++ recompiler/src/config_loader.h | 7 +++++++ runtime/include/tex_pack.h | 2 ++ runtime/src/main.cpp | 12 +++++++++++- runtime/src/tex_pack.cpp | 6 ++++++ 5 files changed, 30 insertions(+), 1 deletion(-) diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 395f6328..6e972c0b 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -614,6 +614,10 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& if (video.contains("hd_texture_dir")) { rt.video_hd_texture_dir = toml::find(video, "hd_texture_dir"); } + if (video.contains("hd_texture_exclude")) { + rt.video_hd_texture_exclude = + toml::find>(video, "hd_texture_exclude"); + } if (video.contains("geometry_correction")) { rt.video_geometry_correction = toml::find(video, "geometry_correction"); diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index f71e5361..c8162356 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -468,6 +468,13 @@ struct RuntimeConfig { // authored for RetroArch already expects to sit. std::string video_hd_texture_dir; + // hd_texture_exclude: texture hashes (hex) never substituted from the HD + // pack, merged with the pack dir's optional exclude.txt. Config-owned so + // the exclusions survive pack-directory regeneration - the pack dir is + // game content (gitignored), and losing its exclude.txt silently + // re-enabled font-atlas substitution (the doubled-glyph defect). + std::vector video_hd_texture_exclude; + // geometry_correction: sub-pixel vertex precision (the PGXP-style fix for // PS1 polygon jitter/wobble). The GTE projects in 16.16 and then throws the // fraction away when it saturates SXY to integer screen pixels; vertices of diff --git a/runtime/include/tex_pack.h b/runtime/include/tex_pack.h index 9ef71cab..e421b831 100644 --- a/runtime/include/tex_pack.h +++ b/runtime/include/tex_pack.h @@ -51,6 +51,8 @@ extern "C" { * still just works. The dump folder always follows the disc-stem convention. * * Cheap no-op (and tex_pack_active() stays 0) when both flags are 0. */ +/* Add a config-owned exclusion (call after tex_pack_init). */ +void tex_pack_add_excluded(uint32_t hash); void tex_pack_init(const char *disc_path, int enable_replace, int enable_dump, const char *dir_override, const char *pack_dir); void tex_pack_shutdown(void); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 436295e4..84991aed 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1151,7 +1151,8 @@ static int g_video_renderer = PSXRecompV4::DEFAULT_VIDEO_RENDERER; * newly-seen texture. */ static int g_hd_textures = 0; static int g_hd_texture_dump = 0; -static std::string g_hd_texture_dir; /* relocates the whole convention (dev knob) */ +static std::string g_hd_texture_dir; +static std::vector g_hd_texture_exclude; /* [video] hd_texture_exclude */ /* relocates the whole convention (dev knob) */ static std::string g_bezel_path; /* [video] bezel -- margin artwork */ static std::string g_hd_texture_pack; /* the active pack folder itself (manager) */ static int g_fullscreen = 0; /* tri-state: 0 windowed, 1 borderless (desktop) @@ -11404,6 +11405,7 @@ int main(int argc, char** argv) { g_hd_textures = gc.runtime.video_hd_textures ? 1 : 0; g_hd_texture_dump = gc.runtime.video_hd_texture_dump ? 1 : 0; g_hd_texture_dir = gc.runtime.video_hd_texture_dir; + g_hd_texture_exclude = gc.runtime.video_hd_texture_exclude; g_bezel_path = gc.runtime.video_bezel; if (gc.runtime.runtime_cpu_overclock != 100u) { psx_set_cpu_overclock(gc.runtime.runtime_cpu_overclock); @@ -13113,6 +13115,14 @@ int main(int argc, char** argv) { } tex_pack_init(resolved_disc.string().c_str(), g_hd_textures, g_hd_texture_dump, g_hd_texture_dir.c_str(), g_hd_texture_pack.c_str()); + /* Config-owned exclusions survive pack-directory regeneration (the pack + * dir is gitignored game content; losing its exclude.txt silently + * re-enabled font-atlas substitution - the doubled-glyph defect). */ + for (const std::string &hx : g_hd_texture_exclude) { + unsigned h = 0; + if (std::sscanf(hx.c_str(), "%x", &h) == 1) + tex_pack_add_excluded((uint32_t)h); + } /* Coverage report for the launcher's per-pack stats. atexit alongside the * other end-of-session flushes; a no-op when no pack is active. */ std::atexit(tex_pack_write_coverage); diff --git a/runtime/src/tex_pack.cpp b/runtime/src/tex_pack.cpp index b9ce2e24..78bc0103 100644 --- a/runtime/src/tex_pack.cpp +++ b/runtime/src/tex_pack.cpp @@ -371,6 +371,12 @@ void dump_upload(const Upload &up, int depth, int clut_x, int clut_y, uint32_t p /* ---- public API -------------------------------------------------------- */ +/* Config-owned exclusions ([video] hd_texture_exclude): merged with the pack + * dir's optional exclude.txt so exclusions survive pack regeneration. */ +extern "C" void tex_pack_add_excluded(uint32_t hash) { + g.excluded.push_back(hash); +} + extern "C" void tex_pack_init(const char *disc_path, int enable_replace, int enable_dump, const char *dir_override, const char *pack_dir) { From ada331a401e7720d08cb5c999c4079c3f803c3e3 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:39:10 -0700 Subject: [PATCH 14/27] =?UTF-8?q?pgxp/gte:=20exact-sign=20NCLIP=20hardenin?= =?UTF-8?q?g=20=E2=80=94=20validate-on-read,=20shadow-silent=20replay,=20i?= =?UTF-8?q?dempotent=20enable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-causes the transient 1-frame sign-corner flicker at wide aspects (sign faces blink when the exact-sign NCLIP path silently loses its shadows for one frame): - gte_nclip was the only shadow consumer skipping the engine's single safety invariant (packed-word validation). A stale FIFO shadow (e.g. after an SXYP MTC2 shifts the real FIFO but not shadows 12/13) could invent a silently WRONG winding sign. New pgxp_get_gte_sxy_checked validates the shadow's word against the live SXY register; staleness now demotes to the honest integer fallback. - Overlay shadow-diff REPLAY passes ran the pgxp memory hooks live (record pass's stale FIFO shadows re-validated against different vertices -> flags dropped, live slots overwritten -> arena-wide provenance kill = one-frame integer-sign blink, plus false SHDIFF_GTE_DATA divergences permanently demoting shards to interp). gte_replay_side_effects_begin/end now bracket pgxp_suppress so replay is shadow-silent, like the speculative validation passes. The nclip precise path is additionally gated off inside the sandbox and speculative passes. - pgxp_set_enabled is now idempotent: a redundant re-derive (config apply, correction toggle, debug A/B) no longer costs a generation bump, which killed every live shadow and blinked one frame of exact-sign coverage. - Observability: free-running NCLIP precise/fallback/corrected counters and a PGXPStats.invalidations counter (bumped at the generation-bump site), both exposed in the geom_correction debug reply. All changes are inert at 4:3 (ws_precise_nclip gate) and touch host-only state exclusively. Co-Authored-By: Claude Fable 5 --- runtime/include/cpu_state.h | 43 +++++++++++++++++++++++++++++++++++++ runtime/include/pgxp.h | 6 ++++++ runtime/src/debug_server.c | 15 +++++++++++-- runtime/src/gte.cpp | 36 ++++++++++++++++++++++++++----- runtime/src/pgxp.cpp | 21 ++++++++++++++++++ 5 files changed, 114 insertions(+), 7 deletions(-) diff --git a/runtime/include/cpu_state.h b/runtime/include/cpu_state.h index 5d1713bc..6595d2d0 100644 --- a/runtime/include/cpu_state.h +++ b/runtime/include/cpu_state.h @@ -220,6 +220,49 @@ extern uint32_t gte_geometry_correction_hits(void); extern void gte_geometry_correction_stats(uint32_t *lookups, uint32_t *hits, uint32_t *miss_unrecorded, uint32_t *miss_ambiguous); +/* Exact-sign NCLIP coverage: precise = all three SXY shadows live and + * word-validated; fallback = integer sign decided (the widescreen thin-face + * flicker regime); corrected = sign disagreements actually fixed. */ +extern void gte_nclip_precise_stats(uint64_t *hits, uint64_t *fallbacks, + uint64_t *corrected); + +/* pc=0 escape journal (traps.c). Every runtime site that publishes a null PC + * (the "continue the native chain below" convention) or rescues one records + * an entry; when the convention breaks — a published 0 reaches the top-level + * trampoline and the run dies with "execution completed, PC=0" — the ring + * tail names the exact publisher and its context. Publishes are rare, so the + * always-on cost is a few stores; this works in play builds where the + * per-dispatch fntrace ring is compiled out (the exit trace was blind). */ +enum { + PSX_PC0J_TRAP_CHANGE_SELF = 1, /* a: target_tcb */ + PSX_PC0J_TRAP_FIBER_SWITCH = 2, + PSX_PC0J_TRAP_CRIT_ENTER = 3, /* a: sr */ + PSX_PC0J_TRAP_CRIT_EXIT = 4, /* a: sr */ + PSX_PC0J_TRAP_NULL_TARGET = 5, /* a: addr (0) */ + PSX_PC0J_TRAP_SENTINEL = 6, /* a: addr; in-exception longjmp path */ + PSX_PC0J_TRAP_ASYNC_RFE = 7, /* a: resume pc (rescue, not a 0) */ + PSX_PC0J_TRAP_E10_NOOP = 8, /* a: addr */ + PSX_PC0J_TRAP_STALE_MISS = 9, /* a: addr */ + PSX_PC0J_IRQ_SAME_THREAD = 10, /* a: would-be resume; d: escape reason */ + PSX_PC0J_IRQ_RESCUE = 11, /* a: published resume; d: reason */ + PSX_PC0J_DIRTY_SENTINEL = 12, /* d: bit0 in_exception, bit1 async-armed */ + PSX_PC0J_DIRTY_UNSUPPORTED = 13, /* a: unsupported pc; d: reason */ +}; +typedef struct Pc0JournalEntry { + uint64_t seq; + uint32_t site; /* PSX_PC0J_* */ + uint32_t frame; /* present frame count at publish */ + int32_t depth; /* g_psx_dispatch_depth at publish */ + uint32_t a; /* site-specific (see enum) */ + uint32_t b; /* $ra at publish */ + uint32_t c; /* COP0 EPC at publish */ + uint32_t d; /* site-specific extra */ +} Pc0JournalEntry; +#define PSX_PC0J_CAP 64 +extern Pc0JournalEntry g_pc0j_ring[PSX_PC0J_CAP]; +extern uint64_t g_pc0j_seq; +extern void psx_pc0_journal_note(uint32_t site, CPUState *cpu, + uint32_t a, uint32_t d); /* PGXP dataflow-shadowing hook macros (PGXP_LOAD/STORE/ALU/MULDIV/COP2). * The emitter writes them unconditionally; they expand to real calls only diff --git a/runtime/include/pgxp.h b/runtime/include/pgxp.h index db419e91..f41ddb61 100644 --- a/runtime/include/pgxp.h +++ b/runtime/include/pgxp.h @@ -58,6 +58,11 @@ void pgxp_gte_push_sxy(int32_t x16, int32_t y16, uint16_t sz3, uint32_t packed); /* Current SXY FIFO shadow (index 0..3 selects GTE data regs 12..15). * Returns nonzero when the shadow is live and carries X/Y precision. */ int pgxp_get_gte_sxy(uint32_t index, int32_t *x16, int32_t *y16); +/* Validate-on-read variant: check!=0 additionally requires the shadow's packed + * word to equal expect (the live guest SXY word) - the engine's single safety + * invariant, which sign-consumers must not skip. */ +int pgxp_get_gte_sxy_checked(uint32_t index, uint32_t expect, int check, + int32_t *x16, int32_t *y16); /* Guest write to a GTE data register outside gte_execute (MTC2/CTC2 handled * by psx_pgxp_cop2; this is for direct gte_write_data paths): reg 15 performs @@ -98,6 +103,7 @@ typedef struct PGXPStats { uint64_t w_valid; /* lookups that also carried a usable depth */ uint64_t produced; /* RTPS/RTPT projections pushed into shadows */ uint64_t swc2_stores; /* GTE reg shadows copied to RAM shadows */ + uint64_t invalidations; /* generation bumps (each kills all shadows) */ } PGXPStats; void pgxp_get_stats(PGXPStats *out); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index f4affc18..386fcd52 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -5213,6 +5213,11 @@ static void handle_geom_correction(int id, const char *json) * triangles that reach the predicate. */ uint64_t tc_att = 0, tc_arm = 0, tc_off = 0, tc_nosrc = 0, tc_noz = 0; gpu_texture_correction_stats(&tc_att, &tc_arm, &tc_off, &tc_nosrc, &tc_noz); + /* Exact-sign NCLIP coverage: fallbacks are frames' worth of faces decided + * by the integer sign (the flicker regime); invalidations counts shadow + * generation bumps (each one blinks the whole arena's provenance). */ + uint64_t nc_hit = 0, nc_fb = 0, nc_corr = 0; + gte_nclip_precise_stats(&nc_hit, &nc_fb, &nc_corr); send_fmt("{\"id\":%d,\"ok\":true," "\"geometry_correction\":%d," "\"geometry_vertex_hits\":%u," @@ -5224,7 +5229,10 @@ static void handle_geom_correction(int id, const char *json) "\"lookups\":%llu,\"dataflow_hit\":%llu,\"fallback_hit\":%llu," "\"native\":%llu,\"value_mismatch\":%llu,\"trunc_reject\":%llu," "\"tolerance_reject\":%llu,\"w_valid\":%llu," - "\"produced\":%llu,\"swc2_stores\":%llu}}", + "\"produced\":%llu,\"swc2_stores\":%llu," + "\"invalidations\":%llu}," + "\"nclip\":{\"precise\":%llu,\"fallback\":%llu," + "\"corrected\":%llu}}", id, gte_geometry_correction_enabled(), (unsigned)hits, @@ -5243,7 +5251,10 @@ static void handle_geom_correction(int id, const char *json) (unsigned long long)ps.tolerance_reject, (unsigned long long)ps.w_valid, (unsigned long long)ps.produced, - (unsigned long long)ps.swc2_stores); + (unsigned long long)ps.swc2_stores, + (unsigned long long)ps.invalidations, + (unsigned long long)nc_hit, (unsigned long long)nc_fb, + (unsigned long long)nc_corr); } /* pgxp — live-tune the value-propagation engine for one-toggle isolation runs diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index 351078ea..0c29be74 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -932,6 +932,19 @@ void gte_rtpt(GTEState* gte, uint32_t instr) { // --------------------------------------------------------------------------- // NCLIP (0x06) — Normal Clipping (2D cross product for backface culling) // --------------------------------------------------------------------------- +/* Exact-sign coverage counters (free-running; debug server geom_correction + * reply). A fallback means the integer sign decided — the regime where the + * widescreen X-squash rounding can flip a thin face for a frame. */ +static uint64_t s_nclip_precise_hits = 0; +static uint64_t s_nclip_fallbacks = 0; +static uint64_t s_nclip_corrected = 0; +extern "C" void gte_nclip_precise_stats(uint64_t *hits, uint64_t *fallbacks, + uint64_t *corrected) { + if (hits) *hits = s_nclip_precise_hits; + if (fallbacks) *fallbacks = s_nclip_fallbacks; + if (corrected) *corrected = s_nclip_corrected; +} + void gte_nclip(GTEState* gte, uint32_t instr) { gte->FLAG = 0; int32_t sx0 = static_cast(gte->SXY[0] & 0xFFFF); @@ -955,17 +968,22 @@ void gte_nclip(GTEState* gte, uint32_t instr) { * substitute is honest about the face's true orientation. */ int32_t px0, py0, px1, py1, px2, py2; if (gpu_ws_precise_nclip_enabled() && - pgxp_get_gte_sxy(0, &px0, &py0) && - pgxp_get_gte_sxy(1, &px1, &py1) && - pgxp_get_gte_sxy(2, &px2, &py2)) { + !s_gte_replay_sandbox && s_speculative_depth == 0 && + pgxp_get_gte_sxy_checked(0, gte->SXY[0], 1, &px0, &py0) && + pgxp_get_gte_sxy_checked(1, gte->SXY[1], 1, &px1, &py1) && + pgxp_get_gte_sxy_checked(2, gte->SXY[2], 1, &px2, &py2)) { + s_nclip_precise_hits++; const double fx0 = px0 / 65536.0, fy0 = py0 / 65536.0; const double fx1 = px1 / 65536.0, fy1 = py1 / 65536.0; const double fx2 = px2 / 65536.0, fy2 = py2 / 65536.0; const double cross = fx0 * (fy1 - fy2) + fx1 * (fy2 - fy0) + fx2 * (fy0 - fy1); - if (cross > 0.0 && out <= 0) out = 1; - else if (cross < 0.0 && out >= 0) out = -1; + if (cross > 0.0 && out <= 0) { out = 1; s_nclip_corrected++; } + else if (cross < 0.0 && out >= 0) { out = -1; s_nclip_corrected++; } + } else if (gpu_ws_precise_nclip_enabled() && + !s_gte_replay_sandbox && s_speculative_depth == 0) { + s_nclip_fallbacks++; } gte->MAC0 = out; gte->set_error_flag(); @@ -1565,11 +1583,19 @@ extern "C" int gte_replay_side_effects_begin(void) { if (s_gte_replay_sandbox) return 0; s_gte_replay_saved_caller_ra = s_gte_caller_ra; s_gte_replay_sandbox = 1; + /* Replay passes must be shadow-silent: the record pass already wrote the + * authoritative shadows, and a replay's memory hooks re-validating stale + * FIFO shadows against different vertices drop flags and overwrite live + * slots (arena-wide provenance kill => one-frame integer-sign NCLIP + * blinks under overlay diff mode). Suppression makes every psx_pgxp_* + * hook a no-op for the bracket, like the speculative validation passes. */ + pgxp_suppress_begin(); return 1; } extern "C" void gte_replay_side_effects_end(void) { using namespace PSXRecomp::GTE; if (!s_gte_replay_sandbox) return; + pgxp_suppress_end(); s_gte_caller_ra = s_gte_replay_saved_caller_ra; s_gte_replay_sandbox = 0; } diff --git a/runtime/src/pgxp.cpp b/runtime/src/pgxp.cpp index 8c6bc86d..80932146 100644 --- a/runtime/src/pgxp.cpp +++ b/runtime/src/pgxp.cpp @@ -93,6 +93,7 @@ static PGXPStats s_stats; extern "C" void pgxp_invalidate_all(void) { if (s_suppress != 0) { s_deferred_invalidate = 1; return; } + s_stats.invalidations++; if (++s_gen == 0) { /* generation wrapped: physically clear so stale slots can't revive */ if (s_ram) std::memset(s_ram, 0, s_ram_words * sizeof(PGXPValue)); @@ -104,6 +105,7 @@ extern "C" void pgxp_invalidate_all(void) { } extern "C" void pgxp_set_enabled(int enabled) { + int resized = 0; if (enabled) { /* Cover the REAL guest RAM, not the stock 2 MB. With the 8 MB mod the * extension is real memory, and a 2 MB-wrapped shadow aliased every @@ -129,11 +131,17 @@ extern "C" void pgxp_set_enabled(int enabled) { std::free(s_ram); s_ram = fresh; s_ram_words = want; + resized = 1; } else if (!s_ram) { enabled = 0; /* fail closed: stay faithful */ } } } + /* Idempotence: a redundant re-derive (config apply, correction toggle, + * debug-server A/B) must not cost a generation bump - every bump kills + * all live shadows and blinks one frame of exact-sign NCLIP coverage. */ + if (s_enabled == (enabled ? 1 : 0) && !resized) + return; s_enabled = enabled ? 1 : 0; pgxp_invalidate_all(); recompute_active(); @@ -724,10 +732,23 @@ extern "C" void pgxp_gte_push_sxy(int32_t x16, int32_t y16, uint16_t sz3, } extern "C" int pgxp_get_gte_sxy(uint32_t index, int32_t *x16, int32_t *y16) { + return pgxp_get_gte_sxy_checked(index, 0u, 0, x16, y16); +} + +/* Validate-on-read variant: the engine's single safety invariant is that a + * shadow is believed only while its packed word matches the live guest word. + * gte_nclip was the one consumer skipping that check, so any staleness class + * (e.g. an SXYP MTC2 shifting the real FIFO while shadows 12/13 keep + * describing pre-shift words) could invent a silently WRONG winding sign + * instead of an honest integer fallback. check!=0 enforces the compare. */ +extern "C" int pgxp_get_gte_sxy_checked(uint32_t index, uint32_t expect, + int check, int32_t *x16, int32_t *y16) { if (index >= 4) return 0; const PGXPValue *pv = &s_gte[12 + index]; if (!pv_live(pv) || (pv->flags & PGXP_F_VXY) != PGXP_F_VXY) return 0; + if (check && pv->value != expect) + return 0; if (x16) *x16 = pv->x16; if (y16) *y16 = pv->y16; return 1; From 6ead85c9da0b1820c8434634f000abd120e354e2 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:39:34 -0700 Subject: [PATCH 15/27] recompiler/runtime: honor IRQ redirects at compiled leaders; revive async-RFE latch; pc0 escape journal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the deterministic top-level "execution completed, PC=0" ~10 s into WipEout 3 boot with the static bake linked (the 120 Hz core bug). Root cause (CPS-contract audit, H1): every emitted block-leader IRQ check was `psx_check_interrupts_at(cpu, X); cpu->pc = X;` — whatever resume PC the exception epilogue published (guest RFE to a non-EPC target: longjmp-style card-ISR handlers, SetCustomExitFromException, thread switch) was unconditionally overwritten by the static transfer. The dirty interpreter honors these redirects ("Handler resumed elsewhere — surface to dispatch"); the static bake moved the IRQ take-points from interp pumps onto compiled leaders, where the redirect was dropped — the guest's continuation vanished, downstream state was never established, and a later jr through a null pointer surfaced as a silent top-level PC=0 exit. - code_generator/full_function_emitter: both check emitters (and the install-slot hook) now emit the compiled analogue of the interp contract: `if (cpu->pc != 0 && ((cpu->pc ^ X) & 0x1FFFFFFF)) return;` after the check, surfacing the redirect to the trampoline. Applies to branch/jr/jalr/jal/split sites in the AOT text, the recompiled BIOS, and every overlay shard/static bake on regeneration. - interrupts.c: the async-RFE resume latch (g_async_rfe_resume_pc, "Tomba 2 frame-1997 fix") was documented but NEVER ASSIGNED — the whole rescue in the traps/dirty sentinel gates was inert (audit H3). Latch it at every exception entry that installs a real EPC. - pc0 escape journal (always-on, play builds): every runtime site that publishes/rescues a null PC records {site, frame, dispatch depth, ra, epc, extras} in a 64-entry ring, dumped at the abnormal-exit print. The play build's exit trace was blind (fntrace is debug-only: fntrace_seq=0 in the death artifact); publishes are rare so the always-on cost is a few stores. - compile_overlays.py: PSX_STATIC_NO_ISOLATED=1 A/B guard to skip the isolated-fragment pass. Co-Authored-By: Claude Fable 5 --- recompiler/src/code_generator.cpp | 20 +++++++++++++++-- recompiler/src/full_function_emitter.cpp | 17 ++++++++++---- runtime/src/dirty_ram_interp.c | 5 +++++ runtime/src/interrupts.c | 16 ++++++++++++++ runtime/src/main.cpp | 18 +++++++++++++++ runtime/src/traps.c | 28 ++++++++++++++++++++++++ tools/compile_overlays.py | 4 ++++ 7 files changed, 102 insertions(+), 6 deletions(-) diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 1d5ee098..75a38d59 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -140,18 +140,34 @@ std::string CodeGenerator::emit_mid_block_cycle_charge(uint32_t addr, return ss.str(); } +/* Both check emitters honor an exception REDIRECT: when the delivery's + * epilogue publishes a resume PC different from this site's static + * continuation (guest RFE to a non-EPC target — longjmp-style handlers, + * SetCustomExitFromException, thread switch), the compiled code must + * surface it to the trampoline instead of overwriting it with the static + * transfer. This is the compiled analogue of the dirty interpreter's + * "Handler resumed elsewhere — surface to dispatch" contract; without it a + * baked/compiled leader silently drops the guest's continuation (WipEout 3 + * static bake: deterministic top-level "execution completed, PC=0" ~10 s + * into boot, root-caused to redirects dropped at compiled leaders). A + * published pc equal to the static continuation falls through (same-thread + * restore publishes 0; the depth-guard rescue publishes the site PC). */ std::string CodeGenerator::emit_interrupt_check(uint32_t resume_pc, const std::string& indent) const { return std::string("#ifdef PSX_ENABLE_BLOCK_CYCLES\n") + indent + "psx_cyc_bb_defer_flush();\n#endif\n" + indent + - fmt::format("psx_check_interrupts_at(cpu, 0x{:08X}u);\n", resume_pc); + fmt::format("psx_check_interrupts_at(cpu, 0x{:08X}u);\n", resume_pc) + indent + + fmt::format("if (cpu->pc != 0u && ((cpu->pc ^ 0x{:08X}u) & 0x1FFFFFFFu)) return; /* IRQ redirect */\n", + resume_pc); } std::string CodeGenerator::emit_interrupt_check_expr(const std::string& resume_pc_expr, const std::string& indent) const { return std::string("#ifdef PSX_ENABLE_BLOCK_CYCLES\n") + indent + "psx_cyc_bb_defer_flush();\n#endif\n" + indent + - fmt::format("psx_check_interrupts_at(cpu, {});\n", resume_pc_expr); + fmt::format("psx_check_interrupts_at(cpu, {});\n", resume_pc_expr) + indent + + fmt::format("if (cpu->pc != 0u && ((cpu->pc ^ ({})) & 0x1FFFFFFFu)) return; /* IRQ redirect */\n", + resume_pc_expr); } std::string CodeGenerator::reg_name(int reg_num) { diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index dc9bb0ce..efea2674 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -171,17 +171,25 @@ bool FullFunctionEmitter::emit_function( const char* e = std::getenv("PSX_CPS"); return e == nullptr || e[0] != '0'; }(); + /* Redirect-honoring checks — see CodeGenerator::emit_interrupt_check: + * an exception epilogue that publishes a resume PC different from this + * site's static continuation must surface to the trampoline, never be + * overwritten by the static transfer (the dropped-continuation class + * behind the WipEout 3 static-bake top-level PC=0 exit). */ auto emit_irq_check = [](uint32_t resume_pc, const std::string& indent = " ") { + uint32_t rt = bios_runtime_pc(resume_pc); return std::string("#ifdef PSX_ENABLE_BLOCK_CYCLES\n") + indent + "psx_cyc_bb_defer_flush();\n#endif\n" + indent + - fmt::format("psx_check_interrupts_at(cpu, 0x{:08X}u);\n", - bios_runtime_pc(resume_pc)); + fmt::format("psx_check_interrupts_at(cpu, 0x{:08X}u);\n", rt) + indent + + fmt::format("if (cpu->pc != 0u && ((cpu->pc ^ 0x{:08X}u) & 0x1FFFFFFFu)) return; /* IRQ redirect */\n", rt); }; auto emit_irq_check_expr = [](const std::string& resume_pc_expr, const std::string& indent = " ") { return std::string("#ifdef PSX_ENABLE_BLOCK_CYCLES\n") + indent + "psx_cyc_bb_defer_flush();\n#endif\n" + indent + - fmt::format("psx_check_interrupts_at(cpu, {});\n", resume_pc_expr); + fmt::format("psx_check_interrupts_at(cpu, {});\n", resume_pc_expr) + indent + + fmt::format("if (cpu->pc != 0u && ((cpu->pc ^ ({})) & 0x1FFFFFFFu)) return; /* IRQ redirect */\n", + resume_pc_expr); }; auto emit_cosim_instr = [](uint32_t pc, const std::string& indent = " ") { return "#ifdef PSX_COSIM\n" + indent + @@ -1202,9 +1210,10 @@ bool FullFunctionEmitter::emit_function( " * back here as a registered continuation target. */\n" " if (cpu->read_word(0x{:08X}u) != 0u) {{\n" " psx_check_interrupts_at(cpu, 0x{:08X}u);\n" + " if (cpu->pc != 0u && ((cpu->pc ^ 0x{:08X}u) & 0x1FFFFFFFu)) return; /* IRQ redirect */\n" " cpu->pc = 0x{:08X}u; return;\n" " }}\n", - addr, ram_pc, ram_pc + 0x10u, ram_pc, ram_pc, ram_pc); + addr, ram_pc, ram_pc + 0x10u, ram_pc, ram_pc, ram_pc, ram_pc); } // Non-terminator: emit normally — unless this load's successor reads diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 82213e5d..208fbbb1 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -2682,6 +2682,9 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ g_sentinel_reach_dirty++; if (!psx_get_in_exception()) g_sentinel_reach_traps++; /* reuse: in_exc==0 dirty reaches */ g_sentinel_reach_async = g_async_rfe_resume_pc; + psx_pc0_journal_note(PSX_PC0J_DIRTY_SENTINEL, cpu, addr, + (psx_get_in_exception() ? 1u : 0u) | + (g_async_rfe_resume_pc ? 2u : 0u)); cpu->pc = 0; if (psx_get_in_exception()) { psx_exception_longjmp(); /* does not return */ @@ -3032,6 +3035,8 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ g_dirty_ram_last_unsupported_pc = g_unsupported_pc; g_dirty_ram_last_unsupported_insn = g_unsupported_insn; g_dirty_ram_last_unsupported_reason = g_unsupported_reason; + psx_pc0_journal_note(PSX_PC0J_DIRTY_UNSUPPORTED, cpu, + g_unsupported_pc, g_unsupported_insn); cpu->pc = 0; OV_FPLOG_RET1(); } diff --git a/runtime/src/interrupts.c b/runtime/src/interrupts.c index 4a4e03c8..1da51b32 100644 --- a/runtime/src/interrupts.c +++ b/runtime/src/interrupts.c @@ -1471,6 +1471,8 @@ irq_deliver_eval: * disarms the RFE-flag escape). */ g_rfe_escape_pending = 0; { + extern uint32_t g_async_rfe_resume_pc; /* dirty_ram_interp.c */ + extern uint64_t g_async_rfe_set_count; uint32_t real_pc = g_dirty_safe_resume_pc ? g_dirty_safe_resume_pc : s_compiled_interrupt_resume_pc; /* Top-level flush_resume / savestate: resync cleared the latches and @@ -1514,6 +1516,15 @@ irq_deliver_eval: cpu->cop0[COP0_EPC] = real_pc; /* architectural: the real resume PC */ g_exception_real_epc = real_pc; g_exc_escape_reason = PSX_EXC_ESCAPE_NONE; /* set at the actual RFE/SYSCALL return */ + /* Async-RFE latch (Tomba 2 frame-1997 fix): persist the most + * recent real interruption PC so a game-driven asynchronous + * ReturnFromException (sentinel RFE with in_exception==0, e.g. a + * card-ISR longjmp installed via HookEntryInt) resumes the guest + * here instead of resolving to pc=0 (abnormal top-level exit). + * The latch was documented but never assigned — the whole rescue + * in the traps/dirty sentinel gates was inert. */ + g_async_rfe_resume_pc = real_pc; + g_async_rfe_set_count++; } else { uint32_t sentinel = PSX_EXC_SENTINEL_PC; cpu->write_word(sentinel, 0x00000000u); /* NOP, read by the handler's BD check */ @@ -1862,7 +1873,12 @@ irq_deliver_eval: if (resume != 0u) cpu->pc = resume; /* else keep post-RFE cpu->pc — never publish 0 */ + psx_pc0_journal_note(PSX_PC0J_IRQ_RESCUE, cpu, resume, + g_exc_escape_reason); } else { + psx_pc0_journal_note(PSX_PC0J_IRQ_SAME_THREAD, cpu, + s_compiled_interrupt_resume_pc, + g_exc_escape_reason); cpu->pc = 0; /* continue the interrupted live chain */ } } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 84991aed..66b14adf 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -14412,6 +14412,24 @@ session_reboot: (unsigned long long)g_dirty_ram_insns_run, g_slice_exit_pc, g_slice_exit_reason, g_slice_exit_iter, g_slice_exit_dispatchable, g_slice_exit_dirty, g_slice_exit_in_text, g_slice_exit_want); } + /* pc=0 escape journal tail: names the exact runtime site that published + * (or rescued) each recent null PC, with frame/depth/ra/epc context — + * the play-build replacement for the debug-only fntrace ring, which is + * compiled out here (the exit trace JSON was blind: fntrace_seq=0). */ + { + uint64_t n = g_pc0j_seq < PSX_PC0J_CAP ? g_pc0j_seq : PSX_PC0J_CAP; + std::fprintf(stdout, "psxrecomp runtime: [pc0 journal] %llu total, last %llu:\n", + (unsigned long long)g_pc0j_seq, (unsigned long long)n); + for (uint64_t i = 0; i < n; i++) { + const Pc0JournalEntry *e = + &g_pc0j_ring[(g_pc0j_seq - n + i) % PSX_PC0J_CAP]; + std::fprintf(stdout, + " seq=%llu site=%u frame=%u depth=%d a=0x%08X " + "ra=0x%08X epc=0x%08X d=0x%08X\n", + (unsigned long long)e->seq, e->site, e->frame, + e->depth, e->a, e->b, e->c, e->d); + } + } shutdown_runtime(); if (g_gl_active) gl_renderer_shutdown(); diff --git a/runtime/src/traps.c b/runtime/src/traps.c index ac6fa06b..9a13c6d9 100644 --- a/runtime/src/traps.c +++ b/runtime/src/traps.c @@ -240,6 +240,24 @@ enum { }; uint32_t g_pc0_reason = PSX_PC0_GUEST_RETURN; +/* pc=0 escape journal — see cpu_state.h for the site enum and rationale. */ +Pc0JournalEntry g_pc0j_ring[PSX_PC0J_CAP]; +uint64_t g_pc0j_seq = 0; +void psx_pc0_journal_note(uint32_t site, CPUState *cpu, uint32_t a, uint32_t d) +{ + extern int g_psx_dispatch_depth; + extern uint64_t s_frame_count; /* present frame counter (debug_server.c) */ + Pc0JournalEntry *e = &g_pc0j_ring[g_pc0j_seq % PSX_PC0J_CAP]; + e->seq = g_pc0j_seq++; + e->site = site; + e->frame = (uint32_t)s_frame_count; + e->depth = g_psx_dispatch_depth; + e->a = a; + e->b = cpu->gpr[31]; + e->c = cpu->cop0[14]; + e->d = d; +} + static uint32_t psx_current_tcb_ptr(CPUState* cpu) { uint32_t tcbh = cpu->read_word(0x00000108u); @@ -555,6 +573,7 @@ static int psx_change_thread_fiber(CPUState* cpu, uint32_t target_tcb) if (current_tcb == target_tcb) { debug_server_log_thread_event(5, cpu, current_tcb, target_tcb, cpu->gpr[31]); g_pc0_reason = PSX_PC0_CHANGE_SELF; + psx_pc0_journal_note(PSX_PC0J_TRAP_CHANGE_SELF, cpu, target_tcb, 0); cpu->pc = 0; return 1; } @@ -625,6 +644,7 @@ static int psx_change_thread_fiber(CPUState* cpu, uint32_t target_tcb) debug_server_log_thread_event(9, cpu, target_tcb, current_tcb, 0); g_pc0_reason = PSX_PC0_FIBER_SWITCH; + psx_pc0_journal_note(PSX_PC0J_TRAP_FIBER_SWITCH, cpu, 0, 0); cpu->pc = 0; return 1; } @@ -943,6 +963,7 @@ int psx_syscall(CPUState* cpu, uint32_t code) { cpu->cop0[12] = sr & ~1u; /* clear IEc (bit 0) */ cpu->gpr[2] = sr & 1u; /* return old IEc */ g_pc0_reason = PSX_PC0_CRIT_SECTION; + psx_pc0_journal_note(PSX_PC0J_TRAP_CRIT_ENTER, cpu, sr, 0); cpu->pc = 0; /* Directly handled "void" syscall. Return 0: under CPS the caller * (`if (psx_syscall(...)) return;`) falls through to the inline @@ -955,6 +976,7 @@ int psx_syscall(CPUState* cpu, uint32_t code) { cpu->cop0[12] = sr | 0x0401u; /* set IEc (bit 0) + IM[2] (bit 10) */ cpu->gpr[2] = 0; g_pc0_reason = PSX_PC0_CRIT_SECTION; + psx_pc0_journal_note(PSX_PC0J_TRAP_CRIT_EXIT, cpu, sr, 0); cpu->pc = 0; return 0; @@ -1106,6 +1128,7 @@ void psx_unknown_dispatch(CPUState* cpu, uint32_t addr, uint32_t phys) { * or uninitialized callback slot is dispatched. */ if (addr == 0) { g_pc0_reason = PSX_PC0_DISPATCH_MISS; /* guest jumped/returned to a null (0) target */ + psx_pc0_journal_note(PSX_PC0J_TRAP_NULL_TARGET, cpu, addr, 0); cpu->pc = 0; return; } @@ -1117,6 +1140,7 @@ void psx_unknown_dispatch(CPUState* cpu, uint32_t addr, uint32_t phys) { * the exception handler, longjmp back to psx_check_interrupts to * properly unwind the handler call tree. */ if (addr == 0x80000048u && psx_get_in_exception()) { + psx_pc0_journal_note(PSX_PC0J_TRAP_SENTINEL, cpu, addr, 1); cpu->pc = 0; psx_exception_longjmp(); /* does not return */ } @@ -1136,6 +1160,8 @@ void psx_unknown_dispatch(CPUState* cpu, uint32_t addr, uint32_t phys) { g_sentinel_reach_async = g_async_rfe_resume_pc; if (g_async_rfe_resume_pc != 0u) { g_async_rfe_fire_count++; + psx_pc0_journal_note(PSX_PC0J_TRAP_ASYNC_RFE, cpu, + g_async_rfe_resume_pc, 0); cpu->pc = g_async_rfe_resume_pc; return; } @@ -1151,6 +1177,7 @@ void psx_unknown_dispatch(CPUState* cpu, uint32_t addr, uint32_t phys) { * the psx_dispatch tail-call loop picking up $ra), it's a no-op — the * continuation was already handled by the merged function. */ if (phys == 0x00000E10u) { + psx_pc0_journal_note(PSX_PC0J_TRAP_E10_NOOP, cpu, addr, 0); cpu->pc = 0; return; } @@ -1422,6 +1449,7 @@ void psx_unknown_dispatch(CPUState* cpu, uint32_t addr, uint32_t phys) { * ring-recorded above. Stale registers WILL corrupt the caller — * diagnostic use only. */ g_pc0_reason = PSX_PC0_DISPATCH_MISS; + psx_pc0_journal_note(PSX_PC0J_TRAP_STALE_MISS, cpu, addr, 0); cpu->pc = 0; } } diff --git a/tools/compile_overlays.py b/tools/compile_overlays.py index 14709f49..5da2d6f1 100644 --- a/tools/compile_overlays.py +++ b/tools/compile_overlays.py @@ -6598,6 +6598,10 @@ def main(): # interiors. Compile each as an isolated dispatch-root shard, then give # every block in those fragments the same universal resume treatment. unresolved = sorted(static_requested_entries - existing_entries) + # PSX_STATIC_NO_ISOLATED=1: A/B guard — skip the isolated-fragment pass + # entirely (its universal-resume treatment is the newest machinery). + if os.environ.get('PSX_STATIC_NO_ISOLATED'): + unresolved = [] fragment_built = 0 new_fragment_parts = [] for entry in unresolved: From 21a8457ed8126baf2987f9b4300ff8c8991b60c2 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:45:58 -0700 Subject: [PATCH 16/27] recompiler: IRQ-redirect guard must CONSUME the benign same-site resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first guard shape (6ead85c) surfaced redirects but left cpu->pc nonzero when the exception epilogue benignly resumed at the site's own PC (the offline mode-1 restore path publishes cpu->pc = real_epc and never zeroes it). At a goto-type check site the stale pc then survived into the NEXT check site, where it read as a foreign redirect and spuriously unwound the frame mid-function — measured live on WipEout 3: the top-level PC=0 exit moved earlier (frame ~639, exit ra=0) instead of dying at the original site. The trampoline's own return-boundary helper already consumes the same-site publish (pc==stop -> pc=0); the guard now does the same: surface a foreign resume, consume a same-site one, fall through untouched only when pc==0. Co-Authored-By: Claude Fable 5 --- recompiler/src/code_generator.cpp | 4 ++-- recompiler/src/full_function_emitter.cpp | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 75a38d59..7a1b173b 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -157,7 +157,7 @@ std::string CodeGenerator::emit_interrupt_check(uint32_t resume_pc, return std::string("#ifdef PSX_ENABLE_BLOCK_CYCLES\n") + indent + "psx_cyc_bb_defer_flush();\n#endif\n" + indent + fmt::format("psx_check_interrupts_at(cpu, 0x{:08X}u);\n", resume_pc) + indent + - fmt::format("if (cpu->pc != 0u && ((cpu->pc ^ 0x{:08X}u) & 0x1FFFFFFFu)) return; /* IRQ redirect */\n", + fmt::format("if (cpu->pc != 0u) {{ if ((cpu->pc ^ 0x{:08X}u) & 0x1FFFFFFFu) return; cpu->pc = 0u; }} /* IRQ redirect / consume same-site resume */\n", resume_pc); } @@ -166,7 +166,7 @@ std::string CodeGenerator::emit_interrupt_check_expr(const std::string& resume_p return std::string("#ifdef PSX_ENABLE_BLOCK_CYCLES\n") + indent + "psx_cyc_bb_defer_flush();\n#endif\n" + indent + fmt::format("psx_check_interrupts_at(cpu, {});\n", resume_pc_expr) + indent + - fmt::format("if (cpu->pc != 0u && ((cpu->pc ^ ({})) & 0x1FFFFFFFu)) return; /* IRQ redirect */\n", + fmt::format("if (cpu->pc != 0u) {{ if ((cpu->pc ^ ({})) & 0x1FFFFFFFu) return; cpu->pc = 0u; }} /* IRQ redirect / consume same-site resume */\n", resume_pc_expr); } diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index efea2674..7bcad55f 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -181,14 +181,14 @@ bool FullFunctionEmitter::emit_function( return std::string("#ifdef PSX_ENABLE_BLOCK_CYCLES\n") + indent + "psx_cyc_bb_defer_flush();\n#endif\n" + indent + fmt::format("psx_check_interrupts_at(cpu, 0x{:08X}u);\n", rt) + indent + - fmt::format("if (cpu->pc != 0u && ((cpu->pc ^ 0x{:08X}u) & 0x1FFFFFFFu)) return; /* IRQ redirect */\n", rt); + fmt::format("if (cpu->pc != 0u) {{ if ((cpu->pc ^ 0x{:08X}u) & 0x1FFFFFFFu) return; cpu->pc = 0u; }} /* IRQ redirect / consume same-site resume */\n", rt); }; auto emit_irq_check_expr = [](const std::string& resume_pc_expr, const std::string& indent = " ") { return std::string("#ifdef PSX_ENABLE_BLOCK_CYCLES\n") + indent + "psx_cyc_bb_defer_flush();\n#endif\n" + indent + fmt::format("psx_check_interrupts_at(cpu, {});\n", resume_pc_expr) + indent + - fmt::format("if (cpu->pc != 0u && ((cpu->pc ^ ({})) & 0x1FFFFFFFu)) return; /* IRQ redirect */\n", + fmt::format("if (cpu->pc != 0u) {{ if ((cpu->pc ^ ({})) & 0x1FFFFFFFu) return; cpu->pc = 0u; }} /* IRQ redirect / consume same-site resume */\n", resume_pc_expr); }; auto emit_cosim_instr = [](uint32_t pc, const std::string& indent = " ") { @@ -1210,7 +1210,7 @@ bool FullFunctionEmitter::emit_function( " * back here as a registered continuation target. */\n" " if (cpu->read_word(0x{:08X}u) != 0u) {{\n" " psx_check_interrupts_at(cpu, 0x{:08X}u);\n" - " if (cpu->pc != 0u && ((cpu->pc ^ 0x{:08X}u) & 0x1FFFFFFFu)) return; /* IRQ redirect */\n" + " if (cpu->pc != 0u) {{ if ((cpu->pc ^ 0x{:08X}u) & 0x1FFFFFFFu) return; cpu->pc = 0u; }} /* IRQ redirect / consume */\n" " cpu->pc = 0x{:08X}u; return;\n" " }}\n", addr, ram_pc, ram_pc + 0x10u, ram_pc, ram_pc, ram_pc, ram_pc); From 3b6986827eb8db24197f4a2d2cf4a1fb7f717fc4 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:55:50 -0700 Subject: [PATCH 17/27] =?UTF-8?q?recompiler:=20image-edge=20fallthrough=20?= =?UTF-8?q?must=20tail-transfer=20=E2=80=94=20the=20WipEout=203=20static-b?= =?UTF-8?q?ake=20boot=20exit=20root=20cause?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A region-truncated final block that runs off the end of its function fell off the C function body when no in-image fallthrough function existed: the entry-switch prologue had consumed cpu->pc to 0, so the dispatcher returned "handled" with pc==0 and the top-level trampoline read it as "program ended". Witnessed live in the WipEout 3 ntsc120full8 static bake: the 0xD5000 region variant's block_800D7FC0 ends at the region edge (continuation 0x800D8004 lies outside the image); the moment CD streaming loaded content matching that variant's identity ranges the dispatcher routed 0x800D7FC0 into the truncated block and the run died — the deterministic "execution completed, PC=0" at ~10 s into boot, ~frame 1221, byte-for-byte reproducible. Both code_generator tail sites (plain functions and alias hosts) now emit `cpu->pc = ; return;` when the last live block runs off the image edge — the CPS tail-transfer the full_function_emitter already emits for in-image fallthroughs. The dispatcher routes the published continuation to whoever owns it (a neighboring region variant, AOT text, or the interpreter). Faithful and class-level: no per-game anything. Diagnosis chain that cornered it (all landed as permanent tooling): - pc0 escape journal site 14 (trampoline top-level exit) named the last dispatched target: 0x800D7FC0. - site 15 (dirty-dispatch handled-with-pc0 tripwire, now journaled with a route tag) proved the producer was route 3 = the static overlay dispatcher, first occurrence, not in exception context. - Return-shape census of every candidate variant body showed no return-with-pc==0 path — forcing the discovery that the dispatch case for 0x800D7FC0 routes FIRST to the 0xD5000 variant, whose truncated tail block falls off the end. Co-Authored-By: Claude Fable 5 --- recompiler/src/code_generator.cpp | 43 ++++++++++++++++++++++++ recompiler/src/full_function_emitter.cpp | 6 ++++ runtime/include/cpu_state.h | 2 ++ runtime/src/dirty_ram_interp.c | 15 ++++++++- runtime/src/main.cpp | 5 +++ runtime/src/traps.c | 10 ++++++ 6 files changed, 80 insertions(+), 1 deletion(-) diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 7a1b173b..6abd90e7 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -2808,6 +2808,30 @@ GeneratedFunction CodeGenerator::generate_function( fallthrough_name); } } + } else if (!cfg.block_order.empty()) { + // No in-image fallthrough function exists (region/image boundary): + // a reachable last block that simply runs off the end must TAIL- + // TRANSFER to its continuation PC, never fall off the C function — + // the entry-switch prologue consumed cpu->pc to 0, so falling off + // returns pc==0 to the dispatcher and the top-level trampoline reads + // it as "program ended" (WipEout 3 static bake: region-truncated + // block_800D7FC0 in the 0xD5000 variant fell off at 0x800D8004 → + // deterministic "execution completed, PC=0" ~10 s into boot). The + // published continuation is routed by the dispatcher to whoever owns + // it (a neighboring region variant, AOT text, or the interpreter). + const BasicBlock& last_block = cfg.blocks.at(cfg.block_order.back()); + bool runs_off_end = + last_block.exit_instr.type == ControlFlowType::None || + ((last_block.exit_instr.type == ControlFlowType::Branch || + last_block.exit_instr.type == ControlFlowType::Jump) && + last_block.successors.empty()); + bool is_reachable = last_block.is_entry || !last_block.predecessors.empty(); + if (runs_off_end && is_reachable) { + uint32_t cont = last_block.end_addr + 4u; + body_ss << fmt::format( + " cpu->pc = 0x{:08X}u; return; /* image-edge fallthrough: tail-transfer */\n", + cont); + } } body_ss << " ; /* label compatibility: C requires a statement after the last label */\n"; body_ss << "}\n"; @@ -3037,6 +3061,25 @@ std::vector CodeGenerator::generate_alias_group( body << fmt::format(" {}(cpu); /* fallthrough to next function */\n", fallthrough_name); } + } else if (!cfg.block_order.empty() && + live_blocks.count(cfg.block_order.back())) { + // Image-edge fallthrough (mirrors generate_function): no in-image next + // function exists, so a live final block that runs off the end must + // tail-transfer to its continuation PC — falling off the C function + // returns the entry-switch's consumed pc==0 and the top-level + // trampoline reads "program ended" (the WipEout 3 static-bake boot + // exit: truncated block_800D7FC0 in the 0xD5000 region variant). + const BasicBlock& last_block = cfg.blocks.at(cfg.block_order.back()); + bool runs_off_end = + (last_block.exit_instr.type == ControlFlowType::None) || + ((last_block.exit_instr.type == ControlFlowType::Branch || + last_block.exit_instr.type == ControlFlowType::Jump) && + last_block.successors.empty()); + if (runs_off_end) { + body << fmt::format( + " cpu->pc = 0x{:08X}u; return; /* image-edge fallthrough: tail-transfer */\n", + last_block.end_addr + 4u); + } } body << " ; /* label compatibility */\n"; body << "}\n"; diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index 7bcad55f..4e9d00d1 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -2162,6 +2162,12 @@ void FullFunctionEmitter::emit_dispatch( out += " if (outermost) {\n"; out += " psx_dispatch_check_return_boundary(cpu, stop_addr);\n"; out += " }\n"; + out += " /* Top-level pc==0 exit is the abnormal-run terminator: name the\n"; + out += " * last dispatched target in the pc0 journal before surfacing. */\n"; + out += " if (stop_addr == 0u) {\n"; + out += " extern void psx_pc0_trampoline_exit(CPUState* cpu, uint32_t last_target);\n"; + out += " psx_pc0_trampoline_exit(cpu, addr);\n"; + out += " }\n"; out += " return;\n"; out += " }\n"; out += " if (stop_addr != 0 && cpu->pc == stop_addr) {\n"; diff --git a/runtime/include/cpu_state.h b/runtime/include/cpu_state.h index 6595d2d0..beb55c57 100644 --- a/runtime/include/cpu_state.h +++ b/runtime/include/cpu_state.h @@ -247,6 +247,8 @@ enum { PSX_PC0J_IRQ_RESCUE = 11, /* a: published resume; d: reason */ PSX_PC0J_DIRTY_SENTINEL = 12, /* d: bit0 in_exception, bit1 async-armed */ PSX_PC0J_DIRTY_UNSUPPORTED = 13, /* a: unsupported pc; d: reason */ + PSX_PC0J_TOP_EXIT = 14, /* trampoline top-level pc==0 exit; + * a: last dispatched target; d: $sp */ }; typedef struct Pc0JournalEntry { uint64_t seq; diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 208fbbb1..de934483 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -200,6 +200,10 @@ uint32_t g_pczero_in_exc = 0; uint32_t g_pczero_async_rfe = 0; uint32_t g_pczero_dirty_safe = 0; uint64_t g_pczero_count = 0; +/* Which dirty_ram_dispatch_inner sub-path produced the last handled dispatch: + * 1=sentinel 2=AOT-compiled 3=static-overlay 4=DLL-shard 5=interp. Read by the + * pc0 tripwire so a null-PC return names its producer. */ +uint32_t g_dd_last_route = 0; /* Mid-block unsupported-opcode counters. Bumped instead of fprintf-spamming * stderr (CLAUDE.md §3). Read via dirty_ram_get_unsupported(). The "last_*" @@ -2355,6 +2359,11 @@ int dirty_ram_dispatch(CPUState* cpu, uint32_t addr, uint32_t stop_addr) { if (r == 1 && cpu->pc == 0u) { extern uint32_t g_async_rfe_resume_pc, g_dirty_safe_resume_pc; g_pczero_count++; + /* Journal every occurrence (site 15): the dirty/overlay dispatch chain + * reported handled yet published a null PC — at top level this IS the + * abnormal exit. a = dispatched addr; d = occurrence count. */ + psx_pc0_journal_note(15u, cpu, addr, + (uint32_t)g_pczero_count | (g_dd_last_route << 28)); if (!g_pczero_latched) { g_pczero_latched = 1; g_pczero_addr = addr; @@ -2679,6 +2688,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ int clean_game_text_miss = 0; if (addr == 0x80000048u) { + g_dd_last_route = 1; g_sentinel_reach_dirty++; if (!psx_get_in_exception()) g_sentinel_reach_traps++; /* reuse: in_exc==0 dirty reaches */ g_sentinel_reach_async = g_async_rfe_resume_pc; @@ -2713,6 +2723,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ ls_func_enter(addr, cpu); int prev_phase = g_exec_phase; g_exec_phase = 3; + g_dd_last_route = 2; int _gc = psx_dispatch_game_compiled(cpu, addr); g_exec_phase = prev_phase; ls_func_exit(addr, cpu, _gc); @@ -2740,6 +2751,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ #ifdef PSX_HAS_OVERLAY_DISPATCH { extern int psx_overlay_dispatch(CPUState *cpu, uint32_t addr); + g_dd_last_route = 3; if (psx_overlay_dispatch(cpu, addr)) return 1; } #endif @@ -2770,6 +2782,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ { extern int overlay_loader_dispatch(CPUState *cpu, uint32_t addr); + g_dd_last_route = 4; if (overlay_loader_dispatch(cpu, addr)) { if (_ovfp) overlay_fp_log(addr, _in_regs, cpu, 1); return 1; @@ -2778,7 +2791,7 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ /* Every exit retires any deferred load writeback: once we hand control back to * compiled code (or the dispatch loop) nothing downstream knows a register * write is still owed, and the pipeline would have drained by then anyway. */ -#define OV_FPLOG_RET1() do { dirty_ram_ld_delay_flush(cpu); if (_ovfp) overlay_fp_log(addr, _in_regs, cpu, 0); return 1; } while (0) +#define OV_FPLOG_RET1() do { g_dd_last_route = 5; dirty_ram_ld_delay_flush(cpu); if (_ovfp) overlay_fp_log(addr, _in_regs, cpu, 0); return 1; } while (0) if (!dirty_ram_is_dirty(phys) && !clean_game_text_miss) { /* Bulk host transfers can populate post-EXE executable RAM without diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 66b14adf..d1849886 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -14412,6 +14412,11 @@ session_reboot: (unsigned long long)g_dirty_ram_insns_run, g_slice_exit_pc, g_slice_exit_reason, g_slice_exit_iter, g_slice_exit_dispatchable, g_slice_exit_dirty, g_slice_exit_in_text, g_slice_exit_want); } + { extern int g_pczero_latched; extern uint32_t g_pczero_addr, g_pczero_ra, g_pczero_in_exc; + extern uint64_t g_pczero_count; + std::fprintf(stdout, "psxrecomp runtime: [pczero tripwire] latched=%u count=%llu addr=0x%08X ra=0x%08X in_exc=%u\n", + g_pczero_latched, (unsigned long long)g_pczero_count, + g_pczero_addr, g_pczero_ra, g_pczero_in_exc); } /* pc=0 escape journal tail: names the exact runtime site that published * (or rescued) each recent null PC, with frame/depth/ra/epc context — * the play-build replacement for the debug-only fntrace ring, which is diff --git a/runtime/src/traps.c b/runtime/src/traps.c index 9a13c6d9..8a8a8b71 100644 --- a/runtime/src/traps.c +++ b/runtime/src/traps.c @@ -258,6 +258,16 @@ void psx_pc0_journal_note(uint32_t site, CPUState *cpu, uint32_t a, uint32_t d) e->d = d; } +/* Called by the emitted trampoline when a top-level dispatch loop exits on + * pc==0 (the abnormal-run terminator): journals the LAST dispatched target, + * which no runtime site can otherwise see (the fatal 0 is published inside + * generated code — a null jr or a plain C-return). site 14; a=last target, + * d=$sp. */ +void psx_pc0_trampoline_exit(CPUState *cpu, uint32_t last_target) +{ + psx_pc0_journal_note(14u, cpu, last_target, cpu->gpr[29]); +} + static uint32_t psx_current_tcb_ptr(CPUState* cpu) { uint32_t tcbh = cpu->read_word(0x00000108u); From 95a56e236e5eb19cae03a8a0730344ce1942ee39 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:00:04 -0700 Subject: [PATCH 18/27] recompiler: the image-edge fallthrough hole is at the BLOCK level, not the function tail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3b69868 patched the two function-tail sites, but the truncated tail is emitted by the block-level fallthrough branch: a block with exit type None and no successor whose next PC is not a known function emitted the interrupt check and nothing else — execution fell off the C function with pc==0 (verified: the re-baked overlays_static.c still carried the truncated block_800D7FC0 tail; zero image-edge transfers were emitted). Publish the continuation there too. The function-tail guards stay as belt-and-braces. Co-Authored-By: Claude Fable 5 --- recompiler/src/code_generator.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 6abd90e7..7b44f765 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -2475,7 +2475,22 @@ std::string CodeGenerator::translate_basic_block( << fmt::format("func_{:08X}(cpu); return; /* fallthrough to split piece */\n", next_addr); } else { + // No successor block and no known function owns the next PC — + // the block runs off the region/image edge. Emitting only the + // interrupt check here let execution FALL OFF the C function with + // the entry-switch's consumed pc==0: the dispatcher returned + // "handled" with a null PC and the top-level trampoline read it + // as "program ended" (WipEout 3 static bake: the 0xD5000 region + // variant's truncated block_800D7FC0, continuation 0x800D8004, + // died the moment CD streaming loaded content matching that + // variant — the deterministic boot exit at ~frame 1221). Publish + // the continuation instead: the dispatcher routes it to whoever + // owns it (a neighboring region variant, AOT text, or the + // interpreter). ss << emit_interrupt_check(next_addr, config_.indent); + ss << config_.indent + << fmt::format("cpu->pc = 0x{:08X}u; return; /* image-edge fallthrough: tail-transfer */\n", + next_addr); } } From 124f8cbe278486d29c0e451cacfa876c8aaca453 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:21:50 -0700 Subject: [PATCH 19/27] present/rewind: two 120Hz pacing fixes from the frame-path audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Rewind degraded-mode fix: a capture deferred past its interval (previous snapshot still compressing at capture time) retried with no PBO prearm and fell into the synchronous full-pipeline VRAM readback (8-17 ms on the emu thread). Load-heavy windows delay the compression pump, making the degraded mode self-selecting exactly where frames are already long — the 0.4x dip class. Arm the async PBO whenever a capture is pending and no compression is in flight. 2. Native-vblank-rate mods no longer force vsync off for fps > 60. The present-cadence policy is already an XOR (swap interval resolves to the configured vsync only when the panel matches the CRTC rate within 2%, else 0 with the wall pacer engaged), so the force was pure over-defense that locked the 120 Hz mod onto the wall pacer even on a matching 120.0 Hz panel — where driver vsync absorbs the sub-frame jitter the pacer converts into debt/catch-up oscillation (measured 99.7-122.5 fps seesaw at 0.83-1.02x). Non-matching panels behave exactly as before. Co-Authored-By: Claude Fable 5 --- runtime/src/main.cpp | 23 ++++++++++++++++++++--- runtime/src/psx_rewind.c | 10 ++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index d1849886..40c3fac0 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1361,10 +1361,27 @@ extern "C" int psx_mod_set_native_vblank_rate( /* * Above the physical panel rate (and in uncapped mode), swap-interval * blocking would silently replace the requested guest cadence with the - * display cadence. The frame pacer owns fixed-rate timing here. - */ - if (frames_per_second == 0 || frames_per_second > 60) + * display cadence. The frame pacer owns fixed-rate timing here — UNLESS + * the panel actually runs at the requested rate: then driver vsync is + * the better cadence owner (it absorbs sub-frame jitter that the wall + * pacer converts into debt/catch-up oscillation — the 99.7-122.5 fps + * seesaw measured on the 120 Hz mod with a 120.0 Hz panel). The + * present-cadence policy (present_vsync_owns_cadence) already contains + * the matching band check and keeps the pacer/vsync XOR intact; only + * force vsync off when the panel genuinely cannot match. */ + if (frames_per_second == 0) g_video_vsync = 0; + /* fps > 60 no longer forces vsync off: the present-cadence policy is + * already an XOR — present_effective_swap_interval() returns the + * configured vsync ONLY when present_vsync_owns_cadence() (which + * requires the panel to match the CRTC rate within 2%), and 0 + * otherwise, with the wall pacer engaged exactly when vsync does not + * own. On a panel that matches the modded rate (120 Hz mod on a + * 120.0 Hz display) driver vsync then owns cadence and absorbs the + * sub-frame jitter the wall pacer converts into debt/catch-up + * oscillation (measured: 99.7-122.5 fps seesaw). On a non-matching + * panel the swap interval resolves to 0 and the pacer owns, same as + * the old forced-off behavior. */ if (frames_per_second) { std::fprintf(stdout, "psxrecomp: mod selected native guest VBlank pacing at %u FPS " diff --git a/runtime/src/psx_rewind.c b/runtime/src/psx_rewind.c index bf2cbd37..80c01c71 100644 --- a/runtime/src/psx_rewind.c +++ b/runtime/src/psx_rewind.c @@ -514,6 +514,16 @@ void psx_rewind_note_frame(void) else if (iv >= 2 && s_frame - s_last_capture_frame == iv - 1u && !s_zpend.active && !s_vram_prearmed) s_vram_prearmed = gl_renderer_vram_readback_begin(); + /* Degraded-mode fix: a capture DEFERRED past its interval (previous + * snapshot still compressing at capture time) used to retry with no + * prearm and fall into the synchronous full-pipeline VRAM readback + * (measured 8-17 ms on the emu thread — the 0.4x dip class during + * load-heavy windows, which delay the compression pump and make the + * degraded mode self-selecting exactly where frames are already long). + * Arm the async PBO whenever a capture is pending and no compression is + * in flight, so every capture consumes an async copy. */ + if (s_capture_due && !s_zpend.active && !s_vram_prearmed) + s_vram_prearmed = gl_renderer_vram_readback_begin(); } /* ---- Amortized snapshot compression -------------------------------------- From f3e3264822385128c9020b2bf5632f61dbf1a53e Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:30:36 -0700 Subject: [PATCH 20/27] perf: memoized static-identity checks, gated dispatch rings, inline i-cache hit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the steady-state dispatch-cost audit of the WipEout 3 static bake (80,847 baked variants; ~1M dispatches/s): 1. DOMINANT: psx_overlay_static_code_matches used a shared 4096-slot cache with no eviction — the first ~4096 tuples dispatched owned it forever, and every other variant paid a full 4096-entry linear probe PLUS a full CRC32 of its code bytes on EVERY dispatch, with the result uncached (entry NULL when the table is full). Boot-order dependence of which tuples win slots explains the session-dependent 0.83-1.02x seesaw. New psx_overlay_static_code_matches_memo takes a per-variant state[2] emitted by the generator next to each ranges array: repeat calls are the page-gen sum plus one compare; the CRC runs only on first sight or after a real write to a covered page. Identical invalidation semantics; unbounded capacity; no probing. compile_overlays.py emits the state and calls the memo variant. 2. fntrace dispatch-tail + SP-domain rings gated out of play builds (PSX_NO_DEBUG_TOOLS): their per-dispatch writes (incl. a psx_get_cycle_count() call each) had no reader — the TCP server that dumps them is compiled out. Abnormal-exit forensics in play builds is carried by the pc0 escape journal. Debug builds unchanged. 3. Per-instruction psx_icache_fetch was an out-of-line call whose hit path is 3 branches + a tag compare; generated code now uses the inline tag-hit (psx_icache_fetch_interp) in-process, keeping the exported slow entry for DLL shards (identical cache evolution — the inline's miss path IS that entry). Both emitters. Also converges the image-edge fallthrough publish with upstream PR #161 (cps_enabled_-gated; non-CPS keeps the legacy check-only fall-through). Upstream PR #158 (xlate capture default off) is already superseded here by the release-build gate. Co-Authored-By: Claude Fable 5 --- tools/compile_overlays.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tools/compile_overlays.py b/tools/compile_overlays.py index 5da2d6f1..7299e8c5 100644 --- a/tools/compile_overlays.py +++ b/tools/compile_overlays.py @@ -2255,6 +2255,13 @@ def generate_overlay_dispatch(variants: list) -> str: 'extern int psx_overlay_static_code_matches(const uint32_t *lo_len_pairs,', ' uint32_t count,', ' uint32_t expected_crc);', + '/* Memoized variant (overlay_loader.c): one private state[2] per', + ' * variant kills the shared match cache (4096 slots for 80k+ variants', + ' * = permanent eviction = full CRC per dispatch for the losers). */', + 'extern int psx_overlay_static_code_matches_memo(const uint32_t *lo_len_pairs,', + ' uint32_t count,', + ' uint32_t expected_crc,', + ' uint32_t state[2]);', 'static uint64_t psx_ov_static_checks = 0;', 'static uint64_t psx_ov_static_hits = 0;', 'static uint64_t psx_ov_static_variant_misses = 0;', @@ -2268,6 +2275,8 @@ def generate_overlay_dispatch(variants: list) -> str: lines.append( f'static const uint32_t {variant["range_symbol"]}[] = ' '{ ' + ', '.join(flat) + ' };') + lines.append( + f'static uint32_t {variant["range_symbol"]}_st[2];') lines += [ '', @@ -2290,9 +2299,9 @@ def generate_overlay_dispatch(variants: list) -> str: count = len(variant['ranges']) lines += [ ' psx_ov_static_checks++;', - f' if (psx_overlay_static_code_matches(' + f' if (psx_overlay_static_code_matches_memo(' f'{variant["range_symbol"]}, {count}u, ' - f'0x{variant["crc"]:08X}u)) {{', + f'0x{variant["crc"]:08X}u, {variant["range_symbol"]}_st)) {{', ' psx_ov_static_hits++;', f' {variant["symbol"]}(cpu);', ' return 1;', From 5b08379cbe86b8bd4332171e91b3668dd854c534 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:54:31 -0700 Subject: [PATCH 21/27] main: PSX_SELF_PROFILE 1kHz IP sampler; emitters: PSX_ICACHE_FETCH macro in both preambles - Env-gated self-profiler: samples the main (emu) thread's RIP at 1 kHz for N seconds, histograms exe-relative 64-byte buckets, dumps the top 120 to psx_self_profile.txt (llvm-addr2line VA = 0x140000000+offset). Play-build hot-spot attribution without ETL symbolization; zero cost when unset. Motivation: internal-scale (16x vs 8x), cadence (vsync vs pacer), and identity-memoization A/Bs all measured within noise, while halving cpu_overclock nearly closed the fps gap - the cost is in guest instruction throughput and needs real attribution, not hypotheses. - Mixed-emitter files (FFE preamble + code_generator bodies) missed the PSX_ICACHE_FETCH macro; both FFE preambles now define it too. Co-Authored-By: Claude Fable 5 --- recompiler/src/code_generator.cpp | 20 ++++++- recompiler/src/full_function_emitter.cpp | 12 +++- runtime/src/fntrace.c | 8 +++ runtime/src/main.cpp | 75 ++++++++++++++++++++++++ runtime/src/overlay_loader.c | 51 ++++++++++++++++ 5 files changed, 162 insertions(+), 4 deletions(-) diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index 7b44f765..e34eff29 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -1828,7 +1828,7 @@ std::string CodeGenerator::translate_basic_block( if (!(insn_addr == block.start_addr || (insn_addr & 0xCu) == 0 || extra_labels_.count(insn_addr))) return; ss << "#ifdef PSX_ENABLE_BLOCK_CYCLES\n"; - ss << indent << fmt::format("psx_icache_fetch(cpu, 0x{:08X}u);\n", insn_addr); + ss << indent << fmt::format("PSX_ICACHE_FETCH(cpu, 0x{:08X}u);\n", insn_addr); ss << "#endif\n"; }; auto emit_cosim_instr = [&](uint32_t insn_addr, const std::string& indent) { @@ -2474,7 +2474,7 @@ std::string CodeGenerator::translate_basic_block( ss << config_.indent << fmt::format("func_{:08X}(cpu); return; /* fallthrough to split piece */\n", next_addr); - } else { + } else if (cps_enabled_) { // No successor block and no known function owns the next PC — // the block runs off the region/image edge. Emitting only the // interrupt check here let execution FALL OFF the C function with @@ -2491,6 +2491,11 @@ std::string CodeGenerator::translate_basic_block( ss << config_.indent << fmt::format("cpu->pc = 0x{:08X}u; return; /* image-edge fallthrough: tail-transfer */\n", next_addr); + } else { + // Non-CPS legacy: the check-only fall-through remains valid (the + // function nests; a C return continues the caller). Same split as + // upstream PR #161. + ss << emit_interrupt_check(next_addr, config_.indent); } } @@ -3405,6 +3410,17 @@ std::string CodeGenerator::generate_file( // Include the generic PSX runtime header. // This provides CPUState, GTE/trap declarations, and call_by_address(). ss << "#include \"psx_runtime.h\"\n\n"; + // Per-instruction i-cache fetch: in-process builds (static bake / AOT) + // use the inline tag-hit (psx_icache.h) — the out-of-line call per + // instruction was a measured steady-state tax. DLL shards cannot touch + // host globals directly and keep the exported slow entry (identical + // cache evolution; the inline's miss path IS that entry). + ss << "#ifdef PSX_OVERLAY_DLL_BUILD\n" + "#define PSX_ICACHE_FETCH(c,a) psx_icache_fetch((c),(a))\n" + "#else\n" + "#include \"psx_icache.h\"\n" + "#define PSX_ICACHE_FETCH(c,a) psx_icache_fetch_interp((c),(a))\n" + "#endif\n\n"; emit_runtime_externs(ss); emit_unaligned_helpers(ss, /*as_inline=*/false); diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index 4e9d00d1..61c8e19b 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -721,7 +721,7 @@ bool FullFunctionEmitter::emit_function( auto emit_icache_fetch = [&](uint32_t rom_addr) { if (!per_insn_cycles) return; if (!(block_leaders.count(rom_addr) || (rom_addr & 0xCu) == 0)) return; - out += fmt::format("#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_icache_fetch(cpu, 0x{:08X}u);\n#endif\n", + out += fmt::format("#ifdef PSX_ENABLE_BLOCK_CYCLES\n psx_icache_fetch_interp(cpu, 0x{:08X}u);\n#endif\n", relocate_ra(rom_addr)); }; @@ -1647,6 +1647,10 @@ void FullFunctionEmitter::emit_dispatch( out += "extern void psx_check_interrupts(CPUState* cpu);\n"; out += "extern void psx_check_interrupts_at(CPUState* cpu, uint32_t resume_pc);\n"; out += "extern void psx_restore_state_escape(void);\n"; + out += "#include \"psx_icache.h\" /* inline per-insn i-cache tag hit */\n"; + out += "#ifndef PSX_ICACHE_FETCH\n" + "#define PSX_ICACHE_FETCH(c,a) psx_icache_fetch_interp((c),(a))\n" + "#endif\n"; out += "extern void gte_execute(CPUState* cpu, uint32_t cmd);\n"; out += "extern void gte_write_data(CPUState* cpu, uint8_t reg, uint32_t val);\n"; out += "extern uint32_t gte_read_data(CPUState* cpu, uint8_t reg);\n"; @@ -1923,7 +1927,7 @@ void FullFunctionEmitter::emit_dispatch( out += " (w1 & 0xFFFF0000u) != 0x25080000u ||\n"; out += " w2 != 0x01000008u || w3 != 0u) return 0;\n"; out += "#ifdef PSX_ENABLE_BLOCK_CYCLES\n"; - out += " psx_icache_fetch(cpu, addr);\n"; + out += " psx_icache_fetch_interp(cpu, addr);\n"; out += fmt::format(" psx_cyc_step(cpu, 0x{:X}u);\n", psx_cyc_dep_res_mask(0x3C080000u)); out += fmt::format(" psx_cyc_step(cpu, 0x{:X}u);\n", @@ -2315,6 +2319,10 @@ EmitStats FullFunctionEmitter::emit( full_c += "extern void psx_check_interrupts(CPUState* cpu);\n"; full_c += "extern void psx_check_interrupts_at(CPUState* cpu, uint32_t resume_pc);\n"; full_c += "extern void psx_restore_state_escape(void);\n"; + full_c += "#include \"psx_icache.h\" /* inline per-insn i-cache tag hit */\n"; + full_c += "#ifndef PSX_ICACHE_FETCH\n" + "#define PSX_ICACHE_FETCH(c,a) psx_icache_fetch_interp((c),(a))\n" + "#endif\n"; full_c += "extern void gte_execute(CPUState* cpu, uint32_t cmd);\n"; full_c += "extern void gte_write_data(CPUState* cpu, uint8_t reg, uint32_t val);\n"; full_c += "extern uint32_t gte_read_data(CPUState* cpu, uint8_t reg);\n"; diff --git a/runtime/src/fntrace.c b/runtime/src/fntrace.c index ee92645a..941f3e9a 100644 --- a/runtime/src/fntrace.c +++ b/runtime/src/fntrace.c @@ -121,6 +121,13 @@ void fntrace_record(CPUState* cpu, uint32_t target) { /* The BIOS has completed the PS-X EXE load by the time it dispatches the * entry point. Apply the validated plan before the first guest instruction. */ mod_runtime_on_dispatch(target); + /* Play builds (PSX_NO_DEBUG_TOOLS): the tail/SP-domain rings have no + * reader — the TCP server that dumps them is compiled out — so their + * per-dispatch writes (incl. a psx_get_cycle_count() call each) are pure + * tax at ~1M dispatches/s. Post-mortem forensics in play builds is + * carried by the pc0 escape journal (traps.c), which records only the + * rare events. Debug builds keep both rings always-on, as designed. */ +#ifndef PSX_NO_DEBUG_TOOLS { extern uint64_t psx_get_cycle_count(void); DispTailEntry *t = &g_disp_tail[g_disp_tail_seq % DISP_TAIL_CAP]; @@ -151,6 +158,7 @@ void fntrace_record(CPUState* cpu, uint32_t target) { s_spdom_prev_sp = sp_now; } } +#endif /* !PSX_NO_DEBUG_TOOLS */ /* On-the-fly string translation (framework feature — text_xlate.cpp). The * generated psx_dispatch_impl calls us at the top of each dispatch iteration diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 40c3fac0..e1a01f29 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -129,6 +129,10 @@ extern "C" void psx_game_codegen_forward_if_built(int argc, char** argv); #include #include #include +#include +#ifdef _WIN32 +#include /* GetModuleInformation (self-profile sampler) */ +#endif #ifdef _WIN32 #ifndef WIN32_LEAN_AND_MEAN @@ -11074,6 +11078,62 @@ namespace { } // namespace #endif +/* PSX_SELF_PROFILE=: 1 kHz instruction-pointer sampler over the main + * (emu) thread, histogrammed by exe-relative offset and dumped to + * psx_self_profile.txt at the end of the window. Play-build-safe hot-spot + * attribution without WPA/ETL symbolization: feed the offsets (plus the PE + * link base 0x140000000) to llvm-addr2line -e . Env-gated diagnostic — + * zero cost when unset. */ +#ifdef _WIN32 +struct SelfProfArgs { HANDLE thread; int seconds; }; +static DWORD WINAPI self_profile_thread(LPVOID param) { + SelfProfArgs *a = (SelfProfArgs *)param; + const int n = a->seconds * 1000; + static uintptr_t rips[600000]; + int count = 0; + HMODULE exe = GetModuleHandleA(NULL); + MODULEINFO mi; memset(&mi, 0, sizeof(mi)); + GetModuleInformation(GetCurrentProcess(), exe, &mi, sizeof(mi)); + uintptr_t base = (uintptr_t)mi.lpBaseOfDll; + uintptr_t size = (uintptr_t)mi.SizeOfImage; + for (int i = 0; i < n && count < 600000; i++) { + Sleep(1); + if (SuspendThread(a->thread) == (DWORD)-1) continue; + CONTEXT ctx; memset(&ctx, 0, sizeof(ctx)); + ctx.ContextFlags = CONTEXT_CONTROL; + if (GetThreadContext(a->thread, &ctx)) rips[count++] = (uintptr_t)ctx.Rip; + ResumeThread(a->thread); + } + /* Bucket by 64-byte line inside the exe; everything else = external. */ + std::map hist; + int external = 0; + for (int i = 0; i < count; i++) { + if (rips[i] >= base && rips[i] < base + size) + hist[(rips[i] - base) & ~63ull]++; + else external++; + } + std::vector> top; + for (auto &kv : hist) top.push_back({kv.second, kv.first}); + std::sort(top.rbegin(), top.rend()); + FILE *f = fopen("psx_self_profile.txt", "wb"); + if (f) { + fprintf(f, "samples=%d external=%d (exe base offsets, 64B buckets; " + "addr2line VA = 0x140000000 + offset) +", count, external); + for (size_t i = 0; i < top.size() && i < 120; i++) + fprintf(f, "%6d 0x%llx +", top[i].first, + (unsigned long long)top[i].second); + fclose(f); + } + fprintf(stdout, "psxrecomp: [self-profile] %d samples (%d external) -> psx_self_profile.txt +", + count, external); + fflush(stdout); + return 0; +} +#endif + int main(int argc, char** argv) { /* Force line-buffered output so messages appear even if killed. */ std::setvbuf(stdout, nullptr, _IOLBF, 0); @@ -11979,6 +12039,21 @@ int main(int argc, char** argv) { * PSX_FRAME_INTERPOLATION=0/1; PSX_FRAME_INTERPOLATION_FPS=0|90+. */ if (const char *e = std::getenv("PSX_LOW_LATENCY_INPUT")) g_low_latency_input = atoi(e) ? 1 : 0; if (const char *e = std::getenv("PSX_VSYNC")) g_video_vsync = atoi(e); +#ifdef _WIN32 + if (const char *e = std::getenv("PSX_SELF_PROFILE")) { + int secs = atoi(e); + if (secs > 0) { + static SelfProfArgs spa; + DuplicateHandle(GetCurrentProcess(), GetCurrentThread(), + GetCurrentProcess(), &spa.thread, + 0, FALSE, DUPLICATE_SAME_ACCESS); + spa.seconds = secs; + CreateThread(NULL, 0, self_profile_thread, &spa, 0, NULL); + std::fprintf(stdout, "psxrecomp: [self-profile] armed for %d s +", secs); + } + } +#endif if (const char *e = std::getenv("PSX_WAYLAND_ALLOW_VSYNC")) g_wayland_allow_vsync = atoi(e) ? 1 : 0; if (const char *e = std::getenv("PSX_SMOOTH_60FPS")) diff --git a/runtime/src/overlay_loader.c b/runtime/src/overlay_loader.c index 2e1134ab..921b0900 100644 --- a/runtime/src/overlay_loader.c +++ b/runtime/src/overlay_loader.c @@ -638,6 +638,57 @@ int psx_overlay_static_code_matches(const uint32_t *lo_len_pairs, return matches; } +/* Memoized per-variant matcher — the static bake emits one private + * state[2] per variant and passes it here, replacing the shared 4096-slot + * cache above for baked identities. That cache serviced 80k+ variants with + * no eviction: the first ~4096 tuples dispatched owned it forever and every + * other variant paid a full-table probe plus a full code-CRC on EVERY + * dispatch (measured as the boot-order-dependent 0.83-1.02x fps seesaw). + * Here a repeat call is the gen-sum loop plus one compare; the CRC runs only + * on first sight or after a real write to a covered page. Invalidation + * semantics are identical (overlay_watch_pagegen_sum). + * state[0] = gen_sum at last verify; state[1] = bit0 valid, bit1 verdict. */ +int psx_overlay_static_code_matches_memo(const uint32_t *lo_len_pairs, + uint32_t count, + uint32_t expected_crc, + uint32_t state[2]) { + const uint8_t *ram = memory_get_ram_ptr(); + if (!ram || !lo_len_pairs || count == 0u || count > 4096u) { + s_static_match_crc_misses++; + return 0; + } + uint32_t gen_sum = 0; + for (uint32_t i = 0; i < count; i++) { + uint32_t lo = lo_len_pairs[i * 2u] & 0x1FFFFFFFu; + uint32_t len = lo_len_pairs[i * 2u + 1u]; + if (len == 0u || lo >= 8u * 1024u * 1024u || + len > 8u * 1024u * 1024u - lo) { + s_static_match_crc_misses++; + return 0; + } + gen_sum += overlay_watch_pagegen_sum(lo, len); + } + if ((state[1] & 1u) && state[0] == gen_sum) { + s_static_match_gen_fastpath++; + return (int)((state[1] >> 1) & 1u); + } + uint32_t crc = 0xFFFFFFFFu; + for (uint32_t i = 0; i < count; i++) { + uint32_t lo = lo_len_pairs[i * 2u] & 0x1FFFFFFFu; + uint32_t len = lo_len_pairs[i * 2u + 1u]; + crc = crc32_update(crc, ram + lo, len); + } + crc ^= 0xFFFFFFFFu; + { + int matches = (crc == expected_crc) ? 1 : 0; + s_static_match_rehashes++; + if (!matches) s_static_match_crc_misses++; + state[0] = gen_sum; + state[1] = 1u | (matches ? 2u : 0u); + return matches; + } +} + void overlay_loader_static_match_stats(uint64_t *rehashes, uint64_t *crc_misses, uint64_t *gen_fastpath) { From 8ebfdb4a3dfcb23467a785f03fa7912f2a56aeb6 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:06:49 -0700 Subject: [PATCH 22/27] =?UTF-8?q?perf:=20profile-guided=20round=201=20?= =?UTF-8?q?=E2=80=94=20tex=5Fpack=20prim=20bookkeeping,=20xlate=20scan,=20?= =?UTF-8?q?PGXP=20ALU=20gating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real attribution (in-process 1 kHz IP sampler, 75k samples at oc900, play build). Top sinks and their fixes: - tex_pack_on_textured_prim (7.4%): per-prim mutex + CLUT hash + linear scan over up to 8192 uploads — entirely matched-set/dump census, a debug/authoring surface with no reader in play builds (substitution lives in tex_pack_lookup_replacement). Gated out of play builds unless dumping. Confirmed gone in the follow-up profile. - text_xlate_on_dispatch (4.2% -> 0.8%): the a0..a3 record scan serves capture (off in release) and STRING substitution; a fixes-only load (vram patches / glyph labels) has an empty string table, so the scan could never act. Early-out on empty table; the throttled patch blocks still run. - psx_pgxp_alu/muldiv (3.6%): ALU/MULDIV shadows feed only tier-2 cpu_mode and the correction consumers' chains; the exact-sign NCLIP arm is LOAD/STORE/COP2 only. Hook bodies now gate on (cpu_mode || full_hooks) — full_hooks maintained by gpu_pgxp_rederive_enable — and the remaining per-instruction CALL cost is removed by an inline g_pgxp_alu_armed check in the PGXP_ALU/PGXP_MULDIV macros themselves. Correctness: a stale shadow from the gated period is dropped by pv_validate at every consumer (the engine's single safety invariant, verified at the MTC2 path). Measured effect of the round at oc900 (before the macro gate): sustained 0.73-0.80x -> 0.87-0.91x; streaming-window dip 0.35 -> 0.50; post-window touching 1.00x. Fresh profile after: xlate and tex_pack prim hook out of the top-15. Co-Authored-By: Claude Fable 5 --- runtime/include/pgxp.h | 6 ++++++ runtime/include/pgxp_hooks.h | 11 +++++++++-- runtime/src/gpu.c | 5 +++++ runtime/src/main.cpp | 16 +++++----------- runtime/src/pgxp.cpp | 27 ++++++++++++++++++++++++++- runtime/src/tex_pack.cpp | 8 ++++++++ runtime/src/text_xlate.cpp | 12 ++++++++++++ 7 files changed, 71 insertions(+), 14 deletions(-) diff --git a/runtime/include/pgxp.h b/runtime/include/pgxp.h index f41ddb61..b33b2e54 100644 --- a/runtime/include/pgxp.h +++ b/runtime/include/pgxp.h @@ -34,6 +34,12 @@ int pgxp_enabled(void); void pgxp_set_cpu_mode(int enabled); int pgxp_cpu_mode(void); +/* ALU/MULDIV hook bodies run only when tier-2 cpu_mode OR this flag is set + * (texture/geometry correction dataflow chains). NCLIP-only arms leave it 0: + * the SXY chain is LOAD/STORE/COP2. Stale shadows from the gated period are + * dropped by pv_validate at every consumer (the single safety invariant). */ +void pgxp_set_full_hooks(int on); + /* Reject a precise position whose sub-pixel offset from the native integer * position exceeds this many pixels. < 0 disables the clamp (default). */ void pgxp_set_tolerance(float pixels); diff --git a/runtime/include/pgxp_hooks.h b/runtime/include/pgxp_hooks.h index 257525f1..a4d91a41 100644 --- a/runtime/include/pgxp_hooks.h +++ b/runtime/include/pgxp_hooks.h @@ -73,10 +73,17 @@ typedef struct PGXPHooks { } PGXPHooks; #if defined(PSX_PGXP) && PSX_PGXP +/* ALU/MULDIV hooks matter only under tier-2 cpu_mode or the correction + * consumers' dataflow chains; an NCLIP-only arm leaves them idle. Their hook + * bodies already early-out, but at ~100M ALU/s the CALL ITSELF was 3.4% of + * the emu thread (WipEout 3 120 Hz profile) — gate inline on a flag pgxp.cpp + * maintains (g_pgxp_alu_armed = active && (cpu_mode || full_hooks)). LOAD/ + * STORE/COP2 stay direct: they carry the SXY provenance chain. */ +extern int g_pgxp_alu_armed; #define PGXP_LOAD(instr, addr, val) psx_pgxp_load(cpu, (instr), (addr), (val)) #define PGXP_STORE(instr, addr, val) psx_pgxp_store(cpu, (instr), (addr), (val)) -#define PGXP_ALU(instr, res, s1, s2) psx_pgxp_alu(cpu, (instr), (res), (s1), (s2)) -#define PGXP_MULDIV(instr, hi, lo, s1, s2) psx_pgxp_muldiv(cpu, (instr), (hi), (lo), (s1), (s2)) +#define PGXP_ALU(instr, res, s1, s2) do { if (g_pgxp_alu_armed) psx_pgxp_alu(cpu, (instr), (res), (s1), (s2)); } while (0) +#define PGXP_MULDIV(instr, hi, lo, s1, s2) do { if (g_pgxp_alu_armed) psx_pgxp_muldiv(cpu, (instr), (hi), (lo), (s1), (s2)); } while (0) #define PGXP_COP2(instr, val, addr) psx_pgxp_cop2(cpu, (instr), (val), (addr)) #else #define PGXP_LOAD(instr, addr, val) ((void)0) diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index d3a7ab12..b78c27b8 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -3400,6 +3400,11 @@ void gpu_pgxp_rederive_enable(void) { pgxp_set_enabled(s_texture_correction_enabled || gte_geometry_correction_enabled() || ws_precise_nclip_cfg); + /* ALU/MULDIV hooks feed only the correction consumers' dataflow chains + * (and tier-2 cpu_mode, gated inside pgxp.cpp); an NCLIP-only arm skips + * their per-instruction bodies (LOAD/STORE/COP2 carry the SXY chain). */ + pgxp_set_full_hooks(s_texture_correction_enabled || + gte_geometry_correction_enabled()); } void gpu_texture_correction_set(int enabled) { diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index e1a01f29..94f304a3 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -130,9 +130,6 @@ extern "C" void psx_game_codegen_forward_if_built(int argc, char** argv); #include #include #include -#ifdef _WIN32 -#include /* GetModuleInformation (self-profile sampler) */ -#endif #ifdef _WIN32 #ifndef WIN32_LEAN_AND_MEAN @@ -142,6 +139,7 @@ extern "C" void psx_game_codegen_forward_if_built(int argc, char** argv); #include #include #include +#include /* GetModuleInformation (self-profile sampler) */ #include #else #include @@ -11118,16 +11116,13 @@ static DWORD WINAPI self_profile_thread(LPVOID param) { FILE *f = fopen("psx_self_profile.txt", "wb"); if (f) { fprintf(f, "samples=%d external=%d (exe base offsets, 64B buckets; " - "addr2line VA = 0x140000000 + offset) -", count, external); + "addr2line VA = 0x140000000 + offset)\n", count, external); for (size_t i = 0; i < top.size() && i < 120; i++) - fprintf(f, "%6d 0x%llx -", top[i].first, + fprintf(f, "%6d 0x%llx\n", top[i].first, (unsigned long long)top[i].second); fclose(f); } - fprintf(stdout, "psxrecomp: [self-profile] %d samples (%d external) -> psx_self_profile.txt -", + fprintf(stdout, "psxrecomp: [self-profile] %d samples (%d external) -> psx_self_profile.txt\n", count, external); fflush(stdout); return 0; @@ -12049,8 +12044,7 @@ int main(int argc, char** argv) { 0, FALSE, DUPLICATE_SAME_ACCESS); spa.seconds = secs; CreateThread(NULL, 0, self_profile_thread, &spa, 0, NULL); - std::fprintf(stdout, "psxrecomp: [self-profile] armed for %d s -", secs); + std::fprintf(stdout, "psxrecomp: [self-profile] armed for %d s\n", secs); } } #endif diff --git a/runtime/src/pgxp.cpp b/runtime/src/pgxp.cpp index 80932146..9a634a89 100644 --- a/runtime/src/pgxp.cpp +++ b/runtime/src/pgxp.cpp @@ -81,8 +81,27 @@ static int s_deferred_invalidate = 0; /* Single hot-path gate for every hook. */ static int g_pgxp_active = 0; +/* ALU/MULDIV hooks are needed only by tier-2 CPU propagation (s_cpu_mode) and + * by the correction consumers' dataflow chains (SLL/SRL z-ride, OR-repack — + * texture/geometry correction). The exact-sign NCLIP chain is LOAD/STORE/COP2 + * only (swc2 -> RAM -> lw -> mtc2), so an NCLIP-only arm (corrections off, + * cpu_mode off) can skip the per-ALU-instruction call bodies entirely — + * measured at 3.6% of the emu thread on the WipEout 3 120 Hz profile. + * gpu_pgxp_rederive_enable maintains the flag. */ +static int g_pgxp_full_hooks = 0; +/* Inline gate read by the PGXP_ALU/PGXP_MULDIV macros in every generated TU + * (pgxp_hooks.h): skip even the hook CALL when nothing consumes ALU shadows. */ +extern "C" { int g_pgxp_alu_armed = 0; } +static void recompute_alu_armed(void) { + g_pgxp_alu_armed = (g_pgxp_active && (g_pgxp_full_hooks || s_cpu_mode)) ? 1 : 0; +} +extern "C" void pgxp_set_full_hooks(int on) { + g_pgxp_full_hooks = on ? 1 : 0; + recompute_alu_armed(); +} static inline void recompute_active(void) { g_pgxp_active = (s_enabled && s_suppress == 0) ? 1 : 0; + recompute_alu_armed(); } static PGXPStats s_stats; @@ -149,7 +168,10 @@ extern "C" void pgxp_set_enabled(int enabled) { extern "C" int pgxp_enabled(void) { return s_enabled; } -extern "C" void pgxp_set_cpu_mode(int enabled) { s_cpu_mode = enabled ? 1 : 0; } +extern "C" void pgxp_set_cpu_mode(int enabled) { + s_cpu_mode = enabled ? 1 : 0; + recompute_alu_armed(); +} extern "C" int pgxp_cpu_mode(void) { return s_cpu_mode; } extern "C" void pgxp_set_tolerance(float pixels) { s_tolerance = pixels; } @@ -470,6 +492,7 @@ extern "C" void psx_pgxp_alu(struct CPUState *cpu, uint32_t instr, uint32_t result, uint32_t s1, uint32_t s2) { (void)cpu; if (!g_pgxp_active) return; + if (!g_pgxp_full_hooks && !s_cpu_mode) return; uint32_t op = f_op(instr); uint32_t dst_reg; @@ -705,6 +728,8 @@ extern "C" void psx_pgxp_muldiv(struct CPUState *cpu, uint32_t instr, uint32_t s1, uint32_t s2) { (void)cpu; (void)instr; (void)s1; (void)s2; if (!g_pgxp_active) return; + if (!g_pgxp_full_hooks && !s_cpu_mode) return; /* see psx_pgxp_alu gate; + * a stale HI/LO/GPR shadow is dropped by pv_validate at any consumer */ /* Products/quotients of screen coordinates are not screen coordinates: * record the results as known-but-imprecise so MFHI/MFLO stay honest. */ pv_reset(&s_gpr[PGXP_REG_HI], hi); diff --git a/runtime/src/tex_pack.cpp b/runtime/src/tex_pack.cpp index 78bc0103..938e6bd4 100644 --- a/runtime/src/tex_pack.cpp +++ b/runtime/src/tex_pack.cpp @@ -990,6 +990,14 @@ extern "C" void tex_pack_note_armed(unsigned long long id, extern "C" void tex_pack_on_textured_prim(const int lim[4], uint16_t clut_x, uint16_t clut_y, uint16_t texpage) { if (!tex_pack_active() || !lim) return; +#ifdef PSX_NO_DEBUG_TOOLS + /* Play build: everything this hook computes — the matched-key set and the + * dump/census bookkeeping — is a debug-server / authoring surface with no + * reader (substitution itself lives in tex_pack_lookup_replacement). The + * per-prim mutex + CLUT hash + linear scan over up to 8192 uploads cost + * 7.4% of the emu thread on the WipEout 3 120 Hz profile. */ + if (!g.dump_on) return; +#endif int depth = (texpage >> 7) & 3; if (depth > 2) depth = 2; diff --git a/runtime/src/text_xlate.cpp b/runtime/src/text_xlate.cpp index 80b47d4c..c1fc17fe 100644 --- a/runtime/src/text_xlate.cpp +++ b/runtime/src/text_xlate.cpp @@ -419,6 +419,7 @@ std::atomic g_msg_sep_pending{0}; // unpatched coun std::mutex g_mtx; std::atomic g_apply_armed{false}; // table non-empty AND language enabled +std::atomic g_string_table_nonempty{false}; // any STRING entries (not vram/glyph fixes) /* The capture inventory is an AUTHORING tool: it ingests every source string the * game draws so a translator can enumerate them. It is not needed to APPLY a * shipped translation (that is g_apply_armed, decided by the loaded tables). @@ -607,6 +608,8 @@ void load_tables_locked() { !g_vram_patches.empty() || !g_msg_inplace.empty() || !g_msg_seps.empty()), std::memory_order_relaxed); + g_string_table_nonempty.store(!lang_off && !g_table.empty(), + std::memory_order_relaxed); // Mark inventory records that now have a translation. for (auto& kv : g_inv) kv.second.translated = (g_table.find(kv.first) != g_table.end()); @@ -864,6 +867,15 @@ extern "C" void text_xlate_on_dispatch(CPUState* cpu, uint32_t target) { msg_seps_patch_locked(ram); } + /* The a0..a3 record scan below serves CAPTURE (inventory) and STRING + * substitution (g_table). A fixes-only load (vram patches / glyph labels / + * in-place messages — all handled by the throttled blocks above) has an + * EMPTY string table, and with capture off the scan can never do anything + * — yet it cost 4.2% of the emu thread on the WipEout 3 120 Hz profile + * (4x per dispatch: RAM-range check, first-byte load, record read). */ + if (!cap && !g_string_table_nonempty.load(std::memory_order_relaxed)) + return; + const uint32_t sp = cpu->gpr[29]; // Scan the argument registers a0..a3 for source-text pointers. KV-gated // apply means swapping only the reg that pointed at a KNOWN record — a From 8ec702dae73e6f23a69827b5bcf893da512c6b49 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:14:17 -0700 Subject: [PATCH 23/27] runtime: adaptive overclock pins guest time to wall; page-granular text-ok memo; tex_pack lookup memo; bezel seam removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADAPTIVE OVERCLOCK: the configured cpu_overclock is now a ceiling. A proportional controller on an EMA of the wall vblank period trades the effective overclock down (floor 200%, PSX_ADAPTIVE_OC env) when the emu thread cannot retire the scaled work, and back up when there is headroom — so the GUEST TIMELINE never dilates. Measured live before: guest time at 0.4x wall in races (CD audio at 0.4x pitch, SIO pad exchange lagging, race clock slow) while per-vblank game logic ran fast on the vblanks that did arrive. After: 0.94-0.98x sustained through scenes that previously dropped to 0.3-0.5x, CDDA at wall rate. Frozen across intentional unpaced windows (turbo loads, HLE boot). - dirty_ram_text_native_ok memo made page-granular: a diverging guarded write now bumps only its page's generation; the memo keys on the sum over a function's own range pages. CD streaming (hundreds of dirty transitions per frame in races) no longer flushes every verdict (was 6.8% of the emu thread with the memo never hitting). - tex_pack_lookup_replacement memo with lazy upload validation: keyed on (uv limits, clut, texpage); kills/repl growth invalidate hard, upload ADDS are checked lazily against the prim rect and CLUT row so continuous race streaming does not self-flush the memo (was 9.3%). - Bezel: seam hairline removed (user request); the near-black gradient and wide falloff stay exactly as before. Co-Authored-By: Claude Fable 5 --- recompiler/src/code_generator.cpp | 8 +++ runtime/src/gpu_gl_renderer.c | 29 +++------- runtime/src/main.cpp | 64 ++++++++++++++++++++++ runtime/src/memory.c | 38 ++++++++++--- runtime/src/tex_pack.cpp | 88 ++++++++++++++++++++++++++++++- 5 files changed, 198 insertions(+), 29 deletions(-) diff --git a/recompiler/src/code_generator.cpp b/recompiler/src/code_generator.cpp index e34eff29..b0b70408 100644 --- a/recompiler/src/code_generator.cpp +++ b/recompiler/src/code_generator.cpp @@ -3387,6 +3387,14 @@ std::string CodeGenerator::build_shared_decls_header(const std::vector 0.003f) { - int t = (int)(min_dim / 720.0f + 0.5f); - if (t < 1) t = 1; - if (lx > 0) - bezel_sdf_rect(lx - t, ly, t, lh, 0.62f, 0.62f, 0.66f, - seam_a, seam_a, view, 1.0f); - if (right_w > 0) - bezel_sdf_rect(right_x, ly, t, lh, 0.62f, 0.62f, 0.66f, - seam_a, seam_a, view, 1.0f); - if (top_h > 0) - bezel_sdf_rect(lx - t, top_y, lw + 2 * t, t, - 0.62f, 0.62f, 0.66f, seam_a, seam_a, view, 1.0f); - if (bot_h > 0) - bezel_sdf_rect(lx - t, ly - t, lw + 2 * t, t, - 0.62f, 0.62f, 0.66f, seam_a, seam_a, view, 1.0f); - } + /* No seam: the user wants the game to meet the bezel pattern with + * no separator hairline (removed 2026-08-19; BEZEL_SEAM_A kept for + * reference). */ + (void)BEZEL_SEAM_A; } glDisable(GL_BLEND); p_glBindVertexArray(0); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 94f304a3..e0e6d5aa 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -6635,6 +6635,70 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { load_transition_note(cdrom_data_read_active(), logical_load_active, turbo_loads_active, load_run_value); + /* Adaptive overclock: pin GUEST TIME to 1.0x wall. The configured + * cpu_overclock is a CEILING; when the emu thread cannot retire that many + * scaled cycles per wall vblank the guest timeline dilates — CD audio, + * SIO pad exchange, and every guest timer slow together while per-vblank + * game logic runs on whatever vblanks do arrive (measured live: CDDA at + * 0.4x with gameplay 1.6x fast). Trading overclock DOWN in heavy scenes + * keeps guest time (and so vblank cadence, CDDA, inputs) at wall rate; + * headroom brings it back up toward the ceiling. Proportional controller + * on an EMA of the wall vblank period, adjusted at most every 8 vblanks, + * frozen during intentional unpaced windows (turbo loads / HLE boot). + * PSX_ADAPTIVE_OC=0 disables; =N sets the floor percent (default 200). */ + { + static int aoc_enabled = -1; + static uint32_t aoc_floor = 200, aoc_ceiling = 0; + static double aoc_ema = 0.0; + static Uint64 aoc_last_pc = 0; + static int aoc_cool = 0; + if (aoc_enabled < 0) { + const char *e = std::getenv("PSX_ADAPTIVE_OC"); + aoc_enabled = 1; + if (e && *e) { + int v = atoi(e); + if (v == 0) aoc_enabled = 0; + else if (v >= 100) aoc_floor = (uint32_t)v; + } + aoc_ceiling = psx_get_cpu_overclock(); + if (aoc_ceiling <= aoc_floor) aoc_enabled = 0; + if (aoc_enabled) + std::fprintf(stdout, + "psxrecomp: adaptive overclock armed (floor %u%%, ceiling %u%%)\n", + aoc_floor, aoc_ceiling); + } + if (aoc_enabled && g_frame_period_ms > 0.0 && !turbo_loads_active && + !psx_netplay_active()) { + Uint64 pc_now = SDL_GetPerformanceCounter(); + if (aoc_last_pc) { + double dt = (double)(pc_now - aoc_last_pc) * 1000.0 / + (double)SDL_GetPerformanceFrequency(); + if (dt > 0.0 && dt < 250.0) { + aoc_ema = (aoc_ema > 0.0) ? aoc_ema * 0.85 + dt * 0.15 : dt; + if (++aoc_cool >= 8 && aoc_ema > 0.0) { + aoc_cool = 0; + double r = aoc_ema / g_frame_period_ms; + uint32_t cur = psx_get_cpu_overclock(); + if (r > 1.04) { + uint32_t n = (uint32_t)((double)cur / r); + if (n < aoc_floor) n = aoc_floor; + if (n < cur) psx_set_cpu_overclock(n); + } else if (r < 0.99 && cur < aoc_ceiling) { + uint32_t n = cur + (aoc_ceiling - cur + 9u) / 10u; + if (n > aoc_ceiling) n = aoc_ceiling; + psx_set_cpu_overclock(n); + } + } + } else { + aoc_ema = 0.0; /* pause/hitch: relearn */ + } + } + aoc_last_pc = pc_now; + } else { + aoc_last_pc = 0; /* frozen window: do not learn across it */ + } + } + /* FMV auto-skip ([video] auto_skip_fmv). A streaming FMV is XA audio + MDEC * video together. Detect "MDEC produced a frame since the last vblank AND XA * is streaming"; a short hold rides out brief inter-frame gaps. The present/ diff --git a/runtime/src/memory.c b/runtime/src/memory.c index fbca1d5e..b2895919 100644 --- a/runtime/src/memory.c +++ b/runtime/src/memory.c @@ -613,6 +613,28 @@ static uint32_t g_text_exact_last_ref = 0; * PSX_TEXT_GUARD_MEMO=0 disables the memo (bisect switch: if a stale-native * class ever appears, this proves or clears the memo in one run). */ static uint32_t g_text_guard_gen = 1u; +/* Page-granular guard generations: bumped only for the PAGE a diverging + * guarded write touched. The native-ok memo keys on the SUM over a function's + * own range pages (plus the global gen for image-registration/bless events), + * so CD streaming into unrelated text pages — hundreds of transitions per + * frame during races — no longer invalidates every memoized verdict in the + * game (measured: psx_game_text_native_ok at 6.8% of the emu thread with the + * memo never hitting). A diverging write to a covered page still invalidates + * exactly the functions on it. */ +static uint32_t text_guard_page_gen[DIRTY_RAM_PAGE_COUNT]; +static inline uint32_t text_guard_ranges_gen(const uint32_t *lo_len_pairs, + uint32_t count) { + uint32_t sum = g_text_guard_gen; + for (uint32_t i = 0; i < count; i++) { + uint32_t phys = lo_len_pairs[i * 2u] & 0x1FFFFFFFu; + uint32_t len = lo_len_pairs[i * 2u + 1u]; + if (len == 0 || phys >= RAM_SIZE || len > RAM_SIZE - phys) continue; + uint32_t fp = phys >> DIRTY_RAM_PAGE_SHIFT; + uint32_t lp = (phys + len - 1u) >> DIRTY_RAM_PAGE_SHIFT; + for (uint32_t pg = fp; pg <= lp; pg++) sum += text_guard_page_gen[pg]; + } + return sum; +} #define TEXT_OK_MEMO_SLOTS 8192u /* power of two */ typedef struct { @@ -671,11 +693,12 @@ static inline void text_guard_note_write(uint32_t phys, uint32_t val, int size) if (memcmp(ref, buf, (size_t)size) != 0) { uint32_t page = phys >> DIRTY_RAM_PAGE_SHIFT; text_modified_bitmap[page >> 5] |= (1u << (page & 31u)); - /* Live text now diverges here: any memoized "native ok" covering this - * address must be re-decided. A store that MATCHES the reference is - * deliberately not invalidated — it can only flip a verdict no->yes, - * and keeping the stale "no" costs interpretation, not correctness. */ - g_text_guard_gen++; + /* Live text now diverges here: any memoized "native ok" covering THIS + * PAGE must be re-decided (page-granular; see text_guard_page_gen). + * A store that MATCHES the reference is deliberately not invalidated — + * it can only flip a verdict no->yes, and keeping the stale "no" costs + * interpretation, not correctness. */ + text_guard_page_gen[page]++; } } @@ -735,7 +758,8 @@ int dirty_ram_text_native_ok_ranges_from(const uint32_t *lo_len_pairs, if (text_ok_memo_enabled()) { memo = &s_text_ok_memo[text_ok_memo_slot(lo_len_pairs, exec_pc)]; if (memo->key == lo_len_pairs && memo->exec_pc == exec_pc && - memo->count == count && memo->gen == g_text_guard_gen) + memo->count == count && + memo->gen == text_guard_ranges_gen(lo_len_pairs, count)) return memo->ok; } @@ -784,7 +808,7 @@ done: memo->key = lo_len_pairs; memo->exec_pc = exec_pc; memo->count = count; - memo->gen = g_text_guard_gen; + memo->gen = text_guard_ranges_gen(lo_len_pairs, count); memo->ok = ok; } return ok; diff --git a/runtime/src/tex_pack.cpp b/runtime/src/tex_pack.cpp index 938e6bd4..4419f22c 100644 --- a/runtime/src/tex_pack.cpp +++ b/runtime/src/tex_pack.cpp @@ -808,14 +808,100 @@ State::Repl *repl_get_locked(uint64_t key, const Upload &up, int shift, return ins.pixels.empty() ? nullptr : &ins; } +static int tex_pack_lookup_replacement_uncached(const int lim[4], uint16_t clut_x, + uint16_t clut_y, uint16_t texpage, + TexPackRepl *out); + +/* Per-prim lookup memo. The uncached path pays a CLUT hash plus a newest-first + * containment scan over every live upload (up to 8192) for EVERY textured + * primitive — measured at 9.3% of the emu thread during WipEout 3 races. The + * decision is fully determined by (uv-limits, clut, texpage) and the upload/ + * decode state, so key on those and an epoch that changes whenever any input + * can: uploads added (n_uploads — CLUT re-uploads included), uploads removed + * (n_kills), savestate revalidation (n_restore_kept), and repl-cache growth + * (repl.size() — a realloc moves the Repl objects the returned gl_handle/ + * pixels pointers alias, so any new decode must flush the memo). */ +struct TexLookupMemo { + int l0, l1, l2, l3; + uint16_t cx, cy, tp; + uint8_t valid; + int ret; + /* Snapshot of the mutation counters at store time. Kills and repl-cache + * growth invalidate hard (indices/pointers move). Upload ADDS are checked + * LAZILY on hit: only uploads appended after `uploads_mark` are scanned, + * and the entry survives unless one of them touches this prim's sampled + * rect or its CLUT row — races upload continuously, and a global epoch + * self-flushed the memo exactly when it was needed most. */ + uint64_t kills_mark, repl_mark, restore_mark; + size_t uploads_mark; + int rx, ry, rw, rh; /* sampled VRAM rect at store time */ + int clut_w; /* CLUT row width in halfwords (0 = 15bpp) */ + TexPackRepl out; +}; +static TexLookupMemo s_lookup_memo[8192]; + extern "C" int tex_pack_lookup_replacement(const int lim[4], uint16_t clut_x, uint16_t clut_y, uint16_t texpage, TexPackRepl *out) { if (!out) return 0; if (!tex_pack_active() || !lim) return 0; - std::lock_guard lk(g.mu); if (!g.replace_on || !g.repl_apply || g.known.empty()) return 0; + const uint32_t h = ((uint32_t)lim[0] * 73856093u) ^ + ((uint32_t)lim[1] * 19349663u) ^ + ((uint32_t)lim[2] * 83492791u) ^ + ((uint32_t)lim[3] * 2654435761u) ^ + ((uint32_t)texpage << 16) ^ + ((uint32_t)clut_y << 8) ^ (uint32_t)clut_x; + TexLookupMemo &m = s_lookup_memo[h & 8191u]; + if (m.valid && + m.l0 == lim[0] && m.l1 == lim[1] && m.l2 == lim[2] && m.l3 == lim[3] && + m.cx == clut_x && m.cy == clut_y && m.tp == texpage && + m.kills_mark == g.n_kills && m.restore_mark == g.n_restore_kept && + m.repl_mark == (uint64_t)g.repl.size() && + m.uploads_mark <= g.uploads.size()) { + int fresh_touches = 0; + for (size_t i = m.uploads_mark; i < g.uploads.size(); i++) { + const Upload &up = g.uploads[i]; + if (rects_overlap(up.x, up.y, up.w, up.h, m.rx, m.ry, m.rw, m.rh) || + (m.clut_w && + rects_overlap(up.x, up.y, up.w, up.h, + (int)m.cx, (int)m.cy, m.clut_w, 1))) { + fresh_touches = 1; + break; + } + } + if (!fresh_touches) { + m.uploads_mark = g.uploads.size(); /* re-anchor past clean adds */ + if (m.ret) *out = m.out; + return m.ret; + } + } + int ret = tex_pack_lookup_replacement_uncached(lim, clut_x, clut_y, + texpage, out); + { + int depth = (texpage >> 7) & 3; + if (depth > 2) depth = 2; + const int shift = (depth == 0) ? 2 : (depth == 1) ? 1 : 0; + const int base_x = (texpage & 0xF) * 64; + const int base_y = ((texpage >> 4) & 1) * 256; + sampled_vram_rect(lim, base_x, base_y, shift, &m.rx, &m.ry, &m.rw, &m.rh); + m.clut_w = (depth == 0) ? 16 : (depth == 1) ? 256 : 0; + } + m.l0 = lim[0]; m.l1 = lim[1]; m.l2 = lim[2]; m.l3 = lim[3]; + m.cx = clut_x; m.cy = clut_y; m.tp = texpage; + m.kills_mark = g.n_kills; m.restore_mark = g.n_restore_kept; + m.repl_mark = (uint64_t)g.repl.size(); + m.uploads_mark = g.uploads.size(); + m.ret = ret; + if (ret) m.out = *out; + m.valid = 1; + return ret; +} + +static int tex_pack_lookup_replacement_uncached(const int lim[4], uint16_t clut_x, + uint16_t clut_y, uint16_t texpage, + TexPackRepl *out) { int depth = (texpage >> 7) & 3; if (depth > 2) depth = 2; From 42109c580af1467fbaea5549f946abf9b36dc871 Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:32:24 -0700 Subject: [PATCH 24/27] present/audio: menu 4:3 with natural bars; SDL3 stream-rate fix; adaptive overclock removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adaptive overclock REMOVED (user decision, and correctly so): trading overclock down preserved vblank cadence but starved the game's render loop (~30-50 real frames/s under a 110+ vblank counter) and modulated the guest-time ratio continuously — an audible CDDA warble. The DuckStation-standard model stands: static user overclock; a too-slow host slows everything uniformly and audio underruns, never pitch-shifts. - SDL3 audio: psx_sdl_audio_open reported the DEVICE format instead of the stream's INPUT spec. SDL3 converts fed data FROM the requested spec; telling the DRC host_rate=48000 while SDL consumed it as 44100 was a constant ~0.92x pitch-down plus perpetual ring overfill (overflow_drops clicks) on every SDL3 build. Report the input spec — the DRC stays the only resampler. (Audio-clocking audit confirmed the bridge already implements the DuckStation contract: guest-clocked production at 768 cycles/sample, device-rate pull, +-0.5% trim, hold+fade on underrun; production stages must not be touched — netplay/savestate invariants at main.cpp:3009/3071.) - Present-aspect scene switch: with the widescreen mod engaged, menus/2D (scene gate inactive, 45-frame hysteresis in gpu.c) present at the game's own 4:3 so its NATURAL black bars stay, framed by the bezels; gameplay presents at the mod aspect. New export psx_ws_scene_wide_active(). Co-Authored-By: Claude Fable 5 --- runtime/src/gpu.c | 4 ++ runtime/src/main.cpp | 80 ++++++++--------------------------- runtime/src/psx_sdl_audio.cpp | 15 ++++--- 3 files changed, 31 insertions(+), 68 deletions(-) diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index b78c27b8..a0409b03 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -368,6 +368,10 @@ int gpu_ws_present_native_43(void) { /* Squash applies only when configured AND the frame is being stretched. */ static int ws_active(void) { return ws_configured() && !gpu_ws_present_native_43(); } +/* Exported scene gate for the present-aspect switch (main.cpp): menus/2D and + * FMV present at the game's own 4:3 (its natural bars intact, bezels around); + * gameplay presents at the mod aspect. */ +int psx_ws_scene_wide_active(void) { return ws_active() && ws_game_mode(); } /* ----- Native-wide (mode 2) ------------------------------------------------ * Render the wider FOV into an actually wider frame instead of squashing it. diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index e0e6d5aa..d29cf5d6 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -6635,69 +6635,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { load_transition_note(cdrom_data_read_active(), logical_load_active, turbo_loads_active, load_run_value); - /* Adaptive overclock: pin GUEST TIME to 1.0x wall. The configured - * cpu_overclock is a CEILING; when the emu thread cannot retire that many - * scaled cycles per wall vblank the guest timeline dilates — CD audio, - * SIO pad exchange, and every guest timer slow together while per-vblank - * game logic runs on whatever vblanks do arrive (measured live: CDDA at - * 0.4x with gameplay 1.6x fast). Trading overclock DOWN in heavy scenes - * keeps guest time (and so vblank cadence, CDDA, inputs) at wall rate; - * headroom brings it back up toward the ceiling. Proportional controller - * on an EMA of the wall vblank period, adjusted at most every 8 vblanks, - * frozen during intentional unpaced windows (turbo loads / HLE boot). - * PSX_ADAPTIVE_OC=0 disables; =N sets the floor percent (default 200). */ - { - static int aoc_enabled = -1; - static uint32_t aoc_floor = 200, aoc_ceiling = 0; - static double aoc_ema = 0.0; - static Uint64 aoc_last_pc = 0; - static int aoc_cool = 0; - if (aoc_enabled < 0) { - const char *e = std::getenv("PSX_ADAPTIVE_OC"); - aoc_enabled = 1; - if (e && *e) { - int v = atoi(e); - if (v == 0) aoc_enabled = 0; - else if (v >= 100) aoc_floor = (uint32_t)v; - } - aoc_ceiling = psx_get_cpu_overclock(); - if (aoc_ceiling <= aoc_floor) aoc_enabled = 0; - if (aoc_enabled) - std::fprintf(stdout, - "psxrecomp: adaptive overclock armed (floor %u%%, ceiling %u%%)\n", - aoc_floor, aoc_ceiling); - } - if (aoc_enabled && g_frame_period_ms > 0.0 && !turbo_loads_active && - !psx_netplay_active()) { - Uint64 pc_now = SDL_GetPerformanceCounter(); - if (aoc_last_pc) { - double dt = (double)(pc_now - aoc_last_pc) * 1000.0 / - (double)SDL_GetPerformanceFrequency(); - if (dt > 0.0 && dt < 250.0) { - aoc_ema = (aoc_ema > 0.0) ? aoc_ema * 0.85 + dt * 0.15 : dt; - if (++aoc_cool >= 8 && aoc_ema > 0.0) { - aoc_cool = 0; - double r = aoc_ema / g_frame_period_ms; - uint32_t cur = psx_get_cpu_overclock(); - if (r > 1.04) { - uint32_t n = (uint32_t)((double)cur / r); - if (n < aoc_floor) n = aoc_floor; - if (n < cur) psx_set_cpu_overclock(n); - } else if (r < 0.99 && cur < aoc_ceiling) { - uint32_t n = cur + (aoc_ceiling - cur + 9u) / 10u; - if (n > aoc_ceiling) n = aoc_ceiling; - psx_set_cpu_overclock(n); - } - } - } else { - aoc_ema = 0.0; /* pause/hitch: relearn */ - } - } - aoc_last_pc = pc_now; - } else { - aoc_last_pc = 0; /* frozen window: do not learn across it */ - } - } + /* FMV auto-skip ([video] auto_skip_fmv). A streaming FMV is XA audio + MDEC * video together. Detect "MDEC produced a frame since the last vblank AND XA @@ -6968,6 +6906,22 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { refresh_widescreen_projection(); } + /* Present-aspect scene switch: menus/2D (widescreen scene gate inactive) + * present at the game's own 4:3 so its NATURAL black bars stay, framed by + * the bezels; gameplay presents at the mod aspect. The gpu.c gate carries + * the 45-frame hysteresis and the bezel fade covers the transition. */ + if (g_ws_engaged) { + extern int psx_ws_scene_wide_active(void); + static int s_last_present_wide = -1; + int wide_now = psx_ws_scene_wide_active() ? 1 : 0; + if (wide_now != s_last_present_wide) { + s_last_present_wide = wide_now; + gl_renderer_set_display_aspect( + wide_now ? g_video_aspect_num : 4, + wide_now ? g_video_aspect_den : 3); + } + } + /* Rollback resim (§33/§47): short catch-up keeps hold-last; long catch-up * periodically presents live Replay VRAM so the display shows progress * while sim stays uncapped. TipHold invent-cap stall uses hold-last from diff --git a/runtime/src/psx_sdl_audio.cpp b/runtime/src/psx_sdl_audio.cpp index 03c64590..df2f818f 100644 --- a/runtime/src/psx_sdl_audio.cpp +++ b/runtime/src/psx_sdl_audio.cpp @@ -46,13 +46,18 @@ extern "C" SDL_AudioDeviceID psx_sdl_audio_open( } const SDL_AudioDeviceID device = SDL_GetAudioStreamDevice(s_audio_stream); - SDL_AudioSpec obtained = requested; - (void)SDL_GetAudioDeviceFormat(device, &obtained, nullptr); if (have) { + /* Report the STREAM'S INPUT spec, not the device format: SDL3 + * converts whatever we feed FROM `requested` TO the device rate + * internally. Reporting the device rate here made the DRC render + * at (typically) 48 kHz intent while SDL re-resampled it as + * 44.1 kHz — a constant ~0.92x pitch-down on every SDL3 build plus + * a perpetual ring overfill drained by overflow_drops clicks. The + * DRC is the only resampler; SDL3 must see honest 44.1 kHz input. */ std::memset(have, 0, sizeof(*have)); - have->freq = obtained.freq; - have->format = obtained.format; - have->channels = obtained.channels; + have->freq = requested.freq; + have->format = requested.format; + have->channels = requested.channels; have->samples = want->samples; } From 17a96507b46ce586534be41fdee75c7ab977c48d Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:39:50 -0700 Subject: [PATCH 25/27] main: extern C linkage for psx_ws_scene_wide_active Co-Authored-By: Claude Fable 5 --- runtime/src/main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index d29cf5d6..55a41e3c 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -6911,7 +6911,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * the bezels; gameplay presents at the mod aspect. The gpu.c gate carries * the 45-frame hysteresis and the bezel fade covers the transition. */ if (g_ws_engaged) { - extern int psx_ws_scene_wide_active(void); + extern "C" int psx_ws_scene_wide_active(void); static int s_last_present_wide = -1; int wide_now = psx_ws_scene_wide_active() ? 1 : 0; if (wide_now != s_last_present_wide) { From 1c9869cfa2873ad76da1d92a8a81c7e50e86ec5e Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:49:09 -0700 Subject: [PATCH 26/27] main: file-scope extern C decl for psx_ws_scene_wide_active Co-Authored-By: Claude Fable 5 --- runtime/src/main.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 55a41e3c..0ab1b2c7 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1664,6 +1664,8 @@ static void netplay_local_viewport_projection_aspect( static int g_ws_projection_num = 4; static int g_ws_projection_den = 3; static int g_ws_projection_mode = -1; +extern "C" int psx_ws_scene_wide_active(void); /* gpu.c: ws scene gate */ + static void refresh_widescreen_projection() { if (!g_ws_engaged) return; @@ -6911,7 +6913,6 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * the bezels; gameplay presents at the mod aspect. The gpu.c gate carries * the 45-frame hysteresis and the bezel fade covers the transition. */ if (g_ws_engaged) { - extern "C" int psx_ws_scene_wide_active(void); static int s_last_present_wide = -1; int wide_now = psx_ws_scene_wide_active() ? 1 : 0; if (wide_now != s_last_present_wide) { From 4f0b07b1491ecca25b48a17480e4bccbc36e8b2c Mon Sep 17 00:00:00 2001 From: tetrisgm <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:15:24 -0700 Subject: [PATCH 27/27] main/gpu: dual speed readout (vblank rate + internal render fps); menu aspect switch reverted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Internal rendered-frame counter: GP1(05h) display-origin CHANGES (the game finishing a frame and flipping buffers — the standard emulator "internal FPS"). Shown beside the vblank rate in the title, OSD, and [FPS] telemetry: "VBlank N/s X.XXx | Render M fps". The two diverge under load and users could not tell which one they were seeing (measured: races render exactly one frame per vblank; menus idle far below their vblank rate). - The per-scene 4:3 menu present (previous commit) is reverted per user direction: uniform mod aspect everywhere; the game's own image is never re-shaped between menu and gameplay. Co-Authored-By: Claude Fable 5 --- runtime/src/gpu.c | 13 +++++++++-- runtime/src/main.cpp | 53 ++++++++++++++++++++++---------------------- 2 files changed, 37 insertions(+), 29 deletions(-) diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index a0409b03..08541de3 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -5841,12 +5841,21 @@ static void gp1_dma_direction(uint32_t val) { dma_direction = val & 3; } +/* Internal rendered-frame counter: the game finishing a frame = it flips the + * display origin to the other buffer (GP1(05h) with a CHANGED address). This + * is the "internal FPS" every mainstream emulator reports, distinct from the + * vblank rate — under load the two diverge and both matter. */ +static uint64_t s_display_flips = 0; +uint64_t psx_gpu_display_flip_count(void) { return s_display_flips; } static void gp1_display_area_start(uint32_t val) { /* GP1(05h): Start of display area in VRAM * bits 0-9: X (in halfwords, 0-1023) * bits 10-18: Y (0-511) */ - display_area_x = val & 0x3FF; - display_area_y = (val >> 10) & 0x1FF; + uint32_t nx = val & 0x3FF; + uint32_t ny = (val >> 10) & 0x1FF; + if (nx != display_area_x || ny != display_area_y) s_display_flips++; + display_area_x = nx; + display_area_y = ny; ws_note_display_base(display_area_x); /* learn the display buffer set (native-wide) */ } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 0ab1b2c7..db43af85 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1665,6 +1665,7 @@ static int g_ws_projection_num = 4; static int g_ws_projection_den = 3; static int g_ws_projection_mode = -1; extern "C" int psx_ws_scene_wide_active(void); /* gpu.c: ws scene gate */ +extern "C" uint64_t psx_gpu_display_flip_count(void); /* gpu.c: internal fps */ static void refresh_widescreen_projection() { if (!g_ws_engaged) return; @@ -6304,6 +6305,14 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { const double seconds = (double)(now - s_fps_last_time) / (double)frequency; const double fps = (double)(s_frame_count - s_fps_last_frame) / seconds; const double speed = fps / psx_crtc_frame_hz(); + /* Internal rendered-frame rate (display-origin flips): the game + * actually finishing frames, as distinct from the vblank rate — + * the two diverge under load and users need both on screen. */ + static uint64_t s_fps_last_flips = 0; + const uint64_t flips_now = psx_gpu_display_flip_count(); + const double render_fps = + (double)(flips_now - s_fps_last_flips) / seconds; + s_fps_last_flips = flips_now; double display_fps = 0.0; if (g_frame_interpolation && g_gl_active) { display_fps = g_frame_interpolation_fps > 0 @@ -6314,11 +6323,13 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { char title[256]; if (display_fps > 0.0) { snprintf(title, sizeof(title), - "%s [Game %.0f fps %.2fx | Display %.0f fps]", - s_fps_base_title.c_str(), fps, speed, display_fps); + "%s [VBlank %.0f/s %.2fx | Render %.0f fps | Display %.0f fps]", + s_fps_base_title.c_str(), fps, speed, render_fps, + display_fps); } else { - snprintf(title, sizeof(title), "%s [Game %.0f fps %.2fx]", - s_fps_base_title.c_str(), fps, speed); + snprintf(title, sizeof(title), + "%s [VBlank %.0f/s %.2fx | Render %.0f fps]", + s_fps_base_title.c_str(), fps, speed, render_fps); } SDL_SetWindowTitle(sdl_window, title); } @@ -6326,11 +6337,12 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { char osd[96]; if (display_fps > 0.0) { snprintf(osd, sizeof(osd), - "Game %.0f FPS %.2fx | Display %.0f FPS", - fps, speed, display_fps); + "VBl %.0f/s %.2fx Render %.0f FPS Disp %.0f", + fps, speed, render_fps, display_fps); } else { - snprintf(osd, sizeof(osd), "Game %.0f FPS %.2fx", - fps, speed); + snprintf(osd, sizeof(osd), + "VBl %.0f/s %.2fx Render %.0f FPS", + fps, speed, render_fps); } host_osd_set_status(osd); } @@ -6353,10 +6365,10 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * than replay%. Pass: post-settle present_gap_p95 ≤ 33ms. */ netplay_present_gap_stats(&gap_p95, &gap_max); std::fprintf(stderr, - "[FPS] game: %.1f fps (%.2fx) | frames: %llu | " + "[FPS] game: %.1f fps (%.2fx) render: %.1f | frames: %llu | " "guest=%.2f ms/f admit=%.2f ms/f replay=%.0f%% " "present_gap_p95=%u ms max=%u ms (n=%llu)\n", - fps, speed, (unsigned long long)s_frame_count, + fps, speed, render_fps, (unsigned long long)s_frame_count, guest_ms, admit_ms, replay_pct, (unsigned)gap_p95, (unsigned)gap_max, (unsigned long long)s_np_timing_frames); @@ -6364,8 +6376,10 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { s_np_guest_ticks = 0; s_np_timing_frames = 0; } else { - std::fprintf(stderr, "[FPS] game: %.1f fps (%.2fx) | frames: %llu\n", - fps, speed, (unsigned long long)s_frame_count); + std::fprintf(stderr, + "[FPS] game: %.1f fps (%.2fx) render: %.1f | frames: %llu\n", + fps, speed, render_fps, + (unsigned long long)s_frame_count); } std::fflush(stderr); @@ -6908,21 +6922,6 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { refresh_widescreen_projection(); } - /* Present-aspect scene switch: menus/2D (widescreen scene gate inactive) - * present at the game's own 4:3 so its NATURAL black bars stay, framed by - * the bezels; gameplay presents at the mod aspect. The gpu.c gate carries - * the 45-frame hysteresis and the bezel fade covers the transition. */ - if (g_ws_engaged) { - static int s_last_present_wide = -1; - int wide_now = psx_ws_scene_wide_active() ? 1 : 0; - if (wide_now != s_last_present_wide) { - s_last_present_wide = wide_now; - gl_renderer_set_display_aspect( - wide_now ? g_video_aspect_num : 4, - wide_now ? g_video_aspect_den : 3); - } - } - /* Rollback resim (§33/§47): short catch-up keeps hold-last; long catch-up * periodically presents live Replay VRAM so the display shows progress * while sim stays uncapped. TipHold invent-cap stall uses hold-last from