# Plan: Transactional Local Installs, Intent-Logged Recovery, Event-Gated Scans ## Summary - `local/` being a directory is the install signal. The contents of `local/` are the user's responsibility, not ours. - `version.ini` is the **download completion sentinel**: a game is downloaded iff `version.ini` exists at the game-root level. `version.ini` is 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.ini` is renamed aside at the start, new `version.ini` is 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-tauri` into a new `lanspread-peer::install` module 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.ini` is 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 ``` / .lanspread/ -- peer-wide cache (ignored by watcher and scans) /... /... ... ``` ### Per game root ``` // .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_owned` may 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 between `rename(local → .local.backup)` and the marker drop, and between `mkdir(.local.installing)` and the marker drop. The intent log already proves ownership; the marker is belt-and-suspenders for the `None`-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 but `availability = LocalOnly` (refuses to serve archives). - `!installed && downloaded` → ready to install; UI shows pending; `eti_version` is surfaced from `version.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 `/.lanspread.json`: ```json { "schema_version": 1, "id": "...", "recorded_at": , "state": "None" | "Installing" | "Updating" | "Uninstalling", "eti_version": "20251028"?, "manifest_hash": ? } ``` `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 1. Write `.lanspread.json.tmp`. 2. `file.sync_all()` (fsync the data). 3. `rename(.lanspread.json.tmp, .lanspread.json)`. 4. `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 `None` immediately after the commit rename, before emitting events or starting cleanup. - **Cleanup** (e.g., deleting `.local.backup` after 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.tmp` present → partial commit; delete. - `.version.ini.discarded` present → interrupted download; delete (do not restore; `.eti` bytes may be partially overwritten). - After sweeping, the `version.ini` presence/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`. 1. Operation table: insert `Downloading` for game ID. 2. If `version.ini` exists in the game root, `rename(version.ini, .version.ini.discarded)`. 3. `prepare_game_storage` runs over file descs **filtered to exclude** `is_version_ini()` entries — non-version.ini files are pre-allocated and overwritten in place by chunk writes as today. 4. Run parallel chunk downloads as today. 5. Version.ini chunks (small file, typically a few bytes) are routed to an in-memory buffer instead of disk during chunk receipt. 6. 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)`. 7. Sweep `.version.ini.discarded` (best-effort). 8. Operation table: remove `Downloading`. 9. Emit `DownloadGameFilesFinished`. 10. Peer auto-fires `InstallGame { id }` (see install transaction). **Rollback** (any failure or cancel): - Sweep `.version.ini.tmp` if any. - Sweep `.version.ini.discarded`. - Do **not** restore old version.ini. `.eti` bytes 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/`) 1. Write intent `Installing`. 2. Create `.local.installing/`, drop `.lanspread_owned` marker. 3. Extract archives into `.local.installing/`. Non-zero unrar exit ⇒ error ⇒ rollback. 4. `rename(.local.installing, local)`. ← commit point. 5. Write intent `None`. 6. Emit success event; trigger rescan. **Rollback** (any step before commit): `rm -rf .local.installing`; write intent `None`; emit failure. ### Update (prior `local/` present) 1. Write intent `Updating`. 2. `rename(local, .local.backup)`; drop `.lanspread_owned` marker inside. 3. Create `.local.installing/`, drop marker. 4. Extract new archive into `.local.installing/`. Non-zero unrar exit ⇒ error ⇒ rollback. 5. `rename(.local.installing, local)`. ← commit point. 6. Write intent `None`. 7. Emit success event; trigger rescan. 8. 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 1. Write intent `Uninstalling`. 2. `rename(local, .local.backup)`; drop `.lanspread_owned` marker. 3. `rm -rf .local.backup`. 4. Write intent `None`. 5. 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: ```rust use std::{future::Future, path::Path, pin::Pin}; pub type UnpackFuture<'a> = Pin> + 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: ```rust pub fn start_peer( game_dir: impl Into, tx_notify_ui: UnboundedSender, peer_game_db: Arc>, unpacker: Arc, catalog: Arc>>, ) -> eyre::Result ``` 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>>`. 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_library` and the optimized rescan: a game root whose ID is not in the catalog is skipped — no `GameSummary` emitted, no entry in the library index, no contribution to `library_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_available` and the file-stream handlers (see "Operation table" below): refuse all serve requests for non-catalog IDs. - `InstallGame` / `UpdateGame` / `UninstallGame` commands 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`: filter `file_descs` to exclude `desc.is_version_ini()` entries. Non-version.ini files are pre-allocated as today. - `download_game_files`: - Partition `game_file_descs` into version.ini and non-version.ini. - Insert `Downloading` into the operation table; rename existing `version.ini` to `.version.ini.discarded` if 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 `Downloading` from the operation table. - `download_chunk` for version.ini paths: writes to memory rather than disk. - New `PeerCommand` variant: `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). Plus `UpdateGame { id }` and `UninstallGame { 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 by `handle_get_game`) — refuse `GetGame` for a game during any active operation, when `version.ini` is absent, or when the ID is not in the catalog. - File-stream handlers (`handle_file_data_request`, `handle_file_chunk_request` in `services/stream.rs`) — refuse `GetGameFileData` and `GetGameFileChunk` requests when the ID has an active operation, when `version.ini` is 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 on `local_download_available` having gated the original manifest fetch. Additional rule: `local/`-relative paths are rejected unconditionally — manifests never contain `local/` 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 `LibraryDelta` while 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 aggregating `GameSummary` values across peers, `Game.peer_count` counts only peers whose advertised `availability == Ready`. `LocalOnly` peers do not contribute to `peer_count` and do not flip the aggregated `downloaded` flag. - `peers_with_game` and `peers_with_latest_version`: filter to peers with `availability == Ready` before returning addresses. A peer advertising `LocalOnly` must never be selected as a download source — the download will fail when the remote refuses `GetGame`. - `get_latest_version_for_game`: ignore `LocalOnly` entries when computing latest version. A version we can't actually transfer should not influence "is an update available?" decisions. ### State model implementation - `local_games.rs` switches `downloaded` from `.eti exists` to `version.ini exists` (exact `/version.ini`, regular file). - `local_download_available` gates on `version.ini` + no active operation + catalog-known ID. - `build_game_summary` reads `eti_version` from `version.ini` whenever `downloaded`, not only when `installed`. The current "Game is not downloaded" error path is removed: a game with `local/` present but no `version.ini` returns a `GameSummary` with `downloaded = false`, `installed = true`, `availability = LocalOnly`. - `update_index_for_game` no longer short-circuits on missing `.eti`. The "no game here" decision is now: skip iff `version.ini` is absent **and** `local/` is not a directory **and** no `*.eti` is present at root level. Otherwise build a summary. - Manifest building (`scan_game_descriptions` / `should_skip_root_entry`) continues to skip `local/`; `local/` contents are user-owned and never enter manifests, fingerprints, or transfer paths. - Fingerprint still tracks `version.ini` mtime (already there); `.eti` mtime 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 == "/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 on `game_dir` root 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) 1. Stat `version.ini` (existence + mtime), check `local/` is-a-directory, stat any `*.eti` files at root level. 2. Compare to cached fingerprint. 3. 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 inside `handle_download_finished` — unpack now happens inside the peer's install transaction. `handle_download_finished` becomes a plain `game-download-finished` event 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 touch `local/`, so there's nothing to back up). - Remove `update_game_installation_state`'s "downloaded required for installed" coupling: `installed` is now `local/` is-a-directory, independent of `downloaded`. - Provide the `Unpacker` impl that shells out to the unrar sidecar; pass it to `start_peer`. Populate the catalog set from `load_bundled_game_db` and pass it to `start_peer` too. - Rename `games_in_download: HashSet` to a generic in-progress operation set mirroring the peer's operation table (`HashMap` 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 sending `GetGame`: - `version.ini` absent → send `PeerCommand::GetGame { id }` (fetch first; peer auto-fires `InstallGame` on download commit). - `version.ini` present, `local/` absent → send `PeerCommand::InstallGame { id }` directly (no re-download). - `version.ini` present, `local/` present → no-op (already installed); the UI's "Open" affordance handles launch. - `update_game(id)` becomes a thin wrapper that sends `PeerCommand::GetGame { id }`. The fresh download triggers the peer's auto-install, which does a transactional update (rename `local → .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) and `InstallStatus.Uninstalling`. - New install lifecycle events (peer-driven, separate from download and unpack): - `game-install-begin` — intent flipped to `Installing` / `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-finished` fires when the `version.ini` rename commits (which happens inside `download_game_files`, before the auto-fired `InstallGame`). - Existing `game-unpack-finished` fires only on true transactional install success (peer-driven). It does NOT fire on extract failure or commit-rename failure — those route to `game-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_version` for games that are downloaded but not installed. - Render the `LocalOnly` availability 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_owned` layout 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.ini` last via atomic rename. - Re-download renames old `version.ini` to `.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_storage` skips `version.ini` entries. - Version.ini chunk routing to memory: buffer accumulates correctly across out-of-order chunk arrivals. - Multi-`.eti` games: 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.tmp` and `.version.ini.discarded` swept unconditionally on startup. - JSON missing / corrupted / wrong schema_version → fall back to FS observation. - `.local.*` dir without `.lanspread_owned` marker → 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 no `LibraryDelta` change. ### Unit — serve gating - `GetGame` is refused when an operation is active for the ID. - `GetGameFileData` and `GetGameFileChunk` are 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.ini` is absent (covers manually-deleted-sentinel case). - File-chunk handler rejects any `relative_path` that resolves under `local/`. ### Unit — aggregation - `get_all_games` counts only `Ready` peers toward `peer_count`; a single `LocalOnly` peer reports `peer_count = 0` for a game no other peer carries. - `peers_with_game` and `peers_with_latest_version` exclude `LocalOnly` peers. - `get_latest_version_for_game` ignores `LocalOnly` entries. ### Unit — installed-only state - A game root with `local/` present and `version.ini` absent produces a summary with `downloaded = false`, `installed = true`, `availability = LocalOnly`. - Same root, after the user copies in `version.ini`, flips to `Ready` on the next rescan. ### Manual - New game appears via file-manager `mv` (atomic) and `cp` (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.ini` manually while game is installed; verify `LocalOnly` availability and that UI still shows installed. ### Verify - `just fmt` - `just clippy` - `just build` ## Assumptions - Uninstall removes only `local/`; the archives and `version.ini` stay 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_owned` marker. `.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.ini` is not restored because `.eti` bytes 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. For `cp`, the fallback scan reconciles once `version.ini` lands. - 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.