Files
virtualjaguar-libretro/test/test_eeprom_read_race.c
Joe MattielloandClaude Fable 5 4fa7236682 fix(jerry): EEPROM data-out must not shift on joystick reads (Raiden music death) (#344)
* fix(eeprom): joystick polls no longer steal EEPROM read bits (Raiden music death)

Raiden (BIOS boot) would organically lose its background music mid-
session: audio glitched, then music stopped for good while sound
effects kept playing. The user's sick save state showed the DSP mixer
engine alive at its idle loop ($F1B2E2, I2S-only FLAGS $21) with the
four music voices parked on the $FFFFFFFC free sentinel -- the 68K
driver had simply stopped issuing music notes.

Root cause is in the EEPROM model, not the DSP. The 93C46's DO line is
sampled at $F14000 -- the same address as the JOYSTICK register that
the game's VBL/timer interrupt scans every frame (~6 reads/frame,
~10.2k of 11.4k pad reads over 1800 gameplay frames come from
interrupt context). eeprom_get_do() treated every sample as a clock
edge and shifted the read shifter, so a pad scan preempting an
in-flight READ transaction stole data bits: the shifter ran dry early
and the driver's remaining strobe+sample iterations read the idle DO
level (1s). Raiden's settings reload (game over / score flow) reads
its EEPROM music-enabled option that way -- 8 stolen bits turn the
read into $xxFF, the game stores $FF at $482C3 ("music off"), and its
TOM-PIT timer ISR skips the music sequencer tick forever after
(btst #3,$36384 / tst.b $482C3 gate at $22E18). On real silicon DO
only advances on SK edges, so a joystick read cannot disturb an EEPROM
transaction.

Fix: model sampling and clocking separately.
- Reading $F14000 now only samples the current DO bit (dummy 0 before
  the first clock, then the addressed word MSB-first).
- Reading $F14800 -- the Atari-standard driver's `tst.w` strobe before
  each sample -- is the SK edge and advances the shifter.
- A DI write during the data-out phase also counts as a clock edge
  instead of resetting the state machine.
- The write/erase busy handshake keeps its poll-once-then-ready shape.

Deterministic repro (new test/test_eeprom_read_race, wired into
`make test` after the lifecycle test): drives the exact Atari driver
sequence through JERRYReadWord/JERRYWriteWord with joystick polls
injected mid-transaction. Pre-fix the preempted read returns $00FF --
bit-for-bit the corruption in the user's sick state; post-fix all
four cases return the written word.

Validation: full suite green (audio clipping+presence incl. IS1
envelope and Skyhammer clean-and-not-silent, EEPROM lifecycle, SRAM);
Raiden BIOS from-boot 7200 frames LTXD 88.2% healthy; 18000-frame
scripted-gameplay soak 99.5% healthy; Tempest 2000 (EEPROM-heavy)
84.2% healthy. The sick save state stays music-less by design -- the
$FF is baked into the game variables inside the snapshot; toggling
music in Raiden's options menu (or clearing the .srm if the option
was persisted) restores it.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(jerry): align eeprom_set_di prototype parameter name

clang-tidy lints only changed files, so this pre-existing mismatch
(prototype said 'state', definition says 'data') surfaced as soon as
this PR touched eeprom.c.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-07 02:30:47 -04:00

143 lines
5.0 KiB
C

/*
* test/test_eeprom_read_race.c — EEPROM DO sampling vs clocking contract.
*
* Regression test for the Raiden background-music death: the 93-series
* EEPROM's DO line is sampled at $F14000 — the same address as the
* JOYSTICK register the game polls from its VBL/timer interrupt. The
* old model shifted the read shifter on every DO sample, so a joystick
* poll preempting an in-flight READ transaction stole data bits and the
* mainline driver's remaining samples returned the idle level (1s).
* Raiden's game-over settings reload read its music-enabled EEPROM
* option back as $FF that way and muted the music sequencer for the
* rest of the session.
*
* Contract under test (93C46 data sheet + Atari-standard driver shape):
* - Reading $F14800 (`tst.w $F14800`) is the serial clock: it shifts
* the next bit onto DO during the data-out phase.
* - Reading $F14000 samples DO without advancing anything.
* - Therefore joystick polls interleaved with an EEPROM READ
* transaction must not corrupt the value read.
*
* Build: cc -O2 -Wall -std=c99 -I./test -Ilibretro-common/include \
* -o test/test_eeprom_read_race test/test_eeprom_read_race.c \
* test/harness/harness.c -ldl -lm
*
* Usage: ./test/test_eeprom_read_race [core.dylib] <any_rom.j64>
* (needs the wide test ABI: make TEST_EXPORTS=1)
*/
#include "harness/harness.h"
#include <stdio.h>
typedef uint16_t (*jrw_fn)(uint32_t, uint32_t);
typedef void (*jww_fn)(uint32_t, uint16_t, uint32_t);
static jrw_fn jrw;
static jww_fn jww;
#define WHO 0 /* UNKNOWN */
static void cs_pulse(void) { (void)jrw(0xF15000, WHO); }
static void send_bit(int b) { jww(0xF14800, (uint16_t)(b & 1), WHO); }
static void send_bits(uint32_t v, int n)
{
int i;
for (i = n - 1; i >= 0; i--)
send_bit((int)((v >> i) & 1));
}
static int sample_do(void) { return jrw(0xF14000, WHO) & 1; }
static void strobe(void) { (void)jrw(0xF14800, WHO); }
/* Atari-standard write: EWEN, WRITE addr+data, CS pulse, busy poll, EWDS */
static void ee_write(int addr, uint16_t val)
{
int i;
cs_pulse();
send_bits(0x130, 9); /* EWEN %1 00 110000 */
cs_pulse();
send_bits(0x140 | (addr & 0x3F), 9); /* WRITE %1 01 aaaaaa */
send_bits(val, 16);
cs_pulse();
for (i = 0; i < 100; i++)
if (sample_do())
break;
cs_pulse();
send_bits(0x100, 9); /* EWDS */
}
/* Atari-standard read (strobe then sample, 16 bits), optionally with a
* burst of joystick polls injected mid-transaction like an ISR pad scan. */
static uint16_t ee_read(int addr, int inject_at, int inject_reads)
{
uint16_t v = 0;
int i, j;
cs_pulse();
send_bits(0x180 | (addr & 0x3F), 9); /* READ %1 10 aaaaaa */
for (i = 0; i < 16; i++) {
if (i == inject_at)
for (j = 0; j < inject_reads; j++)
(void)jrw(0xF14000, WHO); /* preempting pad scan */
strobe(); /* tst.w $F14800 */
v = (uint16_t)((v << 1) | (uint16_t)sample_do());
}
return v;
}
int main(int argc, char **argv)
{
harness_config cfg = HARNESS_CONFIG_DEFAULT;
harness_result results[4];
unsigned n = 0;
uint16_t got;
int fails = 0;
cfg.frames = 1;
if (!harness_init_from_args(&cfg, argc, argv)) return 2;
if (!harness_load_rom(&cfg)) return 2;
jrw = (jrw_fn)harness_dlsym(&cfg, "JERRYReadWord");
jww = (jww_fn)harness_dlsym(&cfg, "JERRYWriteWord");
if (!jrw || !jww) {
fprintf(stderr, "FAIL: JERRYReadWord/JERRYWriteWord not exported "
"(build with TEST_EXPORTS=1)\n");
return 2;
}
ee_write(3, 0xA5C3);
got = ee_read(3, -1, 0);
results[n].status = (got == 0xA5C3) ? "PASS" : "FAIL";
results[n].name = "clean_read";
results[n].detail = "uninterrupted READ returns the written word";
if (got != 0xA5C3) { fprintf(stderr, "clean read: got $%04X want $A5C3\n", got); fails++; }
n++;
got = ee_read(3, 8, 8);
results[n].status = (got == 0xA5C3) ? "PASS" : "FAIL";
results[n].name = "preempted_read";
results[n].detail = "8 joystick polls mid-READ must not steal DO bits";
if (got != 0xA5C3) { fprintf(stderr, "preempted read: got $%04X want $A5C3\n", got); fails++; }
n++;
got = ee_read(3, 0, 20);
results[n].status = (got == 0xA5C3) ? "PASS" : "FAIL";
results[n].name = "preempted_read_head";
results[n].detail = "20 joystick polls before first strobe must not steal bits";
if (got != 0xA5C3) { fprintf(stderr, "head-preempted read: got $%04X want $A5C3\n", got); fails++; }
n++;
got = ee_read(3, -1, 0);
results[n].status = (got == 0xA5C3) ? "PASS" : "FAIL";
results[n].name = "post_read";
results[n].detail = "transaction after the preempted one still reads clean";
if (got != 0xA5C3) { fprintf(stderr, "post read: got $%04X want $A5C3\n", got); fails++; }
n++;
harness_report(&cfg, results, n);
harness_shutdown(&cfg);
return fails ? 1 : 0;
}