Steam Non-Steam Shortcuts¶
Technical reference for how decky-romm-sync 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.
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:
SteamClient.Apps.SetShortcutExe(appId, "/home/deck/homebrew/plugins/decky-romm-sync/bin/rom-launcher");
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/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.exeand the display name are applied by delete + recreate. A launch-config change that touchesexeor the name is handled by removing the shortcut and re-syncing it, which yields a newappId(a fresh shortcut); alaunchOptions-only change is not. This delete + recreate behavior is unchanged; the original rationale — that changingexe/name re-hashes to a differentappId— rests on the CRC derivation above and is no longer verified.
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: 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/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. SeeresolveShortcutAppIdinsrc/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.
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:
getLiveRomMShortcutAppIds()(src/utils/steamShortcuts.ts) scans Steam's live shortcuts and returns the raw appIds of every RomM-owned shortcut (exe ends with/bin/rom-launcher), regardless of any backend binding. It returnsnullwhen the store was unreadable (collectionStoreabsent) versus[]when the scan ran and found none — a load-bearing distinction.reconcileStaleShortcuts()(src/utils/syncManager.ts) skips the reconcile on anullscan (reconciling against "couldn't look" would unbind every binding), and otherwise calls thereconcile_shortcutscallable with the live set. 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 through route patching. This avoids fighting Steam's internal rendering logic.
See: src/patches/gameDetailPatch.tsx, src/components/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: src/patches/metadataPatches.ts, src/utils/metadataCache.ts (paged fetch), onSyncComplete in 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/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: py_modules/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 (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.
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 py_modules/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 the Danger Zone's Remove Orphaned
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.
Key Files¶
| File | Purpose |
|---|---|
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 |
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 |
py_modules/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) |
src/utils/collections.ts |
Machine-scoped Steam collection management |
src/patches/gameDetailPatch.tsx |
Route patch for /library/app/:appid — injects RomMPlaySection for custom game detail UI |
src/patches/metadataPatches.ts |
Store patches for description, associations, categories, release date display |
py_modules/adapters/steam_config.py |
SteamConfigAdapter — VDF read/write, grid dir, shortcut icon write, Steam Input config |
py_modules/services/library/ |
LibraryService — builds shortcut data, drives per-unit sync apply |
py_modules/domain/sgdb_artwork.py |
to_signed_app_id, SGDB asset-type/endpoint maps |
bin/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 |
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 (src/utils/shortcutRemoval.ts, over pacedForEach,
#977): every bulk removal path — the DangerZone actions
(per-platform, Remove-All-RomM including the live-orphan sweep, and the Remove-Non-Steam bulk action) 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.) Only
exe/name changes still go through delete + recreate — a fresh AddShortcut, which yields a new appId.
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
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 (
removeShortcutsPacedinsrc/utils/shortcutRemoval.ts, shared by the DangerZone actions 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. No heartbeat hook: the DangerZone actions run outside any sync, andsync_stalefires at run finalize — after every per-unit heartbeat window has already closed and detached from the watchdog (the backend never awaits 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).