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);
// An empty launch_options (uninstalled placeholder) needs no write or confirm —
// a fresh shortcut's launch options are already empty, so skip both. A non-empty
// command takes the confirmed write (setLaunchOptionsConfirmed).
if (launchOptions !== "") await setLaunchOptionsConfirmed(appId, launchOptions);
Steam must finish registering the new app internally before the Set* calls land, or they silently fail. Rather than a
fixed worst-case wait, the plugin 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. Skipping the
launch-options write for the (majority) uninstalled case also avoids setLaunchOptionsConfirmed's
RegisterForAppDetails poll, which forces Steam to load and cache a fat AppDetails object per call.
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:
Updating existing shortcuts¶
Steam assigns a shortcut's appId when AddShortcut creates it, and that appId is stable for the shortcut's
lifetime. The plugin 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. The plugin uses it directly to bake the launch command in at download-complete and to re-resolve paths after a RetroDECK-home migration.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 plugin start, 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, the plugin fires the set then polls
RegisterForAppDetails until the read-back strLaunchOptions matches (setLaunchOptionsConfirmed). 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), the plugin
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 → 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 group's only fetched member is the freshrom_id, the old boundrom_idhas vanished, so collapse emits one entry keyed to the vanished sibling — the frontend reuses its existing shortcut byrom_id— carryingbind_rom_id→ the fresh representative. 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. (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 → delete + create. 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, and the old shortcut is torn down by the stale path. Artwork and collection membership are re-established for the new shortcut. This is honest degradation — the identity link the rebind lane needs is gone, so the shortcut cannot be preserved. - 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 + launch bake (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.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.
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 existing detail request for the bound id 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
detail 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 are excluded before the existing default-resolution kernel runs. 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 local drift, 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 returns
version_vanished without changing the binding or any recorded launch 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 failures retain 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.
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.
For a vanished bound version with a live sibling, the default-on repoint action reuses the version switch independently
of the row-removal option — through switch_version_unchecked, which checks no conflict rule and takes no lease, since
the cleanup switches from inside its own run — then the frontend confirm-writes the returned exact launch options.
Cover/cache publication and the version_switched event are deferred until terminal prune completion. Before emitting a
terminal result that needs repoint publication, the backend acquires a continuation lease while the old run is still
active; the frontend registers that token immediately and holds it across both release acknowledgement and the final
artwork write. Another prune therefore cannot enter between the old claim and publication. Publication uses the same
path as VersionPicker. Repoint changes neither shortcut name nor exe and never calls AddShortcut, so the assigned
appId, collections, and Steam playtime remain attached. Unsynced-save stranding can be overridden only after enabled
recovery has sealed.
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 cannot fit the wire bound. 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 component owner or plugin dismount releases
them. Owner/plugin mount generations are captured before backend waits, so a token arriving after 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 the plugin 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 bakes aim at a Steam app that no longer exists) and the incremental skip never recreates it: the skip
counts bound roms rows, not 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_shortcutscallable 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.
Once a row is unbound, the fetcher's incremental baseline (_read_incremental_baseline, which reconstructs only rows
with a non-NULL shortcut_app_id) no longer counts it, so unit.rom_count == registry_count fails and the platform
falls through to a full fetch that recreates the shortcut. The unbind is reversible by design — the next sync re-binds.
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 the plugin 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, the plugin 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. It runs on every sync_complete, cancelled runs included (a partial run's committed units still carry fresh
metadata, and the pass is idempotent), in its own detached block with its own error handling so a re-fetch failure never
touches the toast, collections, or playtime paths (#1207). 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 the plugin 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; its shortcuts.vdf read/write helpers remain in the adapter but are no longer on any live path after 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 full launch command the bin/tender-rom-launcher exec wrapper runs, e.g. flatpak run net.retrodeck.retrodeck "/path/to/game.iso" — or "" (placeholder) for an uninstalled ROM. 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. The plugin 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. The plugin 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. The plugin 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.
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.
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 reporter's dict key, computed backend-side in
SyncReporter._resolve_collection_memberships (services/library/reporter.py) from 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
SyncReporter._resolve_collection_memberships' docstring (backend/services/library/reporter.py), and thevirtual_typecomment inbackend/domain/work_unit.py.
tests/services/library/test_reporter.py asserts the union keys the reporter builds, so a label change fails there on
its own. The reporter 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 → reporter.
Label-format constraint: the reconcile parses the collection name with /^RomM: \[([^\]]+)\]/
(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 reporter 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.
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, collection and platform name
identity is treated case-insensitively everywhere the plugin compares names: the reporter groups both
romm_collection_app_ids and platform_app_ids by a case-folded key (str.casefold()), keeping the first-seen
original casing for display (which exact casing wins is irrelevant — Steam uppercases collection names anyway); the
frontend create/find (createOrUpdateCollections / createOrUpdateRomMCollections), the cleanup matchers
(clearPlatformCollection / clearAllRomMCollections), and the onSyncComplete stale-delete comparisons all match by
toLowerCase(). This is always safe precisely 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 the plugin 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 plugin-created shortcuts 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 the plugin'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() callable, 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 the plugin 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 icon PNG 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.
The backend also writes each {app_id}p.png grid file at commit (SyncReporter._finalize_cover_path →
ArtworkService.finalize_cover_path). 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.
Pre-launch launch-options confirmation¶
Both launch funnels (the game-detail Play button and Steam's direct-launch watcher) re-fetch the selected ROM's resolved
command and confirm-write it immediately before RunGame. Ordinary fetch or Steam-write failures remain best-effort and
the launch proceeds. A three-second callable timeout is different: the already-cancelled launch remains blocked, while
the unresolved callable stays observed so a lease token returned later is released without a Steam write. Each launch
captures its plugin/component generation before gate and modal waits; teardown makes that admission stale, and even an
immediate remount cannot let the old chain write launch options or invoke RunGame under the new generation.
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 |
bin/tender-rom-launcher |
Pure exec "$@" wrapper invoked by Steam — runs the full launch command baked into the shortcut's launch options; owns no state, no path resolution, 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. The plugin 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 orchestrator clears the staging and nulls
unit_complete_event, so a stray late ack can't commit a cancelled chunk. - Heartbeat timeout — the orchestrator 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).