Skip to content

fix(app): follow a disc swap in the content identity; keep the .srm on the opened disc (#1010) - #1011

Merged
JoeMatt merged 2 commits into
developfrom
feat/app-disc-swap-content-identity
Oct 17, 2026
Merged

JoeMatt merged 2 commits into
developfrom
feat/app-disc-swap-content-identity

Conversation

@JoeMatt

@JoeMatt JoeMatt commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1010

Draft: stacked on #1006, which is stacked on #1000. The branch is cut from fix/app-disk-control-images. The new commits are the top two: fix(app): follow a disc swap in the content identity... and docs(app): .... Merge #1000, then #1006, then rebase this one. It targets develop because app CI only runs on PRs into develop.

Problem

After a disc swap (File > Disc, or Add disc image...), the core reboots on the new disc. The app still named the first disc as the content, so:

  • Save states went to the first disc's slot. A state saved on disc B landed in disc A's slot. The core refuses that state whenever A is mounted (savestate v14 records which disc it was taken on).
  • Everything else named disc A too: achievements, cheats, the window title, Discord, and the TV menu title.

What follows what

Follows the disc in the tray (contentPath()) Stays with the opened disc (loadedPath())
Save-state slots and thumbnails, including the auto slot. The core only accepts a state on the disc it was taken on. The .srm file, and the fallback copy a second app instance reads.
Achievements, through rc_client_begin_identify_and_change_media. A disc of the same game keeps the session and hardcore. rcheevos decides what an unknown disc means. Restart (reloadContent).
Cheats re-key, because the core re-reads the per-title row on insert (titledb_probe_disc). Recents and the library's recently played.
Window title, Discord, TV menu title. These read contentPath every frame and needed no change. Per-game control overrides (input.setContent). These are picked once per open; left unchanged to keep scope.

Why the .srm stays. The core's save RAM is the EEPROM pair plus the Memory Track. It survives an insert and is never reloaded (disk_set_eject_state only re-installs the NVM BIOS module). So the buffer still holds what was read from A's .srm, plus anything B wrote.

  • Re-keying the file to B would write A's Memory Track into B.srm.
  • It would also split one Memory Track across two files.

Keeping the save with the opened content is what RetroArch does. It is also what an .m3u session would do.

Recents follow the same reasoning. Reopening from recents is a fresh load, and it reads the .srm of the entry it opens. Disc A's entry is the one that brings the session's saves back.

Change

CoreHost

  • contentPath() / contentName() now name the disc in the tray. followMountedDisc moves them only when an insert worked and left the tray closed.
    • The image index alone is not enough: the core moves disk_index before it tries the insert, and leaves it moved when the insert fails (it puts the previous disc back and leaves the tray open).
    • Off the owner thread, these answer from EmuSnapshot, because discSetEject / discSetIndex may run as queued commands.
  • New loadedPath() / loadedName() hold what was opened; saveRamPath() uses them. New discSwaps() counts swaps.

App::followDiscSwap()

  • Runs once per UI iteration, after addDisc, and in the headless check, so it catches every disc menu: ImGui, native and TV.
  • On a swap it drops a queued state load and the resume offer that belonged to the previous disc.
  • In one call(), it hands the swap to achievements and re-keys cheats, then drops the cached state thumbnails.

Achievements — onDiscChanged()

  • If the first disc was never identified (.chd, .cdi, or signed out), it starts a fresh identify.
  • If rcheevos disables hardcore for an unidentified disc, the app leaves it off. Calling applyHardcore() would have turned it straight back on.

Tests

--disc-swap (discSwapHeadlessCheck) now checks, after each step and as the UI reads it (the pinned snapshot when threaded):

  • After the add: contentPath, contentName and statePath(slot) name disc B. loadedPath and saveRamPath are unchanged. A state is saved, and synth_jagcd_multi.slot0.state exists.

  • With the tray open: the content is still disc B.

  • Back on disc A: the content is A again, and exactly one swap was counted.

  • New line: it prints DISC content synth_jagcd -> synth_jagcd_multi -> synth_jagcd, save RAM synth_jagcd.

  • Data dirs: smoke_disc_swap, smoke_disc_swap_threaded and smoke_disc_swap_bios_threaded each get their own --data-dir in the build tree, so the saved state never reaches a user folder.

  • Negative check: with followMountedDisc disabled, smoke_disc_swap fails with after the add, the content is '.../synth_jagcd.chd' and the state slot is .../synth_jagcd.slot0.state.

  • Full app suite: 117/117 pass on macOS arm64 after the restack, including all 21 emu_thread tests.

  • TSan, local (macOS): the 11 emu_thread tests that don't run the app binary pass. That includes core_host_thread, which queues discSetEject / discSetIndex (the snapshot write path). macOS refuses to load the sanitizer into the app binary ("Sanitizer load violates platform policy"), so the threaded disc-swap tests get TSan only from the Linux tsan job.

  • CI from the base: before fix(app): register the loaded CD with disk control so the disc menu works (#1003) #1006 was rebased, its ThreadSanitizer job was red on a lock-order inversion in smoke_yarc_threaded, a cart run with no disc. Its macOS job also failed emu_thread_unit once. Both come from feat(app): emulation on its own thread, phase 1 (#967) #1000, and its 4d7668f targets them. This branch now sits on fix(app): register the loaded CD with disk control so the disc menu works (#1003) #1006 at 08b340f, on top of feat(app): emulation on its own thread, phase 1 (#967) #1000's tip c7de134.

Not covered:

  • Real disc identification for achievements. The fixtures are .chd, which rcheevos cannot hash.
  • The windowed menus. Per the repo rule, no app windows are opened on this Mac.

Not in this PR

  • Resume after quitting on disc B. The auto-save goes to B's slot. Reopening disc A from recents offers A's older auto-state. B's auto-state is reachable only by opening B directly, which reads B.srm. This follows from the table above. A real fix needs a multi-disc set identity, such as .m3u.
  • Per-game control overrides stay with the opened disc.
  • Cheats lag by one frame. On a swap from the ImGui or native menus, cheats re-key on the next loop iteration, so B's first frame still runs with A's cheats.

ðŸĪ– Generated with Claude Code

@JoeMatt
JoeMatt force-pushed the feat/app-disc-swap-content-identity branch from d4c185a to c7e7dfc Compare October 17, 2026 21:25
@github-actions github-actions Bot added ci CI, workflows, release automation documentation Docs, site, wiki, release notes labels Oct 17, 2026
@github-actions

Copy link
Copy Markdown

No automated review was posted on this PR — the plan is out of quota for the current billing cycle. This is a billing limit, not a repo or credential problem — it resumes on its own when the quota refreshes.

This PR has not been machine-reviewed. Posted by the review workflow so that an unreviewed PR is not mistaken for a clean one; it updates in place rather than repeating on every push.

@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Regression: linux-arm64

Regression Test Results

ROM Status Details Diff
jagniccc ✅ PASS 0 pixels differ -
yarc ✅ PASS 0 pixels differ -
jagniccc (determinism) ✅ PASS identical across runs -
yarc (determinism) ✅ PASS identical across runs -
jagniccc (frameskip) ✅ PASS skip=0 matches skip=3 -
yarc (frameskip) ✅ PASS skip=0 matches skip=3 -
jagniccc (save state) ✅ PASS round-trip matches -
yarc (save state) ✅ PASS round-trip matches -
jagniccc (rewind) ✅ PASS rewind matches -
yarc (rewind) ✅ PASS rewind matches -

Platform: Linux aarch64

Updated by CI at 2026-10-17T23:45:26.296Z

@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Regression: linux-x64

Regression Test Results

ROM Status Details Diff
jagniccc ✅ PASS 0 pixels differ -
yarc ✅ PASS 0 pixels differ -
jagniccc (determinism) ✅ PASS identical across runs -
yarc (determinism) ✅ PASS identical across runs -
jagniccc (frameskip) ✅ PASS skip=0 matches skip=3 -
yarc (frameskip) ✅ PASS skip=0 matches skip=3 -
jagniccc (save state) ✅ PASS round-trip matches -
yarc (save state) ✅ PASS round-trip matches -
jagniccc (rewind) ✅ PASS rewind matches -
yarc (rewind) ✅ PASS rewind matches -

Platform: Linux x86_64

Updated by CI at 2026-10-18T00:53:17.747Z

@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Regression: macos-arm64

Regression Test Results

ROM Status Details Diff
jagniccc ✅ PASS 0 pixels differ -
yarc ✅ PASS 0 pixels differ -
jagniccc (determinism) ✅ PASS identical across runs -
yarc (determinism) ✅ PASS identical across runs -
jagniccc (frameskip) ✅ PASS skip=0 matches skip=3 -
yarc (frameskip) ✅ PASS skip=0 matches skip=3 -
jagniccc (save state) ✅ PASS round-trip matches -
yarc (save state) ✅ PASS round-trip matches -
jagniccc (rewind) ✅ PASS rewind matches -
yarc (rewind) ✅ PASS rewind matches -

Platform: Darwin arm64

Updated by CI at 2026-10-18T00:44:12.147Z

JoeMatt and others added 2 commits October 17, 2026 19:08
â€Ķn the opened disc

After File > Disc / Add disc image... booted another disc, contentPath()/contentName() and everything keyed on them
still named the first disc.

CoreHost now keeps two identities:
- contentPath()/contentName(): the disc in the tray.  It moves only after an insert that worked and left the tray
  closed (followMountedDisc).  The core moves its image index before it tries the insert and keeps it when the insert
  fails, so the index alone is not enough.  Off the owner thread these answer from the snapshot, since
  discSetEject/discSetIndex may run as queued commands.
- loadedPath()/loadedName(): what was opened.  The .srm (and the second-copy fallback) is named after it, and Restart
  reopens it.  The core keeps SAVE_RAM (the EEPROM pair and the Memory Track) across an insert and never reloads it,
  so re-keying the file would write disc A's Memory Track into B's .srm.

App::followDiscSwap (main loop, addDisc, the headless check) catches up on a swap:
- save-state slots and thumbnails follow the tray (statePath reads contentName): the core refuses a state taken on a
  different disc (savestate v14 disc identity);
- achievements use rc_client_begin_identify_and_change_media, so the session and hardcore survive a disc of the same
  game; an unidentified first disc gets a fresh identify instead;
- cheats re-key (the core re-reads the per-title row on insert, titledb_probe_disc);
- a queued state load and the resume offer for the previous disc are dropped.
The window title and Discord read contentPath every frame, so they follow without changes.  Recents, the library's
recently played and the per-game control overrides stay with the opened disc: reopening must start from its .srm.

--disc-swap now checks after each step that the content and the state slot name the disc in the tray while
loadedPath and saveRamPath stay on the opened disc, and saves a state on the second disc.  The three disc-swap tests
get their own --data-dir so that state lands in the build tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit b96ce11)
â€Ķapshot

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit c7e7dfc)
@JoeMatt
JoeMatt marked this pull request as ready for review October 17, 2026 23:08
@JoeMatt
JoeMatt force-pushed the feat/app-disc-swap-content-identity branch from c7e7dfc to 1108a36 Compare October 17, 2026 23:08
Copilot AI balanced review requested due to automatic review settings October 17, 2026 23:08
@JoeMatt
JoeMatt merged commit d97d25a into develop Oct 17, 2026
43 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@JoeMatt
JoeMatt deleted the feat/app-disc-swap-content-identity branch October 17, 2026 23:09
JoeMatt added a commit that referenced this pull request Oct 17, 2026
â€Ķ1008)

Neither the core nor the app read .m3u.  The app now does (app/src/m3u.h, RetroArch's
format: one image per line, relative to the playlist or absolute; '#' lines, a BOM,
CRLF and a "|label" suffix are ignored).

- CoreHost::loadContent: an .m3u boots its first disc and appends every disc to the
  core's disc list (owner thread, inside the load).  Each entry must exist and be a
  cue/cdi/chd/iso, or the load fails with the entry named: a missing disc would still
  register and only fail when picked.
- Identity, on #1011's model: the playlist is what was opened (loadedPath/loadedName:
  one .srm per set, Restart, recents, the library's recently played, per-game
  controls); the disc in the tray is the content (contentPath/contentName: the window
  title, save-state slots, achievements, cheats) -- the first disc at boot, then
  whatever a swap boots.  finishLoad takes the opened path for this.
- File > Open filter takes .m3u; the headless load check, tv_script's load and the
  library's "load this state" compare against loadedPath.
- Library: isDiscExt includes m3u; the playlist claims the discs it lists (they leave
  the grid) and is identified by its first disc's track-1 CRC.
- Headless --expect-discs N: the disc list the menus read holds N images; at boot the
  content is disc 1 and the .srm / recents are the playlist's; after Open tray, File >
  Disc > last, Close tray the content and state slot are the last disc's while the
  .srm stays the playlist's.  smoke_m3u_playlist (inline) and
  smoke_m3u_playlist_threaded (--emu-thread on, emu_thread label) load
  tests/fixtures/synth_jagcd_set.m3u (../-relative entries to
  test/roms/synth_jagcd{,_multi}.chd).
- library_unit: parser cases and the scan dedupe / hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JoeMatt added a commit that referenced this pull request Oct 17, 2026
â€Ķ1008)

Neither the core nor the app read .m3u.  The app now does (app/src/m3u.h, RetroArch's
format: one image per line, relative to the playlist or absolute; '#' lines, a BOM,
CRLF and a "|label" suffix are ignored).

- CoreHost::loadContent: an .m3u boots its first disc and appends every disc to the
  core's disc list (owner thread, inside the load).  Each entry must exist and be a
  cue/cdi/chd/iso, or the load fails with the entry named: a missing disc would still
  register and only fail when picked.
- Identity, on #1011's model: the playlist is what was opened (loadedPath/loadedName:
  one .srm per set, Restart, recents, the library's recently played, per-game
  controls); the disc in the tray is the content (contentPath/contentName: the window
  title, save-state slots, achievements, cheats) -- the first disc at boot, then
  whatever a swap boots.  finishLoad takes the opened path for this.
- File > Open filter takes .m3u; the headless load check, tv_script's load and the
  library's "load this state" compare against loadedPath.
- Library: isDiscExt includes m3u; the playlist claims the discs it lists (they leave
  the grid) and is identified by its first disc's track-1 CRC.
- Headless --expect-discs N: the disc list the menus read holds N images; at boot the
  content is disc 1 and the .srm / recents are the playlist's; after Open tray, File >
  Disc > last, Close tray the content and state slot are the last disc's while the
  .srm stays the playlist's.  smoke_m3u_playlist (inline) and
  smoke_m3u_playlist_threaded (--emu-thread on, emu_thread label) load
  tests/fixtures/synth_jagcd_set.m3u (../-relative entries to
  test/roms/synth_jagcd{,_multi}.chd).
- library_unit: parser cases and the scan dedupe / hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JoeMatt added a commit that referenced this pull request Oct 17, 2026
â€Ķ1008) (#1012)

Neither the core nor the app read .m3u.  The app now does (app/src/m3u.h, RetroArch's
format: one image per line, relative to the playlist or absolute; '#' lines, a BOM,
CRLF and a "|label" suffix are ignored).

- CoreHost::loadContent: an .m3u boots its first disc and appends every disc to the
  core's disc list (owner thread, inside the load).  Each entry must exist and be a
  cue/cdi/chd/iso, or the load fails with the entry named: a missing disc would still
  register and only fail when picked.
- Identity, on #1011's model: the playlist is what was opened (loadedPath/loadedName:
  one .srm per set, Restart, recents, the library's recently played, per-game
  controls); the disc in the tray is the content (contentPath/contentName: the window
  title, save-state slots, achievements, cheats) -- the first disc at boot, then
  whatever a swap boots.  finishLoad takes the opened path for this.
- File > Open filter takes .m3u; the headless load check, tv_script's load and the
  library's "load this state" compare against loadedPath.
- Library: isDiscExt includes m3u; the playlist claims the discs it lists (they leave
  the grid) and is identified by its first disc's track-1 CRC.
- Headless --expect-discs N: the disc list the menus read holds N images; at boot the
  content is disc 1 and the .srm / recents are the playlist's; after Open tray, File >
  Disc > last, Close tray the content and state slot are the last disc's while the
  .srm stays the playlist's.  smoke_m3u_playlist (inline) and
  smoke_m3u_playlist_threaded (--emu-thread on, emu_thread label) load
  tests/fixtures/synth_jagcd_set.m3u (../-relative entries to
  test/roms/synth_jagcd{,_multi}.chd).
- library_unit: parser cases and the scan dedupe / hash.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot removed the ci CI, workflows, release automation label Oct 18, 2026
@github-actions

Copy link
Copy Markdown

App packages

Built from 1108a36 (workflow run).

Platform Download Size
Linux x86_64: Flatpak bundle (.flatpak) app-package-flatpak 19.1 MB
Linux aarch64: .AppImage + .deb app-package-linux-aarch64 44.2 MB
Linux x86_64: .AppImage + .deb app-package-linux-x86_64 45.6 MB
macOS (universal): .zip app-package-macos-universal 30.0 MB
Windows ARM64: portable .zip + -setup.exe app-package-windows-arm64 41.5 MB
Windows x64: portable .zip + -setup.exe app-package-windows-x64 45.1 MB

Downloading an artifact needs a GitHub login. macOS: Developer ID signed and notarized (PRs from forks get
no signing secrets and stay ad-hoc signed: right-click > Open, or xattr -dr com.apple.quarantine "Virtual Jaguar.app").
Windows: not code-signed yet, SmartScreen may warn. Linux AppImage: chmod +x it.

Updated by CI at 2026-10-18T03:54:36.836Z

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Docs, site, wiki, release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

app: after a disc swap, the content identity still names the first disc

2 participants