From 7b746a0a4b225c307866031a1819bae2d198f60e Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 9 Jul 2026 17:05:37 -0700 Subject: [PATCH 1/5] fix(gpu): stop stale GPUSTAT-poll VBlank over-delivery; add present pacing + strobe tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Framework-level improvements distilled from an external Crash Bash fork of psxrecomp, rebased onto current master. Two of the fork's five touched files (code_provider.c stddef include, overlay_loader.c non-Windows atomic shim) are already fixed upstream and were dropped; the CrashBash runtime target is game-side and lives in the game repo, not here. gpu.c — the core bug. gpu_read_gpustat()'s poll fallback raised IRQ_VBLANK+LCF every 1000 GPUSTAT reads with no cycle gate. A title that busy-polls GPUSTAT for GPU/DMA readiness (not just vsync) got ~38 extra "fake" vblanks/s — ~96/s delivered vs the correct 60 — over-advancing the guest VSync frame counter and jittering animation (measured on Crash Bash as character strobe). The recompiled BIOS+game now carry per-block interrupt checks + cycle charging, so the tight poll loops that once justified this hack already advance guest cycles and let the one cycle-paced VBlank authority (interrupts.c) fire on schedule. The hack is now OFF by default; PSX_POLLHACK_VBLANK=1 restores legacy for A/B. Also adds per-frame GP0(E5) draw-offset-Y range instrumentation for double-buffer analysis. debug_server.c — two debug commands: `vblank_rate` (cycle-paced raise/deliver counts, poll-fallback raises, per-frame draw-offset range) and `dump_buffer` (dumps a raw 512x240 VRAM band at a given display-Y so both double-buffer halves can be inspected in one call). main.cpp — two presentation improvements, both generic: * sync-to-host-refresh: nudge the wall-clock pacer to the exact panel period when the display is within ~2% of 60 Hz, so the pacer and SDL PRESENTVSYNC stop fighting (a 59.94 pacer vs a 60.00 panel beats 2/3/1 = judder). ON by default but only engages on ~60 Hz panels; other refresh rates keep 59.94. * frame-blend masker: optional two-frame average for 30fps double-buffered content, guarded to the plain software path. OFF by default (PSX_FRAME_BLEND=1). Not yet compile-verified in this environment (no toolchain/ISO here); anchors, symbol linkage (g_vblank_*/g_doff_*/g_pollhack_*, gpu_display_pixel_rgb, png_write_rgb) and brace balance verified against master by inspection. Co-Authored-By: Claude Opus 4.8 (1M context) --- runtime/src/debug_server.c | 65 +++++++++++++++++++++++++++++++ runtime/src/gpu.c | 48 ++++++++++++++++++++--- runtime/src/main.cpp | 79 +++++++++++++++++++++++++++++++++++++- 3 files changed, 185 insertions(+), 7 deletions(-) diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index dfdaa146..2fe267b1 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -4677,6 +4677,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; @@ -7213,6 +7237,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 @@ -11273,6 +11336,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 }, @@ -11421,6 +11485,7 @@ static const CmdEntry s_commands[] = { { "get_snapshots", handle_get_snapshots }, { "screenshot", handle_screenshot_file }, { "screenshot_file", handle_screenshot_file }, /* alias */ + { "dump_buffer", handle_dump_buffer }, { "wide_full", handle_wide_full }, { "wide_shot", handle_wide_shot }, { "gpu_opcodes", handle_gpu_opcodes }, diff --git a/runtime/src/gpu.c b/runtime/src/gpu.c index d5d96f71..b8dccd83 100644 --- a/runtime/src/gpu.c +++ b/runtime/src/gpu.c @@ -1207,6 +1207,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; @@ -1317,6 +1326,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 ---- */ @@ -1398,11 +1408,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; @@ -1512,6 +1540,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(); @@ -2316,6 +2351,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/main.cpp b/runtime/src/main.cpp index cb26adb0..15115b3c 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1432,8 +1432,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 @@ -1731,7 +1742,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); @@ -1904,6 +1915,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 @@ -2866,6 +2918,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. */ From 3d05c9e98b65827b712d60683ed8f9ff1b94c87f Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 9 Jul 2026 18:47:38 -0700 Subject: [PATCH 2/5] fix(dispatch): don't shadow runtime-relocated game code in the BIOS shell window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boots Crash Bash (SCUS-94570) past its frame-~144 hang into the attract loop. Root cause: the Class-A shell-window fix (normalize() maps RAM 0x30000-0x5AFFF -> shell ROM) collides with games whose code lives in that window, and Crash Bash RELOCATES a code page onto 0x30000 at runtime. Three interacting gaps sent that real game code to dead shell ROM -> unknown-dispatch abort: 1. fntrace.c — game_started never latched: it only tripped on a dispatch to entry_pc (0x2E7B0), but Crash Bash reaches entry via compiled internal flow, so the latch never armed and the shell-window shadow stayed active. Now also latches on the first dispatch to native-ok game text (safe pre-load: that RAM still holds shell bytes that differ from the game image). 2. dirty_ram_interp.c — THE decisive fix. When a game-text address diverged from the static EXE image (native_ok=0, i.e. runtime-relocated/overlaid code) but was not "dirty", dirty_ram_dispatch_inner bailed (return 0) and the caller shadowed it to shell ROM. Now a diverged game-text address is marked a clean game-text miss so the dirty interpreter executes the live RAM bytes. 3. full_function_emitter.cpp (BIOS dispatch driver) — also route native-ok game-text shell-window addresses to the game path regardless of the latch. Likely redundant now that (1) latches correctly; flagged to slim during the final cross-game validation. NOT merged/validated against other titles yet (MMX6/Tomba2) — per plan that happens after Crash is fully running. Verified: Crash Bash runs headless to 6000+ frames and windowed with no dispatch abort; the vblank fix delivers 60/s. Known remaining issue: character flicker at the game-type menu (Bart's original report) — under investigation, unrelated to this dispatch fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- recompiler/src/full_function_emitter.cpp | 16 +++++++++++++++- runtime/src/dirty_ram_interp.c | 12 ++++++++++++ runtime/src/fntrace.c | 17 ++++++++++++++++- 3 files changed, 43 insertions(+), 2 deletions(-) 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/dirty_ram_interp.c b/runtime/src/dirty_ram_interp.c index 31224f0c..1ff9e000 100644 --- a/runtime/src/dirty_ram_interp.c +++ b/runtime/src/dirty_ram_interp.c @@ -2149,6 +2149,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 a50df5cf..326802fd 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 From c4474b93405c0ff682ee055df719de19407fb29b Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 9 Jul 2026 23:19:21 -0700 Subject: [PATCH 3/5] fix(gte): honor lm/sf from the instruction in the depth-cue/lighting family; add always-on GTE observability rings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The depth-cue tail (INTPL/DPCS/DPCT/DPCL/NCDS/NCDT/CDP) and the lighting helpers (light_transform / light_color / color_output) hardcoded lm=1 on every IR write-back, clamping all negative components to zero. Crash Bash tweens its vertex animation through INTPL (cmd 0x0980011, lm=0): pose A in IR1-3, pose B in the far-color registers, blend in IR0. With lm forced on, every negative vertex component collapsed to 0 on tween frames, squashing characters onto their anchor point. At 10fps keyframes / 30fps renders that made the menu character strobe (2 of 3 renders flattened, so exactly one display buffer ever held a healthy pose) and in-game characters flatten. Beetle/hardware render the same frames correctly. depth_cue_from_ir is now the faithful hardware formula: base = IR<<12, step = lim(((FC<<12) - base) >> sf*12) with the forced-unsigned-off intermediate clamp, MAC = (base + IR0*step) >> sf*12, final IR clamp with lm from the instruction word. sf (bit 19) is honored instead of assuming 12. The lighting helpers take lm from the instruction too (games light with lm=1, so their behavior is unchanged). Observability (always-on, build-dbg commands): - GTE projection ring (gte_ring_dump): every RTPS/RTPT's V/RT/TR/H/OFX/OFY inputs and SXY/SZ/FLAG outputs, frame-stamped, with caller ra. - GTE INTPL ring (gte_intpl_dump): every INTPL's IR0/IR1-3/FC inputs (snapshotted pre-op) and MAC/IR/FLAG outputs, with frame filter AND offset paging so a whole frame is reachable. - gte_frame_stats: per-frame nproj/nsat/nflat/nintpl/nintpl_tiny — nintpl_tiny (all-tiny output from non-tiny input) is the collapse signature and pinned this bug to the INTPL math in one query. - gte_latch_dump: retained ring of saturated projections. Verified on Crash Bash SELECT GAME TYPE: nintpl_tiny 335-352/473 -> 0-3, vertex buffer at 0x80056AC8 no longer alternates healthy/collapsed, and a 20-shot jittered screenshot burst shows the character in every frame (was bimodal present/absent). Co-Authored-By: Claude Opus 4.8 (1M context) --- runtime/src/debug_server.c | 92 ++++++++ runtime/src/gte.cpp | 443 +++++++++++++++++++++++++++++++------ 2 files changed, 473 insertions(+), 62 deletions(-) diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 2fe267b1..ecff09b0 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -5427,6 +5427,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; @@ -11496,6 +11584,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/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); From 92b42934f3f5062b0b85fd251a02d08e07bbb508 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 10 Jul 2026 00:18:09 -0700 Subject: [PATCH 4/5] docs: GTE lm/sf fix writeup with cross-game validation checklist Documents the c4474b9 root cause (hardcoded lm=1 in the depth-cue/lighting family collapsing Crash Bash's signed INTPL vertex tweens), the faithful depth_cue_from_ir formula, regression-risk analysis, and open validation checkboxes for Mega Man X6, Tomba 2, and Ape Escape. Co-Authored-By: Claude Opus 4.8 (1M context) --- GTE_LM_FIX.md | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 GTE_LM_FIX.md diff --git a/GTE_LM_FIX.md b/GTE_LM_FIX.md new file mode 100644 index 00000000..5d68e4b0 --- /dev/null +++ b/GTE_LM_FIX.md @@ -0,0 +1,86 @@ +# 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 (open) + +Verified so far: + +- [x] **Crash Bash (SCUS-94570)** — 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. +- [ ] **Mega Man X6** — boot to gameplay, verify sprites/models and any + depth-cued fades match pre-fix behavior (expected unchanged: lighting + uses `lm = 1`). +- [ ] **Tomba 2** — boot to gameplay, same checks (expected unchanged). +- [ ] **Ape Escape** — boot to gameplay, same checks; heavy GTE user, good + canary for the depth-cue step clamp. + +For each: run the game's existing verify flow, watch `gte_frame_stats` for +anomalous `nsat`/`nflat` shifts vs a pre-fix baseline, and eyeball lit / +fogged scenes. From 21b358f36a0d064da7d72718e40877c72846cbc5 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Fri, 10 Jul 2026 00:21:42 -0700 Subject: [PATCH 5/5] docs: validation is mandatory for all titles, not a checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframe the cross-game section: every supported title (Mega Man X6, Tomba 2, Ape Escape) must be validated before merge — no optional checkboxes, no subset. Adds the per-title procedure and flags Ape Escape as highest-risk (heavy GTE user, most likely to hit the depth-cue step clamp). Co-Authored-By: Claude Opus 4.8 (1M context) --- GTE_LM_FIX.md | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/GTE_LM_FIX.md b/GTE_LM_FIX.md index 5d68e4b0..80820eae 100644 --- a/GTE_LM_FIX.md +++ b/GTE_LM_FIX.md @@ -67,20 +67,30 @@ Low, and skewed toward correctness: - `sf` handling: all common libgte encodings of these ops use `sf = 1`, which reproduces the old shift exactly. -## Cross-game validation (open) +## Cross-game validation — REQUIRED, all titles, before merge -Verified so far: +This branch does not merge to master until every supported title has been +validated against this change. No subset, no sampling. -- [x] **Crash Bash (SCUS-94570)** — 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. -- [ ] **Mega Man X6** — boot to gameplay, verify sprites/models and any - depth-cued fades match pre-fix behavior (expected unchanged: lighting - uses `lm = 1`). -- [ ] **Tomba 2** — boot to gameplay, same checks (expected unchanged). -- [ ] **Ape Escape** — boot to gameplay, same checks; heavy GTE user, good - canary for the depth-cue step clamp. +**Validated:** -For each: run the game's existing verify flow, watch `gte_frame_stats` for -anomalous `nsat`/`nflat` shifts vs a pre-fix baseline, and eyeball lit / -fogged scenes. +**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.