The follow-up review found a few stale lifecycle edges around local game transactions. Recovery could sweep active roots, post-operation refreshes still re-ran full startup recovery, and the UI kept inferring local-only state from downloaded and installed flags instead of the backend availability. This updates the peer lifecycle so startup recovery skips active operations, install/update/uninstall refresh only the affected game after the operation guard is dropped, and path-changing game-directory updates are rejected while operations are active. It also removes the dead UpdateGame command, drops the unused manifest_hash write field while preserving old JSON reads, renames the internal install-finished event, and carries availability through the DB, peer summaries, Tauri refreshes, and the React model. The included follow-up documents record the review source, implementation decisions, and the remaining FOLLOW_UP_2.md work so later commits can stay small instead of reopening the completed plan items. Test Plan: - git diff --check - just fmt - just clippy - just test Follow-up-Plan: FOLLOW_UP_PLAN.md
28 KiB
Plan: Transactional Local Installs, Intent-Logged Recovery, Event-Gated Scans
Summary
local/being a directory is the install signal. The contents oflocal/are the user's responsibility, not ours.version.iniis the download completion sentinel: a game is downloaded iffversion.iniexists at the game-root level.version.iniis the last file written by every successful download, via atomic rename.- Each game root carries a per-game JSON intent log for install operations, updated atomically before and after every state-changing FS operation. Startup recovery is a
(recorded_intent × fs_state)lookup. - Installs/updates/uninstalls run as transactions over each game root using fixed-name staging and backup directories, marked Lanspread-owned to prevent collisions with user data.
- Downloads are transactional via a single sentinel: old
version.iniis renamed aside at the start, newversion.iniis written atomically at the end. No intent log entry needed for downloads — the FS state is authoritative. - Filesystem watcher replaces 15s polling. Events are gated by per-game operation locks and a per-game rescan-pending flag — no time-based debounce. A 5-minute fallback scan reconciles missed events.
- Install transaction logic moves out of
src-tauriinto a newlanspread-peer::installmodule so the peer owns the state machine end-to-end. Unpacker is injected at peer startup so the peer crate stays Tauri-free. - A game is transferable to peers only when its ID is in the ETI catalog,
version.iniis present, and no operation is active. Non-catalog game roots are ignored for peer announcements — we currently have no system to determine title/thumbnail for non-catalog games, so they're future scope.
Wire protocol
Unchanged. Existing library_rev / LibraryDelta / manifest_hash machinery already handles announcement on any state change; uninstall is just another library change. version.ini remains part of the per-game manifest.
Layout
Game-dir root
<game_dir>/
.lanspread/ -- peer-wide cache (ignored by watcher and scans)
<game_id_1>/...
<game_id_2>/...
...
Per game root
<game_dir>/<game_id>/
<game_id>.eti -- main archive
*.eti -- additional archives (multi-archive games are valid)
version.ini -- download sentinel. Presence = downloaded.
local/ -- committed install. Is-a-directory ⇒ installed.
.version.ini.tmp -- transient: version.ini being staged for atomic rename
.version.ini.discarded -- transient: previous version.ini parked aside during re-download
.local.installing/ -- extraction staging dir (Lanspread-owned)
.local.backup/ -- previous install retained for rollback (Lanspread-owned)
.lanspread.json -- per-game install intent log (atomic writes)
Fixed names; no timestamps. The intent log disambiguates "fresh" vs "stale" install-staging/backup dirs; for downloads the FS state alone is authoritative.
Ownership marker
When the peer creates .local.installing/ or .local.backup/, it drops a zero-byte .lanspread_owned file inside as soon as the directory exists.
Two ownership rules apply at recovery time:
- Intent says
None: the marker is the only signal. Only.local.*dirs carrying.lanspread_ownedmay be touched; markerless.local.*dirs are user data — log and leave alone. - Intent says
Installing/Updating/Uninstalling: the corresponding.local.installing/and.local.backup/are unconditionally Lanspread-owned regardless of marker. This covers the inevitable crash window betweenrename(local → .local.backup)and the marker drop, and betweenmkdir(.local.installing)and the marker drop. The intent log already proves ownership; the marker is belt-and-suspenders for theNone-intent case.
Download-related transient files (.version.ini.tmp, .version.ini.discarded) use fixed reserved names — no marker is needed because the names are reserved by Lanspread and any conflict would already be a name collision with our own scheme.
State model
downloaded = version.ini exists (regular file, at game-root level)
installed = local/ is a directory
The two predicates are independent.
installed && downloaded→ Ready, fully shareable.installed && !downloaded→ installed locally; peer announces in deltas butavailability = LocalOnly(refuses to serve archives).!installed && downloaded→ ready to install; UI shows pending;eti_versionis surfaced fromversion.ini.!installed && !downloaded→ blank slate.
is_committed_install (the predicate) requires local/ to be a directory, not merely "a path that exists." This handles symlinks, leftover regular files named local, etc.
Intent log (install operations only)
Per-game JSON at <game_root>/.lanspread.json:
{
"schema_version": 1,
"id": "...",
"recorded_at": <unix_ts>,
"state": "None" | "Installing" | "Updating" | "Uninstalling",
"eti_version": "20251028"?
}
state: "None" means no install-side operation in flight; FS is the truth. The intent log does not track download state — that's read directly from version.ini / .version.ini.* FS signals.
Atomic write
- Write
.lanspread.json.tmp. file.sync_all()(fsync the data).rename(.lanspread.json.tmp, .lanspread.json).fsync(parent_dir)on Unix; on Windows skip (no portable directory-fsync equivalent).
Intent transitions
- Begin operation: write intent with new state before any FS mutation.
- Commit operation: write intent with
Noneimmediately after the commit rename, before emitting events or starting cleanup. - Cleanup (e.g., deleting
.local.backupafter a successful update): best-effort, after intent flush and event emit. Failure logs but doesn't fail the operation.
Startup recovery
Install recovery matrix
For each game root, read intent + inspect FS, then act:
recorded | local/ | .local.installing | .local.backup | action
-------------|--------|-------------------|---------------|----------------------------------------
None | - | - | - | trust FS; leave markerless .local.* alone (user data); sweep only markered .local.* orphans
Installing | yes | - | - | commit landed, intent crash; write None
Installing | - | yes | - | sweep staging; write None
Installing | - | - | - | crash before any staging; write None
Updating | no | yes | yes | sweep staging, restore backup; write None (old ver)
Updating | yes | yes (stale) | yes (stale) | sweep both; write None (new ver — commit landed before intent flush)
Updating | yes | - | yes | commit landed, intent crash before cleanup; sweep backup, write None
Updating | yes | - | - | commit + cleanup landed, intent crash; write None
Uninstalling | no | - | yes | finish: delete backup; write None
Uninstalling | no | - | - | write None
Uninstalling | yes | - | - | intent crash before any FS work; redo uninstall from step 1
Default rule: for any (intent, fs) combination not listed, write intent matching FS observation; do not mutate FS. Conservative bias — never delete unless we have positive proof of ownership (intent is Installing / Updating / Uninstalling for that ID, or the .local.* dir carries the marker).
Sideloading (user drops a game dir in via file manager): no intent log, FS-only observation. The "None" row handles it. JSON corruption or accidental deletion follows the same path — treat missing/unparseable intent as None.
Download recovery
Independent of the install intent log; pure FS sweep:
.version.ini.tmppresent → partial commit; delete..version.ini.discardedpresent → interrupted download; delete (do not restore;.etibytes may be partially overwritten).- After sweeping, the
version.inipresence/absence is authoritative.
Transaction primitives
All in the new lanspread-peer::install module (install) plus changes to download.rs (download). The same per-peer operation table holds Downloading | Installing | Updating | Uninstalling entries.
Download transaction
Driven by download_game_files.
- Operation table: insert
Downloadingfor game ID. - If
version.iniexists in the game root,rename(version.ini, .version.ini.discarded). prepare_game_storageruns over file descs filtered to excludeis_version_ini()entries — non-version.ini files are pre-allocated and overwritten in place by chunk writes as today.- Run parallel chunk downloads as today.
- Version.ini chunks (small file, typically a few bytes) are routed to an in-memory buffer instead of disk during chunk receipt.
- After all non-version.ini downloads complete and the version.ini buffer is full:
- Write buffer to
.version.ini.tmp. file.sync_all().rename(.version.ini.tmp, version.ini).
- Write buffer to
- Sweep
.version.ini.discarded(best-effort). - Operation table: remove
Downloading. - Emit
DownloadGameFilesFinished. - Peer auto-fires
InstallGame { id }(see install transaction).
Rollback (any failure or cancel):
- Sweep
.version.ini.tmpif any. - Sweep
.version.ini.discarded. - Do not restore old version.ini.
.etibytes may be partially overwritten and would no longer match the old state. - Operation table: remove
Downloading. - Emit
DownloadGameFilesFailed.
A failed download is destructive to the previous downloaded state but leaves local/ untouched. The user can re-trigger download. This is acceptable because local/ is what the user plays from, and download is fundamentally about replacing the on-disk archive.
Install (no prior local/)
- Write intent
Installing. - Create
.local.installing/, drop.lanspread_ownedmarker. - Extract archives into
.local.installing/. Non-zero unrar exit ⇒ error ⇒ rollback. rename(.local.installing, local). ← commit point.- Write intent
None. - Emit success event; trigger rescan.
Rollback (any step before commit): rm -rf .local.installing; write intent None; emit failure.
Update (prior local/ present)
- Write intent
Updating. rename(local, .local.backup); drop.lanspread_ownedmarker inside.- Create
.local.installing/, drop marker. - Extract new archive into
.local.installing/. Non-zero unrar exit ⇒ error ⇒ rollback. rename(.local.installing, local). ← commit point.- Write intent
None. - Emit success event; trigger rescan.
- Best-effort
rm -rf .local.backup; failure logged, not fatal.
Rollback: rm -rf .local.installing (best-effort); rename(.local.backup, local); write intent None; emit failure.
Uninstall
- Write intent
Uninstalling. rename(local, .local.backup); drop.lanspread_ownedmarker.rm -rf .local.backup.- Write intent
None. - Emit success event; trigger rescan.
Rollback (step 3 fails): attempt rename(.local.backup, local); surface error.
unrar correctness
Non-zero exit status from unrar is an error. The None intent and the user-facing "unpack-finished" event fire only after both extraction and the promotion rename succeed.
Peer crate changes
New module crates/lanspread-peer/src/install/
mod.rs -- public surface: install(), update(), uninstall(), recover_on_startup()
transaction.rs -- staging/backup/commit/rollback primitives over a game root
intent.rs -- atomic read/write of per-game intent JSON
unpack.rs -- unrar invocation, via the injected Unpacker trait
Unpacker injection
Object-safe boxed-future trait — no async_trait dep, no async fn in trait:
use std::{future::Future, path::Path, pin::Pin};
pub type UnpackFuture<'a> = Pin<Box<dyn Future<Output = eyre::Result<()>> + Send + 'a>>;
pub trait Unpacker: Send + Sync {
fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a>;
}
start_peer gains a 4th argument:
pub fn start_peer(
game_dir: impl Into<PathBuf>,
tx_notify_ui: UnboundedSender<PeerEvent>,
peer_game_db: Arc<RwLock<PeerGameDB>>,
unpacker: Arc<dyn Unpacker>,
catalog: Arc<RwLock<HashSet<String>>>,
) -> eyre::Result<PeerRuntimeHandle>
Tauri provides the impl that wraps app_handle.shell().sidecar("unrar") and passes it in. The peer crate stays Tauri-free.
Catalog injection
The peer must know which game IDs the ETI catalog covers — only those IDs are announced to peers, only those flow through the install state machine, and only those participate in download/serve paths. The catalog set is owned by Tauri (loaded from the bundled game.db in load_bundled_game_db) and shared with the peer as Arc<RwLock<HashSet<String>>>. The RwLock leaves room for a future "catalog refresh without peer restart" path; today the set is populated once at startup before start_peer is called.
Catalog enforcement points inside the peer:
scan_local_libraryand the optimized rescan: a game root whose ID is not in the catalog is skipped — noGameSummaryemitted, no entry in the library index, no contribution tolibrary_rev. The intent log and recovery still operate on it (so we don't corrupt on-disk state for an unknown ID), but it is invisible to peers and the UI.local_download_availableand the file-stream handlers (see "Operation table" below): refuse all serve requests for non-catalog IDs.InstallGame/UninstallGamecommands for non-catalog IDs are rejected with an error; the peer logs and emits no events.
This is also why the watcher reacting to a new game root cannot announce it without consulting the catalog — events for non-catalog IDs flow through gating but never become deltas.
Download changes (crates/lanspread-peer/src/download.rs)
prepare_game_storage: filterfile_descsto excludedesc.is_version_ini()entries. Non-version.ini files are pre-allocated as today.download_game_files:- Partition
game_file_descsinto version.ini and non-version.ini. - Insert
Downloadinginto the operation table; rename existingversion.inito.version.ini.discardedif present. - Run parallel chunked downloads for non-version.ini files (existing path).
- Route version.ini chunk writes to an in-memory buffer keyed by relative path.
- After all chunks succeed, commit each version.ini buffer atomically via
.version.ini.tmp→ rename. - Sweep
.version.ini.discarded. - Remove
Downloadingfrom the operation table.
- Partition
download_chunkfor version.ini paths: writes to memory rather than disk.- New
PeerCommandvariant:InstallGame { id }— runs the install transaction over an already-downloaded game (auto-fired by the peer when a download commits; can also be fired by Tauri for explicit re-install). PlusUninstallGame { id }.
Operation table
A single per-peer table holds the in-progress operation for each game ID (Downloading | Installing | Updating | Uninstalling). Consulted by:
local_download_available(manifest serve path, used byhandle_get_game) — refuseGetGamefor a game during any active operation, whenversion.iniis absent, or when the ID is not in the catalog.- File-stream handlers (
handle_file_data_request,handle_file_chunk_requestinservices/stream.rs) — refuseGetGameFileDataandGetGameFileChunkrequests when the ID has an active operation, whenversion.iniis absent at the game root, or when the ID is not in the catalog. A remote peer may already hold a manifest from before our operation began; these handlers must re-verify per request rather than rely onlocal_download_availablehaving gated the original manifest fetch. Additional rule:local/-relative paths are rejected unconditionally — manifests never containlocal/entries, but defense-in-depth against a malicious or stale request. - Watcher event handler — drop events for IDs with active operations.
- Scanner — preserve last-known summary for IDs with active operations; do not emit transient deltas.
- Library announcement — do not announce in
LibraryDeltawhile an operation is active.
Aggregation and candidate selection (peer_db.rs)
The Availability flag must propagate through peer-side aggregation; today it does not, which would make LocalOnly peers count as downloadable.
get_all_games: when aggregatingGameSummaryvalues across peers,Game.peer_countcounts only peers whose advertisedavailability == Ready.LocalOnlypeers do not contribute topeer_countand do not flip the aggregateddownloadedflag.peers_with_gameandpeers_with_latest_version: filter to peers withavailability == Readybefore returning addresses. A peer advertisingLocalOnlymust never be selected as a download source — the download will fail when the remote refusesGetGame.get_latest_version_for_game: ignoreLocalOnlyentries when computing latest version. A version we can't actually transfer should not influence "is an update available?" decisions.
State model implementation
local_games.rsswitchesdownloadedfrom<id>.eti existstoversion.ini exists(exact<game_root>/version.ini, regular file).local_download_availablegates onversion.ini+ no active operation + catalog-known ID.build_game_summaryreadseti_versionfromversion.iniwheneverdownloaded, not only wheninstalled. The current "Game is not downloaded" error path is removed: a game withlocal/present but noversion.inireturns aGameSummarywithdownloaded = false,installed = true,availability = LocalOnly.update_index_for_gameno longer short-circuits on missing.eti. The "no game here" decision is now: skip iffversion.iniis absent andlocal/is not a directory and no*.etiis present at root level. Otherwise build a summary.- Manifest building (
scan_game_descriptions/should_skip_root_entry) continues to skiplocal/;local/contents are user-owned and never enter manifests, fingerprints, or transfer paths. - Fingerprint still tracks
version.inimtime (already there);.etimtime is retained as a cache invalidation hint.local/is-a-directory remains part of the fingerprint.
is_version_ini predicate
GameFileDescription::is_version_ini is tightened to an exact root-level match: the predicate returns true only when relative_path == "<game_id>/version.ini". The current ends_with("/version.ini") would misclassify nested foo/bar/version.ini as the sentinel; under the new sentinel rule that would break both download partitioning and the install state machine.
If a remote manifest contains zero or more than one root-level version.ini entry, download_game_files fails fast with a clear error — the sentinel is mandatory and must be unique.
Scanner + watcher
Workspace dependency
Add notify = "7" (or the current 7.x) to the top-level Cargo.toml [workspace.dependencies] and pull it into lanspread-peer/Cargo.toml. The crate is not currently in the dependency graph.
Watcher topology
notify-based, non-recursive watches ongame_dirroot and on each game root.- Never watch inside
local/,.local.installing/,.local.backup/, or the root.lanspread/. - Lifecycle: the watcher reconciles its watched set whenever a game root appears or disappears, and tears down + rebuilds all watches on
SetGameDir.
No time-based debounce
Per-game-ID gating:
- Operation-lock present → drop event.
- Rescan running for ID → set "rescan-pending" flag; the running task re-runs once on completion.
- Else → spawn rescan for that ID.
Optimized rescan (single ID)
- Stat
version.ini(existence + mtime), checklocal/is-a-directory, stat any*.etifiles at root level. - Compare to cached fingerprint.
- Recursive walk only on fingerprint change.
Fallback scan
Every 300s, list top-level game-root dirs and run the optimized rescan per ID.
Ignore list
In manifests, scans, and event handling: .local.*, .version.ini.*, .lanspread, .lanspread.json, .sync, .softlan_game_installed (latter two already skipped).
Watcher init failure
Non-fatal. Log a warning; the fallback scan is sufficient on its own.
Tauri / UI changes
src-tauri/src/lib.rs
- Remove direct unpack logic:
unpack_game,do_unrar. Remove the unpack-spawn insidehandle_download_finished— unpack now happens inside the peer's install transaction.handle_download_finishedbecomes a plaingame-download-finishedevent relay (no spawn, no sidecar invocation, no backup cleanup). - Remove the entire
___TO_BE_DELETE___{id}backup-the-whole-game-dir scheme:backup_game_folder,restore_game_folder,cleanup_backup_folder,cleanup_failed_download(download rollback now lives in the peer). The whole-game-folder backup approach is incompatible with the new model (downloads no longer touchlocal/, so there's nothing to back up). - Remove
update_game_installation_state's "downloaded required for installed" coupling:installedis nowlocal/is-a-directory, independent ofdownloaded. - Provide the
Unpackerimpl that shells out to the unrar sidecar; pass it tostart_peer. Populate the catalog set fromload_bundled_game_dband pass it tostart_peertoo. - Rename
games_in_download: HashSet<String>to a generic in-progress operation set mirroring the peer's operation table (HashMap<String, OpKind>or similar — the variant tracks Downloading / Installing / Updating / Uninstalling so the UI can pick the right spinner). install_game(id)is rewritten to route by local state rather than always sendingGetGame:version.iniabsent → sendPeerCommand::GetGame { id }(fetch first; peer auto-firesInstallGameon download commit).version.inipresent,local/absent → sendPeerCommand::InstallGame { id }directly (no re-download).version.inipresent,local/present → no-op (already installed); the UI's "Open" affordance handles launch.
update_game(id)becomes a thin wrapper that sendsPeerCommand::GetGame { id }. The fresh download triggers the peer's auto-install, which does a transactional update (renamelocal → .local.backup, extract, commit-rename). The old Tauri-side whole-folder backup is gone.
New Tauri command
uninstall_game(id) → forwards to PeerCommand::UninstallGame.
Frontend (src/App.tsx)
- New
InstallStatus.Installing(extraction in flight) andInstallStatus.Uninstalling. - New install lifecycle events (peer-driven, separate from download and unpack):
game-install-begin— intent flipped toInstalling/Updating, before extraction.game-install-failed— extraction or commit-rename failed, rollback complete.game-uninstall-begin,game-uninstall-finished,game-uninstall-failed.
- Existing
game-download-finishedfires when theversion.inirename commits (which happens insidedownload_game_files, before the auto-firedInstallGame). game-install-finishedfires only on true transactional install success (peer-driven). It does NOT fire on extract failure or commit-rename failure — those route togame-install-failed.- Uninstall affordance: secondary action (icon button or row-context menu), not a mode of the main button. Main button stays Install / Open.
- Surface
eti_versionfor games that are downloaded but not installed. - Render the
LocalOnlyavailability badge for installed-but-not-downloaded games: the user can play them locally but the row should communicate that this peer can't serve archives.
ARCHITECTURE.md
Update crates/lanspread-peer/ARCHITECTURE.md:
- Replace the "short debounce (1-2 seconds)" wording with the per-ID operation-lock + rescan-pending model.
- Replace the fingerprint-cache description with the per-game intent log.
- Document the
.local.installing/.local.backup/.lanspread.json/.lanspread_ownedlayout and the ownership-marker rule. - Document the version.ini-as-sentinel rule and
.version.ini.*reserved names.
Test plan
Unit — install transactions
- Install success, update success, update rollback on extract failure, update rollback on commit-rename failure, uninstall success, uninstall delete-failure restore.
Unit — download transaction
- Fresh download writes
version.inilast via atomic rename. - Re-download renames old
version.inito.version.ini.discarded, commits new one, sweeps discarded. - Download interrupted between phases 1 and 5 leaves no
version.ini; subsequent scan sees game as not downloaded. prepare_game_storageskipsversion.inientries.- Version.ini chunk routing to memory: buffer accumulates correctly across out-of-order chunk arrivals.
- Multi-
.etigames: all archives downloaded, none of them used as the sentinel.
Unit — recovery
- Each row of the install matrix, including the default-rule cases.
- Download recovery:
.version.ini.tmpand.version.ini.discardedswept unconditionally on startup. - JSON missing / corrupted / wrong schema_version → fall back to FS observation.
.local.*dir without.lanspread_ownedmarker → ignored, not deleted.
Unit — atomic intent write
- Simulated crash between tmp-write and rename leaves the previous intent intact.
- Schema version mismatch handled as "treat as missing."
Unit — scanner gating
- Events for an ID under an operation lock are dropped.
- Rescan-pending flag collapses bursts to ≤2 scans per ID.
- Sideloaded dir is picked up by the fallback scan and ingested with correct state.
- Non-catalog game root is observed by the scanner but produces no
GameSummary, no library-index entry, and noLibraryDeltachange.
Unit — serve gating
GetGameis refused when an operation is active for the ID.GetGameFileDataandGetGameFileChunkare refused mid-operation even when the requesting peer holds a pre-operation manifest.- All three handlers refuse non-catalog IDs.
- All three handlers refuse when
version.iniis absent (covers manually-deleted-sentinel case). - File-chunk handler rejects any
relative_paththat resolves underlocal/.
Unit — aggregation
get_all_gamescounts onlyReadypeers towardpeer_count; a singleLocalOnlypeer reportspeer_count = 0for a game no other peer carries.peers_with_gameandpeers_with_latest_versionexcludeLocalOnlypeers.get_latest_version_for_gameignoresLocalOnlyentries.
Unit — installed-only state
- A game root with
local/present andversion.iniabsent produces a summary withdownloaded = false,installed = true,availability = LocalOnly. - Same root, after the user copies in
version.ini, flips toReadyon the next rescan.
Manual
- New game appears via file-manager
mv(atomic) andcp(piecemeal). - Game disappears via
rm -rf. - Install / update / uninstall while watcher is active.
- Failed unpack restores old install.
- 100-game fallback scan stays fast.
- Pull plug mid-download; verify on restart: game shown as not downloaded, no stale partials lingering.
- Pull plug mid-update; verify install recovery restores old version.
- Delete
version.inimanually while game is installed; verifyLocalOnlyavailability and that UI still shows installed.
Verify
just fmtjust clippyjust build
Assumptions
- Uninstall removes only
local/; the archives andversion.inistay so the game can be reinstalled without re-download. - All
.local.*,.version.ini.*, and.lanspread*paths are reserved namespaces. Existing user dirs with.local.*names are not touched unless they carry the.lanspread_ownedmarker..version.ini.*names are reserved by convention. - The peer process owns all install/update/uninstall and download FS mutations; users editing inside
local/is explicitly unsupported. - A failed download is destructive to previous download state: old
version.iniis not restored because.etibytes may already be partially overwritten.local/is preserved (downloads never touch it). The user retries the download. - Watcher failure is non-fatal; the 5-minute fallback scan is sufficient on its own.
- Sideload recommendation: users move complete game directories into
game_dir(atomic rename), rather than copying file-by-file. Forcp, the fallback scan reconciles onceversion.inilands. - Non-catalog game roots are ignored because the app has no system yet to determine title and thumbnail for them. Supporting arbitrary non-ETI games is future scope.