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= TrueOn; with the ROM cache enabled, launch boots a local copy of the ROM.
attributedisplay_name= 'xemu'Human-readable name.
attributerom_extensions= ROM_EXTENSIONSBootable disc formats, .iso only.
attributelog_path= XEMU_LOG_PATHThe emulator log file.
attributeclears_stale_saves= TrueTrue; 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_DIRNAMEHost-side directory the dump and restore read and write.
attributesave_root= self.hdd_image.parentThe 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= Falseattribute_restore_failed= Falseattribute_forced_exit= Falseattribute_title_idOptional[str]= NoneFunctions
func__init__(self) -> NoneResolve the HDD image and staging directory for this session.
paramselfReturns
Nonefunc_clear_staging(self) -> NoneRemove the staging directory and everything under it, logging a failure.
paramselfReturns
Nonefuncstop(self) -> NoneStop 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.
paramselfReturns
Nonefunc_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.
paramselfReturns
typing.OptionalThe 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.
paramselfReturns
typing.OptionalThe 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.
paramselfparamfsFatxThe open FATX filesystem.
Returns
typing.OptionalThe 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.
paramselfparamfsFatxThe open FATX filesystem.
paramrootsIterable[str]Absolute partition paths to walk.
paramdestPathHost directory the files are written under.
Returns
typing.OptionalThe number of files written, or None when a directory listing
func_swap_staging(self, scratch) -> boolPut a finished extraction in place as the staging directory.
paramselfparamscratchPathThe finished extraction, a sibling of the staging dir.
Returns
boolTrue 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.
paramselfReturns
typing.OptionalThe 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.
paramselfparampathPathA ROM file, or a folder searched up to two levels deep.
Returns
typing.OptionalThe file itself, the best-ranked .iso in the folder, or None.
funcprepare_restore(self) -> NoneGet 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.
paramselfReturns
Nonefuncimport_spec(self) -> imports.ImportSpecDeclare 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.
paramselfReturns
webstation_broker.imports.ImportSpecThe spec.
funcplace_import(self, member, spec, ctx) -> Union[imports.Placement, imports.ImportRefusal]File one declared save file under the session's title.
paramselfparammemberimports.ImportMemberThe member, already past the kind gate.
paramspecimports.ImportSpecThis emulator's spec.
paramctximports.ImportCtxThe launch context.
Returns
typing.UnionThe 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.
paramselfReturns
typing.OptionalA hex8 source that reads the disc and reads RomM's id as an Xbox one.
funclaunch(self, rom_path, resume_slot) -> NoneInject any restored saves, pin display settings and boot the disc.
paramselfparamrom_pathPathThe XISO to boot.
paramresume_slotOptional[int]Ignored with a warning; a raw image holds no states.
Returns
Nonefuncsave_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.
paramselfparamslotOptional[int]Ignored; there are no save states.
Returns
dictThe state fields all None (state_saved, state_slot,