diff --git a/docs/architecture/backend-architecture.md b/docs/architecture/backend-architecture.md
index d9fff037..b41a20c9 100644
--- a/docs/architecture/backend-architecture.md
+++ b/docs/architecture/backend-architecture.md
@@ -127,6 +127,21 @@ the rest are single modules. A service over ~700 LOC is the decomposition signal
| `connection.py` | ConnectionService — connection test + RomM minimum-version gate + Client API Token lifecycle (mint/establish via credentials, validate/store a user-pasted token, or exchange a short-lived pairing code for a token — the latter two for OIDC accounts; host-bound to the minting origin; see [ConnectionService notes](#connectionservice-notes)) |
| `protocols/` | Protocol interfaces grouped by concern (see [Protocol Interfaces](#protocol-interfaces)) |
+#### Version-list liveness
+
+`VersionSwitchService.get_version_list` separates retained local membership from current RomM availability. On each lazy
+Game Page load, the bound detail request proves the bound id live and supplies the direct sibling ids; only non-bound
+local members absent from that direct sibling view are exact-id probed through `RommRomReader.get_rom_once`, the
+adapter's single-attempt short-timeout `request_once` path, concurrently on the existing executor fan-out. Only
+`RommNotFoundError` produces `vanished: true`. All other probe failures and malformed/falsy responses fail open. No
+result is persisted.
+
+The response carries `vanished` on every version and `bound_vanished` even when `multi_version` is false. Retained
+vanished rows remain in the payload and keep the independently-computed `switchable` membership verdict, but are
+excluded from default ranking. A bound-id 404 is a successful entity verdict (`bound_vanished: true`,
+`server_query_failed: false`), not a global reachability signal. Artwork and save reads do not contribute availability
+evidence; `fetch_cover_base64` remains a nullable data callable.
+
#### LibraryService decomposition (`services/library/`)
The library sync subsystem is a façade over three sub-services that coordinate through a shared `LibrarySyncStateBox`:
diff --git a/docs/architecture/steam-non-steam-shortcuts.md b/docs/architecture/steam-non-steam-shortcuts.md
index 49754138..d679db32 100644
--- a/docs/architecture/steam-non-steam-shortcuts.md
+++ b/docs/architecture/steam-non-steam-shortcuts.md
@@ -125,6 +125,32 @@ Two guards keep a re-import from wiping a freshly-bound shortcut (`#1036`):
the run just re-bound onto the new `rom_id` is never emitted for removal. The `get_by_app_id` reverse lookup orders
`rom_id DESC LIMIT 1` so 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 are disabled and 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.
+
## 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
diff --git a/docs/user-guide/managing-games.md b/docs/user-guide/managing-games.md
index 16fb0ecb..c2b470f7 100644
--- a/docs/user-guide/managing-games.md
+++ b/docs/user-guide/managing-games.md
@@ -54,6 +54,16 @@ a not-yet-synced version whose match conflicts, are both shown (so you know they
either would move the shortcut onto a different game. To make one selectable, fix its metadata match in RomM so it
agrees with this game's, then re-sync.
+A retained local version that RomM has positively confirmed it no longer has is also shown dimmed and disabled, with the
+separate label _"No longer available on RomM"_. Its **active** and **Downloaded** markers remain visible so it is clear
+what the shortcut and local files still refer to, but it never receives the **Default** badge. If the shortcut is still
+active on that unavailable id, choose any live alternative in the same list to recover the game.
+
+This availability check is fresh each time the version list loads and is not saved in the plugin database. Only a 404
+for that exact ROM id establishes that it is gone. Timeouts, sign-in/permission errors, server errors, and malformed
+responses fail open: the plugin does not disable a version merely because it could not get a trustworthy answer. A
+ROM-specific 404 also does not make the rest of the plugin report that RomM is offline.
+
Selecting a different version **rebinds** the game to it: the Download button now fetches that version, the panel's
title and Region/Languages rows update to reflect it, and its cover refreshes to the new version. The Steam shortcut
keeps its name, its place in your collections, and its playtime — all tied to the shortcut, which never changes. A
@@ -71,7 +81,8 @@ switch. What happens depends on whether the version you're leaving is downloaded
drops the sync option and offers only **Switch anyway** and **Cancel**, explaining why.
When you leave a downloaded version with unsynced saves behind, the **Saves** tab shows a reminder banner ("switch back
-to sync them") so those saves aren't forgotten.
+to sync them") so those saves aren't forgotten. It does not recommend switching back to a version positively confirmed
+as no longer available on RomM; it skips that retained row and continues checking any later live versions.
While a download of the game is running, switching is blocked with a short message — cancel the running download first.
diff --git a/docs/user-guide/save-sync.md b/docs/user-guide/save-sync.md
index cf44cf54..7ae2d614 100644
--- a/docs/user-guide/save-sync.md
+++ b/docs/user-guide/save-sync.md
@@ -40,6 +40,11 @@ plugin warns you before you leave such a version and shows a reminder banner on
forgotten (see [Managing Games → Versions](managing-games.md#versions)). Nothing is lost — the saves just don't sync
while their version is inactive.
+The reminder never tells you to switch back to a retained version that the current version-list check positively
+confirmed is [no longer available on RomM](managing-games.md#the-switch-version-control). Such a row may still keep its
+local files and saves, but it cannot be selected; the Saves tab skips it and continues looking for drift on later live
+inactive versions.
+
## Important: Use Your Own RomM Account
Save files in RomM are tied to the authenticated user account. If multiple people share the same RomM account, their
diff --git a/py_modules/adapters/romm/http.py b/py_modules/adapters/romm/http.py
index 6661c65e..deeb73fb 100644
--- a/py_modules/adapters/romm/http.py
+++ b/py_modules/adapters/romm/http.py
@@ -348,10 +348,9 @@ def request_once(self, path: str, *, timeout: int):
"""GET a JSON resource in a SINGLE attempt with a SHORT *timeout*.
Deliberately bypasses :meth:`with_retry` and the 30s urlopen timeout used
- by :meth:`request`. The launch-gate reachability probe needs a fast
- offline verdict (~3s, one shot) instead of waiting through 3 retry
- attempts and up to ~90s of accumulated remote timeouts. The real sync
- paths keep the retrying :meth:`request`.
+ by :meth:`request`. Point probes need a fast verdict (~3s, one shot)
+ instead of waiting through 3 retry attempts and up to ~90s of accumulated
+ remote timeouts. The real sync paths keep the retrying :meth:`request`.
"""
return self._build_get(path, timeout=timeout)()
diff --git a/py_modules/adapters/romm/romm_api.py b/py_modules/adapters/romm/romm_api.py
index fa836eb9..ff8320f6 100644
--- a/py_modules/adapters/romm/romm_api.py
+++ b/py_modules/adapters/romm/romm_api.py
@@ -101,9 +101,8 @@ def get_version(self) -> str | None:
# ── Server / Auth ─────────────────────────────────────────────────
- # Fast-fail reachability probe: a single ~3s attempt, no retry. Keeps the
- # launch gate's "offline" verdict snappy instead of waiting through the
- # retrying heartbeat (3 attempts + up to ~90s of accumulated timeouts).
+ # Fast-fail point probes: a single ~3s attempt, no retry. Keeps verdicts
+ # snappy instead of waiting through 3 attempts and accumulated timeouts.
_PROBE_TIMEOUT_SECONDS = 3
def heartbeat(self) -> dict[str, Any]:
@@ -130,6 +129,10 @@ def get_current_user(self) -> dict[str, Any]:
def get_rom(self, rom_id: int) -> dict[str, Any]:
return self._client.request(f"/api/roms/{rom_id}")
+ def get_rom_once(self, rom_id: int) -> dict[str, Any]:
+ """Single-attempt, short-timeout exact-ROM read for liveness probes."""
+ return self._client.request_once(f"/api/roms/{rom_id}", timeout=self._PROBE_TIMEOUT_SECONDS)
+
def list_roms(self, platform_id: int, limit: int = LIST_PAGE_SIZE, offset: int = 0) -> dict[str, Any]:
return self._client.request(
f"/api/roms?platform_ids={platform_id}&limit={limit}&offset={offset}{_LIST_AGGREGATIONS_DISABLED}"
diff --git a/py_modules/services/artwork.py b/py_modules/services/artwork.py
index fc11fb82..2e3861da 100644
--- a/py_modules/services/artwork.py
+++ b/py_modules/services/artwork.py
@@ -835,7 +835,7 @@ async def refresh_cover(self, rom_id: int) -> dict[str, Any]:
if not rom:
return {
"success": False,
- "reason": ErrorCode.SERVER_UNREACHABLE.value,
+ "reason": ErrorCode.NOT_FOUND.value,
"message": "Could not fetch ROM from server",
}
diff --git a/py_modules/services/protocols/transport.py b/py_modules/services/protocols/transport.py
index 1d064f7c..6c728dae 100644
--- a/py_modules/services/protocols/transport.py
+++ b/py_modules/services/protocols/transport.py
@@ -145,6 +145,10 @@ def get_rom(self, rom_id: int) -> dict[str, Any]:
"""
...
+ def get_rom_once(self, rom_id: int) -> dict[str, Any]:
+ """Fetch a single ROM by ID once with the short probe timeout."""
+ ...
+
def list_roms(self, platform_id: int, limit: int = LIST_PAGE_SIZE, offset: int = 0) -> dict[str, Any]:
"""List ROMs for a platform with pagination.
diff --git a/py_modules/services/version_switch.py b/py_modules/services/version_switch.py
index 0697c5bc..644be65f 100644
--- a/py_modules/services/version_switch.py
+++ b/py_modules/services/version_switch.py
@@ -4,9 +4,10 @@
``get_version_list`` reports every version of a sibling group — the local rows
(rich version dimensions) merged with the server's live ``sibling_roms`` view
(slim stubs for versions not yet synced) — with the active / downloaded / default
-markers the picker paints. ``switch_version`` moves the group's Steam-shortcut
-binding to a chosen sibling (the active version), persisting a server-only target
-first when needed.
+markers the picker paints. Retained local rows are checked for ephemeral liveness
+on each read; only an exact-ROM 404 marks one vanished. ``switch_version`` moves
+the group's Steam-shortcut binding to a chosen sibling (the active version),
+persisting a server-only target first when needed.
A version switch is a pure binding move: the shortcut's name and appId stay
sticky (ADR-0021 §2) and no save state migrates (ADR-0021 §4). Switching a
@@ -158,12 +159,13 @@ def __init__(self, *, config: VersionSwitchServiceConfig) -> None:
async def get_version_list(self, app_id: int) -> dict[str, Any]:
"""Report the version picker's state for the group bound to ``app_id``.
- Returns ``{"multi_version": False}`` when the appId is unknown/unbound or
- the group has a single version (the frontend renders no picker). Otherwise
- ``{"multi_version": True, "versions": [...], "server_query_failed": bool}``
- — one entry per version with ``rom_id`` / ``label`` / ``name`` / the
- version dimensions and the ``synced`` / ``installed`` / ``active`` /
- ``is_default`` / ``switchable`` markers. Local rows always appear; the
+ Returns ``multi_version: False`` when the appId is unknown/unbound or the
+ group has a single version (the frontend renders no picker). Every result
+ carries ``bound_vanished``; every known-group result also carries the
+ server-query verdict, including the single-row shape. A multi-version
+ result has one entry per version with ``rom_id`` / ``label`` / ``name`` /
+ the version dimensions and the ``synced`` / ``installed`` / ``active`` /
+ ``is_default`` / ``switchable`` / ``vanished`` markers. Local rows always appear; the
server's ``sibling_roms`` add any not-yet-synced versions
(``synced: False``). ``switchable`` is the same membership authority
``switch_version`` decides by (:func:`target_in_sibling_group`): a RomM
@@ -171,8 +173,12 @@ async def get_version_list(self, app_id: int) -> dict[str, Any]:
under a different group key (#1359) or never synced but carrying a
*different* id at the bound group's canonical source (#1360) — is listed but
``switchable: False``, so the picker disables it instead of offering a
- switch the backend would reject. When the server view can't be
- fetched the local-only list is returned with the additive
+ switch the backend would reject. Liveness is recomputed per call: the
+ successful bound detail makes that id live, local ids in its direct
+ sibling view are live, and absent local ids are exact-id probed once on
+ the executor. Only :class:`RommNotFoundError` sets ``vanished``; every
+ other probe outcome fails open. When the server view can't be fetched the
+ local-only list is returned with the additive
``server_query_failed: True`` flag (partial-success carve-out) — the picker
still works over what's synced. A definitive 404 on the bound id is NOT
such a failure: the server answered, it just no longer has that ROM, so
@@ -182,7 +188,7 @@ async def get_version_list(self, app_id: int) -> dict[str, Any]:
app_id = int(app_id)
local = await self._loop.run_in_executor(None, self._read_local_group, app_id)
if local is None:
- return {"multi_version": False}
+ return {"multi_version": False, "server_query_failed": False, "bound_vanished": False}
try:
bound_detail = await self._loop.run_in_executor(None, self._romm_api.get_rom, local.bound_rom_id)
@@ -192,19 +198,34 @@ async def get_version_list(self, app_id: int) -> dict[str, Any]:
# list must not raise the ``server_query_failed`` flag the picker
# funnels into the global connection store (#1570).
self._logger.warning(f"Version list: bound rom {local.bound_rom_id} is gone from the server")
+ vanished_ids = {local.bound_rom_id}
+ vanished_ids.update(await self._probe_vanished(local.member_ids - {local.bound_rom_id}))
return self._build_version_list(
- local, server_only_stubs=[], detail_by_id={}, stub_local_group_keys={}, server_query_failed=False
+ local,
+ server_only_stubs=[],
+ detail_by_id={},
+ stub_local_group_keys={},
+ vanished_ids=vanished_ids,
+ bound_vanished=True,
+ server_query_failed=False,
)
except Exception as e: # transport failure degrades to a local-only list
self._logger.warning(f"Version list: sibling fetch failed for rom {local.bound_rom_id}: {e}")
return self._build_version_list(
- local, server_only_stubs=[], detail_by_id={}, stub_local_group_keys={}, server_query_failed=True
+ local,
+ server_only_stubs=[],
+ detail_by_id={},
+ stub_local_group_keys={},
+ vanished_ids=set(),
+ bound_vanished=False,
+ server_query_failed=True,
)
- stubs = bound_detail.get("sibling_roms") or []
- server_only_stubs = [
- s for s in stubs if int(s.get("id", 0)) not in local.member_ids and int(s.get("id", 0)) > 0
- ]
+ stubs = self._valid_sibling_stubs(bound_detail)
+ direct_sibling_ids = {int(s["id"]) for s in stubs}
+ suspect_ids = local.member_ids - direct_sibling_ids - {local.bound_rom_id}
+ vanished_ids = await self._probe_vanished(suspect_ids)
+ server_only_stubs = [s for s in stubs if int(s["id"]) not in local.member_ids]
detail_by_id = await self._fetch_stub_details(server_only_stubs)
stub_local_group_keys = await self._loop.run_in_executor(
None, self._read_stub_local_group_keys, [int(s["id"]) for s in server_only_stubs]
@@ -214,9 +235,49 @@ async def get_version_list(self, app_id: int) -> dict[str, Any]:
server_only_stubs=server_only_stubs,
detail_by_id=detail_by_id,
stub_local_group_keys=stub_local_group_keys,
+ vanished_ids=vanished_ids,
+ bound_vanished=False,
server_query_failed=False,
)
+ @staticmethod
+ def _valid_sibling_stubs(bound_detail: Any) -> list[dict[str, Any]]:
+ """Return dict-shaped sibling stubs carrying a positive integer id."""
+ raw_stubs = bound_detail.get("sibling_roms") if isinstance(bound_detail, dict) else None
+ stubs: list[dict[str, Any]] = []
+ if not isinstance(raw_stubs, list):
+ return stubs
+ for stub in raw_stubs:
+ if not isinstance(stub, dict):
+ continue
+ try:
+ rom_id = int(stub.get("id", 0))
+ except (TypeError, ValueError):
+ continue
+ if rom_id > 0:
+ stubs.append(stub)
+ return stubs
+
+ async def _probe_vanished(self, rom_ids: set[int]) -> set[int]:
+ """Return ids whose single-attempt exact-ROM probe produced a typed 404."""
+ ordered_ids = sorted(rom_ids)
+ if not ordered_ids:
+ return set()
+ verdicts = await asyncio.gather(
+ *(self._loop.run_in_executor(None, self._probe_rom_vanished, rom_id) for rom_id in ordered_ids)
+ )
+ return {rom_id for rom_id, vanished in zip(ordered_ids, verdicts, strict=True) if vanished}
+
+ def _probe_rom_vanished(self, rom_id: int) -> bool:
+ """Probe one exact id once; every outcome except a typed 404 fails open."""
+ try:
+ self._romm_api.get_rom_once(rom_id)
+ except RommNotFoundError:
+ return True
+ except Exception as e:
+ self._logger.warning(f"Version list: liveness probe failed open for rom {rom_id}: {e}")
+ return False
+
async def _fetch_stub_details(self, stubs: list[dict[str, Any]]) -> dict[int, dict[str, Any]]:
"""Fetch each server-only sibling's full dims concurrently (best-effort).
@@ -306,6 +367,8 @@ def _build_version_list(
server_only_stubs: list[dict[str, Any]],
detail_by_id: dict[int, dict[str, Any]],
stub_local_group_keys: dict[int, str | None],
+ vanished_ids: set[int],
+ bound_vanished: bool,
server_query_failed: bool,
) -> dict[str, Any]:
"""Merge local rows + server-only stubs into the picker's version list.
@@ -315,8 +378,10 @@ def _build_version_list(
one is available (else empty). Every entry carries ``switchable`` — the
single authority (:func:`target_in_sibling_group`) that ``switch_version``
also decides by, so a listed row the switch would reject is rendered
- non-switchable rather than a dead-end. A single-version group renders no
- picker.
+ non-switchable rather than a dead-end. ``vanished`` is independent of
+ membership: retained rows keep their switchable verdict but are excluded
+ from default ranking and disabled by the frontend. A single-version group
+ renders no picker while preserving its bound/server verdicts.
"""
entries: list[dict[str, Any]] = [
{
@@ -329,6 +394,7 @@ def _build_version_list(
"tags": m.tags,
"synced": True,
"installed": m.installed,
+ "vanished": m.rom_id in vanished_ids,
# Members share the bound key by construction (iter_by_group_key),
# so they are always switchable — routed through the shared
# predicate to keep a single authority.
@@ -346,14 +412,23 @@ def _build_version_list(
]
if len(entries) <= 1:
- return {"multi_version": False}
+ return {
+ "multi_version": False,
+ "server_query_failed": server_query_failed,
+ "bound_vanished": bound_vanished,
+ }
- switchable_ids = {e["rom_id"] for e in entries if e["switchable"]}
- default_rom_id = self._resolve_default(local, server_only_stubs, detail_by_id, switchable_ids)
+ default_candidate_ids = {e["rom_id"] for e in entries if e["switchable"] and not e["vanished"]}
+ default_rom_id = self._resolve_default(local, server_only_stubs, detail_by_id, default_candidate_ids)
for e in entries:
e["active"] = e["rom_id"] == local.bound_rom_id
e["is_default"] = e["rom_id"] == default_rom_id
- return {"multi_version": True, "versions": entries, "server_query_failed": server_query_failed}
+ return {
+ "multi_version": True,
+ "versions": entries,
+ "server_query_failed": server_query_failed,
+ "bound_vanished": bound_vanished,
+ }
def _server_only_entry(
self,
@@ -385,6 +460,9 @@ def _server_only_entry(
"tags": list(meta.get("tags") or []),
"synced": False,
"installed": False,
+ # Presence in the bound ROM's current direct sibling response is a
+ # positive server statement, so a server-only stub is live.
+ "vanished": False,
"switchable": target_in_sibling_group(
bound_group_key=local.group_key,
target_group_key=stub_local_group_keys.get(rom_id) if target_is_local else None,
@@ -399,7 +477,7 @@ def _resolve_default(
local: _LocalGroup,
server_only_stubs: list[dict[str, Any]],
detail_by_id: dict[int, dict[str, Any]],
- switchable_ids: set[int],
+ candidate_ids: set[int],
) -> int | None:
"""The version the resolution chain would pick as the group's default.
@@ -407,12 +485,12 @@ def _resolve_default(
filters, so the badge marks the *natural* default — RomM's
``is_main_sibling`` else the 1G1R + preferred-region ranking — independent
of which version is currently bound (marking that would be circular with
- the ``active`` flag). Ranks over the group's *switchable* versions only
- (``switchable_ids``): a cross-group RomM sibling is not a real member here,
- so it must not win the Default badge. A server-only stub with no fetched
- detail ranks with only its label. Returns ``None`` if the chain somehow
- can't decide (never, for a non-empty group) so the badge is simply absent
- rather than the call failing.
+ the ``active`` flag). Ranks only over the caller's candidates: switchable,
+ live versions. A cross-group RomM sibling and a retained vanished row must
+ not win the Default badge. A server-only stub with no fetched detail ranks
+ with only its label. Returns ``None`` when no candidate remains or the
+ chain cannot decide, so the badge is simply absent rather than the call
+ failing.
"""
ranking: list[dict[str, Any]] = [
{
@@ -424,11 +502,11 @@ def _resolve_default(
"fs_name_no_ext": m.label,
}
for m in local.members
- if m.rom_id in switchable_ids
+ if m.rom_id in candidate_ids
]
for stub in server_only_stubs:
rom_id = int(stub["id"])
- if rom_id not in switchable_ids:
+ if rom_id not in candidate_ids:
continue
detail = detail_by_id.get(rom_id)
meta = extract_version_metadata(detail) if detail is not None else {}
diff --git a/src/api/backend.ts b/src/api/backend.ts
index cf2d90ef..5ebf9596 100755
--- a/src/api/backend.ts
+++ b/src/api/backend.ts
@@ -420,7 +420,9 @@ export const selectDisc = callable<[number, string | null], SelectDiscResult>("s
* this game's — a locally-synced ROM under a different group key (#1359) or a
* not-yet-synced sibling carrying a different id at the group's canonical source
* (#1360) — the picker lists it but disables the row, because switch_version would
- * reject it; every other row is switchable (#1368).
+ * reject it; every other row is switchable (#1368). `vanished` is a separate,
+ * ephemeral liveness verdict: RomM answered 404 for that exact local id during
+ * this list load. The retained row stays visible but cannot be selected.
*/
export interface VersionInfo {
rom_id: number;
@@ -435,6 +437,7 @@ export interface VersionInfo {
active: boolean;
is_default: boolean;
switchable: boolean;
+ vanished: boolean;
}
/**
@@ -443,15 +446,17 @@ export interface VersionInfo {
* the picker renders nothing. When `true`, `versions` lists every version
* (local + server-only) with markers; `server_query_failed` is `true` when the
* live `sibling_roms` view could not be fetched, so the list is local-only
- * (partial-success carve-out). A 404 on the bound id is NOT such a failure —
- * the server answered — so the local-only list comes back with
- * `server_query_failed: false` and the picker leaves the connection state
- * alone (#1570).
+ * (partial-success carve-out). `bound_vanished` is required even on a
+ * single-version/unknown result so a bound-id 404 is never lost when the picker
+ * itself does not render. A 404 on the bound id is NOT a query failure — the
+ * server answered — and the picker does not feed that entity verdict into the
+ * global connection state (#1570).
*/
export interface VersionList {
multi_version: boolean;
versions?: VersionInfo[];
server_query_failed?: boolean;
+ bound_vanished: boolean;
}
/**
diff --git a/src/components/RomMPlaySection.test.tsx b/src/components/RomMPlaySection.test.tsx
index 1e49c806..1da0e364 100644
--- a/src/components/RomMPlaySection.test.tsx
+++ b/src/components/RomMPlaySection.test.tsx
@@ -609,7 +609,7 @@ describe("RomMPlaySection", () => {
tags: ["Demo"],
});
// Single-version group → the compact switcher is absent too.
- vi.mocked(backend.getVersionList).mockResolvedValue({ multi_version: false });
+ vi.mocked(backend.getVersionList).mockResolvedValue({ multi_version: false, bound_vanished: false });
const { container } = render();
await flushAsync();
@@ -622,6 +622,7 @@ describe("RomMPlaySection", () => {
vi.mocked(backend.getVersionList).mockResolvedValue({
multi_version: true,
server_query_failed: false,
+ bound_vanished: false,
versions: [
{
rom_id: 1,
@@ -636,6 +637,7 @@ describe("RomMPlaySection", () => {
active: true,
is_default: true,
switchable: true,
+ vanished: false,
},
{
rom_id: 2,
@@ -650,6 +652,7 @@ describe("RomMPlaySection", () => {
active: false,
is_default: false,
switchable: true,
+ vanished: false,
},
],
});
@@ -672,7 +675,7 @@ describe("RomMPlaySection", () => {
it("renders no version trigger for a single-version group", async () => {
vi.mocked(cachedStore.getCachedGameDetail).mockResolvedValue({ found: true, rom_id: 12 });
- vi.mocked(backend.getVersionList).mockResolvedValue({ multi_version: false });
+ vi.mocked(backend.getVersionList).mockResolvedValue({ multi_version: false, bound_vanished: false });
const { container } = render();
await flushAsync();
diff --git a/src/components/SavesTab.test.tsx b/src/components/SavesTab.test.tsx
index a6380674..b87dcf9e 100644
--- a/src/components/SavesTab.test.tsx
+++ b/src/components/SavesTab.test.tsx
@@ -132,7 +132,7 @@ describe("SavesTab", () => {
setRommConnectionState("connected");
// Stranded-version banner probes (#1298) — default to "no other versions" so
// the banner is absent for the unrelated slot/legacy tests.
- vi.mocked(backend.getVersionList).mockResolvedValue({ multi_version: false });
+ vi.mocked(backend.getVersionList).mockResolvedValue({ multi_version: false, bound_vanished: false });
vi.mocked(backend.checkLocalDrift).mockResolvedValue({ drifted: false, rom_id: 0 });
installDomEventListenerSpy();
});
@@ -653,6 +653,7 @@ describe("SavesTab", () => {
active: false,
is_default: false,
switchable: true,
+ vanished: false,
...overrides,
};
}
@@ -660,6 +661,7 @@ describe("SavesTab", () => {
// Active = Japan (installed); an inactive USA build is also on disk.
const listWithStranded: import("../api/backend").VersionList = {
multi_version: true,
+ bound_vanished: false,
versions: [
makeVersion({ rom_id: 2, label: "Game (Japan)", active: true, installed: true }),
makeVersion({ rom_id: 5, label: "Game (USA)", active: false, installed: true }),
@@ -668,6 +670,7 @@ describe("SavesTab", () => {
// Active = Japan (installed); the other version is NOT downloaded.
const listNoStranded: import("../api/backend").VersionList = {
multi_version: true,
+ bound_vanished: false,
versions: [
makeVersion({ rom_id: 2, label: "Game (Japan)", active: true, installed: true }),
makeVersion({ rom_id: 5, label: "Game (USA)", active: false, installed: false }),
@@ -724,6 +727,7 @@ describe("SavesTab", () => {
// drifted. The banner must probe past USA and name Europe.
vi.mocked(backend.getVersionList).mockResolvedValue({
multi_version: true,
+ bound_vanished: false,
versions: [
makeVersion({ rom_id: 2, label: "Game (Japan)", active: true, installed: true }),
makeVersion({ rom_id: 5, label: "Game (USA)", active: false, installed: true }),
@@ -747,6 +751,30 @@ describe("SavesTab", () => {
expect(container.textContent).not.toContain("Game (USA)");
});
+ it("skips vanished installs and continues to a later live drifted version", async () => {
+ vi.mocked(backend.getVersionList).mockResolvedValue({
+ multi_version: true,
+ bound_vanished: false,
+ versions: [
+ makeVersion({ rom_id: 2, label: "Game (Japan)", active: true, installed: true }),
+ makeVersion({ rom_id: 5, label: "Gone (USA)", installed: true, vanished: true }),
+ makeVersion({ rom_id: 6, label: "Game (Europe)", installed: true }),
+ ],
+ });
+ vi.mocked(backend.checkLocalDrift).mockResolvedValue({ drifted: true, rom_id: 6 });
+
+ const { container } = render();
+
+ await waitFor(() =>
+ expect(container.textContent).toContain(
+ 'Version "Game (Europe)" has saves that were never uploaded — switch back to sync them.',
+ ),
+ );
+ expect(backend.checkLocalDrift).not.toHaveBeenCalledWith(5);
+ expect(backend.checkLocalDrift).toHaveBeenCalledWith(6);
+ expect(container.textContent).not.toContain("Gone (USA)");
+ });
+
it("logs a warning and hides the banner when the version-list probe rejects", async () => {
const logWarnSpy = vi.spyOn(backend, "logWarn").mockImplementation(() => {});
try {
diff --git a/src/components/SavesTab.tsx b/src/components/SavesTab.tsx
index 938c6fdd..553acbb1 100644
--- a/src/components/SavesTab.tsx
+++ b/src/components/SavesTab.tsx
@@ -102,7 +102,7 @@ export const SavesTab: FC = ({
const list = await getVersionList(appId);
if (isCancelled()) return;
const inactiveInstalled = list.multi_version
- ? (list.versions ?? []).filter((v) => v.installed && !v.active)
+ ? (list.versions ?? []).filter((v) => v.installed && !v.active && !v.vanished)
: [];
// Probe every inactive-installed sibling (any of them can hold stranded
// saves), and surface the first one that actually drifted.
diff --git a/src/components/VersionPicker.test.tsx b/src/components/VersionPicker.test.tsx
index 4a60b795..50ef1a6d 100644
--- a/src/components/VersionPicker.test.tsx
+++ b/src/components/VersionPicker.test.tsx
@@ -15,6 +15,7 @@ import { toaster } from "@decky/api";
import { VersionPicker } from "./VersionPicker";
import * as backend from "../api/backend";
import type { VersionList } from "../api/backend";
+import { getRommConnectionState, setRommConnectionState } from "../utils/connectionState";
import { emitDeckyEvent } from "../test-utils/decky-api-mock";
import type { DownloadCompleteEvent } from "../types";
import {
@@ -69,6 +70,7 @@ function multiVersionList(overrides: Partial = {}): VersionList {
return {
multi_version: true,
server_query_failed: false,
+ bound_vanished: false,
versions: [
{
rom_id: 1,
@@ -83,6 +85,7 @@ function multiVersionList(overrides: Partial = {}): VersionList {
active: true,
is_default: false,
switchable: true,
+ vanished: false,
},
{
rom_id: 2,
@@ -97,6 +100,7 @@ function multiVersionList(overrides: Partial = {}): VersionList {
active: false,
is_default: true,
switchable: true,
+ vanished: false,
},
{
rom_id: 3,
@@ -111,6 +115,7 @@ function multiVersionList(overrides: Partial = {}): VersionList {
active: false,
is_default: false,
switchable: true,
+ vanished: false,
},
],
...overrides,
@@ -136,7 +141,7 @@ describe("VersionPicker — render gate", () => {
});
it("renders nothing for a single-version group (multi_version:false)", async () => {
- vi.mocked(backend.getVersionList).mockResolvedValue({ multi_version: false });
+ vi.mocked(backend.getVersionList).mockResolvedValue({ multi_version: false, bound_vanished: false });
const { container } = render();
@@ -212,6 +217,7 @@ describe("VersionPicker — non-switchable rows (#1359)", () => {
active: true,
is_default: true,
switchable: true,
+ vanished: false,
},
{
rom_id: 5,
@@ -226,6 +232,7 @@ describe("VersionPicker — non-switchable rows (#1359)", () => {
active: false,
is_default: false,
switchable: false,
+ vanished: false,
},
],
});
@@ -261,6 +268,101 @@ describe("VersionPicker — non-switchable rows (#1359)", () => {
});
});
+describe("VersionPicker — vanished retained rows (#1570)", () => {
+ beforeEach(() => {
+ captured.menu = null;
+ vi.mocked(backend.getVersionList).mockReset();
+ vi.mocked(backend.switchVersion).mockReset();
+ vi.mocked(backend.fetchCoverBase64).mockResolvedValue({ base64: null });
+ vi.mocked(toaster.toast).mockReset();
+ });
+
+ function listWithBoundVanished(): VersionList {
+ const versions = (multiVersionList().versions ?? []).slice(0, 2).map((v) => ({ ...v }));
+ versions[0] = { ...versions[0]!, active: true, installed: true, is_default: false, vanished: true };
+ versions[1] = { ...versions[1]!, active: false, is_default: true, vanished: false };
+ return multiVersionList({ bound_vanished: true, versions });
+ }
+
+ it("keeps a vanished active row visible, dimmed, disabled, labelled, and marked", async () => {
+ vi.mocked(backend.getVersionList).mockResolvedValue(listWithBoundVanished());
+
+ const { menu } = await renderAndOpen();
+
+ const items = within(menu.container).getAllByRole("menuitem");
+ const vanishedRow = items.find((i) => i.textContent.includes("Game (USA)"));
+ expect(vanishedRow).toBeTruthy();
+ expect(vanishedRow?.getAttribute("aria-disabled")).toBe("true");
+ expect(vanishedRow?.textContent).toContain("No longer available on RomM");
+ expect(vanishedRow?.textContent).toContain("Downloaded");
+ expect(vanishedRow?.textContent).toContain("✓");
+ expect((vanishedRow?.firstElementChild as HTMLElement | null)?.style.opacity).toBe("0.55");
+ // The live recovery target remains selectable.
+ const liveRow = items.find((i) => i.textContent.includes("Game (Japan)"));
+ expect(liveRow?.getAttribute("aria-disabled")).toBeNull();
+ });
+
+ it("clicking an inactive vanished row is inert in code", async () => {
+ const list = listWithBoundVanished();
+ list.bound_vanished = false;
+ list.versions = (list.versions ?? []).map((v) =>
+ v.rom_id === 1 ? { ...v, active: false } : { ...v, active: true },
+ );
+ vi.mocked(backend.getVersionList).mockResolvedValue(list);
+
+ const { menu } = await renderAndOpen();
+ await clickRow(menu.container, "Game (USA)");
+
+ expect(backend.switchVersion).not.toHaveBeenCalled();
+ expect(toaster.toast).not.toHaveBeenCalled();
+ });
+
+ it("allows recovery by selecting a live alternative to the vanished binding", async () => {
+ vi.mocked(backend.getVersionList).mockResolvedValue(listWithBoundVanished());
+ vi.mocked(backend.switchVersion).mockResolvedValue({
+ success: false,
+ reason: "bound_elsewhere",
+ message: "test stop",
+ });
+
+ const { menu } = await renderAndOpen();
+ await clickRow(menu.container, "Game (Japan)");
+
+ expect(backend.switchVersion).toHaveBeenCalledWith(APP_ID, 2, false);
+ });
+});
+
+describe("VersionPicker — liveness connection signals (#1570)", () => {
+ beforeEach(() => {
+ captured.menu = null;
+ vi.mocked(backend.getVersionList).mockReset();
+ vi.mocked(backend.fetchCoverBase64).mockResolvedValue({ base64: null });
+ setRommConnectionState("checking");
+ });
+
+ afterEach(() => setRommConnectionState("checking"));
+
+ it("does not feed a bound-id 404 into the global connection store", async () => {
+ vi.mocked(backend.getVersionList).mockResolvedValue(
+ multiVersionList({ bound_vanished: true, server_query_failed: false }),
+ );
+
+ render();
+ await waitFor(() => expect(backend.getVersionList).toHaveBeenCalledWith(APP_ID));
+
+ expect(getRommConnectionState()).toBe("checking");
+ });
+
+ it("preserves the explicit server-unreachable feed", async () => {
+ vi.mocked(backend.getVersionList).mockResolvedValue(
+ multiVersionList({ bound_vanished: false, server_query_failed: true }),
+ );
+
+ render();
+ await waitFor(() => expect(getRommConnectionState()).toBe("offline"));
+ });
+});
+
describe("VersionPicker — per-version covers (#1346)", () => {
beforeEach(() => {
captured.menu = null;
diff --git a/src/components/VersionPicker.tsx b/src/components/VersionPicker.tsx
index 31456c77..44a1d241 100644
--- a/src/components/VersionPicker.tsx
+++ b/src/components/VersionPicker.tsx
@@ -104,11 +104,13 @@ export const VersionPicker: FC = ({ appId }) => {
const result = await getVersionList(appId);
// get_version_list touches the server for the sibling view (#1345): an
// explicit server_query_failed means offline; a multi-version list that
- // loaded without failure proves the server is reachable. A single/unbound
- // group carries no reachability signal.
+ // loaded without failure proves the server is reachable. A bound-id 404
+ // is an entity verdict, not a connection signal, so it feeds neither
+ // direction into the global store. A single/unbound group carries no
+ // reachability signal.
if (result.server_query_failed) {
reportServerReachable(false);
- } else if (result.multi_version) {
+ } else if (result.multi_version && !result.bound_vanished) {
reportServerReachable(true);
}
memberIdsRef.current = new Set((result.versions ?? []).map((v) => v.rom_id));
@@ -276,7 +278,7 @@ export const VersionPicker: FC = ({ appId }) => {
// guard once the fresh list lands — so the trigger never re-enables against a
// stale list.
const handleSwitch = async (target: VersionInfo): Promise => {
- if (target.active) return;
+ if (target.active || target.vanished) return;
// A non-switchable row is a RomM sibling that lives in a different local group
// (#1359) — its row is rendered disabled, and this guard makes a click a no-op
// (defense-in-depth), so switch_version's rejection can never reach a toast.
@@ -354,6 +356,20 @@ export const VersionPicker: FC = ({ appId }) => {
return ;
};
+ const rowAvailabilityHint = (v: VersionInfo): ReactNode => {
+ let text: string;
+ if (v.vanished) {
+ text = "No longer available on RomM";
+ } else if (!v.switchable) {
+ text = "conflicting metadata match in RomM";
+ } else {
+ return null;
+ }
+ return (
+ {text}
+ );
+ };
+
const openMenu = (e: MouseEvent): void => {
// Blocked while a switch is in flight — the list is stale until the reload
// lands, so opening it now would let a click act against the wrong versions.
@@ -361,17 +377,16 @@ export const VersionPicker: FC = ({ appId }) => {
showContextMenu(