fix(cd): let game-installed CD drivers own the transfer; fix HLE progress cadence (IS2 match-load checksum)

Iron Soldier 2's match load rejected every HLE CD_read delivery and
retried forever (68K looping $6AAA-$6AD2, wedge at frame ~331).  Three
distinct defects, each pinned byte-level:

1. Jump-table hijack of a game-owned driver (the decisive fix).
   IS2's loader copies a complete BIOS-workalike CD driver over the
   $3000-$3DFF jump table, replacing our $4E75 HLE stubs with real
   code (e.g. $303C: `bra` into its own CD_read, $304E: a CD_poll that
   reads the write pointer straight from [$3074] -> GPU data area).
   The HLE hooks kept firing at those PCs anyway, so the HLE stream
   and the driver's native DSA-seek + GPU-ISR FIFO transfer
   double-drove the same buffer.  The native path is exactly what
   BIOS mode runs -- and it works.  JaguarCDHLEHook now steps aside
   whenever the entry PC no longer holds our $4E75 stub.

2. Byte-granular progress reporting broke incremental checksummers.
   The game sums the buffer *as it polls* (ADD.L (A2)+,D0 against the
   advancing pointer) and never re-reads.  Reporting dest+written at
   byte granularity let a partial long expose up to 3 not-yet-written
   bytes; the running sum went stale and the final compare at $6AD4
   (expected D5=$D76563EE, from the match table at $6B88+16*idx+8)
   missed even though the completed buffer was byte-exact vs the disc
   (verified: full-buffer sum of the delivered 273360 bytes from LBA
   224643+1846 == $D76563EE).  Stream progress is now reported with
   the real GPU ISR cadence: dest-4 advancing in whole 32-byte FIFO
   batches, never past delivered data.

3. BUTCH FIFO feed vs a concurrent HLE stream.
   The driver's own seek ($1031/$1139/$120C -> LBA 224637) re-armed
   the FIFO mid-stream and its GPU ISR (gpupc $F03F32) drained stale
   audio words to its live write pointer, stomping 13 bytes at the
   checksum base ($8C029-$8C035: RAM held 97480102... where the disc
   holds 7441BAC2..., first divergence at stream offset 100037 of a
   273360-byte transfer that was otherwise byte-identical).  BUTCH now
   holds FIFO-ready off while an HLE stream is active, and an accepted
   HLE data read stops the CDDA play/FIFO feed like the real CD_read's
   seek does.  Also: the BUTCH edge tracker now matches GPUSetIRQLine's
   assert-drop condition (halted OR single-step-parked GPU captures no
   edges) via GPUCanCaptureIRQ(), so an edge dropped during a
   SINGLE_STEP handshake window is not marked consumed.

Validation:
- IS2 HLE repro (save state + press B) runs 1800 frames clean; the
  match visibly loads and plays (in-cockpit gameplay at frame 1800,
  audio RMS ~1218).  Was: exit 42 at frame 331.
- IS2 BIOS repro still exits 0 (900/1800 frames).
- cd_boot_matrix (3000 frames): no backward stage moves; forward:
  IS2 hle 0/1->1/1, Battle Morph bios pc_escape->GAME_CODE,
  Baldies hle 0/1->1/1.
- Battle Morph wedge probe 1200 frames clean, both blitter paths.
- make TEST_EXPORTS=1 test: 0 failed.  c89-lint clean on all touched
  files.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Joseph MattielloandClaude Fable 5 committed 2026-07-26 22:46:03 -04:00
1 parent 8675138374
commit 73438bae78
5 files changed
+118 -19

No files matched your search

+60 -15
View File
@@ -20,6 +20,7 @@
#include <string.h>
#include "cdintf.h"
#include "jagcd_boot.h"
#include "jagcd_hle.h"
#include "log.h"
#include "gpu.h"
#include "dsp.h"
@@ -324,6 +325,22 @@ static uint32_t dsaQueueCount = 0;
#define DSA_RESPONSE_DELAY_TICKS 4
static int32_t dsaResponseDelay = 0;
/* While an HLE CD_read stream is delivering into the destination buffer,
* BUTCH must not flag FIFO data ready: the HLE stream *is* the transfer,
* and the game's GPU CD ISR would otherwise drain unframed FIFO words
* into the same buffer at its live write pointer, corrupting freshly
* streamed bytes. Iron Soldier 2's loader issues its own DSA seek
* ($10/$11/$12) to the data track in parallel with the BIOS CD_read
* call; the seek-done path re-armed the FIFO mid-stream and the ISR
* stomped 13 bytes at the match checksum base -> checksum mismatch ->
* infinite retry. Once the stream completes the ISR's write pointer is
* parked past the end address, so post-stream FIFO drains are harmless
* (and some drivers rely on them to see the drive "playing"). */
static bool FIFOFeedAllowed(void)
{
return !JaguarCDHLEStreamActive();
}
static void DSAQueuePush(uint16_t response)
{
if (dsaQueueCount < DSA_QUEUE_SIZE)
@@ -586,6 +603,27 @@ void CDROMHLESetAudioPlaying(int playing)
CD_LOG("HLE audio playing=%d (block=%u)\n", playing, block);
}
/* An HLE data CD_read models the real BIOS CD_read's DSA seek to the
* data track: any in-progress audio play ends there, and the BUTCH FIFO
* stops feeding the GPU CD ISR (the HLE stream delivers the data
* itself, so the ISR has nothing to drain). Leaving the CDDA feed
* running double-drives the transfer: the game's GPU CD ISR keeps
* firing on FIFO-ready edges and drains stale audio words to its live
* write pointer inside the destination buffer, stomping freshly
* streamed data (Iron Soldier 2 match load: the ISR corrupted 13 bytes
* at the checksum base -> checksum mismatch -> infinite retry loop).
* Audio resumes when the game calls CD_I2S_enable (hle_post_read_lba)
* or issues a bit-31 just-seek CD_read, exactly as on hardware. */
void CDROMHLEDataReadBegin(void)
{
if (!haveCDGoodness)
return;
cdPlaying = false;
fifoDataReady = false;
fifoReadCount = 0;
CD_LOG("HLE data read: audio play + FIFO feed stopped\n");
}
/* Park the drive head at `lba`, paused, with the data-path framing the
* seek handler establishes (cdBufPtr=2: BUTCH's one-word capture skew —
* see the $12xx Goto Frame handler). Used by the BIOS boot-stub
@@ -797,7 +835,7 @@ void BUTCHExec(uint32_t cycles)
cdPlaying = true;
{
bool i2sDataEnabled = (cdRam[I2CNTRL + 3] & 0x04) != 0;
if (i2sDataEnabled)
if (i2sDataEnabled && FIFOFeedAllowed())
{
fifoDataReady = true;
fifoReadCount = 0;
@@ -825,14 +863,14 @@ void BUTCHExec(uint32_t cycles)
{
bool i2sDataEnabled = (cdRam[I2CNTRL + 3] & 0x04) != 0;
fifoFillDelay--;
if (fifoFillDelay == 0 && cdPlaying && i2sDataEnabled)
if (fifoFillDelay == 0 && cdPlaying && i2sDataEnabled && FIFOFeedAllowed())
{
fifoDataReady = true;
fifoReadCount = 0;
CDTracePush(CD_TRACE_FIFO_FILL, 0, block);
CD_LOG("BUTCHExec: FIFO half-full — ready for GPU ISR\n");
}
else if (fifoFillDelay == 0 && cdPlaying && !i2sDataEnabled)
else if (fifoFillDelay == 0 && cdPlaying && (!i2sDataEnabled || !FIFOFeedAllowed()))
{
fifoFillDelay = 1; // Retry next tick
}
@@ -956,14 +994,17 @@ void BUTCHExec(uint32_t cycles)
m68k_set_irq(2);
}
}
/* 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();
/* The edge is only "consumed" if a GPU that can capture IRQs saw
* it — a halted OR single-step-parked GPU's interrupt logic is not
* clocked (GPUSetIRQLine drops asserts in both states). Keeping
* the tracker false across such a window means the still-high line
* re-asserts on the first tick after the 68K lets the GPU run;
* marking it true there would swallow the edge forever and stall
* the transfer (the press-B@550/650 variant of the Hover Strike
* lockup; Iron Soldier 2's post-load engine bring-up drives the
* GPU through a SINGLE_STEP handshake and lost the FIFO-ready
* edge the same way — the streaming ISR never got its first IRQ). */
cdPrevShouldIRQ = shouldIRQ && GPUCanCaptureIRQ();
}
}
@@ -1060,7 +1101,7 @@ uint16_t CDROMReadWord(uint32_t offset, uint32_t who/*=UNKNOWN*/)
// at seek completion in BUTCHExec. Re-assert in case STOP
// cleared them between seek completion and this read.
cdPlaying = true;
if (!fifoDataReady)
if (!fifoDataReady && FIFOFeedAllowed())
{
fifoDataReady = true;
fifoReadCount = 0;
@@ -1078,7 +1119,8 @@ uint16_t CDROMReadWord(uint32_t offset, uint32_t who/*=UNKNOWN*/)
{
data = 0x0100 | (cdCmd & 0xFF); // Echo: $01nn -> $01nn (Found)
cdPlaying = true;
fifoDataReady = true;
if (FIFOFeedAllowed())
fifoDataReady = true;
CD_LOG("Play Title response consumed — playback and FIFO now active\n");
}
else if ((cdCmd & 0xFF00) == 0x0200) // Stop CD
@@ -1144,8 +1186,11 @@ TOC: 2 10 00 b 00:00:00 00 54:26:17 <-- Track #11
{
data = 0x0100; // Found (seek complete)
cdPlaying = true;
fifoDataReady = true;
fifoReadCount = 0;
if (FIFOFeedAllowed())
{
fifoDataReady = true;
fifoReadCount = 0;
}
CD_LOG("Seek response $0100 consumed (direct) — cdPlaying=true\n");
}
else if ((cdCmd & 0xFF00) == 0x1400) // Read "full" session TOC
+5
View File
@@ -69,6 +69,11 @@ void CDTraceHLERead(uint32_t lba, uint16_t byteCountTrunc);
* pause/resume the stream. */
void CDROMHLEStartAudio(uint32_t lba);
void CDROMHLESetAudioPlaying(int playing);
/* Stop the CDDA play + BUTCH FIFO feed when an HLE data CD_read is
* armed (models the BIOS CD_read's seek away from the audio position;
* prevents the game's GPU CD ISR from draining stale FIFO words into
* the destination buffer while the HLE stream delivers the data). */
void CDROMHLEDataReadBegin(void);
/* Read-only accessor for crash_detect.c's cd_seek_wedge watchdog. Any
* pointer may be NULL. seekStarts/seekDones count genuine (non-redundant)
+41 -4
View File
@@ -390,6 +390,15 @@ static void HLEHandleCDRead(void)
return;
}
/* Accepted data read: the real CD_read's DSA seek ends any running
* audio play before data flows. Stop the CDDA/FIFO feed for the
* duration of this transfer — otherwise the game's GPU CD ISR keeps
* draining stale FIFO audio words to its live write pointer inside
* the destination buffer, corrupting the freshly streamed data
* (Iron Soldier 2 match-load checksum stomp). Audio restarts via
* CD_I2S_enable / bit-31 just-seek, as on hardware. */
CDROMHLEDataReadBegin();
/* Clear the DSP completion flag so polling code sees a 0 -> $FFFFFFFF
* transition once the transfer finishes. Real hardware: the GPU CD ISR
* writes $FFFFFFFF here when its write pointer reaches the end address. */
@@ -912,10 +921,22 @@ void JaguarCDHLEStreamTick(void)
}
/* Advance the write pointer the game's poll loop watches — at the
* base latched when this read was armed (see statusBase). */
* base latched when this read was armed (see statusBase).
*
* Report it with the real GPU ISR's cadence: the pre-decremented
* pointer (dest-4) advancing in whole 32-byte FIFO batches, and
* never past the bytes actually delivered. Games read this pointer
* straight out of the GPU data area (IS2's driver CD_poll at $304E
* does `movea.l ($3074).l,a0; movea.l (a0),a0`) and checksum the
* buffer INCREMENTALLY against it as it advances. A byte-granular
* `dest+written` value exposed partial longs whose tail bytes had
* not been written yet — the running sum consumed up to 3 stale
* bytes, never re-read them, and the final compare missed (Iron
* Soldier 2 match-load retry loop). */
if (hleStream.statusBase != 0)
GPUWriteLong(hleStream.statusBase + 0,
hleStream.dest + hleStream.written, 0);
hleStream.dest - 4 + ((hleStream.written + 4) / 32) * 32,
0);
}
/* ------------------------------------------------------------------ */
@@ -965,10 +986,13 @@ static void HLEHandleCDPoll(void)
* take a sequencing branch. A0=0 fails both completion idioms. */
if (hleStream.active)
{
/* Transfer still streaming — report the partial position.
/* Transfer still streaming — report the partial position with
* the same 32-byte-batch quantization as the GPU-data-area
* pointer (see JaguarCDHLEStreamTick): never past the bytes
* actually delivered, so incremental checksummers stay exact.
* A1 (the [+8] error status) stays 0: boot stubs abort on
* nonzero A1, it is NOT a progress counter. */
a0_val = hleStream.dest - 4 + hleStream.written;
a0_val = hleStream.dest - 4 + ((hleStream.written + 4) / 32) * 32;
a1_val = 0;
}
else if (hle_read_pending)
@@ -1269,6 +1293,19 @@ bool JaguarCDHLEHook(uint32_t pc)
if (pc < BIOS_JUMPTABLE_BASE || pc > 0x00306B)
return false;
/* Step aside for games that install their own BIOS-workalike CD
* driver over the jump table (Iron Soldier 2's loader copies a full
* driver to $3000+, replacing our $4E75 stubs with real code). Such
* a driver performs the complete transfer protocol natively — DSA
* seek, GPU CD ISR, FIFO drain — against the BUTCH emulation, which
* is exactly the path BIOS mode runs (and it works). Intercepting
* on top of it double-drives the transfer: the HLE stream and the
* driver's ISR write the same buffer, and the HLE CD_poll fights the
* driver's own register returns. Hook only while OUR stub is still
* what executes at the entry PC. */
if (GET16(jaguarMainRAM, pc) != 0x4E75)
return false;
switch (pc)
{
case JT_CD_READ:
+7
View File
@@ -842,6 +842,13 @@ int GPUIsRunning(void)
return GPU_RUNNING ? 1 : 0;
}
/* See gpu.h: matches the assert-drop condition in GPUSetIRQLine (a
* stopped or single-step-parked core samples no interrupt lines). */
int GPUCanCaptureIRQ(void)
{
return (GPU_RUNNING && !(gpu_control & 0x08)) ? 1 : 0;
}
/* Is the Object Processor interrupt (IRQ3) enabled in G_FLAGS? Used by
* OPProcessList to decide whether a GPU object's inline release-wait can
* ever be serviced — if the game never enabled IRQ3, waiting is futile. */
+5
View File
@@ -35,6 +35,11 @@ void GPUCPUINTCallback(void);
void GPUResetStats(void);
uint32_t GPUReadPC(void);
int GPUIsRunning(void);
/* True when the RISC core cannot capture interrupt asserts: stopped
* (GPUGO clear) or parked in the SINGLE_STEP coprocessor barrier.
* Mirrors the drop condition in GPUSetIRQLine/GPUHandleIRQs so edge
* trackers (BUTCH) don't mark a dropped assert as consumed. */
int GPUCanCaptureIRQ(void);
int GPUOPInterruptEnabled(void);
void GPUDumpState(const char *tag);