"retroarch" activity kind #129

Open
albert wants to merge 9 commits from feat/retroarch-emulator-support into main
Owner

Fixes #125.

This provides a "retroarch" activity kind for content that can run within any of RetroArch's supported cores. Special considerations are made for:

  • locking out the rest of the RetroArch interface and booting directly into the emulated content
  • auto save/restore on close/open, including timeouts and API-based shutdowns
  • actually installing RetroArch and specific cores
Fixes #125. This provides a "retroarch" activity kind for content that can run within any of RetroArch's [supported cores](https://docs.libretro.com/guides/core-list/). Special considerations are made for: * locking out the rest of the RetroArch interface and booting directly into the emulated content * auto save/restore on close/open, including timeouts and API-based shutdowns * actually installing RetroArch and specific cores
A graceful stop sent three: a `pkill -f` by command name, a
process-group kill, and one signal per descendant. An app whose
handler *counts* signals reads the second as "the user is
impatient" -- RetroArch's `frontend_unix_sighandler` calls
`exit(1)` on it, skipping the in-game save flush and the save
state, so an emulator session could not be closed without losing
the child's progress.

`ManagedProcess::terminate` now signals the process group and
nothing else. `setsid()` at spawn makes the child the group
leader, so one group-directed signal still reaches it and every
descendant that stayed in the group. Anything that broke away is
left to the SIGKILL escalation rather than chased with a second
SIGTERM. The adapter's own `kill_by_command` on the graceful path
goes too: it was the second signal, and matching by command line
also reached unrelated copies of the same program running outside
the session.

The decision moves into a `GracefulSignal` enum so the rule is
unit-testable. Sandboxed kinds keep their cgroup- and app-id-based
delivery, since the real process is not in our child's group.

Note the behavioural test asserts that the activity completes its
shutdown, not that one signal arrived: a shell stand-in folds two
SIGTERMs milliseconds apart into a single trap invocation, so
counting receipts cannot tell one from two. Verified that the
C-level case it stands in for does break, which is why the
`GracefulSignal` unit test pins the mechanism separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmzB2jqPktJdFPNpg89h4M
Emulated games launched as plain `type = "process"` entries lose the
child's progress: RetroArch only writes the in-game save and its
resume state on a clean exit, and nothing arranges for one. The
dedicated kind makes an emulator behave like every other supervised
activity -- close saves, open restores, and the child cannot walk
out of the game into RetroArch's file browser.

    [entries.kind]
    type = "retroarch"
    core = "mgba"
    content = "~/Games/retroarch/pokemon-firered.gba"

`shepherd-host-linux::retroarch` renders the settings that need to
hold into a config fragment passed with `--appendconfig`: per-entry
save/state directories, save-state on close and restore on open,
`autosave_interval` so the in-game save survives a crash,
`pause_nonactive = false` (the HUD takes keyboard focus for its
prompts), and kiosk mode. RetroArch sessions also get a longer
graceful-stop floor, since their shutdown has to unload the core
and write both kinds of save.

The user's own `retroarch.cfg` is never edited. That takes an
explicit setting to guarantee: `config_save_on_exit` defaults to
*true*, so a clean exit would otherwise write RetroArch's whole
live settings block -- everything we appended included -- back into
it, making per-activity choices permanent and global.

Core names are resolved against the files actually present rather
than a filename computed from the name, because the two disagree
for most cores: `genesisplusgx` ships `genesis_plus_gx_libretro.so`,
`mupen64plus-next` ships `mupen64plus_next_libretro.so`, and the ten
Beetle cores are libretro's Mednafen forks. Of 87 packaged cores, 74
match by name and 12 need the alias table; both sets are covered by
tests built from the package contents.

`content` must be absolute or `~/`-prefixed: a bare relative path
resolves against the daemon's working directory, not the operator's,
so it would fail at launch rather than at config validation.

`SpawnOptions` gains `entry_id` so per-entry state can be keyed by
it -- two entries pointing at the same ROM keep separate progress.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmzB2jqPktJdFPNpg89h4M
`shepherd-admin apps install retroarch [--ppa[=channel]] [core...]`,
beside the existing steam and chrome backends. Cores are named the
way an entry's `core =` field names them (`mgba`, not the
`libretro-mgba` package), so there is one spelling to learn, and the
naming holds across both sources.

Cores come from apt, never from RetroArch's built-in core
downloader: that fetches unsigned binaries at runtime, which a
supervised kiosk should not be doing behind the operator's back.

The Ubuntu archive packages 14 cores. The rest -- N64, GameCube/Wii,
Saturn, arcade, ~85 more -- are packaged only by the libretro team's
PPA, so `--ppa` opts into it. That adds a third-party apt source for
the whole system, which is why it is not the default; the command
says so before adding it and prints how to remove it.

The channel names invite the wrong guess, so a bare `--ppa` selects
`testing`: `ppa:libretro/stable` publishes the frontend *only*, no
cores at all, at the same upstream version the archive already ships
on recent releases. `--ppa=stable` remains available for tracking
upstream frontend builds.

Core names are validated against the built-in list before asking for
root, so a typo doesn't cost a sudo prompt; with `--ppa` they are
checked against apt after the repository is added, since the point
is that the catalog is larger.

No games are installed, and none can be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmzB2jqPktJdFPNpg89h4M
Save-state resume has a consequence: once every launch restores
where the child left off, the game's own title screen becomes
unreachable. This adds the "reboot the console" button the HUD
needs, beside the "X", shown only for activities that offer it.

`reset_current` stops the activity cleanly (so it flushes the
in-game save), deletes the save state that would otherwise resume,
and relaunches it under the same session -- same id, same deadline,
same clock, no new cooldown. The child's actual saved game is
untouched: resetting a console returns it to its title screen, it
does not wipe the cartridge.

Spawn options are resolved through one shared `resolve_spawn`, so a
restarted activity comes back under exactly the rules it launched
with; a reset that quietly dropped the firewall or browser policy
would be a hole.

Deliberately no use of RetroArch's UDP command interface, which has
a `RESET` verb that would do this in place. It binds to all
interfaces, cannot be restricted to localhost (there is no setting;
`command_network_new` passes a NULL host to `socket_init`, which
makes it passive), and has no authentication -- anyone on the
network could quit a child's game or load different content into
it. Nothing here opens a listening socket.

The exit-event race needed two guards, not one. `CoreEngine` marks
itself restarting so the teardown's exit -- which the host reports
exactly like a crash -- does not end the session. That alone was not
enough: the host reports exits through a channel the reset does not
wait on, so the outgoing process's exit routinely arrived *after*
the restart finished, and ended a session whose activity was running
fine. `notify_process_exited` now ignores an exit from a process
that no longer backs the session, which closes that by construction
and also fixes a pre-existing hazard: a previous activity's late
exit could end a freshly launched session, since the old path never
looked at which process had died.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmzB2jqPktJdFPNpg89h4M
This is what issue #125 originally asked for: show RetroArch driven
from its CLI, in the example config and in prose.

`config.example.toml` gains the issue's own Pokemon FireRed entry,
with the two problems in the original fixed -- an absolute content
path (the relative one would have resolved against the daemon's
working directory) and the XWayland HiDPI workaround explained
rather than merely present.

`docs/emulators.md` covers installing, the full catalog of 87
packaged cores with the value each takes in `core =`, the two kinds
of save and which one the reset button touches, where files live and
how to back them up, what shepherd writes into RetroArch's config
and what it deliberately leaves alone, and troubleshooting.

The catalog is derived from the packages themselves -- `dpkg -c`
over every `libretro-*` in both sources, joined with the
`libretro-core-info` database -- rather than from package
descriptions, because the two disagree often enough to matter. It
notes the two packages that ship several cores (`vice` ships ten,
`beetle-psx` ships both PlayStation renderers), the eight
transitional packages whose old names are not valid `core =` values,
and `libretro-bash-launcher`, which is omitted deliberately: it
makes RetroArch execute shell scripts as content, which in a
supervised kiosk turns any activity into arbitrary command
execution.

Also records the scoping and as-built notes for all four phases
under docs/ai/history, including the findings that only showed up
against real hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmzB2jqPktJdFPNpg89h4M
Written from the canonical VERSION file by `shepherd version set`,
which updates the Cargo and npm literals that cannot read it at
manifest-parse time. `shepherd version check` passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmzB2jqPktJdFPNpg89h4M
feat(host): warn when a RetroArch override fights the generated config
All checks were successful
CI / Version harmony (pull_request) Successful in 10s
CI / Arch neutrality (pull_request) Successful in 10s
CI / ShellCheck (pull_request) Successful in 14s
CI / CI image (pull_request) Successful in 22s
CI / CI image (Android) (pull_request) Successful in 21s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 11s
CI / Warm cargo registry (pull_request) Successful in 35s
CI / Android companion (unit tests) (pull_request) Successful in 1m38s
CI / Android media (cargo-ndk build) (pull_request) Successful in 2m7s
CI / Firewall E2E (pull_request) Successful in 3m48s
CI / Clippy (pull_request) Successful in 2m24s
CI / Build (pull_request) Successful in 6m1s
CI / Package (.deb smoke build) (pull_request) Successful in 3m12s
CI / E2E (pull_request) Successful in 6m19s
CI / Test (pull_request) Successful in 7m30s
77bbe88c35
Settings made outside shepherd already carry into supervised
sessions: the fragment is *appended* to the user's own
retroarch.cfg, so controller bindings, video settings and per-core
options configured from a normal desktop session are inherited, and
`config_save_on_exit = "false"` keeps the traffic one-way.

Per-core overrides are the exception. RetroArch applies
`config/<Core>/<Core>.cfg` -- and the per-content-directory and
per-game files beside it -- *after* `--appendconfig`, so an override
naming one of the nine settings the fragment relies on wins. Two
fail quietly: `kiosk_mode_enable = "false"` unlocks RetroArch's menu
inside a supervised session, and the `savestate_auto_*` pair break
resume with no error at all -- the activity runs fine and the child
just loses their place. Confirmed against the real emulator: a core
override with `savestate_auto_save = "false"` meant no save state
was written on close.

Disabling overrides wholesale (`auto_overrides_enable = "false"`)
would guarantee shepherd's settings but throw away the per-core
tuning that is the reason overrides exist, so this warns instead,
naming the file and the offending keys. The operator's overrides
stay theirs.

Read-only and best-effort: an unreadable or absent config tree
reports nothing, and nothing here can fail a launch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmzB2jqPktJdFPNpg89h4M
fix(host): keep the in-game save where RetroArch puts it
All checks were successful
CI / Version harmony (pull_request) Successful in 14s
CI / Arch neutrality (pull_request) Successful in 11s
CI / ShellCheck (pull_request) Successful in 16s
CI / CI image (pull_request) Successful in 23s
CI / CI image (Android) (pull_request) Successful in 22s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 11s
CI / Warm cargo registry (pull_request) Successful in 18s
CI / Android media (cargo-ndk build) (pull_request) Successful in 33s
CI / Android companion (unit tests) (pull_request) Successful in 1m45s
CI / Build (pull_request) Successful in 2m49s
CI / Clippy (pull_request) Successful in 1m25s
CI / Test (pull_request) Successful in 3m1s
CI / E2E (pull_request) Successful in 3m2s
CI / Firewall E2E (pull_request) Successful in 3m47s
CI / Package (.deb smoke build) (pull_request) Successful in 2m11s
a1cd67fda2
The first cut redirected `savefile_directory` into a per-entry
directory, for isolation and a one-directory backup story. That was
the wrong split, and it silently stranded existing saves.

RetroArch defaults the in-game save to
`~/.config/retroarch/saves/<Core>/`, so a `type = "process"` entry --
the shape issue #125 started from -- wrote the child's save there.
Redirecting it pointed the new entry kind somewhere that save had
never been: the game booted as a fresh cartridge
(`[SRAM] Skipping SRAM load.`) and started accumulating a second,
divergent save. Nothing was destroyed, but the child's progress was
invisible and would fork from then on.

The same redirect broke playing a game from a normal desktop session
and then handing the machine over: one game, one save per launch
path.

Split by ownership instead. The in-game save is the game's, so it
stays where RetroArch puts it -- shared with desktop play, and found
with no migration step. The save state is shepherd's, since nothing
outside a supervised session produces one, so it stays in the
per-entry directory and keeps two entries on one ROM resuming
independently.

Verified against the real emulator: with a save at the legacy
location, `Skipping SRAM load` is gone and the session reads it and
writes back to the same file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmzB2jqPktJdFPNpg89h4M
albert force-pushed feat/retroarch-emulator-support from a1cd67fda2
All checks were successful
CI / Version harmony (pull_request) Successful in 14s
CI / Arch neutrality (pull_request) Successful in 11s
CI / ShellCheck (pull_request) Successful in 16s
CI / CI image (pull_request) Successful in 23s
CI / CI image (Android) (pull_request) Successful in 22s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 11s
CI / Warm cargo registry (pull_request) Successful in 18s
CI / Android media (cargo-ndk build) (pull_request) Successful in 33s
CI / Android companion (unit tests) (pull_request) Successful in 1m45s
CI / Build (pull_request) Successful in 2m49s
CI / Clippy (pull_request) Successful in 1m25s
CI / Test (pull_request) Successful in 3m1s
CI / E2E (pull_request) Successful in 3m2s
CI / Firewall E2E (pull_request) Successful in 3m47s
CI / Package (.deb smoke build) (pull_request) Successful in 2m11s
to b3656e34f2
All checks were successful
CI / Version harmony (pull_request) Successful in 14s
CI / Arch neutrality (pull_request) Successful in 13s
CI / ShellCheck (pull_request) Successful in 17s
CI / CI image (pull_request) Successful in 23s
CI / CI image (Android) (pull_request) Successful in 22s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 17s
CI / Warm cargo registry (pull_request) Successful in 23s
CI / Android media (cargo-ndk build) (pull_request) Successful in 37s
CI / Android companion (unit tests) (pull_request) Successful in 1m55s
CI / Build (pull_request) Successful in 2m55s
CI / Clippy (pull_request) Successful in 1m25s
CI / Test (pull_request) Successful in 3m8s
CI / E2E (pull_request) Successful in 3m5s
CI / Firewall E2E (pull_request) Successful in 3m48s
CI / Package (.deb smoke build) (pull_request) Successful in 2m9s
2026-08-17 23:37:17 +00:00
Compare
albert force-pushed feat/retroarch-emulator-support from b3656e34f2
All checks were successful
CI / Version harmony (pull_request) Successful in 14s
CI / Arch neutrality (pull_request) Successful in 13s
CI / ShellCheck (pull_request) Successful in 17s
CI / CI image (pull_request) Successful in 23s
CI / CI image (Android) (pull_request) Successful in 22s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 17s
CI / Warm cargo registry (pull_request) Successful in 23s
CI / Android media (cargo-ndk build) (pull_request) Successful in 37s
CI / Android companion (unit tests) (pull_request) Successful in 1m55s
CI / Build (pull_request) Successful in 2m55s
CI / Clippy (pull_request) Successful in 1m25s
CI / Test (pull_request) Successful in 3m8s
CI / E2E (pull_request) Successful in 3m5s
CI / Firewall E2E (pull_request) Successful in 3m48s
CI / Package (.deb smoke build) (pull_request) Successful in 2m9s
to 5217d7de9d
All checks were successful
CI / Version harmony (pull_request) Successful in 12s
CI / ShellCheck (pull_request) Successful in 17s
CI / Arch neutrality (pull_request) Successful in 9s
CI / CI image (pull_request) Successful in 27s
CI / CI image (Android) (pull_request) Successful in 30s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 12s
CI / Warm cargo registry (pull_request) Successful in 38s
CI / Android companion (unit tests) (pull_request) Successful in 1m45s
CI / Android media (cargo-ndk build) (pull_request) Successful in 2m48s
CI / Firewall E2E (pull_request) Successful in 4m48s
CI / Clippy (pull_request) Successful in 3m17s
CI / Package (.deb smoke build) (pull_request) Successful in 3m34s
CI / Build (pull_request) Successful in 8m54s
CI / E2E (pull_request) Successful in 9m28s
CI / Test (pull_request) Successful in 11m32s
2026-08-19 04:27:41 +00:00
Compare
Merge branch 'main' into feat/retroarch-emulator-support
All checks were successful
CI / Version harmony (pull_request) Successful in 11s
CI / Arch neutrality (pull_request) Successful in 11s
CI / ShellCheck (pull_request) Successful in 17s
CI / CI image (pull_request) Successful in 25s
CI / CI image (Android) (pull_request) Successful in 23s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 14s
CI / Warm cargo registry (pull_request) Successful in 22s
CI / Android media (cargo-ndk build) (pull_request) Successful in 47s
CI / Android companion (unit tests) (pull_request) Successful in 1m50s
CI / Clippy (pull_request) Successful in 1m51s
CI / Build (pull_request) Successful in 3m43s
CI / Firewall E2E (pull_request) Successful in 4m41s
CI / Test (pull_request) Successful in 4m3s
CI / E2E (pull_request) Successful in 3m42s
CI / Package (.deb smoke build) (pull_request) Successful in 2m33s
e4933ab862
All checks were successful
CI / Version harmony (pull_request) Successful in 11s
Required
Details
CI / Arch neutrality (pull_request) Successful in 11s
Required
Details
CI / ShellCheck (pull_request) Successful in 17s
Required
Details
CI / CI image (pull_request) Successful in 25s
Required
Details
CI / CI image (Android) (pull_request) Successful in 23s
Required
Details
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 14s
Required
Details
CI / Warm cargo registry (pull_request) Successful in 22s
CI / Android media (cargo-ndk build) (pull_request) Successful in 47s
Required
Details
CI / Android companion (unit tests) (pull_request) Successful in 1m50s
Required
Details
CI / Clippy (pull_request) Successful in 1m51s
Required
Details
CI / Build (pull_request) Successful in 3m43s
Required
Details
CI / Firewall E2E (pull_request) Successful in 4m41s
Required
Details
CI / Test (pull_request) Successful in 4m3s
Required
Details
CI / E2E (pull_request) Successful in 3m42s
Required
Details
CI / Package (.deb smoke build) (pull_request) Successful in 2m33s
Required
Details
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/retroarch-emulator-support:feat/retroarch-emulator-support
git switch feat/retroarch-emulator-support
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
albert/shepherd-launcher!129
No description provided.