diff --git a/GTE_LM_FIX.md b/GTE_LM_FIX.md new file mode 100644 index 00000000..80820eae --- /dev/null +++ b/GTE_LM_FIX.md @@ -0,0 +1,96 @@ +# GTE `lm`/`sf` fix in the depth-cue & lighting family + +**Branch:** `feat/crashbash-fw-fixes` — commit `c4474b9` +**Fixes:** Crash Bash (SCUS-94570) menu-character 30 Hz strobe + in-game +character flattening. Root-caused and verified 2026-07-09. + +## What was wrong + +The runtime GTE hardcoded `lm = 1` (clamp IR results to `[0, 0x7FFF]`) on +every IR write-back in the shared lighting/depth-cue helpers +(`light_transform`, `light_color`, `color_output`, `depth_cue_from_ir`). +Real hardware takes `lm` from **bit 10 of the instruction word** — `lm = 0` +clamps to `[-0x8000, 0x7FFF]` and preserves negative results. + +`depth_cue_from_ir` also ignored `sf` (bit 19) and skipped the hardware's +intermediate step clamp. + +Affected ops: `INTPL`, `DPCS`, `DPCT`, `DPCL`, `NCDS`, `NCDT`, `NCS`, `NCT`, +`NCCS`, `NCCT`, `CDP`, `CC`. (`RTPS`/`RTPT`/`MVMVA`/`OP`/`SQR`/`GPF`/`GPL` +already honored the instruction word and are untouched.) + +## Why it broke Crash Bash + +Crash Bash tweens its 10 fps keyframe vertex animation to the 30 fps render +rate through **INTPL with `lm = 0`** — an off-label, signed-vector use of the +color-interpolation op: + +- pose-A vertex → IR1–3 +- pose-B vertex → far-color regs (FC) via `ctc2` +- blend factor → IR0 +- `INTPL` (`0x0980011`) → lerped vertex read back from IR1–3 (`mfc2`) + +With `lm` forced on, every **negative component** of a tweened vertex clamped +to 0, collapsing the character mesh onto its anchor point on tween frames. +Keyframe frames (no INTPL) stayed healthy, so with alternating draw buffers +exactly one display buffer ever held a healthy pose → the character strobed +at 30 Hz on menus and rendered flattened/half-visible in-game. Beetle and +real hardware honor per-instruction `lm`, so they never showed it. + +## What changed + +- `light_transform` / `light_color` / `color_output`: `lm` read from the + instruction word and passed to every `saturate_ir` write-back. +- `depth_cue_from_ir`: rewritten to the faithful hardware formula — + `base = IR << 12`; `step = lim±0x8000(((FC << 12) − base) >> sf·12)` + (intermediate clamp with `lm` forced OFF, per hardware); `MAC = (base + + IR0·step) >> sf·12`; final `IR = lim(MAC, lm)` with `lm` from the + instruction. MAC overflow flags checked. +- Always-on GTE observability rings (build-dbg debug-server commands): + `gte_ring_dump` (per-RTPS/RTPT inputs+outputs), `gte_intpl_dump` + (per-INTPL inputs+outputs, frame filter + offset paging), + `gte_frame_stats` (per-frame `nproj`/`nsat`/`nflat`/`nintpl`/ + `nintpl_tiny`), `gte_latch_dump` (retained saturated projections). + +## Regression risk + +Low, and skewed toward correctness: + +- Standard libgte **lighting** calls encode `lm = 1`, so games using the + ops as designed see identical clamping to before. +- The two real behavior changes are (a) `lm = 0` callers now keep negative + results — the bug being fixed — and (b) the depth-cue step is now clamped + to ±0x8000 before the IR0 multiply, which only matters when + `|FC − IR| > 0x7FFF` (far color very distant from the lit color). Both + changes move us **toward** hardware/Beetle behavior, so the only regression + class is a game that accidentally depended on our old inaccuracy. +- `sf` handling: all common libgte encodings of these ops use `sf = 1`, + which reproduces the old shift exactly. + +## Cross-game validation — REQUIRED, all titles, before merge + +This branch does not merge to master until every supported title has been +validated against this change. No subset, no sampling. + +**Validated:** + +**Crash Bash (SCUS-94570)** — the fix target. Menu strobe gone; +`nintpl_tiny` 335–352/473 → 0–3; character present in both VRAM +double-buffers and in all frames of a jittered screenshot burst; +menus/animation healthy. + +**Pending (every one required):** + +**Mega Man X6**, **Tomba 2**, **Ape Escape**. Ape Escape is the highest-risk +of the three — a heavy GTE user and the most likely to exercise the +depth-cue step clamp with distant far colors; Tomba 2 and MMX6 exercise the +standard `lm = 1` lighting path that must be bit-identical to before. + +**Per-title procedure (same for all):** + +1. Boot to gameplay through the title's existing verify flow on this branch. +2. Capture `gte_frame_stats` over a representative scene and diff + `nsat`/`nflat` against a pre-fix baseline built from master + this + branch's parent commit — any shift is a stop-and-investigate, not a note. +3. Eyeball lit and fogged/depth-cued scenes side-by-side with the Beetle + oracle; the oracle is ground truth, not the pre-fix recomp. diff --git a/recompiler/src/full_function_emitter.cpp b/recompiler/src/full_function_emitter.cpp index d2c69a31..e92a5ab5 100644 --- a/recompiler/src/full_function_emitter.cpp +++ b/recompiler/src/full_function_emitter.cpp @@ -1453,9 +1453,23 @@ void FullFunctionEmitter::emit_dispatch( out += " * it to the stale shell image. A genuine pre-start shell address\n"; out += " * (game not started) still falls through to normalize(). */\n"; out += " extern int fntrace_is_game_started(void);\n"; + out += " extern int dirty_ram_text_native_ok(uint32_t phys);\n"; out += " int game_shell_overlap = fntrace_is_game_started() &&\n"; out += " game_phys >= 0x00030000u && game_phys <= 0x0005AFFFu;\n"; - out += " if (!found && (game_shell_overlap ||\n"; + out += " /* Shell-window addresses the loaded game EXE image covers with\n"; + out += " * matching bytes are GAME code, not the BIOS shell \xE2\x80\x94 route them to\n"; + out += " * the game path even when the game-start latch (a dispatch to\n"; + out += " * entry_pc) never tripped. Fixes titles whose static text fills\n"; + out += " * 0x30000-0x5AFFF: Crash Bash reaches its entry 0x2E7B0 via compiled\n"; + out += " * internal flow, so `started` never latched and normalize() shadowed\n"; + out += " * real text at 0x3358C to dead shell ROM (unknown-dispatch abort).\n"; + out += " * The native-ok compare is ground truth: pre-game the RAM holds shell\n"; + out += " * bytes (differ from the game image -> not routed here -> shell\n"; + out += " * dispatches); post-load it holds the game EXE (matches -> game path). */\n"; + out += " int game_text_in_shell_window =\n"; + out += " game_phys >= 0x00030000u && game_phys <= 0x0005AFFFu &&\n"; + out += " psx_game_address_in_text(addr) && dirty_ram_text_native_ok(game_phys);\n"; + out += " if (!found && (game_shell_overlap || game_text_in_shell_window ||\n"; out += " (psx_game_address_in_text(addr) && dirty_ram_is_dirty(game_phys)))) {\n"; out += " found = dirty_ram_dispatch(cpu, addr, stop_addr);\n"; out += " }\n"; diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index e6468b11..9880a868 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -4678,6 +4678,30 @@ static void handle_irq_state(int id, const char *json) dma_get_dpcr(), dma_get_dicr()); } +/* vblank_rate: report the ONE cycle-paced VBlank authority's raise/deliver + * counts, the (normally-off) GPUSTAT-poll fallback raise count, and the + * per-frame GP0(E5) draw-offset-Y range/count. Used to confirm the guest is + * receiving exactly 60 VBlanks/s (not the ~96/s the stale poll fallback caused) + * and to probe double-buffer draw-offset alternation. */ +static void handle_vblank_rate(int id, const char *json) +{ + (void)json; + extern uint64_t g_vblank_raise_count, g_vblank_deliver_count; + extern uint64_t g_pollhack_vblank_count; + extern int32_t g_doff_min_last, g_doff_max_last; + extern uint32_t g_doff_cnt_last; + send_fmt("{\"id\":%d,\"ok\":true," + "\"cycle_paced_raise\":%llu," + "\"delivered\":%llu," + "\"pollhack_raise\":%llu," + "\"doff_min\":%d,\"doff_max\":%d,\"doff_cnt\":%u}", + id, + (unsigned long long)g_vblank_raise_count, + (unsigned long long)g_vblank_deliver_count, + (unsigned long long)g_pollhack_vblank_count, + g_doff_min_last, g_doff_max_last, g_doff_cnt_last); +} + static void handle_timers_state(int id, const char *json) { (void)json; @@ -5404,6 +5428,94 @@ static void handle_gte_state(int id, const char *json) send_fmt("%s", buf); } +/* Dump recent GTE RTPS/RTPT projections (inputs + outputs) from the always-on + * GTE ring. {"cmd":"gte_ring_dump","count":N,"newest":1,"frame":F} — frame + * optional (omit or -1 for all). Used to find flattened/degenerate character + * projections and split game-code input bugs from GTE-math bugs. */ +static void handle_gte_ring_dump(int id, const char *json) +{ + extern unsigned long long gte_rtp_ring_total(void); + extern int gte_rtp_ring_dump_json(char *out, int outsz, int max_count, + int newest_first, long frame_filter); + int count = json_get_int(json, "count", 64); + if (count < 1) count = 1; + if (count > 512) count = 512; + int newest = json_get_int(json, "newest", 1) != 0; + long frame = (long)json_get_int(json, "frame", -1); + + size_t BUF_SZ = 256u + (size_t)count * 720u; + char *entries = (char *)malloc(BUF_SZ); + char *reply = (char *)malloc(BUF_SZ + 256u); + if (!entries || !reply) { free(entries); free(reply); send_err(id, "oom"); return; } + int n = gte_rtp_ring_dump_json(entries, (int)BUF_SZ, count, newest, frame); + snprintf(reply, BUF_SZ + 256u, + "{\"id\":%d,\"ok\":true,\"total\":%llu,\"emitted\":%d,\"entries\":[%s]}", + id, gte_rtp_ring_total(), n, entries); + debug_server_send_line(reply); + free(entries); free(reply); +} + +/* INTPL (vertex-lerp) ring: inputs (ir0 blend, in=IR1-3 pose A, fc=pose B) + * and outputs (mac / out=IR1-3 / flag) per op. offset pages through the + * matching entries after the frame filter, so a whole frame is reachable. */ +static void handle_gte_intpl_dump(int id, const char *json) +{ + extern unsigned long long gte_intpl_ring_total(void); + extern int gte_intpl_ring_dump_json(char *out, int outsz, int max_count, + int newest_first, long frame_filter, + int offset); + int count = json_get_int(json, "count", 64); + if (count < 1) count = 1; + if (count > 512) count = 512; + int newest = json_get_int(json, "newest", 1) != 0; + long frame = (long)json_get_int(json, "frame", -1); + int offset = json_get_int(json, "offset", 0); + if (offset < 0) offset = 0; + + size_t BUF_SZ = 256u + (size_t)count * 420u; + char *entries = (char *)malloc(BUF_SZ); + char *reply = (char *)malloc(BUF_SZ + 256u); + if (!entries || !reply) { free(entries); free(reply); send_err(id, "oom"); return; } + int n = gte_intpl_ring_dump_json(entries, (int)BUF_SZ, count, newest, frame, offset); + snprintf(reply, BUF_SZ + 256u, + "{\"id\":%d,\"ok\":true,\"total\":%llu,\"emitted\":%d,\"offset\":%d,\"entries\":[%s]}", + id, gte_intpl_ring_total(), n, offset, entries); + debug_server_send_line(reply); + free(entries); free(reply); +} + +/* Per-frame GTE projection stats (nproj / nsat / nflat) over recent frames — + * shows the alternating flat/normal render pattern. */ +static void handle_gte_frame_stats(int id, const char *json) +{ + extern int gte_fstat_dump_json(char *out, int outsz, int max_frames); + int n = json_get_int(json, "frames", 120); + if (n < 1) n = 1; if (n > 512) n = 512; + size_t BUF = 256u + (size_t)n * 96u; + char *body = (char *)malloc(BUF), *reply = (char *)malloc(BUF + 128u); + if (!body || !reply) { free(body); free(reply); send_err(id, "oom"); return; } + int emitted = gte_fstat_dump_json(body, (int)BUF, n); + snprintf(reply, BUF + 128u, "{\"id\":%d,\"ok\":true,\"emitted\":%d,\"frames\":[%s]}", + id, emitted, body); + debug_server_send_line(reply); free(body); free(reply); +} + +/* Latched degenerate (saturated-output) GTE projections with full inputs. */ +static void handle_gte_latch_dump(int id, const char *json) +{ + extern unsigned long long gte_latch_total(void); + extern int gte_latch_dump_json(char *out, int outsz, int max_count); + int n = json_get_int(json, "count", 64); + if (n < 1) n = 1; if (n > 256) n = 256; + size_t BUF = 256u + (size_t)n * 720u; + char *body = (char *)malloc(BUF), *reply = (char *)malloc(BUF + 128u); + if (!body || !reply) { free(body); free(reply); send_err(id, "oom"); return; } + int emitted = gte_latch_dump_json(body, (int)BUF, n); + snprintf(reply, BUF + 128u, "{\"id\":%d,\"ok\":true,\"latch_total\":%llu,\"emitted\":%d,\"entries\":[%s]}", + id, gte_latch_total(), emitted, body); + debug_server_send_line(reply); free(body); free(reply); +} + static void handle_sio_state(int id, const char *json) { (void)json; @@ -7506,6 +7618,45 @@ static void handle_screenshot_file(int id, const char *json) id, path, w, h); } +/* dump_buffer: dump a raw 512x240 VRAM region starting at display Y = `y` to a + * PNG, regardless of what the game currently displays. Used to inspect BOTH + * double-buffer halves (y=0 and y=256) coherently in one call to see whether a + * strobing character is present in one buffer only. */ +static void handle_dump_buffer(int id, const char *json) +{ + extern void gl_renderer_sync_cpu(void); + gl_renderer_sync_cpu(); + extern void vk_renderer_sync_cpu(void); + vk_renderer_sync_cpu(); + + int y0 = json_get_int(json, "y", 0); + char path[512]; + if (!json_get_str(json, "path", path, sizeof(path))) + strncpy(path, "psx_buffer.png", sizeof(path) - 1); + path[sizeof(path) - 1] = '\0'; + + GpuDisplayInfo di; + di.display_x = 0; di.display_y = (uint32_t)y0; di.depth24 = 0; + di.disabled = 0; di.width = 512; di.height = 240; + + uint32_t w = 512, h = 240; + FILE *f = fopen(path, "wb"); + if (!f) { send_err(id, "cannot open file"); return; } + uint8_t *rgb = (uint8_t *)malloc((size_t)w * h * 3); + if (!rgb) { fclose(f); send_err(id, "alloc failed"); return; } + for (uint32_t y = 0; y < h; y++) + for (uint32_t x = 0; x < w; x++) { + uint8_t r, g, b; + gpu_display_pixel_rgb(&di, x, y, &r, &g, &b); + uint8_t *p = rgb + ((size_t)y * w + x) * 3; + p[0] = r; p[1] = g; p[2] = b; + } + int ok = png_write_rgb(f, rgb, w, h); + free(rgb); fclose(f); + if (!ok) { send_err(id, "png encode failed"); return; } + send_fmt("{\"id\":%d,\"ok\":true,\"path\":\"%s\",\"y\":%d}", id, path, y0); +} + /* wide_shot: capture the NATIVE-WIDE present surface (post-compositor, what the * window actually shows in 16:9) to a PNG — unlike screenshot_file, which dumps * the canonical 320 VRAM region (pre-compositor). Pulls the exact buffer the @@ -11564,6 +11715,7 @@ static const CmdEntry s_commands[] = { { "gl_fbo_peek", handle_gl_fbo_peek }, { "gl_vram_diff", handle_gl_vram_diff }, { "irq_state", handle_irq_state }, + { "vblank_rate", handle_vblank_rate }, { "cycles_to_next_event", handle_cycles_to_next_event }, { "timers_state", handle_timers_state }, { "cdrom_state", handle_cdrom_state }, @@ -11718,6 +11870,7 @@ static const CmdEntry s_commands[] = { { "display_ring_get", handle_display_ring_get }, { "display_ring_aux", handle_display_ring_aux }, { "display_ring_stats", handle_display_ring_stats }, + { "dump_buffer", handle_dump_buffer }, { "wide_full", handle_wide_full }, { "wide_shot", handle_wide_shot }, { "gpu_opcodes", handle_gpu_opcodes }, @@ -11728,6 +11881,10 @@ static const CmdEntry s_commands[] = { { "capture_quads", handle_capture_quads }, { "get_quads", handle_get_quads }, { "gte_state", handle_gte_state }, + { "gte_ring_dump", handle_gte_ring_dump }, + { "gte_intpl_dump", handle_gte_intpl_dump }, + { "gte_frame_stats", handle_gte_frame_stats }, + { "gte_latch_dump", handle_gte_latch_dump }, { "quit", handle_quit }, { "dispatch_stats", handle_dispatch_stats }, { "dispatch_check", handle_dispatch_check }, diff --git a/runtime/src/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 86f42732..24264b01 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -2152,6 +2152,18 @@ static int dirty_ram_dispatch_inner(CPUState* cpu, uint32_t addr, uint32_t stop_ if (_gc) return 1; } clean_game_text_miss = psx_game_address_in_text(addr) ? 1 : 0; + } else if (psx_game_address_in_text(addr)) { + /* RAM at a game-text address diverged from the static EXE image + * (runtime-relocated / overlaid / self-modified code the compiled + * static function no longer reflects). The live RAM is the truth: + * fall through to INTERPRET it here rather than bail (line below) to + * the shell shadow (normalize() -> shell ROM). Crash Bash relocates a + * code page onto 0x30000 (inside the BIOS shell window); without this + * its 0x30FF4 call diverged (native_ok=0) yet was not dirty, so the + * interpreter bailed and normalize() shadowed it to dead shell ROM -> + * unknown-dispatch abort. Marking it a clean game-text miss lets the + * dirty interpreter execute the real RAM bytes. */ + clean_game_text_miss = 1; } #endif diff --git a/runtime/src/fntrace.c b/runtime/src/fntrace.c index c6b1c4eb..b5e8a437 100644 --- a/runtime/src/fntrace.c +++ b/runtime/src/fntrace.c @@ -104,7 +104,22 @@ void fntrace_record(CPUState* cpu, uint32_t target) { } if (!s_game_started && s_game_entry_phys != 0) { - if ((target & 0x1FFFFFFFu) == s_game_entry_phys) { + /* Latch on the entry_pc dispatch (the common case) OR the first dispatch + * to any game-text address whose RAM already holds the loaded game EXE + * image (native-ok). Some titles reach their PS-EXE entry via compiled + * internal flow rather than a dispatcher round-trip, so the entry_pc + * dispatch never arrives and `started` would never latch — leaving the + * shell-window shadow (normalize() maps RAM 0x30000-0x5AFFF -> shell ROM) + * active over REAL game text. Crash Bash: entry 0x2E7B0 is reached + * internally, so its first shell-window call (0x30FF4 / 0x3358C) got + * shadowed to dead shell ROM -> unknown-dispatch abort. The native-ok + * test is safe before the game loads: that RAM still holds shell bytes + * that differ from the game image, so it cannot fire prematurely. */ + extern int psx_game_address_in_text(uint32_t addr); + extern int dirty_ram_text_native_ok(uint32_t phys); + uint32_t _tphys = target & 0x1FFFFFFFu; + if (_tphys == s_game_entry_phys || + (psx_game_address_in_text(target) && dirty_ram_text_native_ok(_tphys))) { s_game_started = 1; /* Establish the clean compiled-image baseline now: the boot EXE is fully * loaded into the game-text region (== compiled image) and no gameplay diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index 9cba56e2..c41b1e37 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -1296,6 +1296,15 @@ static uint32_t draw_area_right, draw_area_bottom; /* Draw offset (set by GP0(E5h)) */ static int32_t draw_offset_x, draw_offset_y; +/* Instrumentation: per-vblank range/count of GP0(E5) draw-offset-Y sets. If a + * single frame sets offsets in BOTH the top (y<128) and bottom (y>=128) buffer + * bands, the game is drawing different parts of the scene into different display + * buffers in the same frame (candidate root of a character-in-one-buffer + * strobe). Snapshotted each vblank into the *_last copies, exposed via debug. */ +int32_t g_doff_min_this = 0x7fffffff, g_doff_max_this = -0x7fffffff; +uint32_t g_doff_cnt_this = 0; +int32_t g_doff_min_last = 0, g_doff_max_last = 0; +uint32_t g_doff_cnt_last = 0; /* Texture window raw value (set by GP0(E2h), readback via GP1(10h)) */ static uint32_t texture_window_value; @@ -1418,6 +1427,7 @@ static int sq_cap_armed; * flips every ~33,868 GPU clocks (one NTSC frame). */ #define GPUSTAT_POLL_VBLANK_THRESHOLD 1000 static uint32_t gpustat_poll_count; +uint64_t g_pollhack_vblank_count = 0; /* instrumentation: poll-fallback VBlank IRQ count */ /* ---- Initialization ---- */ @@ -1499,11 +1509,29 @@ uint32_t gpu_read_gpustat(void) { gpustat_poll_count++; if (gpustat_poll_count >= GPUSTAT_POLL_VBLANK_THRESHOLD) { gpustat_poll_count = 0; - lcf ^= 1; - psx_irq_raise(0, 0); /* IRQ_VBLANK (GPUSTAT-poll fallback path) */ - /* DEQUEUE: VBlank fired via the GPUSTAT-poll fallback path (distinct - * from the cycle-paced VBlank in psx_check_interrupts). */ - event_ring_record_aux(EV_DEQ, (uint8_t)SRC_VBLANK, 0xFFFFFFFFu); + /* The recompiled BIOS + game carry per-block interrupt checks + * (psx_check_interrupts_at) and per-block cycle charging, so tight + * LCF/VSync poll loops DO advance guest cycles and let the ONE + * cycle-paced VBlank authority (interrupts.c) fire on schedule. The old + * "spin forever" premise that justified raising VBlank+LCF from a raw + * GPUSTAT read-count is stale — and it injected ~38 fake VBlanks/s + * (measured on Crash Bash), delivering ~96/s to the game instead of 60, + * over-advancing the game's VSync frame counter and jittering animation + * (character strobe). Firing is now OFF by default; PSX_POLLHACK_VBLANK=1 + * restores the legacy behavior for A/B. */ + static int poll_fire = -1; + if (poll_fire < 0) { + const char* e = getenv("PSX_POLLHACK_VBLANK"); + poll_fire = (e && e[0] == '1') ? 1 : 0; + } + if (poll_fire) { + g_pollhack_vblank_count++; /* count only when actually firing */ + lcf ^= 1; + psx_irq_raise(0, 0); /* IRQ_VBLANK (GPUSTAT-poll fallback path) */ + /* DEQUEUE: VBlank fired via the GPUSTAT-poll fallback path (distinct + * from the cycle-paced VBlank in psx_check_interrupts). */ + event_ring_record_aux(EV_DEQ, (uint8_t)SRC_VBLANK, 0xFFFFFFFFu); + } } uint32_t stat = 0; @@ -1613,6 +1641,13 @@ static uint16_t rgb888_to_rgb555(uint32_t color24) { void gpu_vblank_tick(void) { lcf ^= 1; + /* snapshot per-frame draw-offset-Y range for the strobe instrumentation */ + if (g_doff_cnt_this) { + g_doff_min_last = g_doff_min_this; + g_doff_max_last = g_doff_max_this; + g_doff_cnt_last = g_doff_cnt_this; + } + g_doff_min_this = 0x7fffffff; g_doff_max_this = -0x7fffffff; g_doff_cnt_this = 0; gpustat_poll_count = 0; psx_irq_raise(0, 0); /* IRQ_VBLANK (gpu_vblank_tick) */ if (vblank_callback) vblank_callback(); @@ -2428,6 +2463,9 @@ static void gp0_exec_draw_offset(void) { uint32_t param = gp0_cmd_buf[0] & 0x00FFFFFFu; draw_offset_x = sign_extend(param & 0x7FFu, 11); draw_offset_y = sign_extend((param >> 11) & 0x7FFu, 11); + if (draw_offset_y < g_doff_min_this) g_doff_min_this = draw_offset_y; + if (draw_offset_y > g_doff_max_this) g_doff_max_this = draw_offset_y; + g_doff_cnt_this++; /* Native-wide mirrors framebuffer draws into a separate wide surface (canonical * VRAM stays faithful); the renderer needs the live draw offset to translate * into surface-local coords, so keep gr_set_draw_offset current. */ diff --git a/runtime/src/gte.cpp b/runtime/src/gte.cpp index 314bb0a4..9f9782f7 100644 --- a/runtime/src/gte.cpp +++ b/runtime/src/gte.cpp @@ -2,6 +2,8 @@ #include "cpu_state.h" #include #include +#include +#include namespace PSXRecomp { namespace GTE { @@ -62,8 +64,18 @@ static int32_t gte_divide(uint16_t H, uint16_t SZ3, uint32_t& FLAG) { return (int32_t)result; } +// lm (bit 10) selects the lower clamp bound of every IR write in the +// lighting/depth-cue ops: 0 → -0x8000, 1 → 0. sf (bit 19) selects the +// accumulator shift. Hardware takes BOTH from the instruction word; these +// helpers used to hardcode lm=1, which zeroed every negative component — +// Crash Bash lerps signed VERTICES through INTPL (lm=0), so its characters +// collapsed onto their anchor point on every tween frame (menu strobe bug). +static inline bool gte_instr_lm(uint32_t instr) { return (instr & (1u << 10)) != 0; } +static inline int gte_instr_sf(uint32_t instr) { return (instr & (1u << 19)) ? 12 : 0; } + // Transform a vertex by Light matrix → IR1/IR2/IR3 (step 1 of lighting) -static void light_transform(GTEState* gte, int16_t* V) { +static void light_transform(GTEState* gte, int16_t* V, uint32_t instr) { + const bool lm = gte_instr_lm(instr); int64_t mac1 = ((int64_t)gte->L[0][0] * V[0] + (int64_t)gte->L[0][1] * V[1] + (int64_t)gte->L[0][2] * V[2]) >> 12; @@ -76,13 +88,14 @@ static void light_transform(GTEState* gte, int16_t* V) { gte->MAC1 = static_cast(mac1); gte->MAC2 = static_cast(mac2); gte->MAC3 = static_cast(mac3); - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, true); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, true); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, true); + gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); + gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); + gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); } // Apply light color matrix + background color to IR → IR (step 2 of lighting) -static void light_color(GTEState* gte) { +static void light_color(GTEState* gte, uint32_t instr) { + const bool lm = gte_instr_lm(instr); int64_t mac1 = ((int64_t)gte->BK[0] << 12) + (int64_t)gte->LC[0][0] * gte->IR1 + (int64_t)gte->LC[0][1] * gte->IR2 + @@ -98,13 +111,14 @@ static void light_color(GTEState* gte) { gte->MAC1 = static_cast(mac1 >> 12); gte->MAC2 = static_cast(mac2 >> 12); gte->MAC3 = static_cast(mac3 >> 12); - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, true); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, true); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, true); + gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); + gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); + gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); } // Multiply IR by RGBC color and push to RGB FIFO (step 3 of color output) -static void color_output(GTEState* gte) { +static void color_output(GTEState* gte, uint32_t instr) { + const bool lm = gte_instr_lm(instr); uint8_t r0 = (gte->RGBC >> 0) & 0xFF; uint8_t g0 = (gte->RGBC >> 8) & 0xFF; uint8_t b0 = (gte->RGBC >> 16) & 0xFF; @@ -114,32 +128,45 @@ static void color_output(GTEState* gte) { gte->MAC1 = static_cast(mac1 >> 12); gte->MAC2 = static_cast(mac2 >> 12); gte->MAC3 = static_cast(mac3 >> 12); - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, true); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, true); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, true); + gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); + gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); + gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); gte->push_rgb( gte->saturate_color(gte->MAC1 >> 4, 0), gte->saturate_color(gte->MAC2 >> 4, 1), gte->saturate_color(gte->MAC3 >> 4, 2)); } -// Depth cue from current IR values using IR0 interpolation toward far color -// MAC = FC * 0x1000 - (BK + LC*IR) * 0x1000 ; then IR = (BK+LC*IR) + IR0 * MAC / 0x1000 -static void depth_cue_from_ir(GTEState* gte) { - // Interpolate: result = IR + IR0 * (FC - IR) / 0x1000 - int64_t mac1 = ((int64_t)gte->FC[0] << 12) - ((int64_t)gte->IR1 << 12); - int64_t mac2 = ((int64_t)gte->FC[1] << 12) - ((int64_t)gte->IR2 << 12); - int64_t mac3 = ((int64_t)gte->FC[2] << 12) - ((int64_t)gte->IR3 << 12); - // Scale by IR0 and add back - mac1 = (int64_t)gte->IR1 + ((gte->IR0 * (mac1 >> 12)) >> 12); - mac2 = (int64_t)gte->IR2 + ((gte->IR0 * (mac2 >> 12)) >> 12); - mac3 = (int64_t)gte->IR3 + ((gte->IR0 * (mac3 >> 12)) >> 12); +// Depth cue / interpolate current IR toward the far color using IR0 +// (common tail of DPCS/DPCT/DPCL/INTPL/NCDS/NCDT/CDP). Hardware: +// base = IR << 12 +// step = lim(((FC << 12) - base) >> (sf*12)) ; ±0x8000 clamp, lm FORCED off +// MAC = (base + IR0 * step) >> (sf*12) +// IR = lim(MAC, lm) ; lm from the instruction +static void depth_cue_from_ir(GTEState* gte, uint32_t instr) { + const int shift = gte_instr_sf(instr); + const bool lm = gte_instr_lm(instr); + int64_t base1 = (int64_t)gte->IR1 << 12; + int64_t base2 = (int64_t)gte->IR2 << 12; + int64_t base3 = (int64_t)gte->IR3 << 12; + int16_t step1 = gte->saturate_ir( + (int32_t)(( ((int64_t)gte->FC[0] << 12) - base1) >> shift), 1, false); + int16_t step2 = gte->saturate_ir( + (int32_t)(( ((int64_t)gte->FC[1] << 12) - base2) >> shift), 2, false); + int16_t step3 = gte->saturate_ir( + (int32_t)(( ((int64_t)gte->FC[2] << 12) - base3) >> shift), 3, false); + int64_t mac1 = (base1 + (int64_t)gte->IR0 * step1) >> shift; + int64_t mac2 = (base2 + (int64_t)gte->IR0 * step2) >> shift; + int64_t mac3 = (base3 + (int64_t)gte->IR0 * step3) >> shift; + gte->check_mac_overflow(mac1, 1); + gte->check_mac_overflow(mac2, 2); + gte->check_mac_overflow(mac3, 3); gte->MAC1 = static_cast(mac1); gte->MAC2 = static_cast(mac2); gte->MAC3 = static_cast(mac3); - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, true); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, true); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, true); + gte->IR1 = gte->saturate_ir(gte->MAC1, 1, lm); + gte->IR2 = gte->saturate_ir(gte->MAC2, 2, lm); + gte->IR3 = gte->saturate_ir(gte->MAC3, 3, lm); } // --------------------------------------------------------------------------- @@ -248,6 +275,286 @@ extern "C" int gte_dome_probe_dump(uint32_t* funcs, uint32_t* counts, int32_t* m return n; } +// --------------------------------------------------------------------------- +// GTE projection ring (ALWAYS-ON) — records each RTPS/RTPT's inputs (vertex, +// rotation matrix, translation, projection config) and outputs (screen XY / Z / +// FLAG). A post-hoc probe queries this to find flattened/degenerate character +// projections and decide whether the fault is in the INPUTS supplied by the +// recompiled game code (bad matrix/vertex) or the GTE math itself. +// --------------------------------------------------------------------------- +// NOTE: this block is already inside namespace PSXRecomp::GTE (opened line 8), +// so types are referred to unqualified. +extern "C" { extern uint64_t s_frame_count; } +struct GteRtpRec { + uint32_t seq, frame, caller_ra, cmd; + int16_t V0[3], V1[3], V2[3]; + int16_t RT[9]; + int32_t TR[3]; + uint16_t H; int32_t OFX, OFY; + int32_t SXY0, SXY1, SXY2; + uint16_t SZ1, SZ2, SZ3; + uint32_t FLAG; +}; +#define GTE_RTP_RING_CAP (1u << 18) /* 256K raw entries (~a few seconds) */ +static GteRtpRec* s_gte_rtp_ring = nullptr; +static uint64_t s_gte_rtp_seq = 0; + +/* Per-frame aggregate stats (retained ring over recent distinct frames). Lets a + * probe see the alternating flat/normal render pattern without shipping 84k + * projections/sec over JSON. nsat = projections whose newest output vertex + * saturated off-screen; nflat = RTP whose last-3 output verts are collinear + * (area 0) = a flattened triangle. */ +#define GTE_FSTAT_CAP 512 +struct GteFStat { uint32_t frame, nproj, nsat, nflat, nintpl, nintpl_tiny; }; +static GteFStat s_gte_fstat[GTE_FSTAT_CAP]; +static uint32_t s_gte_fstat_head = 0; /* index of most-recent frame slot */ +static int s_gte_fstat_valid = 0; + +/* Degenerate-latch: full records of flat/saturated projections, retained (small + * ring) so intermittent flat frames are caught regardless of raw-ring roll. */ +#define GTE_LATCH_CAP (1u << 13) /* 8192 */ +static GteRtpRec* s_gte_latch = nullptr; +static uint64_t s_gte_latch_seq = 0; + +static inline int gte_sxx(int32_t p){ int v=p&0xFFFF; return v>=0x8000? v-0x10000:v; } +static inline int gte_syy(int32_t p){ int v=(p>>16)&0xFFFF; return v>=0x8000? v-0x10000:v; } + +static void gte_rtp_record(const GTEState* g, uint32_t cmd) { + if (!s_gte_rtp_ring) { + s_gte_rtp_ring = (GteRtpRec*)calloc(GTE_RTP_RING_CAP, sizeof(GteRtpRec)); + if (!s_gte_rtp_ring) return; + } + GteRtpRec* e = &s_gte_rtp_ring[s_gte_rtp_seq % GTE_RTP_RING_CAP]; + e->seq = (uint32_t)s_gte_rtp_seq; + e->frame = (uint32_t)s_frame_count; + e->caller_ra = s_gte_caller_ra; + e->cmd = cmd; + for (int i = 0; i < 3; i++) { e->V0[i]=g->V0[i]; e->V1[i]=g->V1[i]; e->V2[i]=g->V2[i]; } + for (int r = 0; r < 3; r++) for (int c = 0; c < 3; c++) e->RT[r*3+c]=g->RT[r][c]; + for (int i = 0; i < 3; i++) e->TR[i]=g->TR[i]; + e->H=g->H; e->OFX=g->OFX; e->OFY=g->OFY; + e->SXY0=g->SXY[0]; e->SXY1=g->SXY[1]; e->SXY2=g->SXY[2]; + e->SZ1=g->SZ[1]; e->SZ2=g->SZ[2]; e->SZ3=g->SZ[3]; + e->FLAG=g->FLAG; + s_gte_rtp_seq++; + + /* classify */ + int x0=gte_sxx(e->SXY0),y0=gte_syy(e->SXY0); + int x1=gte_sxx(e->SXY1),y1=gte_syy(e->SXY1); + int x2=gte_sxx(e->SXY2),y2=gte_syy(e->SXY2); + int sat = (x2<=-1023||x2>=1023||y2<=-1023||y2>=1023) ? 1 : 0; + long area2 = (long)(x1-x0)*(y2-y0) - (long)(x2-x0)*(y1-y0); + int flat = (area2==0) ? 1 : 0; /* three output verts exactly collinear */ + + /* per-frame stats */ + uint32_t f=(uint32_t)s_frame_count; + if (!s_gte_fstat_valid || s_gte_fstat[s_gte_fstat_head].frame != f) { + s_gte_fstat_head = s_gte_fstat_valid ? (s_gte_fstat_head+1)%GTE_FSTAT_CAP : 0; + s_gte_fstat_valid = 1; + s_gte_fstat[s_gte_fstat_head].frame=f; + s_gte_fstat[s_gte_fstat_head].nproj=0; + s_gte_fstat[s_gte_fstat_head].nsat=0; + s_gte_fstat[s_gte_fstat_head].nflat=0; + s_gte_fstat[s_gte_fstat_head].nintpl=0; + s_gte_fstat[s_gte_fstat_head].nintpl_tiny=0; + } + s_gte_fstat[s_gte_fstat_head].nproj++; + s_gte_fstat[s_gte_fstat_head].nsat += sat; + s_gte_fstat[s_gte_fstat_head].nflat += flat; + + /* latch degenerate (saturated) projections — the "flat/absent" character */ + if (sat) { + if (!s_gte_latch) { + s_gte_latch = (GteRtpRec*)calloc(GTE_LATCH_CAP, sizeof(GteRtpRec)); + if (!s_gte_latch) return; + } + s_gte_latch[s_gte_latch_seq % GTE_LATCH_CAP] = *e; + s_gte_latch_seq++; + } +} + +extern "C" uint64_t gte_rtp_ring_total(void) { return s_gte_rtp_seq; } +extern "C" uint64_t gte_latch_total(void) { return s_gte_latch_seq; } + +// --------------------------------------------------------------------------- +// GTE INTPL ring (ALWAYS-ON) — records each INTPL (0x11) vertex-lerp: inputs +// (IR0 blend, IR1-3 pose-A vertex, FC pose-B vertex) snapshotted BEFORE the op +// and outputs (MAC1-3 / IR1-3 / FLAG) after. Crash Bash tweens character +// vertex animation through INTPL; this ring decides whether collapsed tween +// output comes from already-tiny inputs (upstream CPU/game-state divergence) +// or from the INTPL math itself. +// --------------------------------------------------------------------------- +struct GteIntplRec { + uint32_t seq, frame, caller_ra; + int16_t ir0, in1, in2, in3; + int32_t fc[3]; + int32_t mac[3]; + int16_t out1, out2, out3; + uint32_t flag; +}; +#define GTE_INTPL_RING_CAP (1u << 17) /* 128K entries (~4s at menu rates) */ +static GteIntplRec* s_gte_intpl_ring = nullptr; +static uint64_t s_gte_intpl_seq = 0; + +static void gte_intpl_record(const GTEState* g, + const int16_t pre_ir[4], const int32_t pre_fc[3]) { + if (!s_gte_intpl_ring) { + s_gte_intpl_ring = (GteIntplRec*)calloc(GTE_INTPL_RING_CAP, sizeof(GteIntplRec)); + if (!s_gte_intpl_ring) return; + } + GteIntplRec* e = &s_gte_intpl_ring[s_gte_intpl_seq % GTE_INTPL_RING_CAP]; + e->seq = (uint32_t)s_gte_intpl_seq; + e->frame = (uint32_t)s_frame_count; + e->caller_ra = s_gte_caller_ra; + e->ir0 = pre_ir[0]; e->in1 = pre_ir[1]; e->in2 = pre_ir[2]; e->in3 = pre_ir[3]; + for (int i = 0; i < 3; i++) e->fc[i] = pre_fc[i]; + e->mac[0]=g->MAC1; e->mac[1]=g->MAC2; e->mac[2]=g->MAC3; + e->out1=g->IR1; e->out2=g->IR2; e->out3=g->IR3; + e->flag=g->FLAG; + s_gte_intpl_seq++; + + /* per-frame stats (shares the fstat ring; nintpl_tiny = all outputs small + * while at least one input was not — the collapse signature) */ + uint32_t f=(uint32_t)s_frame_count; + if (!s_gte_fstat_valid || s_gte_fstat[s_gte_fstat_head].frame != f) { + s_gte_fstat_head = s_gte_fstat_valid ? (s_gte_fstat_head+1)%GTE_FSTAT_CAP : 0; + s_gte_fstat_valid = 1; + s_gte_fstat[s_gte_fstat_head].frame=f; + s_gte_fstat[s_gte_fstat_head].nproj=0; + s_gte_fstat[s_gte_fstat_head].nsat=0; + s_gte_fstat[s_gte_fstat_head].nflat=0; + s_gte_fstat[s_gte_fstat_head].nintpl=0; + s_gte_fstat[s_gte_fstat_head].nintpl_tiny=0; + } + s_gte_fstat[s_gte_fstat_head].nintpl++; + { + int out_tiny = (e->out1>-48 && e->out1<48 && e->out2>-48 && e->out2<48 + && e->out3>-48 && e->out3<48); + int in_big = (e->in1<=-48 || e->in1>=48 || e->in2<=-48 || e->in2>=48 + || e->in3<=-48 || e->in3>=48 + || e->fc[0]<=-48 || e->fc[0]>=48 + || e->fc[1]<=-48 || e->fc[1]>=48 + || e->fc[2]<=-48 || e->fc[2]>=48); + if (out_tiny && in_big) s_gte_fstat[s_gte_fstat_head].nintpl_tiny++; + } +} + +extern "C" uint64_t gte_intpl_ring_total(void) { return s_gte_intpl_seq; } + +/* Dump INTPL ring entries as JSON array body. offset skips the first N + * MATCHING entries (after frame filtering), so a probe can page through a + * whole frame regardless of where it sits in the ring. */ +extern "C" int gte_intpl_ring_dump_json(char* out, int outsz, int max_count, + int newest_first, long frame_filter, + int offset) { + if (!s_gte_intpl_ring || outsz < 64) { if (out && outsz) out[0]=0; return 0; } + uint64_t total = s_gte_intpl_seq; + if (total == 0) { out[0]=0; return 0; } + uint64_t oldest = total > GTE_INTPL_RING_CAP ? total - GTE_INTPL_RING_CAP : 0; + int pos = 0, emitted = 0, matched = 0; + for (uint64_t k = 0; k < (total - oldest) && emitted < max_count; k++) { + uint64_t seq = newest_first ? (total - 1 - k) : (oldest + k); + if (seq < oldest || seq >= total) break; + const GteIntplRec* e = &s_gte_intpl_ring[seq % GTE_INTPL_RING_CAP]; + if (frame_filter >= 0 && e->frame != (uint32_t)frame_filter) continue; + if (matched++ < offset) continue; + if (pos > outsz - 400) break; + pos += snprintf(out+pos, outsz-pos, + "%s{\"seq\":%u,\"frame\":%u,\"ra\":\"0x%08X\"," + "\"ir0\":%d,\"in\":[%d,%d,%d],\"fc\":[%d,%d,%d]," + "\"mac\":[%d,%d,%d],\"out\":[%d,%d,%d],\"flag\":\"0x%08X\"}", + emitted?",":"", e->seq, e->frame, e->caller_ra, + e->ir0, e->in1, e->in2, e->in3, e->fc[0], e->fc[1], e->fc[2], + e->mac[0], e->mac[1], e->mac[2], e->out1, e->out2, e->out3, + e->flag); + emitted++; + } + out[pos] = 0; + return emitted; +} + +/* Emit per-frame stats (newest first) as JSON array body. */ +extern "C" int gte_fstat_dump_json(char* out, int outsz, int max_frames) { + if (!s_gte_fstat_valid) { if(out&&outsz) out[0]=0; return 0; } + int pos=0, emitted=0; + for (int k=0; knproj==0 && !(k==0)) continue; + if (pos>outsz-96) break; + pos+=snprintf(out+pos,outsz-pos, + "%s{\"frame\":%u,\"nproj\":%u,\"nsat\":%u,\"nflat\":%u," + "\"nintpl\":%u,\"nintpl_tiny\":%u}", + emitted?",":"", s->frame,s->nproj,s->nsat,s->nflat, + s->nintpl,s->nintpl_tiny); + emitted++; + } + out[pos]=0; return emitted; +} + +/* Dump latched degenerate projections (newest first). */ +extern "C" int gte_latch_dump_json(char* out, int outsz, int max_count) { + if (!s_gte_latch || s_gte_latch_seq==0){ if(out&&outsz) out[0]=0; return 0; } + uint64_t total=s_gte_latch_seq; + uint64_t oldest = total>GTE_LATCH_CAP ? total-GTE_LATCH_CAP : 0; + int pos=0, emitted=0; + for (uint64_t k=0; k<(total-oldest) && emittedoutsz-700) break; + pos+=snprintf(out+pos,outsz-pos, + "%s{\"frame\":%u,\"ra\":\"0x%08X\",\"cmd\":\"0x%08X\"," + "\"RT\":[%d,%d,%d,%d,%d,%d,%d,%d,%d],\"TR\":[%d,%d,%d],\"H\":%u," + "\"V0\":[%d,%d,%d],\"V1\":[%d,%d,%d],\"V2\":[%d,%d,%d]," + "\"S0\":[%d,%d],\"S1\":[%d,%d],\"S2\":[%d,%d],\"SZ\":[%u,%u,%u],\"FLAG\":\"0x%08X\"}", + emitted?",":"", e->frame, e->caller_ra, e->cmd, + e->RT[0],e->RT[1],e->RT[2],e->RT[3],e->RT[4],e->RT[5],e->RT[6],e->RT[7],e->RT[8], + e->TR[0],e->TR[1],e->TR[2],(unsigned)e->H, + e->V0[0],e->V0[1],e->V0[2], e->V1[0],e->V1[1],e->V1[2], e->V2[0],e->V2[1],e->V2[2], + gte_sxx(e->SXY0),gte_syy(e->SXY0),gte_sxx(e->SXY1),gte_syy(e->SXY1), + gte_sxx(e->SXY2),gte_syy(e->SXY2),(unsigned)e->SZ1,(unsigned)e->SZ2,(unsigned)e->SZ3,e->FLAG); + emitted++; + } + out[pos]=0; return emitted; +} + +/* Format up to max_count recent entries as a JSON array body (no outer braces). + * If frame_filter >= 0, only entries with that frame are emitted. Returns count. */ +extern "C" int gte_rtp_ring_dump_json(char* out, int outsz, int max_count, + int newest_first, long frame_filter) { + if (!s_gte_rtp_ring || outsz < 64) { if (out && outsz) out[0]=0; return 0; } + uint64_t total = s_gte_rtp_seq; + if (total == 0) { out[0]=0; return 0; } + uint64_t oldest = total > GTE_RTP_RING_CAP ? total - GTE_RTP_RING_CAP : 0; + int pos = 0, emitted = 0; + for (uint64_t k = 0; k < (total - oldest) && emitted < max_count; k++) { + uint64_t seq = newest_first ? (total - 1 - k) : (oldest + k); + if (seq < oldest || seq >= total) break; + const GteRtpRec* e = &s_gte_rtp_ring[seq % GTE_RTP_RING_CAP]; + if (frame_filter >= 0 && e->frame != (uint32_t)frame_filter) continue; + auto sxx = [](int32_t p){ int v=p&0xFFFF; return v>=0x8000? v-0x10000:v; }; + auto syy = [](int32_t p){ int v=(p>>16)&0xFFFF; return v>=0x8000? v-0x10000:v; }; + if (pos > outsz - 700) break; + pos += snprintf(out+pos, outsz-pos, + "%s{\"seq\":%u,\"frame\":%u,\"ra\":\"0x%08X\",\"cmd\":\"0x%08X\"," + "\"V0\":[%d,%d,%d],\"V1\":[%d,%d,%d],\"V2\":[%d,%d,%d]," + "\"RT\":[%d,%d,%d,%d,%d,%d,%d,%d,%d],\"TR\":[%d,%d,%d]," + "\"H\":%u,\"OFX\":%d,\"OFY\":%d," + "\"S0\":[%d,%d],\"S1\":[%d,%d],\"S2\":[%d,%d]," + "\"SZ\":[%u,%u,%u],\"FLAG\":\"0x%08X\"}", + emitted?",":"", e->seq, e->frame, e->caller_ra, e->cmd, + e->V0[0],e->V0[1],e->V0[2], e->V1[0],e->V1[1],e->V1[2], e->V2[0],e->V2[1],e->V2[2], + e->RT[0],e->RT[1],e->RT[2],e->RT[3],e->RT[4],e->RT[5],e->RT[6],e->RT[7],e->RT[8], + e->TR[0],e->TR[1],e->TR[2], (unsigned)e->H, e->OFX, e->OFY, + sxx(e->SXY0),syy(e->SXY0), sxx(e->SXY1),syy(e->SXY1), sxx(e->SXY2),syy(e->SXY2), + (unsigned)e->SZ1,(unsigned)e->SZ2,(unsigned)e->SZ3, e->FLAG); + emitted++; + } + out[pos] = 0; + return emitted; +} + extern "C" void gte_set_display_aspect(int num, int den) { if (num <= 0 || den <= 0) { s_ws_xnum = s_ws_xden = 1; return; } // squash = (4/3) / (num/den) = (4*den) / (3*num); identity for 4:3. @@ -399,15 +706,15 @@ void gte_avsz4(GTEState* gte, uint32_t instr) { // --------------------------------------------------------------------------- // NCCS (0x1B) — Normal Color Color Single // --------------------------------------------------------------------------- -void gte_nccs_internal(GTEState* gte, int16_t* V) { - light_transform(gte, V); - light_color(gte); - color_output(gte); +void gte_nccs_internal(GTEState* gte, int16_t* V, uint32_t instr) { + light_transform(gte, V, instr); + light_color(gte, instr); + color_output(gte, instr); } void gte_nccs(GTEState* gte, uint32_t instr) { gte->FLAG = 0; - gte_nccs_internal(gte, gte->V0); + gte_nccs_internal(gte, gte->V0, instr); gte->set_error_flag(); } @@ -416,25 +723,25 @@ void gte_nccs(GTEState* gte, uint32_t instr) { // --------------------------------------------------------------------------- void gte_ncct(GTEState* gte, uint32_t instr) { gte->FLAG = 0; - gte_nccs_internal(gte, gte->V0); - gte_nccs_internal(gte, gte->V1); - gte_nccs_internal(gte, gte->V2); + gte_nccs_internal(gte, gte->V0, instr); + gte_nccs_internal(gte, gte->V1, instr); + gte_nccs_internal(gte, gte->V2, instr); gte->set_error_flag(); } // --------------------------------------------------------------------------- // NCDS (0x13) — Normal Color Depth Cue Single // --------------------------------------------------------------------------- -void gte_ncds_internal(GTEState* gte, int16_t* V) { - light_transform(gte, V); - light_color(gte); - depth_cue_from_ir(gte); - color_output(gte); +void gte_ncds_internal(GTEState* gte, int16_t* V, uint32_t instr) { + light_transform(gte, V, instr); + light_color(gte, instr); + depth_cue_from_ir(gte, instr); + color_output(gte, instr); } void gte_ncds(GTEState* gte, uint32_t instr) { gte->FLAG = 0; - gte_ncds_internal(gte, gte->V0); + gte_ncds_internal(gte, gte->V0, instr); gte->set_error_flag(); } @@ -443,18 +750,18 @@ void gte_ncds(GTEState* gte, uint32_t instr) { // --------------------------------------------------------------------------- void gte_ncdt(GTEState* gte, uint32_t instr) { gte->FLAG = 0; - gte_ncds_internal(gte, gte->V0); - gte_ncds_internal(gte, gte->V1); - gte_ncds_internal(gte, gte->V2); + gte_ncds_internal(gte, gte->V0, instr); + gte_ncds_internal(gte, gte->V1, instr); + gte_ncds_internal(gte, gte->V2, instr); gte->set_error_flag(); } // --------------------------------------------------------------------------- // NCS (0x1E) — Normal Color Single (no vertex color multiply) // --------------------------------------------------------------------------- -void gte_ncs_internal(GTEState* gte, int16_t* V) { - light_transform(gte, V); - light_color(gte); +void gte_ncs_internal(GTEState* gte, int16_t* V, uint32_t instr) { + light_transform(gte, V, instr); + light_color(gte, instr); // Output directly (no RGBC multiply) gte->push_rgb( gte->saturate_color(gte->MAC1 >> 4, 0), @@ -464,7 +771,7 @@ void gte_ncs_internal(GTEState* gte, int16_t* V) { void gte_ncs(GTEState* gte, uint32_t instr) { gte->FLAG = 0; - gte_ncs_internal(gte, gte->V0); + gte_ncs_internal(gte, gte->V0, instr); gte->set_error_flag(); } @@ -473,9 +780,9 @@ void gte_ncs(GTEState* gte, uint32_t instr) { // --------------------------------------------------------------------------- void gte_nct(GTEState* gte, uint32_t instr) { gte->FLAG = 0; - gte_ncs_internal(gte, gte->V0); - gte_ncs_internal(gte, gte->V1); - gte_ncs_internal(gte, gte->V2); + gte_ncs_internal(gte, gte->V0, instr); + gte_ncs_internal(gte, gte->V1, instr); + gte_ncs_internal(gte, gte->V2, instr); gte->set_error_flag(); } @@ -492,7 +799,7 @@ void gte_dpcs(GTEState* gte, uint32_t instr) { gte->IR2 = gte->saturate_ir(g << 4, 2, false); gte->IR3 = gte->saturate_ir(b << 4, 3, false); // Interpolate toward far color using IR0 - depth_cue_from_ir(gte); + depth_cue_from_ir(gte, instr); // Output gte->push_rgb( gte->saturate_color(gte->MAC1 >> 4, 0), @@ -513,7 +820,7 @@ void gte_dpct(GTEState* gte, uint32_t instr) { gte->IR1 = gte->saturate_ir(r << 4, 1, false); gte->IR2 = gte->saturate_ir(g << 4, 2, false); gte->IR3 = gte->saturate_ir(b << 4, 3, false); - depth_cue_from_ir(gte); + depth_cue_from_ir(gte, instr); gte->push_rgb( gte->saturate_color(gte->MAC1 >> 4, 0), gte->saturate_color(gte->MAC2 >> 4, 1), @@ -534,11 +841,11 @@ void gte_dpcl(GTEState* gte, uint32_t instr) { gte->MAC1 = (r * gte->IR1) >> 8; gte->MAC2 = (g * gte->IR2) >> 8; gte->MAC3 = (b * gte->IR3) >> 8; - gte->IR1 = gte->saturate_ir(gte->MAC1, 1, true); - gte->IR2 = gte->saturate_ir(gte->MAC2, 2, true); - gte->IR3 = gte->saturate_ir(gte->MAC3, 3, true); + gte->IR1 = gte->saturate_ir(gte->MAC1, 1, gte_instr_lm(instr)); + gte->IR2 = gte->saturate_ir(gte->MAC2, 2, gte_instr_lm(instr)); + gte->IR3 = gte->saturate_ir(gte->MAC3, 3, gte_instr_lm(instr)); // Depth cue toward far color - depth_cue_from_ir(gte); + depth_cue_from_ir(gte, instr); gte->push_rgb( gte->saturate_color(gte->MAC1 >> 4, 0), gte->saturate_color(gte->MAC2 >> 4, 1), @@ -551,7 +858,7 @@ void gte_dpcl(GTEState* gte, uint32_t instr) { // --------------------------------------------------------------------------- void gte_intpl(GTEState* gte, uint32_t instr) { gte->FLAG = 0; - depth_cue_from_ir(gte); + depth_cue_from_ir(gte, instr); gte->push_rgb( gte->saturate_color(gte->MAC1 >> 4, 0), gte->saturate_color(gte->MAC2 >> 4, 1), @@ -565,9 +872,9 @@ void gte_intpl(GTEState* gte, uint32_t instr) { void gte_cdp(GTEState* gte, uint32_t instr) { gte->FLAG = 0; // IR1/IR2/IR3 already set (from previous NCS or similar) - light_color(gte); - depth_cue_from_ir(gte); - color_output(gte); + light_color(gte, instr); + depth_cue_from_ir(gte, instr); + color_output(gte, instr); gte->set_error_flag(); } @@ -576,8 +883,8 @@ void gte_cdp(GTEState* gte, uint32_t instr) { // --------------------------------------------------------------------------- void gte_cc(GTEState* gte, uint32_t instr) { gte->FLAG = 0; - light_color(gte); - color_output(gte); + light_color(gte, instr); + color_output(gte, instr); gte->set_error_flag(); } @@ -971,6 +1278,15 @@ extern "C" void gte_execute(CPUState* cpu, uint32_t cmd) { * (no-op unless the game opts in — early-out on the config flag). */ if (func == 0x01 || func == 0x30) psx_ws_note_gte_project(func == 0x30 ? 3 : 1); + /* INTPL ring: snapshot inputs before the op mutates IR (outputs recorded + * after the switch). */ + int16_t intpl_pre_ir[4] = {0,0,0,0}; + int32_t intpl_pre_fc[3] = {0,0,0}; + if (func == 0x11) { + intpl_pre_ir[0]=gte.IR0; intpl_pre_ir[1]=gte.IR1; + intpl_pre_ir[2]=gte.IR2; intpl_pre_ir[3]=gte.IR3; + intpl_pre_fc[0]=gte.FC[0]; intpl_pre_fc[1]=gte.FC[1]; intpl_pre_fc[2]=gte.FC[2]; + } switch (func) { case 0x01: gte_rtps(>e, cmd); break; case 0x06: gte_nclip(>e, cmd); break; @@ -999,6 +1315,9 @@ extern "C" void gte_execute(CPUState* cpu, uint32_t cmd) { break; } + if (func == 0x01 || func == 0x30) gte_rtp_record(>e, cmd); + if (func == 0x11) gte_intpl_record(>e, intpl_pre_ir, intpl_pre_fc); + for (int i = 0; i < 32; i++) cpu->gte_data[i] = gte_mfc2(>e, i); for (int i = 0; i < 32; i++) cpu->gte_ctrl[i] = gte_cfc2(>e, i); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 104f1c82..4b71ea01 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1594,8 +1594,19 @@ static void sample_headless_pad_into_sio(int override) { * audio sample generation (735 samples/vblank * 60 = 44100/sec) matched * to the SDL audio device drain rate, eliminating queue overflow drops * and underruns. Uncapped, the host runs the simulation at whatever - * speed it can — typically several × realtime — and audio glitches. */ + * speed it can — typically several × realtime — and audio glitches. + * + * The wall-clock pacer target; nudged to the host display refresh at + * window-creation time (sync-to-host-refresh) so the pacer and SDL PRESENTVSYNC + * do not fight — a fixed 59.94 pacer against a 60.00 Hz panel makes rendered + * frames land on an uneven vblank count (a 2/3/1 beat) that reads as + * moving-object judder/flicker. See g_frame_period_ms. */ static constexpr double PSX_FRAME_PERIOD_MS = 1000.0 / 59.94; +/* Live pacer period (ms). Defaults to the PSX rate; set to the host refresh + * period when the panel is within ~2% of 60 Hz so 30fps content pads evenly to + * two host refreshes. Left at the PSX rate on non-~60Hz panels to avoid running + * the sim at the wrong speed. */ +static double g_frame_period_ms = PSX_FRAME_PERIOD_MS; /* ── Host-stack-usage profile (RECURSION_BUG.md §17) ────────────────────────── * The decisive instrument for the long-run freeze. The guest call graph mirrors @@ -1893,7 +1904,7 @@ static void sdl_vblank_present(void) { * hard freeze). */ { static FramePacer pacer = { 0 }; - frame_pacer_wait(&pacer, PSX_FRAME_PERIOD_MS); + frame_pacer_wait(&pacer, g_frame_period_ms); } latency_ring_mark(LAT_PACED); @@ -2066,6 +2077,47 @@ static void sdl_vblank_present(void) { } } } + + /* Frame blending (CRT-persistence masker for 30fps double-buffered + * content). Some games (e.g. Crash Bash menus/characters) leave a + * dynamic object in only one of the two display buffers per 30fps cycle, + * so it strobes on/off as the display alternates buffers — the static + * background, present in both, stays put. Real CRTs hid this via phosphor + * persistence; accurate emulators either time it away (cycle accuracy) or + * offer a "blend frames" option that averages the last two presented + * frames. This is the latter: presentation only, game logic untouched. + * Guarded to the plain software present path (no GL/VK/wide/hires/FMV) so + * the buffer is a simple present_w*h ARGB grid. PSX_FRAME_BLEND=0 off. */ + { + static int blend_cfg = -1; /* -1 unresolved, 0 off, 1 on */ + if (blend_cfg < 0) { + const char* e = getenv("PSX_FRAME_BLEND"); + blend_cfg = (e && e[0] == '1') ? 1 : 0; /* default off (masker only) */ + } + const bool simple_sw = !g_gl_active && !g_vk_active && !wide_present && + !di.depth24 && active_scale == 1; + if (blend_cfg && simple_sw) { + static uint32_t prev_buf[640 * 512]; + static uint32_t prev_px = 0; + const uint32_t npx = present_w * h; + if (npx <= (uint32_t)(640 * 512)) { + if (prev_px == npx) { + for (uint32_t i = 0; i < npx; i++) { + const uint32_t a = sdl_pixel_buf[i]; + const uint32_t b = prev_buf[i]; + prev_buf[i] = a; /* store this frame (pre-blend) */ + sdl_pixel_buf[i] = + 0xFF000000u | + (((a & 0xFEFEFEu) >> 1) + ((b & 0xFEFEFEu) >> 1)); + } + } else { + /* Resolution changed / first frame: seed, no blend. */ + for (uint32_t i = 0; i < npx; i++) prev_buf[i] = sdl_pixel_buf[i]; + prev_px = npx; + } + } + } + } } /* Update only the active display rectangle. The backing texture is sized @@ -3061,6 +3113,29 @@ int main(int argc, char** argv) { return 1; } + /* Sync-to-host-refresh: with SDL PRESENTVSYNC on, a fixed 59.94 Hz wall-clock + * pacer fights a 60.00 Hz panel — rendered frames slip onto an uneven vblank + * count (2/3/1 beat) that reads as moving-object judder. If the panel is + * within ~2% of 60 Hz, nudge the pacer to the exact panel period so the pacer + * and vsync agree and 30fps content pads to a steady 2 refreshes each. + * Non-~60Hz panels keep the PSX rate (vsync then governs; wrong-speed sim is + * worse than a benign slow beat). */ + { + SDL_DisplayMode dm; + int disp_idx = SDL_GetWindowDisplayIndex(sdl_window); + if (disp_idx >= 0 && SDL_GetCurrentDisplayMode(disp_idx, &dm) == 0 && dm.refresh_rate > 0) { + double host_hz = (double)dm.refresh_rate; + if (host_hz >= 58.8 && host_hz <= 61.2) { + g_frame_period_ms = 1000.0 / host_hz; + std::printf("psxrecomp: sync-to-host-refresh: pacing to %d Hz panel " + "(%.4f ms/frame)\n", dm.refresh_rate, g_frame_period_ms); + } else { + std::printf("psxrecomp: host panel %d Hz not ~60 Hz; keeping PSX " + "59.94 Hz pacing\n", dm.refresh_rate); + } + } + } + /* OpenGL backend: create the GL context now. On failure, relabel the * facade back to software (rasterization already runs through software in * this phase) and fall through to the SDL_Renderer present path below. */