# Changelog All notable changes to Oneirodex are documented in this file. Historical entries keep the former public string GameTheca where the first rewrite left it; do not restore git history. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [1.1.9] — 2026-10-15 ### Improved - Unify member and admin navigation, controls, tables, filters and shared icon/theme primitives across the React applications. - Complete the static React and native FastAPI runtime transition, removing Jinja-rendered application pages while retaining existing session compatibility. - Scrub Docker build context of user BIOS staging output so firmware and key files cannot enter shipped image layers. ### Security - Authenticate forwarded client and origin headers against `TRUSTED_PROXY_CIDRS` before applying `TRUSTED_PROXIES` hop parsing. Supported launchers disable Uvicorn's implicit rewriting. Reverse-proxy deployments must configure the immediate proxy source IP/CIDR. - Bind desktop path reveals to an accessible game folder; require admin authority for unmatched and unbound scan-job paths. - Revalidate qBittorrent hardlink pipeline redirects and outbound destinations through the shared safe-request policy. ## [1.1.8] — 2026-10-08 ### Improved - Bring Auto, Manual, and Jobs together on one compact Scans page, with job actions beside the table and no Scan shortcuts in the thin top bar. - Move RetroAchievements connection settings under Artwork & Secondary and remove duplicate Metadata & Art actions. - Give native, standalone, and Docker startup a shared shaded Onei kaiju adventure that ends with ready or recovery art before server logs. ## [1.1.7] — 2026-10-08 ### Fixed - Invoke the EmulatorJS fetcher through Bash in Docker builds so checkout file permissions do not prevent image creation. ## [1.1.6] — 2026-10-08 ### Added - Search additional metadata providers for unmatched games and normalize storefront IDs from folder names before matching. - Include the EmulatorJS browser player in the standard image, with an admin setting to turn it off. ### Improved - Identify game updates and patches from folder names and offer an admin action to place them under the root game folder. - Refresh the built-in themes, avatars, and themed navigation icons; refresh README screenshots and how-to media from the populated capture instance. - Redesign scan-management actions and comparisons, merge scan jobs into Scan, and address the annotated admin and member layout issues. ### Fixed - Fetch the pinned EmulatorJS release using its published 7z asset so Docker builds can include the browser player. ## [1.1.5] — 2026-10-08 ### Added - Configure RetroAchievements for the household in Admin → Integrations, including Docker and Unraid installs where operators cannot edit environment variables. ### Improved - Give member and admin menus a consistent appearance and dismiss them when clicking outside. Reposition page actions and dropdowns, keep dashboard widgets within the page, and align admin panels around a shared layout. - Refine library artwork sizing, browse rows, Systems and Ways to Play spacing, Help typography, and sidebar branding. VR uses available game artwork, and trailer playback sits inside a CRT bezel. ### Fixed - Remove the unused sidebar footer box and obsolete theme selector. Prevent dashboard widgets from obscuring status text and prevent game art from stretching into the wrong aspect ratio. ## [1.1.4] — 2026-10-07 ### Fixed - Build static SPA assets on the native Docker build platform so arm64 image builds do not run npm installation under QEMU. ## [1.1.3] — 2026-10-07 ### Added - **Onei expedition terminal.** Native installers and application startup now share Onei’s full-body field-scout art, quest-log progress, and retro-future console styling. - **Bulk local firmware install.** Admin → Emulation can upload the matching firmware files from a folder on the operator’s device, with progress and duplicate-name protection. ### Security - **Deployment and chat storage safeguards.** Secure authentication cookies are the default across deployment templates, retained chat attachments have bounded storage, and first-admin setup rechecks state after acquiring its mutation lock. ## [1.1.2] — 2026-10-06 ### Fixed - Stream remote artwork responses with a 25 MiB cap so oversized provider downloads are rejected before they consume unbounded memory or are written to the library. - Validate stable release tags against `VERSION` before publishing any image, and allow a Docker Hub overview permission failure to leave the independent Unraid CA template sync runnable. ## [1.1.1] — 2026-10-06 ### Fixed - **Admin library and scan workflows.** Restored the authenticated server-folder picker, centered the first-library state, moved queue and invite actions into the top bar, and tightened Library & Matching, installed themes, and whitelist layouts. - **Admin Help and loading feedback.** Admin Help now uses the member Help page; loading slates display an animated progress indicator. Removed obsolete SMTP / IGDB moved-page banners. - **Unraid release discovery.** Keep the CA template synchronized with each stable release while publishing versioned and latest Docker images. - **Persistent artwork paths.** Docker and Unraid now derive `IMAGE_SAVE_PATH` from the mounted library directory, so the dashboard no longer reports the image path missing and generated/downloaded art stays on appdata. The CA template also exposes the optional LAN AI-art settings. - **Member VR navigation.** VR filters now use the shared thin header navigation, keeping the page controls visible without adding another toolbar to the content. ## [1.1.0] — 2026-10-06 ### Added - **First-run onboarding and library creation.** Setup now guides admins through optional API connections and creation of the first library; library creation is restored as a supported flow. - **Admin experience refresh.** Image queue, Library & Matching, Play & Emulation, scan management, and newsletter pages were rebuilt to match the current interface and remain usable without SMTP. Discover cards and discount/news artwork now follow their intended layouts; free games are excluded from discount rows. ### Security - **Archive and remote-feed limits.** ROM extraction has a shared 64 GiB expansion ceiling and cleans temporary files on failure; remote metadata feeds are streamed with response and record limits. - **Release publishing guard.** Manual Docker publishing is restricted to `main`; `latest` is published only from `main` or a version tag. ### Fixed - **Unraid setup defaults.** The CA template supplies the persistent library path; optional ClamAV settings are documented without enabling scanning unless its sidecar is configured. ### Fixed - **Admin protection:** deleting the only active admin is refused, including when that admin attempts to delete their own account. - **Database time zone:** PostgreSQL connections now use UTC across Flask, CLI, and migration entry points; embedded PostgreSQL servers also start in UTC. Existing timestamps already shifted by older local-time settings are left as stored and are not migrated. - **Registration, password, and SSO:** mixed-case confirmation links resolve correctly, registration links use the configured or trusted public origin, password changes require the current password, and edited OIDC settings replace the registered client. - **Live updates and shutdown:** SSE subscribers use async queues without consuming Uvicorn's worker executor; ASGI lifespan shutdown stops workers, with Uvicorn's graceful wait controlled by `UVICORN_GRACEFUL_TIMEOUT` (default 5 seconds). - **Rate limits, newsletters, and event logs:** expired login buckets are pruned and capped, newsletter delivery tests and handling use the boolean email API, and affected audit events pass metadata by keyword. - **Installers and plugins:** Linux/macOS/Windows installers preserve an existing `SECRET_KEY` and back up `.env` even with force; Linux DB credentials stay synchronized and `pg_hba.conf` rules are idempotent. Save-path plugin status is isolated from store-notification failures. - **Cheats and scans:** child accounts can read but cannot mutate `.cht` cheats; update-scan errors no longer expose exception text, and concurrent scan starts yield only to an older busy row. - **Email copy:** password reset email and confirmation page use plain Oneirodex wording. ### Security - **CI permissions and dependencies:** test and desktop workflows default to `contents: read`; third-party actions are pinned to verified commit SHAs. Docker image publishing now depends on the reusable CI workflow. ### Changed - **Time and shutdown compatibility:** already-shifted database rows are intentionally not rewritten; new PostgreSQL timestamps use UTC. `UVICORN_GRACEFUL_TIMEOUT` sets the server's graceful-shutdown wait and defaults to 5 seconds. - **GitHub owner update:** source, support, installer, and published links now use `cephyrixzyth/oneirodex`. ### Changed - **Docker Hub image is `cephyrixzyth/oneirodex`.** The Unraid Community Apps template, `docker-compose.single.yml`, `ca_profile.xml`, env examples, and runbooks now use the publishable image path. Set the repository variable `DOCKERHUB_USERNAME` to `cephyrixzyth` (and the `DOCKERHUB_TOKEN` secret) for the publish workflow. GitHub links and `SUPPORT_GITHUB_REPO` use the current repository owner. ## [1.0.1] — 2026-10-03 ### Added - **Unraid Community Apps profile.** `ca_profile.xml` at the repository root (a non-empty `` with repository overview and support information, plus icon and web page), with a test that keeps it and the container template valid. ### Changed - **Desktop releases.** `desktop-build.yml` gained a `release` job: a `v*` tag push, or a manual run with `release_tag`, attaches all six installers (full + thin × Windows, macOS, Linux) to that GitHub release. Until now the installers were only workflow artifacts and no workflow touched a release. - **Admin navigation.** Settings and Integrations unfold in the left rail into four icon sub-sections each; the pages inside a sub-section are icon buttons in the top bar. Every admin rail sub-link now has an icon (24 new shared glyphs). The Integrations hub is rebuilt as the same grouped rows as Settings, Arr moved to Integrations → Acquisition, the Support inbox lives only under Users, and the command palette is built from the same lists. Nine hand-built admin pages now use the shared `Page` shell and one panel class. See [admin/navigation.md](docs/admin/navigation.md). - **Admin widget boards (Dashboard, Ops):** edge and corner resize, S/M/L/Full presets, keyboard move/resize, pin to lock a widget in place, hide, and an **Add widget** menu; metric tiles grow to fit wrapped text. New optional Dashboard widgets: CPU, Memory, Unmatched folders, Active scan jobs. ### Added - **Scan and identify fixes from a real library export.** Standalone update/patch folders (` update 1.0.7.0 - 1.0.8.0`, `Update_from_vX_to_vY`, `Unofficial Patch`) are no longer imported as games: one was imported *as* the game (so the real folder was flagged a duplicate of its own update) and `Alan Wake 2 update …` was imported as Alan Wake; they now go to Unmatched as update packages (`(Incl. Update N)` full releases still import). **A hand-picked Steam match now brings Steam's content** — genres, developer, publisher, release date, modes, cover and system requirements — where it used to bring the name only; IGDB-identified games that have a Steam App ID get the same full mapping. Store-title matching survives how stores print titles (™/®, apostrophes, colons, accents, `&`, a leading *The*), so `Assassins Creed Rogue` finds `Assassin's Creed Rogue` (still exact-after-normalisation and unique-hit only: sequels and editions do not match). `fetch_steam_data` no longer attaches the *first* Steam hit when nothing matches exactly (it gave Baldur's Gate 2 the data and App ID of Dark Alliance). The Steam fetch retries rate limits and says why it failed. GOG, Epic and itch search hits keep the developer, publisher, genres and release date their endpoints return, so the built-in sources can fill the core fields without an API key. - **Free-ROM fetcher**: `archive: zip` and `sha256:` manifest fields; Atari Lynx and Virtual Boy demos from Zophar's Public Domain list; the README reviews each ROM source (and why Internet Arcade and MyAbandonware are excluded). - **Single-container install with the database built in.** With no `DATABASE_URL`, the image starts its own PostgreSQL 17 (127.0.0.1 only, scram, generated password and `SECRET_KEY`) inside `/config` and stops it cleanly after the app; the Compose stack with a separate `db` service is unchanged. New `docker-compose.single.yml`, an Unraid Community Apps template (`unraid/oneirodex.xml`), a tag-triggered multi-arch publish workflow (GHCR, plus Docker Hub when credentials exist) and a [runbook](docs/runbooks/unraid-community-apps.md) with the submission checklist. Written, not yet built or run. - **Matcher triage script** (`scripts/match_triage.py`). Replays the scan's name cleaning over an export of the Unmatched list (text or CSV) and buckets each folder by what is still left in the cleaned label — unbalanced brackets, versions, update/build notes, DLC notes, language tags, release-group tails, platform words — so the biggest cleaning gap is a count, not a guess. Offline and read-only (no database, no network); `--groups` strips release groups the database would. A name that comes out clean and still did not match is a catalogue or scoring problem instead. - **Bring a library in from another launcher, and sync every store at once.** Store sync now has a launcher import framework (`utils/launcher_imports.py`): one `LauncherFormat` per launcher (name, the `store` its rows are recorded under, a sniffer and a parser), so a launcher with an export is one entry, not a new pipeline. Playnite, **Heroic** (`legendary_library.json`, `gog_library.json`, `nile_library.json`, sideload), **Lutris** (`lutris -l -j`), **GOG Galaxy** (community JSON exports) and a generic CSV are in. `POST /api/imports/launcher` (file or JSON, optional `launcher` hint, otherwise detected) records ownership only — never downloads — and goes through `upsert_owned_title`: re-importing refreshes names and never overwrites a reviewed match, and a name alone never auto-links (unmatched titles land in the review queue). `GET /api/imports/launchers` lists the formats; the four new sources appear as import-only providers. **Sync all stores** (`POST /api/ownership/sync-all`, `utils/store_sync_all.py`) runs each linked store as its own recorded job, so one store failing is reported against that store and does not stop the rest; unlinked or not-opted-in stores come back as `skipped` with a reason. The store list gets a **Sync all stores** button and per-launcher import labels. Plan and the path to more launchers: [`docs/dev/launcher-import-plan.md`](docs/dev/launcher-import-plan.md). - **Store connections you can see and repair** (LIB-02 / LIB-04). Members link stores from one shared card set (first run and Settings): each card says what is linked, which sign-in it uses, when it last synced and why it failed, with *Sync now* and *Stop* per store. Syncs run as per-member jobs (`store_sync_jobs`, Alembic `a7c1d9e2f3b4`) with one running slot per store, cooperative cancel and stale jobs marked interrupted. **Ownership review** lets a member accept, change or undo a match (reversible decisions, Alembic `d4e5f6a7b8c9`), the catalog gains owned / store / review filters, and Admin → **Store connections** shows every member's state without ever showing credentials, account IDs or title lists. - **Standalone install, preview** (ADR 0011, DESK-04). `python -m oneirodex_standalone --pg-home ` runs the normal server against a PostgreSQL 17 it starts in your user account: no admin rights, no service, `127.0.0.1` only, generated secrets in an owner-only `standalone.json`, crash restart and clean stop. Everything the server writes goes to the data folder (`ONEIRODEX_LIBRARY_DIR`), so the install folder can be read-only. Proven end to end on Linux and on a real Windows 11 machine; on Windows the database and server end with the launcher even when it is killed outright (a job object), and recover on the next start. macOS is not called supported until it runs on a real machine. - **Move a standalone install to a household server** (DESK-05). `python -m oneirodex_standalone export` writes a move folder and prints its fingerprint; `import --expect ` restores it into an empty server database, copies members' files, rewrites machine paths (Windows to Linux included), re-encrypts encrypted saves for the server's key and verifies the result row for row. Rollback is dropping the target. [Runbook](docs/runbooks/standalone-move.md). - **Surprise me on Discover** (INSP-4b, H-I). One title drawn from the **top twelve** of your own taste ranking — not the whole library, which is a coin toss that mostly lands on things nobody wanted, and not the single best match, which is the same answer every time. **Another** moves on without repeating what you were just shown. Your strongest genres, read from the stored taste profile, sit beside it as chips to steer the draw. Anything you have favourited, played or marked **Won’t play** is never offered. Nothing is drawn until you ask, and pressing the button is **not** read as a taste signal — treating it as one would teach the profile that you like whatever the dice showed. A member with no profile yet still gets a pick, labelled honestly as *well regarded* rather than *picked from what you play*. `GET /api/discover/surprise`. - **Smart collections** (INSP-29, H-I). A saved filter can now tick **Show on Collections** and appear as a shelf beside the hand-made ones — held together by its question rather than by a list of games, so it keeps up as the library grows. It is the *same row* as the saved filter (`SavedFilter.is_collection`), not a second kind of thing to build and keep in step. Opening one links into the catalog with the tree applied, which needed `LibraryApp` to learn `filter_tree` as a URL param — its query parsing is an allow-list, so without that the link would have opened on an unfiltered library and looked like the collection held everything. The section renders nothing at all until a member makes one. - **A Build tile on Admin → Dashboard and Admin → Ops.** After a deploy there was no way to confirm from inside the product that the running code was the code that shipped: the version was a literal in `__init__.py` while a tracked `VERSION` file held the same string and nothing read it, the Alembic revision was never read back (only the table’s *existence* was checked), and no commit or build time reached the process at all — the image tag was the only build identity and it is not readable from inside the container. The tile now answers it: version, the commit and date the image was built from, the schema revision **compared against the one this code expects**, and the themes generator. The comparison is the point — a revision on its own is a hex string nobody can check by eye, while *migration pending* is an answer. `VERSION` is now the single source for the version (a test asserts the two agree), `scripts/ops/unraid_ship_update_now.py` stamps the deploying commit through a new Dockerfile `ARG`, and **an unstamped image reads *no build stamp* rather than inventing one**. Every field degrades to `null` without taking the Ops poll down, the contract the GPU tile already set. - **A filter builder in the catalog** (INSP-3, H-I — the UI half). Under the Signals chips, **Build a filter** opens a small editor for the nested tree the backend already compiles: rows grouped as **All of** / **Any of** / **None of**, nesting allowed, with a live count of how many titles match and **Save** to keep it under a name. Applying one is a *conjunct beside* the chips, never a replacement. The field list is served from the backend registry rather than copied into the SPA, so the builder can only ever offer what the chip row already offers and cannot go stale. A flag row is **Yes / No**, not a checkbox, because a false flag means *not this* on the server and an unticked box would read as *ignore this*. When the server refuses a tree it names the offending node, and the builder outlines **that row** instead of putting "invalid filter" above a form with fifteen of them. - **Filters that can say "or"** (INSP-3, H-I). The chip row can only ever mean *and* — tick three chips and you get titles matching all three — so a question like *VR titles that are behind, or anything imported this fortnight* had no expression at all. Filters are now a nested AND / OR / NOT tree (`utils/filter_tree.py`), and a member can **name and save one**: `GET|POST /api/filters/saved`, `PUT|DELETE /api/filters/saved/`, `POST /api/filters/preview` for a live count as the filter is built, and `GET /api/filters/fields` for what a builder may offer. Saved rows are per-member (Alembic `c3d4e5f6a7b8`) — one person narrowing their own view does not change anyone else’s — and carry `is_collection`, which is what will make the same row a smart collection. **A tree can only name the fields the chips already offer**, and both faces compile through one registry, so the tree cannot reach a column the chip row cannot and the two cannot drift apart; shape is bounded at 6 deep and 60 parts. A tree we will not run comes back as a 400 naming the offending part rather than being quietly dropped, because ignoring a filter hands back the whole library and lets it read as the answer. Negation folds away SQL’s third value: *not behind* includes the titles the field was never set on, which plain `NOT (x IN (...))` silently excludes. - **BYO VR runtime runbook** (INSP-41 / VR-XR-1…4, H3b). `docs/runbooks/vr-byo-runtime.md`: the four layers a headset seat needs around Oneirodex — OpenXR runtime, OpenVR adapter, streamer, tweaks — which fills each on Windows and Linux, the wired USB-NCM link for a Quest-class headset (0.7 ms RTT / ~756 Mbps measured) step by step, and upscaler / foveation as documentation only. Cites the Linux VR wiki instead of restating it. Oneirodex ships, installs and injects none of it; docs index, FAQ and README rows added. - **An arcade play path, opt-in** (INSP-43, H4b). Admin → Emulators can now pick an arcade core from `ARCADE_COMPANION_CORES` — deliberately *not* through `platform_emulator_mapping`, which stays empty because ARCADE is a locked, no-WASM platform and a mapped core is what would make a browser session look possible — and **choosing a core is what lifts the catalog-only lock**: until then the arcade shelf still reads *catalogue*, because promising Play with no core chosen would be a lie (every other locked platform stays locked). `Game.input_family` (`joystick` / `spinner` / `lightgun` / `trackball`, Alembic `b2c3d4e5f6a7`) says how the cabinet was driven; `PATCH /api/games//input_family` (librarian, `validate_body`) sets it, `game_card_flags` carries it, and the desktop companion appends the matching RetroArch input remap on launch. No ROMs, no MAME sets, no file paths. - **A BYO notification bus** (INSP-6, H4d). `utils/notification_bus.py` fans the alerts you already get in-app out to an **Apprise API** server (`NOTIFY_APPRISE_URLS`) and/or an **ntfy** topic (`NOTIFY_NTFY_URL`) through `safe_request`: one event per admin alert, riding the existing `admin_notify_*` flags; social kinds (friend requests, mentions, DMs, free games) only when `NOTIFY_SOCIAL_TO_BUS=true` and the member's own preference allows. `POST /api/admin/notify-bus/test` sends one test event and says what was delivered. Plugins `notify.apprise` / `notify.ntfy` and an Integrations row. Nothing bundled; a dead endpoint is logged and never breaks the in-app notice. - **A headset record per title** (INSP-40, H3a). `GameVrProfile` — one row per (game, kind): `native` / `injector` / `flat`, the runtime side (`openxr` / `openvr`), the community **profile page** URL (http(s) only; files and paths are refused) and a note. `vr_compat` derives from the rows when a librarian did not set it, the `/vr` hub and its filters admit profile-backed titles, and the details VR line adds the runtime and a *Community profile page* link; librarians get *Add / Edit headset record*. `GET|PUT|DELETE /api/games//vr_profiles[/]` with `validate_body`. Alembic `a1b2c3d4e5f6`. A record and a link — never a shim. - **Xbox and PlayStation ownership registers — unofficial, opt-in, register-only** (INSP-42, H4a). `store_ownership_xbox.py` (xbox-webapi title history) and `store_ownership_psn.py` (psnawp title stats) on the per-store pattern: connect / disconnect / sync / CSV routes with validated bodies, IDs and names into the register, a 401 fails closed with one sentence. CSV import always works; **live sync is off by default** — `ENABLE_UNOFFICIAL_STORE_SYNC=xbox,psn` opts a store in (decision G5), and the optional clients live in the new `requirements-optional.txt`, imported lazily, so the plugin and Integrations rows read *disabled* / *available* / *configured* truthfully. `store_sync_mode()` promotes the two to `live` only while opted in and the poller enrols them the same way; the ownership summary carries `unofficial` / `opt_in` per store so the page says *snapshot* honestly. Ownership page gains the two cards. Nothing downloads; Nintendo stays CSV. - **Assists links in the companion overlay** (INSP-45 under the cheats ruling's Option A, H4f). An assist pack can carry `overlay_links` — maps, guides, clips, a wiki — validated as **http(s) pages only** (`@validate_body`; a `file:` URL or unknown kind is refused), and every PC title gets a PCGamingWiki deep link even with no pack. The stay-open Friends window shows them for the game on screen: the desktop companion passes the last launched title as `?game=`. Links beside the game — **no memory access, no injection, nothing read from the running process**. `get_json` baseline 100 → 99. - **Mod compat gate, dependencies, update discovery, download integrity** (INSP-38/39, H2e). A row can `requires` other rows (the companion stages them first, whatever the load order); `GET …/mods` reports `loader_conflicts` — enabled rows whose loader disagrees with the pack default — the panel says *Loader mismatch* and the companion refuses to apply that set until the rows or the default agree. The catalogue drawer marks hits already tracked (by source URL) with **update: vX** when the registry is newer and offers *Mark updated*; Thunderstore hits carry `dependencies` and *Add with N needed* files them in one action. The companion refuses an empty or `Content-Length`-short download before writing it and re-stages on the next apply. - **Save location on PC details pages** (INSP-1 PC half, H4e). `utils/save_paths.py` wraps the community save-location manifest — one keyless fetch a day, a compact JSON index built beside it so no request parses the YAML — and answers by Steam id, then exact name, PC shelves only; `None` when silent. The details facts grid gains **Save location** with one templated path per OS and, with the companion online, **Open save folder**: the companion expands ``-class placeholders for its own PC and refuses when one is unknown there. Plugin `compat.save_paths`, Integrations row, `ENABLE_SAVE_PATHS`. The folder, not a backup — nothing is synced or copied. - **Mod profiles and a shareable code** (INSP-37, H2d). A game's pack holds named sets of tracked mods; **Activate** is the one-click enable set — the profile's rows on, every other row off — which is what the companion's next *Apply mods* stages (its result now names the profile). **Copy code** gives `od-mod:` + base64url JSON (name, default loader, each mod's name / version / loader / source page — never a file); importing it on another game matches what that game already tracks (by id, then source URL) and lists the rest as *missing* instead of inventing rows. `utils/game_mod_profiles.py`, six routes under `/api/games//mods/profiles` (writes librarian, export readable by members), `ModProfiles.tsx` under the Mods panel. JSON pack, no migration. - **GameBanana and Nexus Mods in the catalogue drawer** (INSP-22, H2c). GameBanana (keyless, rate-limited: the game's mod feed, filtered by name) and Nexus Mods (**browse only** — trending then latest for the game's domain, behind a personal `NEXUS_API_KEY` the operator places; its terms forbid third-party download automation and nothing here downloads). Without the key the Nexus tab says so instead of returning nothing; plugin and Integrations rows read *available* until the key exists. CurseForge stays *declined*. - **A GPU tile on Admin → Ops** (INSP-44 / MISS-OPS-3, H4c). `system_stats.get_gpu_usage()` reads NVIDIA's own management library when the optional `pynvml` package and a device exist, else a **BYO sensor reader** on a household PC — LibreHardwareMonitor / HWiNFO-class JSON at `TELEMETRY_READER_URL`, polled read-only through `safe_request` with a generous parser — and returns `None` when neither answers; the tile then reads *n/a* with *no NVML or reader* as the hint, otherwise the first GPU's load, name, VRAM, temperature and source. **Nothing bundled, no sensor tool, no kernel driver.** The touched `system_stats` functions moved `print()` → `logger` (print baseline 591 → 587). - **A Mods panel on the details page, with a read-only catalogue browse** (INSP-22, H2b). Everyone sees the tracked mod list (name, version, loader, source); a librarian gets the pack's default loader, an add form and **Browse catalogue** — Thunderstore (BepInEx / MelonLoader communities) and Modrinth (Minecraft), both keyless, through `utils/mod_catalog/` and `GET /api/games//mods/catalog?source=`. A hit is a name, a version, the loader it needs and the registry page; *Add to list* records that page as `source_url`. The answer is `ok` with hits or `unavailable` with `hits: null` and a note — never a silent `[]`. Plugins `mods.catalog.*` and Integrations rows, with CurseForge listed as *declined* on its API terms. `ENABLE_MOD_CATALOG` (default on). Nothing downloads. - **Mods say which loader they need** (INSP-36, H2a). Each tracked mod row carries `loader` (`bepinex`, `melonloader`, `smapi`, `lovely`, `forge`, `fabric`, `quilt`, `neoforge`, `manual`, `none` suggested; any slug kept) and the pack a `default_loader` rows inherit — `PATCH /api/games//mods/pack`, or on the bulk `PUT`. The mods API bodies are now validated (`422` on unknown fields; three `get_json` sites retired, baseline 103 → 100). The desktop companion reads the loader into its apply result — *Needs BepInEx installed — not managed here* — and installs nothing. No migration: the pack is JSON. - **Anti-cheat on the details page** (INSP-35, H1e — and gate 2 for the cheats policy's Option B). A PC title the community anti-cheat list knows gets an *Anti-cheat* fact: Supported / Running / Planned / Broken / Denied, the anti-cheat's name, and a link to the report page, worded as *community reports* never a guarantee. `utils/anticheat_compat.py` fetches the public list once a day (keyless, through `safe_request`) into `static/library/anticheat/` and answers from that cache only — a details page never waits on the network, and a title the list does not know shows nothing. `game_card_flags` emits `anticheat`; plugin `compat.anticheat` and an Integrations row say whether the first fetch has landed. `ENABLE_ANTICHEAT_COMPAT` (default on). - **DAT repair preview, a dry run** (INSP-24, H1f). Admin → ROM reference sets gains **Preview repairs**: for one platform (one region's set or all of them) it sorts every owned file against the set — hash matches under another name (rename candidates, with the set's name), names that match under another hash (bad dump / revision / header), clone-named files with whether the parent is owned, and files the set never listed — plus counts of verified and not-yet-hashed files. `POST /api/reference-sets/repair-preview` (admin, `validate_body`, `api_ok`) in `utils/set_repair.py`; a `mame` set never proposes renames (its files are named by machine short name). Report only: nothing is renamed, moved or marked. - **An agreement floor before auto-import** (H1d, the pass-8 finding). `IDENTIFY_AGREEMENT_MIN` (default 1 — today's behaviour) says how many sources must name a title before the scan creates a game: IGDB counts one, each agreeing catalogue row one more. At 2, a title only IGDB named goes to Review with `insufficient_agreement` and a proposal sidecar that says how many agreed, instead of importing. One decision point in `scan_identify`, beside the existing catalogue-disagreement branch; the scan tests are the net. - **Keyless hash identify for console ROMs** (INSP-31, H1a). After an IGDB miss *and* a local-DAT miss, the file's own CRC/MD5/SHA1 (outer, then inner dumps) are asked of a community hash-lookup service (`HASH_IDENTIFY_BASE_URL`, default hasheous.org; no account). A named answer creates the same custom-range Game a DAT hit would, with a summary naming the service, the digest that matched and the proxied IGDB id; every miss, error or unparseable body is a plain miss. Identity only — never enrichment, never a download; every call through the SSRF-guarded client; off under pytest. New switch under Integrations → Metadata (`hash_identify`, default on), plugin + inventory rows. `GENERATOR_VERSION` 40 → 41 because the switch's handler lives on the themes volume. - **One game, one ROM** (INSP-5 / 1G1R, H1c). Reference-set entries carry the DAT's `cloneof` parent (`ReferenceSetEntry.parent_name`, Alembic `f1a2b3c4d5e6`); set completion counts parents only, so a MAME set with forty clones of one game counts it once; when a DAT lists a title for several regions the kept row is the preferred region (USA → EUR → JPN …), not the first listed. The Unmatched *Duplicate* glance says *Reference DAT lists this dump as a clone of a game already in the library* (`clone_of`) when that is what happened — advisory only, nothing is marked or deleted on it. - **TOSEC and MAME reference sets** (INSP-34, H1b). `tosec` and `mame` join `nointro` / `redump` as DAT sources, so home-computer and arcade shelves get set completion and hash identify. TOSEC's `(1991)(Publisher)(EU)[cr Group]` groups peel to the title with the scanner's own rules; MAME `-listxml` machines and softlist `software` entries are titled by `` (the set id stays the attribute), and nested `//` hashes are read. No new platform enum — `ARCADE` is the platform. Same replace-on-upload, same ambiguity rule (a CRC shared by clone sets never auto-creates). - **Ways to play → In a headset** (VR-L4b / VR-FILTER, the UI half of rider R3). Three rows — *Plays in VR*, *VR via community profile*, *Plays flat* — each a deep link and nothing more: a community profile is named and linked, never shipped, installed or pointed at as a file. The details page carries the same line under the play row; the `/vr` hub now admits librarian-stored `native_vr` / `injector_profile` titles (a stored `flat` removes one), filters with `?vr_compat=`, and marks community-profile cards; the VR badge says which. `controllers-and-vr.md` gains a PSVR2-on-PC walk-through and the README's VR line says what the headset path actually is. - **Ops → Companions has a device list** (TC-4). `GET /admin/api/ops/devices` returns every companion, thin seat or browser shell that has sent a heartbeat — kind, name, member, version, last seen — with `online` judged on the same 3-minute window as the tile, and the Ops page renders it under the by-kind table. FAQ, troubleshooting and the thin-client guide say where a seat appears and why it might not. - **`vr_compat` beside `is_vr`** (rider R3). Every card and details payload says how a title plays in VR: `native_vr` (derived from the VR perspective unless a librarian set otherwise), `injector_profile`, `flat`, or unknown — never a guessed `flat`. Librarians set it with `PATCH /api/games//vr_compat` (`VrCompatBody`, `null` clears); Library filters take `?vr_compat=`, where `native_vr` also admits derived VR so the filter and the badge agree. Alembic `e5f6a7b8c9d0`. Catalogue data only — nothing here ships, installs or points at a shim. ### Fixed - **"Won’t play" now means something** (INSP-4, with INSP-21 folded in). The status has been recordable on every tile for a long time and **nothing read it** — a member could say no to a game and Discover would keep offering it, which looks like not listening. Titles marked Won’t play are now excluded from the personal recommendation rows, per member. Two smaller holes closed with it: the multi-select bar could set four of the five statuses and **silently refused the fifth** (it was missing from the batch allow-list), and the vocabulary itself was spelled out as literals in four backend modules and two SPA files, which is how they drifted apart — it now has one definition in `utils/play_status.py`. Deliberately narrow: one "won’t play" suppresses **that title only** and is not read as evidence against its genre, because letting a single click reweigh every facet it touches would quietly narrow the shelves. - **Save states never reached the server, and the ones that did could not be loaded** — the other half of the same report (*"the badge doesnt update and loading manually does not load the save state"*), reproduced in the browser against the live box. Two faults. **One:** the play room is a static file under `/static`, so its `` is never rendered and the CSRF cookie is `HttpOnly` — the room had no token to send, and every upload and delete it attempted came back *"The CSRF token is missing"*. The saves listing now hands the room a token (same exposure as the SPA’s rendered meta: login required, same-origin, no CORS anywhere in the app), and the room remembers it. **Two:** RetroArch writes a state’s 8-byte RZIP header at once and compresses the payload over the next second or three, while webretro copies the file into IndexedDB from its write callback — so a read taken straight after the save command returns the header alone, and the bridge accepted the first non-empty read. Measured live: the first export returned **9 bytes** of a 741-byte state. The export now reads the file being written in preference to the cached copy and retries until the size stops growing and is big enough to be a state (~0.6 s on a warm core). Verified end to end afterwards: *Sync now* stored 741 bytes, *Load* restored them into the core, *Delete* worked for the first time. The `?v=` shell cache key moved to `save-states-4`, without which none of this reaches a member who has played before. - **NeoGeo CD (and any RAR5) ROMs would not play** — reported on the Human Queue, reproduced on the box. Three faults in a row: the 7-Zip in the image *lists* a RAR5 it cannot decompress (28 zero-byte files, *Unsupported Method*); the extractor assumed the tool that listed could also extract, so bsdtar — which decodes the archive perfectly — was never reached; and bsdtar could not be handed the member anyway, because ROM names like `[!][NGCD-058]` are glob character classes to it. Now: the listing tool is tried and then the other one, a zero-byte result counts as a failed decode (and the husk is deleted so it cannot be cached), member names reach bsdtar backslash-escaped so an exact name matches literally instead of reading as a character class; and a short `rarfile` read is refused instead of writing half a ROM. A damaged member no longer costs the whole rip: bsdtar stops the entire run on a CRC error, so one bad audio track in a 34-track CD image used to hide the `.cue` sitting at member 34 — the extractor now drops the member bsdtar named, asks again for the rest, and logs which track was damaged. The rar path also stopped reporting a generic *Failed to read rar archive* over the top of the CLI’s actual diagnosis, which is what made this look for months like a missing unrar tool. New `scripts/ops/playability_report.py` answers the rest of the report — *"we should run a test for each system that has games in our library"* — by opening one real file per platform through the play route’s own resolver. - **The Thunderstore catalogue could never find a community.** Its community list is paginated at 100 and Oneirodex read only the first page, so *Browse catalogue* answered *no data* for Risk of Rain 2, Valheim and Lethal Company — titles the registry certainly knows. It now follows the `pagination.next_link` cursor (capped at 20 pages). Found by calling the source live from the box after the deploy, not from reading it. - **Librarian controls on a details page could never appear.** The page has read `game.can_edit` since the PC cheats panel shipped — and the details payload never sent the field, so the cheats form, the new Mods panel and the headset-record editor were all gated on `undefined`. The payload now emits `can_edit` (librarian or above, the same rule the routes behind those controls apply); `is_admin` is unchanged. Found by reading the live page after the H2–H4 deploy: the API answered with mod and save data the page would not show. - **Lazy member pages no longer blank the SPA.** `/chat`, `/calendar`, `/ways-to-play` and every other lazily loaded route that reads `useViewer()` / `useShellConfig()` threw *must be used within a Provider* and unmounted the app. Cause: `dist_asset` versioned the module `