Steam Non-Steam Shortcuts¶
Technical reference for how Tender creates, manages, and launches non-Steam shortcuts. This covers the
SteamClient.Apps.AddShortcut API, VDF format details, and app ID handling.
AddShortcut API Behavior¶
Signature¶
SteamClient.Apps.AddShortcut(name: string, exe: string, startDir: string, launchOptions: string): Promise<number>
Returns the new shortcut's appId (a number), or 0/null on failure.
What it actually does¶
Despite accepting four parameters, AddShortcut ignores startDir and launchOptions. This was confirmed by the
MoonDeck plugin developers. Only name and exe are used during creation.
To set all shortcut properties reliably, wait for Steam to register the new app's overview before the Set* calls:
const appId = await SteamClient.Apps.AddShortcut(name, exe, "", "");
// Poll appStore.GetAppOverviewByAppID(appId) (~100ms cadence) until the overview
// exists, with a 1000ms fallback; on timeout, proceed anyway.
await waitForAppOverview(appId, 1000);
SteamClient.Apps.SetShortcutName(appId, name);
SteamClient.Apps.SetShortcutExe(appId, exe);
SteamClient.Apps.SetShortcutStartDir(appId, startDir);
// Every shortcut's launch options are its own appId (shortcutLaunchOptions).
// Unconfirmed here; the reconcile after the sync confirms it.
SteamClient.Apps.SetAppLaunchOptions(appId, shortcutLaunchOptions(appId));
Steam must finish registering the new app internally before the Set* calls land, or they silently fail. Rather than a
fixed worst-case wait, Tender polls appStore for the new overview (readiness) — the common case proceeds in ~100ms
instead of a blind 500ms, and the 1000ms ceiling keeps the old wait's safety net when the overview is slow. The
launch-options write is not confirmed at creation: setLaunchOptionsConfirmed's RegisterForAppDetails poll forces
Steam to load and cache a fat AppDetails object per call, which a sync creating hundreds of shortcuts cannot afford.
The reconcile after the sync (get_bound_launch_options) lists every bound shortcut whose launch options in
shortcuts.vdf are not its appId, and confirm-sets those.
Where the exe points¶
Every shortcut's exe is <bin root>/tender-rom-launcher — ~/.local/bin/tender-rom-launcher by default, the place
XDG names for a user's own executables. Not in the directory the program is installed in, because a shortcut's exe is
the one thing about it this program cannot repair from inside: an update that replaced its own install directory would
leave every game pointing at a file nothing puts back. Not under the data root either, which holds the database and
nothing executable (ADR-0038, which supersedes
ADR-0032 on the home; the roots themselves are
ADR-0036). The backend installs this release's launcher there unconditionally
on every start.
Shortcuts of ours that are not at that home are repointed once, at panel load. The backend decides which: it parses
shortcuts.vdf and returns the app IDs whose exe ends in /bin/tender-rom-launcher but is not the launcher's home,
plus the exe and startDir to write (get_shortcut_relocation). The frontend writes exactly those and reports; it
records nothing (frontend/src/utils/launcherRelocation.ts). The transition is stamped by the NEXT start's own reading
of that file, once Steam has written its in-memory shortcuts out — after which no start reads it again. An app
overview carries no exe, so the frontend's own route to the same fact would be a RegisterForAppDetails per
shortcut at every start.
Three properties of that path are load-bearing:
- It ends in
/bin/tender-rom-launcher. Ownership is decided by that suffix and nothing else (isRomMShortcutDetails,domain/shortcut_data.py::select_shortcuts_to_relocate, andbackend/services/prune/requests.py), so a launcher kept under any other last two components makes every shortcut written before that change stop being recognised as ours. - One ending is the whole of ownership. A shortcut naming a launcher an earlier version of this program wrote is foreign here: not recognised, not repointed, not counted, not pruned. A user coming from 0.33 removes all non-Steam shortcuts and syncs again (ADR-0038).
- The app id in the file is signed.
shortcuts.vdfstores it as a signed int32 (to_signed_app_id) while everySteamClient.Apps.Set*takes the unsigned form, so anything reading ids back out of the file converts them (to_unsigned_app_id). A negative id names no shortcut and fails silently.
Exe quoting¶
Do NOT pass quoted exe paths to AddShortcut or SetShortcutExe. The API handles quoting internally. Passing
"\"path/to/exe\"" (pre-quoted) results in double-quoting, which causes launches to fail with "file not found."
Pass the raw path:
Launch options pass through a shell¶
Steam runs a non-Steam shortcut's launch options through a shell, and the launcher receives what that shell made of
them. Measured on the reference device: a $HOME inside double quotes in the launch options reached the program
expanded, and a $(…) inside them was run. Double quotes therefore do not keep a file name literal — $, backticks and
$(…) in it are still expanded.
So nothing but the appId is written there (backend/domain/shortcut_data.py::shortcut_launch_options): digits, which no
shell changes. What a start runs never passes through that shell. The start check hands the command to the launcher in a
file, one argument per record, and the launcher runs those records as they are
(Core & emulator selection);
tests/scripts/test_tender_rom_launcher.py checks that an argument holding spaces or a quote reaches the program as
written, and that anything after the appId is ignored.
Updating existing shortcuts¶
Steam assigns a shortcut's appId when AddShortcut creates it, and that appId is stable for the shortcut's
lifetime. Tender never computes it: it records Steam's assigned id in roms.shortcut_app_id and detects ownership by
the exe path (…/bin/tender-rom-launcher), not by re-deriving the id. (The historical "appId is
CRC32(exe + appName)" formula does not hold on current Steam — see App IDs and Artwork.) Two
consequences follow:
launchOptionsandstartDirare appId-safe. Changing either on an existing shortcut keeps the sameappId, so the shortcut's identity, artwork, collection membership, androms.shortcut_app_idbinding all survive.SetAppLaunchOptionson an existing shortcut is reliable — confirmed on hardware in #827 across in-session writes, a Steam restart, and removal-churn re-syncs. Tender uses it to set a shortcut's launch options back to its appId before a start and in the reconcile at startup and after a sync.exeis appId-safe too, and that is now measured. Every one of a 826-shortcut library had itsexeandstartDirrewritten in one pass — the relocation 0.33 ran at start-up, into the launcher home of ADR-0032 that ADR-0038 has since superseded (see Where the exe points) — and the appId set afterwards was identical to ashortcuts.vdfbackup taken before it: 0 new, 0 lost, names unchanged, and the 1,652Set*calls (oneSetShortcutExeand oneSetShortcutStartDirper shortcut) cost 12 ms of renderer time. Earlier revisions of this page said anexechange had to be applied by delete + recreate; that rested on the CRC derivation disproven in App IDs and Artwork, and this measurement replaces it.- The display name is the one nobody has measured. The sync writes it in place as well —
rewriteShortcutIdentitysets name, exe, start dir and launch options together for a rom that already holds a binding — and nothing has established what aSetShortcutNamedoes to the appId, in either direction. Do not read theexemeasurement above as covering it: it says nothing about the name, and no delete + recreate path exists for one to fall back on.
Because SetAppLaunchOptions returns void with no success signal, Tender fires the set then polls
RegisterForAppDetails until the read-back strLaunchOptions matches (setLaunchOptionsConfirmed). That report can
take longer than the 2 s the poll waits — right after a Steam start, Steam has already written the new value to
shortcuts.vdf while the report is still outstanding — so when the wait runs out, Tender reads the shortcut's details
once more (getAppDetails, bounded by the same 2 s) and counts the write as confirmed if they show the value — a
confirm only the re-read gave is logged at INFO, a first-report confirm is not. A re-read can also answer with a value
other than the one written while the file already holds the written one: on the reference device it did so for two
shortcuts, each written back to a value it had held before, and which value the re-read showed was not recorded (#2348).
So when the re-read does not show the value either, Tender asks the backend what shortcuts.vdf holds for the shortcut
(get_shortcut_launch_options, which reads the file and never writes it), and the written value there confirms the
write, logged at INFO with how long this confirm waited for the reading (in a batch, the wait for the batch's shared
reading, not its parse). The reading waits at most 5 s — an endpoint call otherwise waits out a backend that is down —
and one that does not answer in time counts as a file that could not be read. The file's answer is sound because on the
reference device Steam rewrote the file about 0.1 s after each write measured in #2348 (four writes, two shortcuts),
while the file is read 2–4 s after the write. What it cannot tell apart is a write that did not land of a value the file
still holds from before — possible only while Steam holds a different value in memory that it has not yet written out —
and that prompt rewrite makes such a window negligible. Where many confirms run together, as in the reconcile, each
batch of them reads the file once, at its first confirm that needs it, rather than once per shortcut
(batchConfirmLaunchOptions). Only a confirm the file does not settle either — it shows another value, holds no such
shortcut, or could not be read — is a failed confirm, and its warning names what the re-read and the file each showed;
every caller of setLaunchOptionsConfirmed gets that answer, so a write that did not land is still reported where its
caller reports one. Setting "" — the placeholder an uninstalled ROM carries until it is downloaded — is valid and
confirms against an empty read-back.
The real hazard is not the set: heavy removal-churn can corrupt Steam's in-memory shortcut state. A Steam restart clears it. The sync engine processes removals before additions to minimise churn.
See: frontend/src/utils/steamShortcuts.ts
Recovery after a server switch / re-import¶
Switching the RomM server URL — or re-importing on the same server — reissues rom_ids while the Steam shortcuts are
not deleted (their assigned appIds persist) and the roms rows survive (ADR-0007 retention) with the binding in
roms.shortcut_app_id, reverse-lookupable via get_app_id_rom_id_map(). Because Steam assigns the appId at
creation (the CRC32(exe + name) derivation is disproven — see App IDs and Artwork), Tender
never re-derives an id to "find" the old shortcut. It keeps a game's shortcut alive across the rom_id churn through
three lanes:
- Stable sibling-group keys and the same dump → the rebind lane. An unchanged game whose
sibling_group_keyis the same after the re-import (same IGDB/… identity) ridescollapse_sibling_groups's rebind lane: the old boundrom_idhas vanished, and a fetched member of the group carries the same non-emptyfs_name_no_extas the old row's storedfs_namewithout its extension, has a file on RomM and holds no shortcut, so collapse emits one entry keyed to the vanished sibling — the frontend reuses its existing shortcut byrom_id— carryingbind_rom_id→ that member, whether or not the old version is installed. The shortcut, its artwork, its collection membership, and its playtime all survive; only the DB binding moves onto the newrom_id, and no duplicate is minted. A vanished bound sibling with no such member keeps its binding and its shortcut (ADR-0021 §2's amendment): another version of the game is one the user did not choose, and the game page says the bound one is gone. The exception is a group that holds another bound shortcut, which converges (A game with more than one shortcut). (The domain behavior is unit-pinned intests/domain/test_sync_diff.py; on-device confirmation of the end-to-end path is still pending.) - Changed / absent group keys → a second shortcut. If the re-import changes or drops the
sibling_group_key(the game rematches to a different metadata entry, or its identity is lost), the group no longer resolves to the old bound sibling, so the freshrom_idtakes the new lane: a freshAddShortcutwith a new appId. The old shortcut stays bound to its vanished row, since the stale removal keeps a row missing from a platform it read whole, and the removed-game cleanup is what takes it out. This is honest degradation — the identity link the rebind lane needs is gone, so the shortcut cannot be carried over. - Untracked orphans → adoption at create time. A live RomM-owned shortcut (exe ends
/bin/tender-rom-launcher) that carries no DB binding — a crashed run's uncommitted in-flight shortcut, or a zombie left after a DB reset — is invisible to both the rebind lane (noromsrow) and the stale path (nothing to unbind), so a naive create would leave a duplicate (#1366). When such a ROM reaches the create path, the frontend adopts the orphan instead: it matches the built entry's display name against a once-per-run pool of live-but-unbound RomM appIds (getLiveRomMShortcutAppIds()minus the run's already-bound appIds, resolved to names viaappStore.GetAppOverviewByAppID), reuses the matched appId, and rewrites its identity and launch options (the sameSet*writes as an update). The pool is built lazily on the first create candidate (a pure-update run pays no scan cost); each orphan is adopted at most once per run; a name collision adopts the lowest appId deterministically; anulllive scan (store unreadable) disables adoption for the run rather than guess. An adoption counts as "added" in the post-sync toast (a game came under management) but skips theAddShortcuta duplicate would cost. SeeresolveShortcutAppIdinfrontend/src/utils/syncManager.ts.
Two guards keep a re-import from wiping a freshly-bound shortcut (#1036):
- One appId, one bound row.
SqliteRomRepository.save()unbinds any sibling row holding the appId before the per-rom_idUPSERT, and migration003's partial unique index onshortcut_app_idenforces it (see Database Design). A re-import never leaves two bound rows sharing one appId. - Stale-removal excludes appIds bound this run. The finalize stale pass flags bound rows whose
rom_idwasn't synced this run — which includes the oldrom_idthe rebind lane just superseded — and removes only a game outside what the sync syncs (Backend Architecture — group-aware sync).domain/sync_diff.py:select_stale_removalsremoves any candidate whose appId is in the run'scommitted_app_ids(every appId bound this run, across both the happy-path and the heartbeat-timeout late-ack commit paths), so the appId the run just re-bound onto the newrom_idis never emitted for removal. Theget_by_app_idreverse lookup ordersrom_id DESC LIMIT 1so it resolves the live (newest) binding for any pre-migration edge state.
A bound ROM RomM holds no file for¶
RomM keeps a ROM row whose file has left its disk, and lists a physical game that never had one. Its payload's
has_file_on_disk is false for both, and RomM answers a content download of either with a 404; missing_from_fs and
is_physical are not read on their own. The sync stores the flag on the row (roms.has_file_on_disk, see
Database Design), and the representative chain reads it
(Backend Architecture — group-aware sync).
A shortcut bound to such a member that is not installed is moved by the next complete-view sync (a platform unit, or the
preview for a game a platform unit delivers) onto a sibling with a file whose fs_name_no_ext equals the bound
member's, through the same rebind entry the re-import case uses: keyed by the bound row, so the appId, artwork,
collections and playtime survive, and carrying bind_rom_id → the sibling. An equal stem means the file was replaced in
place — a rescan that turns Game (USA).zip into a folder Game (USA). A sibling whose name differs in any part
(language, region, version, a hack or a patch) never takes over: it is another version the user did not choose, and RomM
parses some of those dimensions, such as (v1.1), without sending them. The target must hold no shortcut of its own,
not even one the same sync converges away (A game with more than one shortcut),
and two bound members never move onto one target; of several candidates the chain's order picks. Otherwise the shortcut
stays on the member without a file, and its play button says No file on RomM.
classify_roms counts an entry carrying bind_rom_id as changed — this rebind and the re-import one alike — because
the apply moves a binding for it even when the shortcut's name and launch command stay the same. Without that, a rebind
alone left the preview at "Everything is up to date" with no Apply to press. The exception is an entry keyed by a row of
a unit the run skipped: the apply moves no binding for a skipped unit, so the preview counts none.
A game with more than one shortcut¶
A sibling group holding more than one bound shortcut converges to one on the next complete-view sync (#2645), unless one
of its shortcuts is running then. A game a collection brings in, its platform turned off, converges over the versions
RomM lists as siblings of the one the collection holds (#2661); a version RomM no longer lists as such a sibling is not
converged — it keeps its shortcut unless the version the collection holds is the one RomM moved to another game, which
leaves it a game no collection holds that goes the stale way — and a game on a platform RomM no longer lists keeps all
of them, since nothing reads that platform. Which one stays, and how the sync gets there, is
Backend Architecture, "Convergence". The others reach the panel in sync_stale like any
stale shortcut, each entry naming the shortcut its game keeps (kept_app_id, kept_rom_id), and are removed by the
same paced RemoveShortcut loop. Before that loop, frontend/src/utils/shortcutCarryOver.ts hands the kept shortcut
what Steam lets a running session move:
- the user's collections in
collectionStore.userCollectionsthat hold the removed appId, each on its own, and not Tender's ownRomM: …collections, which the run's collection maps file the kept shortcut in; that Favorites is among them is not measured on a device; - Hidden, through
collectionStore.BIsHidden/SetAppsAsHidden, which the panel reads apart from the user collections and skips where Steam has neither; neither call is measured on a device; - every image but the icon the ARTWORK read (
get_artwork_slots) calls set in Steam on the removed shortcut, where the kept one has none set in Steam, read from the loopback URL Steam shows it at and written withSetCustomArtworkForApp.
Each part is best effort: one that fails is logged, and the removal still runs. What a session cannot move stays behind
with the removed shortcut: its icon (a path in shortcuts.vdf), its Steam playtime, screenshots and last-played time,
and a controller layout under a name the kept shortcut does not share. The panel reports the removal like a stale one
(below, "A removal the panel did not finish"); for a converged one report_sync_removals also resets the removed
appId's Steam Input setting and deletes its grid images, as a removal the user starts does. The per-ROM cover cache
stays, because the ROM is still a version of a game with a shortcut.
A removal the panel did not finish¶
A row whose shortcut a run removes — stale or converged — keeps its binding through the run's finalize: the backend
unbinds it only once the panel reports the shortcut gone from Steam (#2653). The finalize builds everything else as
though the removal had happened — the collection maps, the platform-name cache and the terminal frame's game count leave
the row out — and settles the stamps the removal affects (the skip of a platform the run did not process, the stamp of
every collection holding the row). It settles them whatever the row's binding by then: the run emits sync_stale before
it measures its memory and writes the finalize, and a running sync does not refuse the report, so a report can unbind
the row first.
The sync_stale tail in frontend/src/index.tsx reports from a finally, so a tail the next run's sync_plan cuts
short, one whose lease renewal is refused, or one a carry-over stopped reports too. After the paced loop it reads
Steam's shortcut store once Steam has settled (shortcutsGoneFromSteam in frontend/src/utils/steamShortcuts.ts: every
100 ms, until every shortcut a removal was tried for is gone or 3 s have passed, then it logs how long it waited and how
many of those Steam stopped listing) and reports every entry Steam no longer lists through report_sync_removals
(frontend/src/utils/syncRemovalReport.ts), stale and converged apart. The backend unbinds each reported row only while
it still holds the reported appId, so a report that arrives after a later run bound the row again changes nothing. An
unreadable store reports nothing. A report that unbound a row re-reads the sync stats, so the panel's game count, which
counts a pending row until then, drops.
The passes onSyncComplete runs that read the bound rows — launch options, playtime and overview metadata — start only
once this tail has settled, its report included and whatever its outcome, so a row the report unbound is not among what
they read. The Steam collections pass runs beside the tail, since the run's collection maps already leave the removed
rows out.
What is not reported is finished by the next sync, in one of two ways:
- The shortcut is still in Steam — the removal was cut short, no panel received the event, or Steam did not remove
it. The row is still bound, so the next run's stale removal emits it again, or its group still holds two bound
shortcuts and converges again (the platform, or for a game a collection brings in the collection, is fetched until it
has), with the same
kept_*and a carry-over that adds nothing twice. The preview counts that removal. - The shortcut is gone but the report was lost — the backend or the panel stopped between the removal and the report, or the backend refused it. The next sync's start reconcile (below) unbinds the row, so the run emits nothing for it and the preview counts nothing.
Not taken: removing, on a later sync, every live launcher shortcut no row is bound to. After a database reset that would remove the shortcuts of games a sync keeps on purpose, and the preview could not count it.
Retained-row availability in the version picker¶
ADR-0007 keeps a roms row after RomM stops returning that id because the row can anchor local-only saves, playtime,
and an installed ROM. Retention does not imply that RomM still offers the row as a playable version. The Game Page's
lazy get_version_list load therefore recomputes availability every time; there is no liveness column, migration,
cache, or persisted verdict. (The no_file marker below is a different thing: a fact the last sync stored, not a
liveness verdict.) The version picker loads it when the page opens, after a version switch, after a group member's
download or uninstall, and on every sync_progress frame that ends a library sync run, so the page's banner follows a
sync without the page being reopened.
The bound id's own read (get_rom_simple, GET /api/roms/{id}/simple) supplies its own answer and RomM's current
direct sibling_roms view. The bound id and every local id positively present in that view are live. A local group
member absent from the direct view is only a suspect because RomM sibling membership can be transitive, so each suspect
is checked by an exact-id get_rom_once request. These checks fan out concurrently on the worker executor and use the
short timeout with no retry, keeping them off the event loop and out of the initial Game Page render.
Only a typed RommNotFoundError from an exact id marks that entry vanished. A successful response is live; timeout,
transport, authentication, server errors, and malformed or empty data all fail open and leave it available. If the bound
read itself 404s, bound_vanished is true, server_query_failed stays false, and every other local member is checked
individually. That entity-specific 404 is not fed into the global connection store; a genuine explicit
server-unreachable result still is. Cover and save endpoints are not liveness authorities.
Vanished rows stay visible with their active and downloaded markers, but cannot be switched to — selecting one opens its
cleanup — and keep the Default badge where RomM holds them as the default. vanished does not change switchable or
domain.sibling_group.target_in_sibling_group: availability and sibling membership remain separate verdicts. This lets
a shortcut still bound to a vanished id show the retained context while the user selects a live alternative. The Saves
tab likewise skips positively vanished inactive installs before checking for unsynced saves, then continues through
later live candidates.
The list verdict is advisory UI state, not authority for a later write. Immediately before switch_version moves the
binding onto an already-local target, it checks that exact target id again through the same three-second, single-attempt
get_rom_once path. The request runs on the worker executor outside the write UoW and after the save-stranding guard
permits the attempt. Consequently an initial unsynced-save warning makes no target request, while both Sync now and
Switch anyway retries are protected; allow_stranded never bypasses liveness. A typed target 404 refuses with
version_vanished without changing the binding or any recorded state. Every other optional-probe outcome fails open, so
a local switch remains fast when RomM is uncertain or offline. The active-target no-op does not probe because it moves
no binding.
A server-only target already requires its full RomM detail for membership validation and row construction. That fetch
keeps its normal retry policy, doubles as the liveness verdict, and receives no second probe. Its typed 404 produces the
same version_vanished refusal; other RomM errors keep their ordinary classified reason. Network I/O remains outside
the short write UoW, whose fresh membership and bound-elsewhere checks still decide SQLite races. This leaves an
unavoidable cross-system interval after a successful response: the liveness check reduces stale-list risk but is not a
transaction with RomM.
A version RomM still lists but holds no file for carries no_file — for a local row the column the last sync stored,
for a server-only one the fetched detail — and the row reads No file on RomM. It keeps the Default badge where RomM
holds it as the default, and unlike a vanished row it offers no local cleanup, since a rescan in RomM can bring back a
file that was moved or renamed. The row is selectable only where that version is installed here, and switch_version
refuses any other switch to it with no_file_on_romm. A download refuses the same way on RomM's live answer, read when
it starts, so a file a rescan brought back since the last sync downloads, and one that left since does not.
Explicit cleanup of vanished versions¶
Automatic sync remains unbind/retain-only. Deleting retained local state is a separate confirmed workflow under Danger Zone → Clean Up Removed RomM Games, also reachable by activating a synced vanished version's own picker row (which carries a trash affordance, the menu row being the focusable unit) or as a focused button for a synced singleton vanished binding. Candidate discovery is not deletion authority: the backend freshly probes each exact RomM id, and only typed 404s can proceed.
The cleanup never moves a shortcut onto another version. A vanished bound version whose game still has a live sibling is kept with its shortcut and row; the version picker is the way to switch, and once the user has, the vanished version is an unbound row the cleanup removes like any other.
For a fully vanished bound game, whole-game cleanup is its own confirmation option, default-on and paired with the default-on recovery bundle that keeps the shortcut rebuildable. With recovery enabled, the root frontend handler captures complete shortcut details, available Steam playtime fields, and every collection id/name or fails closed if that JSON is over the snapshot's byte cap. The backend resolves the active account from Steam's login identity once, stores that identity in the recovery handle, and adds only that user's grid artwork, both per-app Steam Input roots, and relevant controller setting. Cleanup must use those exact captured roots even if the active account changes later.
Every action event is deduplicated and serialized. Before any Steam mutation, the frontend claims its token from the
backend, re-reads the live shortcut, and requires the appId to exist with an exe ending in /bin/tender-rom-launcher.
Only then does shortcut removal capture a fresh complete snapshot, compare it with the sealed snapshot, call
RemoveShortcut(appId) once, and poll the live store until absence. Identical claim retries are idempotent. Completion
reporting uses bounded retries of the same payload without repeating the Steam operation. If every completion report is
lost, the claimed lease expires as an ambiguous partial and retains source data. A RemoveShortcut call followed by an
unreadable store or settle timeout is also explicitly attempted-but-unconfirmed, never reported as an unchanged failure;
a later run can confirm the appId is already absent and reconcile the binding without calling removal again. An
unreadable/foreign store before mutation, stale claim, or unclaimed timeout is failure, and no row/source finalization
follows an uncommitted action.
The same reciprocal exclusion covers ordinary frontend Steam continuations outside an action event. SGDB fetch results
carry leases through hero/logo/grid/icon writes; sync_complete keeps one shared lease until launch-option, collection,
playtime, and overview-metadata branches all settle; and bulk shortcut removal clears its collections before
acknowledging and releasing the removal lease. Leases live in one frontend registry, renew only for a bounded active
continuation, and receive a cooperative cancellation signal before their owner releases them. A continuation with an
owner captures that owner's mount generation before backend waits, so a token arriving after the owner's teardown is
released without admitting old continuation work even if a new owner has since mounted. Each non-empty sync_stale
frame owns a lease through its paced tail; successful sync_complete processing overlaps that lease while joining the
same tail. A backend emit failure rolls back a token the frontend never received.
Recovery records the Steam-assigned appId and playtime, but there is no automatic restore and Steam cannot currently reattach those values to a newly created shortcut.
Sync-start reconcile of Steam-UI-deleted shortcuts¶
A user can delete a RomM shortcut through Steam's own UI (remove from library), which Tender never observes. The
roms row keeps its now-dead shortcut_app_id, so get_app_id_rom_id_map keeps serving it (playtime writes and
launch-options writes aim at a Steam app that no longer exists) and the incremental skip never recreates it: the
skip reads RomM's answer and rebuilds the platform from its bound roms rows, not from live Steam shortcuts, so the
platform reports "unchanged" forever. The game stays gone until a server-side change or a Force Full Sync (#1046).
The fix is a frontend-assisted reconcile at sync start, because only the frontend can read Steam's shortcut store.
It runs before the sync builds its work queue — so the unbind lands before the incremental-skip decision — on both
the skip-preview (start_sync) and preview (sync_preview) paths:
scanShortcutOwnership()(frontend/src/utils/steamShortcuts.ts) scans Steam's live shortcuts and answers two sets of raw appIds, regardless of any backend binding:owned, every RomM-owned shortcut (exe ends with/bin/tender-rom-launcher), andunresolved, every entry whose details Steam did not answer for beforegetAppDetailstimed out. It returnsnullwhen the store was unreadable (collectionStoreabsent) versus two empty sets when the scan ran and found none — a load-bearing distinction.reconcileStaleShortcuts()(frontend/src/utils/syncManager.ts) skips the reconcile on anullscan (reconciling against "couldn't look" would unbind every binding), and otherwise calls thereconcile_shortcutsendpoint withownedandunresolvedtogether. An unresolved entry is one nothing was established about, so it keeps its binding: a binding that should have gone is corrected by the next run that can read the entry. Unbinding a live shortcut instead sends its platform back through a full fetch and the ROM through the apply, which adopts the unbound shortcut by its display name when the apply's own scan identifies it as ours (the orphan-adoption pool insyncManager.ts). The Steam-side state on it — its collections, and the appId playtime and artwork hang off — is lost when that scan cannot identify it either, or when the shortcut's display name no longer matches the game's name — a game renamed in RomM; in both cases the apply creates a second shortcut beside it. It is best-effort: a scan or backend failure is logged and swallowed, never blocking the sync.ShortcutRemovalService.reconcile_live_shortcutsunbinds every boundromsrow whoseshortcut_app_idis not in the live set — clearing only the binding (Rom.unbind_shortcut, ADR-0007), never deleting the row or its per-ROM children. An empty live set is the correct "they're all gone" signal and unbinds every binding.
The reconcile revokes the skip of every platform it unbinds a row on (PlatformSyncStateRepository.revoke_skip, in the
same write UoW as the unbind), so the platform falls through to a full fetch that recreates the shortcut. The unbind is
reversible by design — the next sync re-binds. It also finishes a sync's removal whose report never arrived (above, "A
removal the panel did not finish"): that row is still bound to a shortcut Steam no longer lists, so the reconcile
unbinds it before the run decides what to remove, and the run removes nothing for it.
This is eager (sync-start) reconciliation of the Steam-shortcut binding, distinct from #951's lazy on-access
reconciliation of the rom_installs (on-disk install) view: a different aggregate, a different cost driver, and —
unlike installs — one the backend physically cannot reconcile lazily, since no per-game backend seam observes Steam's
shortcut store.
BIsModOrShortcut¶
Non-Steam shortcuts return BIsModOrShortcut() = true by default. This is their natural state — Steam uses this flag to
determine how to render and launch an app.
An earlier version of Tender used a "bypass counter" pattern (inspired by MetaDeck) to temporarily return false from
BIsModOrShortcut() so that Steam would render metadata sections (description, developer, etc.) on the game detail
page. This approach was dropped in Phase 5.6 because it caused launch failures — Steam skips the shortcut launch
path when BIsModOrShortcut() returns false.
The current approach owns the entire game detail UI via custom React components (RomMPlaySection, RomMGameInfoPanel,
CustomPlayButton) injected into the render of Steam's own game-page route — the seam, and why it is that one, is in
frontend-bundles.md. This avoids fighting Steam's internal
rendering logic.
See: frontend/src/bigpicture/patches/gameDetailPatch.tsx, frontend/src/bigpicture/RomMPlaySection.tsx
Overview metadata mutations (readiness-gated)¶
Beyond the custom UI, Tender writes three fields directly onto each RomM shortcut's SteamAppOverview so the shortcut
presents like a native Steam game: controller_support = 2 (the "Full Controller Support" badge — important so Game
Mode doesn't flag the controller-driven RetroDECK launch), metacritic_score (from RomM's average_rating), and
m_setStoreCategories (RomM's steam_categories).
Steam rebuilds appStore from scratch on every SharedJSContext mount, so these in-memory mutations are lost on each
reload and must re-apply per mount. registerMetadataPatches builds the appId→romId map; applyAllMetadata then
applies the mutations with a readiness retry (the same [0, 1s, 3s, 5s] ladder as applyAllPlaytime). Without the
retry the pass runs before appStore is populated and silently no-ops on a cold boot, so the badge/rating/categories
never appear until a later mount (#1203). The mutations are idempotent, so retries are safe.
The pass also re-runs on sync_complete. A sync adds or re-keys ROMs whose metadata the init-time pass never saw,
so onSyncComplete re-fetches the full paged metadata cache + appId map (fetchMetadataCachePages, shared with init),
re-registers via registerMetadataPatches with the fresh data, and re-applies — mirroring the playtime re-apply beside
it, and like it only once the run's stale removal has reported (above, "A removal the panel did not finish"). It runs on
every sync_complete, cancelled runs included (a partial run's committed units still carry fresh metadata, and the pass
is idempotent), with its own error handling, so a re-fetch failure never touches the toast, collections, or playtime
paths. The backend commits metadata per unit during the sync (before the terminal emit), so this re-fetch always sees
the new ROMs.
See: frontend/src/utils/metadataPatches.ts (which appId gets which value, and the retry ladder),
frontend/src/utils/steamOverview.ts (writeMetadataFields, where all three assignments onto the overview are made),
frontend/src/utils/metadataCache.ts (paged fetch), onSyncComplete in frontend/src/index.tsx
VDF Format Notes¶
Shortcut creation and every field update go through the frontend SteamClient.Apps.AddShortcut() / Set* API —
AddShortcut returns the real appId directly, so Tender never computes app IDs itself and never edits shortcuts.vdf
while Steam is running (Steam holds the file in memory and rewrites it from memory, silently clobbering external writes
— see shortcuts.vdf is memory-authoritative). The backend SteamConfigAdapter
(adapters/steam_config.py) still lays down artwork files in the grid directory, including the icon PNG. It reads
shortcuts.vdf for three questions — which shortcuts the one-time relocation still has to repoint
(read_shortcut_exes), what a shortcut's launch options are when Steam's reports did not confirm a write
(read_shortcut_launch_options), and which icon each shortcut shows, which tells the shortcut artwork job whether its
icon slot is filled (read_shortcut_icons) — and its whole-file shortcuts.vdf helpers (read_shortcuts,
write_shortcuts) are on no live path since the icon write moved to SteamClient.Apps.SetShortcutIcon.
shortcuts.vdf structure¶
Steam stores non-Steam shortcuts in a binary VDF file at:
Each entry has these key fields:
| VDF Field | Format | Notes |
|---|---|---|
AppName |
string | Display name |
Exe |
string | Executable path. AddShortcut-created entries store it unquoted (on-device inspection) — the API handles any quoting internally |
StartDir |
string | Start directory. Stored unquoted for AddShortcut-created entries |
LaunchOptions |
string | The shortcut's own appId, the same for every bound shortcut, installed or not (#2221 D7, D10); bin/tender-rom-launcher reads only the appId and ignores anything after it. No romm:<id> marker; ownership is detected by the exe path instead |
appid |
signed int32 | Assigned by Steam when AddShortcut runs; stored as the signed int32 form (to_signed_app_id) |
icon |
string | Icon path or hash |
tags |
object | Steam collection tags. Tender manages collections via collectionStore (machine-scoped names like RomM: N64 (steamdeck)), not by writing this VDF field. |
shortcuts.vdf is memory-authoritative¶
While Steam is running, shortcuts.vdf is authoritative in Steam's memory: Steam rewrites the file from memory
mid-session and on exit, so any external write to it while Steam runs is silently clobbered. Tender therefore creates
and mutates shortcuts only through the SteamClient API (AddShortcut / Set* / SetShortcutIcon), never by editing
shortcuts.vdf directly. Pass raw, unquoted paths through those APIs — the API adds any quoting internally, and
on-device inspection confirms AddShortcut-created entries are stored unquoted; pre-quoting double-quotes the path and
breaks launches (see Exe quoting).
See: backend/adapters/steam_config.py
Collection management¶
Steam collections are managed entirely on the frontend via collectionStore, not by writing the shortcut's tags VDF
field. Tender owns machine-scoped collections named RomM: <platform> (<hostname>) for platforms and
RomM: [<name>] (<hostname>) for synced RomM collections. The sync_complete event carries platform_app_ids and
romm_collection_app_ids maps; onSyncComplete (frontend/src/index.tsx) creates/updates the collections for the maps
it receives and then runs a stale-collection cleanup that deletes any RomM: … collection for this machine whose
platform/collection name is absent from those maps.
<platform> is the name RomM's platform listing gives the platform, never the custom_name a user set in RomM, for
every platform the run's listing names, its sync on in Tender or not: the fetcher records each listed platform's name
beside its fs_slug (LibrarySyncStateBox.run_listed_platform_names, through
domain/platform_names.py::listed_platform_names) (#2564 D1,
D2). Two listed platforms can share a name — two folders RomM files under one slug, a version folder beside its
console's — so a platform whose fs_slug is not its slug, and whose name another listed platform carries too, is named
<name> (<fs_slug>), and each keeps a collection of its own
(#2452 D2). A platform RomM no longer lists keeps the name
the platform_names cache holds for its fs_slug, else its fs_slug; a game with no fs_slug recorded is filed under
no platform's collection (Backend Architecture,
"The stale removal keeps a game gone from RomM").
The cleanup is gated on a completed (non-cancelled) sync (!data.cancelled). On a cancelled run the maps are
partial — they list only the platforms the run reached before the cancel (empty if the cancel fired before the first
unit), because the backend builds platform_app_ids from the cross-unit accumulator of reached platforms. Treating a
partial map as the authoritative active-set would delete the collections for unreached platforms — an early cancel would
wipe the entire library organization. The additive create/update path stays ungated, so the platforms that did complete
still get their collections; only the destructive deletion is skipped on cancel. Steam collections are not backed up, so
the safe behavior on a partial/cancelled run is to delete nothing.
The collections are written once, at the end of the run, and not as each game's shortcut is created. Writing each game
into its collections during the run was measured on the reference device over a sync of 329 new games on 10 platforms
(#2416, D7): it took 151–154 s against 118–120 s, and each new game cost about 280–290 ms in the apply against about 200
ms. A collection's Save() cost 250–380 ms whatever the collection's size, and while collections changed during the
apply, Steam's own calls for each new shortcut — its creation, its registration and its artwork — got slower, 30–60 ms
per game together. Grouping the saves every 5 s cut the cost of Tender's own collection writes to about 25 ms per game
but left Steam's slowdown as it was; a sync over games already filed cost nothing extra.
Collection naming mode — merge vs by_label (#1539)¶
The romm_collection_app_ids wire payload is name → appIds only — kind/virtual_type are collapsed away before the
frontend sees them, and the frontend simply wraps each key as RomM: [<key>] (<hostname>). So the Steam-collection name
is decided entirely by the run finalizer's dict key, which RunFinalizer._resolve_collection_memberships
(services/library/run_finalizer.py) takes from domain/collection_label.py::steam_collection_key under the
collection_naming_mode setting:
merge(default) — the key is the bare collection display name. Same-named RomM collections of any kind union into oneRomM: [<name>]Steam collection (RomM permits same-named collections across kinds/users, #1503).by_label(opt-in) — a standard collection (a favorites collection included) keeps the bare name as its key; every other kind's key is"<name> (<Label>)", where the label comes from the puredomain/collection_label.pykernel. The rule: the label is a short form of the kind's row name on the Collections page —Smart(Smart collections),Franchise(Franchises),IGDB Collection(IGDB collections). A virtual collection of no known type falls back to its group heading's word,Autogenerated; the sync never writes it, since it fetches only the franchise and IGDB-collection types. So a hand-picked collection, a franchise and an IGDB collection that share a name becomeRomM: [<name>],RomM: [<name> (Franchise)]andRomM: [<name> (IGDB Collection)]— three Steam collections. Collections whose keys match still union: two of the same name and kind, and — accepted, since it takes a name spelling out another kind's label — a standard collection named"Foo (Smart)"with a smart one named"Foo".
IGDB Collection keeps its capital C: Steam tells collection names apart without regard to case, and Tender finds an
existing collection the same way (below), so a change of case alone would not rename a collection that already exists.
The Collections page does not quote the label strings. Besides the kernel, its test and this section's own examples, they are spelled in these places, and a label change is a change in each:
- the user guide's "Collections that share a name" (
docs/user-guide/syncing-your-library.md); - the description of the Steam Library setting that turns the mode on
(
frontend/src/bigpicture/settings/LibrarySection.tsx), which gives two as examples; - qam-panel.md § Library, which quotes the three a synced kind gets and the fallback;
- the format example in
RunFinalizer._resolve_collection_memberships' docstring (backend/services/library/run_finalizer.py), and thevirtual_typecomment inbackend/domain/work_unit.py.
tests/services/library/test_reporter.py asserts the union keys the run finalizer builds, so a label change fails there
on its own. The run finalizer needs the kind/virtual_type at its union key, so WorkUnit.virtual_type and
CollectionMembership.kind + CollectionMembership.virtual_type thread that identity through the fetcher →
orchestrator → run finalizer.
Label-format constraint: the reconcile parses the collection name with /^RomM: \[([^\]]+)\]/i
(frontend/src/index.tsx), so a label must contain no ] character — it sits inside the single existing bracket
pair. Parens ((Franchise)) are safe; a bracket would truncate the parsed name and orphan the collection. Every
produced label is bracket-free (asserted in tests/domain/test_collection_label.py).
No Force Full Sync on a mode flip. Because the create-name and the reconcile's activeNames both derive from the
same romm_collection_app_ids keys, flipping the mode is applied by the ordinary complete-set reconcile on the next
normal sync: the run finalizer re-emits the complete set of enabled collections under the new keys, onSyncComplete
creates the new-named collections and deletes any old-named collection absent from the new complete set. No skip-state
invalidation is involved (same mechanism owner-scope reshaping uses). A change to a label reaches Steam the same way: a
sync that reaches the collection creates it under its new name, and the first sync that finishes in full (its
sync_complete carries no cancelled) deletes the old one — a new Steam collection, not the old one renamed.
The preview names collections by the same key. A completed run records the run finalizer's keys as its
collections_completed, and the preview's collection_diff builds its current set with the same
steam_collection_key, so an unchanged set of Steam collections shows no collection change in either mode, and the
Added and Removed names the Sync page lists are those keys, the name-part of each Steam collection (Filter (Smart)
under by_label). It compares them without regard to case ("Name identity is case-insensitive", below). A mode flip is
a real rename: the preview lists each labelled collection's new name as added and its old name as removed, except a name
another collection already carries on that side; this holds until a run completes. Under by_label the same holds for a
standard collection over a run recorded while standard collections still carried a (Standard) label.
Name identity is case-insensitive (#1569). Steam collapses collection names by a case-insensitive identity — two
collections whose display names differ only in case (RomM: [7 up] vs RomM: [7 Up]) are the same Steam collection, so
creating the second silently overwrites the first and loses its games. To match, Tender folds both names wherever it
asks whether two names are one Steam collection, by one rule on both sides: lower case, then upper case, then lower case
again — fold_collection_name (backend/domain/collection_name.py) on the backend, foldCollectionName
(frontend/src/utils/collectionName.ts) on the frontend, both tested against the names in
tests/domain/collection_name_folds.json. It is that chain rather than Python's str.casefold(), which JavaScript has
no counterpart for, and rather than a single lower-casing, which keeps Straße apart from STRASSE. Python and
JavaScript compute the chain identically on every code point both assign, and it joins each code point with everything
str.casefold(), toLowerCase() or toUpperCase() joins it with — plus one pair str.casefold() keeps apart, a
dotless ı and i (measured: CPython 3.13 / Unicode 15.1 against Node 24 / Unicode 17). For grouping and for the
create/find, joining more is the safe direction: what loses games is a pair Steam holds as one and Tender keeps apart.
On the delete matchers, joining more means deleting more.
Where the fold applies: the run finalizer groups both romm_collection_app_ids and platform_app_ids by the folded
key, keeping the first-seen original casing for display (which casing wins is irrelevant — every match folds both
sides); the preview's collection_diff and platform_collection_diff compare folded names the same way; and the
frontend create/find (createOrUpdateCollections / createOrUpdateRomMCollections), the cleanup matchers
(clearPlatformCollection / clearAllRomMCollections) and the onSyncComplete stale-delete (removeStaleCollections,
frontend/src/index.tsx) fold the whole name, the RomM: prefix and the host suffix included — so a case variant of
one of this machine's names matches as that name does, while one carrying another machine's suffix does not — a hostname
differing from this one only by case counts as this machine, as it does for Steam. This is safe because Steam's identity
is case-insensitive: two collections differing only by case can never coexist, so there is never an ambiguous match to
disambiguate. The DB is unaffected — collection_sync_state is keyed by (collection_id, collection_kind), never by
name — so there is no migration.
App IDs and Artwork¶
SteamClient.Apps.AddShortcut() returns the real appId, so Tender does not compute shortcut app IDs itself —
there is no app-ID generator in the codebase. Steam assigns the appId at creation and it is stable for the
shortcut's lifetime, which is why mutating launchOptions or startDir keeps the same appId (see
Updating existing shortcuts) while delete + recreate yields a new one.
Errata (2026-07): the appId is not
CRC32(exe + appName). Earlier docs described theappIdasCRC32(exe + appName). On-device inspection of 68 live shortcuts Tender created matched none against any CRC32 candidate (exe/name variants, quoted/unquoted, with/without a trailing NUL, top bit set), and the live appids are uniformly spread across[0x80000000, 0xFFFFFFFF]— consistent with random assignment at creation (and with the community observation that delete + re-add yields a different appid). The load-bearing facts are unchanged: the appId is stable for the shortcut's lifetime (solaunchOptions/startDiredits are appId-safe), delete + recreate yields a new appId, and Tender's identity model never computes appIds — it records Steam's assigned id inroms.shortcut_app_idand detects ownership by the exe path. Only the derivation mechanism was wrong.
The frontend stores the returned appId and the backend persists it as shortcut_app_id on the ROM's roms row (the
synced-ROM registry; reverse-lookupable by shortcut_app_id). The frontend resolves rom_id ↔ appId through the
backend's get_app_id_rom_id_map() endpoint, which reads that binding.
The signed-int32 helper to_signed_app_id(app_id) remains in backend/domain/sgdb_artwork.py (alongside the SGDB
endpoint/asset-type maps) for the shortcuts.vdf record format, but no longer has a production caller now that the icon
write goes through SteamClient rather than editing the VDF.
Artwork file naming¶
Grid artwork is stored at userdata/<user_id>/config/grid/, keyed by the shortcut's real appId:
| Suffix | Artwork Type |
|---|---|
<appId>p.png |
Portrait grid (cover) |
<appId>_hero.png |
Hero banner |
<appId>_logo.png |
Logo overlay |
<appId>.png |
Wide grid / horizontal |
<appId>_icon.png |
Icon |
Each form also occurs with a .jpg / .jpeg extension. On shortcut removal Tender deletes the full suffix ×
extension set for the removed appId (ArtworkService.remove_artwork_files), so companion art (hero/logo/icon/wide)
never outlives its shortcut. Files a removal missed historically are reclaimed by Data Management's Grid images
cleanup (cleanup_orphaned_grid_images): candidates are only grid-image-named files whose appId sits in the
non-Steam-shortcut range ([0x80000000, 0xFFFFFFFF] — see the errata above; store-game custom art is out of range and
never touched) and whose appId belongs to no live shortcut in the frontend's full scan; if any bound
roms.shortcut_app_id is missing from that scan the cleanup refuses and deletes nothing.
ArtworkService (cover staging/finalisation, renaming the staged cover to {app_id}p.png) and SteamGridService (SGDB
hero/logo/grid/icon) own the artwork flow. The icon is a two-step write: SteamGridService.save_shortcut_icon writes
the ROM's cached icon into the grid dir via SteamConfigAdapter.write_shortcut_icon and returns its icon_path; the
frontend then points the live shortcut at it with SteamClient.Apps.SetShortcutIcon(appId, icon_path). The backend no
longer edits the shortcuts.vdf icon field — Steam is memory-authoritative and clobbered that write, so pointing the
shortcut must go through SteamClient (see
shortcuts.vdf is memory-authoritative).
Covers are applied per created shortcut through Steam's own artwork API during the apply, so tiles show their real cover
in-session with no client restart. Right after a newly created shortcut resolves its appId in the per-item apply loop
(applyCoverArtwork in syncManager.ts), the frontend fetches the cover bytes for that ROM
(get_artwork_base64(rom_id)) and hands them to SteamClient.Apps.SetCustomArtworkForApp(appId, base64, "png", 0).
Steam decodes the image, owns the tile, and writes the file itself as {app_id}p.png in the grid dir — the same path
the backend also writes — so the cover appears as the shortcut is created and Steam refreshes the tile in-session.
The key properties:
- Creates only. A cover is applied only when the item is a fresh create. An updated or rebound shortcut keeps its
existing grid file (in-session cover refresh on a version/metadata change is tracked separately,
#1386). - One cover per item, under the session-budget gate. The cover is fetched and applied inside the existing 50
ms-paced per-item loop — never prefetched or batched. Decoding many covers resident at once is exactly the CEF heap
overflow (
#797) that crashedSharedJSContexton large libraries; the session-budget gate (see ADR-0024) prices each create at its permanent cost plus the cover's transient peak and pauses the run before the renderer nears its heap cliff. - Fail-soft. A cover that can't be fetched (
base64: null) or applied (a throwingSetCustomArtworkForApp) is logged and never fails the shortcut — the shortcut is already created, and the backend's commit-time grid write is the durability net.
A cover that is set in Steam, or one the user chose in the ARTWORK tab (Game page artwork), is left alone by the sync: neither its cover refresh nor the apply's cover download and commit-time copy replace it, and RomM's change to it stays pending. Which step does what, and how that change arrives later, is the cover-cache invalidation in Backend Architecture.
The backend also writes each {app_id}p.png grid file at commit (SyncReporter._finalize_cover_path →
ArtworkService.finalize_cover_path), but for a cover set in Steam or chosen by the user; what a rebind to another
version does is in the Backend Architecture section linked above. That copy costs no renderer heap and is the durability
net: it lands the grid file even if a per-item API call failed, so a residual gray tile resolves the next time the
game's page is opened or on the next client restart.
Game page artwork¶
The hero, the logo and the wide grid come from SteamGridDB, and Steam has to be handed them in-session through
SteamClient.Apps.SetCustomArtworkForApp: a file written into the grid directory shows only after Steam restarts. The
icon comes from SteamGridDB too, and is pointed at with SteamClient.Apps.SetShortcutIcon. The cover is RomM's, and
Tender's pick among SteamGridDB's covers until RomM's arrives ("The cover where RomM has none", below). The first visit
to a game's page in a session sets each of the four, and the cover, only where the slot is not filled
(fillEmptyArtwork in utils/artwork.ts). Refresh Artwork and a change of SteamGridDB game are an explicit request
and replace the slots on automatic only (applyAutomaticArtwork, which reads the ARTWORK tab's slots first and leaves a
chosen slot and one set in Steam alone, #2381 D6, D12). A
version switch still replaces every slot (applyArtwork): once artwork was applied for one ROM of an app id, the newly
bound ROM's is applied in the replacing mode, which sets Tender's image over one set by hand too; whether Steam then
shows it over a hand-set file kept under another extension is not measured (the order Steam tries a slot's files in is
under "The ARTWORK tab", below). A version switched back to brings back the images chosen for it: the replacing fetch
serves the chosen image, downloading exactly that one where the cache lost it, and the switch sets a cover chosen for
the version rather than RomM's — it asks get_sgdb_artwork_base64(rom_id, 0), Steam's own number for the cover, which
serves only a chosen cover, reports what Steam made of it, and sets RomM's cover only where that answers no image (D16,
setChosenCover in utils/artwork.ts). Where fetch_cover_base64 answers no RomM cover — RomM has none for the
version, or it cannot be read at that moment — it sets that version's SteamGridDB pick through get_sgdb_slot_artwork
with replace and reports it, as the fill does (setSteamGridDbSwitchCover); the next sync that reads RomM's cover
replaces that pick (D24). Whenever a version becomes the one a shortcut is bound to — the switch's bind
(VersionSwitchService._rebind_local_io) and the sync commit's (SyncReporter._forget_cover_record_on_bind: a rebind,
a shortcut created anew after removal, an adopted orphan) — the same write forgets that version's cover record and
hand-over unless the cover is chosen (domain/artwork_slot_outcome.py, binding_forgets_cover_record), so the cover
written next is the one "Set in Steam" judges by, and a SteamGridDB cover Tender set for the version earlier never reads
as set in Steam under another image (D28). The replacing mode records the hero, the wide grid, the logo and the icon it
set, or Steam refused, under the ROM now bound, as the fill does below: what the report passes back — the image id, the
hash of the bytes and the ROM's SteamGridDB id — comes with get_sgdb_artwork_base64's answer for a hero, wide grid or
logo, and with save_shortcut_icon's for the icon. The shortcut artwork job fills the icon, the wide grid, the cover
and the logo in the background by the same rules (Shortcut artwork), so a first visit usually finds
them filled; the hero is the game page's alone. The icon is fetched only as a slot — on the game page through
get_sgdb_slot_artwork, in the job through do_fetch_slot_artwork — and get_sgdb_artwork_base64 refuses its asset
type (4) with invalid_payload.
The SteamGridDB game. A ROM's artwork comes from the SteamGridDB id RomM gives it where RomM gives one, else from
the game the user chose in the ARTWORK tab (roms.chosen_sgdb_id; domain/sgdb_artwork.py, effective_sgdb_id). Every
reader of the ROM's SteamGridDB id for artwork takes that one rule: the game page's fetches (get_sgdb_slot_artwork,
get_sgdb_artwork_base64), Refresh Artwork's resolution (get_sgdb_resolution), the shortcut artwork job's fetch
(do_fetch_slot_artwork) and its worklist, and the id each record is stored under. A game chosen in the tab
(apply_sgdb_game_id) is stored once SteamGridDB has answered without refusing; the change discards every image the
user chose for the ROM, those slots going back to automatic under the new game, downloads only the slots not set in
Steam — the cover among them where it shows Tender's SteamGridDB pick, which the page then hands to Steam
(applySteamGridDbCover) — and reloads RomM's cover where a chosen cover went (D12); a chosen cover set in Steam loses
its choice and the record of the image the choice set, which Steam no longer shows. Without either id Refresh Artwork is
disabled and opens no game picker (D3). The sync writes only RomM's id, to sgdb_id, and never the choice: once RomM
supplies an id it wins — the next sync or Refresh Artwork's resolution stores it — while the choice stays beside it,
unread.
Downloading anew. applyArtwork serves each image from the artwork cache first (get_sgdb_artwork_base64, and
get_sgdb_slot_artwork with replace for the icon), so a version switch downloads only what the new ROM's cache lacks.
For a ROM with neither RomM's id nor a chosen game the per-slot fetch serves what the cache holds for the slot, which a
pick made before picks were stored may have left there (not the hero, whose old file carries no image id), and answers
none only where it holds nothing. Refresh Artwork and a game change download past the cache: once
get_sgdb_resolution answers resolved, the frontend asks refresh_sgdb_artwork(rom_id, sgdb_id), which downloads the
slots on automatic that are not set in Steam, and a game change asks apply_sgdb_game_id, which downloads every slot
not set in Steam. Both replace a cached file only with a download that succeeded, before applyAutomaticArtwork serves
them; a game change then drops the previous game's file of each image it tried and did not download. A SteamGridDB id
RomM now provides therefore replaces an image a manual pick left in the cache, where SteamGridDB has such an image for
that game. A 401 or 403 from SteamGridDB on either refuses auth_failed with KEY_REFUSED
(services/steamgrid/_refusals.py), and a 429 refuses rate_limited; the frontend shows that message. Only the key
dialog's check (verify_sgdb_api_key) answers a 401 or 403 with "Invalid API key", as does every use case for a key no
header can carry. On Refresh Artwork and after a game change, a refusal of an artwork endpoint is shown as its own
message rather than read as "no image"; the first visit only logs it, and stops asking once a slot answers
key_refused. Refresh Artwork reloads RomM's cover only while the cover is automatic and hands the reloaded cover to
Steam through SetCustomArtworkForApp, as the sync's cover refresh does, since the grid file alone shows only after a
restart — under the sgdb_artwork lease refresh_cover_artwork answers with, through applyTabSlotImage's cover
answer as the tab's Automatic sets RomM's cover; where RomM answers it has none, it sets the cover back to automatic,
which downloads Tender's SteamGridDB pick anew and sets it (D25), and its toast counts that cover among the images it
applied ("Artwork refreshed (1/5 images applied)"). Its one toast also names a failed RomM cover refresh after the
SteamGridDB result, once where both say the same: "RomM has no cover artwork" where SteamGridDB has none either.
When a slot is filled (domain/artwork_slot.py, is_slot_filled). A slot already holding an image is left alone by
the fill, whoever set it — the fill never replaces an image set by hand. It is filled when Steam's grid directory holds
a regular file, links followed, named exactly the slot's name plus one extension: <appid> for the wide grid,
<appid>_logo, <appid>_hero. Any extension counts but .json, where Steam keeps the logo's position: which image
formats Steam reads there is not documented beyond PNG and JPEG, and missing an image set by hand would put a second one
beside it. The cover's file (<appid>p) fills the cover and not the wide grid, the logo or the hero; a link that leads
nowhere fills nothing, and nor does a file named by the 64-bit game id that other tools write.
SteamConfigAdapter.grid_file_names lists the regular files; the rule reads the names.
Set in Steam (domain/artwork_slot.py, is_set_in_steam; domain/shortcut_icon.py, is_icon_set_in_steam). A
slot is set in Steam when Steam's grid directory holds a file for it that is not Tender's
(#2381 D4, D10). Tender hands Steam every image as PNG, and
measured on the device, every wide grid, logo and icon it had recorded sits in the grid directory with exactly the bytes
and hash it recorded, while an image set through Steam's own "Set custom artwork" is kept under the slot's name with the
source file's extension — <appid>_hero.jpg, <appid>_logo.jpg, <appid>p.jpg — beside Tender's .png, and is the
one Steam shows. So a file filling the slot under any extension but png is not Tender's, and neither is a <stem>.png
whose hash is neither of Tender's two references (D15): the image Steam last confirmed — the slot's recorded set hash,
else Tender's cached copy, for the cover its cover cache file — and the image Tender last handed to Steam whose answer
is outstanding ("What was handed to Steam", below). Where there is nothing to compare with, only another extension
counts, and the .png is Tender's, so the sync's cover refresh still refreshes a cover whose cache copy is gone; a PNG
set through Steam overwrites Tender's file under the same name, so such a PNG whose reference is also gone is replaced
by that refresh. The icon is set in Steam when the shortcut's icon path is neither empty, a stand-in nor Tender's
<appid>_icon.png in the grid directory. The shortcut artwork job never fills a wide grid or logo set in Steam: it
fills only slots that are not filled, and a file under any extension fills one; an icon set in Steam it keeps too,
unless its path is in the grid directory and its file is gone (Worklist, under Shortcut artwork).
The sync's cover refresh and the apply's cover write leave a cover set in Steam alone
(Artwork file naming), and so do they a cover the user chose. Neither the fill, the job nor
Refresh Artwork replaces a slot set in Steam; a version switch and a sync rebind do (above), and so does a choice in the
ARTWORK tab, after the picker asked once more (D13) — except for the icon, which it repoints without asking and whose
file stays. Over an image the frontend then calls ClearCustomArtworkForApp(appId, assetType) before it sets Tender's
(removeImageSetInSteam in utils/artwork.ts, the one place to change), because Steam would keep showing its own
(The ARTWORK tab, below, has the order Steam tries the files in). What that call removes is a
device measurement still to be made.
One slot at a time. For each slot the frontend asks get_sgdb_slot_artwork(rom_id, app_id, slot), which answers
skip for a filled slot or one a record rules out, none when SteamGridDB's list is empty or it does not know the game
(a 404), or the ROM has no SteamGridDB game and the cache holds nothing for the slot, key_refused for a 401 or 403 and
rate_limited for a 429 — the answers the shortcut artwork job tells apart, through the same mapping
(SgdbArtworkFiles.fetch_or_answer in services/steamgrid/_files.py), which answers a stored key no request can carry
key_refused for the job — failed for an unsuccessful answer, a list with no usable entry or a download that failed,
or the image's base64 with SteamGridDB's image id, the SHA-256 of its bytes, the ROM's SteamGridDB id and an
sgdb_artwork lease; for the icon it answers ready in place of the image (below). The frontend sets the image under
that lease, waits for it, and only then asks for the next slot, so one image at a time is in the renderer. For the hero,
the wide grid and the logo it then reports what Steam made of it through report_sgdb_slot_artwork — set, or
refused when SetCustomArtworkForApp failed — passing the fetch's answer back; the backend cannot see Steam's answer
itself.
The icon on the game page is judged by the job's own rule (domain/artwork_slot_outcome.py, icon_waits): the
backend reads the shortcut's icon path in shortcuts.vdf, the grid directory's listing and the icon's record, so a
first visit keeps an icon somebody set and replaces an empty path, the placeholder, the logo icon or a path in the grid
directory whose file is gone, unless the record rules it out (the filled check is under Worklist in
Shortcut artwork). A shortcuts.vdf that cannot be read, or does not hold the shortcut yet,
answers failed: nothing is replaced on a guess. The replacing mode asks with replace, which makes none of these
checks: it reads neither shortcuts.vdf, the grid directory nor the record, so it answers neither skip nor failed
for them and does not refuse no_grid_dir. Where the icon is to be set the answer is ready, carrying the lease and no
bytes; the frontend then asks save_shortcut_icon(app_id, rom_id), which writes the cached icon as the job does
("Writing the icon", below) and answers its path, its image id, the hash of the bytes written and the ROM's SteamGridDB
id. The frontend points the shortcut at the path with SetShortcutIcon and reports set, or refused when that
throws, passing the save's answer back.
What was found is recorded per ROM and slot, for the hero, the wide grid, the logo and the icon, in
artwork_slot_outcomes (database design): set with the image id and the hash, none, or
refused, the ROM's SteamGridDB id at that time, and when. A none or refused row keeps the slot from being asked
again for 7 days (RULED_OUT_SECONDS in domain/artwork_slot_outcome.py): SteamGridDB may have an image tomorrow, and
a week costs one request per slot instead of one at every run of the shortcut artwork job, which starts after each sync,
and at every first visit in a session. The row stops counting at once when the ROM's SteamGridDB id changes, and
Refresh Artwork deletes the game's none and refused rows (in get_sgdb_resolution, before it resolves the id);
a set row stays, because its hash is what "Set in Steam" compares the grid file with. A set row never rules a slot
out: a slot whose file is gone is asked again. Rows are keyed by ROM rather than app id, because a re-created shortcut
gets a new app id. A cover is recorded as set, for a cover from SteamGridDB, so that image reads as Tender's, and as
none where SteamGridDB has no cover for an empty cover slot (D27); a cover Steam refused is not recorded. Setting the
cover back to automatic deletes the row with the choice.
What was handed to Steam (services/steamgrid/_handover.py, D15). Every image Tender gives Steam passes through the
artwork cache — downloaded into a slot's cache file (SgdbArtworkFiles.store tells HandedOverImages.stored_io) or
served from it (get_sgdb_artwork_base64, the per-slot fetch, the ARTWORK tab's answers) — so Refresh Artwork, the
fill, the job, the tab's choice and "Automatic", a change of game and a version switch all record it there: the slot's
hand-over, in artwork_slot_handovers (database design), names its image id and hash until
Steam's answer is reported. A grid file holding either the hand-over's bytes or the set row's is Tender's, so an apply
that was cancelled or overtaken, or whose report was lost, leaves Tender's image Tender's. A slot with no set row is
judged by Tender's cached copy, which a download replaces; so before it does, a grid .png holding that copy's bytes is
recorded as set, or the file would read "Set in Steam" once the copy goes. Steam's answer settles the hand-over of the
image it names, and only that one, so a late answer for an older image leaves a newer hand-over: set becomes the
slot's set row; refused forgets the hand-over and never replaces a set row. The hand-over also keeps the image the
user had chosen for the slot at the press, or none (D20): where Steam refuses the handed-over image while it is the
slot's choice, that earlier choice comes back — chosen A, then B refused, is chosen A again; nothing chosen before is
nothing chosen — and, only where a choice actually came back, that refused image's own file leaves the cache, so it is
never served afterwards as Tender's automatic image. The same image chosen again and refused gives nothing back: it
stays chosen, and its file stays. The tab reads the choice before it stores anything and hands it to the hand-over
itself. A refusal for an image that is no longer the hand-over or the choice changes no choice, and neither does a
refusal of a chosen image served as itself — by a version switch, the fill or the job — which stays chosen and cached.
So the slot is as it was before the press, and the tab says Steam did not take the image. The icon, judged by its path,
has no hand-over. A version switch writes the chosen cover where there is one, RomM's where there is none, and no cover
at all where a chosen cover exists but cannot be served now (the cache lost it and it cannot be downloaded, or the
download was refused): get_sgdb_artwork_base64(rom_id, 0) says which in chosen. A read of it that fails or is
refused writes no cover either, since it cannot rule a choice out. In both cases the switch logs why it left the cover
Steam shows: that the chosen cover cannot be served now, or that whether one was chosen could not be read, with the
read's reason.
Which image (domain/sgdb_artwork.py, choose_candidate), in the one download path the game page, Refresh Artwork,
a game change, "Automatic" in the ARTWORK tab and the shortcut artwork job's icons, wide grids, covers and logos share
(SgdbArtworkFiles.fetch, behind the cache check of SgdbArtworkFiles.download for all but those three). A slot the
user chose is downloaded as chosen instead — by the fill, the job and the replacing fetch alike — never replaced by
Tender's pick where the cache lost it or holds another image (ArtworkSlots.download_io, cached_as_chosen_io). The
wide grid and the logo are chosen among SteamGridDB's candidates by their stated size and never scaled locally —
decoding in pure Python takes seconds for a large logo and reads no JPEG or WebP; the icon is downscaled before it is
written ("Why 64×64", below). The wide grid and the logo are asked as static images only (types=static), the wide grid
in its two sizes only, since /grids also lists the portrait covers. The wide grid prefers 920×430, Valve's own library
header size, then 460×215, then the first candidate; the logo prefers the first candidate between 640 and 2048 pixels
wide, then the first candidate: the band keeps SteamGridDB's top-ranked logo unless it is tiny or many times larger than
Steam draws it. A cover from SteamGridDB is asked among its three portrait sizes and prefers 600×900, then 660×930, then
342×482 (D7). The icon and the hero take the first candidate. SteamGridDB lists an asset's candidates in its website's
"Highest Score (Beta)" order and reports a score of 0 for every image, so the first candidate is the best-rated one
(checked for one game against the website by the maintainer). A slot never stays empty for want of a preferred size.
The cover where RomM has none (D1, D23, D24, D27). An empty cover slot — no <appid>p file in Steam's grid
directory — is the sign that RomM has no cover for the game: the sync publishes RomM's cover there wherever it has one.
So the cover is filled like the other SteamGridDB slots, only while empty, with Tender's pick among SteamGridDB's
portrait grids ("Which image"): by the shortcut artwork job in its first pass, by the first visit's fill, by the cover's
"Automatic" in the ARTWORK tab where RomM's reload answers no_cover, and by Refresh Artwork. Such a cover is the one
cover recorded set without a choice, which is how every reader tells it from RomM's (domain/artwork_slot_view.py:
the tab names its source tender; services/artwork/_steamgriddb_cover.py). A cover whose RomM download failed, or
that was removed through Steam, is empty too and gets the same stand-in. Either way, once the sync's fetch carries a
RomM cover for a game whose automatic cover is that pick — its fingerprint unknown, unchanged or changed — the cover
refresh downloads RomM's cover whole, publishes it, drops the pick's record and hand-over so RomM's cover does not read
as set in Steam, and lists it among the covers the frontend sets in Steam at once; the preview counts it as a changed
cover, and the apply's cover download leaves it to that refresh, so it is never seeded from the grid file. A cover the
user chose and one set in Steam stay. Refresh Artwork's reload of RomM's cover drops the pick's record as well. A
platform the skip leaves unfetched keeps the pick until something on it changes, or until Refresh Artwork or the cover's
"Automatic" asks RomM.
Keeping automatic artwork current (services/steamgrid/_current.py, domain/artwork_keep_current.py; D5, D21, D22,
D26, D29). Each visit to a game's page, after get_artwork_game, asks keep_artwork_current(rom_id)
(utils/artworkKeepCurrent.ts). At most once in seven days per ROM — roms.artwork_checked_at, one per version — it
asks SteamGridDB for the candidates of every slot on automatic that shows Tender's image (not chosen, not set in Steam,
not empty, not RomM's cover; the SteamGridDB cover above included), applies "Which image", and where the pick's image id
differs from the one shown — the set record's, else the cached file's — downloads it into the artwork cache. The
answer names those slots; the frontend fetches each through get_sgdb_slot_artwork with replace, sets it under its
lease without claiming the newest-apply-wins generation (it stops once any apply for the shortcut starts), reports it,
reads the tab's slots again, tells GAME INFO of a new cover, and says once which slots it replaced: "Updated the hero
and the logo from SteamGridDB". Steam redraws the page when an image changes, and the notification says why. The time is
recorded when the check starts, so the section Steam mounts afresh after Tender's own write does not check again, and
two visits at once check once; it is taken back where SteamGridDB left a slot unanswered — no connection, a 429, a
server error, a refused key, an unsuccessful answer, a download that failed — and where the check raised, so the next
visit checks again, while a slot replaced before that stays replaced. Every visit, inside the seven days too and without
asking SteamGridDB, also names each of those slots whose cached image is not the one its set record names: a
replacement downloaded and not handed over, because the page was left or Steam rebuilt it partway through, is handed
over at the next visit, and that visit's notification names it (D29). For a slot told apart by its hash that holds only
while its hand-over still names the cached image: Steam's refusal settles the hand-over, so a replacement Steam refused
waits for the next due check rather than being tried at every visit; the icon keeps no hand-over, so a refused icon is
tried again at the next visit. "No images" and a 404 are answers: the image shown stays. The check holds the cleanup
rule (prune) for its length; a game with no key or no SteamGridDB game is not checked and its time not recorded. Not
taken: a background pass over every game after a sync, about 3,200 SteamGridDB requests a week for an 800-game library
(D5).
The artwork cache keeps a downloaded image under its SteamGridDB image id, as {rom_id}_grid_{image_id}.png /
{rom_id}_logo_{image_id}.png / {rom_id}_hero_{image_id}.png / {rom_id}_icon_{image_id}.png /
{rom_id}_cover_{image_id}.png: the cache is re-derivable and may vanish at any time, so which image it holds lives and
dies with the file rather than in the database. A file named without an id, such as {rom_id}_icon.png or
{rom_id}_hero.png, is not counted as cached; it is downloaded again under the rule above and replaced, once per slot,
and the orphan prune and the removed-game cleanup match both names (a cover was never cached without an id). A candidate
without an integer id is therefore no candidate for any slot. One SgdbArtworkFiles serves the whole
services/steamgrid/ package, because it keeps the index of the cache's names that its downloads and removals keep
current.
The ARTWORK tab (bigpicture/ArtworkTab.tsx, D2, D3). Every visit to a game's page asks get_artwork_game(rom_id):
the ROM's effective SteamGridDB game — its id and source (romm, chosen or none), no name, so that read asks
SteamGridDB nothing; only keeping current does, at most weekly — which the play row disables Refresh Artwork by. The
same read settles a game RomM's id has replaced (D17): where the ROM holds both RomM's id and a different chosen one and
the user chose images, those choices are discarded as a change of game discards them, in one Unit of Work with the
chosen game, so it happens once; the discarded SteamGridDB slots' cached images go too, so the automatic apply downloads
Tender's pick under RomM's game rather than serving the old game's chosen image, and so does the cached image of a cover
that shows Tender's SteamGridDB pick, which the page then picks anew under RomM's game (applySteamGridDbCover). A
chosen cover set in Steam loses its choice and the record of the image the choice set, as a change of game drops them.
The answer's superseded names RomM's game, the SteamGridDB slots that had a choice and the cover only where its choice
was kept — a cover set in Steam is named nowhere and nothing is written over it — and the page says "RomM now maps this
game to …; your chosen images went back to automatic.", applies the slots on automatic and sets a chosen cover back to
RomM's through set_artwork_slot_automatic (utils/artworkGameChange.ts). Only that visit's read settles it. The tab
itself asks get_artwork_slots(rom_id, app_id), a read that discards nothing and answers no superseded: the same
game, with SteamGridDB's name for it (/games/id/{id}, asked once per game id), and the five slots, each with its state
(automatic, chosen or set_in_steam, set in Steam outranking a choice), its source, the SteamGridDB image id it
shows where Tender knows it, has_choice, which also holds under an image set in Steam, and the signature its
thumbnail carries (domain/artwork_slot_view.py, describe_slot; read in services/steamgrid/_slots.py, the cover's
state through the cover's owner, CoverSlotReader). Each row's thumbnail is Steam's own copy of the slot's image (D14):
Steam's appStore.GetCustom…URLs getters answer the URLs it tries under https://steamloopback.host/customimages/,
.jpg before .png, and the row shows the first that loads (utils/artworkThumbs.ts); the icon shows
<appid>_icon.png while it is Tender's and a placeholder once it is set in Steam, and the GAME INFO tab shows the cover
the same way, falling back to RomM's cover (D18). Steam's URLs carry a cache-buster of their own, v=, but it is one
value per shortcut, which every SetCustomArtworkForApp moves, and it never moves for the icon, nor for a slot set back
to an image under the same name. So the thumbnail drops it and carries two values of the slot's own instead: a token of
Tender's (artworkThumbToken in utils/artworkSlotsStore.ts), moved by every write of an image into that slot and by
no other — each place that hands Steam a slot's image (the tab, the first visit's fill, Refresh Artwork, a change of
game, a version switch, the shortcut artwork job, the sync's covers) writes through writeSlotArtwork, or notes the
icon it set — and the slot's signature from the tab's read, a digest of the size and modification time of each file
filling the slot in the grid directory (SteamConfigStore.grid_file_stamps), which also moves for an image set through
Steam itself. Only the row whose image changed reloads. GAME INFO's cover carries the cover's token, the cover's
signature once the tab's read is held for that ROM, and Steam's v= beside them: nothing reads the slots again after a
cover set through Steam's own menu, so there only v= moves for it, and reloading GAME INFO's one image on another
slot's write costs little. On the device (2026-10-22) the renderer kept showing the old image for minutes without a
value of Tender's, and with Steam's v= in every URL a write to one slot reloaded all four. Measured on the desktop
client in Desktop Mode (2026-10-21): each URL answered as many bytes as the named file holds, and a name without an
extension answered 404; this paragraph is the one home of that measurement and of the .jpg-before-.png order.
Pressing a row opens the picker (bigpicture/ArtworkPickerModal.tsx): get_artwork_slot_candidates(rom_id, slot, page)
answers a page of the slot's candidates in SteamGridDB's order — the same query automatic draws from, the cover's being
its three portrait sizes, so the picker shows every candidate of the slot's class (D7) — each with its thumb URL,
which the tile loads straight from SteamGridDB, its size, and on page 0 tenders_pick, the one Tender's rule takes —
for the cover only where its automatic image is SteamGridDB's, an automatic cover showing Tender's pick or empty (D27);
RomM's cover and a chosen one name none. choose_artwork_slot_image(rom_id, slot, image_id) downloads the chosen image
into the cache, which makes it the slot's hand-over, records it in artwork_slot_choices, and serves it like the
per-slot fetch, or ready for the icon; set_artwork_slot_automatic(rom_id, slot) serves Tender's pick downloaded anew
and drops the choice once it is in the cache — where SteamGridDB has none it answers none and the choice stays, since
Steam still shows the chosen image — or, for the cover, reloads RomM's cover at once (refresh_cover_unchecked, under
its own update, migration and cleanup rules, D9) and answers cover for the frontend to set what get_artwork_base64
serves; where RomM answers no_cover, it serves Tender's SteamGridDB pick downloaded anew as for a SteamGridDB slot,
and RomM's refusal stands where there is no key or game to pick from or SteamGridDB has no cover either. An image or
cover answer carries an sgdb_artwork lease; the frontend sets it through applyTabSlotImage under the
newest-apply-wins guard, then reports it as the replacing mode does. The tab reads its slots again as soon as the
backend answered — the backend already holds the new state then — and once more when the press is done, since Steam's
write may re-render the page before it returns. That re-render mounts Tender's section afresh, which Tender cannot
prevent: Steam rebuilds the game page when its artwork changes. For 15 seconds after a press the panel therefore opens
on the ARTWORK tab again, with the row pressed as its preferred focus (keptArtworkSlot, openingTab in
bigpicture/panelState.ts); Steam's own scroll position of the page is not Tender's to keep. A change of game
(apply_sgdb_game_id) reloads no cover itself: it leaves a chosen cover that is not set in Steam to the tab's
set_artwork_slot_automatic call that follows (I3), and discards a chosen cover set in Steam outright. On the page the
tab is the last of the game page's tabs, always shown; each of the five rows is a focus stop of its own
(DialogButton), drawn as a card whose focus is a ring in Steam's accent blue rather than Steam's white button face, so
the row's text and its state pill stay readable. The game line is a stop only where it has a button ("Choose game…" /
"Change game…"), which carries the same ring; with RomM's game the d-pad goes from the tab row straight to the cover's
row. The picker is a modal over the page: its grid is four tiles wide, and its first page — Automatic, 14 candidates and
"Load more" — fills four rows; each "Load more" adds 16 candidates, asking SteamGridDB for the next page only once the
held one runs out, so "Load more", a full tile, always closes the grid at its bottom right. A SteamGridDB page holding
fewer than 16 is topped up from the next one, so it stays there; while a page is fetched it stays in place with a
spinner, and focus then moves to the first new tile, where it stood — or to the last tile, where the load brought
nothing new. A page that cannot be listed shows nothing more and says why. Cancel and "Use this image" sit below the
grid, outside its scrolling, so they are reached without walking past every candidate; a size SteamGridDB states as 0 is
not shown. How the d-pad walks them is checked on the device. A row opens the picker only while the ROM has a
SteamGridDB game and a key is configured, but for the cover's, which always does: without a game or a key its picker
offers "Automatic — RomM cover, or Tender's pick until it arrives" alone, says why no SteamGridDB cover is offered, and
lists nothing (D19).
Shortcut artwork¶
Every shortcut Tender manages gets its SteamGridDB icon, wide grid and logo, and its SteamGridDB cover where RomM has none, in the background, never inside the apply loop: there a cold icon fetch cost about a second per minted shortcut, almost all of it SteamGridDB's image CDN missing its edge cache (measured by the maintainer on a Steam Deck, Steam client version 1788652215).
What Steam shows, measured on a Steam Deck LCD in Desktop Mode, in the desktop client, Steam client version 1788652215 (2026-10-05):
- A shortcut with no icon shows a newly set one only after Steam restarts — through
SetShortcutIconor by hand in Properties alike. Replacing an icon a shortcut already has shows at once, and so does an icon set right afterAddShortcut, and new bytes written to the same file followed bySetShortcutIconwith the same path. - Steam rewrites
shortcuts.vdfsoon after a shortcut's creation and after an icon is set, not only on exit. The maintainer measured the first on the same client: five shortcuts added 50 ms apart were all in the file 0.15 s after the lastAddShortcut. - Steam's app overview carries no icon for a shortcut that had none when the session began:
icon_hash,icon_dataandicon_data_formatreadundefinedbefore and afterSetShortcutIcon. So the file is where the icons are read from.
The placeholder. The apply loop gives every shortcut it mints the placeholder icon right after its cover
(applyPlaceholderIcon in syncManager.ts): tender-icon-placeholder.png, a transparent 1×1 PNG of 68 bytes
(domain/shortcut_icon.py) that the backend writes into the grid directory and rewrites when it is missing; each
sync_apply_unit frame names its path as icon_placeholder_path. The list shows an empty spot rather than a grey box,
and the job's icon is then a replacement, which shows at once. A shortcut the sync adopts (an untracked orphan) gets
none, since it may carry an icon somebody set by hand. Without a SteamGridDB API key no job would replace the
placeholder, so the frame names none and a minted shortcut keeps Steam's own grey box. A shortcut that had no icon
before — one minted without a key included — shows its first one after one Steam restart.
Why 64×64. In the desktop client the library list draws icons at 20 px (40 px at 2×), SteamGridDB's are often 512 or
1024 px, and Steam holds a shortcut's icon in the renderer as base64 for the whole session once it has one: a 1 MB PNG
becomes 1.35 MB of icon_data. Both are the maintainer's measurements (Steam Deck, client 1788652215). 64 px, the size
the maintainer chose, leaves room above the largest size drawn. So every SteamGridDB icon Tender writes into the grid
directory, the game page's included, is downscaled to fit 64×64 with its aspect kept (adapters/icon_image.py); the
full-size file stays in the artwork cache. Where the vendored png does not load, icons are written full size and that
is logged once.
The job (services/shortcut_artwork.py) gives every bound shortcut its icon by the rules above, and its SteamGridDB
wide grid, cover and logo where the slot is empty, so a library looks complete without opening each game; why an empty
cover is filled from SteamGridDB is "The cover where RomM has none", above. The hero stays with the game page.
- Worklist: every bound shortcut's icon, wide grid, cover and logo whose slot is not filled and whose recorded
outcome does not rule it out (
domain/artwork_slot_outcome.py,icon_waitsandslot_waits; the record's rules are those of Game page artwork). The wide grid, the cover and the logo are filled by a file in Steam's grid directory. The icon is filled by the shortcut'siconpath inshortcuts.vdf(domain/shortcut_icon.py,is_icon_filled): an empty path, the placeholder and the logo icon are stand-ins that fill nothing, by name in any directory, so one under another Steam account's grid directory counts as well; a path in the grid directory the job lists fills it only while its file is there, as a wide grid's or a logo's file does; any other path — an icon somebody set by hand, one in another Steam account's grid directory, one on a drive that is not mounted — fills it whether its file is there or not, so the job never replaces it. Derived at every run and never stored, so a stopped run leaves exactly the rest. A bound shortcut the file does not hold yet is counted in the log and its icon left for the next run; its wide grid, cover and logo do not depend on the file. Where the grid directory cannot be listed, no wide grid, cover or logo is asked for, nor an icon whose path is in that directory; a stand-in still is. A shortcut still showing no icon whosenonerules the slot out enters too, for the logo icon alone and without a request ("No icon", below). - Two passes: first each shortcut's icon, wide grid and cover together, shortcut by shortcut, then the logos. A sync may run in the desktop client, where the icon shows in the library list, or in Big Picture, where the wide grid shows in search and among recent games; the cover is what the library shows (D23). None of the three should wait for another; a logo shows only on the game page, which fills an empty one itself on opening. The number of requests is the same in any order.
- One icon, wide grid, cover or logo:
SteamGridService.do_fetch_slot_artworkputs SteamGridDB's image into the artwork cache by the rule the game page uses ("Which image", above) and answers that it is ready; no bytes leave the backend here. A ROM with no SteamGridDB game — neither RomM's id nor one the user chose — gets anonerecord without a request; no name search runs, since picking from one needs a person. A game SteamGridDB has no candidate for, or does not know (a 404), gets anonerecord too. The record keeps the slot off the worklist for a week, or until the ROM's SteamGridDB id changes. A failure records nothing, so the next run asks again. - Writing the icon:
SteamGridService.write_job_iconreads the cached icon, downscales it and writes it as{app_id}_icon.png, answering SteamGridDB's image id and the SHA-256 of the bytes written, the downscaled ones, for itssetrecord. A file the downscale cannot read — a page in place of an image, a truncated PNG — is not written and leaves the artwork cache, so the next run downloads it again. The game page's write,save_shortcut_icon, is the same write. - No icon: a game SteamGridDB has no icon for, one with no SteamGridDB game, or one whose id SteamGridDB does not
know, gets Tender's logo —
tender-icon.png, shipped indefaults/and copied into the grid directory — and nothing is recorded beyond thenone. The logo icon is a stand-in, so the record alone decides when the job asks again: at once when the ROM's SteamGridDB game changes — a RomM scan adds an id and the next sync stores it, or the user chooses a game — and otherwise after a week. A shortcut showing the logo icon with no record is asked on the next run, and recorded then. Refresh Artwork asks at once; the first visit to the game page in a session asks by the same rule as the job. The week rules out asking SteamGridDB again, not showing the logo: a shortcut still showing no icon — empty or the placeholder — while itsnonerules the slot out is given the logo icon at the next run without a request (domain/artwork_slot_outcome.py,logo_icon_waits), so a logo icon lost on its way to Steam — a batch not handed over, or a logo that could not be read, which is logged — does not leave the placeholder in its place for a week. A shortcut already showing the logo icon needs nothing. A timeout, an answer that says it failed, or any other failure is not "no icon": nothing is recorded and the next run asks again. - Pace: four shortcuts at a time. A 429 from SteamGridDB's API or its image CDN (
SgdbRateLimitedError) pauses every worker: 30 s, twice as long at each 429 in a row, at most 15 minutes, then on, with only what the 429 met asked again; every run starts the pause over. A 401 or 403 from the API — a key SteamGridDB does not accept — or a stored key no request can carry ends the run with one warning; what was written before it is still handed over. - Hand-over: what is finished goes to the frontend as the
shortcut_artworkevent in batches of up to 25 entries, each batch under one prune lease (emit_under_lease):icons, each an app id and an icon path, a SteamGridDB icon with arecordof the ROM, its SteamGridDB id, the image id and the hash, andartwork,(app_id, rom_id, slot)triples that carry no image.applyShortcutArtwork(utils/shortcutArtwork.ts) sets each icon and reports what Steam made of a SteamGridDB one throughreport_sgdb_slot_artwork—set, orrefusedwhenSetShortcutIconthrows — passing itsrecordback; the logo icon has nothing to report. Then it sets each wide grid, cover and logo one at a time: it fetches the image's base64 throughget_sgdb_slot_artwork— which skips a slot that has filled since — sets it withSetCustomArtworkForApp, waits, reports the outcome throughreport_sgdb_slot_artwork, and only then fetches the next (setJobSlotArtworkinutils/artwork.ts). It gives the lease back after the last, and stops once the lease is cancelled. The job hands the next batch over only once that lease has ended — given back, disowned by a panel that mounts, or expired — so one image at a time is in the renderer across batches too, an image too large fails only its own call, and what is set is what the cache holds at that moment. That holds across a stop too: a stop does not wait for the frontend, so a run stopped while it waits leaves its batch out, and the next run waits for that batch before it reads the renderer and hands one over. It waits at most 10 minutes (_BATCH_WAIT_SECONDS), past the frontend's 5-minute bound on a batch and the lease's 300 s expiry, and ends the run when a batch outlasts that. A batch no panel heard — Steam restarted, as it does at every switch between Game Mode and Desktop Mode — ends the run, and the job runs again once a panel is connected. - Steam's memory: before each batch, so after the batch before it has been set, the job reads the renderer's memory
through the sync's own
SessionBudgetMonitor(LibraryService.renderer_above_ceiling; at or aboveGC_SKIP_BELOW_KBSteam collects its garbage first, bounded at about 5 s). At or above the session budget's ceiling the run ends without handing the batch over; only a Steam restart frees that memory, so the job waits for the panel to go and a panel to connect again — polled every 5 s — and the next sync asks for it too. What was not handed over stays on the worklist. - Triggers: the end of every sync run — after an apply, 10 s later, so Steam has rewritten its file with the shortcuts the run minted — backend start once a panel is connected, the end of a removed-game cleanup and the end of a shortcut removal (one whose lease expires, or is released as orphaned when a panel mounts, leaves it to the next trigger). Without a SteamGridDB API key it does nothing.
- Yielding: the job yields to a sync in flight, a removed-game cleanup and a shortcut removal (from the call that
names what to remove until its report, or until its lease expires or is released as orphaned). It asks before each
fetch, write and hand-over; once it has yielded, the run ends, and what it wrote but did not hand over waits for the
next run, which the end of what it yielded to asks for. A removal stops the run under way once its claim is held
(
stop_for_removal), and so does its report, which may arrive after its lease is gone and a run has started. A cleanup's start stops it (stop_for_cleanup) before it reserves its start, and no run starts until the cleanup resumes it (resume_after_cleanup). What either stop waits for, and what a fetch it abandons may still do, isShortcutArtworkJob's (services/protocols/cross_service.py). The job is no endpoint, so it takes no conflict-rulehold; the lease on each batch is a claim like any event's, and a cleanup pressed while the frontend holds one is refused (Prune conflicts). - An icon the game page writes wins:
save_shortcut_iconand the job's write take turns under one lock per app id, and the job writes only while the page has saved no icon since it took that app's generation, which it does when it plans the run, before it readsshortcuts.vdf; the hand-over leaves out a shortcut whose generation has moved since, so a logo icon queued before the page writes one is left out; and the frontend skips a shortcut whose artwork apply is under way (isArtworkApplyInFlight), and sets no wide grid or logo once a game page's apply for the shortcut has started since it began fetching it.
Pre-launch launch-options confirmation¶
Both launch funnels (the game-detail Play button and Steam's direct-launch watcher) ask prepare_game_start immediately
before RunGame: the backend writes the start hand-over and answers the shortcut's launch options, its appId, which the
panel confirm-writes. A start without an answer, or with a refusal, is refused with a toast, because without the
hand-over the launcher starts nothing; a Steam write that fails does not stop it. The Play button's launch captures its
component generation before gate and modal waits; the button's unmount makes that admission stale, and even an immediate
remount cannot let the old chain write launch options or invoke RunGame under the new generation. The watcher's launch
has no component owner, so no teardown short of a JS-context rebuild stops its chain; the endpoint timeout still does.
Which starts are refused and with what is Save-file sync's, step 9.
Key Files¶
| File | Purpose |
|---|---|
frontend/src/utils/steamShortcuts.ts |
addShortcut(), removeShortcut(), getExistingRomMShortcuts(), getLiveRomMShortcutAppIds() (raw live appId scan for the sync-start reconcile) — frontend shortcut CRUD. The existing-shortcut scan emits a sync heartbeat every 10s between batches so a large library can't stall the run past the backend's per-unit heartbeat timeout |
frontend/src/utils/syncManager.ts |
Listens for sync events, orchestrates shortcut creation/removal, artwork application, collection management. reconcileStaleShortcuts() runs the sync-start reconcile of Steam-UI-deleted shortcuts. Caches the existing-shortcut scan per run (keyed by the sync_apply_unit run_id) so it scans Steam once per run, not once per unit |
backend/services/shortcut_removal.py |
ShortcutRemovalService — resolves shortcut-removal sets, unbinds removed ROMs, and runs reconcile_live_shortcuts (the sync-start reconcile of Steam-UI-deleted bindings) |
frontend/src/utils/collections.ts |
Machine-scoped Steam collection management |
frontend/src/bigpicture/patches/gameDetailPatch.tsx |
The tree patch for Steam's game page — injects RomMPlaySection for custom game detail UI. Installed on the route component's render by installGamePagePatch.ts (frontend-bundles.md) |
frontend/src/utils/metadataPatches.ts |
Store patches for description, associations, categories, release date display |
backend/adapters/steam_config.py |
SteamConfigAdapter — VDF read/write, grid dir, shortcut icon write, Steam Input config |
backend/services/library/ |
LibraryService — builds shortcut data, drives per-unit sync apply |
backend/domain/sgdb_artwork.py |
to_signed_app_id, SGDB asset-type/endpoint maps and their queries, the image choice by stated size (choose_candidate), the artwork cache's file names (cached_artwork_name, cached_image_id) |
bin/tender-rom-launcher |
Invoked by Steam with the shortcut's appId — claims that start's hand-over in the runtime directory and runs the command it holds, in the folder it names; without a hand-over, or with one older than 30 s, it starts nothing. Owns no path resolution and no emulator knowledge. Shipped here, run from <bin root>/tender-rom-launcher: bootstrap() installs this copy there at every start (ADR-0038) |
Common Pitfalls¶
Quoting exe breaks launches¶
Pre-quoting the exe path in AddShortcut or SetShortcutExe causes double-quoting. Steam tries to execute
""/path/to/exe"" and fails with "file not found." Always pass raw paths through the SteamClient API.
Empty Set* params after AddShortcut¶
Calling Set* methods too quickly after AddShortcut (before the new app's overview is registered) results in the
properties not being saved. The shortcut appears in the library but with wrong or missing exe/startDir/launchOptions.
Launches fail or open the wrong thing. Tender gates the Set* calls on an overview-readiness poll
(waitForAppOverview, 1000ms fallback) rather than a fixed delay.
Removal-churn can corrupt shortcut state¶
SetAppLaunchOptions on an existing shortcut is reliable (validated in
#827; see
Updating existing shortcuts) — the historical "property updates may not persist" warning
has been narrowed. The remaining hazard is removal-churn: adding and removing many shortcuts in one pass can corrupt
Steam's in-memory shortcut state. A Steam restart clears it. Two things keep churn down. The sync engine processes
removals before additions, and every launch-options write uses the fire-then-poll setLaunchOptionsConfirmed so a
silently dropped write is observable rather than assumed. And mass removals are awaited and chunk-paced through the
shared removeShortcutsPaced helper (frontend/src/utils/shortcutRemoval.ts, over pacedForEach,
#977): every bulk removal path — Library › Platforms' removal,
Data Management's "Remove all shortcuts" with its live-orphan sweep, and its non-Steam-games removal, and the
sync-run stale-shortcut cleanup (sync_stale, fired at run finalize) — awaits each removeShortcut in sequence and
yields a 50ms breather every 25 removals, so the CEF renderer never blocks and thousands of removals can't stack as
fire-and-forget promises. (The sync_stale handler records its "removed" delta for the terminal toast up front, before
the first breather, so the paced removal can't leave the count partial when sync_complete interleaves.)
AddShortcut / RemoveShortcut timing between shortcuts¶
Bulk shortcut loops corrupt Steam's internal store if driven too fast — added shortcuts may silently fail to register,
and removals churn the in-memory state (above). Both cadences live in one place: the shared paced loop pacedForEach in
frontend/src/utils/pacedOps.ts, which iterates awaiting each item and yields a breather between chunks (no trailing
delay). The two callers differ only in chunk size:
- Add (
syncManager.ts—processUnitShortcuts,processCoverRefreshes) paces one item at a time: a 50ms breather after everyaddShortcut()/ cover apply, plus the per-unit heartbeat + cancel hooks. - Remove (
removeShortcutsPacedinfrontend/src/utils/shortcutRemoval.ts, shared by the page removals above and thesync_stalecleanup) paces in 25-item chunks with a 50ms breather between them. A removal is a single cheap call, so chunked yielding keeps a 5000-game teardown at ~seconds of overhead instead of the ~4 minutes strict 50ms/item would cost, while still letting the renderer breathe. RomM-shortcut andsync_staleremovals hold renewable prune conflict leases for the complete paced loop even though the backend does not await the frontend's stale removal.
The apply is chunked; a heartbeat timeout must not discard a chunk's delivered bindings¶
A unit's emitted shortcuts are split into fixed-size chunks (200,
ADR-0023); the pipeline
emits one sync_apply_unit per chunk (carrying chunk_index / chunk_count / chunk_offset / unit_total,
shortcuts = the chunk slice), then waits for the frontend's report_unit_results ack — echoing the chunk_index back
— and commits that chunk's roms rows durably before emitting the next. A mid-unit crash, cancel, or timeout forfeits
only the in-flight chunk; every chunk committed before it stays committed. See
Backend Architecture — per-unit apply for the full loop.
If the frontend stops heartbeating for longer than the per-chunk timeout (_UNIT_HEARTBEAT_TIMEOUT_SEC, 60s — e.g. a
chunk slow enough that real heartbeats lag), the wait gives up. But by then the frontend has already created that
chunk's Steam shortcuts and will still fire its late report_unit_results. Dropping that ack is data loss: the
bindings are never written to roms, so get_app_id_rom_id_map doesn't know about the shortcuts, and the next sync
re-creates them as duplicates (an unmapped exe-detected shortcut takes the addShortcut branch).
So a heartbeat timeout is handled differently from a user cancel (#1052 / #1367):
- User cancel — the in-flight chunk is intentionally discarded. The chunk dispatcher clears the staging and nulls
unit_complete_event, so a stray late ack can't commit a cancelled chunk. - Heartbeat timeout — the chunk dispatcher moves the abandoned chunk into an
abandoned_chunkstash onLibrarySyncStateBox(stash_abandoned_chunk): its run/unit/chunk identity plus this chunk's ROMs (only the abandoned chunk), while keeping the whole-unit staging live for the commit to read and clearing the dispatch identity. The stash lives outside the run-lifecycle state and deliberately survives the run's teardown (finish_runnullscurrent_sync_id), because in production the latereport_unit_resultsarrives after the run has wound down — the window an earlier design missed, where the active-unit ack check could no longer match and the recovery was unreachable (#1367). The late ack matches the stash by identity (take_abandoned_chunk) and drivescommit_unit_resultsitself over the stashed rows (binding + metadata), never stamping a timed-out platform complete. Bounded lifetime: the next run'stry_begin_runclears an unacked stash.
The committed binding self-heals the duplicate hazard: a bound roms row is mapped by getExistingRomMShortcuts next
sync, so resolveShortcutAppId takes the update branch. The orchestrator does not add active orphan deletion — a
Steam shortcut is the sole record of its tile (the "never delete data that exists nowhere else" invariant).