diff --git a/.gitignore b/.gitignore index bd6184e1..2274e0dc 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ test/test_* test/tools/test_memory_map test/tools/test_op_gpu_object test/tools/test_dsp_audio_diag +test/tools/cd_wedge_probe test/tools/sram_test test/dump_caller test/dump_pc diff --git a/CLAUDE.md b/CLAUDE.md index 84c1daee..658707f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,7 @@ To add a new probe: create `test/harness/foo_probe.h` + `.c`, resolve symbols vi - `test/sram_test.sh` — SRAM round-trip - `test/tools/cd_boot_matrix.sh` — per-title CD boot-stage matrix (HLE + BIOS mode) vs `docs/cd-boot-matrix.md`; env knobs `CD_MATRIX_FRAMES`, `CD_MATRIX_TIMEOUT`, `CD_MATRIX_MAX_RUNS`, `CD_MATRIX_LOGDIR`, `CD_MATRIX_OUT`, `CD_MATRIX_ROMS_ROOT`; chunked/resumable across invocations - CD trace ring: core option `virtualjaguar_cd_trace` (or env `VJ_CD_TRACE=1` for headless use) records `DSA_TX`/`DSA_RX`, `SEEK_START`/`SEEK_DONE`, `FIFO_FILL`/`FIFO_DRAIN`, `STOP`, `HLE_READ` events; dumped to the log on `cd_seek_wedge` or on request -- `test/tools/cd_wedge_probe.c` — catches intermittent CD lockups: detects a frozen framebuffer (`--arm N --freeze-frames N`, script the repro with `--press`), then dumps 68K registers, the pcQueue traceback ring, CD counters + trace ring, and a RAM hexdump around the stuck PC; exits 42 when caught so a retry loop can distinguish "caught" from "ran clean". Found the Hover Strike instant-CD_read code-stomp. +- `test/tools/cd_wedge_probe.c` — catches intermittent CD lockups: detects a frozen framebuffer (`--arm N --freeze-frames N`, script the repro with `--press`), then dumps 68K registers, the pcQueue traceback ring, CD counters + trace ring, and a RAM hexdump around the stuck PC; exits 42 when caught so a retry loop can distinguish "caught" from "ran clean". `--ram-dump BASE` writes full 2MB main-RAM + GPU-RAM snapshots at wedge time; `--snap FRAME` (repeatable, with `--snap-prefix BASE`) snapshots mid-run for clean-vs-wedged state diffing. Found the Hover Strike instant-CD_read code-stomp and the B-skip stale-GPU-IRQ-latch corruption. Caveat: a static loading screen can false-positive short freeze windows — confirm real wedges with `--freeze-frames 900`. - `test/tools/cd_visual_verify.c` — automated visual+audio verification for CD titles (`make cd-visual CD_VISUAL_DISC=`): per-second frame-motion timeline, non-black coverage, audio RMS, periodic screenshots (PPM; `sips -s format png` to view). Replaces most "boot it on a device and look" checks — an agent can Read the PNGs directly. Headless read-path caveat still applies for final "looks right" sign-off. ### Build-identity guard (stale-binary protection) diff --git a/src/cd/cdrom.c b/src/cd/cdrom.c index 22b8d574..a4575e28 100644 --- a/src/cd/cdrom.c +++ b/src/cd/cdrom.c @@ -874,8 +874,22 @@ void BUTCHExec(uint32_t cycles) * BIOS hasn't installed its EXT1 trampoline (Hover Strike, * Primal Rage), corrupting the stack with a bogus return address. * Keep the JERRY pending bit (so JINTCTRL reads see it) but skip - * BLIND m68k_set_irq dual-delivery. */ - GPUSetIRQLine(GPUIRQ_DSP, ASSERT_LINE); + * BLIND m68k_set_irq dual-delivery. + * + * The GPU latch assert lives INSIDE the edge gate below: real + * BUTCH raises its IRQ line once per condition onset (FIFO + * crossing half-full, DSA response arriving), and TOM latches + * that edge. Asserting every BUTCHExec tick while the level + * holds re-latches GPU IRQ1 immediately after every ISR ack — + * an IRQ storm that livelocks the GPU (RTI -> instant + * redispatch), and guarantees a stale pending latch whenever + * the 68K stop/reprograms/restarts the GPU. That pending latch + * dispatched at restart BEFORE the new program's `movei + * #$F04000,r31` stack init, pushing the return address through + * a stale r31 into GPU code — Hover Strike's B-skip lockup + * (press B at the boot logo: the CD driver's timer stub movei + * at $F03110 got its operand overwritten, the game's schedule + * clock at $5DC2E froze, and the 68K polled it forever). */ /* 68K delivery IS required when software asks for it: gate on * JINTCTRL's external-interrupt enable, exactly like the JERRY @@ -893,6 +907,11 @@ void BUTCHExec(uint32_t cycles) * Level delivery every halfline is an IRQ storm that starves the * 68K (4 matrix titles regressed to wall-clock hangs when this * was level-triggered). */ + /* Edge-paced (see the BUTCH+2 ack comment in CDROMWriteWord): + * assert the GPU latch once per service cycle, not per tick. */ + if (!cdPrevShouldIRQ) + GPUSetIRQLine(GPUIRQ_DSP, ASSERT_LINE); + if (!cdPrevShouldIRQ) { int tomEna = TOMIRQEnabled(IRQ_DSP); @@ -912,7 +931,14 @@ void BUTCHExec(uint32_t cycles) m68k_set_irq(2); } } - cdPrevShouldIRQ = shouldIRQ; + /* The edge is only "consumed" if a RUNNING GPU could capture it — + * a halted GPU's interrupt logic is not clocked (GPUSetIRQLine + * drops asserts while stopped). Keeping the tracker false across + * a stopped window means the still-high line re-asserts on the + * first tick after the 68K restarts the GPU; marking it true there + * would swallow the edge forever and stall the transfer (the + * press-B@550/650 variant of the Hover Strike lockup). */ + cdPrevShouldIRQ = shouldIRQ && GPUIsRunning(); } } @@ -1370,6 +1396,19 @@ void CDROMWriteWord(uint32_t offset, uint16_t data, uint32_t who/*=UNKNOWN*/) prevSubEna = data; } SET16(cdRam, offset, data & 0x007F); // Store only enable bits (0-6) + /* Writing the enable word is how software services/acks the BUTCH + * interrupt (the GPU ISR entry does a RMW here every invocation). + * Real BUTCH re-evaluates its IRQ line after the ack: if a condition + * (FIFO half-full, DSA response) still holds, the line rises again + * and TOM latches a NEW edge. Re-arm the edge detector so BUTCHExec + * asserts once per SERVICE CYCLE — not once per condition onset + * (starves transfers: a single missed edge kills the stream) and + * not once per tick (IRQ storm: the GPU latch is pending at every + * instant, so any 68K stop/reprogram/restart of the GPU dispatches + * into the new program before its `movei #$F04000,r31` stack init + * and pushes a return address through stale r31 into GPU code — + * Hover Strike's B-skip lockup). */ + cdPrevShouldIRQ = false; CD_LOG("WriteWord BUTCH+2: data=0x%04X enables=0x%02X [PC=$%06X]\n", data, data & 0x7F, m68k_get_reg(NULL, M68K_REG_PC)); return; diff --git a/src/cd/jagcd_bios.c b/src/cd/jagcd_bios.c index 806c425b..f2d1a98c 100644 --- a/src/cd/jagcd_bios.c +++ b/src/cd/jagcd_bios.c @@ -43,8 +43,18 @@ static bool bios_instruction_hook(uint32_t m68kPC) { /* GPU auth magic — boot ROM checks this to verify GPU ran auth code. * Empirically still load-bearing: removing it makes every BIOS disc - * loop at $0050B6 because the GPU never naturally writes the magic. */ - if (m68kPC == 0x005E40) + * loop at $0050B6 because the GPU never naturally writes the magic. + * + * Gated on the boot stub NOT having been injected yet: $005E40 is a + * BIOS-phase PC, but after handoff the GAME owns that RAM. Myst + * loads its 68K code at $5000-$1D380 and hot paths cross $5E40 — + * every crossing re-stomped $F03000 with the magic, overwriting the + * first two words of Myst's GPU IRQ0 handler stub + * (`movei #$F0D000,r0 / jump (r0)`). The 68K's next command kick + * (G_CTRL=$05, Myst's 68K->GPU doorbell) then dispatched IRQ0 into + * the corrupted slot and the GPU flew off through a stale r0 — + * frozen black screen during boot. */ + if (m68kPC == 0x005E40 && !cdBootStubInjected) { GPUWriteLong(0xF03000, 0x03D0DEAD, 0); return true; diff --git a/src/tom/gpu.c b/src/tom/gpu.c index 403e899b..a3b8049f 100644 --- a/src/tom/gpu.c +++ b/src/tom/gpu.c @@ -182,6 +182,7 @@ void (*gpu_opcode[64])()= static uint8_t gpu_ram_8[0x1000]; uint32_t gpu_pc; + /* Diagnostic IRQ counters (see gpu.h). Pure observability — incremented on * GPUSetIRQLine(line, ASSERT_LINE), reset in GPUReset. */ uint32_t gpu_irq0_count = 0; @@ -597,6 +598,14 @@ void GPUWriteLong(uint32_t offset, uint32_t data, uint32_t who/*=UNKNOWN*/) data &= ~0x02; } + /* Apply GO and the other persistent control bits BEFORE + * processing the CPU->GPU interrupt request below: a single + * write may set GPUGO and INT0 together, and the interrupt + * is only captured by a RUNNING GPU (see GPUSetIRQLine) — + * so the GO bit must land first. $06 (the two transient + * interrupt-request bits) never persists in gpu_control. */ + gpu_control = (gpu_control & 0xF7C0) | (data & ~(0xF7C0 | 0x06)); + // check for CPU -> GPU interrupt #0 if (data & 0x04) { @@ -606,8 +615,6 @@ void GPUWriteLong(uint32_t offset, uint32_t data, uint32_t who/*=UNKNOWN*/) data &= ~0x04; } - gpu_control = (gpu_control & 0xF7C0) | (data & (~0xF7C0)); - // if gpu wasn't running but is now running, execute a few cycles #ifdef GPU_SINGLE_STEPPING if (gpu_control & 0x18) @@ -657,6 +664,18 @@ void GPUHandleIRQs(void) { uint32_t bits, mask; uint32_t which = 0; //Isn't there a #pragma to disable this warning??? + + /* A halted GPU (G_CTRL GPUGO=0) latches interrupts but cannot service + * them: the dispatch sequence (push return address via r31, jump to + * vector) is executed by the RISC core itself, and a stopped core + * executes nothing. Dispatching here while stopped pushes a return + * address through the STOPPED context's r31 — if the 68K halted the + * GPU mid-handler (as Hover Strike's CD driver does around seeks), + * that push lands inside GPU code/variables and corrupts them. + * Pending latches are serviced by GPUExec() when the GPU next runs. */ + if (!GPU_RUNNING) + return; + // Bail out if we're already in an interrupt! if (gpu_flags & IMASK) return; @@ -717,6 +736,19 @@ void GPUSetIRQLine(int irqline, int state) if (state) { + /* A halted GPU (GPUGO=0) does not capture interrupts: the RISC + * core's clock is stopped, so nothing samples the interrupt lines + * and no latch accumulates. Dropping the assert here is what real + * silicon does — the source's LEVEL either still holds when the + * GPU is restarted (and re-edges once software services it) or the + * condition was consumed by the 68K in the meantime (e.g. a polled + * DSA response) and no interrupt should be seen at all. Latching + * here instead meant every 68K stop/reprogram/restart of the GPU + * could dispatch a stale interrupt into the new program before its + * r31 stack init, pushing a return address into GPU code — Hover + * Strike's B-skip lockup. */ + if (!GPU_RUNNING) + return; /* Diagnostic counters — see gpu.h */ if (irqline == 0) gpu_irq0_count++; else if (irqline == 3) gpu_irq3_count++; diff --git a/test/test_butch_cd.c b/test/test_butch_cd.c index 4e7ce62b..f1d00a48 100644 --- a/test/test_butch_cd.c +++ b/test/test_butch_cd.c @@ -399,7 +399,14 @@ TEST(butch_dsa_irq_routes_to_gpu_irq1) core.GPUReset(); /* Mimic the CD BIOS interrupt setup exactly: - * G_FLAGS = INT_ENA1 only; BUTCH = master enable + DSA RX enable. */ + * G_FLAGS = INT_ENA1 only; BUTCH = master enable + DSA RX enable. + * GPUGO must be set: the BIOS's GPU is RUNNING (parked in its wait + * loop) when the DSA response arrives — a halted GPU captures no + * interrupts at all (asserts are dropped, not latched; that stale + * latch dispatching at restart was Hover Strike's B-skip lockup). + * INT_ENA1 + a pending latch dispatches immediately on assert, so + * the "running" GPU never needs GPUExec to run garbage code. */ + core.GPUWriteLong(0xF02114, 0x00000001, CALLER_M68K); core.GPUWriteLong(0xF02100, GPU_FLAGS_INT_ENA1, CALLER_M68K); core.CDROMWriteWord(BUTCH_INT_CTRL + 2, BUTCH_INT_ENABLE | BUTCH_INT_RBUF_EN, CALLER_M68K); diff --git a/test/test_hle_bios.c b/test/test_hle_bios.c index 65917298..c2943154 100644 --- a/test/test_hle_bios.c +++ b/test/test_hle_bios.c @@ -1122,12 +1122,17 @@ static void test_jerry_jintctrl_multi_pending_selective_clear(void) * latch lives in gpu_control bits 6..10, mirrored as INT_LAT0..4. * GPUSetIRQLine(line, ASSERT_LINE) sets bit (6+line); the latch is * sticky until SW writes the matching CINTxFLAG bit (bits 9..13) to - * gpu_flags via the memory-mapped F02100 path. With GPU off (HLE - * mode) and INT_ENA cleared, GPUHandleIRQs must NOT advance gpu_pc. + * gpu_flags via the memory-mapped F02100 path. * - * We deliberately keep INT_ENA0..4 = 0 so HandleIRQs takes the - * "!bits" early-out and never dispatches; this lets us inspect the - * latch directly without perturbing R31/SP or GPU RAM. + * A HALTED GPU (GPUGO=0) captures no interrupts at all: the RISC + * clock is stopped, so asserts are dropped, not latched (see + * GPUSetIRQLine). Latching a stale interrupt across a 68K + * stop/reprogram/restart cycle dispatched it into the new program + * before its r31 stack init — Hover Strike's B-skip lockup. The + * latch mechanics below therefore run with GPUGO=1; INT_ENA0..4 + * stay 0 so HandleIRQs takes the "!bits" early-out and never + * dispatches (and the harness never calls GPUExec), which lets us + * inspect the latch without perturbing R31/SP, gpu_pc, or GPU RAM. * * Observables (memory-mapped via GPUReadLong): * $F02100 - gpu_flags (returns gpu_flags & 0xFFFFC1FF) @@ -1159,6 +1164,20 @@ static void test_gpu_irq_latch_redispatch(void) p_GPUWriteLong(0xF02100, 0x00000000, WHO_M68K); /* INT_ENA=0, IMASK=0 */ pc_before = p_gpu_pc ? *p_gpu_pc : p_GPUReadLong(0xF02110, WHO_M68K); + /* --- Sub-assert 0: halted GPU captures no interrupts --- */ + p_GPUWriteLong(0xF02114, 0x00000000, WHO_M68K); /* GPUGO=0 */ + p_GPUSetIRQLine(0, ASSERT_LINE_LOCAL); + ctrl = p_GPUReadLong(0xF02114, WHO_M68K); + if (!(ctrl & 0x40)) + PASS("assert while GPUGO=0 is dropped (halted GPU latches nothing)"); + else + FAIL("ctrl=$%08X (want bit6 clear: halted GPU must not latch)", ctrl); + + /* Latch mechanics below need a running GPU (GPUGO=1). INT_ENA + * stays 0, so nothing can dispatch, and the harness never calls + * GPUExec — the "running" GPU executes no instructions. */ + p_GPUWriteLong(0xF02114, 0x00000001, WHO_M68K); + /* --- Sub-assert 1: latch survives without enable --- */ p_GPUSetIRQLine(0, ASSERT_LINE_LOCAL); ctrl = p_GPUReadLong(0xF02114, WHO_M68K); @@ -1244,7 +1263,9 @@ static void test_gpu_irq_latch_redispatch(void) * CD BIOS installs its CD-data ISR. * * Pure latch test: INT_ENA all 0 / IMASK 0 so no dispatch happens; - * we just inspect gpu_control bits 6 and 7. + * we just inspect gpu_control bits 6 and 7. GPUGO is set for the + * duration — a halted GPU drops asserts instead of latching them + * (see Test 9d sub-assert 0). * ================================================================ */ static void test_butch_gpu_irq_line_mapping(void) { @@ -1260,6 +1281,7 @@ static void test_butch_gpu_irq_line_mapping(void) /* Clear any latch and disable enables so this is a pure-latch test. */ p_GPUWriteLong(0xF02100, 0x00003E00, WHO_M68K); /* CINT0..4FLAG -> clear latch */ p_GPUWriteLong(0xF02100, 0x00000000, WHO_M68K); /* INT_ENA=0, IMASK=0 */ + p_GPUWriteLong(0xF02114, 0x00000001, WHO_M68K); /* GPUGO=1: halted GPU drops asserts */ /* IRQ line 0 (GPUIRQ_CPU == 0, EXT1, vector $F03000) -> bit 6. */ p_GPUSetIRQLine(0, ASSERT_LINE_LOCAL); diff --git a/test/tools/cd_wedge_probe.c b/test/tools/cd_wedge_probe.c index f3c1cbed..f8f7c052 100644 --- a/test/tools/cd_wedge_probe.c +++ b/test/tools/cd_wedge_probe.c @@ -46,8 +46,53 @@ typedef struct { unsigned arm_frame; unsigned freeze_frames; int wedged; + const char *ram_dump_path; + unsigned snap_frames[8]; + unsigned n_snaps; + const char *snap_prefix; } wp_state; +/* Dump main RAM (2MB) and GPU local RAM ($F03000-$F03FFF) to files. */ +static void wp_snapshot(harness_config *cfg, const char *base) +{ + /* jaguarMainRAM is a POINTER variable (into jagMemSpace) — dlsym gives + * the address of the pointer; dereference to get the RAM base. */ + uint8_t **ramp = (uint8_t **)harness_dlsym(cfg, "jaguarMainRAM"); + uint8_t *ram = ramp ? *ramp : NULL; + uint32_t (*p_gpurl)(uint32_t, uint32_t) = + (uint32_t (*)(uint32_t, uint32_t))harness_dlsym(cfg, "GPUReadLong"); + uint32_t *p_gpupc = (uint32_t *)harness_dlsym(cfg, "gpu_pc"); + char path[1024]; + FILE *f; + + if (ram) { + snprintf(path, sizeof(path), "%s.ram", base); + f = fopen(path, "wb"); + if (f) { + fwrite(ram, 1, 0x200000, f); + fclose(f); + fprintf(stderr, "[WEDGE-PROBE] main RAM -> %s\n", path); + } + } + if (p_gpurl) { + uint32_t a; + snprintf(path, sizeof(path), "%s.gpu", base); + f = fopen(path, "wb"); + if (f) { + for (a = 0xF03000; a < 0xF04000; a += 4) { + uint32_t v = p_gpurl(a, 0); + uint8_t b[4]; + b[0] = (uint8_t)(v >> 24); b[1] = (uint8_t)(v >> 16); + b[2] = (uint8_t)(v >> 8); b[3] = (uint8_t)v; + fwrite(b, 1, 4, f); + } + fclose(f); + fprintf(stderr, "[WEDGE-PROBE] GPU RAM -> %s (gpu_pc=$%06X)\n", + path, p_gpupc ? *p_gpupc : 0); + } + } +} + static wp_state g_st; static void wp_video_cb(void *ud, const void *data, unsigned w, unsigned h, @@ -121,13 +166,32 @@ static void wp_dump(harness_config *cfg, unsigned frame) fprintf(stderr, "\n"); } + /* GPU PC history ring (TEMP Myst diag in gpu.c) — newest last. */ + { + uint32_t *ring = (uint32_t *)harness_dlsym(cfg, "gpu_pc_ring"); + uint32_t *rptr = (uint32_t *)harness_dlsym(cfg, "gpu_pc_ring_ptr"); + uint32_t *rfrz = (uint32_t *)harness_dlsym(cfg, "gpu_pc_ring_frozen"); + if (ring && rptr) { + unsigned n, idx = (*rptr - 64) & 511; + fprintf(stderr, "[WEDGE-PROBE] GPU PC trail (frozen=%u, oldest first):", + rfrz ? *rfrz : 0); + for (n = 0; n < 64; n++) { + if ((n % 6) == 0) fprintf(stderr, "\n[WEDGE-PROBE] "); + fprintf(stderr, "$%06X ", ring[idx]); + idx = (idx + 1) & 511; + } + fprintf(stderr, "\n"); + } + } + if (p_cdsum) p_cdsum(); if (p_cddump) p_cddump(); /* Hex-dump main RAM around the spinning code so the wait loop can be * disassembled offline, plus the HLE DSP done-flag and CDDA mailbox. */ { - uint8_t *ram = (uint8_t *)harness_dlsym(cfg, "jaguarMainRAM"); + uint8_t **ramp = (uint8_t **)harness_dlsym(cfg, "jaguarMainRAM"); + uint8_t *ram = ramp ? *ramp : NULL; uint32_t (*p_dspr)(uint32_t, uint32_t) = (uint32_t (*)(uint32_t, uint32_t))harness_dlsym(cfg, "DSPReadLong"); @@ -153,6 +217,10 @@ static void wp_dump(harness_config *cfg, unsigned frame) } } } + /* Full main-RAM + GPU-RAM snapshot for offline stack walking / + * disassembly of the code the 68K wedged in. */ + if (g_st.ram_dump_path) + wp_snapshot(cfg, g_st.ram_dump_path); if (p_dspr) { fprintf(stderr, "[WEDGE-PROBE] DSP $F1B4C8 (HLE done flag) = $%08X\n", p_dspr(0xF1B4C8, 0)); @@ -166,6 +234,15 @@ static void wp_dump(harness_config *cfg, unsigned frame) static bool wp_frame_cb(void *ud, unsigned frame) { harness_config *cfg = (harness_config *)ud; + unsigned i; + + for (i = 0; i < g_st.n_snaps; i++) { + if (g_st.snap_frames[i] == frame && g_st.snap_prefix) { + char base[900]; + snprintf(base, sizeof(base), "%s_f%u", g_st.snap_prefix, frame); + wp_snapshot(cfg, base); + } + } if (frame >= g_st.arm_frame && g_st.same_frames >= g_st.freeze_frames) { g_st.wedged = 1; @@ -188,6 +265,13 @@ int main(int argc, char **argv) g_st.arm_frame = (unsigned)atoi(argv[i + 1]); else if (strcmp(argv[i], "--freeze-frames") == 0 && i + 1 < argc) g_st.freeze_frames = (unsigned)atoi(argv[i + 1]); + else if (strcmp(argv[i], "--ram-dump") == 0 && i + 1 < argc) + g_st.ram_dump_path = argv[i + 1]; + else if (strcmp(argv[i], "--snap") == 0 && i + 1 < argc && + g_st.n_snaps < 8) + g_st.snap_frames[g_st.n_snaps++] = (unsigned)atoi(argv[i + 1]); + else if (strcmp(argv[i], "--snap-prefix") == 0 && i + 1 < argc) + g_st.snap_prefix = argv[i + 1]; } cfg.frames = 4800; @@ -200,7 +284,8 @@ int main(int argc, char **argv) if (!harness_init_from_args(&cfg, argc, argv)) return 1; if (!cfg.rom_path) { fprintf(stderr, "usage: cd_wedge_probe [core] [--frames N] " - "[--arm N] [--freeze-frames N] [--press F:BTN[:HOLD]]... " + "[--arm N] [--freeze-frames N] [--ram-dump FILE] " + "[--press F:BTN[:HOLD]]... " "[--system-dir DIR]\n"); return 1; }