emulators.duckstation.Duckstation
PlayStation 1 sessions on duckstation-qt.
PlayStation 1 sessions on duckstation-qt.
The broker launches duckstation-qt -batch -fullscreen -- <disc> after
forcing its settings.ini (no setup wizard, no power-off confirm, save a
state on exit, no state backups, no update check). There is no runtime
control channel, so the lifecycle is entirely command line and shutdown
driven. A resume passes the newest <serial>_resume.sav with
-statefile, resolved by the broker because DuckStation's own -resume
aborts on a missing file. A save is the graceful shutdown itself: stop()
sends SIGTERM, DuckStation writes the resume state on the way out, and
the write is confirmed by diffing the savestates directory across the
stop. term_timeout is raised well above the base default so the SIGKILL
escalation does not discard that write.
Because the resume state is the only state a shutdown produces, there is
no mid-session save or load, and supports_states stays at the base
default; the requested slot is echoed back purely for API symmetry. Save
data (memcards) and states (savestates) both ride the save archive.
DuckStation writes the resume state whether or not one was asked for, so
an exit without a slot simply leaves it unreported, and the emulator
resumes from it locally as usual. The state a saving exit does report is
also what state_path serves, so RomM can file it in its state library.
Memory card 1 is pinned to one shared card, memcards/shared_card_1.mcd,
so an imported card has a fixed name to land under. Launch copies an
older archive's per-game card over to it once, while it does not exist
yet. Declared imports take a raw card, as kind save or memcard, and
one resume state, renamed to the session's serial and marked as this
disc's; the state resumes through save.resume_slot.
Attributes
attributename= 'duckstation'RomM platform key, duckstation.
attributerom_cacheable= TrueOn; with the ROM cache enabled, launch boots a local copy of the ROM.
attributedisplay_name= 'DuckStation'Human-readable name shown in the UI.
attributesave_root= DATA_DIRDuckStation's data root, which the save subtrees hang off.
attributesave_subtrees= ('memcards', 'savestates')memcards and savestates, the directories the save archive carries.
attributestate_subtrees= ('savestates',)attributeclears_stale_saves= TrueOn; activate empties both save subtrees, keeping quarantined states.
attributerom_extensions= ROM_EXTENSIONSBootable disc formats, best first.
attributelog_path= DUCKSTATION_LOG_PATHThe DuckStation log the broker exposes.
attributeterm_timeout= float(os.environ.get('DUCKSTATION_STOP_WAIT', '30'))Seconds SIGTERM gets before SIGKILL (env DUCKSTATION_STOP_WAIT, default 30).
attribute_rom_pathOptional[Path]= Noneattribute_exit_stateOptional[Path]= NoneFunctions
func__init__(self) -> NoneInitialize the emulator with no disc booted yet.
paramselfReturns
Nonefuncsave_file_kind(self, rel) -> strClassify an archive member for the manifest.
The subtree default labels everything under savestates/ a state,
which would offer RomM the broker's own owner markers and the states
a force-killed exit set aside as states the player can pick. Neither
is loadable, so both ride the archive as opaque save data instead.
paramselfparamrelstrThe member path, relative to save_root and posix-separated.
Returns
strOne of the kinds Emulator.save_file_kind defines.
funcimport_spec(self) -> imports.ImportSpecDeclare what DuckStation takes: a raw memory card, and one resume state.
A card is accepted under either kind and lands as the pinned card.
The state rides the archive and resumes through save.resume_slot,
like the one the broker saves on exit. There is still no mid-session
save or load, so supports_states stays False.
paramselfReturns
webstation_broker.imports.ImportSpecThe spec.
funcplace_import(self, member, spec, ctx) -> Union[imports.Placement, imports.ImportRefusal]Place one declared member: a card as the pinned card, a state as the session's resume state.
paramselfparammemberimports.ImportMemberThe member, already past the kind gate.
paramspecimports.ImportSpecThis emulator's spec.
paramctximports.ImportCtxThe launch context.
Returns
typing.UnionThe placement, or a refusal.
funcvalidate_import_plan(self, plan, ctx) -> list[imports.ImportRefusal]Refuse an imported card when the archive already carries another one.
Launch carries an older archive's per-game card over to the pinned card only while the pinned card does not exist. Once an import has put one there, the per-game card is never mounted again, and the progress on it drops out of play without a word.
A card that clashes with another member is left to the shared one-member-per-destination check, which has already refused it, so it is not refused twice. That covers a second imported card, and an archive member at the pinned card's path, below it, or at a path the pinned card needs as a directory.
paramselfparamplanlist[imports.Placement]The placements that passed every per-member check.
paramctximports.ImportCtxThe launch context; its archive_paths are the archive's ordinary members.
Returns
listOne destination_conflict for the imported card, or none.
funcidentity_source(self) -> Optional[imports.IdentitySource]Take the session's serial from RomM.
The broker never reads a serial off the disc, so RomM's title_id is
the only source. An imported state is renamed to it, and one named for
another serial is refused.
paramselfReturns
typing.OptionalA PlayStation serial source with no rom reader.
funcclear_working_slot(self, excluded=()) -> NoneEmpty the save subtrees this session owns before the archive restore.
All titles share one flat state directory, and the broker cannot read the booting disc's serial to tell which state is its own. Emptying the directory here is what leaves the incoming archive's states, and the markers restored beside them, as the only pairs a resume can see. A marker outliving its state would be worse than none: DuckStation reuses a serial's filename, so the next state written under that name would inherit an ownership claim nothing verified.
The memory cards go the same way. A .mcd is named by slot, not by
player, DuckStation has no whole-card route to move it on, and the
restore only writes the members the incoming archive carries, so a
card the last player left would otherwise be mounted for this one and
ship back out in their dump.
States set aside under UNTRUSTED_SUFFIX are left alone. They can be
the only copy of that progress and no resume can pick them up anyway.
paramselfparamexcludedtuple[str, ...]= ()Subtrees carried by the whole-card routes. DuckStation names no memory card subtree, so this is always empty.
Returns
Nonefunc_set_aside_untrusted_state(self, path) -> NoneRename a resume state a force-killed exit may have torn, marker and all.
Size and mtime are all the broker has to judge a state by, and that
is enough to refuse to resume from one but not enough to destroy what
can be the only copy of the player's progress, so the file is moved
to an UNTRUSTED_SUFFIX sidecar instead of unlinked. Its marker
travels with it: left behind it would claim the next state DuckStation
writes under the same serial.
paramselfparampathPathThe resume state to move aside.
Returns
Nonefuncresolve_rom_file(self, path) -> Optional[Path]Resolve a RomM path to the disc image to boot.
A file is taken as is. A directory is searched one level deep for the
best candidate by _pick_rom_file.
paramselfparampathPathThe ROM file or folder RomM handed over.
Returns
typing.OptionalThe image to pass to duckstation-qt, or None when there is nothing bootable.
funclaunch(self, rom_path, resume_slot) -> NoneStop any running instance, prepare the data dir, and start duckstation-qt.
Preparing the data dir means patching settings.ini, then carrying an
older archive's per-game memory card over to the pinned card. The
binary comes from env DUCKSTATION_BIN (default
/opt/duckstation/AppRun). With resume_slot set, the resume state
_resume_state_for claims for this disc is passed with -statefile;
a resume with no state on disk is logged and boots clean. The disc is
recorded on the instance so the exit can mark the state it writes as
belonging to it.
Raises
RuntimeError: When the broker's settings.ini values cannot be applied, which would cost the session its exit save state, or when an older archive's memory card cannot be carried over, which would boot the session on a blank card.
paramselfparamrom_pathPathThe disc image or playlist to boot.
paramresume_slotOptional[int]Any slot to resume from (the number itself is not used), or None to boot clean.
Returns
Nonefuncsave_and_exit(self, slot) -> dict[str, Any]Stop the emulator and report the resume state its shutdown wrote.
The save is the graceful shutdown: the savestates directory is snapshotted, the process is stopped, and a resume state that appeared or changed across the stop is reported as the saved state and marked as belonging to the booted disc.
A state a force-killed exit may have torn is neither reported nor
left where a resume can find it, but it is kept:
_set_aside_untrusted_state renames it rather than deleting it.
paramselfparamslotOptional[int]The slot RomM asked for, echoed back unchanged; None reports no state even though DuckStation still writes one.
Returns
dictA dict with state_saved (bool), state_slot (slot as given) and state_file
funcstate_path(self) -> Optional[Path]Return the resume state the last saving exit confirmed, or None.
Only a confirmed exit state is served, never whatever sits in the savestates directory: a state already there came in with the archive and can belong to another disc, and one a force-killed exit set aside may be torn. A launch clears it, since the new session has confirmed nothing yet.
paramselfReturns
typing.OptionalThe state file's path, or None when no saving exit has confirmed one