webstation-broker
DeveloperPython referenceemulators

emulators.scummvm

ScummVM launcher: folder ROMs, ini pinning, and GMM-driven save states.

ScummVM launcher: folder ROMs, ini pinning, and GMM-driven save states.

ScummVM has no control socket and no state hotkeys, so the broker drives it the way a player does. A game is a folder of data files rather than a single image: scummvm --add --path=<folder> registers it in scummvm.ini under a generated target name (monkey, gob1-cd-fr), and that target, not the path, is what boots the game and what every save file is named after. The exit code of --add is not a detection signal (it returns success having added nothing), so the ini is read back afterwards and a folder with no domain is what "not bootable" means here.

Save and load go through the Global Main Menu with xdotool: the menu key opens it, the bare-letter Save/Load hotkey picks the button, Down walks the chooser to the slot and Return activates it. Three things are pinned in scummvm.ini for that to work at all. The chooser is forced to list mode, because the default grid has no keyboard path to a numbered slot. The menu key is bound on the global keymap, because ScummVM's own C+F5 loses its modifier on the way through this container's Xwayland and the unmodified F5 belongs to the engine keymap, which an engine may take for itself (see MENU_KEY). The button letters follow the GUI translation, so they are read back from gui_language. The macro is silent about its result, so a save is only reported once the slot's file has actually changed on disk.

ScummVM's saves are its states: there is no separate state format, so the save archive and the working slot both come out of the same directory and save_file_kind tells them apart by filename. Slot 0 is ScummVM's autosave and its own chooser marks it write protected, so the broker never saves there; it works in STATE_SLOT (default 1) and, like every launcher here, resolves whatever slot RomM asks for to that one.

Two more ini settings are pinned for the stream rather than for ScummVM's own sake. fullscreen, pinned off whatever the launch, because going fullscreen makes SDL grab and confine the pointer against a stack that feeds it absolutely; the window is grown to the display afterwards instead, which SDL reads as an ordinary resize (SCUMMVM_FILL_SCREEN). gfx_mode=surfacesdl because the OpenGL renderer scales mouse coordinates through getSdlDpiScalingFactor (backends/platform/sdl/sdl-window.cpp), which divides by SDL_GL_GetDrawableSize and only means anything for a GL window, so against an injected absolute pointer the game takes clicks while the cursor stops moving. Surface SDL has no such path and is ScummVM's own default.

attributelog
= logging.getLogger(__name__)
attributeROM_ROOT
= Path(os.environ.get('ROM_ROOT', '/romm'))

Root of the RomM library mount (env ROM_ROOT, default /romm).

A resolved game folder must sit under it; anything resolving outside is discarded.

attributeCONFIG_DIR
= Path(os.environ.get('SCUMMVM_CONFIG_DIR', '/config/.config/scummvm'))

ScummVM's config directory (env SCUMMVM_CONFIG_DIR, default /config/.config/scummvm).

Safe to move, because every ScummVM the broker runs is told where it is: --config names INI_PATH on the launch command line and on --add's. Without that the knob would move only the file the broker patches while ScummVM kept resolving its own, so the pinned settings and the registered game targets would land in a file nothing opens.

attributeINI_PATH
= CONFIG_DIR / 'scummvm.ini'

The config the broker pins before every launch and reads game targets back out of.

attributeDATA_DIR
= Path(os.environ.get('SCUMMVM_DATA_DIR', '/config/.local/share/scummvm'))

ScummVM's data directory (env SCUMMVM_DATA_DIR, default /config/.local/share/scummvm).

attributeSAVE_DIR
= DATA_DIR / 'saves'

Where ScummVM writes every save, which is also where the working slot lives.

attributeSCUMMVM_LOG_PATH
= Path(os.environ.get('SCUMMVM_LOG_PATH', '/config/scummvm.log'))

Log file the broker appends this emulator's output to (env SCUMMVM_LOG_PATH).

attributeCACHE_DIR
= Path(os.environ.get('SCUMMVM_CACHE_DIR', str(DATA_DIR / 'extracted')))

Where archived games are extracted and booted from (env SCUMMVM_CACHE_DIR, default DATA_DIR/extracted).

An extraction is kept and reused by every later launch of the same archive, and its folder is what gets registered in scummvm.ini, so the target and the saves named after it stay the same from one launch to the next.

attributeCACHE_MAX_GB
= float(os.environ.get('SCUMMVM_CACHE_MAX_GB', '10'))

Cap on the extraction cache in GB (env SCUMMVM_CACHE_MAX_GB, default 10).

The least recently booted games are evicted to make room for a new one.

attributeARCHIVE_LIST_TIMEOUT
= 60.0

Seconds 7z or unrar gets to list an archive's members before it is considered hung.

attributeAUTOSAVE_SLOT
= 0

ScummVM's own autosave slot.

MetaEngine::getAutosaveSlot returns 0 and MetaEngine::listSaves marks it setWriteProtectedFlag(true), so the save chooser refuses to write there. It is never the working slot, and a save request that names it lands in STATE_SLOT like any other.

attributeSTATE_SLOT
= int(os.environ.get('SCUMMVM_STATE_SLOT', '1'))

The one slot the broker saves into (env SCUMMVM_STATE_SLOT, default 1).

Low on purpose: the chooser is walked with one Down per slot, so a high slot would spend a keystroke per row getting there. Slot 0 is unavailable (see AUTOSAVE_SLOT), which makes 1 the first usable row.

attributeSTATE_WAIT
= float(os.environ.get('SCUMMVM_STATE_WAIT', '10'))

Seconds to wait for the save macro's write to land (env SCUMMVM_STATE_WAIT).

attributeSTATE_STABLE
= float(os.environ.get('SCUMMVM_STATE_STABLE', '1.0'))

Seconds size and mtime must both hold still before a save counts as written.

From env SCUMMVM_STATE_STABLE, default 1.0. Long enough that a write still in progress on a loaded host does not read as a finished one.

attributeKEY_DELAY
= float(os.environ.get('SCUMMVM_KEY_DELAY', '0.8'))

Seconds between the macro's steps (env SCUMMVM_KEY_DELAY).

The GMM animates itself in and its chooser fades in after it, and a keystroke sent into either transition is dropped without a trace.

attributeADD_TIMEOUT
= float(os.environ.get('SCUMMVM_ADD_TIMEOUT', '120'))

Seconds scummvm --add gets to scan a folder (env SCUMMVM_ADD_TIMEOUT).

Generous because detection reads through every file in the folder, which on a network mount holding a CD rip is not fast.

attributeRESUME_LOAD_WAIT
= float(os.environ.get('SCUMMVM_RESUME_LOAD_WAIT', '45'))

Seconds a deferred resume waits for RomM to push its state (env SCUMMVM_RESUME_LOAD_WAIT).

attributeRESUME_LOAD_SETTLE
= float(os.environ.get('SCUMMVM_RESUME_LOAD_SETTLE', '8'))

Seconds a deferred resume gives the game to reach a menu-able state (env SCUMMVM_RESUME_LOAD_SETTLE).

attributeFILL_SCREEN
= os.environ.get('SCUMMVM_FILL_SCREEN', 'true').lower() not in ('false', '0', 'no', 'off')

Whether the game is grown to fill the stream (env SCUMMVM_FILL_SCREEN, default on).

A ScummVM window is its game's own resolution, 640x480 for most, which is a postage stamp in the middle of the stream. The obvious fix is ScummVM's own fullscreen, and it is the wrong one: going fullscreen makes SDL grab the pointer and confine it to a rect (SdlWindow::createOrUpdateWindow: shouldGrab = ... || fullscreenFlags, then SDL_SetWindowMouseRect) and ask for an explicit display mode on the way in. Both are harmless on a real X server and both fight a streaming stack that feeds an absolute pointer and owns the display size itself: the pointer stops tracking while clicks still land, and these games are nothing but mouse. fullscreen is therefore pinned off in the ini whatever this setting says.

What works is asking the window manager for the size instead. The window is resized to the display after launch, SDL sees an ordinary resize and scales its output into it, letterboxing to keep the game's aspect on its own, and never sets the flag that triggers the grab. The cost is the title bar, which no tool in this image can remove (xdotool 3.2016 has no windowstate, and there is no wmctrl); a labwc window rule in the image would.

window_maximized is deliberately not used either: labwc leaves the window at its own size regardless, so it fills nothing.

attributeFILL_SCREEN_WAIT
= float(os.environ.get('SCUMMVM_FILL_SCREEN_WAIT', '10'))

Seconds to wait for the game window before giving up on resizing it.

attributeFILL_SCREEN_POLL
= float(os.environ.get('SCUMMVM_FILL_SCREEN_POLL', '2'))

Seconds between checks that the window still matches the display.

The display is resized by whichever client is connected, so the window has to follow it rather than being sized once at launch.

attributeMENU_KEY
= os.environ.get('SCUMMVM_MENU_KEY', 'F11')

Key that opens the Global Main Menu (env SCUMMVM_MENU_KEY, default F11).

Bound to the global keymap in scummvm.ini on every launch rather than trusting a default, for two reasons. ScummVM ships the global menu on C+F5, and a modifier does not survive injection into this container's Xwayland: the key arrives without its Ctrl, so the menu never opens. The unmodified F5 that does work belongs to the engine keymap, which an engine is free to take for itself (gob answers it with Gobliiins' own panel, which has no Save). Pinning one unmodified key on the global keymap avoids both.

attributeSCUMMVM_LANGUAGES
= {'ar', 'bg', 'ca', 'zh', 'cn', 'tw', 'hr', 'cs', 'da', 'nl', 'en', 'gb', 'us', 'et', 'fi', 'be', 'fr', 'fr-ca', 'de', 'el', 'he', 'hu', 'it', 'ja', 'ko', 'lt', 'lv', 'nb', 'fa', 'pl', 'br', 'pt', 'ru', 'sr', 'sk', 'es', 'eu', 'sv', 'tr', 'uk'}

The language codes ScummVM itself accepts (Common::parseLanguage).

Not ISO-639: ScummVM keeps a few of its own spellings (Brazilian Portuguese is br, Chinese splits into zh/cn/tw), so a code from outside has to be translated before it means anything on the command line.