webstation-broker
DeveloperPython referenceemulatorsemulators.xemu

emulators.xemu.Xemu

Original Xbox via xemu, with saves synced at the FATX level.

Original Xbox via xemu, with saves synced at the FATX level.

xemu is a QEMU derivative with no control channel the broker can reach, so the session is command line in (-dvd_path) and SIGTERM out. SIGTERM gives QEMU a clean shutdown that flushes the HDD image the post-close extraction then reads, so the grace window is long enough for the flush to land before the SIGKILL escalation tears it. Any xemu the broker does not own is stopped before every hook, SIGTERM first, because an orphan keeps writing the image the hooks are about to touch. Display settings (renderer and fullscreen) are pinned into xemu.toml before each launch, since xemu rewrites that file on exit and drops both.

Save data lives inside the raw HDD image's FATX E partition. An archive the activate restored lands in a staging directory next to the image and is injected into E:/UDATA and E:/TDATA before boot; after exit the launched title's trees are extracted back into the staging directory for the standard dump. The image outlives the session, so the launched title's trees on it are emptied before the injection: the injection only writes what the archive names, and anything it does not overwrite would otherwise be the last player's, both readable in game and swept into this player's dump (_clear_stale_saves). There are no save states: the image is kept raw so pyfatx can read it, and a raw image cannot hold QEMU internal snapshots, so supports_states stays off and a resume slot is logged and ignored.

Attributes

attributename
= 'xemu'

Provider key, xemu.

attributerom_cacheable
= True

On; with the ROM cache enabled, launch boots a local copy of the ROM.

attributedisplay_name
= 'xemu'

Human-readable name.

attributerom_extensions
= ROM_EXTENSIONS

Bootable disc formats, .iso only.

attributelog_path
= XEMU_LOG_PATH

The emulator log file.

attributeclears_stale_saves
= True

True; prepare_restore empties the staging dir and _clear_stale_saves empties the title's trees on the image.

attributeterm_timeout
= float(os.environ.get('XEMU_STOP_WAIT', '15'))

SIGTERM grace before SIGKILL (env XEMU_STOP_WAIT, default 15).

attributehdd_image
= _hdd_image_path()

The HDD image xemu mounts, resolved once per session.

attributestaging_dir
= self.hdd_image.parent / SAVE_STAGING_DIRNAME

Host-side directory the dump and restore read and write.

attributesave_root
= self.hdd_image.parent

The image's parent directory, which the save subtrees hang off.

attributesave_subtrees
= (SAVE_STAGING_DIRNAME,)

The staging directory name, scoping dump and restore to it.

attribute_restore_pending
= False
attribute_restore_failed
= False
attribute_forced_exit
= False
attribute_title_idOptional[str]
= None

Functions

func__init__(self) -> None

Resolve the HDD image and staging directory for this session.

paramself

Returns

None
func_clear_staging(self) -> None

Remove the staging directory and everything under it, logging a failure.

paramself

Returns

None
funcstop(self) -> None

Stop xemu, recording whether it had to be killed rather than asked.

QEMU flushes the HDD image on the clean shutdown SIGTERM starts and not on SIGKILL, so an escalation leaves the image holding whatever happened to be written through when the process died. The post-close extraction reads that same image seconds later and cannot tell the difference on its own, so the escalation is recorded here for it.

paramself

Returns

None
func_clear_stale_saves(self) -> Optional[int]

Pre-launch hook: empty the launched title's save trees on the image.

The HDD image outlives the session. prepare_restore empties the host side staging directory, but nothing empties the image itself, and _inject_saves only writes the files the incoming archive names. So whatever the last player left under this title survives every file the archive does not overwrite: readable by this player from inside the game, and swept back out by _extract_saves at exit into this player's archive, where it overwrites what RomM held for them.

The sweep is scoped to this title's directory under each top level save directory, which is exactly what the exit extraction reads back, so nothing this session can archive is left holding another session's data. It stops there: another title's directory is not in this session's dump, and the dashboard's own TDATA is what the image needs to boot. Without a title id there is nothing to scope to and nothing is cleared, which is safe here because that session archives nothing either (see _save_roots).

The roots are resolved here rather than through _save_roots because the two want opposite answers for the same disk. A top level directory that is missing means the extraction cannot know the save set and has to report a failure, while for a clear it means there is demonstrably no save data to leak, which is a clean zero. Reusing it would make the first session on a freshly formatted image look like a failed clear.

Only the files go. The directories stay, both because an empty tree contributes nothing to an extraction and because _inject_saves matches its path components against the directories already on the disk, which is how an archive whose case differs still lands in place.

paramself

Returns

typing.Optional

The number of files removed, 0 when the title has no save tree

func_inject_saves(self) -> Optional[int]

Pre-launch hook: write every staged file into the FATX E partition.

Hidden entries and symlinks in the staging dir are skipped. Directory components are matched case-insensitively against the disk so an archive whose case differs lands in the existing directory.

A failed injection is reported apart from an empty one rather than as another zero: the two look identical to the caller, but a session whose restore never reached the image must not archive over the player's saves at exit, and one that simply had nothing to restore may.

paramself

Returns

typing.Optional

The number of files that landed on the image, 0 when nothing was

func_save_roots(self, fs) -> Optional[list[str]]

The partition directories this session's saves live under.

paramself
paramfsFatx

The open FATX filesystem.

Returns

typing.Optional

The absolute partition paths to extract, empty when the title has

func_extract_into(self, fs, roots, dest) -> Optional[int]

Copy every file under roots out of the image into dest.

paramself
paramfsFatx

The open FATX filesystem.

paramrootsIterable[str]

Absolute partition paths to walk.

paramdestPath

Host directory the files are written under.

Returns

typing.Optional

The number of files written, or None when a directory listing

func_swap_staging(self, scratch) -> bool

Put a finished extraction in place as the staging directory.

paramself
paramscratchPath

The finished extraction, a sibling of the staging dir.

Returns

bool

True when the staging directory now holds the extraction, False

func_extract_saves(self) -> Optional[int]

Post-close hook: copy the title's save data out of the FATX E partition.

The extraction runs into a scratch directory and is swapped over the staging one only once it has finished. The dump ships whatever staging holds, so clearing it up front would turn a failed read into an empty archive uploaded over the title's real saves; leaving the session's restored files in place instead means the dump finds nothing newer than the launch baseline and uploads nothing at all.

An extraction that finds nothing is treated the same way: swapping the empty result in would drop whatever the activate restored into staging earlier in the session, which is the one copy of the player's saves the broker still holds. Reporting zero and leaving staging alone costs at most a session of progress; the swap costs the save.

Files land carrying fresh mtimes, so that baseline filter ships all of them: the archive is the title's complete save set, not a delta. Without a title id nothing is extracted; see _save_roots.

paramself

Returns

typing.Optional

The number of files staged, 0 when there was nothing on the image

funcresolve_rom_file(self, path) -> Optional[Path]

The disc image to boot for path.

paramself
parampathPath

A ROM file, or a folder searched up to two levels deep.

Returns

typing.Optional

The file itself, the best-ranked .iso in the folder, or None.

funcprepare_restore(self) -> None

Get the image and staging dir ready before the archive is extracted.

Stops anything holding the image, makes sure it is raw, and empties the staging dir: leftovers from the previous session's dump would otherwise mix into the injection, and the newer-file guard could skip archive members over them. Marks the restore pending so the next launch injects what lands.

paramself

Returns

None
funcimport_spec(self) -> imports.ImportSpec

Declare what xemu takes: the title's UDATA and TDATA trees, and nothing else.

FATX names are 42 bytes at most and compare without regard to case, so the spec carries both facts for the shared name and clash checks.

paramself

Returns

webstation_broker.imports.ImportSpec

The spec.

funcplace_import(self, member, spec, ctx) -> Union[imports.Placement, imports.ImportRefusal]

File one declared save file under the session's title.

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.

funcidentity_source(self) -> Optional[imports.IdentitySource]

Take the session's title from the disc's certificate, then from RomM's Xbox id.

RomM's id names the session for the routes that report it, but not for an import: place_import places a save only under a title id the disc itself gave, since that is the only one the clear and the dump scope to.

paramself

Returns

typing.Optional

A hex8 source that reads the disc and reads RomM's id as an Xbox one.

funclaunch(self, rom_path, resume_slot) -> None

Inject any restored saves, pin display settings and boot the disc.

paramself
paramrom_pathPath

The XISO to boot.

paramresume_slotOptional[int]

Ignored with a warning; a raw image holds no states.

Returns

None
funcsave_and_exit(self, slot) -> dict[str, Any]

Stop xemu and stage the launched title's saves for the dump.

A failed extraction leaves the staging directory alone rather than emptying it, so the dump uploads nothing instead of an empty archive over the title's real saves. A session whose restore never reached the image is not extracted at all for the same reason: the disk holds the pre-restore save set, and archiving that would overwrite the player's own with an older one.

A forced exit is reported rather than allowed to skip the extraction. SIGKILL costs the guest's unflushed writes, so what comes off the image can be stale, but it is still the only copy of the session and the dump's newer-file baseline keeps it from replacing anything untouched.

paramself
paramslotOptional[int]

Ignored; there are no save states.

Returns

dict

The state fields all None (state_saved, state_slot,

On this page