webstation-broker
DeveloperPython referenceemulatorsemulators.duckstation

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
= True

On; 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_DIR

DuckStation'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
= True

On; activate empties both save subtrees, keeping quarantined states.

attributerom_extensions
= ROM_EXTENSIONS

Bootable disc formats, best first.

attributelog_path
= DUCKSTATION_LOG_PATH

The 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]
= None
attribute_exit_stateOptional[Path]
= None

Functions

func__init__(self) -> None

Initialize the emulator with no disc booted yet.

paramself

Returns

None
funcsave_file_kind(self, rel) -> str

Classify 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.

paramself
paramrelstr

The member path, relative to save_root and posix-separated.

Returns

str

One of the kinds Emulator.save_file_kind defines.

funcimport_spec(self) -> imports.ImportSpec

Declare 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.

paramself

Returns

webstation_broker.imports.ImportSpec

The 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.

paramself
parammemberimports.ImportMember

The member, already past the kind gate.

paramspecimports.ImportSpec

This emulator's spec.

paramctximports.ImportCtx

The launch context.

Returns

typing.Union

The 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.

paramself
paramplanlist[imports.Placement]

The placements that passed every per-member check.

paramctximports.ImportCtx

The launch context; its archive_paths are the archive's ordinary members.

Returns

list

One 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.

paramself

Returns

typing.Optional

A PlayStation serial source with no rom reader.

funcclear_working_slot(self, excluded=()) -> None

Empty 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.

paramself
paramexcludedtuple[str, ...]
= ()

Subtrees carried by the whole-card routes. DuckStation names no memory card subtree, so this is always empty.

Returns

None
func_set_aside_untrusted_state(self, path) -> None

Rename 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.

paramself
parampathPath

The resume state to move aside.

Returns

None
funcresolve_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.

paramself
parampathPath

The ROM file or folder RomM handed over.

Returns

typing.Optional

The image to pass to duckstation-qt, or None when there is nothing bootable.

funclaunch(self, rom_path, resume_slot) -> None

Stop 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.
paramself
paramrom_pathPath

The 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

None
funcsave_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.

paramself
paramslotOptional[int]

The slot RomM asked for, echoed back unchanged; None reports no state even though DuckStation still writes one.

Returns

dict

A 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.

paramself

Returns

typing.Optional

The state file's path, or None when no saving exit has confirmed one

On this page