emulators.flycast
Flycast (Sega Dreamcast) launcher.
Flycast (Sega Dreamcast) launcher.
Handles ROM resolution, save state via a graceful close request, and boot-time resume.
Flycast has no control socket and, unlike DuckStation, installs no
SIGTERM/SIGINT handler at all: a bare SIGTERM is a hard kill. Its only
graceful-shutdown path is SDL_QUIT (a window close request), which
dc_exit() turns into unloadGame(), and that is also the only place
Dreamcast.AutoSaveState gets written.
Getting a real SDL_QUIT out of it takes more than an xdotool window-close
command: windowclose is a raw XDestroyWindow, which never reaches SDL's own
event loop, and windowquit's _NET_CLOSE_WINDOW is sent to the root window
for a window manager to pick up, but this container's compositor (labwc, on
wlroots) has no handler for that message at all, only for its own internal
Close action. That action is what actually sends a real WM_DELETE_WINDOW to
the window (wlroots' wlr_xwayland_surface_close()), and the only way to
reach it externally is the same keybind a player would use: Alt+F4, which
labwc's default config binds to Close. stop() therefore activates the
window and sends that keypress, exactly the hotkey-injection shape
dolphin.py/ppsspp.py already use for their own hotkeys, before falling back
to the base SIGTERM->SIGKILL, the same graceful-trigger-then-escalate shape
shadPS4 uses for its IPC STOP command.
Everything the broker needs (auto-load, auto-save, slot, fullscreen) rides
-config section:key=value transient overrides on the command line rather
than a patched ini, so nothing here touches emu.cfg. The section for the
typed Dreamcast.* options is "config" (that's the whole key, dot included;
see core/cfg/option.cpp), not "Dreamcast" as the key's own name might
suggest.
Save state is a single deterministic file, <rom-basename>.state, in the
flat XDG data dir ($XDG_DATA_HOME/flycast or ~/.local/share/flycast, same
resolution flycast itself does). With slot pinned to 0 the broker can compute
that path itself instead of guessing at the newest or most-recently-changed
file in the directory.
That name is flycast's, not the broker's to pick, and it carries the rom's
basename alone: two discs sharing a basename in different folders (a region
or revision pair) write the same state file. So a confirmed write also
records a <rom-basename>.state.rom marker naming the rom it came from, the
same ownership trick duckstation.py uses on its flat savestates directory,
and a resume is refused when the marker names a different rom. A state with
no marker still loads, since an unidentified state is all the broker had
before.
VMU saves are loose files in the same flat data dir (vmu_save_A1.bin etc., shared across games by default), beside the arcade nvmem and eeprom files. The whole directory ships as one save subtree rather than named files, since nothing here otherwise separates VMU saves from savestates from a first-run onboarding artifact. clear_working_slot() sweeps all of it at activate: none of it is namespaced by title or by player, and it runs before the rom is even known (activate resolves the rom, then clears the slot, then launches), so anything left behind is handed to the next player as their own and swept into their exit archive. The BIOS pair, emu.cfg and flycast's own subdirectories stay; they belong to the container, not to a session.
attributelog= logging.getLogger(__name__)attributeROM_ROOT= Path(os.environ.get('ROM_ROOT', '/romm'))attributeFLYCAST_BIN= os.environ.get('FLYCAST_BIN', '/opt/flycast/AppRun')attributeDATA_DIR= xdg_data_dir('flycast')Flycast's data directory, holding the VMU saves and the savestate.
Not configurable, and deliberately: nothing on Flycast's command line names it,
so an override would move only the tree the broker dumps and restores and leave
Flycast writing its own. That loses saves without reporting anything. launch
exports the root this resolved to instead.
attributeFLYCAST_LOG_PATH= Path(os.environ.get('FLYCAST_LOG_PATH', '/config/flycast.log'))attributeROM_EXTENSIONS= ('.chd', '.gdi', '.cdi', '.cue', '.elf')attributeUNTRUSTED_SUFFIX= '.untrusted'Suffix a resume state is renamed with when a force-killed exit leaves it unusable.
The file is only suspected of being torn, never known to be, so it is set
aside under this name rather than deleted. It stops matching *.state, so
neither clear_working_slot nor a resume can pick it up, and it rides the
save archive as ordinary save data (see save_file_kind), which is what
makes it recoverable at all: DATA_DIR itself does not outlive the container.
attributeOWNER_SUFFIX= '.rom'Suffix of the marker file recording which rom a resume state belongs to.
The marker sits beside the state it names, holds the booted rom's resolved
path, and rides the save archive with it. It does not match *.state, so it
is never mistaken for a state.
attributeCONTAINER_FILES= frozenset({'dc_boot.bin', 'dc_flash.bin', 'emu.cfg'})DATA_DIR entries that belong to the container rather than to a session.
The Dreamcast BIOS pair is uploaded once during setup and every session needs
it, and emu.cfg holds the graphics, audio and controller preferences the
desktop session set. Everything else in the flat data dir is one player's
progress, so clear_working_slot sweeps it and leaves these three.
attributeSTATE_WAIT= float(os.environ.get('FLYCAST_STATE_WAIT', '30.0'))Seconds a shutdown save gets to land on disk (env FLYCAST_STATE_WAIT, default 30).
attributeSTATE_STABLE= float(os.environ.get('FLYCAST_STATE_STABLE', '1.5'))Seconds size and mtime must both hold still before a state counts as written.
From env FLYCAST_STATE_STABLE, default 1.5. Long enough that a stalled
write does not read as a finished one on a loaded host.
attributeVMU_IMAGE_BYTES= 131072The size of a raw VMU image, which is what Flycast mounts: 256 blocks of 512 bytes.
attributeDEFAULT_VMU= 'vmu_save_A1.bin'The VMU a memory-card import fills when its name picks none: controller A's first socket.
attributeNVMEM_NAME= 'dc_nvmem.bin'The Dreamcast's writable system flash (clock, language, and so on), as the Flycast binary names it.