Imported from Spinnich/rommbat (
.claude/skills/retrobat-layout/SKILL.md). Install upstream withnpx skills add Spinnich/rommbat --skill retrobat-layout. Copyright stays with the author.
RetroBat layout
RomMBat integrates purely through seams RetroBat already has. Do not fork RetroBat.
Tree
| Path | Use |
|---|---|
roms/<system>/ |
ROMs. Folder names come from es_systems.cfg |
roms/<system>/gamelist.xml |
Metadata ES reads directly |
roms/<system>/images, videos, manuals |
Media siblings, named after the ROM file |
saves/ |
Emulator save output |
bios/ |
BIOS and firmware, and much else. See below |
emulationstation/.emulationstation/ |
ES home: es_settings.cfg, scripts/, themes |
system/es_menu/*.menu |
How RetroBat registers launchable apps in the ES menu |
system/version.info |
The version, used for the compatibility gate |
Locate the root by walking up from AppContext.BaseDirectory to a marker
(retrobat.ini, emulationstation/, roms/). Registry lookups are a fallback for fixed
installs only, never the primary path.
bios/ is a shared tree, and RomMBat owns almost none of it
The requirements manifest is not a file in the install. batocera-systems.json ships as a
.NET string resource inside emulationstation/batocera-systems.exe, byte-identical to
reference/batocera-systems.json apart from a trailing newline. So the "read the live copy"
rule below has no live copy to read: the manifest is bundled at data/retrobat/bios.json,
generated by tools/build-bios-manifest.py.
Measured on a real install, bios/ holds 4,683 files and 373 MB before RomMBat writes
anything: dolphin-emu 2,508 files, mame 858 (776 of them software-list XML), nxengine
436, blueMSX's Machines 296, plus openMSX's whole user directory including its save
states. Exactly 3 files sat at a path the manifest names carrying the md5 it names.
So the rules here are stricter than under roms/:
- Never overwrite. A file at a required path whose md5 disagrees is reported and left exactly as it is. A BIOS the user plays with beats the manifest's idea of the right one.
- Never delete. A file with the right md5 that RomMBat did not download is adopted as a
fact, not as something it may later remove. Firmware rows carry no
rom_id, which is what keeps eviction away from them at the schema level. - Everything lands under
bios/. Seven manifest entries nameemulators/paths; all seven are hashless and therefore unreachable through an md5 join, and the prefix is enforced anyway. - Destination paths go six segments deep and one md5 can owe three of them
(
coleco.rom,colecovision.rom,openMSX/share/systemroms/coleco.rom). The path is the key; the md5 is not. - 172 of the 346 requirements carry no md5 at all, and 28 systems have nothing else. Those are unverifiable, which is a third state beside matched and missing, and reporting them as missing tells a user to hunt for a file RomMBat could not recognise if they already had it.
es_systems.cfg is the authority on systems and extensions
Read the live copy, not the vendored template: it reflects that machine's actual
configuration. Each <system> carries <name>, <fullname>, <manufacturer>,
<hardware>, <release>, <path>, <extension> and <command>.
The folder is <path>, not <name>. They are different vocabularies and five systems
in the shipped 8.2.1 file disagree: gw writes to gameandwatch, powerbomberman to pb,
casloopy to loopy, Windows to windows, and starship is used twice, for
ghostship and starship, so <name> is not even unique. Four entries own no folder under
roms/ (library, screenshots, kodi, and retrobat at system/es_menu) and mess
declares no path; none is a sync target. ~ expands to <root>/emulationstation, so the
ubiquitous ~\..\roms\<folder> resolves to <root>/roms/<folder>. Match
case-insensitively, and parse it as XML: arcade and kodi sit inside comments, which a
regex over <system> would wrongly pick up.
<extension> is a sync filter. Syncing a file the emulator cannot launch produces the
worst failure this app has: a game that appears in ES, looks right, and dies on launch. It
also moves between patch releases: 8.2.1 added .decomp to eleven systems and .zar to
ps4, which is the concrete reason the list is read live rather than bundled. Never cache a
per-system extension set across runs.
<manufacturer>, <hardware> and <release> let the platform rollout order be derived
rather than hand-maintained.
es_savestates.cfg is the authority on save states
Per-emulator templates for <directory>, <file>, <image>, <autosave_file> and
<autosave_image>, plus firstslot/lastslot and autosave/incremental flags.
Placeholders: {{system}}, {{core}}, {{romfilename}}, {{slot}}, {{slot0}},
{{slot2d}}.
Parse it. Never hardcode state paths. <image> maps onto RomM's optional screenshotFile.
Note the libretro entry is core-scoped ({{system}}/libretro.{{core}}), so the same game
has independent state sets per core.
Do not go looking for sort_savestates_enable in retroarch.cfg to explain the core
folder. Other RetroArch front ends derive that segment from the sort flag, whose default
when the key is absent is on for savestates and off for savefiles, an asymmetry that
silently misplaces a state. RetroBat never leaves it to the default: emulatorlauncher writes
all four sort keys as "false" on every launch and bakes the core into the path instead
(savestate_directory = "<root>\saves\mastersystem\libretro.genesis_plus_gx"). Verified on a
real 8.2.1 install against states from four cores on disk. Two consequences: the hazard does not
exist here, and the folder is named libretro.<core>, RetroBat's own convention, not the
libretro corename that front ends reading retroarch.cfg produce. es_savestates.cfg is the
source, and it is the stronger one because retroarch.cfg is regenerated per launch and
describes only the last game run. See
argosy-findings.md, A7.
Trust <file>, verify <directory>. Across the twelve emulators M0 drove, every <file>
template was correct and one <directory> declaration still is not: openmsx writes
bios/openmsx/savestates/, outside the saves tree entirely, against a declared
saves/msx1/openmsx. So never read an empty declared directory as "this game has no states",
and cross-check against the emulator's generated config where it matters.
flycast was the second and no longer is. It wrote dreamcast/reicast/states against a
declared dreamcast/flycast/sstates on 8.2.0; RetroBat 8.2.1 fixed that
(emulatorlauncher#1336) by pointing the save-state watcher at the directory Flycast really
writes. Confirmed by hand, three runs: the state lands in both, same bytes, same millisecond,
live. Flycast still writes reicast/states first and emu.cfg's Dreamcast.SavestatePath
still names it, so the declaration became usable without the template moving. Re-run
tools/m0-probes/probe2-flycast-mirror.ps1 if that ever looks doubtful; a changelog line is
not a measurement, which is why this one was driven.
The declared directory is otherwise the one to use even when the emulator writes elsewhere.
An emulator may write under its own naming, with RetroBat mirroring into the declared path
about 120 ms later while the game is still running (PPSSPP:
psp/PPSSPP_STATE/<GAMEID>_<ver>_<slot>.ppst mirrored to
psp/ppsspp/<rom filename>_<slot>.ppst). ES passes the launcher -state_slot and
-state_file naming the declared path, and the launcher hands it to the emulator, so a
state written there is loaded. A manual save mirrors live; an autosave state appears only at
exit. libretro needs no mirroring, since RetroArch is pointed at the declared path directly
via savestate_directory.
Watch for a .txt sidecar carrying the native basename: RetroBat writes it beside the state
unconditionally, and it belongs with the state. Its contents vary by emulator and one of them
is useful: some hold nothing but the rom filename, while DuckStation's holds the bare disc
serial (SLUS-00594), which is the join key a database-named memory card otherwise has to be
reverse engineered from. Read it rather than assuming. See save-sync for the unreliable
<image>.
A declaration is not an installation. Six of the thirteen emulators in es_savestates.cfg
had no executable on a real, well-used install: RetroBat downloads emulators on demand. Check
for the binary before promising state sync for a system.
And installation is not launchability. Launching an uninstalled emulator raises a modal
"install now?" dialog with no window title and no timeout, which blocks that launch
indefinitely; launchers were found still waiting on it seven hours later. And bizhawk
crashes in BizhawkGenerator.CreateControllerConfiguration when the launcher is invoked
without -core (inputPortNb[core] is unguarded), which ES never does but a direct
invocation easily does, so always pass -core when driving emulatorLauncher yourself.
Both failures leave the launcher hung or gone with no game started, so detect them from the
launcher rather than recording a play session that did not happen.
es_settings.cfg is how you configure emulators
emulatorlauncher regenerates each emulator's INI from ES options at every launch, so
editing an emulator INI is pointless: it gets clobbered on the next boot. Write the
option instead. Precedence (emulatorlauncher/Program.cs):
es_settings.cfg -> global.<key> -> <system>.<key> -> <system>["<rom filename>"].<key>
That last form is a real per-game override, measured in M0: emulatorlauncher honours it, it
outranks the system key, and it affects only its own rom. Write the rom filename with its
extension (ps2["Game (USA).iso"].pcsx2_slot1_memory). A bare stem is ignored silently,
so build the key from fs_name and never from a stripped name.
Keys read from the live es_features.cfg, with the value RomMBat should set:
| Key | Choices | Set to | Why |
|---|---|---|---|
duckstation_memcardtype |
PerGameTitle, Shared, PerGameFileTitle, PerGame |
leave unset | stock already binds a disc set |
pcsx2_slot1_memory |
standard, folder, game |
game |
names the card after the rom basename |
dolphin_slotA |
8 (GCI folder), 1 (memory card) |
8 |
already the stock default |
flycast_vmupergame |
switch, unset by default | on | per-game VMU, port 1, serial-keyed |
Leave duckstation_memcardtype alone. The stock PerGameTitle keys the card by DuckStation's
internal database title, which sounds worse than a filename key until a multi-disc set is
driven: the title is gamedb.yaml's saveName with the disc marker stripped, so the whole set
shares one card while regions stay separate. PerGameFileTitle keys on three separate
filenames and splits it.
dolphin_sync_saves does not do what its description says, and four documents repeated the
description. It is GameCube only, it runs once per launch inside emulatorlauncher before
Dolphin starts, and it reconciles saves/gamecube/dolphin-emu/User/GC/<REGION>/ against a
Card A/ subdirectory of that same folder, newest wins, loser renamed .old, every failure
swallowed. A .gci in Card A with nothing beside it is copied back out, so a save removed
from the region root reappears one session stale. DolphinSaveSync detects and reports it and
never acts on it. Finding 189.
GameCube's save class is set by dolphin_slotA, which the menu calls SAVE FORMAT. 8 is
the GCI folder RomMBat treats as class C; 1 is one shared raw SRAM.<REGION>.raw, class D.
Slot B is never rewritten by RetroBat, so it stays at Dolphin's stock relative default in
top-level saves/dolphin/, outside every declared container. Finding 193.
Never write this file while EmulationStation is running. The write is discarded. ES loads
es_settings.cfg at startup and serialises that model on every write, so a key present at load
survives (ones ES cannot understand included) and a key that appears afterwards does not.
Driven with ES up: two custom keys merged in atomically and confirmed on disk were gone after
ES's next write. Language proves it is not a merge, because ES added that key itself at
startup and dropped it again on the same write. M0's nonsense key survived only because it was
written before ES started.
Merging and atomicity do not save you here; both were done and the write still vanished. ES
writes twice a session, at launch as well as on exit, timed against ES's own hook events: the
launch write landed 7.7 s before the start hook and the other 2.4 s before quit. So the safe
window is strictly "while ES is not running". Detect ES, refuse with a reason, and re-read after writing to
confirm the key is there rather than trusting the rename.
ES prunes any setting equal to its own default on that rewrite, so an entry written at the stock value disappears. Never read a missing entry as the user having reverted something.
GET http://127.0.0.1:1234/quit closes ES cleanly only when no game is running. With an
emulator up, /quit and /emukill both return 200 and do nothing. Poll for the process to
exit rather than trusting the response. Changing a user's emulator config is opt-in and
reversible.
The ES menu entry is two files, and one of them is somebody else's
es_menu is not a bespoke mechanism. es_systems.cfg declares it like any other system,
named retrobat, with <extension>.menu</extension>, so a .menu is a ROM of that system
and the thing that parses it is emulatorLauncher, not EmulationStation. Registration
therefore takes two files:
system/es_menu/<app>.menu, plain text, no trailing newline. Line 1 is the executable and later lines are arguments. The path resolves underemulators\and..\escapes are refused outright ([Generator] Failed. path is null, exit 204), which is why RomMBat installs atemulators/rommbat/and why its line is\rommbat\RomMBat.exe. The portable zip therefore carries theemulators/rommbat/prefix on every entry and is extracted at the RetroBat root: a flat archive extracts to a tree whose menu entry cannot resolve its executable, andhooks installreports the hook missing.- a
<game>element insystem/es_menu/gamelist.xmlwhose<path>names the.menu(./rommbat.menu). Without it the entry shows under its bare filename with no artwork, driven rather than assumed. Artwork convention:<image>and<marquee>both pointing at./media/<name>-logo.png, which is what all 92 shipped entries do.
Both halves are picked up live. With ES running, writing the .menu took the retrobat
system from 92 games to 93 in 209 ms after GET /reloadgames, and adding the <game>
element gave it its name and artwork 262 ms after a second reload. No restart.
This gamelist is not encoded like the others and ES does not rewrite it. The stock file is
UTF-8 with a BOM and CRLF, where all 42 roms/<system>/gamelist.xml measured across two
installs are neither; a writer that emits its own convention rewrites all 96 entries to add
one. And ES left it byte- and mtime-identical across three sessions, including one where it
had the change in its model, so RomMBat is the only writer that can damage it. Three of its
<game> elements are commented out (citra_canary, yuzu-early-access, zsnes-dos), which
is how RetroBat withdraws an entry whose markup it still ships, so a merge that drops comments
resurrects them. Its own indentation is inconsistent, two entries out of 93, so byte identity
against the stock file is not achievable and is not the assertion to write.
Do not re-assert a field the user changed. The name and the artwork are what they see on their own front end. Fill in what is absent, report what differs, correct nothing. Same rule as a per-game setting somebody else wrote.
Controller input: read es_input.cfg, never detect a layout
EmulationStation has already answered the question a layout lookup only guesses at.
.emulationstation/es_input.cfg is semantic: it does not record that a pad is an Xbox pad, it
records which physical input is a on that pad. Read it live, for the same reason
es_systems.cfg is read live, and because a user who remapped their pad in ES has said what
they want.
Measured against five real controllers on 8.2.1, and each of these refutes a vendor-id table:
- The 8BitDo Ultimate 2 maps
a/b/x/ybyte-identically to the Xbox 360 pad. Its vendor id is0x2dc8, which Argosy'sControllerDetectorlists as a Nintendo layout. The table gets a real, common pad backwards. - The Switch Pro genuinely differs (
a=1,b=0), so that is a live difference, not an absent one. - The Switch Pro reports its d-pad as buttons 11-14 while the other four report a hat. That is a difference of shape, and no vendor-id table can express it.
Three traps, all measured rather than reasoned about:
xandydo not mean what the pad says they mean.aandbmatch the labels on an Xbox-layout pad, butxmaps to the button printed Y andyto the button printed X. So the file is the authority on which physical input a name refers to, and not on what to print in a button prompt: a footer hint that says "X" and runs ones_input'sxsends the user to the wrong button, which is how this was found. Finding 225.- One press can mean two things.
selectandhotkeyare the same button on both the 8BitDo and the Xbox pad. A lookup that returns the first match silently drops the hotkey, so resolve to every name a reading satisfies. - A hat is a bitmask,
up=1,right=2,down=4,left=8, so a diagonal sets two bits and means both directions. Compare bits, never equality. - An analog trigger rests at
-32768, not zero, on the same pad whose sticks rest at zero. An axis binding names a direction (valueis-1or1) and only a reading of that sign is that input. Treating any non-zero axis as pressed reports both triggers permanently held.
A half-written es_input.cfg is an ordinary state, so parsing it must not throw. ES rewrites
the whole file every time a pad is configured, which makes an interrupted write the normal way to
find one that is empty or unclosed, and XDocument.Load on an empty file throws
XmlException: Root element is missing. The UI reads this before any window exists, in a
WinExe: a throw there is the ES menu entry flashing and returning with nothing on screen, on a
device with no keyboard to diagnose from. EsInputMap.Read degrades to an empty map and keeps
the reason in Problem; EsInputMap.Load, which a caller hands a path to, throws
EsInputException as EsSystemsFile and GamelistDocument do for the other two live ES XML
files. Keeping the reason matters: an empty map otherwise reaches the status screen as "your pad
is not configured" for a pad that is. The remedy is the same sentence either way, because
configuring a controller in ES is what rewrites the file.
The GUID has two spellings and a straight comparison never matches. SDL 2.0.18+ fills bytes
2-3 of a joystick GUID with a CRC-16 of the device name; ES writes them zeroed. The same 8BitDo
is 0300b155c82d0000... from the running library and 03000000c82d0000... in the file.
Normalise before comparing; EsInputMap.NormalizeGuid is the one place that does it.
The ids are SDL joystick indices, so only the same library can read them. emulationstation.exe
imports SDL2.dll (2.32.8 on 8.2.1) and not SDL3.dll, though RetroBat ships both. RomMBat
loads emulationstation/SDL2.dll rather than bundling its own build: that costs zero
published bytes, and more importantly it makes an index mismatch impossible by construction,
where a different SDL build enumerating some pad differently would mis-map silently.
SDL_Init(SDL_INIT_JOYSTICK) alone is enough, with no video subsystem and no SDL event loop.
It does need a Win32 message pump, which is a different thing, and the failure is silent:
SDL 2.32.8 defaults to the RAWINPUT backend, and in a console process with no pumped window
SDL_NumJoysticks() returns 0 while three controllers are attached (finding 226). Avalonia
pumps, so the shipped UI is unaffected; a console probe of controller state is not, and has
to set SDL_JOYSTICK_RAWINPUT=0, which then changes the GUID it reads and makes it
incomparable to the file (finding 227). If the library is missing or the pad has no
inputConfig, say so and name the fix
(configure the controller in EmulationStation first) rather than inventing a default map: a pad
ES cannot drive is one the user's own front end cannot drive either.
EmulationStation has already answered the on-screen keyboard, and RomMBat now copies it
rather than resembling it. GuiTextEditPopupKeyboard binds A to press the highlighted key,
Start to OK, B to BACK, L (pageup) to DELETE, R (pagedown) to SPACE,
y to SHIFT and x to RESET, with the d-pad moving the cursor. Findings 228 and 234:
read off a live 8.2.1 session, corroborated in
resources/locale/*/LC_MESSAGES/emulationstation2.po, and then settled against upstream's own
source, which is the only place the layout exists.
- The tables are compiled into
emulationstation.exe, so there is nothing forreference/refresh.shto pull and nothing on disk to read.KeyboardLayoutsholds a transcription in upstream's exact shape, which is what makes re-checking it against a newer ES a diff. Its provenance comment names the file; keep it accurate or the copy becomes folklore. - Three layouts,
kbUs,kbFrandkbKr, and no mechanism to add one. A German install types on the US grid upstream and does here too. - Four faces per key (lower, upper, alted, alted-upper) on a 13-column, 5-row grid.
OKspans two rows, the layer key two columns, and the bottom row 2/7/2/2. A key whose face is empty on the current layer is drawn, holds focus and does nothing, which is what keeps every layer the same shape and the cursor impossible to strand. Do not compact it. - The shoulders are not free. RomMBat first put the case toggle there, which is where a RetroBat user's thumb expects delete and space.
:and/are on the upper face, so a URL costs one shift press. That is upstream's layout and not a slip; the grid RetroBat already taught the user beats two presses saved.- RESET is the one key whose meaning does not transfer. Upstream's commits the empty string and closes, which is how a setting is cleared there. RomMBat has no field that may be empty, so the same key in the same place under the same word restores what the screen opened with.
The language picks the layout, and on Windows it lives in es_settings.cfg. ES asks
SystemConf for system.language; on a Windows release build SystemConf has no config
file of its own, because Paths.cpp sets one only under #if defined(WIN32) && defined(_DEBUG),
so it falls back to ES's Settings and reads Language from es_settings.cfg. batocera.conf
is absent on the live install, which agrees. InstallSession.EmulationStationLanguage is the one
place that reads it: the UI may not name EsSettingsFile and a structural test says so.
Absent is the ordinary answer and means ES's default, because ES prunes any setting equal to
one (finding 170), so never read a null here as "nobody chose". Finding 235.
Never print a button letter in a prompt; draw its position. The bottom face button is A on
an Xbox pad, Cross on a DualSense and B on a Switch Pro, so any letter is wrong on two layouts
out of three, and a stock RetroBat es_input.cfg routinely has all three configured. ES draws a
four-dot diamond with one dot filled, naming a position, which is what es_input.cfg already
encodes: a is the bottom button, b the right, y the left, x the top. In RomMBat a
FooterHint therefore carries a NavAction and never a string, so a screen cannot name a
button and there is one place to be wrong. Finding 230.
Closing the hint channel is not the whole rule, because prose is a second channel. The
first build with position glyphs in its footer still had "Press A to pair" in a status row and
in README.md, which on a Switch Pro names the button that is EmulationStation's b, which is
back, which on the root screen closes RomMBat: the instruction did the opposite of what it
said. The ban covers every user-facing string, not the footer only. Where a sentence has to
point at an action, quote the footer's own label ("Pair with RomM"), which the renderer
draws next to the glyph, so the words on the two lines match and neither names a letter.
StatusScreenTests.No_string_this_screen_shows_names_a_face_button asserts it, because the
type system cannot.
ES surfaces controller hotplug itself, with %s connected and %s disconnected in that same
string table. A front end living inside it that cannot notice a pad arriving is the odd one out.
Enumerate more than once, because a controller is not a fixed fact about a session. A pad
asleep in its cradle at launch, batteries that go mid-session, and a virtual pad from a
streaming host that attaches only once the client sends input are the same shape, and all three
end with a person holding a controller that does nothing and no way to reach the thing that
would restart the app. A lost pad also does not announce itself: reading a handle whose device
has gone away returns released buttons and centred axes, which is exactly what an untouched
controller looks like, so SDL_JoystickGetAttached is the only way to tell them apart.
GamepadReader asks that per frame and re-enumerates once a second when it holds nothing.
A UI launched from the ES menu has the controller to itself, and needs no workaround.
Measured with a stamping hook on game-selected: ES fired zero navigation events during the
26.5 s a full-screen app was in front of it, while five D-pad presses landed in the app, and it
resumed 0.64 s after the app exited with its selection unchanged. ES suspends a .menu app
exactly as it suspends a game. emulatorLauncher does not compete either: an ES-menu launch
carries no -p1* controller arguments, so PadToKey loads its config and attaches to
nothing. Findings 218 to 220, 223.
es_padtokey.cfg is not a navigation mechanism. 153 apps, and 156 of about 170 mappings are
hotkey start to close or kill; exactly one maps directions to arrow keys. There is no default
or global section, so an app that is not listed inherits nothing.
The window in which es_settings.cfg can be written
Only while EmulationStation is not running, and "not running" means the process is gone.
Timed across three sessions from GET /quit:
| Event | When |
|---|---|
ES writes es_settings.cfg on exit |
175.6 / 324.8 / 324.1 ms |
the quit hook fires |
807.3 / 524.8 / 551.6 ms |
| the process is gone | 875.5 / 573.1 / 604.0 ms |
So the exit write comes first, with 200 to 630 ms to spare, and nothing writes the file again afterwards. The hook still fires while ES is alive, for another 48 to 68 ms, and that window is inside the load-and-serialise window a key is discarded in. Poll for the process, not for the hook. It is cheap: 10 ms, one poll, on a real session.
start is inside the discard window, not outside it. ES's launch write lands 1.6 to 4.9 s
before the start hook fires, so by the time a start-hook pass runs, ES has already loaded
its model and already written the file once. Never write config there.
So a UI launched from the ES menu can never write this file at all, because it runs under a
live ES by construction. It queues the change instead (pending_config, migration 012) and
background quit applies it once the process is confirmed gone.
Event hooks
.emulationstation/scripts/<event>/. Nine folders ship: start, game-start, game-end,
quit, shutdown, sleep, wake, update-gamelists, reboot. ES also fires
game-selected and system-selected on every navigation move, with no folder for either.
Write the hook as an .exe, never a .bat. RetroBat's own updatestores.bat works only
because it takes no arguments. M0 measured both scripted forms failing to start on ordinary
rom names, silently and with no error anywhere:
| Form | Fails when | Why |
|---|---|---|
.bat |
any argument is quoted, so any space | ShellExecute uses cmd /c "%1" %*, whose quote-stripping rule mangles the line |
.ps1 |
the name contains (, ) or , |
ES omits -File, so it is an implicit -Command and PowerShell parses the tail as code |
.exe |
not observed | arguments arrive through normal CommandLineToArgvW splitting |
Hooks resolve the agent relative to their own location, never an absolute path. Mind the
depth: a hook sits at .emulationstation/scripts/<event>/, so three levels up lands in
emulationstation/ (where emulatorLauncher.exe lives) and reaching the RetroBat root takes
four. The agent is four levels up plus emulators\rommbat\. Do not rely on the working
directory; it differs by hook form.
M0 measured the hook behaviour; do not assume the Batocera convention. See
docs/retrobat-findings.md probe 1. The load-bearing results:
- Hooks do not block game launch. The launcher starts ~30 ms after the hook fires, regardless of how long the hook runs. They are fire-and-forget.
- They do run concurrently, with each other and across events. Three
game-endhooks were seen in flight at once, interleaving writes to one file. A lock file is mandatory and the journal must survive interleaved appends from separate processes. game-startfires for every game, contrary to an earlier reading. It is the.batthat never starts when the display name contains a space. An exe hook is unaffected.- Take the launch facts from
emulationstation/emulatorLauncher.loganyway, withgame-endas the trigger. It carries rom path,-system,-emulatorand-corewith a millisecond timestamp and rotates across two files, and the hook is told none of those three. Open the journal record ongame-start, but do not source facts from it. game-startgets three arguments, not five:$1absolute rom path,$2rom basename,$3gamelist display name.$4and$5are empty, so the system, emulator and core are not available to the hook even thoughemulatorLauncherreceives all three. Batocera documents$3as the system; that is wrong here.- ES logs its scripting decisions only at
LogLevel=debugines_settings.cfg, and logsexecuting:even for a process that never starts. Useful for diagnosis, not proof of execution. - A host can be unable to run a script at all. In the M0 portable-move test the tree
worked on a second PC while no hook produced anything. Two causes there, both silent:
Notepad++'s installer had taken the
.batassociation (HKCR\.bat=Notepad++_file), and the PowerShell execution policy was the defaultRestricted. An.exehook fires all four events there. This is the strongest reason the hook is an exe. Detect and report the state anyway; never assume silence means nothing was played. game-endgets none. It fires without a matchinggame-startfor launches that fail, but a successful ES-menu launch fires both: driven live on 8.2.1, RomMBat's own menu entry produced agame-startcarryingsystem/es_menu/rommbat.menuand agame-endcarrying nothing. M0's "no precedinggame-start" came from three launches driven by callingemulatorLauncher.exedirectly, two of which failed. So never key the discard on a missinggame-start: key it on the launcher log's-system retrobatwith a rom undersystem\es_menu\, and discard the pairedgame-startwith it. Findings 221 and 222.- Every script in an event folder runs, alphabetically, so install beside
updatestores.batrather than replacing it. startandquitmay start a process;game-startandgame-endmay not. That is CLAUDE.md rule 4's boundary and it is the reason the rule exists rather than an exception to it: the rule forbids network work because hooks run in the game-launch path, and only those two do. RomMBat'sstartandquithooks spawnemulators/rommbat/rommbat-agent.exe background <event>detached,UseShellExecute=false,CreateNoWindow=true, no wait.CreateNoWindowis load-bearing: the agent is a console app and ES is full screen, so without it a console flashes over the front end at every boot.
RetroBat's scraper settings are settings RomMBat should read
Measured on 8.2.1: es_settings.cfg carries exactly three scraper keys, and only two of
them map onto anything RomMBat fetches.
| Key | Type | Maps to |
|---|---|---|
ScrapeVideos |
bool | RomMBat's MediaKind.Video |
ScrapeManual |
bool | RomMBat's MediaKind.Manual |
ScrapperImageSrc |
string, e.g. sstitle |
nothing. It picks which ScreenScraper image ES uses as the cover, and RomM serves its own art |
There is no toggle for the cover, the thumbnail or the marquee. Do not invent keys for them.
Never read EmulationStation's compiled defaults as RetroBat's defaults. RetroBat ships
system/templates/emulationstation/es_settings.cfg and seeds the live file from it at install,
so what a user sees on a fresh machine is the template's value and not Settings.cpp's. The two
disagree on exactly the keys that matter here. Read the template, and check a fresh install
rather than reasoning from upstream source. Seeding happens once: a used install whose live file
has lost a key does not get it back from the template on the next launch, measured on 8.2.1.
Both scraper switches ship on, and off is unrepresentable, so an absent key is a deliberate
no. Settings::saveMap drops any key whose value equals its default, and any key with no
registered default whose value is false:
| Key | RetroBat template | ES compiled default | So the file says |
|---|---|---|---|
ScrapeVideos |
true |
mBoolMap["ScrapeVideos"] = false in Settings.cpp |
true on, absent off |
ScrapeManual |
true |
none at all, so getBool returns the map's own false |
true on, absent off |
Turning either switch off in the scraper menu deletes the key. A literal value="false"
never occurs, so never write an off branch that depends on seeing one.
Two hands-on rounds were lost to reading absent as something else. First absent was read as "on", which turned manuals on for every install whose ES had never written the key. The correction read absent as "RomMBat's own default", which for video is also on, so turning video off in RetroBat still did nothing at all: measured afterwards on the live install, 389 MB of video on one platform and 2.05 GB across the tree that no setting could reach.
Use EsSettingsFile.Has before Value, and treat the absent branch as off.
"Absent means off" is a rule about a file that is there, and it does not extend to a file
that is not. EsSettingsFile.Load answers a missing path with an empty <config>, which is
byte-for-byte the answer a real file with both switches turned off produces, so an install that
has never said anything is indistinguishable from one that said no to everything. Reading that
as a decision is harmless where the answer only picks what to fetch next, and it is not harmless
where the answer decides what to delete: MediaSync.Discard removes the synced files of any
kind that is not wanted, so a missing es_settings.cfg swept a whole library's artwork. The fix
is to gate the destructive half, not the read: the kinds stay the same in all three states and
only the delete asks whether anybody actually said so (MediaPolicy.Preference, FromInstall).
A malformed one throws, and nothing on the sync path catches it. XDocument.Load raises
XmlException, which is not an IOException, so a truncated file after a power cut escapes
MediaSync, GameSync, LibrarySyncService and SyncViewModel alike and leaves the screen at
"Working" with a stop footer for ever. Any read of this file on a long-running path catches it.
SaveScanner.cs's catch filter does not list it either, which is untouched and unfixed.
Three more settings pick a source for a slot rather than adding a kind. ES's own comments name the tag each one feeds, so these are not new media:
| Key | Feeds | Values | ES compiled default | RetroBat template |
|---|---|---|---|---|
ScrapperImageSrc |
<image> |
ss, sstitle, mixrbv1, mixrbv2, box-2D, box-3D, fanart, "" |
ss |
sstitle |
ScrapperThumbSrc |
<thumbnail> |
box-2D, box-3D, "" |
box-2D |
not seeded |
ScrapperLogoSrc |
<marquee> |
wheel, marquee, "" |
wheel |
not seeded |
A stored source value is not necessarily a choice. GuiScraperSettings rebuilds every row
from the currently selected Scraper, guarding each on isMediaSupported(...), and when the
stored value is not in the new scraper's list it calls selectFirstItem() and writes it on
close. Switching SCRAPE FROM therefore rewrites a source the user never touched. Read the
value, map what is recognised, fall back on anything else, and ignore Scraper: RomM is
not one of the scrapers it names. Finding 241.
Three of the remaining switches map onto real RomM fields and two are dead. ScrapeBezel,
ScrapeBoxBack and ScrapeFanart map onto bezel_path, box2d_back_path and fanart_path.
ScrapeMap and ScrapePadToKey have no counterpart at all, which is a fact about the schema
rather than about one library, and padtokey is input config rather than media. Do not rule a
kind out because a library holds none of it: that number says when the platform was last
scraped. Findings 239 and 240, and #108 for the shape.
A kind turned off is also a kind removed. Stopping future downloads and leaving what is
already there makes the setting mean two different things depending on which way it is moved,
and nothing else reclaims it: eviction works on whole games under budget pressure and has no
notion of a kind. Only FileOrigin.Synced goes; a user's own scrape at the same name is theirs.
RomMBat honours the two that exist, as the default rather than as an override. A hands-on
pass turned video off in RetroBat's scraper and RomMBat carried on downloading it, which is two
switches that look like they should agree and do not. MediaPolicy.Read(settings, install)
reads them; an explicit media.kinds still wins, because that is what somebody typed. This is
the same rule that makes the on-screen keyboard follow Language: where RetroBat already has
the setting, RomMBat asks it rather than inventing a second one.
gamelist.xml
Merge, never clobber; write atomically via temp file plus rename; include only locally present ROMs. Key generation by resolved folder, not by platform, because two RomM platforms can share one folder.
Own an allowlist of the fields you write, never a blocklist of ES's. The four the plan
first named are not the surface. Across 4,531 entries in 32 gamelists from a real scraped
install: playcount 115, lastplayed 115, gametime 114, and no favorite and no
hidden at all, plus scrap 4,525 (self-closing, name and date attributes),
id on <game> 4,493, cheevosHash 4,187, md5 2,815, cheevosId 2,329,
arcadesystemname 568, multidisk 161, crc32 8.
Media is named after the ROM file, stem being the file name without its extension:
images/<stem>-image.png, images/<stem>-thumb.png, images/<stem>-marquee.png (marquee
lives under images/, not its own folder), videos/<stem>-video.mp4,
manuals/<stem>-manual.pdf. Those are the exact names a user's own scrape writes, so never
delete one RomMBat did not create.
After writing, call GET http://127.0.0.1:1234/reloadgames. M0 measured that ES keeps a
stale in-memory model until asked to reload, and rewrites gamelist.xml from that model when
it exits. Write-then-reload makes the edit stick and takes effect immediately; write without
reloading and ES can serialise its stale copy over you. ES writes no <game> entry for a rom
it has no metadata for, and does not list a <game> whose <path> names a file that is not
on disk, so a stale entry is inert rather than a phantom game.
Do not depend on ES preserving what it read. When it has a reason to rewrite the file it
drops every XML comment, at document level and inside a <game> alike; moves the entry it
changed to the end; rewrites that entry's children into its own order
(path,name,desc,genre,rating,releasedate,developer,publisher,players,favorite,playcount,lastplayed,gametime,lang,region,...);
and prunes <hidden>false</hidden> as a default, the same behaviour it has on
es_settings.cfg. Unknown elements and attributes do survive. When it has no reason, it
leaves the file byte-identical, mtime included, so a no-churn assertion is meaningful but
has to be made about the file ES left behind.
/reloadgames returns in 1-2 ms and does the work afterwards, so its response is not a
completion signal. Time to the change being visible was 269 ms for a 200-entry list and
1.1 s for 100,000. Poll /systems (a few KB, carries totalGames) rather than
/systems/<system>/games, which serialises the whole library.
And it is ignored outright while a game is running, 200 in 1 ms with nothing reloaded,
exactly as /quit and /emukill are. Reload again after the game ends rather than treating
the 200 as done. With ES absent, which is the ordinary case for a background sync, the connect
is refused after 2.04 s on loopback, so this client needs a ConnectTimeout far below the
2 s used for reachability or every sync pays it.
Size is not the constraint you would expect. ES loaded a 100,000-entry, 65 MB gamelist in 2.07 s from a cold start for 419 MB of working set, roughly 2 MB per 1,000 entries, and 2.93 s with a real image file per entry. Do not cap a gamelist, though: ES lists ROM files it has no entry for, so dropping entries hides no games and only strips their art, leaving the user the same number of tiles to scroll past. What bounds navigability is the sync set's own game cap. Report a folder that grows past a threshold; never truncate it.
The EmulationStation HTTP API
ES serves an API on 127.0.0.1:1234 whenever it is running. It works on loopback with the
PublicWebAccess setting untouched, because that setting gates only non-local callers, so
using it requires no change to the user's configuration.
| Route | Method | Use |
|---|---|---|
/reloadgames |
GET | Rescan roms and re-read gamelists, no restart |
/systems |
GET | Systems as JSON, including totalGames |
/systems/<system>/games |
GET | Games as JSON: name, desc, image |
/caps |
GET | {"Version": "8.2.0-stable-win64", ...} |
/quit |
GET | Close ES cleanly, before writing es_settings.cfg |
/emukill |
GET | Kill the running emulator |
/launch |
POST | Does nothing. 200 and no launch; see below |
POST /reloadgames is 404; the verb is GET. Treat the whole API as best-effort: it only
answers while ES is running, so every call needs a short timeout and a no-ES fallback.
A 200 from this API is never evidence the action happened, and that now covers every route
that does something. /quit and /emukill are ignored while a game is running; /reloadgames
is too, and answers in 1-2 ms before doing the work either way; and POST /launch does not
launch anything at all.
"Ignored" is the wrong word for /reloadgames, and the difference decides a design. It is
deferred, not discarded: a reload issued while an app is in front of ES is queued and
applied when that app exits. Measured on 8.2.1 with RomMBat itself as the app in front, which
is the case that matters because an ES-menu launch is suspended exactly as a game is (finding
233):
| With RomMBat in front | totalGames |
|---|---|
| marker written, reload issued, 200 in 6 ms | unchanged for 10 s |
| RomMBat exits | the change lands, with no further call |
| marker written, no reload issued, RomMBat exits | no change, ever |
The third row is the one that carries it: ES does not rescan on resume by itself, so the
call is still required, it simply takes effect later. So issue /reloadgames after writing
gamelists even from the interface, and expect the games to appear when the user leaves
RomMBat rather than while they are still in it. Do not build a workaround, do not tell the user
to restart the front end, and do not skip the call on the theory that ES will notice.
Built that way in 7b-2b, and the stop path is included. The sync screen runs the same
GamelistSync pass the agent does, through LibrarySyncService, and it runs it after a stop
as well as after a completed run. A run that ended early still touched folders, and leaving
their lists unwritten would be work postponed rather than a run that stopped.
A rolled-back game needs no gamelist handling of its own, which is worth knowing before
adding some. GamelistSync writes from local_file, and the rollback removes the row with the
bytes, so a game that was taken back is simply never written. Verified on the live install: a
sync stopped mid-transfer left no row without a file, nothing under partial/, and the store
byte-identical to before the run.
Also measured, since it costs nothing to say: the control reload worked with ES unfocused,
so ES's own reload does not depend on focus. Driven twice with the exact path /systems/<system>/games reports
and an explicit text/plain body: 200, empty response, emulatorLauncher.log did not grow by
a byte, no emulator process. M0 recorded /launch as working from the API's own help page,
which was documentation rather than a drive. A hands-on pass covering game-start and
game-end needs a person at the controller; it cannot be scripted through this API.