mirror of
https://github.com/libretro/virtualjaguar-libretro.git
synced 2026-11-07 04:36:00 -10:00
* 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]>
143 lines
5.0 KiB
C
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;
|
|
}
|