cdrom: flow-control accelerated reads -- never outrun the guest's ack

THE BUG, measured end to end. Legend of Mana's land-creation effect
renders as hard-edged full-screen wedges because ONE sector of a
multi-sector scene load never arrives: cd_read_log shows LBA 125111 ->
0x0E1F18 followed by LBA 125113 -> 0x0E2718 -- destinations one sector
apart, LBAs two apart. Sector 125112, which the ISO shows holds the
effect's 5-colour per-vertex palette (0x0888F8 = (248,136,8), 0xB0F8F8 =
(248,248,176)), was skipped; sector 125113's raw file bytes landed at
its address and were faithfully rendered as 216 distinct vertex colours.
DuckStation places 125112 correctly and draws the clean bloom.

The skip is manufactured by accelerated pacing. At authentic cadence the
guest has a full sector period (~6.6 ms at 2x) to ack each data-ready
INT1; the one-deep pend-then-lose below is Beetle-faithful, and a loss
there means the game truly was too slow. With the speed divisor or
bounded-instant mode active, sectors arrive in a fraction of that
period, so a guest that is merely BUSY -- here, mid scene-load
decompression -- gets a sector clobbered that hardware would have
delivered. s_int1_lost counted it; the stream corrupted silently.

Fix, class-level per RULE -1: when running FASTER than hardware, never
overwrite a sector the guest has not consumed -- hold delivery, capped
at the authentic sector period, then fall through to faithful clobber
semantics. Accelerated mode is thus never slower than hardware and never
lossier than hardware; loads stay near-instant while the guest keeps up
and degrade toward guest speed when it does not. XA/streaming modes are
excluded by the same gates as apply_read_speed and keep authentic
timing. Warm routes keep their existing indefinite handshake.

New counters accel_consumer_waits/_wait_cycles in cdrom_state: nonzero
is healthy (the enhancement waited); int1_lost rising while the divisor
!= 1 is the regression tripwire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Vanderveen
2026-08-23 23:35:26 -04:00
parent 36ba6bd950
commit 493055846d
3 changed files with 72 additions and 0 deletions
+6
View File
@@ -178,6 +178,12 @@ typedef struct CDROMDebugState {
/* One-deep pended data-ready INT1 accounting (Beetle SetAIP analog). */
uint64_t int1_pended;
uint64_t int1_lost;
/* Accelerated-read flow control: holds where a faster-than-hardware
* sector was deferred rather than allowed to clobber an unconsumed one.
* Nonzero is healthy (the guest was busy and the enhancement waited);
* int1_lost rising while the speed divisor != 1 is the regression. */
uint64_t accel_consumer_waits;
uint64_t accel_consumer_wait_cycles;
uint8_t int1_pending_now;
} CDROMDebugState;
+63
View File
@@ -2391,6 +2391,53 @@ static int warm_route_consumer_blocked(void) {
return 0;
}
/* ---- Flow control for ACCELERATED data reads (speed divisor / instant) ----
*
* At authentic cadence the guest is guaranteed a full sector period
* (~6.6 ms at 2x) to ack each data-ready INT1 before the next sector can
* clobber the buffer; hardware losing a sector there means the game really
* was too slow, and the one-deep pend-then-lose below is Beetle-faithful.
* Accelerated pacing destroys that guarantee: sectors arrive in a fraction
* of the period, so a guest that is merely BUSY (not slow) gets sectors
* overwritten that real hardware would have delivered. That is a silent
* stream corruption manufactured by the enhancement -- Legend of Mana's
* land-creation palette (one sector, LBA 125112, of a multi-sector scene
* load) was skipped exactly this way and the raw file bytes that landed in
* its place were rendered as vertex colours.
*
* Rule: run the disc as fast as configured, but never overwrite a sector
* the guest has not consumed while running FASTER than hardware. The wait
* is capped at the authentic sector period: if the guest still has not
* acked after the time real hardware would have given it, fall through to
* the faithful clobber semantics. Accelerated mode is therefore never
* slower than hardware and never lossier than hardware; loads stay
* near-instant while the guest keeps up and degrade toward guest speed
* when it does not.
*
* XA/streaming modes are excluded by the same gates as apply_read_speed:
* those paths run at authentic cadence anyway, and pausing fictional disc
* time is only sound when the cadence IS fictional. */
static uint64_t s_accel_consumer_waits;
static uint64_t s_accel_consumer_wait_cycles;
static int s_accel_block_accum;
static int authentic_sector_period(void) {
return (mode_reg & 0x80) ? (CDROM_SINGLE_SPEED_SECTOR_CYCLES / 2)
: CDROM_SINGLE_SPEED_SECTOR_CYCLES;
}
static int accelerated_read_active(void) {
if (xa_stream_active || (mode_reg & 0x68u)) return 0;
return g_disc_speed_divisor != 1; /* 0 = instant, >1 = divided */
}
static int accelerated_consumer_blocked(void) {
if (!accelerated_read_active()) return 0;
if (pending_dataready) return 1;
if (irq_flag != 0 && !dma_cdrom_transfer_active()) return 1;
return 0;
}
static void process_read_stream(uint32_t cycles) {
if (!reading) return;
@@ -2400,6 +2447,18 @@ static void process_read_stream(uint32_t cycles) {
return;
}
if (accelerated_consumer_blocked() &&
s_accel_block_accum < authentic_sector_period()) {
/* Hold the sector back rather than clobber it: see the flow-control
* comment above. The accumulator caps the hold at the authentic
* sector period, after which delivery proceeds with hardware
* semantics. */
s_accel_block_accum += (int)cycles;
s_accel_consumer_waits++;
s_accel_consumer_wait_cycles += cycles;
return;
}
/* Disc time NEVER pauses while the drive reads (Beetle cdc.cpp
* HandleSectorRead: the sector pipeline advances on cycle deadlines
* regardless of INT ack state). XA-ADPCM realtime audio flows to the
@@ -2445,6 +2504,8 @@ static void process_read_stream(uint32_t cycles) {
s_int1_pended++;
}
}
s_accel_block_accum = 0; /* the pipeline advanced; the next hold
* starts a fresh authentic-period budget */
read_delay += sector_delay_cycles();
/* Clamp pathological underflow to one sector period: never replay a
* catch-up burst of missed sectors, never leave a huge negative debt. */
@@ -2827,6 +2888,8 @@ void cdrom_debug_snapshot(CDROMDebugState* out) {
out->read_hold_events = s_read_hold_events;
out->int1_pended = s_int1_pended;
out->int1_lost = s_int1_lost;
out->accel_consumer_waits = s_accel_consumer_waits;
out->accel_consumer_wait_cycles = s_accel_consumer_wait_cycles;
out->int1_pending_now = pending_dataready;
out->pending_pending = pending.pending;
out->pending_delay = pending_rem_cycles();
+3
View File
@@ -5591,6 +5591,7 @@ static void handle_cdrom_state(int id, const char *json)
"\"read_cmd\":\"0x%02X\",\"read_delay\":%d,"
"\"read_hold_cycles\":%llu,\"read_hold_events\":%llu,"
"\"int1_pended\":%llu,\"int1_lost\":%llu,\"int1_pending_now\":%u,"
"\"accel_consumer_waits\":%llu,\"accel_consumer_wait_cycles\":%llu,"
"\"filter_file\":%u,\"filter_channel\":%u,\"muted\":%u,"
"\"seek_msf\":[%u,%u,%u],"
"\"pending\":{\"cmd\":\"0x%02X\",\"active\":%d,\"delay\":%d,\"phase\":%d},"
@@ -5609,6 +5610,8 @@ static void handle_cdrom_state(int id, const char *json)
(unsigned long long)s.int1_pended,
(unsigned long long)s.int1_lost,
s.int1_pending_now,
(unsigned long long)s.accel_consumer_waits,
(unsigned long long)s.accel_consumer_wait_cycles,
s.filter_file, s.filter_channel, s.muted,
s.seek_min, s.seek_sec, s.seek_sect,
s.pending_cmd, s.pending_pending, s.pending_delay,