fix(libretro): expose the Memory Track as SAVE_RAM on no-content boot (#810)

A no-content session never exposed SAVE_RAM (no_game_active), so Memory
Track writes made through a disc inserted via disk control were never
handed to the frontend. Two causes:

1. SAVE_RAM was gated on no_game_active, a #646-era rule ("no cartridge,
   no EEPROM"). #726 made no-content boot a CD session with the Memory
   Track plugged in from frame 0, so the gate is stale. It now follows
   jaguarMemTrackInserted. It is deliberately not keyed on a mounted
   disc: frontends size and load SAVE_RAM once after retro_load_game and
   libretro has no change notification, so exposing it first after an
   insert would write a file that is never read back.
2. The insert path never reinstalled the NVM BIOS module. The boot resets
   RAM, wiping the '_NVM' cookie; retro_load_game and retro_reset
   reinstall it, disk_set_eject_state did not. Found by the new test.

Tests: test_disk_control cases 6/7/8; test_boot_config's #646 size-0
assertion becomes the new contract. mtMem joins the test export lists.
No savestate change.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Joseph MattielloandClaude Sonnet 5.5 committed 2026-10-07 01:21:30 -04:00
1 parent 93e4097f48
commit 75eb401755
7 files changed
+269 -22

No files matched your search

+7
View File
@@ -2086,6 +2086,11 @@ test: test/test_dram_timing test/test_cheat test/test_event_queue test/test_jlin
@# in the `if [ -n "$$disc" ]` block, where it silently ran nowhere but
@# a corpus machine.
./test/tools/test_disk_control ./$(TARGET) --case 5 --quiet
@# Case 6 (#810): the Memory Track is exposed as SAVE_RAM from the first
@# query of a no-content boot, and a frontend-loaded .srm reaches mtMem.
@# Also disc-free, so it stays outside the corpus guard for the same
@# reason as case 5.
./test/tools/test_disk_control ./$(TARGET) --case 6 --quiet
@# Baldies HLE cutscene (#738): the boot classifier PASSED this title
@# while it was frozen from frame ~600 (it reaches game code and is
@@ -2113,6 +2118,8 @@ test: test/test_dram_timing test/test_cheat test/test_event_queue test/test_jlin
rc=0; \
./test/tools/test_disk_control ./$(TARGET) --disc "$$disc" --case 1 --quiet || rc=1; \
./test/tools/test_disk_control ./$(TARGET) --disc "$$disc" --case 3 --quiet || rc=1; \
./test/tools/test_disk_control ./$(TARGET) --disc "$$disc" --case 7 --quiet || rc=1; \
./test/tools/test_disk_control ./$(TARGET) --disc "$$disc" --case 8 --option virtualjaguar_memory_track=disabled --quiet || rc=1; \
discb=$$(find -L test/roms/private -iname '*.cdi' -o -iname '*.cue' 2>/dev/null | sed -n 2p); \
if [ -n "$$discb" ]; then \
if ./test/tools/test_disk_control ./$(TARGET) --disc "$$disc" --disc-b "$$discb" --case 4 --quiet; then :; \
+1 -1
View File
@@ -27,7 +27,7 @@ None required. The Jaguar boot ROMs and CD BIOSes are embedded, and cartridges b
| Cartridge | `j64`, `jag`, `rom`, `abs`, `cof`, `bin`, `prg` | Headerless raw homebrew is detected conservatively. Soft patching is covered in [`docs/rom-patches.md`](docs/rom-patches.md). |
| Jaguar CD | `cue` (with `bin`), `cdi`, `chd` | `.iso` is not supported. CHD needs session metadata from a recent `chdman`; see [`docs/jagcd-chd.md`](docs/jagcd-chd.md). Each release attaches a suitable `chdman` as `jagcd-tools-*.zip`. |
The core can also start with no content and take a disc through RetroArch's disk control. Cartridge EEPROM/SRAM and the CD Memory Track are saved as `<game>.srm` in RetroArch's `saves` folder; save states go in `states`. Exception: in a no-content session, a disc inserted through disk control gets no `.srm` (its Memory Track writes are not exposed to the frontend; use save states, or load the disc as content; [#810](https://github.com/libretro/virtualjaguar-libretro/issues/810)).
The core can also start with no content and take a disc through RetroArch's disk control. Cartridge EEPROM/SRAM and the CD Memory Track are saved as `<game>.srm` in RetroArch's `saves` folder; save states go in `states`. Exception: a no-content session exposes the Memory Track as save RAM from the start ([#810](https://github.com/libretro/virtualjaguar-libretro/issues/810)), but with no content the frontend has nothing to name the `.srm` after, so it is not per-game and is not named after the disc you insert. Load the disc as content for per-game saves.
## Features
+1
View File
@@ -71,6 +71,7 @@ _joypad1Buttons
_InputDev*
_Quad*
_MTState*
_mtMem
_NVMBios*
_NVM_*
_JGD*
+39 -6
View File
@@ -5560,6 +5560,16 @@ static bool disk_set_eject_state(bool ejected)
return false; /* disk_ejected stays true */
}
/* The boot just reset RAM, which wipes the Memory Track NVM BIOS module
* (the '_NVM' cookie + dispatcher stub) that no-content load installed.
* retro_load_game and retro_reset reinstall it after their boot for the
* same reason; without this, a game started by an insert sees the Memory
* Track cart but no NVM BIOS behind it, so the saves #810 exposes would
* never be written (#810). */
NVMBiosReset();
if (jaguarMemTrackInserted)
NVMBiosInstall();
LOG_INF("[CD] disk control: inserted '%s', booting via '%s'\n",
cd_image_path,
bootConfig.strategy->name ? bootConfig.strategy->name : "?");
@@ -6652,14 +6662,38 @@ static void eeprom_unpack_save_buf(void)
memcpy(mtMem, eeprom_save_buf + MT_SAVE_OFFSET, MT_SAVE_SIZE);
}
/* Does this session have anything for the frontend to persist?
*
* Only a no-content boot can answer no. There, "no cartridge" used to mean
* "no save media" (#646: don't hand frontends a meaningless .srm for a bare
* BIOS), but #726 changed what a no-content boot IS: it comes up in the CD
* BIOS with the CD unit attached and the Memory Track cart plugged in from
* frame 0. The Memory Track is real, writable save media whether or not a
* disc is mounted, so the answer follows it (#810).
*
* Deliberately NOT keyed on a disc being mounted. libretro has no "save RAM
* changed" notification: frontends size the buffer and copy the .srm into it
* once, right after retro_load_game (RetroArch also sizes its autosave once,
* at init), and only re-query at shutdown. Exposing the buffer for the first
* time after an insert would therefore be a write without a load -- the
* session's saves hit disk but are never read back, and each session blindly
* overwrites the last one's file. So the answer has to be right at the FIRST
* query, which is why it keys on the Memory Track and nothing that can change
* later. With the Memory Track disabled a no-content session keeps
* reporting nothing, and an inserted disc does not change that. */
static bool save_ram_available(void)
{
return !no_game_active || jaguarMemTrackInserted;
}
void *retro_get_memory_data(unsigned type)
{
if (type == RETRO_MEMORY_SYSTEM_RAM)
return jaguarMainRAM;
if (type == RETRO_MEMORY_SAVE_RAM)
{
/* No-content boot (#646): no cartridge means no EEPROM chip. */
if (no_game_active)
/* No-content boot with no Memory Track and no disc (#646, #810). */
if (!save_ram_available())
return NULL;
/* Memory Track cart uses 128K NVRAM directly */
if (jaguarMainROMCRC32 == 0xFDF37F47)
@@ -6680,10 +6714,9 @@ size_t retro_get_memory_size(unsigned type)
return 0x200000;
if (type == RETRO_MEMORY_SAVE_RAM)
{
/* No-content boot (#646): no cartridge means no EEPROM chip -- report
* zero so frontends don't create a meaningless .srm for a bare BIOS
* session. */
if (no_game_active)
/* No-content boot with no Memory Track and no disc (#646, #810):
* nothing to persist, so don't create a meaningless .srm. */
if (!save_ram_available())
return 0;
if (jaguarMainROMCRC32 == 0xFDF37F47)
return MT_SAVE_SIZE;
+1
View File
@@ -74,6 +74,7 @@
InputDev*;
Quad*;
MTState*;
mtMem;
NVMBios*;
NVM_*;
JGD*;
+12 -6
View File
@@ -614,7 +614,7 @@ TEST(no_game_boot_selects_cd_bios_strategy)
core_teardown();
}
TEST(no_game_save_ram_is_empty)
TEST(no_game_save_ram_is_memory_track)
{
bool loaded;
@@ -622,10 +622,16 @@ TEST(no_game_save_ram_is_empty)
loaded = p_retro_load_game(NULL);
if (!loaded) FAIL("retro_load_game(NULL) failed");
/* No cartridge -> no EEPROM chip. A frontend must not be handed a
* meaningless .srm for a bare-BIOS session. */
ASSERT_EQ(p_retro_get_memory_size(RETRO_MEMORY_SAVE_RAM), 0);
ASSERT(p_retro_get_memory_data(RETRO_MEMORY_SAVE_RAM) == NULL);
/* Originally (#646) this asserted size 0: no cartridge, no EEPROM chip,
* so no .srm for a bare-BIOS session. #726 made no-content boot a CD
* session with the Memory Track plugged in from frame 0, and #810 found
* that exposing nothing loses every save made through a disc inserted
* later: a frontend sizes and loads SAVE_RAM once, right after the load,
* so it has to be exposed from the FIRST query. Memory Track is on by
* default; the disabled case is covered by test_disk_control case 8. */
ASSERT_EQ(p_retro_get_memory_size(RETRO_MEMORY_SAVE_RAM),
128 + 128 + 0x20000);
ASSERT(p_retro_get_memory_data(RETRO_MEMORY_SAVE_RAM) != NULL);
core_teardown();
}
@@ -724,7 +730,7 @@ int main(int argc, char *argv[])
SUITE("No-Content Boot (RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME)");
RUN(no_game_env_declared);
RUN(no_game_boot_selects_cd_bios_strategy);
RUN(no_game_save_ram_is_empty);
RUN(no_game_save_ram_is_memory_track);
RUN(no_game_runs_and_resets_without_crashing);
total_fail += REPORT();
+208 -9
View File
@@ -38,9 +38,30 @@
* only the return value would pass against the inert stub this
* interface shipped with one commit earlier.
*
* 6 No-content boot exposes the Memory Track as SAVE_RAM from the very
* first query (#810). Needs no disc. The size and pointer must be
* CD_SAVE_SIZE and non-NULL straight after the load, and a marker the
* "frontend" writes into the buffer before frame 1 must reach mtMem --
* i.e. the .srm load a frontend does once, after retro_load_game,
* actually lands. A late-exposure fix would pass a size>0 check after
* an insert and still lose every save, because that load already
* happened against a size of 0.
*
* 7 Same, then insert a disc. Size and pointer must be unchanged, the
* marker must still be in mtMem (the insert's reboot must not wipe the
* Memory Track), and the NVM BIOS module cookie must be present in RAM.
*
* 8 Memory Track DISABLED (--option virtualjaguar_memory_track=disabled):
* nothing to persist on a bare no-content session, so SAVE_RAM is 0 --
* and it STAYS 0 after a disc is inserted. Deliberate: a frontend sizes
* and loads the buffer once at load, so exposing it for the first time
* after an insert would be a write without a load (the file is
* overwritten at exit from a buffer that never saw it). Guards against
* "fixing" #810 by keying SAVE_RAM on a mounted disc.
*
* Run: test_disk_control <core> --disc <image> --case N [--quiet]
* (--disc is required for cases 1, 3 and 4 only; case 5 is the
* no-content boot check and takes no disc. Case 4 also needs --disc-b.)
* (--disc is required for cases 1, 3, 4, 7 and 8; cases 5 and 6 are
* no-disc no-content boot checks. Case 4 also needs --disc-b.)
*/
#define _DEFAULT_SOURCE 1
@@ -154,6 +175,48 @@ static void vid_cb(void *ud, const void *data, unsigned w, unsigned h,
vid_prev_hash = hsh;
}
/* Mirrors libretro.c: 128 B cart EEPROM + 128 B CD EEPROM + 128 KB Memory
* Track. Hard-coded on purpose -- a test that derived the expectation from
* the core's own constant could not catch that constant being wrong. */
#define DC_CD_SAVE_SIZE (128u + 128u + 0x20000u)
#define DC_MT_SAVE_OFFSET 256u
#define DC_MARKER_OFF 0x40u /* offset inside the Memory Track */
#define DC_NVM_COOKIE_RAM 0x2400u /* src/core/nvmbios.h NVM_COOKIE_ADDR */
static size_t (*lr_mem_size)(unsigned);
static void *(*lr_mem_data)(unsigned);
static const uint8_t *mt_mem; /* the core's real Memory Track array */
static const uint8_t dc_marker[8] = { 0xA5, 0x5A, 0xC3, 0x3C,
0x81, 0x18, 0xE7, 0x7E };
/* Stand in for the frontend loading a .srm: it asks for the size and the
* pointer once, after retro_load_game, and copies the file into the buffer. */
static int seed_srm_marker(void)
{
uint8_t *buf = (uint8_t *)lr_mem_data(RETRO_MEMORY_SAVE_RAM);
if (!buf || lr_mem_size(RETRO_MEMORY_SAVE_RAM) != DC_CD_SAVE_SIZE)
return 0;
memcpy(buf + DC_MT_SAVE_OFFSET + DC_MARKER_OFF, dc_marker,
sizeof(dc_marker));
return 1;
}
/* jaguarMainRAM is a POINTER variable into jagMemSpace, so dlsym returns
* the address of the pointer, not of the RAM (cf. cd_wedge_probe.c). */
static const uint8_t *main_ram(harness_config *c)
{
uint8_t **ramp = (uint8_t **)harness_dlsym(c, "jaguarMainRAM");
return ramp ? *ramp : NULL;
}
static int marker_in_mtmem(void)
{
return memcmp(mt_mem + DC_MARKER_OFF, dc_marker, sizeof(dc_marker)) == 0;
}
static harness_result mkres(int ok, const char *name, const char *detail)
{
harness_result r;
@@ -167,7 +230,7 @@ int main(int argc, char **argv)
{
harness_config cfg = HARNESS_CONFIG_DEFAULT;
struct retro_game_info gi;
harness_result results[6];
harness_result results[8];
unsigned nres = 0;
const char *disc_path = NULL;
const char *disc_path_b = NULL;
@@ -185,18 +248,20 @@ int main(int argc, char **argv)
disc_path_b = argv[i + 1];
}
if (case_num != 1 && case_num != 3 && case_num != 4
&& case_num != 5) {
&& case_num != 5 && case_num != 6 && case_num != 7
&& case_num != 8) {
fprintf(stderr, "usage: test_disk_control <core> --case N "
"[--disc <image>] [--disc-b <image>] [--quiet]\n"
" N is one of 1|3|4|5. Cases 1, 3 and 4 need "
"--disc <image>; case 4 also needs\n"
" --disc-b <image>. Case 5 (no-content boot) "
"takes no disc.\n"
" N is one of 1|3|4|5|6|7|8. Cases 1, 3, 4, 7 and 8 "
"need --disc <image>; case 4 also needs\n"
" --disc-b <image>. Cases 5 and 6 (no-content "
"boot) take no disc. Case 8 also wants\n"
" --option virtualjaguar_memory_track=disabled.\n"
" (case 2 needs a one-session audio disc; none "
"exists in the corpus)\n");
return 1;
}
if (!disc_path && case_num != 5) {
if (!disc_path && case_num != 5 && case_num != 6) {
fprintf(stderr, "test_disk_control: no --disc given\n");
return 1;
}
@@ -226,6 +291,17 @@ int main(int argc, char **argv)
return 1;
}
lr_mem_size = (size_t (*)(unsigned))harness_dlsym(&cfg,
"retro_get_memory_size");
lr_mem_data = (void *(*)(unsigned))harness_dlsym(&cfg,
"retro_get_memory_data");
mt_mem = (const uint8_t *)harness_dlsym(&cfg, "mtMem");
if (!lr_mem_size || !lr_mem_data || !mt_mem) {
fprintf(stderr, "test_disk_control: memory accessors / mtMem not "
"exported -- rebuild with `make TEST_EXPORTS=1`\n");
return 1;
}
if (!harness_load_no_content(&cfg)) {
fprintf(stderr, "test_disk_control: no-content load failed\n");
return 1;
@@ -373,6 +449,129 @@ int main(int argc, char **argv)
pass = rendered && moving;
break;
}
case 6: {
/* #810: the buffer must be exposed from the FIRST query, because a
* frontend sizes and loads it exactly once, right after the load.
* (Memory Track enabled is the default; it is plugged in from frame
* 0 on this path, see the no-content branch of retro_load_game.) */
int sized, seeded, landed, stable;
size_t size_at_load = lr_mem_size(RETRO_MEMORY_SAVE_RAM);
void *ptr_at_load = lr_mem_data(RETRO_MEMORY_SAVE_RAM);
sized = size_at_load == DC_CD_SAVE_SIZE && ptr_at_load != NULL;
seeded = sized && seed_srm_marker();
harness_step(&cfg); /* first frame unpacks the .srm */
landed = seeded && marker_in_mtmem();
harness_step(&cfg);
stable = lr_mem_data(RETRO_MEMORY_SAVE_RAM) == ptr_at_load
&& lr_mem_size(RETRO_MEMORY_SAVE_RAM) == DC_CD_SAVE_SIZE;
results[nres++] = mkres(sized, "case6_save_ram_exposed_at_load",
sized ? "no-content boot reports CD_SAVE_SIZE and a buffer at "
"load time"
: "SAVE_RAM is size 0 / NULL on a no-content boot with "
"the Memory Track plugged in (#810)");
results[nres++] = mkres(landed, "case6_srm_load_reaches_mtmem",
landed ? "data the frontend wrote into SAVE_RAM reached the "
"Memory Track"
: "frontend-loaded save data never reached mtMem");
results[nres++] = mkres(stable, "case6_buffer_stable",
stable ? "pointer and size unchanged across frames"
: "pointer or size moved under the frontend");
pass = sized && seeded && landed && stable;
break;
}
case 7: {
int sized, seeded, inserted, same, kept, nvm, nvm0, i;
const uint8_t *ram;
void *ptr_before = lr_mem_data(RETRO_MEMORY_SAVE_RAM);
sized = lr_mem_size(RETRO_MEMORY_SAVE_RAM) == DC_CD_SAVE_SIZE
&& ptr_before != NULL;
seeded = sized && seed_srm_marker();
/* Control for the cookie probe below: it must be present straight
* after the no-content load, or "missing after the insert" would
* only mean the probe is looking at the wrong address. */
ram = main_ram(&cfg);
nvm0 = ram && memcmp(ram + DC_NVM_COOKIE_RAM, "_NVM", 4) == 0;
harness_step(&cfg); /* first frame unpacks the .srm */
gi.path = disc_path;
inserted = cfg.disk_cb_registered
&& cfg.disk_add_image_index()
&& cfg.disk_replace_image_index(0, &gi)
&& cfg.disk_set_eject_state(true)
&& cfg.disk_set_eject_state(false);
/* Sampled BEFORE any frame runs: the boot has just reset the machine,
* so nothing the game does can have touched $2400 yet, and a missing
* cookie here is the insert path's doing, not the game's. */
ram = main_ram(&cfg);
nvm = ram && memcmp(ram + DC_NVM_COOKIE_RAM, "_NVM", 4) == 0;
for (i = 0; i < 30; i++)
harness_step(&cfg);
same = lr_mem_size(RETRO_MEMORY_SAVE_RAM) == DC_CD_SAVE_SIZE
&& lr_mem_data(RETRO_MEMORY_SAVE_RAM) == ptr_before;
kept = seeded && marker_in_mtmem();
results[nres++] = mkres(seeded, "case7_exposed_before_insert",
seeded ? "SAVE_RAM exposed and seeded before the insert"
: "SAVE_RAM not exposed before the insert");
results[nres++] = mkres(inserted, "case7_insert_succeeded",
inserted ? "disc inserted" : "insert was refused");
results[nres++] = mkres(same, "case7_save_ram_after_insert",
same ? "size and pointer unchanged by the insert"
: "SAVE_RAM size/pointer changed or vanished after the "
"insert (#810)");
results[nres++] = mkres(kept, "case7_memory_track_survives_insert",
kept ? "Memory Track contents survived the insert reboot"
: "the insert reboot wiped the Memory Track -- the next "
"frontend save would write it back blank");
results[nres++] = mkres(nvm0, "case7_nvm_module_at_load",
nvm0 ? "'_NVM' cookie present after the no-content load"
: "'_NVM' cookie missing straight after the no-content "
"load -- the probe address is wrong");
results[nres++] = mkres(nvm, "case7_nvm_module_present",
nvm ? "'_NVM' cookie present after the insert reboot"
: "'_NVM' cookie missing after the insert reboot -- games "
"launched by insert would not see the Memory Track");
pass = seeded && nvm0 && inserted && same && kept && nvm;
break;
}
case 8: {
/* Memory Track disabled: a bare no-content session has nothing to
* persist and keeps reporting 0 (the pre-#810 contract). Mounting a
* disc must NOT flip that mid-session -- see the header comment. */
int bare_zero, inserted, still_zero;
bare_zero = lr_mem_size(RETRO_MEMORY_SAVE_RAM) == 0
&& lr_mem_data(RETRO_MEMORY_SAVE_RAM) == NULL;
gi.path = disc_path;
inserted = cfg.disk_cb_registered
&& cfg.disk_add_image_index()
&& cfg.disk_replace_image_index(0, &gi)
&& cfg.disk_set_eject_state(true)
&& cfg.disk_set_eject_state(false);
harness_step(&cfg);
still_zero = lr_mem_size(RETRO_MEMORY_SAVE_RAM) == 0
&& lr_mem_data(RETRO_MEMORY_SAVE_RAM) == NULL;
results[nres++] = mkres(bare_zero, "case8_bare_session_has_no_save_ram",
bare_zero ? "no Memory Track, no disc: SAVE_RAM is 0/NULL"
: "bare session exposes SAVE_RAM with nothing to save "
"(is the memory_track option actually disabled?)");
results[nres++] = mkres(inserted, "case8_insert_succeeded",
inserted ? "disc inserted" : "insert was refused");
results[nres++] = mkres(still_zero, "case8_save_ram_stays_unexposed",
still_zero ? "SAVE_RAM still 0/NULL after the insert (no write "
"without a load)"
: "SAVE_RAM appeared mid-session: the frontend never "
"loaded the .srm into it, so its exit save would "
"clobber the previous session's file");
pass = bare_zero && inserted && still_zero;
break;
}
case 3: {
int added, refused, still_ejected, unchanged, still_runs;