This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
# Keep the build context to the sources the images actually copy.
|
||||
#
|
||||
# Without this every local `docker compose build` ships the whole working tree —
|
||||
# node_modules, build output, the Git history and any local candidate data — which
|
||||
# is slow, non-reproducible and can push unrelated working-tree content into a layer.
|
||||
# The release gate builds from `git archive`; this keeps a direct build equivalent.
|
||||
|
||||
.git/
|
||||
.gitattributes
|
||||
.gitleaks.toml
|
||||
|
||||
**/node_modules/
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/dist/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
**/playwright-report/
|
||||
**/test-results/
|
||||
.playwright-mcp/
|
||||
.stitch-reference/
|
||||
artifacts/
|
||||
deploy/data/
|
||||
|
||||
# Documentation and evidence are not part of any image.
|
||||
docs/
|
||||
checklists/
|
||||
prompts/
|
||||
samples/
|
||||
specs/
|
||||
templates/
|
||||
*.md
|
||||
!deploy/README.md
|
||||
|
||||
.env
|
||||
.idea/
|
||||
.vs/
|
||||
*.user
|
||||
*.log
|
||||
@@ -0,0 +1,6 @@
|
||||
*.sh text eol=lf
|
||||
*.py text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
Dockerfile.* text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Public source validation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
source:
|
||||
name: source
|
||||
if: ${{ gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Set up .NET SDK
|
||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
with:
|
||||
global-json-file: global.json
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 24.18.1
|
||||
cache: npm
|
||||
cache-dependency-path: src/Ludarium.Web/package-lock.json
|
||||
- name: Export reviewed public source
|
||||
run: node scripts/export-public-source.mjs "${RUNNER_TEMP}/ludarium-public"
|
||||
- name: Verify exported manifest
|
||||
run: cd "${RUNNER_TEMP}/ludarium-public" && node scripts/validate-public-source.mjs
|
||||
- name: Validate source without privileged services
|
||||
run: cd "${RUNNER_TEMP}/ludarium-public" && sh deploy/run-managed-validation.sh source
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
**/playwright-report/
|
||||
**/test-results/
|
||||
.playwright-mcp/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.idea/
|
||||
.vs/
|
||||
*.user
|
||||
*.log
|
||||
artifacts/
|
||||
deploy/data/
|
||||
.stitch-reference/
|
||||
|
||||
# Produced by the Docker build (web bundle plus the emulator payloads).
|
||||
src/Ludarium.Api/wwwroot/
|
||||
|
||||
# Ephemeral, checksum-verified inputs materialized by the managed image gate.
|
||||
.build-inputs/emulatorjs/*.7z
|
||||
@@ -0,0 +1,6 @@
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[[allowlists]]
|
||||
description = "Ignore the deployment host's untracked runtime environment file"
|
||||
paths = ['''(^|/)deploy/\.env$''']
|
||||
+641
@@ -0,0 +1,641 @@
|
||||
# Changelog
|
||||
|
||||
## 0.4.19-rc.8 — populated Home height closeout
|
||||
|
||||
- Removes unused desktop-only bottom whitespace so the complete populated Home stays within its
|
||||
1,700 px release budget without compressing content, controls or mobile navigation space.
|
||||
|
||||
## 0.4.19-rc.7 — settled populated Home guard
|
||||
|
||||
- Keeps the fully settled five-signal production Home hero below its fixed 440 px visual budget,
|
||||
including after asynchronous playability data arrives.
|
||||
|
||||
## 0.4.19-rc.6 — populated Home visual closeout
|
||||
|
||||
- Keeps the cinematic populated Home hero within its 440 px desktop height budget by tightening
|
||||
its responsive padding and display scale without changing archive content or controls.
|
||||
|
||||
## 0.4.19-rc.5 — release truth and fail-closed qualification
|
||||
|
||||
- Makes Home, navigation badges and repair views use exact review, artwork, source, hash and
|
||||
collection-playability signals, so an incomplete archive can no longer be reported as healthy.
|
||||
- Adds a bounded SQL playability summary and progressive Library/Attention loading while retaining
|
||||
the stricter evidence-bound runtime check for every actual launch.
|
||||
- Refuses zero-item integrity snapshots with HTTP 409 and identifies historical empty snapshots as
|
||||
unusable legacy baselines.
|
||||
- Separates Attention decisions from findings, uses action-specific verbs and adds uncapped
|
||||
server-side grouped-review paging with an honest warning for the legacy 500-group response.
|
||||
- Bundles Inter and Manrope locally, fixes light-theme hero and heading contrast, removes overlapping
|
||||
generated-cover layers, improves empty Wishlist focus and extends responsive validation to 880 px.
|
||||
- Adds one canonical `VERSION` identity for .NET runtime surfaces and guards the required Compose,
|
||||
Unraid, runbook and changelog copies against drift.
|
||||
- Replaces generic repository guessing in Gitea Actions with Ludarium's locked .NET, nested frontend,
|
||||
controller and exact-image security gates.
|
||||
- Makes production autodeploy depend on a successful full validation of the same source revision.
|
||||
- Keeps SNES `.smc`, PSX `.pbp`, PSP `.iso`/`.cso` and Genesis `.gen`/`.smd`/`.bin` recognised but
|
||||
fail-closed until an exact deterministic runtime fixture qualifies each format.
|
||||
- Makes an empty candidate-platform qualification request fail instead of silently re-running the
|
||||
already-qualified browser matrix.
|
||||
- Opens threaded PPSSPP sessions in their required isolated top-level page instead of leaving users
|
||||
in a non-isolated embedded player that can never expose `SharedArrayBuffer`.
|
||||
- Records the release contract in ADR 0067. Local exact-image security passes; isolated Unraid
|
||||
candidate backup/restore, browser and live active-scan evidence remain required before promotion.
|
||||
|
||||
## 0.4.19-rc.4 — Sega qualification and three faults a live archive found
|
||||
|
||||
- Makes accessibility-gate startup semantic and diagnosable: it now waits for Ludarium's visible
|
||||
application landmark and, after login, its page heading instead of a Home-specific CSS selector.
|
||||
- Binds the candidate backup filename and restored-database assertion to the application's declared
|
||||
schema version, eliminating the final stale schema-23 release assumption.
|
||||
- Makes the Home workflow height budget deterministic and diagnostic, and gives that workflow the
|
||||
same semantic application-readiness boundary as the accessibility gate.
|
||||
- Recalibrates Home's total height allowance for the qualified Living Archive hero while adding
|
||||
tighter independent hero and metric-grid caps to prevent local layout regressions.
|
||||
- Keeps game-drawer header titles and controls high-contrast on the Living Archive dark-glass
|
||||
header in both light and dark themes.
|
||||
- Aligns every runtime, export, support, gate and Unraid release identifier with `0.4.19-rc.4`.
|
||||
- Binds the candidate gate and support bundle to the application's declared schema version.
|
||||
- Qualifies Genesis, Master System and Game Gear on the bundled `genesis_plus_gx` core, each proven
|
||||
by its own deterministic fixture in a browser.
|
||||
- Marks a file that left the archive as missing again, instead of leaving it Present and playable.
|
||||
- Reads a game's creation date from the record, so sorted and filtered library requests work.
|
||||
- Lets a confirmation be answered with a pointer instead of rendering behind the surface that
|
||||
raised it.
|
||||
- Stops leaving a file extension in a derived title, without splitting existing games in two.
|
||||
- Finishes a cheat lookup that finds nothing.
|
||||
- Keeps a resolved review readable, and repairs any an earlier version made unreadable.
|
||||
- Answers an unrecognised scan mode with the modes that exist instead of an empty 400.
|
||||
- Lets the browser gates run against any archive rather than one operator's, and proves bulk
|
||||
organising against a real catalogue.
|
||||
- Names the stylesheet's colours, and gives the light theme the hairlines and lifts it never had.
|
||||
- Lets the library filters show the option they are set to, and counts the catalog once.
|
||||
- Gives a recently played game room for its name.
|
||||
- Stops a critical finding with no decision beside it from reading as nothing to do.
|
||||
- Says when nothing is blocked, and prints a one-sentence cause once.
|
||||
|
||||
## 0.4.19-rc.3 — navigation parity, bulk organising and session choices
|
||||
|
||||
- Gives desktop and mobile the same destinations, and surfaces Playability & naming from the
|
||||
navigation and from any game that cannot start.
|
||||
- Adds selection and bulk favourite, play status, collection and tag actions to the library grid.
|
||||
- Makes staying signed in an explicit choice on a trusted device instead of a per-tab default.
|
||||
- Makes controller navigation discoverable in Settings and possible to switch off.
|
||||
- Follows the operating system theme while the application is open.
|
||||
- Gives every overlay a unique title id.
|
||||
|
||||
## 0.4.19-rc.2 — library query, first run and interface correctness
|
||||
|
||||
- Moves library sorting and filtering into the catalog query, so ordering describes the collection
|
||||
rather than the page on screen.
|
||||
- Adds filtering by favourites, play status, minimum rating, collection, tag and whether a playable
|
||||
copy exists, and sorting by last played, rating and recently updated.
|
||||
- Scopes `GET /releases` to named games, so browsing no longer loads every release in the catalog.
|
||||
- Tells a fresh install to add a library instead of reporting that the empty archive looks good.
|
||||
- Replaces every `window.confirm` with the application's own focus-trapped confirmation.
|
||||
- Makes the game details one tab set with one selected tab, a real tab panel and arrow-key movement.
|
||||
- Makes global search a combobox: focus stays in the input and the whole result list is reachable.
|
||||
- Runs the accessibility gate in both themes, which it never did, and fails if the theme it asked
|
||||
for was not applied.
|
||||
- Declares five colour and layout tokens that were used but never defined, gives the light theme its
|
||||
missing navigation colour, and guards the stylesheet so hard-coded colours cannot increase.
|
||||
|
||||
## 0.4.19-rc.1 — emulation completion
|
||||
|
||||
- Gives every isolated native player one shared contract: one registry describes a player, and its
|
||||
routes, cookies, sessions, proxying, control and capability vocabulary follow from that
|
||||
description. Adding a player is a registry entry plus a launch resolver.
|
||||
- Moves the sidecar control surface, path validation, Xwayland display resolution, process
|
||||
supervision and save-data transfer into one shared module. The hardened display resolution added
|
||||
for Eden had never reached Dolphin, which shipped with the bug that fix removed.
|
||||
- Stops the running title when a player session is revoked, so a closed player cannot leave an
|
||||
emulator holding a render device and a disc image.
|
||||
- Reports a native player as available only when its sidecar answers a live status probe, instead of
|
||||
trusting configuration alone until the operator clicks Play.
|
||||
- Targets player hotkeys at the launched process rather than a window name, so a state hotkey cannot
|
||||
reach an emulator's own front-end window.
|
||||
- Brings native save data into the Game Data Vault: Switch, GameCube and Wii save directories can be
|
||||
captured as immutable SHA-256 evidenced revisions and restored from them.
|
||||
- Classifies every container format a player accepts, including `.gcm`, `.gcz`, `.wia`, `.isz`,
|
||||
`.3ds`, `.cia`, `.wud`, `.wux`, `.cdi` and `.vpk`.
|
||||
- Identifies GameCube and Wii discs by their on-media magic word instead of by a folder name, and
|
||||
records a conflict when the directory disagrees.
|
||||
- Identifies Sega hardware from the cartridge header, separating Mega Drive from 32X and Sega CD and
|
||||
Master System from Game Gear, and resolves every common Sega directory spelling.
|
||||
- Fixes a classifier defect where `MediaType.Rom` equalling the default enum value made every ROM
|
||||
extension without a matching header signature classify as `Unknown`.
|
||||
- Delivers CUE/BIN PlayStation discs and multi-disc M3U games by resolving a linked descriptor to
|
||||
every disc and track it names and streaming them to the browser player as one archive built while
|
||||
it is read.
|
||||
- Scopes a native save capture to the directories that hold saves rather than to the whole emulator
|
||||
profile, which on Dolphin also carries shader caches far larger than one vault revision, and
|
||||
reports the resolved directories so a wrong path is visible before it matters.
|
||||
- Gives the player both halves of the vault: a capture control and the stored restore points, with a
|
||||
capture taken while a title runs marked as such.
|
||||
- Adds a `.dockerignore`, so a local image build no longer ships the working tree as build context.
|
||||
- Separates container formats Ludarium can deliver from formats a fixture has proven, so an
|
||||
implemented delivery path never reports a game as playable before its qualification pass has run.
|
||||
- Binds the image's EmulatorJS core retention list to the platform matrix, drops the unused
|
||||
`mupen64plus_next` core and bundles `genesis_plus_gx`.
|
||||
- Adds deterministic Genesis, Master System and Game Gear fixtures and their qualification pass. The
|
||||
three platforms fail closed until that pass has completed the real bundled core.
|
||||
- Gives every native player one embedded shell: delayed-load recovery, a reload control, the session
|
||||
expiry, assertive failure announcements and state controls only where the runtime exposes them.
|
||||
Recently Played can now resume a GameCube or Wii title, which it never could.
|
||||
- Covers the sidecar security boundary with a stdlib-only test suite that runs in the candidate gate.
|
||||
|
||||
## 0.4.17-rc.1 — stable navigation and contextual wayfinding
|
||||
|
||||
- Replaces the expanding global platform/collection tree with a stable Play, Organize and
|
||||
Operations hierarchy that never pushes core destinations below the fold.
|
||||
- Adds compact breadcrumbs for page, section, platform and detail context.
|
||||
- Moves console browsing into a hardware-led, horizontally scrollable Library platform rail with
|
||||
exact counts, active state and a direct overview path.
|
||||
- Replaces the six-way mobile dock with Home, Library, central Search, Wishlist and More; the
|
||||
accessible More sheet contains platform, collection, operational and advanced archive routes.
|
||||
- Keeps pinned collections meaningful by ordering them first inside the Collections workspace.
|
||||
|
||||
## 0.4.16-rc.1 — hardened Switch runtime and premium entry experience
|
||||
|
||||
- Replaces the sparse administrator-token screen with a responsive, console-led welcome flow,
|
||||
clear privacy/source-safety promises, a deliberate token reveal control and mobile-first layout.
|
||||
- Adds deterministic login interaction, accessibility, reflow and production-build coverage.
|
||||
- Removes unused container, build, SSH, cron, mail and scripting stacks from the isolated Eden
|
||||
runtime and health-gates it before the main application becomes ready.
|
||||
- Adds an in-container runtime audit and version-bound OpenVEX evidence so every inherited Critical
|
||||
scanner match is either reachable and blocking or explicitly justified from the actual runtime.
|
||||
- Makes release archives preserve Unix line endings and executable bits when produced on Windows.
|
||||
- Publishes Switch sessions through the dedicated HTTPS player origin and closes an Nginx Proxy
|
||||
Manager HTTP/2 POST-stream stall while retaining WebSockets on the isolated player host.
|
||||
- Makes the public export workflow wait for the exact requested artifact with a bounded production-
|
||||
sized timeout instead of accepting an unrelated status message.
|
||||
- Gives both Unraid containers stable DockerMan ownership, HTTPS WebUI links and the persisted SVG
|
||||
icon, groups them as one Folder View application and removes verified stopped release candidates
|
||||
plus their obsolete images without touching appdata or read-only source libraries.
|
||||
|
||||
## 0.4.15-rc.1 — reliable resume and self-healing runtime
|
||||
|
||||
- Makes every visible game detail route independent of catalog pagination and transient browser
|
||||
cache, with canonical history, reload, Back, Close, loading and recovery semantics.
|
||||
- Turns Recently Played into a capability-aware one-click action: Resume only for qualified
|
||||
automatic restore, Play for another available player, and an honest Details fallback otherwise.
|
||||
- Reuses one browser player across Home and Library, revokes sessions on exit/unmount and gives the
|
||||
Switch player a single-modal focus trap plus body scroll lock.
|
||||
- Makes readiness a read-only PostgreSQL/schema check and makes PID 1 supervise both PostgreSQL and
|
||||
the API so database loss terminates the broken runtime and activates Docker restart policy.
|
||||
- Pins the patched SSH.NET 2026.0.0 test dependency after the RC gate rejected the newly disclosed
|
||||
recursive SCP path-traversal advisory affecting 2025.1.0 and earlier.
|
||||
- Gives Unraid candidate compilation and browser gates explicit CPU, memory, process and concurrency
|
||||
budgets so exhaustive validation cannot claim unbounded production-host resources.
|
||||
- Keeps the Play!.js supply-chain boundary fail-closed after upstream artifact drift: both images
|
||||
now record the reviewed upstream commit and exact refreshed hashes as in-image provenance, with
|
||||
real PS2 fixture qualification still mandatory before promotion.
|
||||
- Extends the live browser workflow through the new Recently Played Play/Resume action and replaces
|
||||
a fixed 80 ms controller-focus assumption with a bounded observable focus contract.
|
||||
- Gives the playable Recently Played Details action a real secondary-button treatment and
|
||||
theme-aware text token, restoring WCAG AA contrast in every gated light-theme viewport.
|
||||
- Replaces the security-rejected PostgreSQL 16.14/Alpine final image with a unified Ubuntu Noble
|
||||
runtime containing patched PostgreSQL 16.15 and OpenSSL 3.0, removing the reported database and
|
||||
runtime vulnerabilities without suppressions while retaining non-root API execution via `gosu`.
|
||||
- Makes UTF-8 an explicit image and candidate-gate invariant, so a fresh database cannot fall back
|
||||
to SQL_ASCII and reject Unicode platform metadata during bootstrap or after restart.
|
||||
- Removes EmulatorJS's hidden runtime version-check request by resolving its pinned metadata from
|
||||
the bundled same-origin file, with build-time residue checks and a real-browser assertion that
|
||||
every qualified EmulatorJS core stays entirely on the Ludarium origin.
|
||||
- Makes exact-title Switch launch follow the active PixelFlux Xwayland display instead of assuming
|
||||
`:0`, with active-process discovery, a bounded newest-socket fallback and explicit early-exit
|
||||
diagnostics in the isolated Eden controller.
|
||||
- Repairs libc collation-provider upgrades before the API can seed data: unversioned legacy indexes
|
||||
are rebuilt and versioned, while only semantically identical non-custom seed duplicates may be
|
||||
removed; every ambiguous or manual record fails closed for operator review.
|
||||
|
||||
## 0.4.14-rc.1 — controller-first couch navigation
|
||||
|
||||
- Extends standard gamepad support from the isolated players to the complete Ludarium shell.
|
||||
- Adds spatial D-pad and left-stick focus across responsive grids, navigation, filters and actions,
|
||||
with horizontal select changes, A to activate, B to close a modal or return Home, and Menu search.
|
||||
- Keeps controller focus trapped inside active dialogs and visibly identifies the selected control.
|
||||
- Suspends shell polling while browser or Switch playback owns the controller, avoiding double input.
|
||||
- Runs no animation loop until a controller is actually connected and cleans up every listener,
|
||||
frame and timer on disconnect or unmount.
|
||||
|
||||
## 0.4.13-rc.1 — durable recently played flow
|
||||
|
||||
- Records successful browser and Switch launches as auditable personal play history while preserving
|
||||
manual status, favorites, notes, rating, difficulty and completion progress.
|
||||
- Adds a bounded recently-played API ordered by the actual last launch time rather than UI visits.
|
||||
- Adds a responsive cover-led Jump back in section on Home with relative play time, session count,
|
||||
completion progress and direct game-detail navigation.
|
||||
- Makes every Home support request cancellable as one parallel batch so navigation cannot update an
|
||||
unmounted page.
|
||||
|
||||
## 0.4.12-rc.1 — recoverable operational UX
|
||||
|
||||
- Replaces permanent spinners with consistent first-load failure and retry states across Attention,
|
||||
Activity, scan history, snapshots and data-backed Settings sections.
|
||||
- Keeps already loaded operational information visible when a refresh fails and shows a focused
|
||||
reconnect action instead of blanking the screen.
|
||||
- Makes Activity polling cancellable and overlap-free so slow refreshes cannot accumulate or emit
|
||||
repeated unhandled failures.
|
||||
- Adds visible busy states and duplicate-click protection for all-library scans, individual library
|
||||
scans and integrity snapshot creation.
|
||||
- Adds deterministic regressions for failed and recovered Activity, Attention and Settings loads.
|
||||
|
||||
## 0.4.11-rc.1 — resilient browse and detail UX
|
||||
|
||||
- Debounces library search, cancels superseded requests and prevents stale cards from appearing
|
||||
under a newly selected query or platform.
|
||||
- Adds explicit initial, refresh-error and retry states instead of leaving a permanent spinner or
|
||||
silently hiding a failed refresh.
|
||||
- Keeps game browsing available when an optional play-capability service is down, fails Play closed
|
||||
and provides a focused readiness retry.
|
||||
- Loads fourteen game-detail resources independently so one failed secondary source no longer
|
||||
empties the complete drawer; partial results remain usable with a bounded retry action.
|
||||
- Prioritizes Overview, My library, Saves & states and Releases while moving six maintenance views
|
||||
behind a clear More details disclosure.
|
||||
- Separates Attention's open-decision count from the number of currently shown evidence items.
|
||||
|
||||
## 0.4.10-rc.2 — truthful per-game Switch readiness
|
||||
|
||||
- Added single-game and bounded bulk Switch play-capability APIs that validate the exact title,
|
||||
runtime/controller, selected production keys and read-only source before advertising Play.
|
||||
- Library cards and the page-ready count now use the per-game result instead of global Eden
|
||||
availability; ambiguous or unavailable titles show a concrete reason before launch.
|
||||
- Game details use the same capability and preserve independent session-time revalidation so a
|
||||
stale or changed catalog still fails closed.
|
||||
- Distinguishes a missing base game from genuine ambiguity: update/DLC-only folders now explain
|
||||
that their base XCI/NSP is required instead of incorrectly suggesting multiple matches.
|
||||
- Added deterministic policy and frontend regressions for available, missing-prerequisite and
|
||||
ambiguous base-game states.
|
||||
|
||||
## 0.4.9-rc.1 — dormant sources and descriptor truth
|
||||
|
||||
- Added an explicit dormant-library control. Disabled roots preserve their catalog, cannot be
|
||||
scanned, and no longer generate false archive-health attention while their mounts are empty.
|
||||
- Recognizes an exact `.bin.ecm` companion referenced by a PlayStation CUE as a present legacy
|
||||
encoded track. The bundle remains visibly `CompleteWithWarnings` until a fixture-qualified,
|
||||
app-owned conversion/playback path exists; source content is never changed.
|
||||
- Corrected Home and Settings states so only active empty sources affect the archive headline.
|
||||
- Added deterministic integration coverage for ECM sidecars, source immutability and dormant scan
|
||||
rejection.
|
||||
- Updated the shipped .NET runtime to 10.0.11 and the build SDK to 10.0.302 after the RC security
|
||||
gate identified the fixed High-severity runtime advisory `GHSA-m93f-wj8c-rp8p` in 10.0.10.
|
||||
|
||||
## 0.4.8-rc.1 — platform hardware identity
|
||||
|
||||
- Replaced generic platform monograms with real, locally bundled hardware imagery for all sixteen
|
||||
active console, handheld and PC platforms.
|
||||
- Added a reusable platform presentation model with device category, generation/era and restrained
|
||||
platform color identity.
|
||||
- Reworked platform cards for stronger visual hierarchy, responsive behavior, focus states and
|
||||
reduced-motion support.
|
||||
- Reused compact hardware portraits in Home collection distribution and Library navigation.
|
||||
- Added asset provenance and component coverage; no runtime image-host dependency was introduced.
|
||||
|
||||
## 0.4.7-rc.1 — deterministic Switch title launch
|
||||
|
||||
- Resolves a selected Switch game to one unique read-only base XCI or NSP and launches that exact
|
||||
path through Eden's fullscreen command-line interface instead of opening Eden's library menu.
|
||||
- Adds a private, separately authenticated Eden controller that rejects traversal, non-game files,
|
||||
missing files and any path outside the fixed `/games` mount.
|
||||
- Adds Ludarium-native remote controls for pause/resume, Eden fullscreen and stop, alongside the
|
||||
existing shell fullscreen, connection state, reload and explicit session exit controls.
|
||||
- Reports native persistent in-game saves accurately and does not present unsupported portable
|
||||
Eden savestates as a working capability.
|
||||
- Fails closed when a directory contains multiple plausible base titles or catalog evidence is not
|
||||
sufficient to select one deterministically.
|
||||
|
||||
## 0.4.6-rc.1 — embedded Switch remote player
|
||||
|
||||
- Embeds the isolated Eden desktop inside a dedicated full-screen Ludarium player instead of
|
||||
sending operators to a separate application, while retaining a secure standalone fallback.
|
||||
- Adds bounded, expiring Switch-player capabilities stored only as SHA-256 digests and delivered
|
||||
through an HttpOnly cookie on a separate port origin.
|
||||
- Adds a certificate-pinned WebSocket/streaming reverse proxy with server-side Eden credentials;
|
||||
neither credentials nor the Ludarium administrator token enter the player frame.
|
||||
- Hardens Eden's web desktop by disabling sharing, collaboration, file transfer, command execution
|
||||
and file/app sidebars, and keeps the game library strictly read-only.
|
||||
- Adds a Settings readiness panel and game-specific play controls while remaining explicit that
|
||||
the operator chooses the exact title inside Eden.
|
||||
- Streams the common single-ROM N64 ZIP layout directly from read-only sources without extracting
|
||||
it, while rejecting ambiguous archives, invalid N64 headers and decompression sizes above 512 MiB.
|
||||
- Disables Eden clipboard and microphone bridging and permits only the screen wake lock needed by
|
||||
the embedded player.
|
||||
|
||||
## 0.4.5-rc.1 — play-first library experience
|
||||
|
||||
- Reworks the game library around premium, responsive tiles with clearer hierarchy, stronger
|
||||
artwork presentation and direct play actions for every currently available browser or Eden title.
|
||||
- Adds a bounded 50-game capability query so cards show honest live play readiness without an
|
||||
unbounded request waterfall; unsupported or absent content remains fail-closed.
|
||||
- Gives the game drawer a dedicated play panel, separates playback from artwork administration,
|
||||
localizes release dates and corrects read-only messaging around explicit operator playback.
|
||||
- Qualifies dark/light themes and the complete responsive surface across eight viewports with
|
||||
WCAG 2.1 AA checks and horizontal-overflow detection.
|
||||
|
||||
## 0.4.4-rc.2 — native N64 and isolated Switch playback
|
||||
|
||||
- Release-qualifies a checksum-pinned N64Wasm WebGL2 player with deterministic `.z64`,
|
||||
`.v64` and `.n64` recognition plus evidence-bound savestate restore.
|
||||
- Adds an optional digest-pinned Eden remote-player profile for Switch, verified personal-key
|
||||
provisioning and a strictly read-only game-library mount.
|
||||
- Preserves automatic browser savestate evidence across rescans when byte-identical content
|
||||
is rebound to a new artifact version.
|
||||
|
||||
## 0.4.4-rc.1 — personal firmware foundation
|
||||
|
||||
- Added schema-23 app-owned BIOS, firmware and key metadata with selected-version provenance, SHA-256 evidence and audited upload/delete operations.
|
||||
- Added a private streaming firmware vault under persistent Ludarium appdata. Uploads are bounded per allowlisted slot and reject host executables, scripts, archives, unsafe names and unknown extensions.
|
||||
- Added Settings UI for personal PSX BIOS, PS2 BIOS and Switch production/title keys with explicit configured-versus-runtime readiness.
|
||||
- Added capability-scoped PSX BIOS delivery to the native browser player; the BIOS URL is emitted only for an operator-selected asset and there is no general firmware or key download endpoint.
|
||||
- Release-qualified checksum-pinned PSX/`pcsx_rearmed`, PSP/`ppsspp` and PS2/Play!.js playback with original, deterministic SDK-built fixtures and real canvas, scoped Range and cancellation gates. PSX and PSP additionally prove automatic state restore; Play!.js does not expose a fixture-proven state API. Switch remains fail-closed without a browser runtime.
|
||||
- Added the locally bundled, checksum-pinned official Play!.js runtime, cross-origin-isolated PS2 player and asynchronous 8 GiB bounded disc Range device. Play!.js uses its built-in HLE BIOS; personal PS2 BIOS bytes are never delivered to the player.
|
||||
- Fixed hardened container startup on correctly pre-owned appdata by avoiding redundant recursive `chown` calls, and aligned both Unraid templates with Compose's read-only root, `no-new-privileges` and six-capability startup policy.
|
||||
- Added bounded deterministic inventory recognition for PSX/PS2/PSP ISO9660 layouts, PSP CSO, Switch XCI and Switch NSP/PFS0 without extracting or executing source content.
|
||||
|
||||
## 0.4.3-rc.1 — deferred functionality closure
|
||||
|
||||
- Added schema-22 game-origin provenance and safe full deletion for manually created games, including dependent app-owned catalog, media, artwork and Vault records while preserving linked wishlist intent as a standalone item.
|
||||
- Release-qualified SNES, GB, GBC, GBA and NDS browser play with deterministic original fixtures and real local-core canvas, scoped Range and savestate gates; N64 remains fail-closed after its legal homebrew fixture failed Mupen startup.
|
||||
- Added explicit live metadata-provider credential/endpoint validation with persisted Ready, Degraded or Offline health snapshots and secret-safe responses.
|
||||
- Fixed EmulatorJS startup in non-English browser locales by using the bundled `en-US` fallback without a missing locale request.
|
||||
- Qualified evidence-bound automatic savestate restore for every playable core: fceumm, snes9x, gambatte, mgba and melonds.
|
||||
- Disabled unused Npgsql 10 GSS negotiation for the bundled loopback PostgreSQL connection after a crash dump identified its cancellation path, and made unexpected candidate restarts a hard release failure.
|
||||
|
||||
## 0.4.2-rc.1 — capability honesty and safe catalog lifecycle
|
||||
|
||||
- Separated bundled EmulatorJS cores from release-qualified playback; only deterministic live-fixture-backed NES/fceumm is currently startable, while unverified mappings fail closed and remain visible as deferred capabilities.
|
||||
- Added schema-21 provenance for scan-derived versus manual releases, with audited, optimistic-concurrency create/update/delete for manual records and immutable scanner-owned records.
|
||||
- Added the complete manual release editor to game details, including origin labels and explicit destructive confirmation.
|
||||
- Hardened administrator bearer-token comparison with strict parsing and fixed-time digest equality.
|
||||
- Updated maintained patch-level dependencies: Npgsql 10.0.3, OpenMcdf 3.2.0, SharpCompress 0.50.4, React 19.2.8, Playwright 1.62.1 and Vite 8.2.1.
|
||||
- Promoted the exact fully gated schema-21 candidate to production after source-manifest, backup/restore, security, real-core, responsive UX and accessibility validation.
|
||||
|
||||
## 0.4.1 — evidence-bound browser restore
|
||||
|
||||
- Added automatic Ludarium-owned save and savestate restore for the deterministically verified `fceumm` browser core, with no RomM runtime or external service.
|
||||
- Bound every automatically restorable revision to the exact artifact UUID/version, emulator core and optional ROM SHA-256 so changed, relinked, manual, legacy and malformed data fails closed.
|
||||
- Added capability-scoped integrity-checked Vault reads and player-side restore through EmulatorJS's in-memory game-manager interface before play continues.
|
||||
- Added startup cleanup for interrupted uploads and database-orphaned app-owned revisions, while skipping reparse points, unrelated files and every source-library path.
|
||||
- Extended the live NROM gate to capture a real savestate, cancel the session, launch a new session and prove automatic restore with no console or request failures.
|
||||
- Promoted the exact fully gated candidate to production with unchanged read-only source manifests, controlled restart, backup restore and post-deployment UX/accessibility evidence.
|
||||
|
||||
## 0.4.0 — browser play candidate
|
||||
|
||||
- Added the native Game Data Vault for stand-alone save and savestate management, including immutable revisions, SHA-256 evidence, metadata, bounded streaming uploads, range downloads and full-history deletion.
|
||||
- Added schema-20 audited PostgreSQL persistence, retry-safe client revision IDs, optimistic concurrency, duplicate-content suppression and restart-safe history without requiring a RomM runtime or database.
|
||||
- Added a responsive Saves & states workspace with evidence cards, lazy revision history, metadata editing, safe downloads and explicit app-owned/source-read-only language.
|
||||
- Added executable/signature rejection, path containment, corruption detection and deterministic vault policy/filesystem/PostgreSQL/frontend regressions.
|
||||
- Added a dedicated live Playwright vault gate with desktop/mobile WCAG checks and corrected light-theme composer/evidence contrast discovered by that gate.
|
||||
- Kept personal vault notes and device labels out of request URLs by moving bounded upload metadata into an encoded internal header while preserving raw streamed file bodies.
|
||||
- Added a native, stand-alone library experience inspired by RomM's product surface: static/smart/virtual collections, tags, personal state, game relationships, rich media/manuals, achievements, editable custom platform definitions and catalog exports.
|
||||
- Added direct opt-in IGDB, MobyGames, ScreenScraper and RetroAchievements providers with bounded network reads, exact-match safeguards, provenance-bearing claims and review items for uncertain conflicts.
|
||||
- Added schema-18 persistence and audited APIs for all native library data, plus app-owned content-addressed media and streaming JSON, EmulationStation and Pegasus export generation without source-library mutation.
|
||||
- Added premium collection navigation and game-detail workflows for state, tags, media, relationships and achievements; provider and platform management now live in Settings.
|
||||
- Added deterministic provider fixtures, unsafe-media rejection tests and a real PostgreSQL restart/scale regression covering 250,000 catalog records.
|
||||
- Added self-contained, fail-closed browser play with checksum-pinned EmulatorJS 4.2.3 assets, six allowlisted cores, scoped expiring sessions and exact read-only ROM streaming; RomM is not a runtime dependency.
|
||||
- Added schema-19 scan-derived game/artifact identity links, bounded expiring play sessions and redacted start/exit audit events.
|
||||
- Added a responsive game-detail player with loading, unavailable, missing-core, error, fullscreen, separate-window and explicit exit states.
|
||||
- Added deterministic synthetic iNES evidence plus executable, script, archive, oversized, missing, ambiguous, unavailable, arbitrary-ID and cancellation regressions.
|
||||
- Replaced hanging Syft/Grype/Gitleaks Docker pulls with checksum-pinned cached release binaries and bounded the remaining pinned Playwright image pull with retries/timeouts.
|
||||
- Added an original deterministic playable NROM fixture and an isolated live gate that proves scan linkage, capability authorization, Range delivery and real local fceumm startup without touching source libraries.
|
||||
- Fixed ASP.NET static delivery for EmulatorJS `.data` core payloads so a missing content-type mapping cannot silently trigger the upstream CDN failsafe.
|
||||
- Promoted the fully gated exact candidate image to production 0.4.0/schema 20 with read-only manifests, backup/restore, restart, DockerMan and post-promotion browser/accessibility evidence.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Reworked Home around the current operational state rather than an alarmist aggregate review count, with direct access to prioritized review work and supporting critical/decision context.
|
||||
- Clarified desktop navigation into Workspace, Collection and Operations; the Attention badge now exposes critical work before an unhelpful high total.
|
||||
- Made empty Library filter responses recoverable with a direct clear action, while retaining truthful zero-result pagination behavior.
|
||||
- Turned the cover-quality queue into a visual review surface with lazy selected-cover previews, source/quality/verification evidence and resilient refresh feedback.
|
||||
- Added a concise, safe grouped-work summary to Attention, direct platform collection links from Home, stronger game-detail framing and responsive/reduced-motion-safe finish work.
|
||||
|
||||
## 0.3.18 — catalog health and operational focus
|
||||
|
||||
- Expired provider snapshots now report an honest `Expired` state across the API and interface instead of appearing ready.
|
||||
- Added per-library scan-freshness guidance, including empty-source protection and clear full-scan visibility.
|
||||
- Attention can now prioritize recommended work, storage impact or recency; historical failed scans are separated from current operations without deleting audit evidence.
|
||||
- Added direct, durable actions to refresh metadata and cover-quality candidates from catalog administration.
|
||||
|
||||
## 0.3.17 — integrity and operational truth
|
||||
|
||||
- Integrity scans now hash OnDemand libraries, making exact duplicate detection, SHA-256 manifests and snapshots operational without changing source files.
|
||||
- Empty mounted roots with retained catalog records now count as attention-required and are clearly marked as historical on Home; bulk scan actions skip them safely.
|
||||
- Attention now opens the actionable decision inbox by default, while unknown content remains a separate review backlog.
|
||||
- Artwork candidates are content-addressed and duplicate candidate records are collapsed by the schema-17 migration; review metrics are per game rather than misleading raw candidate totals.
|
||||
- Provider snapshots report refresh-due status after expiry; diagnostics include expired providers as degraded.
|
||||
- Wishlist export is no longer capped at 100 records; JSON and CSV exports can be restored, store search works, acquired items no longer inflate waiting summaries, uploads/deletes handle artwork safely and binary API failures no longer masquerade as missing covers.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Expanded Wishlist 2.0 with lifecycle statuses, release dates, editions/regions, manual target/current prices, price-target highlighting, sorting, summary metrics, authenticated CSV export, bounded text import, standalone cover uploads and Home/Library integration.
|
||||
- Added exact, ambiguity-safe scan reconciliation: a standalone wish becomes Acquired only when its title resolves to one unique catalog game; uncertain duplicates are never silently linked.
|
||||
- Added optional RAWG-powered discovery with normalized platform identifiers and honest runtime capability flags. The feature is disabled without `RAWG_API_KEY`; retail price automation is explicitly unsupported rather than simulated.
|
||||
- Fixed artwork candidate selection so PostgreSQL never observes a transient second selected cover, eliminating enrichment-job uniqueness conflicts and scan-queue delays.
|
||||
- Fixed Wishlist reflow at 320–360 px by collapsing filters and constraining hero statistics without hiding functionality.
|
||||
- Added a persistent, searchable Wishlist with local-library suggestions, standalone future titles, cover reuse, platform and priority filters, personal notes, optimistic-concurrency editing and audited removal.
|
||||
- Added a dedicated premium Wishlist experience to desktop, mobile and widescreen navigation, including a responsive editorial summary, artwork cards, polished empty states and reduced-motion-safe ambient illustration.
|
||||
- Added a premium visual pass across the live application shell: atmospheric depth, refined glass surfaces, luminous navigation hierarchy, artwork-first gallery elevation, restrained page and panel transitions, responsive mobile dock treatment and an explicit reduced-motion path.
|
||||
- Added a production visual audit covering desktop and mobile hierarchy, real-cover presentation, widescreen density, interaction feedback and motion guardrails.
|
||||
- Added a live empty-source preflight to mount verification and scanning: an unexpectedly empty remounted share can no longer reconcile an existing catalog to missing, and the UI disables that source until content is restored and safety is verified.
|
||||
- Fixed Library platform deep links so sidebar and platform-card navigation immediately apply the requested platform instead of changing only the URL.
|
||||
- Fixed inventory export feedback and the SHA-256 manifest request format; exports now show progress, errors, the generated filename and record count.
|
||||
- Made scan history operationally useful with library names, completion timestamps, duration and retained failure details.
|
||||
- Corrected the live Unraid PS4 and PS5 source mappings from temporary `/mnt/disks` directories to their durable `/mnt/user` shares while preserving read-only container destinations and retained inventory.
|
||||
- Reworked the second-pass UX hierarchy for 0.3.12: truthful Attention routing, stable singular status copy, a responsive library control grid, compact mobile view labels, wider ultrawide collection canvases and explicit result context.
|
||||
- Reduced operational noise by showing active and recent background work first, limiting the scan dashboard to the latest eight runs and keeping the complete scan timeline one action away.
|
||||
- Added targeted workflow regressions for 1440px toolbar containment, mobile tab containment, the actionable Attention route and singular dashboard grammar.
|
||||
- Pinned both Linux Lightning CSS native variants as direct optional dependencies so Ubuntu browser gates and Alpine production builds cannot intermittently omit the required platform binary.
|
||||
- Reworked Attention into a truthful actionable inbox: missing/incomplete and integrity work now appears in `Needs action`, while informational unknown inventory remains available without inflating the priority flow.
|
||||
- Replaced premature generated-cover fallbacks with progressive artwork skeletons, so off-screen real covers are no longer presented as missing before their bounded lazy lookup runs.
|
||||
- Added route scroll restoration, scan-staleness messaging, correct singular status language, compact mobile Home composition and a clearer mobile navigation state.
|
||||
- Removed duplicate mobile Settings navigation and made the desktop platform rail independently scrollable so its protected-library footer cannot overlap controls on short displays.
|
||||
- Added the live 0.3.11 platform/UX audit and E2E regressions for scroll restoration and responsive Settings navigation.
|
||||
- Updated the locked transitive `nanoid` dependency to 3.3.18 after its prior version became subject to a high-severity availability advisory; the frontend audit is clean again.
|
||||
- Isolated candidate container naming from the fixed production DockerMan identity so release gates can run beside the single live `Ludarium` container without a name collision.
|
||||
- Made candidate .NET and browser gates consume an exact `git archive` instead of copying the dirty server worktree, excluding build caches and `.env` secrets from ephemeral test workspaces.
|
||||
- Made the mobile Settings regression selector exact so its label cannot be confused with the similarly named navigation landmark.
|
||||
- Added an explicit accessible name to the responsive Settings selector and wait for its route paint before asserting visibility in the headless mobile workflow.
|
||||
- Deduplicated deterministic health findings already represented by an actionable review group and kept resolved history free from current health summaries.
|
||||
|
||||
- Consolidated the Unraid deployment into one supervised `Ludarium` container containing the application and its private PostgreSQL process, with a functional DockerMan WebUI and persisted 256px PNG icon.
|
||||
- Promoted 0.3.9 to production on port 1230 with schema 14, 371 canonical visible games across 16 platform collections, 371/371 visible cover coverage and all three source roots read-only.
|
||||
- Made the schema-13 data reconciliation genuinely one-shot so readiness remains fast on large inventories, and added regression coverage that preserves words such as `Legends` and `Darkness` during platform-suffix cleanup.
|
||||
- Prepared 0.3.9/schema 13 to turn Attention into an actionable decision inbox while retaining unknown inventory as separately inspectable evidence.
|
||||
- Expanded deterministic support-content recognition for Steam, emulator, tool, redistributable, unlocker and related non-game paths; the repeatable migration resolves matching legacy unknown reviews without touching source libraries.
|
||||
- Tightened canonical title cleanup for scene, platform, region and product-code noise while preserving manual locks.
|
||||
- Restricted automatically verified covers to portrait box-front proportions and made generated artwork visibly pending rather than presenting it as equivalent to real cover art.
|
||||
- Replaced false-zero game-detail states and stale search states with truthful loading skeletons, added cover thumbnails to search, and improved mobile filters, Settings navigation and ultrawide Library density.
|
||||
|
||||
- Adopted the official .NET 10 Azure Linux 3 distroless runtime after comparative security testing; the final image has no shell/package manager and zero Grype findings at every severity.
|
||||
- Stopped the isolated candidate stack after successful promotion while retaining all candidate appdata, databases and backups for rollback.
|
||||
- Made candidate SBOM version naming derive from the requested image tag instead of a hardcoded development label.
|
||||
|
||||
- Reconciled current production evidence with historical UX documentation and added a gated post-0.3.8 optional roadmap.
|
||||
- Added a dedicated bounded .NET health probe and moved the runtime experiment to an official .NET 10 distroless image, removing the shell, package manager and auxiliary network tools; the final distribution is selected by comparative candidate security gates.
|
||||
|
||||
- Promoted 0.3.8 to production on port 1230 with schema 12, 389 visible games, complete visible cover coverage, read-only source mounts and passing post-deployment workflow/accessibility/restart gates.
|
||||
|
||||
- Finalized the production identity as 0.3.8 across status, exports and support bundles, including the correct schema-12 capability report.
|
||||
- Corrected the live system-status capability flag to report schema 12 after the forward migration.
|
||||
|
||||
- Passed the complete isolated Unraid 0.3.8-dev gate on port 1232: real PostgreSQL tests, clean Compose bootstrap, three read-only library scans, browser/axe/reflow stories, restart, backup/restore, invariant source manifest, SBOM, secret scan and zero Critical/High container findings.
|
||||
- Proved the production upgrade path on an isolated copy from schema 6 to schema 12 while preserving exactly 538 games and 13,856 artifacts.
|
||||
- Verified selected cached artwork for every visible candidate game (372/372); the sole raw record without artwork is deterministically hidden as non-game DLC-unlocker support content.
|
||||
- Made the containerized secret gate scan the materialized source tree so linked Git worktrees cannot yield a misleading zero-commit pass.
|
||||
|
||||
- Added a gated post-0.3.7 improvement roadmap covering catalog consistency, review-noise reduction, artwork quality, offline metadata, premium navigation, operations and full-story release hardening.
|
||||
- Centralized visible-game filtering across dashboard, search and platform totals; status now distinguishes excluded support titles and full/incremental scan recency.
|
||||
- Added deterministic platform-suffix and possessive cleanup while retaining manual title locks and source evidence.
|
||||
- Added server-side review grouping plus exact-scope transactional bulk resolution and audited undo.
|
||||
- Added persistent artwork quality evidence, deterministic portrait scoring and an artwork review query.
|
||||
- Replaced synchronous full-catalog artwork enrichment with leased PostgreSQL jobs, durable progress and bounded retry.
|
||||
- Simplified platform navigation, removed the duplicated Library platform strip, added semantic active states and persisted Library layout preference.
|
||||
- Added rich-metadata claim endpoints and semantic Overview, Releases, Files & Integrity, Artwork and Provenance game-detail tabs.
|
||||
- Added bounded LaunchBox metadata snapshots, durable metadata-enrichment jobs, provider freshness/health reporting and last-known-good offline imports without overriding manual claims.
|
||||
- Added Vitest/Testing Library infrastructure, split API rate limits with correct 429 responses, reduced routine artwork log noise and restored favicon compatibility.
|
||||
- Added actionable Attention filters for cause/path, library, platform, severity and age; library settings now show latest scan health and Activity names metadata jobs in operator language.
|
||||
- Added a bounded operational metrics endpoint and Settings pulse for job queue depth, scan throughput, review backlog, artwork quality and provider failures.
|
||||
- Preserved each downloaded/uploaded artwork version in app-owned storage and added game-scoped candidate listing plus conflict-safe manual cover selection in the Artwork detail tab.
|
||||
- Added persistent card density, removable active-filter chips, reset controls, a collapsible mobile filter sheet and contextual Library actions; expanded frontend preference and browser-flow coverage.
|
||||
- Added exact-scope bulk defer with audit/undo and removed raw finding diagnostics from support bundles by construction.
|
||||
- Consolidated `STATUS.md` around current 0.3.8-dev evidence so historical release claims cannot be mistaken for candidate validation.
|
||||
- Extracted and tested read/mutation/expensive-job rate-limit profiles, including metadata enrichment, while retaining runtime `429` and `Retry-After` behavior.
|
||||
- Changed artwork enrichment to continue across providers when a candidate is below the verified portrait threshold, preserve each candidate and deterministically select the best non-rejected result; successful reconciliation now queues idempotent artwork and metadata jobs automatically.
|
||||
- Added durable per-scan new/changed/moved/deleted counters, set-based missing reconciliation for large libraries and operator-readable job duration/running state in Activity.
|
||||
- Added schema 11 game-to-library ownership links and a bounded library-health aggregate for artifact/game counts, detected topology and last full/quick scans in Settings.
|
||||
- Parameterized Compose image/appdata isolation and added a fail-closed Unraid candidate gate covering schema, health, read-only mounts, source manifests, Playwright/axe, restart, dump/restore, SBOM, secret and vulnerability scans.
|
||||
- Added responsive in-context editing for all rich game metadata fields, with durable manual locks, conflict visibility, save feedback and provider-refresh protection explained at the point of use.
|
||||
- Added schema 12 persistent artifact overrides and exact-scope Attention actions for support classification, platform acceptance and bounded folder exclusions, including rescan survival, evidence, audit and complete artifact/library/rule undo.
|
||||
- Replaced filename-only exclusion matching with bounded relative `*`, `**` and `?` glob semantics and regression coverage.
|
||||
- Expanded the candidate browser gate with locked metadata editing/provenance, bulk-classification undo, horizontal-overflow assertions at 320/360/390/430/1280/1440/1920/3440 widths and a 200% zoom check.
|
||||
- Made the isolated candidate gate run locked .NET restore/format/build/tests with PostgreSQL Testcontainers enabled and frontend unit/build/audit before browser, restart, restore and security evidence.
|
||||
- Fixed clean non-root appdata ownership and removed the fixed shared Compose network name so candidate and production projects receive isolated networks.
|
||||
- Made a clean candidate self-bootstrap and verify all three configured roots, complete bounded quick scans before browser gates, and use the empirically detected `/mnt/disks/PS5-Games` archive instead of the empty nominal share.
|
||||
|
||||
### Complete verified cover catalog 0.3.7
|
||||
|
||||
- Added a bounded, cached and streaming LaunchBox metadata provider without requiring an API key; catalog ZIP size, XML entry size and compression ratio are constrained and source archives are never extracted.
|
||||
- Added platform-aware exact and conservative fuzzy matching plus deterministic aliases for scene names, edition labels, Roman numerals and common abbreviated titles.
|
||||
- Excluded Steam launcher, Frosty Mod Tool and DLC-unlocker roots from the visible game catalog while preserving their scan evidence as non-game support content.
|
||||
- Reached verified live artwork coverage for all 389 active game titles: 533 stored game records processed, 533 covers available, zero unmatched and zero failed.
|
||||
|
||||
### Platform collection rail and Nintendo covers 0.3.6
|
||||
|
||||
- Replaced the abbreviated Collections submenu with a complete, scrollable collection rail containing all platforms, platform-family badges, file and game counts, selected states and an explicit all-games entry.
|
||||
- Added high-confidence artwork matching against Nintendo's official European product catalog, including verified Switch packshots.
|
||||
- Improved lookup normalization for scene-style region, platform and release-group suffixes and added conservative fuzzy fallback within platform-specific Libretro catalogs.
|
||||
- Increased proven cover coverage on the live dataset candidate from 262 to 328 of 394 visible games without substituting uncertain artwork.
|
||||
|
||||
### Premium widescreen library and verified cover enrichment 0.3.5
|
||||
|
||||
- Expanded the desktop canvas to 2180 px with a polished 278 px collection rail and an eight-card ultrawide library grid.
|
||||
- Refined the desktop platform hierarchy, active states, archive safety indicator, mobile bottom navigation and responsive filter layout.
|
||||
- Added bounded, cancellable and serialized cover enrichment from official Libretro thumbnail catalogs and exact or high-confidence Steam Store matches.
|
||||
- Persisted artwork source provenance and exposed it in the game drawer; uploaded and read-only library sidecar covers remain authoritative.
|
||||
- Added an authenticated `Find covers` workflow while retaining deterministic generated artwork whenever external catalogs are unavailable or cannot be matched safely.
|
||||
- Verified desktop at 2560×1080 and mobile at 390×844 without page overflow, plus production workflow and WCAG A/AA gates.
|
||||
|
||||
### Archive experience and managed artwork 0.3.4
|
||||
|
||||
- Reworked the application shell, Home, Library, cards, drawers, scan states and empty states with the supplied Stitch Archive Intelligence visual language.
|
||||
- Added a bounded app-owned artwork store with validated JPEG, PNG and WebP uploads; source libraries remain read-only.
|
||||
- Added a deterministic artwork cascade: uploaded cover, library sidecar, then a platform-specific generated cover, so every game remains visually identifiable offline.
|
||||
- Added lazy artwork loading and bounded card rendering for large collections.
|
||||
- Reduced desktop navigation noise by showing platform shortcuts only inside Library and retained complete platform browsing in the Library topology.
|
||||
- Added animated archive, scan and empty-state illustrations with reduced-motion fallbacks.
|
||||
- Corrected mobile filter overflow and completed light-theme WCAG contrast coverage.
|
||||
- Expanded integration and browser gates for artwork persistence, mobile platform navigation and production UI workflows.
|
||||
|
||||
### Library intelligence 0.3.3
|
||||
|
||||
- Added live platform subnavigation beneath Library in the desktop navigation rail, including real game counts and selected state.
|
||||
- Added deterministic non-game content policy for tool, emulator, BIOS, firmware, cache, save, runtime and console-support directories. These records remain auditable as ignored support files but no longer inflate Unknown or Attention.
|
||||
- Added canonical title derivation for region/revision tags, repack markers, scene-release suffixes, disc suffixes and dot/underscore filename notation while preserving manual title locks.
|
||||
- Added bounded authenticated delivery of matching local sidecar artwork from read-only libraries, with abstract offline placeholders when no proven artwork exists.
|
||||
- Automatically resolves obsolete Unknown reviews when the corresponding artifact is deterministically classified as non-game support content.
|
||||
|
||||
### Platform navigation and Stitch integration 0.3.2
|
||||
|
||||
- Added complete server-side platform filtering before game count, ordering and pagination.
|
||||
- Added an always-visible platform navigator to Games and a richer, data-backed platform overview that opens filtered collections directly.
|
||||
- Integrated Stitch's Archive Intelligence tokens, tonal surfaces, typography hierarchy, circular platform motifs and compact mobile collection patterns without importing fictional features or copyrighted artwork.
|
||||
- Corrected the inventory export application version, which had remained at 0.1.0, and aligned API, support bundles, exports and deployment image on 0.3.2.
|
||||
- Added PostgreSQL regression coverage for positive and empty platform filters and expanded desktop/mobile browser journeys for platform navigation.
|
||||
|
||||
### UX simplification 0.3.1
|
||||
|
||||
- Replaced thirteen architecture-oriented destinations with Home, Library, Attention, Activity and Settings.
|
||||
- Rebuilt Home as a compact operational dashboard with one scan action and four primary metrics.
|
||||
- Made Games the default collection view and integrated Platforms, Windows installers and Advanced Files into Library.
|
||||
- Added deterministic local placeholder covers, browse filters, layouts, focused creation and game/file drawers with collapsed technical evidence.
|
||||
- Unified review and health signals into one prioritized Attention inbox; unified scans, history, snapshots and exports under Activity.
|
||||
- Moved identification catalogs into sectioned Settings and added a focused library wizard with advanced policies hidden by default.
|
||||
- Added real grouped global search for games, platforms, files, paths and explicit hashes with Ctrl/Cmd+K and direct detail navigation.
|
||||
- Split the former 64 KB frontend entry point into feature-oriented app, API, component and workspace modules.
|
||||
- Fixed persisted integrity-snapshot JSON deserialization and added a deterministic regression test.
|
||||
- Replaced the old route-count browser tests with five-destination desktop/mobile journeys, overlay focus, screenshots and accessibility checks.
|
||||
|
||||
### Completion pass
|
||||
|
||||
- Added bounded MSI database-table inspection for product properties, media ranges, file relationships and embedded/external cabinets without extraction or installer APIs.
|
||||
- Added deterministic SNES/NDS recognition and richer ROM header provenance for Nintendo and Sega cartridge families.
|
||||
- Added persisted operational settings, schema 6 optimistic concurrency, bounded retention/workflow controls and a bounded PostgreSQL pool.
|
||||
- Added dark/light/system appearance, reusable animated archive illustrations and responsive reduced-motion behavior.
|
||||
- Expanded the operational Settings workspace with analyzer capabilities, privacy guarantees and server preferences.
|
||||
- Added correlation-scoped structured HTTP completion logs without credential/header logging.
|
||||
- Corrected the application/schema capability surfaces to version 0.3.0/schema 6.
|
||||
- Verified an isolated restored candidate stack, PostgreSQL 16.14 container gates, desktop/mobile Playwright, axe, source immutability, Gitleaks, CycloneDX and Grype gates.
|
||||
|
||||
- Added PostgreSQL 16.14 Testcontainers coverage for repeatable migrations, persisted evidence, restart survival, idempotent scans, audited review undo and optimistic concurrency.
|
||||
- Added a 250,000-artifact PostgreSQL search gate and a trigram index for bounded contains-search query plans.
|
||||
- Replaced unsupported Npgsql multi-statement mutations with explicit atomic transactions.
|
||||
- Added bounded PE version-resource and MSI SummaryInformation inspection without invoking executable code, COM or Windows Installer.
|
||||
- Added server-side game search/pagination and operational release creation to the game detail workspace.
|
||||
- Added editable per-root hash, archive-inspection and concurrency policies plus complete new-root recursion controls.
|
||||
- Added bounded static 7z header enumeration with member/path/size/ratio limits and deterministic synthetic tests; no member is extracted.
|
||||
- Added reproducible Gitleaks, Syft and Grype gates with pinned scanner image digests and retained JSON/CycloneDX evidence.
|
||||
- Added desktop/mobile Playwright workflow coverage for metrics, search, review filters, keyboard navigation and responsive overflow.
|
||||
- Added deterministic 10,000 and 100,000 item classifier scale gates with time and allocation ceilings.
|
||||
|
||||
### Product-audit remediation
|
||||
|
||||
- Replaced the hardcoded archive confidence score with persisted-data metrics.
|
||||
- Added artifact evidence details, exact-duplicate relationships and archive/bundle context.
|
||||
- Added operational Storage, Library Health and Windows Archive views.
|
||||
- Added artifact pagination and bounded searchable game browsing for large collections.
|
||||
- Added review deferral, scan pause/resume/cancel/retry controls and real progress calculation.
|
||||
- Added direct root verification, quick/deep scan actions and enable/disable management.
|
||||
- Improved mobile data density, keyboard artifact inspection and responsive detail layouts.
|
||||
|
||||
### Added
|
||||
|
||||
- Phase 0–2 production-oriented vertical slice: domain model, PostgreSQL schema, safe scanning, streamed hashes, evidence-backed classification, exact duplicate query, durable scan records and responsive web application.
|
||||
- CUE/M3U bundle validation foundation, review/audit endpoints, capability reporting, inventory exports and synthetic fixtures.
|
||||
- Non-root container build, hardened Compose stack, Unraid template and operational runbook.
|
||||
- Rescan-stable CUE/M3U and Windows installer bundles, static PE inspection and additional ROM signatures.
|
||||
- Streaming Logiqx catalog import, explained exact-hash matching, immutable snapshots, comparison and complete checksum manifests.
|
||||
- Operational Catalog Sources and Snapshots & Exports screens backed by PostgreSQL APIs.
|
||||
- Manual games and releases with append-only locked claims, optimistic concurrency, audited review resolutions and compensating undo.
|
||||
- Stable health-rule findings and sanitized, path-redacted support bundles exposed through the API and UI.
|
||||
- Bounded ZIP central-directory inspection with persisted virtual members and no archive extraction.
|
||||
- Bounded-memory JSON, CSV and SHA256SUMS exports with format version headers, escaping and cancellation cleanup.
|
||||
- Configurable host port defaulting to 1230 and a pinned PostgreSQL 16.14 deployment baseline for verified Unraid compatibility.
|
||||
- Local administrator-token login with tab-scoped credential storage, explicit sign-out and a publicly loadable application shell.
|
||||
- Three independent deployment roots for the general Games collection and dedicated PS4/PS5 mounts.
|
||||
- Evidence-backed platform inference for PC, Nintendo and PlayStation directory taxonomies, including dedicated PS4/PS5 roots.
|
||||
- Configurable non-root runtime UID/GID for read-only libraries with owner-only directory permissions.
|
||||
- Honest directory-versus-extension conflict handling prevents multipart volumes from being mislabeled as another platform.
|
||||
- Idempotent scan-to-game materialization with directory provenance, real platform aggregates and an accessible mobile navigation drawer.
|
||||
|
||||
### Security
|
||||
|
||||
- Alpine production images include the Kerberos/GSS runtime required for stable PostgreSQL connections; Linux read-only verification uses managed mount metadata without unsafe native structs.
|
||||
- Source filesystem interface exposes read operations only and rejects descriptor path traversal.
|
||||
- Scanned content is never launched; source mounts are read-only in deployment definitions.
|
||||
- Support bundles omit library paths, raw scan errors, credentials and source content.
|
||||
- Production startup fails closed when the PostgreSQL connection string is absent instead of silently targeting localhost.
|
||||
- Upgraded the vulnerable transitive `Microsoft.OpenApi` 2.0.0 dependency to 2.11.0; current NuGet and npm audits report no known vulnerable packages.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- See `STATUS.md`; intentional unsupported capabilities are reported explicitly. The gated 0.3.0 candidate is awaiting only the controlled live replacement and post-deployment validation.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Contributing
|
||||
|
||||
Keep Ludarium changes portable, legally redistributable and safe for private libraries.
|
||||
|
||||
- Branch from the protected default branch.
|
||||
- Use synthetic metadata and homebrew/public-domain fixtures; do not commit ROMs, firmware, keys, private library databases or account data.
|
||||
- Document the origin, license and checksum expectations for emulator components, icons, metadata sources and any bundled asset.
|
||||
- Keep player URLs, bind addresses and storage roots configurable; examples must not contain operator-specific infrastructure.
|
||||
- Add tests for behavioural changes and run the documented .NET, frontend, container and managed validation gates.
|
||||
- Explain schema, migration, content, player-isolation, security and deployment impact in the pull request.
|
||||
|
||||
Report vulnerabilities according to `SECURITY.md`.
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<LudariumVersion>$([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)VERSION').Trim())</LudariumVersion>
|
||||
<Version>$(LudariumVersion)</Version>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AnalysisLevel>latest-recommended</AnalysisLevel>
|
||||
<NoWarn>$(NoWarn);CA1050;CA1725;CA1727;CA1848</NoWarn>
|
||||
<Deterministic>true</Deterministic>
|
||||
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 AS emulator
|
||||
ARG EMULATORJS_VERSION=4.2.3
|
||||
ARG EMULATORJS_SHA256=07d451bc06fa3ad04ab30d9b94eb63ac34ad0babee52d60357b002bde8f3850b
|
||||
COPY .build-inputs/emulatorjs/ /tmp/emulatorjs-input/
|
||||
RUN fetch() { \
|
||||
url="$1"; destination="$2"; attempt=1; \
|
||||
while :; do \
|
||||
if wget -q -T 60 "$url" -O "${destination}.part"; then mv "${destination}.part" "$destination"; return 0; fi; \
|
||||
rm -f "${destination}.part"; \
|
||||
[ "$attempt" -lt 5 ] || return 1; \
|
||||
sleep "$attempt"; attempt=$((attempt + 1)); \
|
||||
done; \
|
||||
} \
|
||||
&& apk add --no-cache p7zip \
|
||||
&& if [ -f "/tmp/emulatorjs-input/${EMULATORJS_VERSION}.7z" ]; then cp "/tmp/emulatorjs-input/${EMULATORJS_VERSION}.7z" /tmp/emulatorjs.7z; else fetch "https://github.com/EmulatorJS/EmulatorJS/releases/download/v${EMULATORJS_VERSION}/${EMULATORJS_VERSION}.7z" /tmp/emulatorjs.7z; fi \
|
||||
&& echo "${EMULATORJS_SHA256} /tmp/emulatorjs.7z" | sha256sum -c - \
|
||||
&& 7z x -y /tmp/emulatorjs.7z -o/emulatorjs >/dev/null \
|
||||
&& find /emulatorjs/data/cores -type f ! -path '/emulatorjs/data/cores/reports/*' ! -name 'fceumm-*' ! -name 'snes9x-*' ! -name 'gambatte-*' ! -name 'mgba-*' ! -name 'melonds-*' ! -name 'genesis_plus_gx-*' ! -name 'pcsx_rearmed-*' ! -name 'mednafen_psx_hw-*' ! -name 'ppsspp-*' ! -name 'cores.json' ! -name 'core-README.md' -delete \
|
||||
&& sed -i 's#https://cdn.emulatorjs.org/stable/data/version.json#/emulatorjs/data/version.json#g' /emulatorjs/data/emulator.min.js /emulatorjs/data/src/emulator.js \
|
||||
&& ! grep -R -F 'https://cdn.emulatorjs.org/stable/data/version.json' /emulatorjs/data \
|
||||
&& printf '%s\n' 'EmulatorJS 4.2.3 update metadata is resolved from the bundled same-origin version.json; no runtime CDN request is allowed.' > /emulatorjs/LUDARIUM_PATCHES.txt \
|
||||
&& rm /tmp/emulatorjs.7z
|
||||
|
||||
FROM alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 AS playjs
|
||||
ARG PLAYJS_UPSTREAM_COMMIT=04bde0df87ee7c0e2f0151b51bb2cc22c88541da
|
||||
ARG PLAYJS_JS_SHA256=5599ff16ae5e3534f9b779736aedfa20661821d57be7f03e587e31ef8d090317
|
||||
ARG PLAYJS_WASM_SHA256=de3ae0efdd7938d1b5ac754a096ec180094d64702ae84b962c4bb9f79abb1da7
|
||||
ARG PLAYJS_LICENSE_SHA256=e9089da5950600267bb657503aba6eb59cb679b455c2997d341d83144144daab
|
||||
RUN fetch() { \
|
||||
url="$1"; destination="$2"; attempt=1; \
|
||||
while :; do \
|
||||
if wget -q -T 60 "$url" -O "${destination}.part"; then mv "${destination}.part" "$destination"; return 0; fi; \
|
||||
rm -f "${destination}.part"; \
|
||||
[ "$attempt" -lt 5 ] || return 1; \
|
||||
sleep "$attempt"; attempt=$((attempt + 1)); \
|
||||
done; \
|
||||
} \
|
||||
&& mkdir -p /playjs \
|
||||
&& fetch https://playjs.purei.org/Play.js /playjs/Play.js \
|
||||
&& fetch https://playjs.purei.org/Play.wasm /playjs/Play.wasm \
|
||||
&& fetch "https://raw.githubusercontent.com/jpd002/Play-/${PLAYJS_UPSTREAM_COMMIT}/License.txt" /playjs/LICENSE.txt \
|
||||
&& echo "${PLAYJS_JS_SHA256} /playjs/Play.js" | sha256sum -c - \
|
||||
&& echo "${PLAYJS_WASM_SHA256} /playjs/Play.wasm" | sha256sum -c - \
|
||||
&& echo "${PLAYJS_LICENSE_SHA256} /playjs/LICENSE.txt" | sha256sum -c - \
|
||||
&& printf '%s\n' \
|
||||
"source=https://github.com/jpd002/Play-" \
|
||||
"commit=${PLAYJS_UPSTREAM_COMMIT}" \
|
||||
"Play.js.sha256=${PLAYJS_JS_SHA256}" \
|
||||
"Play.wasm.sha256=${PLAYJS_WASM_SHA256}" \
|
||||
"License.txt.sha256=${PLAYJS_LICENSE_SHA256}" > /playjs/PROVENANCE.txt
|
||||
|
||||
FROM alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 AS n64wasm
|
||||
ARG N64WASM_COMMIT=bfac222f8a27287022844b47000328531834e9c1
|
||||
ARG N64WASM_JS_SHA256=1db1dce5e356d1a1673d5dab6f73afd30bf4b69523c3292b1d44e33ff8f4d22e
|
||||
ARG N64WASM_WASM_SHA256=c829ac2ac2a620816a3906af1135c1203f606b92ae7a6e118ea8a90749f028ea
|
||||
ARG N64WASM_ASSETS_SHA256=72f8b414469d0b40630c82ca193b6f67dba906bd84ccc43991ee1ac3e4b900a2
|
||||
ARG N64WASM_LICENSE_SHA256=46f8e130794f92e25e5865be3a363c3fb30210a428303c27262690cfc375c3d4
|
||||
RUN fetch() { \
|
||||
url="$1"; destination="$2"; attempt=1; \
|
||||
while :; do \
|
||||
if wget -q -T 60 "$url" -O "${destination}.part"; then mv "${destination}.part" "$destination"; return 0; fi; \
|
||||
rm -f "${destination}.part"; \
|
||||
[ "$attempt" -lt 5 ] || return 1; \
|
||||
sleep "$attempt"; attempt=$((attempt + 1)); \
|
||||
done; \
|
||||
} \
|
||||
&& mkdir -p /n64wasm \
|
||||
&& fetch "https://raw.githubusercontent.com/nbarkhina/N64Wasm/${N64WASM_COMMIT}/dist/n64wasm.js" /n64wasm/n64wasm.js \
|
||||
&& fetch "https://raw.githubusercontent.com/nbarkhina/N64Wasm/${N64WASM_COMMIT}/dist/n64wasm.wasm" /n64wasm/n64wasm.wasm \
|
||||
&& fetch "https://raw.githubusercontent.com/nbarkhina/N64Wasm/${N64WASM_COMMIT}/dist/assets.zip" /n64wasm/assets.zip \
|
||||
&& fetch "https://raw.githubusercontent.com/nbarkhina/N64Wasm/${N64WASM_COMMIT}/LICENSE" /n64wasm/LICENSE \
|
||||
&& echo "${N64WASM_JS_SHA256} /n64wasm/n64wasm.js" | sha256sum -c - \
|
||||
&& echo "${N64WASM_WASM_SHA256} /n64wasm/n64wasm.wasm" | sha256sum -c - \
|
||||
&& echo "${N64WASM_ASSETS_SHA256} /n64wasm/assets.zip" | sha256sum -c - \
|
||||
&& echo "${N64WASM_LICENSE_SHA256} /n64wasm/LICENSE" | sha256sum -c -
|
||||
|
||||
FROM node:24.4.1-alpine@sha256:820e86612c21d0636580206d802a726f2595366e1b867e564cbc652024151e8a AS web
|
||||
WORKDIR /src
|
||||
COPY src/Ludarium.Web/package.json src/Ludarium.Web/package-lock.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
COPY src/Ludarium.Web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0.302@sha256:72dd743782f2ae7e5476fd64f6a460045e3998dc862218b80e6944cba79a01b0 AS build
|
||||
WORKDIR /src
|
||||
COPY NuGet.config global.json Directory.Build.props VERSION Ludarium.slnx ./
|
||||
COPY src/ ./src/
|
||||
RUN dotnet restore src/Ludarium.Api/Ludarium.Api.csproj --locked-mode \
|
||||
&& dotnet restore src/Ludarium.HealthCheck/Ludarium.HealthCheck.csproj --locked-mode
|
||||
COPY --from=web /artifacts/web/ ./src/Ludarium.Api/wwwroot/
|
||||
COPY --from=emulator /emulatorjs/ ./src/Ludarium.Api/wwwroot/emulatorjs/
|
||||
COPY --from=playjs /playjs/ ./src/Ludarium.Api/wwwroot/playjs/
|
||||
COPY --from=n64wasm /n64wasm/ ./src/Ludarium.Api/wwwroot/n64wasm/
|
||||
RUN dotnet publish src/Ludarium.Api/Ludarium.Api.csproj -c Release --no-restore -o /app/publish /p:UseAppHost=false \
|
||||
&& dotnet publish src/Ludarium.HealthCheck/Ludarium.HealthCheck.csproj -c Release --no-restore -o /app/health /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0.11-azurelinux3.0-distroless@sha256:c2fbd15651452871430ce3fbd0083c128b376dbac786797be1c7dd4091d20a23 AS final
|
||||
ARG LUDARIUM_SOURCE_REVISION=local
|
||||
ARG LUDARIUM_RELEASE_VERSION=unversioned
|
||||
LABEL org.opencontainers.image.revision="${LUDARIUM_SOURCE_REVISION}"
|
||||
LABEL org.opencontainers.image.version="${LUDARIUM_RELEASE_VERSION}"
|
||||
WORKDIR /app
|
||||
ENV ASPNETCORE_URLS=http://0.0.0.0:8734 \
|
||||
DOTNET_EnableDiagnostics=0 \
|
||||
LUDARIUM_EXPORTS=/app/exports
|
||||
COPY --from=build --chown=$APP_UID:0 /app/publish ./
|
||||
COPY --from=build --chown=$APP_UID:0 /app/health ./health/
|
||||
USER $APP_UID
|
||||
EXPOSE 8734
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD ["dotnet", "/app/health/Ludarium.HealthCheck.dll"]
|
||||
ENTRYPOINT ["dotnet", "Ludarium.Api.dll"]
|
||||
@@ -0,0 +1,15 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM lscr.io/linuxserver/dolphin:2606-ls78@sha256:7b916f1c97d591b96525b67128c1bf82ce6ede0eff6816fe1c0a3d70f744506d
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 xdotool \
|
||||
&& apt-get purge -y --auto-remove \
|
||||
bsd-mailx catatonit cmake containerd.io cron curl docker-buildx-plugin docker-ce docker-ce-cli \
|
||||
docker-compose-plugin exim4-base exim4-config exim4-daemon-light fuse-overlayfs g++ gcc git make \
|
||||
openssh-client perl perl-modules-5.40 \
|
||||
&& rm -f /etc/s6-overlay/s6-rc.d/user/contents.d/svc-cron /etc/s6-overlay/s6-rc.d/user/contents.d/svc-docker \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --chmod=644 deploy/ludarium_sidecar.py /opt/ludarium/ludarium_sidecar.py
|
||||
COPY --chmod=755 deploy/dolphin-controller.py /opt/ludarium/dolphin-controller.py
|
||||
COPY --chmod=755 deploy/dolphin-controller-run /custom-services.d/ludarium-dolphin-controller
|
||||
RUN sed -i 's/\r$//' /custom-services.d/ludarium-dolphin-controller
|
||||
@@ -0,0 +1,37 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM lscr.io/linuxserver/eden@sha256:f18ec24d55266daab9a440aba5fe3e9587494cf7a9aad63f93806efe4081be83
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libexpat1=2.8.3-1~deb13u1 \
|
||||
&& apt-get purge -y --auto-remove \
|
||||
bsd-mailx \
|
||||
catatonit \
|
||||
cmake \
|
||||
containerd.io \
|
||||
cron \
|
||||
curl \
|
||||
docker-buildx-plugin \
|
||||
docker-ce \
|
||||
docker-ce-cli \
|
||||
docker-compose-plugin \
|
||||
exim4-base \
|
||||
exim4-config \
|
||||
exim4-daemon-light \
|
||||
fuse-overlayfs \
|
||||
g++ \
|
||||
gcc \
|
||||
git \
|
||||
make \
|
||||
openssh-client \
|
||||
perl \
|
||||
perl-modules-5.40 \
|
||||
&& rm -f \
|
||||
/etc/s6-overlay/s6-rc.d/user/contents.d/svc-cron \
|
||||
/etc/s6-overlay/s6-rc.d/user/contents.d/svc-docker \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --chmod=644 deploy/ludarium_sidecar.py /opt/ludarium/ludarium_sidecar.py
|
||||
COPY --chmod=755 deploy/eden-controller.py /opt/ludarium/eden-controller.py
|
||||
COPY --chmod=755 deploy/eden-controller-run /custom-services.d/ludarium-controller
|
||||
COPY --chmod=755 deploy/audit-eden-runtime.sh /opt/ludarium/audit-eden-runtime.sh
|
||||
COPY deploy/security/eden-controller-0.4.9.openvex.json /usr/share/ludarium/security/eden-controller.openvex.json
|
||||
RUN sed -i 's/\r$//' /custom-services.d/ludarium-controller
|
||||
@@ -0,0 +1,144 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 AS emulator
|
||||
ARG EMULATORJS_VERSION=4.2.3
|
||||
ARG EMULATORJS_SHA256=07d451bc06fa3ad04ab30d9b94eb63ac34ad0babee52d60357b002bde8f3850b
|
||||
COPY .build-inputs/emulatorjs/ /tmp/emulatorjs-input/
|
||||
RUN fetch() { \
|
||||
url="$1"; destination="$2"; attempt=1; \
|
||||
while :; do \
|
||||
if wget -q -T 60 "$url" -O "${destination}.part"; then mv "${destination}.part" "$destination"; return 0; fi; \
|
||||
rm -f "${destination}.part"; \
|
||||
[ "$attempt" -lt 5 ] || return 1; \
|
||||
sleep "$attempt"; attempt=$((attempt + 1)); \
|
||||
done; \
|
||||
} \
|
||||
&& apk add --no-cache p7zip \
|
||||
&& if [ -f "/tmp/emulatorjs-input/${EMULATORJS_VERSION}.7z" ]; then cp "/tmp/emulatorjs-input/${EMULATORJS_VERSION}.7z" /tmp/emulatorjs.7z; else fetch "https://github.com/EmulatorJS/EmulatorJS/releases/download/v${EMULATORJS_VERSION}/${EMULATORJS_VERSION}.7z" /tmp/emulatorjs.7z; fi \
|
||||
&& echo "${EMULATORJS_SHA256} /tmp/emulatorjs.7z" | sha256sum -c - \
|
||||
&& 7z x -y /tmp/emulatorjs.7z -o/emulatorjs >/dev/null \
|
||||
&& find /emulatorjs/data/cores -type f ! -path '/emulatorjs/data/cores/reports/*' ! -name 'fceumm-*' ! -name 'snes9x-*' ! -name 'gambatte-*' ! -name 'mgba-*' ! -name 'melonds-*' ! -name 'genesis_plus_gx-*' ! -name 'pcsx_rearmed-*' ! -name 'mednafen_psx_hw-*' ! -name 'ppsspp-*' ! -name 'cores.json' ! -name 'core-README.md' -delete \
|
||||
&& sed -i 's#https://cdn.emulatorjs.org/stable/data/version.json#/emulatorjs/data/version.json#g' /emulatorjs/data/emulator.min.js /emulatorjs/data/src/emulator.js \
|
||||
&& ! grep -R -F 'https://cdn.emulatorjs.org/stable/data/version.json' /emulatorjs/data \
|
||||
&& printf '%s\n' 'EmulatorJS 4.2.3 update metadata is resolved from the bundled same-origin version.json; no runtime CDN request is allowed.' > /emulatorjs/LUDARIUM_PATCHES.txt \
|
||||
&& rm /tmp/emulatorjs.7z
|
||||
|
||||
FROM alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 AS playjs
|
||||
ARG PLAYJS_UPSTREAM_COMMIT=04bde0df87ee7c0e2f0151b51bb2cc22c88541da
|
||||
ARG PLAYJS_JS_SHA256=5599ff16ae5e3534f9b779736aedfa20661821d57be7f03e587e31ef8d090317
|
||||
ARG PLAYJS_WASM_SHA256=de3ae0efdd7938d1b5ac754a096ec180094d64702ae84b962c4bb9f79abb1da7
|
||||
ARG PLAYJS_LICENSE_SHA256=e9089da5950600267bb657503aba6eb59cb679b455c2997d341d83144144daab
|
||||
RUN fetch() { \
|
||||
url="$1"; destination="$2"; attempt=1; \
|
||||
while :; do \
|
||||
if wget -q -T 60 "$url" -O "${destination}.part"; then mv "${destination}.part" "$destination"; return 0; fi; \
|
||||
rm -f "${destination}.part"; \
|
||||
[ "$attempt" -lt 5 ] || return 1; \
|
||||
sleep "$attempt"; attempt=$((attempt + 1)); \
|
||||
done; \
|
||||
} \
|
||||
&& mkdir -p /playjs \
|
||||
&& fetch https://playjs.purei.org/Play.js /playjs/Play.js \
|
||||
&& fetch https://playjs.purei.org/Play.wasm /playjs/Play.wasm \
|
||||
&& fetch "https://raw.githubusercontent.com/jpd002/Play-/${PLAYJS_UPSTREAM_COMMIT}/License.txt" /playjs/LICENSE.txt \
|
||||
&& echo "${PLAYJS_JS_SHA256} /playjs/Play.js" | sha256sum -c - \
|
||||
&& echo "${PLAYJS_WASM_SHA256} /playjs/Play.wasm" | sha256sum -c - \
|
||||
&& echo "${PLAYJS_LICENSE_SHA256} /playjs/LICENSE.txt" | sha256sum -c - \
|
||||
&& printf '%s\n' \
|
||||
"source=https://github.com/jpd002/Play-" \
|
||||
"commit=${PLAYJS_UPSTREAM_COMMIT}" \
|
||||
"Play.js.sha256=${PLAYJS_JS_SHA256}" \
|
||||
"Play.wasm.sha256=${PLAYJS_WASM_SHA256}" \
|
||||
"License.txt.sha256=${PLAYJS_LICENSE_SHA256}" > /playjs/PROVENANCE.txt
|
||||
|
||||
FROM alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 AS n64wasm
|
||||
ARG N64WASM_COMMIT=bfac222f8a27287022844b47000328531834e9c1
|
||||
ARG N64WASM_JS_SHA256=1db1dce5e356d1a1673d5dab6f73afd30bf4b69523c3292b1d44e33ff8f4d22e
|
||||
ARG N64WASM_WASM_SHA256=c829ac2ac2a620816a3906af1135c1203f606b92ae7a6e118ea8a90749f028ea
|
||||
ARG N64WASM_ASSETS_SHA256=72f8b414469d0b40630c82ca193b6f67dba906bd84ccc43991ee1ac3e4b900a2
|
||||
ARG N64WASM_LICENSE_SHA256=46f8e130794f92e25e5865be3a363c3fb30210a428303c27262690cfc375c3d4
|
||||
RUN fetch() { \
|
||||
url="$1"; destination="$2"; attempt=1; \
|
||||
while :; do \
|
||||
if wget -q -T 60 "$url" -O "${destination}.part"; then mv "${destination}.part" "$destination"; return 0; fi; \
|
||||
rm -f "${destination}.part"; \
|
||||
[ "$attempt" -lt 5 ] || return 1; \
|
||||
sleep "$attempt"; attempt=$((attempt + 1)); \
|
||||
done; \
|
||||
} \
|
||||
&& mkdir -p /n64wasm \
|
||||
&& fetch "https://raw.githubusercontent.com/nbarkhina/N64Wasm/${N64WASM_COMMIT}/dist/n64wasm.js" /n64wasm/n64wasm.js \
|
||||
&& fetch "https://raw.githubusercontent.com/nbarkhina/N64Wasm/${N64WASM_COMMIT}/dist/n64wasm.wasm" /n64wasm/n64wasm.wasm \
|
||||
&& fetch "https://raw.githubusercontent.com/nbarkhina/N64Wasm/${N64WASM_COMMIT}/dist/assets.zip" /n64wasm/assets.zip \
|
||||
&& fetch "https://raw.githubusercontent.com/nbarkhina/N64Wasm/${N64WASM_COMMIT}/LICENSE" /n64wasm/LICENSE \
|
||||
&& echo "${N64WASM_JS_SHA256} /n64wasm/n64wasm.js" | sha256sum -c - \
|
||||
&& echo "${N64WASM_WASM_SHA256} /n64wasm/n64wasm.wasm" | sha256sum -c - \
|
||||
&& echo "${N64WASM_ASSETS_SHA256} /n64wasm/assets.zip" | sha256sum -c - \
|
||||
&& echo "${N64WASM_LICENSE_SHA256} /n64wasm/LICENSE" | sha256sum -c -
|
||||
|
||||
FROM node:24.4.1-alpine@sha256:820e86612c21d0636580206d802a726f2595366e1b867e564cbc652024151e8a AS web
|
||||
WORKDIR /src
|
||||
COPY src/Ludarium.Web/package.json src/Ludarium.Web/package-lock.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
COPY src/Ludarium.Web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0.302@sha256:72dd743782f2ae7e5476fd64f6a460045e3998dc862218b80e6944cba79a01b0 AS build
|
||||
WORKDIR /src
|
||||
COPY NuGet.config global.json Directory.Build.props VERSION Ludarium.slnx ./
|
||||
COPY src/ ./src/
|
||||
RUN dotnet restore src/Ludarium.Api/Ludarium.Api.csproj --locked-mode \
|
||||
&& dotnet restore src/Ludarium.HealthCheck/Ludarium.HealthCheck.csproj --locked-mode
|
||||
COPY --from=web /artifacts/web/ ./src/Ludarium.Api/wwwroot/
|
||||
COPY --from=emulator /emulatorjs/ ./src/Ludarium.Api/wwwroot/emulatorjs/
|
||||
COPY --from=playjs /playjs/ ./src/Ludarium.Api/wwwroot/playjs/
|
||||
COPY --from=n64wasm /n64wasm/ ./src/Ludarium.Api/wwwroot/n64wasm/
|
||||
RUN dotnet publish src/Ludarium.Api/Ludarium.Api.csproj -c Release --no-restore -o /app/publish /p:UseAppHost=false \
|
||||
&& dotnet publish src/Ludarium.HealthCheck/Ludarium.HealthCheck.csproj -c Release --no-restore -o /app/health /p:UseAppHost=false
|
||||
|
||||
FROM postgres:16.15-bookworm@sha256:60f4761b9035e0b8d5218f701a8c3382f641bf12b1604822574cf5be3baeb537 AS postgres-entrypoint
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0.11-noble@sha256:a4556ed033fa96f984bb7a8d348851cb2d36b1281dd2420070045f664fbb5f94 AS final
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG POSTGRES_PACKAGE_VERSION=16.15-0ubuntu0.24.04.1
|
||||
ARG GOSU_PACKAGE_VERSION=1.17-1ubuntu0.24.04.3
|
||||
ARG LOCALES_PACKAGE_VERSION=2.39-0ubuntu8.8
|
||||
ARG LUDARIUM_SOURCE_REVISION=local
|
||||
ARG LUDARIUM_RELEASE_VERSION=unversioned
|
||||
LABEL org.opencontainers.image.revision="${LUDARIUM_SOURCE_REVISION}"
|
||||
LABEL org.opencontainers.image.version="${LUDARIUM_RELEASE_VERSION}"
|
||||
RUN apt-get update \
|
||||
&& apt-get upgrade -y \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
"postgresql-16=${POSTGRES_PACKAGE_VERSION}" \
|
||||
"gosu=${GOSU_PACKAGE_VERSION}" \
|
||||
"locales=${LOCALES_PACKAGE_VERSION}" \
|
||||
&& sed -i 's/^# *en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \
|
||||
&& locale-gen en_US.UTF-8 \
|
||||
&& locale -a | grep -Fx 'en_US.utf8' \
|
||||
&& rm -rf /var/lib/apt/lists/* /var/lib/postgresql/16/main
|
||||
WORKDIR /app
|
||||
ENV ASPNETCORE_URLS=http://0.0.0.0:8734 \
|
||||
DOTNET_ROOT=/usr/share/dotnet \
|
||||
DOTNET_EnableDiagnostics=0 \
|
||||
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false \
|
||||
LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8 \
|
||||
LUDARIUM_EXPORTS=/app/exports \
|
||||
PG_MAJOR=16 \
|
||||
PGDATA=/var/lib/postgresql/data \
|
||||
PATH=/usr/lib/postgresql/16/bin:$PATH \
|
||||
POSTGRES_DB=ludarium \
|
||||
POSTGRES_USER=ludarium
|
||||
COPY --from=postgres-entrypoint /usr/local/bin/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
COPY --from=postgres-entrypoint /usr/local/bin/docker-ensure-initdb.sh /usr/local/bin/docker-ensure-initdb.sh
|
||||
COPY --from=build --chown=1654:1654 /app/publish ./
|
||||
COPY --from=build --chown=1654:1654 /app/health ./health/
|
||||
COPY --chmod=755 deploy/ludarium-entrypoint.sh /usr/local/bin/ludarium-entrypoint.sh
|
||||
COPY --chmod=755 deploy/repair-postgres-collation.sh /usr/local/bin/repair-postgres-collation.sh
|
||||
RUN ln -s docker-ensure-initdb.sh /usr/local/bin/docker-enforce-initdb.sh \
|
||||
&& mkdir -p "$PGDATA" /docker-entrypoint-initdb.d \
|
||||
&& chown -R postgres:postgres /var/lib/postgresql \
|
||||
&& sed -i 's/\r$//' /usr/local/bin/ludarium-entrypoint.sh
|
||||
EXPOSE 8734
|
||||
STOPSIGNAL SIGTERM
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 CMD ["/usr/share/dotnet/dotnet", "/app/health/Ludarium.HealthCheck.dll"]
|
||||
ENTRYPOINT ["/usr/local/bin/ludarium-entrypoint.sh"]
|
||||
@@ -0,0 +1,662 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Ludarium.Api/Ludarium.Api.csproj" />
|
||||
<Project Path="src/Ludarium.Application/Ludarium.Application.csproj" />
|
||||
<Project Path="src/Ludarium.Domain/Ludarium.Domain.csproj" />
|
||||
<Project Path="src/Ludarium.HealthCheck/Ludarium.HealthCheck.csproj" />
|
||||
<Project Path="src/Ludarium.Infrastructure/Ludarium.Infrastructure.csproj" />
|
||||
<Project Path="src/Ludarium.Worker/Ludarium.Worker.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/Ludarium.ArchitectureTests/Ludarium.ArchitectureTests.csproj" />
|
||||
<Project Path="tests/Ludarium.E2E/Ludarium.E2E.csproj" />
|
||||
<Project Path="tests/Ludarium.IntegrationTests/Ludarium.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Ludarium.UnitTests/Ludarium.UnitTests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
# Ludarium
|
||||
|
||||
**A local-first, self-hosted catalog for game archives.** Ludarium turns read-only folders of ROMs,
|
||||
disc images and archived Windows installers into a searchable library with provenance, integrity
|
||||
evidence and an explicit review queue. It is built for Unraid, but the supplied Compose deployment
|
||||
also works on a regular Docker host.
|
||||
|
||||

|
||||
|
||||
## What you can do
|
||||
|
||||
- discover and fingerprint files without renaming, moving or executing them;
|
||||
- group multi-file games, discs, installers, patches and DLC into useful catalog entries;
|
||||
- review uncertain matches instead of silently accepting guesses;
|
||||
- find duplicates and integrity problems while retaining the evidence behind each result;
|
||||
- organise games with collections, tags, favourites, play status, ratings and notes;
|
||||
- enrich metadata from optional providers while keeping manual values authoritative;
|
||||
- export JSON, EmulationStation and Pegasus-compatible catalog data;
|
||||
- keep emulator saves and savestates as bounded, immutable revisions in the Game Data Vault;
|
||||
- optionally launch supported browser or isolated native players.
|
||||
|
||||
The source libraries are mounted read-only. Ludarium writes only to its own PostgreSQL database,
|
||||
cache, exports and explicitly configured player data. Scanned native files are never executed by the
|
||||
cataloging pipeline.
|
||||
|
||||
## Quick start
|
||||
|
||||
You need Docker with Compose, three directories to expose as read-only libraries, and two independent
|
||||
random secrets of at least 32 characters.
|
||||
|
||||
```sh
|
||||
cp deploy/.env.example deploy/.env
|
||||
# Edit deploy/.env: set POSTGRES_PASSWORD, LUDARIUM_ADMIN_TOKEN and your library paths.
|
||||
docker compose --env-file deploy/.env -f deploy/compose.yml up -d --build
|
||||
```
|
||||
|
||||
Open `http://localhost:1230`. Check liveness at `/health/live` and database readiness at
|
||||
`/health/ready`. API routes require `Authorization: Bearer <LUDARIUM_ADMIN_TOKEN>`; the web interface
|
||||
stores that token in the browser session after you enter it.
|
||||
|
||||
Before scanning, confirm in the container details that every `/library/*` mount is read-only. The
|
||||
application refuses unsafe paths and does not need write access to your originals. See
|
||||
[`deploy/README.md`](deploy/README.md) for Unraid templates, backups, upgrades, optional providers and
|
||||
isolated player sidecars.
|
||||
|
||||
## How the workflow feels
|
||||
|
||||
1. Add one or more library roots and run a quick inventory.
|
||||
2. Open the review queue for unknown files, ambiguous matches and incomplete bundles.
|
||||
3. Accept or correct proposed identities; Ludarium preserves the evidence and confidence behind them.
|
||||
4. Browse the resulting catalog, build collections and inspect integrity or duplicate reports.
|
||||
5. Run deeper hashes or exports only when useful; the original files remain untouched throughout.
|
||||
|
||||
Ludarium is an archive manager, not a downloader, storefront or rights-management bypass. It does not
|
||||
ship games, firmware, console keys, provider datasets or third-party credentials. You are responsible
|
||||
for the content you index and for complying with the rules that apply to it.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
- Keep Ludarium on a trusted network or place it behind an authenticated HTTPS reverse proxy.
|
||||
- Use a unique admin token; production startup rejects missing, short and obvious placeholder tokens.
|
||||
- Treat the browser-player origin as privileged. A distinct player origin is recommended when exposing
|
||||
browser emulation beyond a trusted LAN.
|
||||
- Optional metadata providers receive only the requests required for enrichment and are disabled when
|
||||
no credentials are configured.
|
||||
- Report vulnerabilities privately using [`SECURITY.md`](SECURITY.md).
|
||||
|
||||
## Development
|
||||
|
||||
The backend targets .NET 10, the UI uses React 19 and Vite, and controller helpers use Python's standard
|
||||
library. Run the complete source-only validation without Docker privileges:
|
||||
|
||||
```sh
|
||||
sh deploy/run-managed-validation.sh source
|
||||
```
|
||||
|
||||
The canonical development repository also contains hardened image and release gates for trusted
|
||||
revisions. The public validation workflow deliberately needs no Docker daemon or deployment secrets.
|
||||
|
||||
## Project status and licensing
|
||||
|
||||
Ludarium is release-candidate software. Back up app data before upgrades and qualify optional native
|
||||
players against your own hardware. First-party source code in this repository is licensed under
|
||||
**AGPL-3.0-or-later**; see [`LICENSE`](LICENSE). Third-party components and assets remain subject to
|
||||
their own documented terms.
|
||||
|
||||
Third-party and generated visual assets are documented in
|
||||
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) and their adjacent provenance records. Game metadata,
|
||||
DAT files, ROMs, firmware and proprietary keys are not part of the project.
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported code
|
||||
|
||||
Security fixes target the current `master` release-candidate line.
|
||||
|
||||
## Reporting vulnerabilities
|
||||
|
||||
Report suspected vulnerabilities privately to `security@itworx.tech`. This
|
||||
monitored mailbox is the permanent private reporting channel for the project.
|
||||
Do not publish credentials, private library paths or inventories, emulator
|
||||
firmware or keys, ROM/game contents, account data, provider tokens, production
|
||||
databases, private infrastructure, copyrighted archives, or exploit-sensitive
|
||||
evidence in a public issue.
|
||||
|
||||
Include the affected version or commit, component, minimal reproduction using
|
||||
synthetic metadata and legally redistributable fixtures, expected and observed
|
||||
behaviour, and impact. State whether the issue affects archive parsing, path
|
||||
containment, read-only source mounts, browser emulation, metadata acquisition,
|
||||
authentication, imports, storage, player isolation, deployment, or third-party
|
||||
artifact provenance.
|
||||
|
||||
## Security and content boundary
|
||||
|
||||
Ludarium coordinates untrusted filenames, metadata, archives, emulator
|
||||
components, and isolated players. Preserve path confinement, process isolation,
|
||||
explicit player origins, least-privilege filesystem access, and read-only source
|
||||
libraries. Contributions must not silently rename, move, delete, patch, extract
|
||||
into, or otherwise mutate source game archives. Unsupported or uncertain
|
||||
content must fail closed or become an explicit review item; runtime emulation
|
||||
must remain allowlisted and user-initiated.
|
||||
|
||||
Never commit live `.env` files, firmware or keys, ROMs, copyrighted game assets,
|
||||
account data, private library databases, deployment credentials, private
|
||||
production exports, or unredacted production evidence.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Third-party and generated media notices
|
||||
|
||||
Ludarium bundles visual assets so its interface remains local and does not contact image hosts at
|
||||
runtime. These assets do not imply endorsement by a platform vendor.
|
||||
|
||||
The platform photographs and their licenses are listed in
|
||||
`src/Ludarium.Web/public/platforms/README.md`. Most are public-domain photographs by Evan-Amos. The
|
||||
PlayStation 5 image is licensed under CC BY-SA 4.0, with the original credited to Osh33m and the
|
||||
retouched transparent version to Soberian. Ludarium resizes images for web delivery without changing
|
||||
their subject matter; share-alike terms continue to apply to derivatives of that image.
|
||||
|
||||
Background-extracted and generated hardware portraits are documented in
|
||||
`src/Ludarium.Web/public/platforms/PROVENANCE.md`. Decorative Living Archive media and its generation
|
||||
records are documented in `src/Ludarium.Web/public/brand/PROVENANCE.md`. The prompts excluded people,
|
||||
logos, copyrighted characters and game screenshots. Those provenance records must remain next to any
|
||||
redistribution of the assets.
|
||||
|
||||
Browser emulator components copied into release images retain their upstream license and provenance
|
||||
files in the image. Ludarium does not bundle games, firmware, console keys, provider datasets or
|
||||
third-party credentials.
|
||||
@@ -0,0 +1,55 @@
|
||||
POSTGRES_PASSWORD=replace-with-a-long-random-password
|
||||
LUDARIUM_ADMIN_TOKEN=replace-with-a-separate-long-random-token
|
||||
TZ=Europe/Brussels
|
||||
LUDARIUM_HTTP_PORT=1230
|
||||
IGDB_CLIENT_ID=
|
||||
IGDB_CLIENT_SECRET="" # optional; leave empty to disable
|
||||
MOBYGAMES_API_KEY="" # optional; leave empty to disable
|
||||
SCREENSCRAPER_DEVID=
|
||||
SCREENSCRAPER_DEVPASSWORD="" # optional; leave empty to disable
|
||||
SCREENSCRAPER_SOFTNAME=Ludarium
|
||||
SCREENSCRAPER_USER=
|
||||
SCREENSCRAPER_PASSWORD="" # optional; leave empty to disable
|
||||
RETROACHIEVEMENTS_API_KEY="" # optional; leave empty to disable
|
||||
RETROACHIEVEMENTS_USERNAME=
|
||||
BROWSERPLAY_MAX_SESSIONS=2
|
||||
LUDARIUM_DATA_ROOT=./data
|
||||
PUID=1000
|
||||
PGID=1000
|
||||
GAMES_LIBRARY=/path/to/games
|
||||
PS4_LIBRARY=/path/to/ps4-games
|
||||
PS5_LIBRARY=/path/to/ps5-games
|
||||
|
||||
# Optional embedded Switch player (use with compose.switch.yml).
|
||||
SWITCH_CONTAINER_NAME=Ludarium-Switch
|
||||
SWITCH_DATA_ROOT=./data/switch
|
||||
SWITCH_LIBRARY=/path/to/switch-games
|
||||
SWITCH_PLAYER_URL=https://switch.example.test/switch-player/
|
||||
SWITCH_PUBLIC_URL=http://[IP]:[PORT:1262]/
|
||||
SWITCH_REMOTE_PORT=1262
|
||||
SWITCH_EMBED_PORT=1231
|
||||
SWITCH_EMBED_ORIGIN=
|
||||
SWITCH_CONTROL_TOKEN=replace-with-a-separate-random-secret
|
||||
SWITCH_CATALOG_PREFIX=roms/switch
|
||||
|
||||
# Optional isolated GameCube/Wii player (compose.dolphin.yml).
|
||||
DOLPHIN_DATA_ROOT=./data/dolphin
|
||||
DOLPHIN_GAMECUBE_LIBRARY=/path/to/gamecube-games
|
||||
DOLPHIN_WII_LIBRARY=/path/to/wii-games
|
||||
DOLPHIN_PLAYER_URL=https://dolphin.example.test/dolphin-player/
|
||||
DOLPHIN_PUBLIC_URL=http://[IP]:[PORT:1263]/
|
||||
DOLPHIN_REMOTE_PORT=1263
|
||||
DOLPHIN_EMBED_PORT=1232
|
||||
DOLPHIN_REMOTE_USER=
|
||||
DOLPHIN_REMOTE_PASSWORD=
|
||||
DOLPHIN_CONTROL_TOKEN=
|
||||
DOLPHIN_PROXY_CERT_SHA256=
|
||||
# Exact SHA-256 of the read-only GameCube/Wii artifact used by the live launch + savestate qualification gate.
|
||||
DOLPHIN_FIXTURE_SHA256=
|
||||
DOLPHIN_GAMECUBE_CATALOG_PREFIX=roms/gamecube
|
||||
DOLPHIN_WII_CATALOG_PREFIX=roms/wii
|
||||
SWITCH_DRI_NODE=/dev/dri/renderD128
|
||||
SWITCH_REMOTE_USER=replace-with-a-local-player-user
|
||||
SWITCH_REMOTE_PASSWORD=replace-with-a-long-random-player-password
|
||||
# SHA-256 of Eden's self-signed TLS certificate, without colons. See deploy/README.md.
|
||||
SWITCH_PROXY_CERT_SHA256=replace-with-64-lowercase-hex-characters
|
||||
@@ -0,0 +1,197 @@
|
||||
# Deployment and operations
|
||||
|
||||
## Unraid DockerMan
|
||||
|
||||
The Unraid Compose deployment runs the web application and its private PostgreSQL 16 process inside one supervised `Ludarium` container. DockerMan exposes one WebUI on port 1230 and one persisted PNG icon; PostgreSQL has no host port. The entrypoint forwards shutdown signals to both processes and the database remains isolated in its durable bind mount.
|
||||
|
||||
Install `src/Ludarium.Web/public/favicon.svg` as `/boot/config/plugins/dockerMan/images/Ludarium-icon.svg` on the Unraid host before recreating the application container.
|
||||
|
||||
## Clean start
|
||||
|
||||
1. Copy `.env.example` to `.env` and replace both secrets with independent random values.
|
||||
2. Set the three host library paths. Do not remove the `:ro` suffixes in `compose.yml`.
|
||||
3. From `deploy/`, run `docker compose build --pull` and `docker compose up -d`.
|
||||
4. Wait for `docker compose ps` to report the single service healthy, then open `http://HOST:1230` (or the configured `LUDARIUM_HTTP_PORT`) and supply the admin bearer token through a trusted reverse proxy or API client.
|
||||
5. Add `/library/games`, `/library/ps4`, and `/library/ps5` in Settings and verify each root. A writable or unverifiable mapping remains visibly unsafe.
|
||||
|
||||
Set `PUID` and `PGID` to the numeric owner of library directories that are not world-readable. Ludarium remains non-root; only its writable appdata directories should be owned by this identity. Never relax source-library permissions solely for a scan.
|
||||
|
||||
The application container is read-only, drops all Linux capabilities, enables `no-new-privileges`, runs as the configured non-root UID/GID, and has no Docker socket, privileged mode, host networking, or source-write mount.
|
||||
|
||||
## Optional native metadata providers
|
||||
|
||||
Ludarium's catalog, collections, tags, personal state, relationships, media and exports work offline and do not require RomM or another service. Optional metadata enrichment is performed directly by Ludarium when the corresponding operator-owned credentials are present:
|
||||
|
||||
- IGDB: `IGDB_CLIENT_ID` and `IGDB_CLIENT_SECRET`;
|
||||
- MobyGames: `MOBYGAMES_API_KEY`;
|
||||
- ScreenScraper: `SCREENSCRAPER_DEVID`, `SCREENSCRAPER_DEVPASSWORD` and `SCREENSCRAPER_SOFTNAME`; account user/password are optional;
|
||||
- RetroAchievements: `RETROACHIEVEMENTS_API_KEY`; `RETROACHIEVEMENTS_USERNAME` additionally enables personal unlock progress.
|
||||
|
||||
Leave variables empty to keep a provider disabled. Provider status and missing configuration are shown in Settings. Credentials stay in the local deployment environment; they are never written to support bundles. Metadata claims, media sources and ambiguous conflicts retain provider provenance, and uncertain conflicts enter review instead of being silently merged.
|
||||
|
||||
## Native browser play
|
||||
|
||||
EmulatorJS 4.2.3 and the official Play!.js PS2 runtime are checksum-pinned and bundled into the Ludarium image. No RomM installation, account, API token, shared database or runtime internet access is required. Play!.js uses its built-in HLE BIOS; Ludarium does not expose an uploaded PS2 BIOS to the browser. `BROWSERPLAY_MAX_SESSIONS` limits concurrent two-hour sessions from 1 through 8 and defaults to 2.
|
||||
|
||||
### Optional isolated Switch player
|
||||
|
||||
`compose.switch.yml` adds LinuxServer's browser-accessible Eden image pinned to its exact amd64
|
||||
digest. It is a native remote Wayland/WebRTC application. Ludarium embeds that remote desktop on
|
||||
the separate `SWITCH_EMBED_PORT` origin through a certificate-pinned streaming/WebSocket proxy;
|
||||
Eden credentials remain server-side and are never written into browser state. Set
|
||||
`SWITCH_DATA_ROOT`, `SWITCH_LIBRARY`, `SWITCH_PLAYER_URL`, `SWITCH_REMOTE_USER`,
|
||||
`SWITCH_REMOTE_PASSWORD`, `SWITCH_PROXY_CERT_SHA256` and optionally `SWITCH_DRI_NODE`, then start
|
||||
with both Compose files. Obtain the lowercase certificate pin from the already-initialized Eden
|
||||
configuration with `openssl x509 -in config/ssl/cert.pem -noout -fingerprint -sha256`, removing
|
||||
colons from the fingerprint. Recheck the pin after replacing Eden's persistent configuration.
|
||||
|
||||
For access through a public reverse proxy, set `SWITCH_EMBED_ORIGIN` to a dedicated HTTPS child
|
||||
origin such as `https://player.ludarium.example/` and forward that hostname to
|
||||
`SWITCH_EMBED_PORT`. The child origin is required: Ludarium rejects the player proxy on the main
|
||||
application hostname so the remote desktop cannot access the administrator origin. Keep WebSocket
|
||||
upgrade enabled on this proxy host.
|
||||
|
||||
Before the first start, after restoring appdata, or after changing `PUID`/`PGID`, run the bounded
|
||||
appdata preparation step. It refuses root identities, broad data roots and any overlap between the
|
||||
app-owned data root and source library. It normalizes only Eden's writable config ownership; it
|
||||
never changes the read-only game library.
|
||||
|
||||
```sh
|
||||
./prepare-switch-appdata.sh
|
||||
```
|
||||
|
||||
`/games` is mounted read-only, Docker's socket is never exposed, Docker-in-Docker is disabled,
|
||||
HTTPS Basic authentication is mandatory, and app-owned Eden configuration is separate from every
|
||||
source library. Eden runs at `/switch-player/`; sharing, collaboration, file transfer, command
|
||||
execution and its file/app sidebars are locked off. Uploading personal `prod.keys` or `title.keys`
|
||||
through Ludarium atomically provisions the selected verified key into Eden's app-owned key
|
||||
directory. Ludarium links Switch games to the embedded player but does not claim exact per-title
|
||||
directory. For a game with one exact present base XCI/NSP, Ludarium starts that title directly in
|
||||
Eden. Update/DLC-only, missing, ambiguous or writable-source records remain unavailable instead of
|
||||
opening a generic game picker.
|
||||
|
||||
```sh
|
||||
docker compose -f compose.yml -f compose.switch.yml up -d
|
||||
```
|
||||
|
||||
The independently versioned sidecar has its own fail-closed security gate. Run it against an
|
||||
isolated healthy candidate, never production appdata:
|
||||
|
||||
```sh
|
||||
./run-eden-security-gate.sh \
|
||||
ludarium/eden-controller:0.4.9-rc.1 \
|
||||
Ludarium-Switch-Candidate \
|
||||
/mnt/user/appdata/ludarium-candidate/eden-0.4.9/evidence
|
||||
```
|
||||
|
||||
The gate retains both raw and OpenVEX-resolved Grype JSON. It accepts a Critical decision only when
|
||||
the exact running image is healthy with zero restarts, `/games` is read-only, the executable
|
||||
runtime-boundary audit passes, and the raw Critical occurrence/ID sets exactly equal the VEX sets.
|
||||
High and lower findings remain visible in the raw report.
|
||||
|
||||
The current LinuxServer Wayland path requires HTTPS for its best WebCodecs path and, for
|
||||
proprietary NVIDIA rendering, driver 580 or newer. Intel/AMD DRM render nodes can be selected
|
||||
directly. Keep both Ludarium and Eden on a trusted LAN. The direct HTTPS URL is retained only as a
|
||||
fallback; the embedded surface is capability-gated and the hardened Eden desktop does not expose
|
||||
its normal command/file controls.
|
||||
|
||||
### Optional isolated GameCube and Wii player
|
||||
|
||||
`compose.dolphin.yml` adds the digest-pinned LinuxServer Dolphin/Selkies runtime and a minimal
|
||||
exact-title controller. Configure `DOLPHIN_DATA_ROOT`, both source-library paths, separate remote
|
||||
credentials and control token, the initialized TLS certificate pin, and the public ports. Both
|
||||
source libraries retain `:ro`; saves and savestates are written only below the app-owned Dolphin
|
||||
data root. No Docker socket is mounted and the remote desktop's command, file, app, clipboard and
|
||||
sharing controls are locked off.
|
||||
|
||||
Do not populate `DOLPHIN_FIXTURE_SHA256` just to enable the UI. First run an exact owned or freely
|
||||
licensed read-only GameCube/Wii image through the isolated candidate and verify launch, streamed
|
||||
video/input, save-state, load-state and persistence after restart. Confirm the source manifest is
|
||||
unchanged, then record that exact artifact's lowercase SHA-256. Ludarium remains fail-closed until
|
||||
this evidence field is a valid digest; it does not imply compatibility for every game.
|
||||
|
||||
```sh
|
||||
docker compose -f compose.yml -f compose.dolphin.yml up -d --build
|
||||
```
|
||||
|
||||
The public player origin, when used, must be a dedicated HTTPS child hostname with WebSocket
|
||||
upgrade enabled. Ludarium rejects a configured origin that is not a child of the main host and
|
||||
keeps the remote credentials server-side.
|
||||
|
||||
The Play control appears only for one exact present ROM linked by the scanner, on a verified read-only library and with an allowlisted platform, extension and size. An explicit click creates an audited, expiring capability; the browser receives no source path. The scoped content route resolves the stored artifact server-side, revalidates the mount and streams with Range support. PS2 has a dedicated 8 GiB ceiling and 32 MiB per-read bound; other platforms retain the 512 MiB ceiling. Executables, scripts, archives and unknown content fail closed.
|
||||
|
||||
## Isolated candidate gate
|
||||
|
||||
Never point a candidate at production appdata. On Unraid, export the normal secret and library variables plus an isolated root and run the fail-closed gate:
|
||||
|
||||
```sh
|
||||
export LUDARIUM_CANDIDATE_DATA_ROOT=/mnt/user/appdata/ludarium-candidate/0.4.19-rc.8
|
||||
export LUDARIUM_CANDIDATE_PORT=1232
|
||||
export LUDARIUM_IMAGE=ludarium/ludarium:0.4.19-rc.8
|
||||
export PUID=1000 PGID=1000 # numeric owner/read-capable identity for the current archive
|
||||
./run-candidate-gate.sh
|
||||
```
|
||||
|
||||
The script accepts candidate data only below `/mnt/user/appdata/ludarium-candidate` (or `/tmp/ludarium-candidate`), uses a distinct Compose project, network and container name, and refuses to continue unless all three library mounts report `RW=false`. It tests an exact `git archive` of `HEAD`, so ignored build/cache files and deployment secrets never enter the ephemeral test workspace. It first runs locked restore, formatting, Release build and all .NET tests with PostgreSQL Testcontainers enabled. It records source manifests before/after, builds the pinned image, checks the runtime schema against the version declared in source and checks readiness, runs frontend unit/build/audit plus the full responsive Playwright/axe matrix in an ephemeral tmpfs, restarts the app, restores a custom-format dump into a temporary database, and runs Gitleaks/Syft/Grype. Scanner binaries are checksum-pinned, cached and downloaded with bounded retries/timeouts; the remaining Playwright image pull is digest-pinned and bounded. Its final line contains the image digest, port, schema and backup path required for release evidence.
|
||||
|
||||
On Unraid, the .NET container defaults to 4 CPUs, 8 GiB memory/no extra swap and 1,024 PIDs;
|
||||
the browser container defaults to 4 CPUs, 4 GiB memory/no extra swap and 1,024 PIDs. MSBuild uses
|
||||
two nodes, Vitest four workers and Compose one concurrent build. Lower-capacity hosts can override
|
||||
`LUDARIUM_GATE_CPUS`, `LUDARIUM_GATE_MEMORY`, `LUDARIUM_BROWSER_GATE_CPUS`,
|
||||
`LUDARIUM_BROWSER_GATE_MEMORY`, `LUDARIUM_GATE_BUILD_NODES` and
|
||||
`LUDARIUM_COMPOSE_PARALLEL_LIMIT`. A reboot or interrupted shell invalidates the attempt; restart
|
||||
with a new versioned candidate root instead of reusing partial evidence.
|
||||
|
||||
After that gate passes, run `./run-live-browserplay-gate.sh` with the same candidate environment. It materializes the original cartridge fixtures and reproducibly builds the PS2 ISO below candidate appdata with a digest-pinned PS2SDK image, verifies every SHA-256, mounts the set read-only and performs an Integrity scan. The matrix proves scoped Range delivery and real local canvases; state-capable EmulatorJS cores also prove cancel/relaunch/restore, while Play!.js proves frames and cancellation without claiming a state API. It fails on browser console errors, request failures or a missing local core and automatically restores the canonical candidate mounts afterward.
|
||||
|
||||
When the Docker registry is unavailable, set `LUDARIUM_BROWSER_GATE_MODE=external`. The script then reports `browser-external-required` instead of a full pass; run the checked-in `e2e/workflows.mjs` and `e2e/accessibility.mjs` from a Playwright 1.62.1 workstation against the candidate before promotion. This mode never converts a missing browser result into a green gate.
|
||||
|
||||
The durable archive-share paths on the current host are `/mnt/user/Media/Games`, `/mnt/user/PS4-Games` and `/mnt/user/PS5-Games`. Map them to `/library/games`, `/library/ps4` and `/library/ps5` respectively and keep every mount read-only. The PS4 and PS5 shares are currently empty; populate or remount them before starting a production scan so retained inventory is not marked missing.
|
||||
|
||||
### Candidate retention and cleanup
|
||||
|
||||
After production validation, stop the candidate application and database and disable their restart policies. Retain candidate appdata, database dumps and the prior production backup for 30 days unless an operator-approved retention policy requires longer. During this window, verify production health and backup readability before removing anything.
|
||||
|
||||
Stopping is non-destructive:
|
||||
|
||||
```sh
|
||||
docker update --restart=no ludarium-candidate-Ludarium
|
||||
docker stop ludarium-candidate-Ludarium
|
||||
```
|
||||
|
||||
After 30 days, first resolve and record the exact candidate project/appdata paths. Remove only explicitly approved candidate containers and versioned candidate appdata; never target `/mnt/user/appdata`, the production `/mnt/user/appdata/ludarium` tree or any library root. Candidate cleanup is intentionally not automated because deletion requires a fresh operator decision.
|
||||
|
||||
## Backup and restore
|
||||
|
||||
Stop application writes with `docker compose stop ludarium`, create a PostgreSQL custom-format dump with `docker compose exec -T ludarium pg_dump -U ludarium -Fc ludarium > ludarium.dump`, and archive `data/app` plus the optional exports. Restart Ludarium afterward.
|
||||
|
||||
To restore, start a clean Ludarium container, pipe the dump through `docker compose exec -T ludarium pg_restore -U ludarium -d ludarium --clean --if-exists`, restore `data/app`, then start Ludarium. Verify libraries, claims, reviews, snapshots and `/health/ready`. Game libraries are not part of this backup and remain untouched.
|
||||
|
||||
## Upgrade and rollback
|
||||
|
||||
Back up first, pull/build the new pinned image, then run `docker compose up -d`. Migrations are forward-only. Rollback means restoring the matching prior image and its pre-upgrade database dump; never run an older binary against a newer schema without a documented compatibility statement.
|
||||
|
||||
## HTTPS, reverse proxy and token rotation
|
||||
|
||||
Expose Ludarium outside a trusted LAN only through an HTTPS reverse proxy. Preserve `X-Forwarded-For`, restrict request-body size and do not log the `Authorization` header. Ludarium itself has no cloud authentication dependency.
|
||||
|
||||
Validate both a normal GET and a completed authenticated inventory export through the public host.
|
||||
Some Nginx Proxy Manager/OpenResty combinations can accept ordinary HTTP/2 navigation while
|
||||
stalling longer browser POST streams. If and only if the public browser export stalls while the
|
||||
same request completes through the direct HTTP/1.1 origin, disable HTTP/2 on the main Ludarium
|
||||
proxy host and keep bounded `proxy_read_timeout`/`proxy_send_timeout` plus
|
||||
`proxy_buffering off`/`proxy_request_buffering off`. The dedicated Switch player host may retain
|
||||
HTTP/2 and must retain WebSocket upgrades. Run `nginx -t` and keep a proxy-manager database backup
|
||||
before reload.
|
||||
|
||||
To rotate the administrator token, generate a new high-entropy value, update only `LUDARIUM_ADMIN_TOKEN` in the protected deployment environment and recreate the application container. Existing browser sessions receive `401`, erase their session-scoped token and return to the login screen. Never place the token in Compose YAML, shell history, support bundles or source control.
|
||||
|
||||
Before promotion, create a custom-format database backup and retain the previous image digest. If post-migration validation fails, stop application writes and restore both the pre-upgrade dump and matching prior image; do not run an older application against schema 7 or later.
|
||||
|
||||
## Live Unraid validation
|
||||
|
||||
Follow `docs/25_RELEASE_AND_HANDOFF.md`. Capture independent path/name/hash manifests before and after quick/deep scans, restart during discovery, test one unavailable root, create exports, and restore a backup into a clean stack.
|
||||
|
||||
For production browser smoke tests set `PLAYWRIGHT_MUTATE_CATALOG=0` so the general workflow remains read-only. Run the Game Data Vault creation story only against an isolated candidate database; it deliberately creates a temporary game identity and is not a production smoke test.
|
||||
|
||||
For the non-mutating structural manifest gate, run `verify-source-manifest.sh` with every host library path before and after the scan. The file count and SHA-256 digest must be identical. Record the result together with the Docker `RW=false` mount inspection in `RELEASE_GATE_EVIDENCE.md`.
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
fail() {
|
||||
printf 'Eden runtime audit failed: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for command in eden Xwayland nginx python3 xdotool findmnt ss pgrep; do
|
||||
command -v "$command" >/dev/null 2>&1 || fail "required command is absent: $command"
|
||||
done
|
||||
|
||||
for command in docker dockerd containerd gcc g++ make cmake git ssh curl cron crond; do
|
||||
command -v "$command" >/dev/null 2>&1 && fail "unused privileged/development command is present: $command"
|
||||
done
|
||||
|
||||
for process in eden Xwayland nginx python3 selkies; do
|
||||
pgrep -f "$process" >/dev/null 2>&1 || fail "required process is not running: $process"
|
||||
done
|
||||
|
||||
for process in dockerd containerd cron crond sshd; do
|
||||
pgrep -x "$process" >/dev/null 2>&1 && fail "unused service is running: $process"
|
||||
done
|
||||
|
||||
mount_options="$(findmnt -n -o OPTIONS --target /games)"
|
||||
case ",$mount_options," in
|
||||
*,ro,*) ;;
|
||||
*) fail "/games is not mounted read-only" ;;
|
||||
esac
|
||||
|
||||
[ -z "${DEV_MODE+x}" ] || fail "PixelFlux development mode must remain disabled"
|
||||
if env | grep -Eiq '^(http|https|all|no)_proxy=|^netrc='; then
|
||||
fail "proxy or netrc environment can expose unused libcurl credential paths"
|
||||
fi
|
||||
if find /config /root -xdev -name .netrc -print -quit 2>/dev/null | grep -q .; then
|
||||
fail "a runtime .netrc file is present"
|
||||
fi
|
||||
if grep -RIEq '^[[:space:]]*map[[:space:]]' /etc/nginx /defaults 2>/dev/null; then
|
||||
fail "nginx map directives are forbidden in the isolated player"
|
||||
fi
|
||||
|
||||
xwayland="$(pgrep -a -x Xwayland | head -1)"
|
||||
printf '%s' "$xwayland" | grep -Fq -- '-rootless' || fail "Xwayland is not rootless"
|
||||
printf '%s' "$xwayland" | grep -Eq -- '-listen[[:space:]]+tcp' && fail "Xwayland TCP listening is enabled"
|
||||
ss -lnt | awk 'NR > 1 { print $4 }' | grep -Eq '(^|:):?60[0-9][0-9]$' && fail "an X11 TCP port is listening"
|
||||
|
||||
if find /config -xdev -type f \( -iname '*.svg' -o -iname '*.dtd' \) -print -quit 2>/dev/null | grep -q .; then
|
||||
fail "untrusted SVG or DTD input is present in app-owned runtime configuration"
|
||||
fi
|
||||
if find /config -xdev -type f -size -1M -exec grep -Il 'rist://' {} + 2>/dev/null | grep -q .; then
|
||||
fail "a RIST media URL is present in runtime configuration"
|
||||
fi
|
||||
env | grep -Eiq 'rist://' && fail "a RIST media URL is present in the environment"
|
||||
|
||||
printf 'Eden runtime audit passed: minimal tools, read-only games, bounded local display and no vulnerable optional protocol configuration.\n'
|
||||
@@ -0,0 +1,83 @@
|
||||
# Optional browser-accessible GameCube/Wii runtime. Combine with compose.yml after setting DOLPHIN_*.
|
||||
# Both source libraries are mounted read-only; only /config is writable for saves and savestates.
|
||||
services:
|
||||
ludarium:
|
||||
environment:
|
||||
LUDARIUM_DOLPHIN_PLAYER_URL: ${DOLPHIN_PLAYER_URL:?set the trusted-LAN Dolphin URL}
|
||||
LUDARIUM_DOLPHIN_PROXY_URL: https://dolphin:3001/
|
||||
LUDARIUM_DOLPHIN_PROXY_USERNAME: ${DOLPHIN_REMOTE_USER:?set the Dolphin trusted-LAN username}
|
||||
LUDARIUM_DOLPHIN_PROXY_PASSWORD: ${DOLPHIN_REMOTE_PASSWORD:?set the Dolphin trusted-LAN password}
|
||||
LUDARIUM_DOLPHIN_PROXY_CERT_SHA256: ${DOLPHIN_PROXY_CERT_SHA256:?pin the Dolphin TLS certificate SHA-256}
|
||||
LUDARIUM_DOLPHIN_EMBED_PORT: ${DOLPHIN_EMBED_PORT:-1232}
|
||||
LUDARIUM_DOLPHIN_EMBED_ORIGIN: ${DOLPHIN_EMBED_ORIGIN:-}
|
||||
LUDARIUM_DOLPHIN_CONTROL_URL: http://dolphin:8765/
|
||||
LUDARIUM_DOLPHIN_CONTROL_TOKEN: ${DOLPHIN_CONTROL_TOKEN:?set the internal Dolphin control token}
|
||||
LUDARIUM_DOLPHIN_FIXTURE_SHA256: ${DOLPHIN_FIXTURE_SHA256:?set after deterministic live launch/save-state validation}
|
||||
LUDARIUM_DOLPHIN_GAMECUBE_CATALOG_PREFIX: ${DOLPHIN_GAMECUBE_CATALOG_PREFIX:-roms/gamecube}
|
||||
LUDARIUM_DOLPHIN_WII_CATALOG_PREFIX: ${DOLPHIN_WII_CATALOG_PREFIX:-roms/wii}
|
||||
ports:
|
||||
- ${DOLPHIN_EMBED_PORT:-1232}:8734
|
||||
depends_on:
|
||||
dolphin:
|
||||
condition: service_healthy
|
||||
|
||||
dolphin:
|
||||
container_name: ${DOLPHIN_CONTAINER_NAME:-Ludarium-Dolphin}
|
||||
image: ${DOLPHIN_IMAGE:-ludarium/dolphin-controller:0.4.19-rc.8}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile.dolphin-controller
|
||||
labels:
|
||||
net.unraid.docker.webui: "${DOLPHIN_PUBLIC_URL:-http://[IP]:[PORT:1263]/}"
|
||||
net.unraid.docker.icon: "/boot/config/plugins/dockerMan/images/Ludarium-icon.svg"
|
||||
net.unraid.docker.managed: "dockerman"
|
||||
net.unraid.docker.shell: ""
|
||||
environment:
|
||||
PUID: ${PUID:-1654}
|
||||
PGID: ${PGID:-1654}
|
||||
TZ: ${TZ:-Europe/Brussels}
|
||||
PIXELFLUX_WAYLAND: "true"
|
||||
DRINODE: ${DOLPHIN_DRI_NODE:-/dev/dri/renderD128}
|
||||
DRI_NODE: ${DOLPHIN_DRI_NODE:-/dev/dri/renderD128}
|
||||
START_DOCKER: "false"
|
||||
DISABLE_IPV6: "true"
|
||||
CUSTOM_USER: ${DOLPHIN_REMOTE_USER:?set the Dolphin trusted-LAN username}
|
||||
PASSWORD: ${DOLPHIN_REMOTE_PASSWORD:?set the Dolphin trusted-LAN password}
|
||||
LUDARIUM_DOLPHIN_CONTROL_TOKEN: ${DOLPHIN_CONTROL_TOKEN:?set the internal Dolphin control token}
|
||||
# App-owned save directory the Game Data Vault captures and restores. Override only when a
|
||||
# different Dolphin build stores its saves and states elsewhere.
|
||||
LUDARIUM_DOLPHIN_SAVE_ROOT: ${DOLPHIN_SAVE_ROOT:-/config/.local/share/dolphin-emu}
|
||||
TITLE: Ludarium Dolphin
|
||||
FILE_MANAGER_PATH: /games
|
||||
SUBFOLDER: /dolphin-player/
|
||||
HARDEN_DESKTOP: "true"
|
||||
HARDEN_OPENBOX: "true"
|
||||
SELKIES_ENABLE_SHARING: "false|locked"
|
||||
SELKIES_ENABLE_COLLAB: "false|locked"
|
||||
SELKIES_ENABLE_SHARED: "false|locked"
|
||||
SELKIES_FILE_TRANSFERS: "none|locked"
|
||||
SELKIES_COMMAND_ENABLED: "false|locked"
|
||||
SELKIES_CLIPBOARD_ENABLED: "false|locked"
|
||||
SELKIES_MICROPHONE_ENABLED: "false|locked"
|
||||
SELKIES_UI_SIDEBAR_SHOW_CLIPBOARD: "false|locked"
|
||||
SELKIES_UI_SIDEBAR_SHOW_FILES: "false|locked"
|
||||
SELKIES_UI_SIDEBAR_SHOW_APPS: "false|locked"
|
||||
MAX_RES: 3840x2160
|
||||
ports:
|
||||
- ${DOLPHIN_REMOTE_PORT:-1263}:3001
|
||||
devices:
|
||||
- /dev/dri:/dev/dri
|
||||
shm_size: 1gb
|
||||
volumes:
|
||||
- ${DOLPHIN_DATA_ROOT:?set isolated app-owned Dolphin data}/config:/config:rw
|
||||
- ${DOLPHIN_GAMECUBE_LIBRARY:?set the GameCube source library}:/games/gamecube:ro
|
||||
- ${DOLPHIN_WII_LIBRARY:?set the Wii source library}:/games/wii:ro
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8765/health', timeout=3).read()"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,85 @@
|
||||
# Optional browser-accessible Switch runtime. Combine with compose.yml only after setting all
|
||||
# SWITCH_* variables. The game library is always mounted read-only and no Docker socket is exposed.
|
||||
services:
|
||||
ludarium:
|
||||
environment:
|
||||
LUDARIUM_SWITCH_PLAYER_URL: ${SWITCH_PLAYER_URL:?set the trusted-LAN Eden URL}
|
||||
LUDARIUM_SWITCH_KEYS_ROOT: /app/switch-keys
|
||||
LUDARIUM_SWITCH_PROXY_URL: https://eden:3001/
|
||||
LUDARIUM_SWITCH_PROXY_USERNAME: ${SWITCH_REMOTE_USER:?set the Eden trusted-LAN username}
|
||||
LUDARIUM_SWITCH_PROXY_PASSWORD: ${SWITCH_REMOTE_PASSWORD:?set the Eden trusted-LAN password}
|
||||
LUDARIUM_SWITCH_PROXY_CERT_SHA256: ${SWITCH_PROXY_CERT_SHA256:?pin the Eden TLS certificate SHA-256}
|
||||
LUDARIUM_SWITCH_EMBED_PORT: ${SWITCH_EMBED_PORT:-1231}
|
||||
LUDARIUM_SWITCH_EMBED_ORIGIN: ${SWITCH_EMBED_ORIGIN:-}
|
||||
LUDARIUM_SWITCH_CONTROL_URL: http://eden:8765/
|
||||
LUDARIUM_SWITCH_CONTROL_TOKEN: ${SWITCH_CONTROL_TOKEN:?set the internal Eden control token}
|
||||
LUDARIUM_SWITCH_CATALOG_PREFIX: ${SWITCH_CATALOG_PREFIX:-roms/switch}
|
||||
ports:
|
||||
- ${SWITCH_EMBED_PORT:-1231}:8734
|
||||
volumes:
|
||||
- ${SWITCH_DATA_ROOT:?set isolated app-owned Switch data}/config/.local/share/eden/keys:/app/switch-keys:rw
|
||||
depends_on:
|
||||
eden:
|
||||
condition: service_healthy
|
||||
|
||||
eden:
|
||||
container_name: ${SWITCH_CONTAINER_NAME:-Ludarium-Switch}
|
||||
image: ${SWITCH_IMAGE:-ludarium/eden-controller:0.4.9-rc.1}
|
||||
labels:
|
||||
net.unraid.docker.webui: "${SWITCH_PUBLIC_URL:-http://[IP]:[PORT:1262]/}"
|
||||
net.unraid.docker.icon: "/boot/config/plugins/dockerMan/images/Ludarium-icon.svg"
|
||||
net.unraid.docker.managed: "dockerman"
|
||||
net.unraid.docker.shell: ""
|
||||
environment:
|
||||
PUID: ${PUID:-1654}
|
||||
PGID: ${PGID:-1654}
|
||||
TZ: ${TZ:-Europe/Brussels}
|
||||
PIXELFLUX_WAYLAND: "true"
|
||||
DRINODE: ${SWITCH_DRI_NODE:-/dev/dri/renderD128}
|
||||
DRI_NODE: ${SWITCH_DRI_NODE:-/dev/dri/renderD128}
|
||||
START_DOCKER: "false"
|
||||
DISABLE_IPV6: "true"
|
||||
CUSTOM_USER: ${SWITCH_REMOTE_USER:?set the Eden trusted-LAN username}
|
||||
PASSWORD: ${SWITCH_REMOTE_PASSWORD:?set the Eden trusted-LAN password}
|
||||
LUDARIUM_EDEN_CONTROL_TOKEN: ${SWITCH_CONTROL_TOKEN:?set the internal Eden control token}
|
||||
# App-owned save directory the Game Data Vault captures and restores. Override only when a
|
||||
# different Eden build stores its saves elsewhere.
|
||||
LUDARIUM_EDEN_SAVE_ROOT: ${SWITCH_SAVE_ROOT:-/config/.local/share/eden}
|
||||
TITLE: Ludarium Switch
|
||||
FILE_MANAGER_PATH: /games
|
||||
SUBFOLDER: /switch-player/
|
||||
HARDEN_DESKTOP: "true"
|
||||
HARDEN_OPENBOX: "true"
|
||||
SELKIES_ENABLE_SHARING: "false|locked"
|
||||
SELKIES_ENABLE_COLLAB: "false|locked"
|
||||
SELKIES_ENABLE_SHARED: "false|locked"
|
||||
SELKIES_FILE_TRANSFERS: "false|locked"
|
||||
SELKIES_COMMAND_ENABLED: "false|locked"
|
||||
SELKIES_CLIPBOARD_ENABLED: "false|locked"
|
||||
SELKIES_CLIPBOARD_IN_ENABLED: "false|locked"
|
||||
SELKIES_CLIPBOARD_OUT_ENABLED: "false|locked"
|
||||
SELKIES_MICROPHONE_ENABLED: "false|locked"
|
||||
SELKIES_UI_SIDEBAR_SHOW_CLIPBOARD: "false|locked"
|
||||
SELKIES_UI_SIDEBAR_SHOW_FILES: "false|locked"
|
||||
SELKIES_UI_SIDEBAR_SHOW_APPS: "false|locked"
|
||||
ports:
|
||||
- ${SWITCH_REMOTE_PORT:-1262}:3001
|
||||
devices:
|
||||
- /dev/dri:/dev/dri
|
||||
shm_size: 1gb
|
||||
volumes:
|
||||
- ${SWITCH_DATA_ROOT}/config:/config:rw
|
||||
- ${SWITCH_LIBRARY:?set the Switch source library}:/games:ro
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- python3
|
||||
- -c
|
||||
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8765/health', timeout=3).read()"
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,52 @@
|
||||
name: ludarium
|
||||
services:
|
||||
ludarium:
|
||||
container_name: ${LUDARIUM_CONTAINER_NAME:-Ludarium}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile.unraid
|
||||
args:
|
||||
LUDARIUM_SOURCE_REVISION: ${GITEA_COMMIT_SHA:-local}
|
||||
LUDARIUM_RELEASE_VERSION: ${LUDARIUM_RELEASE_VERSION:-0.4.19-rc.8}
|
||||
image: ${LUDARIUM_IMAGE:-ludarium/ludarium:0.4.19-rc.8}
|
||||
environment:
|
||||
ConnectionStrings__Ludarium: Host=127.0.0.1;Database=ludarium;Username=ludarium;Password=${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD};GSS Encryption Mode=Disable
|
||||
LUDARIUM_ADMIN_TOKEN: ${LUDARIUM_ADMIN_TOKEN:?set LUDARIUM_ADMIN_TOKEN}
|
||||
RAWG_API_KEY: ${RAWG_API_KEY:-}
|
||||
IGDB_CLIENT_ID: ${IGDB_CLIENT_ID:-}
|
||||
IGDB_CLIENT_SECRET: ${IGDB_CLIENT_SECRET:-}
|
||||
MOBYGAMES_API_KEY: ${MOBYGAMES_API_KEY:-}
|
||||
SCREENSCRAPER_DEVID: ${SCREENSCRAPER_DEVID:-}
|
||||
SCREENSCRAPER_DEVPASSWORD: ${SCREENSCRAPER_DEVPASSWORD:-}
|
||||
SCREENSCRAPER_SOFTNAME: ${SCREENSCRAPER_SOFTNAME:-Ludarium}
|
||||
SCREENSCRAPER_USER: ${SCREENSCRAPER_USER:-}
|
||||
SCREENSCRAPER_PASSWORD: ${SCREENSCRAPER_PASSWORD:-}
|
||||
RETROACHIEVEMENTS_API_KEY: ${RETROACHIEVEMENTS_API_KEY:-}
|
||||
RETROACHIEVEMENTS_USERNAME: ${RETROACHIEVEMENTS_USERNAME:-}
|
||||
BROWSERPLAY_MAX_SESSIONS: ${BROWSERPLAY_MAX_SESSIONS:-2}
|
||||
POSTGRES_DB: ludarium
|
||||
POSTGRES_USER: ludarium
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
||||
PUID: ${PUID:-1654}
|
||||
PGID: ${PGID:-1654}
|
||||
TZ: ${TZ:-Europe/Brussels}
|
||||
ports: ["${LUDARIUM_HTTP_PORT:-1230}:8734"]
|
||||
labels:
|
||||
net.unraid.docker.webui: "${LUDARIUM_PUBLIC_URL:-http://[IP]:[PORT:1230]/}"
|
||||
net.unraid.docker.icon: "/boot/config/plugins/dockerMan/images/Ludarium-icon.svg"
|
||||
net.unraid.docker.managed: "dockerman"
|
||||
net.unraid.docker.shell: ""
|
||||
volumes:
|
||||
- ${LUDARIUM_DATA_ROOT:-./data}/app:/app/data:rw
|
||||
- ${LUDARIUM_DATA_ROOT:-./data}/cache:/app/cache:rw
|
||||
- ${LUDARIUM_DATA_ROOT:-./data}/exports:/app/exports:rw
|
||||
- ${LUDARIUM_DATA_ROOT:-./data}/postgres:/var/lib/postgresql/data:rw
|
||||
- ${GAMES_LIBRARY:-./fixtures/games}:/library/games:ro
|
||||
- ${PS4_LIBRARY:-./fixtures/ps4}:/library/ps4:ro
|
||||
- ${PS5_LIBRARY:-./fixtures/ps5}:/library/ps5:ro
|
||||
read_only: true
|
||||
tmpfs: [/tmp, /var/run/postgresql]
|
||||
cap_drop: [ALL]
|
||||
cap_add: [CHOWN, DAC_OVERRIDE, FOWNER, KILL, SETGID, SETUID]
|
||||
security_opt: [no-new-privileges:true]
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/with-contenv bash
|
||||
exec /usr/bin/python3 /opt/ludarium/dolphin-controller.py
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ludarium Dolphin (GameCube and Wii) sidecar controller.
|
||||
|
||||
Only the Dolphin-specific runtime identity lives here; the bounded HTTP control surface,
|
||||
path validation, display resolution and process supervision are shared with every other
|
||||
Ludarium emulator sidecar. See deploy/ludarium_sidecar.py.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import ludarium_sidecar as sidecar
|
||||
|
||||
TOKEN = os.environ.get("LUDARIUM_DOLPHIN_CONTROL_TOKEN", "")
|
||||
GAMES = os.environ.get("LUDARIUM_GAMES_ROOT", "/games")
|
||||
# App-owned save directory. Ludarium exports and restores it through the vault; the exact
|
||||
# location differs per emulator build, so the operator can override it.
|
||||
SAVE_ROOT = os.environ.get("LUDARIUM_DOLPHIN_SAVE_ROOT", "/config/.local/share/dolphin-emu")
|
||||
|
||||
# Dolphin's profile also holds shader caches and dumps, which dwarf one bounded revision.
|
||||
# Only the GameCube memory cards, the Wii NAND and the savestates are captured.
|
||||
SAVE_PATHS = tuple(part.strip()
|
||||
for part in os.environ.get("LUDARIUM_DOLPHIN_SAVE_PATHS", "").split(",")
|
||||
if part.strip()) or ("GC", "Wii", "StateSaves")
|
||||
RUNTIME = sidecar.EmulatorRuntime(
|
||||
name="dolphin",
|
||||
process_name="dolphin-emu",
|
||||
launch_argv=lambda target: ["/usr/bin/dolphin-emu", "--batch", "--exec", str(target)],
|
||||
games=GAMES,
|
||||
extensions={".iso", ".gcm", ".rvz", ".gcz", ".wbfs", ".wia"},
|
||||
hotkeys={"pause-resume": "F10", "fullscreen": "alt+Return",
|
||||
"save-state": "shift+F1", "load-state": "F1"},
|
||||
save_root=SAVE_ROOT,
|
||||
save_paths=SAVE_PATHS,
|
||||
save_mode="native-and-savestate-persistent",
|
||||
# Dolphin's own main window also carries "Dolphin" in its title, so the launched
|
||||
# process id is the authoritative match and this pattern is only a bounded fallback.
|
||||
window_name="Dolphin",
|
||||
# Both source libraries are mounted under one /games root, so the runtime path must
|
||||
# name its platform directory. The API only ever emits "gamecube/..." or "wii/...".
|
||||
platforms={"gamecube", "wii"},
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
sidecar.serve(RUNTIME, TOKEN)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/with-contenv bash
|
||||
exec /usr/bin/python3 /opt/ludarium/eden-controller.py
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ludarium Eden (Nintendo Switch) sidecar controller.
|
||||
|
||||
Only the Switch-specific runtime identity lives here; the bounded HTTP control surface,
|
||||
path validation, display resolution and process supervision are shared with every other
|
||||
Ludarium emulator sidecar. See deploy/ludarium_sidecar.py.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import ludarium_sidecar as sidecar
|
||||
|
||||
TOKEN = os.environ.get("LUDARIUM_EDEN_CONTROL_TOKEN", "")
|
||||
GAMES = os.environ.get("LUDARIUM_GAMES_ROOT", "/games")
|
||||
# App-owned save directory. Ludarium exports and restores it through the vault; the exact
|
||||
# location differs per emulator build, so the operator can override it.
|
||||
SAVE_ROOT = os.environ.get("LUDARIUM_EDEN_SAVE_ROOT", "/config/.local/share/eden")
|
||||
|
||||
# Eden keeps Switch save data in its emulated NAND user profile.
|
||||
SAVE_PATHS = tuple(part.strip()
|
||||
for part in os.environ.get("LUDARIUM_EDEN_SAVE_PATHS", "").split(",")
|
||||
if part.strip()) or ("nand/user/save",)
|
||||
RUNTIME = sidecar.EmulatorRuntime(
|
||||
name="eden",
|
||||
process_name="eden",
|
||||
launch_argv=lambda target: ["/usr/bin/eden", "-f", "-g", str(target)],
|
||||
games=GAMES,
|
||||
extensions={".xci", ".nsp"},
|
||||
hotkeys={"pause-resume": "F4", "fullscreen": "F11"},
|
||||
save_root=SAVE_ROOT,
|
||||
save_paths=SAVE_PATHS,
|
||||
save_mode="native-persistent",
|
||||
# Eden titles the running game window "eden <title> |"; the emulator's own front-end
|
||||
# window does not match, so a hotkey cannot reach it if _NET_WM_PID is unavailable.
|
||||
window_name=r"eden .* \|",
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
sidecar.serve(RUNTIME, TOKEN)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
postgres_pid=""
|
||||
app_pid=""
|
||||
shutdown_requested=0
|
||||
terminate_children() {
|
||||
[[ -z "$app_pid" ]] || kill -TERM "$app_pid" 2>/dev/null || true
|
||||
[[ -z "$postgres_pid" ]] || kill -TERM "$postgres_pid" 2>/dev/null || true
|
||||
for _ in $(seq 1 10); do
|
||||
if { [[ -z "$app_pid" ]] || ! kill -0 "$app_pid" 2>/dev/null; } &&
|
||||
{ [[ -z "$postgres_pid" ]] || ! kill -0 "$postgres_pid" 2>/dev/null; }; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
[[ -z "$app_pid" ]] || ! kill -0 "$app_pid" 2>/dev/null || kill -KILL "$app_pid" 2>/dev/null || true
|
||||
[[ -z "$postgres_pid" ]] || ! kill -0 "$postgres_pid" 2>/dev/null || kill -KILL "$postgres_pid" 2>/dev/null || true
|
||||
[[ -z "$app_pid" ]] || wait "$app_pid" 2>/dev/null || true
|
||||
[[ -z "$postgres_pid" ]] || wait "$postgres_pid" 2>/dev/null || true
|
||||
}
|
||||
shutdown() {
|
||||
shutdown_requested=1
|
||||
trap - TERM INT
|
||||
terminate_children
|
||||
}
|
||||
trap shutdown TERM INT
|
||||
|
||||
runtime_owner="${PUID:-1654}:${PGID:-1654}"
|
||||
for app_directory in /app/data /app/cache /app/exports; do
|
||||
current_owner="$(stat -c '%u:%g' "$app_directory")"
|
||||
if [[ "$current_owner" != "$runtime_owner" ]]; then
|
||||
chown -R "$runtime_owner" "$app_directory"
|
||||
fi
|
||||
done
|
||||
/usr/local/bin/docker-entrypoint.sh postgres &
|
||||
postgres_pid=$!
|
||||
for attempt in $(seq 1 60); do
|
||||
if pg_isready --host=127.0.0.1 --username="${POSTGRES_USER:-ludarium}" --dbname="${POSTGRES_DB:-ludarium}" >/dev/null 2>&1; then break; fi
|
||||
if ! kill -0 "$postgres_pid" 2>/dev/null; then wait "$postgres_pid"; exit $?; fi
|
||||
if [[ "$attempt" == "60" ]]; then echo "PostgreSQL did not become ready within 60 seconds" >&2; exit 1; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
/usr/local/bin/repair-postgres-collation.sh
|
||||
|
||||
gosu "${PUID:-1654}:${PGID:-1654}" /usr/share/dotnet/dotnet /app/Ludarium.Api.dll &
|
||||
app_pid=$!
|
||||
set +e
|
||||
wait -n "$postgres_pid" "$app_pid"
|
||||
first_status=$?
|
||||
set -e
|
||||
|
||||
if [[ "$shutdown_requested" == "1" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! kill -0 "$postgres_pid" 2>/dev/null; then
|
||||
echo "PostgreSQL exited unexpectedly; stopping the Ludarium API." >&2
|
||||
terminate_children
|
||||
[[ "$first_status" == "0" ]] && exit 1
|
||||
exit "$first_status"
|
||||
fi
|
||||
|
||||
echo "The Ludarium API exited; stopping embedded PostgreSQL." >&2
|
||||
terminate_children
|
||||
exit "$first_status"
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0"?>
|
||||
<Container version="2">
|
||||
<Name>Ludarium</Name><Repository>ludarium/ludarium:0.4.19-rc.8</Repository><Registry>https://hub.docker.com/r/ludarium/ludarium</Registry>
|
||||
<Network>bridge</Network><Privileged>false</Privileged><Support>https://github.com/ludarium/ludarium</Support><Project>https://github.com/ludarium/ludarium</Project>
|
||||
<ExtraParams>--read-only --cap-drop=ALL --cap-add=CHOWN --cap-add=DAC_OVERRIDE --cap-add=FOWNER --cap-add=KILL --cap-add=SETGID --cap-add=SETUID --security-opt=no-new-privileges --tmpfs=/tmp --tmpfs=/var/run/postgresql</ExtraParams>
|
||||
<Overview>Local-first game archive intelligence. Source libraries must be mapped read-only.</Overview><WebUI>http://[IP]:[PORT:1230]/</WebUI><Icon>/boot/config/plugins/dockerMan/images/Ludarium-icon.svg</Icon>
|
||||
<Config Name="Web UI" Target="8734" Default="1230" Mode="tcp" Description="Ludarium web port" Type="Port" Display="always" Required="true" Mask="false"/>
|
||||
<Config Name="Appdata" Target="/app/data" Default="/mnt/user/appdata/ludarium" Mode="rw" Type="Path" Display="always" Required="true" Mask="false"/>
|
||||
<Config Name="Exports" Target="/app/exports" Default="/mnt/user/appdata/ludarium/exports" Mode="rw" Type="Path" Display="advanced" Required="true" Mask="false"/>
|
||||
<Config Name="Games library" Target="/library/games" Default="/mnt/user/Media/Games" Mode="ro" Description="Must remain read-only" Type="Path" Display="always" Required="true" Mask="false"/>
|
||||
<Config Name="PS4 library" Target="/library/ps4" Default="/mnt/user/PS4-Games" Mode="ro" Description="Must remain read-only" Type="Path" Display="always" Required="true" Mask="false"/>
|
||||
<Config Name="PS5 library" Target="/library/ps5" Default="/mnt/user/PS5-Games" Mode="ro" Description="Must remain read-only" Type="Path" Display="always" Required="true" Mask="false"/>
|
||||
<Config Name="Database connection" Target="ConnectionStrings__Ludarium" Default="" Mode="" Type="Variable" Display="always" Required="true" Mask="true"/>
|
||||
<Config Name="Admin token" Target="LUDARIUM_ADMIN_TOKEN" Default="" Mode="" Type="Variable" Display="always" Required="true" Mask="true"/>
|
||||
<Config Name="RAWG API key" Target="RAWG_API_KEY" Default="" Mode="" Description="Optional: enables online wishlist discovery; the offline core remains fully usable without it" Type="Variable" Display="advanced" Required="false" Mask="true"/>
|
||||
<Config Name="IGDB client ID" Target="IGDB_CLIENT_ID" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="false"/>
|
||||
<Config Name="IGDB client secret" Target="IGDB_CLIENT_SECRET" Default="" Mode="" Description="Optional native metadata provider secret" Type="Variable" Display="advanced" Required="false" Mask="true"/>
|
||||
<Config Name="MobyGames API key" Target="MOBYGAMES_API_KEY" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="true"/>
|
||||
<Config Name="ScreenScraper developer ID" Target="SCREENSCRAPER_DEVID" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="true"/>
|
||||
<Config Name="ScreenScraper developer password" Target="SCREENSCRAPER_DEVPASSWORD" Default="" Mode="" Description="Optional native metadata provider secret" Type="Variable" Display="advanced" Required="false" Mask="true"/>
|
||||
<Config Name="ScreenScraper software name" Target="SCREENSCRAPER_SOFTNAME" Default="Ludarium" Mode="" Description="Optional native metadata provider client name" Type="Variable" Display="advanced" Required="false" Mask="false"/>
|
||||
<Config Name="ScreenScraper user" Target="SCREENSCRAPER_USER" Default="" Mode="" Description="Optional personal ScreenScraper account" Type="Variable" Display="advanced" Required="false" Mask="false"/>
|
||||
<Config Name="ScreenScraper password" Target="SCREENSCRAPER_PASSWORD" Default="" Mode="" Description="Optional personal ScreenScraper account secret" Type="Variable" Display="advanced" Required="false" Mask="true"/>
|
||||
<Config Name="RetroAchievements API key" Target="RETROACHIEVEMENTS_API_KEY" Default="" Mode="" Description="Optional native achievements provider" Type="Variable" Display="advanced" Required="false" Mask="true"/>
|
||||
<Config Name="RetroAchievements user" Target="RETROACHIEVEMENTS_USERNAME" Default="" Mode="" Description="Optional personal achievement progress" Type="Variable" Display="advanced" Required="false" Mask="false"/>
|
||||
<Config Name="Timezone" Target="TZ" Default="Europe/Brussels" Mode="" Type="Variable" Display="advanced" Required="true" Mask="false"/>
|
||||
<Config Name="Runtime UID" Target="PUID" Default="1000" Mode="" Type="Variable" Display="advanced" Required="true" Mask="false"/>
|
||||
<Config Name="Runtime GID" Target="PGID" Default="1000" Mode="" Type="Variable" Display="advanced" Required="true" Mask="false"/>
|
||||
</Container>
|
||||
@@ -0,0 +1,474 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared control-surface primitives for Ludarium emulator sidecars.
|
||||
|
||||
Every isolated player sidecar exposes the same bounded surface: a token-authenticated
|
||||
HTTP server on :8765 with /health, /v1/status, /v1/launch and /v1/action. Only the
|
||||
emulator binary, its game-path allowlist, its window identity and its hotkey map
|
||||
differ, so a concrete controller supplies only those.
|
||||
|
||||
Keeping the surface in one module is a safety property, not only tidiness. The
|
||||
Xwayland display resolution below was found and fixed against the live Eden runtime;
|
||||
a per-sidecar copy silently missed that fix. Anything a future Azahar, xemu, Cemu or
|
||||
Vita3K controller needs belongs here so it inherits the same behaviour.
|
||||
|
||||
The module has no third-party dependencies: the sidecar images are minimized and must
|
||||
not regain a package manager surface.
|
||||
"""
|
||||
import hmac
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
CONTROL_PORT = 8765
|
||||
MIN_REQUEST_BYTES = 2
|
||||
MAX_REQUEST_BYTES = 8192
|
||||
# The Ludarium game-data vault stores one revision of at most 64 MiB, so an archive larger than that
|
||||
# could never be kept. Refusing it here keeps the failure at the sidecar instead of half-way upstream.
|
||||
MAX_SAVE_DATA_BYTES = 64 * 1024 * 1024
|
||||
MAX_SAVE_DATA_ENTRIES = 20000
|
||||
MAX_SAVE_DATA_ENTRY_BYTES = 256 * 1024 * 1024
|
||||
MAX_SAVE_DATA_EXPANDED_BYTES = 512 * 1024 * 1024
|
||||
SETUIDGID = "/usr/bin/s6-setuidgid"
|
||||
RUNTIME_USER = "abc"
|
||||
XDOTOOL = "/usr/bin/xdotool"
|
||||
|
||||
|
||||
def respond(handler, status, body):
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
handler.send_response(status)
|
||||
handler.send_header("Content-Type", "application/json")
|
||||
handler.send_header("Content-Length", str(len(payload)))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(payload)
|
||||
|
||||
|
||||
def authorized(handler, token):
|
||||
supplied = handler.headers.get("X-Ludarium-Control-Token", "")
|
||||
return bool(token) and hmac.compare_digest(token, supplied)
|
||||
|
||||
|
||||
def resolve_game_path(value, games, extensions, platforms=None):
|
||||
"""Resolve a runtime-relative game path to an exact file inside the read-only mount.
|
||||
|
||||
This is the sidecar's last defence. The API already validates and prefixes the path,
|
||||
but the controller must never execute anything it cannot prove lives inside the
|
||||
games mount, carries an allowlisted extension and is a regular file. Symlinks are
|
||||
resolved before the containment check, so a link out of the mount fails closed.
|
||||
"""
|
||||
relative = PurePosixPath(value)
|
||||
if relative.is_absolute() or not relative.parts:
|
||||
raise ValueError("Unsafe game path")
|
||||
if any(part in ("", ".", "..") for part in relative.parts):
|
||||
raise ValueError("Unsafe game path")
|
||||
if platforms is not None and (len(relative.parts) < 2 or relative.parts[0] not in platforms):
|
||||
raise ValueError("Unsafe game path")
|
||||
root = Path(games).resolve()
|
||||
target = (root / Path(*relative.parts)).resolve()
|
||||
if root not in target.parents:
|
||||
raise ValueError("Game is outside the read-only games mount")
|
||||
if target.suffix.lower() not in extensions:
|
||||
raise ValueError("Game is not an allowlisted read-only image")
|
||||
if not target.is_file():
|
||||
raise ValueError("Game is not a present read-only file")
|
||||
return target
|
||||
|
||||
|
||||
def desktop_display(process_name):
|
||||
"""Resolve the Xwayland display owned by the active emulator desktop.
|
||||
|
||||
PixelFlux allocates a new display number after container restarts while retaining
|
||||
older socket files. Reading the running desktop process is authoritative; the newest
|
||||
socket is only a bounded fallback for the short interval before the emulator has
|
||||
published its environment.
|
||||
"""
|
||||
try:
|
||||
process = subprocess.run(
|
||||
["/usr/bin/pgrep", "-o", "-x", process_name], capture_output=True, text=True,
|
||||
timeout=2, check=False)
|
||||
pid = process.stdout.strip()
|
||||
if process.returncode == 0 and pid.isdigit():
|
||||
environment = subprocess.run(
|
||||
[SETUIDGID, RUNTIME_USER, "/bin/cat", f"/proc/{pid}/environ"],
|
||||
capture_output=True, timeout=2, check=False)
|
||||
for item in environment.stdout.split(b"\0"):
|
||||
if item.startswith(b"DISPLAY=:"):
|
||||
display = item.removeprefix(b"DISPLAY=").decode("ascii", errors="ignore")
|
||||
if display[1:].isdigit():
|
||||
return display
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
sockets = []
|
||||
try:
|
||||
for socket in Path("/tmp/.X11-unix").glob("X*"):
|
||||
if socket.name[1:].isdigit():
|
||||
sockets.append((socket.stat().st_mtime_ns, int(socket.name[1:])))
|
||||
except OSError:
|
||||
pass
|
||||
if sockets:
|
||||
return f":{max(sockets)[1]}"
|
||||
return os.environ.get("DISPLAY", ":0")
|
||||
|
||||
|
||||
def resolve_save_paths(save_root, save_paths):
|
||||
"""Resolve the subdirectories of a profile that actually hold save data.
|
||||
|
||||
An emulator profile is not a save directory. Dolphin's holds shader caches and dumps that dwarf
|
||||
the bounded revision size, so capturing the whole profile would fail on any real installation.
|
||||
Naming the save-bearing subdirectories keeps a capture to what an operator wants back, and
|
||||
resolving them here means a wrong path is visible in /v1/status instead of silently capturing
|
||||
the wrong thing.
|
||||
"""
|
||||
if save_root is None:
|
||||
return []
|
||||
root = Path(save_root)
|
||||
if not root.is_dir():
|
||||
return []
|
||||
root = root.resolve()
|
||||
if not save_paths:
|
||||
return [root]
|
||||
resolved = []
|
||||
for relative in save_paths:
|
||||
candidate = PurePosixPath(relative)
|
||||
if candidate.is_absolute() or any(part in ("", ".", "..") for part in candidate.parts):
|
||||
continue
|
||||
target = (root / Path(*candidate.parts)).resolve()
|
||||
if root in target.parents and target.is_dir():
|
||||
resolved.append(target)
|
||||
return resolved
|
||||
|
||||
|
||||
def export_save_data(save_root, save_paths=()):
|
||||
"""Pack an emulator's app-owned save directories into one bounded gzip archive.
|
||||
|
||||
Only regular files are packed and every ownership, device and symlink attribute is dropped, so
|
||||
the archive describes save content and nothing about the container it came from. Paths stay
|
||||
relative to the profile root, so a restore lands exactly where the capture came from.
|
||||
"""
|
||||
root = Path(save_root).resolve() if save_root is not None else None
|
||||
directories = resolve_save_paths(save_root, save_paths)
|
||||
if not directories:
|
||||
raise ValueError("No save-data directory is present for this runtime")
|
||||
buffer = io.BytesIO()
|
||||
packed = 0
|
||||
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
|
||||
for directory in directories:
|
||||
for path in sorted(directory.rglob("*")):
|
||||
if path.is_symlink() or not path.is_file():
|
||||
continue
|
||||
packed += 1
|
||||
if packed > MAX_SAVE_DATA_ENTRIES:
|
||||
raise ValueError("The save directories hold more files than one revision may carry")
|
||||
info = archive.gettarinfo(str(path), arcname=str(path.relative_to(root).as_posix()))
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = ""
|
||||
info.mode = 0o644
|
||||
info.mtime = int(info.mtime)
|
||||
with path.open("rb") as content:
|
||||
archive.addfile(info, content)
|
||||
if buffer.tell() > MAX_SAVE_DATA_BYTES:
|
||||
raise ValueError("The save-data archive exceeds the bounded revision size")
|
||||
if packed == 0:
|
||||
raise ValueError("The configured save directories hold no save data yet")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def import_save_data(save_root, payload, save_paths=()):
|
||||
"""Restore a previously exported archive over an emulator's save directories.
|
||||
|
||||
The archive is operator data that has travelled through the vault, so every member is checked
|
||||
before extraction: only plain relative files are written, nothing may escape the root, and a
|
||||
member must land inside one of the configured save directories. A restore therefore cannot
|
||||
reach an emulator's configuration even if the stored archive was crafted to try.
|
||||
"""
|
||||
root = Path(save_root).resolve() if save_root is not None else None
|
||||
directories = resolve_save_paths(save_root, save_paths)
|
||||
if root is None or not root.is_dir() or not directories:
|
||||
raise ValueError("No save-data directory is present for this runtime")
|
||||
if len(payload) > MAX_SAVE_DATA_BYTES:
|
||||
raise ValueError("The save-data archive exceeds the bounded revision size")
|
||||
with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
if len(members) > MAX_SAVE_DATA_ENTRIES:
|
||||
raise ValueError("The save-data archive holds more files than one revision may carry")
|
||||
expanded = 0
|
||||
validated = []
|
||||
for member in members:
|
||||
if not member.isfile():
|
||||
raise ValueError(f"The save-data archive contains an unsupported entry '{member.name}'")
|
||||
if member.size < 0 or member.size > MAX_SAVE_DATA_ENTRY_BYTES:
|
||||
raise ValueError(f"The save-data archive entry '{member.name}' is too large")
|
||||
expanded += member.size
|
||||
if expanded > MAX_SAVE_DATA_EXPANDED_BYTES:
|
||||
raise ValueError("The expanded save-data archive exceeds the restore limit")
|
||||
relative = PurePosixPath(member.name)
|
||||
if relative.is_absolute() or any(part in ("", ".", "..") for part in relative.parts):
|
||||
raise ValueError(f"The save-data archive contains an unsafe path '{member.name}'")
|
||||
destination = (root / Path(*relative.parts)).resolve()
|
||||
if root not in destination.parents:
|
||||
raise ValueError(f"The save-data archive escapes the save directory at '{member.name}'")
|
||||
if not any(directory == destination or directory in destination.parents
|
||||
for directory in directories):
|
||||
raise ValueError(f"The save-data archive targets '{member.name}' outside the save directories")
|
||||
validated.append((member, destination))
|
||||
|
||||
restored = 0
|
||||
for member, destination in validated:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
source = archive.extractfile(member)
|
||||
if source is None:
|
||||
raise ValueError(f"The save-data archive entry '{member.name}' is unreadable")
|
||||
temporary = None
|
||||
try:
|
||||
with source, tempfile.NamedTemporaryFile(dir=destination.parent, delete=False) as output:
|
||||
temporary = Path(output.name)
|
||||
copied = 0
|
||||
while chunk := source.read(1024 * 1024):
|
||||
copied += len(chunk)
|
||||
if copied > member.size or copied > MAX_SAVE_DATA_ENTRY_BYTES:
|
||||
raise ValueError(f"The save-data archive entry '{member.name}' exceeds its declared size")
|
||||
output.write(chunk)
|
||||
if copied != member.size:
|
||||
raise ValueError(f"The save-data archive entry '{member.name}' is truncated")
|
||||
os.chmod(temporary, 0o644)
|
||||
os.replace(temporary, destination)
|
||||
temporary = None
|
||||
restored += 1
|
||||
finally:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return restored
|
||||
|
||||
|
||||
def runtime_environment(process_name):
|
||||
return {
|
||||
**os.environ,
|
||||
"HOME": "/config",
|
||||
"DISPLAY": desktop_display(process_name),
|
||||
"XDG_RUNTIME_DIR": "/config/.XDG",
|
||||
"WAYLAND_DISPLAY": "wayland-0",
|
||||
}
|
||||
|
||||
|
||||
def visible_windows(environment, pid=None, name=None):
|
||||
"""List visible window ids for the launched title.
|
||||
|
||||
The launched process id is authoritative: it is the exact emulator Ludarium started,
|
||||
so a hotkey can never reach an unrelated window such as an emulator's own menu or
|
||||
file browser. Matching on the window name stays as a bounded fallback for runtimes
|
||||
that do not publish _NET_WM_PID.
|
||||
"""
|
||||
for query in ([["--pid", str(pid)]] if pid else []) + ([["--name", name]] if name else []):
|
||||
found = subprocess.run(
|
||||
[SETUIDGID, RUNTIME_USER, XDOTOOL, "search", "--onlyvisible", *query],
|
||||
env=environment, capture_output=True, text=True, timeout=5, check=False)
|
||||
windows = [line for line in found.stdout.splitlines() if line.isdigit()]
|
||||
if windows:
|
||||
return windows
|
||||
return []
|
||||
|
||||
|
||||
class EmulatorRuntime:
|
||||
"""Owns the single title process a sidecar may run at a time."""
|
||||
|
||||
def __init__(self, name, process_name, launch_argv, games, extensions, hotkeys,
|
||||
save_mode, platforms=None, window_name=None, ready_seconds=1.0, save_root=None,
|
||||
save_paths=()):
|
||||
self.name = name
|
||||
self.process_name = process_name
|
||||
self.launch_argv = launch_argv
|
||||
self.games = games
|
||||
self.extensions = extensions
|
||||
self.hotkeys = hotkeys
|
||||
self.save_mode = save_mode
|
||||
self.platforms = platforms
|
||||
self.window_name = window_name
|
||||
self.ready_seconds = ready_seconds
|
||||
self.save_root = save_root
|
||||
self.save_paths = tuple(save_paths)
|
||||
self.lock = threading.Lock()
|
||||
self.active = None
|
||||
|
||||
def log(self, message):
|
||||
print(f"{self.name}-controller: {message}", flush=True)
|
||||
|
||||
def resolve(self, value):
|
||||
return resolve_game_path(value, self.games, self.extensions, self.platforms)
|
||||
|
||||
def environment(self):
|
||||
return runtime_environment(self.process_name)
|
||||
|
||||
def resolved_save_paths(self):
|
||||
return resolve_save_paths(self.save_root, self.save_paths)
|
||||
|
||||
@property
|
||||
def save_data_available(self):
|
||||
return len(self.resolved_save_paths()) > 0
|
||||
|
||||
def status(self):
|
||||
directories = self.resolved_save_paths()
|
||||
with self.lock:
|
||||
running = self.active is not None and self.active.poll() is None
|
||||
return {"running": running, "pid": self.active.pid if running else None,
|
||||
"saveMode": self.save_mode, "saveData": len(directories) > 0,
|
||||
# Naming what will be captured makes a misconfigured path visible here rather
|
||||
# than at the moment an operator tries to keep their progress.
|
||||
"saveRoot": self.save_root,
|
||||
"savePaths": [str(directory) for directory in directories]}
|
||||
|
||||
def terminate_locked(self):
|
||||
if self.active is None or self.active.poll() is not None:
|
||||
self.active = None
|
||||
return False
|
||||
self.active.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
self.active.wait(timeout=8)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.active.kill()
|
||||
self.active.wait(timeout=3)
|
||||
self.active = None
|
||||
return True
|
||||
|
||||
def stop(self):
|
||||
with self.lock:
|
||||
return self.terminate_locked()
|
||||
|
||||
def launch(self, target):
|
||||
environment = self.environment()
|
||||
with self.lock:
|
||||
self.terminate_locked()
|
||||
self.active = subprocess.Popen(
|
||||
[SETUIDGID, RUNTIME_USER, *self.launch_argv(target)], env=environment,
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True)
|
||||
process = self.active
|
||||
pid = process.pid
|
||||
time.sleep(self.ready_seconds)
|
||||
if process.poll() is not None:
|
||||
self.log(f"{self.name} exited early with code {process.returncode}; "
|
||||
f"display={environment['DISPLAY']}; game={target}")
|
||||
return None
|
||||
return pid
|
||||
|
||||
def hotkey(self, key):
|
||||
environment = self.environment()
|
||||
with self.lock:
|
||||
pid = self.active.pid if self.active is not None and self.active.poll() is None else None
|
||||
windows = visible_windows(environment, pid, self.window_name)
|
||||
if not windows:
|
||||
self.log(f"no visible {self.name} title window for hotkey {key}; "
|
||||
f"display={environment['DISPLAY']}; pid={pid}")
|
||||
return False
|
||||
subprocess.run(
|
||||
[SETUIDGID, RUNTIME_USER, XDOTOOL, "windowactivate", "--sync", windows[-1], "key", key],
|
||||
env=environment, timeout=5, check=True)
|
||||
return True
|
||||
|
||||
|
||||
def build_handler(runtime, token):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = f"Ludarium{runtime.name.capitalize()}Controller/1"
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
runtime.log(fmt % args)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/health":
|
||||
return respond(self, 200, {"status": "healthy"})
|
||||
if not authorized(self, token):
|
||||
return respond(self, 404, {"message": "Not found"})
|
||||
if self.path == "/v1/status":
|
||||
return respond(self, 200, runtime.status())
|
||||
if self.path == "/v1/save-data":
|
||||
return self.export_save_data()
|
||||
return respond(self, 404, {"message": "Not found"})
|
||||
|
||||
def export_save_data(self):
|
||||
if not runtime.save_data_available:
|
||||
return respond(self, 409, {"message": f"No {runtime.name} save directory is configured"})
|
||||
try:
|
||||
payload = export_save_data(runtime.save_root, runtime.save_paths)
|
||||
except (ValueError, OSError, tarfile.TarError) as error:
|
||||
runtime.log(f"save-data export failed: {error}")
|
||||
return respond(self, 409, {"message": str(error)})
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/gzip")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def do_POST(self):
|
||||
if not authorized(self, token):
|
||||
return respond(self, 404, {"message": "Not found"})
|
||||
if self.path == "/v1/save-data":
|
||||
return self.import_save_data()
|
||||
body = {}
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if length < MIN_REQUEST_BYTES or length > MAX_REQUEST_BYTES:
|
||||
raise ValueError("Invalid request size")
|
||||
body = json.loads(self.rfile.read(length))
|
||||
if not isinstance(body, dict):
|
||||
raise ValueError("Invalid request body")
|
||||
if self.path == "/v1/launch":
|
||||
return self.launch(body)
|
||||
if self.path == "/v1/action":
|
||||
return self.action(body)
|
||||
return respond(self, 404, {"message": "Not found"})
|
||||
except (ValueError, json.JSONDecodeError) as error:
|
||||
runtime.log(f"rejected {self.path}: {error}; "
|
||||
f"path={body.get('path') if isinstance(body, dict) else None!r}")
|
||||
return respond(self, 400, {"message": str(error)})
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
return respond(self, 503, {"message": f"{runtime.name} control operation failed",
|
||||
"detail": str(error)})
|
||||
|
||||
def import_save_data(self):
|
||||
if not runtime.save_data_available:
|
||||
return respond(self, 409, {"message": f"No {runtime.name} save directory is configured"})
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if length <= 0 or length > MAX_SAVE_DATA_BYTES:
|
||||
return respond(self, 400, {"message": "The save-data archive is empty or exceeds its bound"})
|
||||
payload = self.rfile.read(length)
|
||||
try:
|
||||
written = import_save_data(runtime.save_root, payload, runtime.save_paths)
|
||||
except (ValueError, OSError, tarfile.TarError, EOFError) as error:
|
||||
runtime.log(f"save-data restore rejected: {error}")
|
||||
return respond(self, 400, {"message": str(error)})
|
||||
runtime.log(f"restored {written} save-data files")
|
||||
return respond(self, 202, {"state": "restored", "files": written})
|
||||
|
||||
def launch(self, body):
|
||||
target = runtime.resolve(body.get("path", ""))
|
||||
pid = runtime.launch(target)
|
||||
if pid is None:
|
||||
return respond(self, 503, {"message": f"{runtime.name} exited before the title started"})
|
||||
return respond(self, 202, {"state": "starting", "pid": pid, "saveMode": runtime.save_mode})
|
||||
|
||||
def action(self, body):
|
||||
action = body.get("action", "")
|
||||
if action == "stop":
|
||||
if not runtime.stop():
|
||||
return respond(self, 409, {"message": f"No Ludarium-started {runtime.name} title is running"})
|
||||
return respond(self, 202, {"state": "accepted", "action": action})
|
||||
if action not in runtime.hotkeys:
|
||||
raise ValueError("Unsupported action")
|
||||
if not runtime.hotkey(runtime.hotkeys[action]):
|
||||
return respond(self, 409, {"message": f"No running {runtime.name} title window was found"})
|
||||
return respond(self, 202, {"state": "accepted", "action": action})
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
def serve(runtime, token, port=CONTROL_PORT):
|
||||
if not token:
|
||||
raise SystemExit(f"A {runtime.name} control token is required")
|
||||
ThreadingHTTPServer(("0.0.0.0", port), build_handler(runtime, token)).serve_forever()
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
: "${SWITCH_DATA_ROOT:?set the isolated app-owned Switch data root}"
|
||||
: "${SWITCH_LIBRARY:?set the read-only Switch source library}"
|
||||
: "${PUID:?set the non-root Eden UID}"
|
||||
: "${PGID:?set the non-root Eden GID}"
|
||||
|
||||
case "$PUID:$PGID" in
|
||||
*[!0-9:]*|:*|*:) echo "PUID and PGID must be numeric." >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
if [ "$PUID" -eq 0 ] || [ "$PGID" -eq 0 ]; then
|
||||
echo "Eden must use a non-root PUID and PGID." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$SWITCH_DATA_ROOT" in
|
||||
/*) ;;
|
||||
*) echo "SWITCH_DATA_ROOT must be absolute." >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
case "$SWITCH_LIBRARY" in
|
||||
/*) ;;
|
||||
*) echo "SWITCH_LIBRARY must be absolute." >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
data_parent=$(dirname -- "$SWITCH_DATA_ROOT")
|
||||
data_name=$(basename -- "$SWITCH_DATA_ROOT")
|
||||
if [ ! -d "$data_parent" ]; then
|
||||
echo "The SWITCH_DATA_ROOT parent must already exist: $data_parent" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
data_parent=$(readlink -f -- "$data_parent")
|
||||
data_root=$(readlink -m -- "$data_parent/$data_name")
|
||||
library_root=$(readlink -f -- "$SWITCH_LIBRARY")
|
||||
|
||||
case "$data_root" in
|
||||
/|/mnt|/mnt/user|/mnt/user/appdata)
|
||||
echo "Refusing unsafe SWITCH_DATA_ROOT: $data_root" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$data_root/" in
|
||||
"$library_root"/*)
|
||||
echo "SWITCH_DATA_ROOT must not be inside the source library." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$library_root/" in
|
||||
"$data_root"/*)
|
||||
echo "SWITCH_LIBRARY must not be inside app-owned data." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
mkdir -p -- "$data_root/config"
|
||||
data_root=$(readlink -f -- "$data_root")
|
||||
|
||||
# Eden must be able to read and update its own persistent config after a PUID/PGID change.
|
||||
# This path is app-owned; the source library is intentionally never touched.
|
||||
chown -R "$PUID:$PGID" -- "$data_root/config"
|
||||
chmod 750 -- "$data_root/config"
|
||||
find "$data_root/config" -type f -name qt-config.ini -exec chmod 600 {} +
|
||||
|
||||
printf 'Prepared %s for Eden identity %s:%s; source library untouched: %s\n' \
|
||||
"$data_root/config" "$PUID" "$PGID" "$library_root"
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
database="${POSTGRES_DB:-ludarium}"
|
||||
database_user="${POSTGRES_USER:-ludarium}"
|
||||
if [[ ! "$database" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
|
||||
echo "PostgreSQL collation repair requires a simple validated database identifier." >&2
|
||||
exit 1
|
||||
fi
|
||||
psql_base=(psql -v ON_ERROR_STOP=1 -U "$database_user" -d "$database")
|
||||
|
||||
versions="$("${psql_base[@]}" -At -F '|' -c \
|
||||
"select coalesce(datcollversion,''),coalesce(pg_database_collation_actual_version(oid),'') from pg_database where datname=current_database()")"
|
||||
IFS='|' read -r stored_version actual_version <<< "$versions"
|
||||
|
||||
if [[ -z "$actual_version" || "$stored_version" == "$actual_version" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf 'PostgreSQL collation drift detected for %s: stored=%s actual=%s\n' \
|
||||
"$database" "${stored_version:-<unversioned>}" "$actual_version"
|
||||
|
||||
has_platform_definitions="$("${psql_base[@]}" -At -c \
|
||||
"select to_regclass('public.platform_definitions') is not null")"
|
||||
if [[ "$has_platform_definitions" == "t" ]]; then
|
||||
unsafe_duplicates="$("${psql_base[@]}" -At -c "
|
||||
with duplicate_ids as (
|
||||
select id from platform_definitions group by id having count(*) > 1
|
||||
), variants as (
|
||||
select p.id,
|
||||
count(distinct jsonb_build_object(
|
||||
'custom',p.custom,'enabled',p.enabled,'version',p.version,
|
||||
'data',p.data - 'updatedAt')) as semantic_variants,
|
||||
bool_or(p.custom) as contains_custom
|
||||
from platform_definitions p join duplicate_ids d using(id)
|
||||
group by p.id
|
||||
)
|
||||
select count(*) from variants where contains_custom or semantic_variants <> 1")"
|
||||
if [[ "$unsafe_duplicates" != "0" ]]; then
|
||||
echo "Collation repair found ambiguous or custom platform duplicates; refusing automatic data changes." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
removed_duplicates="$("${psql_base[@]}" -At -c "
|
||||
with ranked as (
|
||||
select ctid,row_number() over(partition by id order by updated_at desc,ctid desc) as ordinal
|
||||
from platform_definitions
|
||||
), removed as (
|
||||
delete from platform_definitions p using ranked r
|
||||
where p.ctid=r.ctid and r.ordinal > 1 returning 1
|
||||
)
|
||||
select count(*) from removed")"
|
||||
if [[ "$removed_duplicates" != "0" ]]; then
|
||||
printf 'Removed %s semantically identical built-in platform duplicate(s) before reindex.\n' "$removed_duplicates"
|
||||
fi
|
||||
fi
|
||||
|
||||
"${psql_base[@]}" -c "REINDEX DATABASE \"$database\"" >/dev/null
|
||||
if [[ -z "$stored_version" ]]; then
|
||||
"${psql_base[@]}" -c "
|
||||
update pg_database
|
||||
set datcollversion=pg_database_collation_actual_version(oid)
|
||||
where datname=current_database() and datcollversion is null" >/dev/null
|
||||
else
|
||||
"${psql_base[@]}" -c "ALTER DATABASE \"$database\" REFRESH COLLATION VERSION" >/dev/null
|
||||
fi
|
||||
|
||||
refreshed_version="$("${psql_base[@]}" -At -c \
|
||||
"select coalesce(datcollversion,'') from pg_database where datname=current_database()")"
|
||||
if [[ "$refreshed_version" != "$actual_version" ]]; then
|
||||
echo "PostgreSQL collation version did not refresh to the runtime version." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'PostgreSQL collation indexes rebuilt and version refreshed to %s.\n' "$refreshed_version"
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
compose="$repo/deploy/compose.yml"
|
||||
project="${LUDARIUM_CANDIDATE_PROJECT:-ludarium-candidate}"
|
||||
candidate_port="${LUDARIUM_CANDIDATE_PORT:-1232}"
|
||||
data_root="${LUDARIUM_CANDIDATE_DATA_ROOT:?set an isolated candidate data root}"
|
||||
release_identity="$(tr -d '\r\n' < "$repo/VERSION")"
|
||||
image="${LUDARIUM_IMAGE:-ludarium/ludarium:$release_identity}"
|
||||
release_version="${LUDARIUM_RELEASE_VERSION:-${image##*:}}"
|
||||
playwright_image="mcr.microsoft.com/playwright:v1.62.1-noble@sha256:dcc5531e97840b9b5e794f2814476b21571c5124a3fca2267d73041f56e7580e"
|
||||
browser_gate_mode="${LUDARIUM_BROWSER_GATE_MODE:-docker}"
|
||||
dotnet_image="mcr.microsoft.com/dotnet/sdk:10.0.302@sha256:72dd743782f2ae7e5476fd64f6a460045e3998dc862218b80e6944cba79a01b0"
|
||||
gate_cpus="${LUDARIUM_GATE_CPUS:-4}"
|
||||
gate_memory="${LUDARIUM_GATE_MEMORY:-8g}"
|
||||
browser_gate_cpus="${LUDARIUM_BROWSER_GATE_CPUS:-4}"
|
||||
browser_gate_memory="${LUDARIUM_BROWSER_GATE_MEMORY:-4g}"
|
||||
build_nodes="${LUDARIUM_GATE_BUILD_NODES:-2}"
|
||||
export COMPOSE_PARALLEL_LIMIT="${LUDARIUM_COMPOSE_PARALLEL_LIMIT:-1}"
|
||||
|
||||
case "$browser_gate_mode" in docker|external) ;; *) echo "browser gate mode must be docker or external" >&2; exit 2;; esac
|
||||
case "$gate_cpus:$browser_gate_cpus:$build_nodes:$COMPOSE_PARALLEL_LIMIT" in
|
||||
*[!0-9.:]*) echo "candidate resource limits must be positive numeric values" >&2; exit 2 ;;
|
||||
esac
|
||||
for value in "$gate_cpus" "$browser_gate_cpus" "$build_nodes" "$COMPOSE_PARALLEL_LIMIT"; do
|
||||
[ "$value" != "0" ] || { echo "candidate resource limits must be greater than zero" >&2; exit 2; }
|
||||
done
|
||||
case "$gate_memory:$browser_gate_memory" in
|
||||
*[!0-9kKmMgGtT:]*|:) echo "candidate memory limits must use Docker byte values such as 8g" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
case "$data_root" in
|
||||
/mnt/user/appdata/ludarium-candidate|/mnt/user/appdata/ludarium-candidate/*|/tmp/ludarium-candidate|/tmp/ludarium-candidate/*) ;;
|
||||
*) echo "candidate data root must remain below /mnt/user/appdata/ludarium-candidate or /tmp/ludarium-candidate" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}"
|
||||
: "${LUDARIUM_ADMIN_TOKEN:?set LUDARIUM_ADMIN_TOKEN}"
|
||||
: "${GAMES_LIBRARY:?set GAMES_LIBRARY}"
|
||||
: "${PS4_LIBRARY:?set PS4_LIBRARY}"
|
||||
: "${PS5_LIBRARY:?set PS5_LIBRARY}"
|
||||
: "${PUID:?set the numeric read-capable application UID}"
|
||||
: "${PGID:?set the numeric read-capable application GID}"
|
||||
|
||||
for root in "$GAMES_LIBRARY" "$PS4_LIBRARY" "$PS5_LIBRARY"; do
|
||||
[ -d "$root" ] || { echo "library root unavailable: $root" >&2; exit 3; }
|
||||
done
|
||||
command -v docker >/dev/null || { echo "docker is required" >&2; exit 3; }
|
||||
|
||||
ensure_image() {
|
||||
target_image="$1"
|
||||
docker image inspect "$target_image" >/dev/null 2>&1 && return
|
||||
attempt=1
|
||||
while [ "$attempt" -le 3 ]; do
|
||||
if command -v timeout >/dev/null; then timeout 420 docker pull "$target_image" && return
|
||||
else docker pull "$target_image" && return
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
echo "bounded image download failed after 3 attempts: $target_image" >&2
|
||||
exit 3
|
||||
}
|
||||
[ "$browser_gate_mode" = "external" ] || ensure_image "$playwright_image"
|
||||
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT INT TERM
|
||||
before="$work/source-before.txt"
|
||||
after="$work/source-after.txt"
|
||||
source_archive="$work/source.tar"
|
||||
git -C "$repo" archive --format=tar --output="$source_archive" HEAD
|
||||
sh "$repo/deploy/verify-source-manifest.sh" "$GAMES_LIBRARY" "$PS4_LIBRARY" "$PS5_LIBRARY" > "$before"
|
||||
cat "$before"
|
||||
|
||||
if command -v dotnet >/dev/null; then
|
||||
dotnet restore "$repo/Ludarium.slnx" --locked-mode
|
||||
dotnet format "$repo/Ludarium.slnx" --no-restore --verify-no-changes
|
||||
dotnet build "$repo/Ludarium.slnx" -c Release --no-restore -m:"$build_nodes" -p:UseSharedCompilation=false
|
||||
LUDARIUM_RUN_CONTAINER_TESTS=1 dotnet test "$repo/Ludarium.slnx" -c Release --no-build --no-restore -m:"$build_nodes"
|
||||
else
|
||||
docker run --rm --network host --cpus "$gate_cpus" --memory "$gate_memory" \
|
||||
--memory-swap "$gate_memory" --pids-limit 1024 --tmpfs /work:rw,nosuid,nodev,size=2g \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock -v "$source_archive:/source.tar:ro" -w /work \
|
||||
-e LUDARIUM_RUN_CONTAINER_TESTS=1 -e LUDARIUM_GATE_BUILD_NODES="$build_nodes" \
|
||||
"$dotnet_image" sh -lc \
|
||||
'tar -xf /source.tar -C /work && dotnet restore Ludarium.slnx --locked-mode && dotnet format Ludarium.slnx --no-restore --verify-no-changes && dotnet build Ludarium.slnx -c Release --no-restore -m:"$LUDARIUM_GATE_BUILD_NODES" -p:UseSharedCompilation=false && dotnet test Ludarium.slnx -c Release --no-build --no-restore -m:"$LUDARIUM_GATE_BUILD_NODES"'
|
||||
fi
|
||||
|
||||
# The emulator sidecar controllers are the last defence before a process launch inside an
|
||||
# isolated player. They are stdlib-only, so this gate needs no dependency installation.
|
||||
if command -v python3 >/dev/null; then
|
||||
( cd "$repo" && python3 -m unittest discover -s tests/controllers -t tests/controllers )
|
||||
else
|
||||
docker run --rm --network none --cpus "$gate_cpus" --memory 512m \
|
||||
-v "$repo:/repo:ro" -w /repo python:3.13-alpine@sha256:540c7d91f98ff6880174c40e99067bf5941eb54d818a7a5e094d188b196a934d \
|
||||
python -m unittest discover -s tests/controllers -t tests/controllers
|
||||
fi
|
||||
|
||||
mkdir -p "$data_root/app" "$data_root/cache" "$data_root/exports" "$data_root/postgres" "$data_root/backups"
|
||||
chown -R "$PUID:$PGID" "$data_root/app" "$data_root/cache" "$data_root/exports"
|
||||
export LUDARIUM_DATA_ROOT="$data_root" LUDARIUM_HTTP_PORT="$candidate_port" LUDARIUM_IMAGE="$image"
|
||||
export LUDARIUM_CONTAINER_NAME="${LUDARIUM_CANDIDATE_CONTAINER_NAME:-${project}-Ludarium}"
|
||||
|
||||
docker compose -p "$project" -f "$compose" build --pull
|
||||
image_digest="$(docker image inspect "$image" --format '{{.Id}}')"
|
||||
docker compose -p "$project" -f "$compose" up -d --wait
|
||||
|
||||
app_id="$(docker compose -p "$project" -f "$compose" ps -q ludarium)"
|
||||
[ -n "$app_id" ] || { echo "candidate app container was not created" >&2; exit 4; }
|
||||
initial_restart_count="$(docker inspect "$app_id" --format '{{.RestartCount}}')"
|
||||
[ "$initial_restart_count" = "0" ] || { echo "candidate started with an unexpected restart count: $initial_restart_count" >&2; exit 4; }
|
||||
docker inspect "$app_id" --format '{{range .Config.Env}}{{println .}}{{end}}' |
|
||||
grep -Fq 'GSS Encryption Mode=Disable' || {
|
||||
echo "candidate database connection must disable unused local GSS negotiation" >&2
|
||||
exit 4
|
||||
}
|
||||
for destination in /library/games /library/ps4 /library/ps5; do
|
||||
docker inspect "$app_id" --format '{{range .Mounts}}{{println .Destination .RW}}{{end}}' | grep -F "$destination false" >/dev/null || {
|
||||
echo "read-only mount proof failed for $destination" >&2; exit 5;
|
||||
}
|
||||
done
|
||||
|
||||
wget -qO- "http://127.0.0.1:$candidate_port/health/ready" >/dev/null
|
||||
schema="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc 'select max(version) from schema_versions')"
|
||||
expected_schema="$(sed -n 's/.*CurrentSchemaVersion = \([0-9][0-9]*\).*/\1/p' "$repo/src/Ludarium.Infrastructure/PostgresStore.cs")"
|
||||
case "$expected_schema" in ''|*[!0-9]*) echo "could not read the declared schema version" >&2; exit 6;; esac
|
||||
[ "$schema" = "$expected_schema" ] || { echo "expected schema $expected_schema, found $schema" >&2; exit 6; }
|
||||
server_encoding="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc 'show server_encoding')"
|
||||
[ "$server_encoding" = "UTF8" ] || { echo "expected UTF8 database encoding, found $server_encoding" >&2; exit 6; }
|
||||
docker compose -p "$project" -f "$compose" exec -T ludarium locale -a | grep -Fx 'en_US.utf8' >/dev/null || {
|
||||
echo "candidate runtime must provide the legacy production locale en_US.utf8" >&2
|
||||
exit 6
|
||||
}
|
||||
for locale_setting in lc_messages lc_monetary lc_numeric lc_time; do
|
||||
docker compose -p "$project" -f "$compose" exec -T ludarium \
|
||||
psql -v ON_ERROR_STOP=1 -U ludarium -d ludarium \
|
||||
-c "ALTER SYSTEM SET $locale_setting TO 'en_US.utf8'" >/dev/null
|
||||
done
|
||||
reload_result="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc 'select pg_reload_conf()')"
|
||||
[ "$reload_result" = "t" ] || { echo "failed to load the production-shaped locale profile" >&2; exit 6; }
|
||||
|
||||
collation_fixture="ludarium_collation_fixture"
|
||||
docker compose -p "$project" -f "$compose" exec -T ludarium dropdb -U ludarium --if-exists "$collation_fixture"
|
||||
docker compose -p "$project" -f "$compose" exec -T ludarium \
|
||||
createdb -U ludarium --template=template0 --locale=en_US.utf8 "$collation_fixture"
|
||||
docker compose -p "$project" -f "$compose" exec -T ludarium psql -v ON_ERROR_STOP=1 -U ludarium -d "$collation_fixture" -c "
|
||||
create table platform_definitions(id text,custom boolean not null,enabled boolean not null,version bigint not null,data jsonb not null,updated_at timestamptz not null);
|
||||
insert into platform_definitions values
|
||||
('game-gear',false,true,1,'{\"id\":\"game-gear\",\"custom\":false,\"enabled\":true,\"updatedAt\":\"2026-08-10T00:00:00Z\"}',timestamptz '2026-08-10T00:00:00Z'),
|
||||
('game-gear',false,true,1,'{\"id\":\"game-gear\",\"custom\":false,\"enabled\":true,\"updatedAt\":\"2026-08-21T00:00:00Z\"}',timestamptz '2026-08-21T00:00:00Z');
|
||||
create table indexed_fixture(id text primary key);
|
||||
insert into indexed_fixture values('alpha'),('beta');" >/dev/null
|
||||
fixture_index_before="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d "$collation_fixture" -Atc \
|
||||
"select relfilenode from pg_class where oid='indexed_fixture_pkey'::regclass")"
|
||||
docker compose -p "$project" -f "$compose" exec -T ludarium psql -v ON_ERROR_STOP=1 -U ludarium -d postgres -c \
|
||||
"update pg_database set datcollversion=null where datname='$collation_fixture'" >/dev/null
|
||||
docker compose -p "$project" -f "$compose" exec -T -e POSTGRES_DB="$collation_fixture" ludarium \
|
||||
/usr/local/bin/repair-postgres-collation.sh
|
||||
fixture_rows="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d "$collation_fixture" -Atc \
|
||||
"select count(*) from platform_definitions where id='game-gear'")"
|
||||
fixture_index_after="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d "$collation_fixture" -Atc \
|
||||
"select relfilenode from pg_class where oid='indexed_fixture_pkey'::regclass")"
|
||||
fixture_version_current="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d "$collation_fixture" -Atc \
|
||||
"select datcollversion=pg_database_collation_actual_version(oid) from pg_database where datname=current_database()")"
|
||||
docker compose -p "$project" -f "$compose" exec -T ludarium dropdb -U ludarium "$collation_fixture"
|
||||
[ "$fixture_rows" = "1" ] || { echo "legacy collation fixture did not safely deduplicate built-in rows" >&2; exit 6; }
|
||||
[ "$fixture_index_before" != "$fixture_index_after" ] || { echo "legacy collation fixture index was not rebuilt" >&2; exit 6; }
|
||||
[ "$fixture_version_current" = "t" ] || { echo "legacy collation fixture version was not refreshed" >&2; exit 6; }
|
||||
|
||||
api="http://127.0.0.1:$candidate_port/api/v1"
|
||||
auth="Authorization: Bearer $LUDARIUM_ADMIN_TOKEN"
|
||||
libraries="$(curl -fsS -H "$auth" "$api/libraries")"
|
||||
if [ "$(printf '%s' "$libraries" | jq 'length')" = "0" ]; then
|
||||
create_library() {
|
||||
curl -fsS -H "$auth" -H 'Content-Type: application/json' -d "$2" "$api/libraries" | jq -r '.id'
|
||||
}
|
||||
games_id="$(create_library games '{"name":"Games","path":"/library/games","kind":"Mixed","recursive":true,"hashPolicy":"OnDemand","inspectArchives":true,"maxConcurrency":1}')"
|
||||
ps4_id="$(create_library ps4 '{"name":"PS4 Games","path":"/library/ps4","kind":"DiscImage","recursive":true,"hashPolicy":"OnDemand","inspectArchives":false,"maxConcurrency":1}')"
|
||||
ps5_id="$(create_library ps5 '{"name":"PS5 Games","path":"/library/ps5","kind":"DiscImage","recursive":true,"hashPolicy":"OnDemand","inspectArchives":false,"maxConcurrency":1}')"
|
||||
libraries="$(curl -fsS -H "$auth" "$api/libraries")"
|
||||
fi
|
||||
scan_ids=""
|
||||
for library_id in $(printf '%s' "$libraries" | jq -r '.[].id'); do
|
||||
curl -fsS -X POST -H "$auth" "$api/libraries/$library_id/verify" >/dev/null
|
||||
scan_id="$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' -d '{"mode":"Quick"}' "$api/libraries/$library_id/scans" | jq -r '.id')"
|
||||
scan_ids="$scan_ids $scan_id"
|
||||
done
|
||||
deadline=$(( $(date +%s) + 1800 ))
|
||||
while :; do
|
||||
all_done=true
|
||||
for scan_id in $scan_ids; do
|
||||
state="$(curl -fsS -H "$auth" "$api/scans/$scan_id" | jq -r '.state')"
|
||||
[ "$state" != "Failed" ] || { echo "candidate bootstrap scan failed: $scan_id" >&2; exit 6; }
|
||||
[ "$state" = "Completed" ] || [ "$state" = "Cancelled" ] || all_done=false
|
||||
done
|
||||
[ "$all_done" = true ] && break
|
||||
[ "$(date +%s)" -lt "$deadline" ] || { echo "candidate bootstrap scans timed out" >&2; exit 6; }
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [ "$browser_gate_mode" = "docker" ]; then
|
||||
docker run --rm --network host --cpus "$browser_gate_cpus" --memory "$browser_gate_memory" \
|
||||
--memory-swap "$browser_gate_memory" --pids-limit 1024 \
|
||||
--tmpfs /work:rw,exec,nosuid,nodev,size=768m \
|
||||
-e PLAYWRIGHT_BASE_URL="http://127.0.0.1:$candidate_port" \
|
||||
-e LUDARIUM_ADMIN_TOKEN \
|
||||
-v "$source_archive:/source.tar:ro" -w /work "$playwright_image" \
|
||||
sh -lc 'tar -xf /source.tar -C /work --strip-components=2 src/Ludarium.Web && (npm ci --ignore-scripts || (sleep 5 && npm ci --ignore-scripts) || (sleep 15 && npm ci --ignore-scripts)) && node node_modules/vitest/vitest.mjs run --maxWorkers=4 && node node_modules/typescript/bin/tsc --noEmit && node node_modules/vite/bin/vite.js build && npm audit --audit-level=high && node e2e/workflows.mjs && node e2e/game-data-vault.mjs && node e2e/accessibility.mjs'
|
||||
else
|
||||
printf 'browser gate delegated: run the checked-in Playwright workflows against http://HOST:%s before promotion\n' "$candidate_port"
|
||||
fi
|
||||
|
||||
post_browser_restart_count="$(docker inspect "$app_id" --format '{{.RestartCount}}')"
|
||||
[ "$post_browser_restart_count" = "$initial_restart_count" ] || {
|
||||
echo "candidate restarted unexpectedly during browser gates: $initial_restart_count -> $post_browser_restart_count" >&2
|
||||
exit 6
|
||||
}
|
||||
|
||||
docker compose -p "$project" -f "$compose" restart ludarium
|
||||
docker compose -p "$project" -f "$compose" up -d --wait
|
||||
wget -qO- "http://127.0.0.1:$candidate_port/health/ready" >/dev/null
|
||||
server_encoding="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc 'show server_encoding')"
|
||||
[ "$server_encoding" = "UTF8" ] || { echo "expected UTF8 database encoding after restart, found $server_encoding" >&2; exit 6; }
|
||||
for locale_setting in lc_messages lc_monetary lc_numeric lc_time; do
|
||||
configured_locale="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium -Atc "show $locale_setting")"
|
||||
[ "$configured_locale" = "en_US.utf8" ] || {
|
||||
echo "production-shaped locale $locale_setting did not survive restart: $configured_locale" >&2
|
||||
exit 6
|
||||
}
|
||||
done
|
||||
post_controlled_restart_count="$(docker inspect "$app_id" --format '{{.RestartCount}}')"
|
||||
[ "$post_controlled_restart_count" = "$initial_restart_count" ] || {
|
||||
echo "candidate restart count changed after controlled restart: $initial_restart_count -> $post_controlled_restart_count" >&2
|
||||
exit 6
|
||||
}
|
||||
|
||||
backup="$data_root/backups/candidate-schema${expected_schema}.dump"
|
||||
docker compose -p "$project" -f "$compose" exec -T ludarium pg_dump -U ludarium -Fc ludarium > "$backup"
|
||||
docker compose -p "$project" -f "$compose" exec -T ludarium createdb -U ludarium ludarium_restore
|
||||
docker compose -p "$project" -f "$compose" exec -T ludarium pg_restore -U ludarium -d ludarium_restore --clean --if-exists < "$backup"
|
||||
restored_schema="$(docker compose -p "$project" -f "$compose" exec -T ludarium psql -U ludarium -d ludarium_restore -Atc 'select max(version) from schema_versions')"
|
||||
docker compose -p "$project" -f "$compose" exec -T ludarium dropdb -U ludarium ludarium_restore
|
||||
[ "$restored_schema" = "$expected_schema" ] || {
|
||||
echo "restore schema verification failed: expected $expected_schema, found $restored_schema" >&2
|
||||
exit 7
|
||||
}
|
||||
|
||||
sh "$repo/deploy/verify-source-manifest.sh" "$GAMES_LIBRARY" "$PS4_LIBRARY" "$PS5_LIBRARY" > "$after"
|
||||
cat "$after"
|
||||
cmp -s "$before" "$after" || { echo "source manifest changed" >&2; exit 8; }
|
||||
|
||||
LUDARIUM_IMAGE="$image" LUDARIUM_RELEASE_VERSION="$release_version" LUDARIUM_RELEASE_TOOL_CACHE="$data_root/release-tools" sh "$repo/deploy/run-security-gates.sh"
|
||||
if [ "$browser_gate_mode" = "docker" ]; then gate_state=passed; else gate_state=infrastructure-passed-browser-external-required; fi
|
||||
printf 'candidate gate %s image=%s digest=%s port=%s schema=%s encoding=%s backup=%s\n' "$gate_state" "$image" "$image_digest" "$candidate_port" "$schema" "$server_encoding" "$backup"
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
image="${1:-${SWITCH_IMAGE:-ludarium/eden-controller:0.4.9-rc.1}}"
|
||||
container="${2:-${SWITCH_CONTAINER_NAME:-Ludarium-Switch}}"
|
||||
evidence="${3:?set an evidence directory below /mnt/user/appdata/ludarium-candidate or /tmp}"
|
||||
vex="$repo/deploy/security/eden-controller-0.4.9.openvex.json"
|
||||
cache="${LUDARIUM_RELEASE_TOOL_CACHE:-/mnt/user/appdata/ludarium-candidate/release-tools}"
|
||||
|
||||
case "$evidence" in
|
||||
/mnt/user/appdata/ludarium-candidate/*|/tmp/*) ;;
|
||||
*) echo "Eden evidence must remain below /mnt/user/appdata/ludarium-candidate or /tmp" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
command -v docker >/dev/null || { echo "docker is required" >&2; exit 3; }
|
||||
command -v jq >/dev/null || { echo "jq is required" >&2; exit 3; }
|
||||
[ -f "$vex" ] || { echo "version-bound Eden OpenVEX document is missing" >&2; exit 3; }
|
||||
mkdir -p "$evidence" "$cache/bin" "$cache/downloads" "$cache/grype-db"
|
||||
|
||||
grype="$cache/bin/grype-0.116.1"
|
||||
if [ ! -x "$grype" ]; then
|
||||
archive="$cache/downloads/grype_0.116.1_linux_amd64.tar.gz"
|
||||
checksum="0122df7b655981abe547ad3d2190d65551dac6a2bfc80b4dc2a989b5d0587458"
|
||||
if [ ! -f "$archive" ] || ! printf '%s %s\n' "$checksum" "$archive" | sha256sum -c - >/dev/null 2>&1; then
|
||||
command -v curl >/dev/null || { echo "curl is required to acquire the pinned scanner" >&2; exit 3; }
|
||||
rm -f "$archive.part"
|
||||
curl --fail --location --silent --show-error --retry 5 --retry-all-errors \
|
||||
--connect-timeout 15 --max-time 300 \
|
||||
https://github.com/anchore/grype/releases/download/v0.116.1/grype_0.116.1_linux_amd64.tar.gz \
|
||||
-o "$archive.part"
|
||||
printf '%s %s\n' "$checksum" "$archive.part" | sha256sum -c - >/dev/null
|
||||
mv "$archive.part" "$archive"
|
||||
fi
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT INT TERM
|
||||
tar -xzf "$archive" -C "$work" grype
|
||||
install -m 0755 "$work/grype" "$grype"
|
||||
fi
|
||||
|
||||
container_image="$(docker inspect "$container" --format '{{.Image}}')"
|
||||
expected_image="$(docker image inspect "$image" --format '{{.Id}}')"
|
||||
[ "$container_image" = "$expected_image" ] || {
|
||||
echo "running Eden container does not use the exact requested image" >&2
|
||||
exit 4
|
||||
}
|
||||
[ "$(docker inspect "$container" --format '{{.State.Health.Status}}')" = "healthy" ] || {
|
||||
echo "Eden container is not healthy" >&2
|
||||
exit 4
|
||||
}
|
||||
[ "$(docker inspect "$container" --format '{{.RestartCount}}')" = "0" ] || {
|
||||
echo "Eden candidate has restarted" >&2
|
||||
exit 4
|
||||
}
|
||||
docker inspect "$container" --format '{{range .Mounts}}{{println .Destination .RW}}{{end}}' |
|
||||
grep -F '/games false' >/dev/null || { echo "Eden /games mount is not read-only" >&2; exit 4; }
|
||||
|
||||
docker exec "$container" /opt/ludarium/audit-eden-runtime.sh | tee "$evidence/runtime-audit.txt"
|
||||
cp "$vex" "$evidence/openvex.json"
|
||||
|
||||
run_grype() {
|
||||
if command -v timeout >/dev/null; then
|
||||
timeout 600 env GRYPE_DB_CACHE_DIR="$cache/grype-db" "$@"
|
||||
else
|
||||
env GRYPE_DB_CACHE_DIR="$cache/grype-db" "$@"
|
||||
fi
|
||||
}
|
||||
run_grype "$grype" db update
|
||||
run_grype "$grype" "docker:$image" -o json --file "$evidence/grype-raw.json"
|
||||
run_grype "$grype" "docker:$image" --vex "$vex" --fail-on critical \
|
||||
-o json --file "$evidence/grype-vex.json"
|
||||
|
||||
raw_critical="$(jq '[.matches[] | select(.vulnerability.severity == "Critical")] | length' "$evidence/grype-raw.json")"
|
||||
active_critical="$(jq '[.matches[] | select(.vulnerability.severity == "Critical")] | length' "$evidence/grype-vex.json")"
|
||||
ignored_critical="$(jq '[.ignoredMatches[] | select(.vulnerability.severity == "Critical")] | length' "$evidence/grype-vex.json")"
|
||||
[ "$raw_critical" -gt 0 ] || { echo "expected raw Critical evidence is absent; review the VEX baseline" >&2; exit 5; }
|
||||
[ "$active_critical" = "0" ] || { echo "unresolved Critical Eden findings remain" >&2; exit 5; }
|
||||
[ "$ignored_critical" = "$raw_critical" ] || { echo "VEX does not account for every raw Critical match" >&2; exit 5; }
|
||||
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT INT TERM
|
||||
jq -r '.matches[] | select(.vulnerability.severity == "Critical") | .vulnerability.id' \
|
||||
"$evidence/grype-raw.json" | sort -u > "$work/raw-ids"
|
||||
jq -r '.ignoredMatches[] | select(.vulnerability.severity == "Critical") | .vulnerability.id' \
|
||||
"$evidence/grype-vex.json" | sort -u > "$work/vex-ids"
|
||||
cmp -s "$work/raw-ids" "$work/vex-ids" || { echo "VEX Critical ID set differs from raw evidence" >&2; exit 5; }
|
||||
|
||||
raw_high="$(jq '[.matches[] | select(.vulnerability.severity == "High")] | length' "$evidence/grype-raw.json")"
|
||||
unique_critical="$(wc -l < "$work/raw-ids" | tr -d ' ')"
|
||||
sha256sum "$evidence/runtime-audit.txt" "$evidence/openvex.json" \
|
||||
"$evidence/grype-raw.json" "$evidence/grype-vex.json" > "$evidence/SHA256SUMS"
|
||||
printf 'Eden security gate passed image=%s digest=%s raw_critical=%s unique_critical=%s active_critical=0 raw_high=%s\n' \
|
||||
"$image" "$expected_image" "$raw_critical" "$unique_critical" "$raw_high"
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
profile="${1:-full}"
|
||||
case "$profile" in
|
||||
source|test|lint|typecheck|build|security|full) ;;
|
||||
*) echo "validation profile is not allowlisted: $profile" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
cd "$repo"
|
||||
git_worktree=false
|
||||
if [ -n "${WSL_INTEROP:-}" ] && command -v git.exe >/dev/null 2>&1; then
|
||||
dotnet_command=dotnet.exe
|
||||
python_command=python.exe
|
||||
docker_command=docker.exe
|
||||
default_release_cache="/var/tmp/ludarium-release-tools"
|
||||
npm_run() { cmd.exe /d /c npm "$@"; }
|
||||
repo_windows="$(wslpath -w "$repo")"
|
||||
if git.exe -C "$repo_windows" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git.exe -C "$repo_windows" diff --check
|
||||
if git.exe -C "$repo_windows" grep -nE '^(<<<<<<< |=======$|>>>>>>> )' -- . ':!*.lock' ':!*.patch'; then
|
||||
echo "unresolved merge markers detected" >&2
|
||||
exit 1
|
||||
fi
|
||||
git_worktree=true
|
||||
fi
|
||||
else
|
||||
dotnet_command=dotnet
|
||||
if command -v python3 >/dev/null 2>&1; then python_command=python3; else python_command=python; fi
|
||||
docker_command=docker
|
||||
default_release_cache="${RUNNER_TEMP:-/tmp}/ludarium-release-tools"
|
||||
npm_run() { npm "$@"; }
|
||||
if git -C "$repo" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git -C "$repo" diff --check
|
||||
if git -C "$repo" grep -nE '^(<<<<<<< |=======$|>>>>>>> )' -- . ':!*.lock' ':!*.patch'; then
|
||||
echo "unresolved merge markers detected" >&2
|
||||
exit 1
|
||||
fi
|
||||
git_worktree=true
|
||||
fi
|
||||
fi
|
||||
if [ "$git_worktree" = false ]; then
|
||||
if grep -RInI -E '^(<<<<<<< |=======$|>>>>>>> )' \
|
||||
--exclude='*.lock' --exclude='*.patch' .; then
|
||||
echo "unresolved merge markers detected" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
sh deploy/verify-release-identity.sh
|
||||
|
||||
case "$profile" in
|
||||
source|test|lint|build|full)
|
||||
"$dotnet_command" restore Ludarium.slnx --locked-mode
|
||||
;;
|
||||
esac
|
||||
case "$profile" in
|
||||
source|lint|full)
|
||||
"$dotnet_command" format Ludarium.slnx --verify-no-changes --no-restore
|
||||
;;
|
||||
esac
|
||||
case "$profile" in
|
||||
source|build|full)
|
||||
"$dotnet_command" build Ludarium.slnx --configuration Release --no-restore
|
||||
;;
|
||||
esac
|
||||
case "$profile" in
|
||||
test)
|
||||
"$dotnet_command" test Ludarium.slnx --configuration Release --no-restore
|
||||
;;
|
||||
source|full)
|
||||
"$dotnet_command" test Ludarium.slnx --configuration Release --no-build --no-restore
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$profile" in
|
||||
source|test|typecheck|build|security|full)
|
||||
npm_run ci --ignore-scripts --prefix src/Ludarium.Web
|
||||
;;
|
||||
esac
|
||||
case "$profile" in
|
||||
source|test|full)
|
||||
npm_run run test --prefix src/Ludarium.Web
|
||||
;;
|
||||
esac
|
||||
case "$profile" in
|
||||
typecheck)
|
||||
npm_run run typecheck --prefix src/Ludarium.Web
|
||||
;;
|
||||
source|build|full)
|
||||
npm_run run build --prefix src/Ludarium.Web
|
||||
;;
|
||||
esac
|
||||
case "$profile" in
|
||||
source|security|full)
|
||||
npm_run audit --audit-level=high --prefix src/Ludarium.Web
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$profile" in
|
||||
source|test|full)
|
||||
"$python_command" -m unittest discover -s tests/controllers -t tests/controllers
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$profile" in
|
||||
security|full)
|
||||
release="$(tr -d '\r\n' < VERSION)"
|
||||
image="${LUDARIUM_IMAGE:-ludarium/ludarium:validation-${GITHUB_SHA:-$release}}"
|
||||
build_network="${LUDARIUM_DOCKER_BUILD_NETWORK:-default}"
|
||||
emulatorjs_asset=""
|
||||
cleanup_build_inputs() {
|
||||
[ -z "$emulatorjs_asset" ] || rm -f "$emulatorjs_asset" "${emulatorjs_asset}.part"
|
||||
}
|
||||
trap cleanup_build_inputs EXIT HUP INT TERM
|
||||
case "$build_network" in
|
||||
default) "$docker_command" build --pull --file Dockerfile.unraid --tag "$image" . ;;
|
||||
host)
|
||||
emulatorjs_version=4.2.3
|
||||
emulatorjs_sha256=07d451bc06fa3ad04ab30d9b94eb63ac34ad0babee52d60357b002bde8f3850b
|
||||
emulatorjs_asset="$repo/.build-inputs/emulatorjs/$emulatorjs_version.7z"
|
||||
mkdir -p "$(dirname "$emulatorjs_asset")"
|
||||
if [ ! -f "$emulatorjs_asset" ] || ! printf '%s %s\n' "$emulatorjs_sha256" "$emulatorjs_asset" | sha256sum -c - >/dev/null 2>&1; then
|
||||
rm -f "$emulatorjs_asset" "${emulatorjs_asset}.part"
|
||||
download_attempt=1
|
||||
while ! "$docker_command" run --rm --dns 1.1.1.1 --dns 8.8.8.8 alpine:3.23@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 \
|
||||
wget -q -T 60 "https://github.com/EmulatorJS/EmulatorJS/releases/download/v$emulatorjs_version/$emulatorjs_version.7z" -O - \
|
||||
> "${emulatorjs_asset}.part"; do
|
||||
rm -f "${emulatorjs_asset}.part"
|
||||
[ "$download_attempt" -lt 3 ] || exit 1
|
||||
sleep "$download_attempt"
|
||||
download_attempt=$((download_attempt + 1))
|
||||
done
|
||||
printf '%s %s\n' "$emulatorjs_sha256" "${emulatorjs_asset}.part" | sha256sum -c - >/dev/null
|
||||
mv "${emulatorjs_asset}.part" "$emulatorjs_asset"
|
||||
fi
|
||||
"$docker_command" build --network host --pull --file Dockerfile.unraid --tag "$image" .
|
||||
;;
|
||||
*) echo "docker build network is not allowlisted: $build_network" >&2; exit 2 ;;
|
||||
esac
|
||||
cleanup_build_inputs
|
||||
trap - EXIT HUP INT TERM
|
||||
LUDARIUM_IMAGE="$image" \
|
||||
LUDARIUM_RELEASE_VERSION="${GITHUB_SHA:-$release}" \
|
||||
LUDARIUM_RELEASE_TOOL_CACHE="${LUDARIUM_RELEASE_TOOL_CACHE:-$default_release_cache}" \
|
||||
sh deploy/run-security-gates.sh
|
||||
;;
|
||||
esac
|
||||
|
||||
printf 'managed validation passed: %s\n' "$profile"
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
output="$repo/deploy/security"
|
||||
mkdir -p "$output"
|
||||
|
||||
application_image="${1:-${LUDARIUM_IMAGE:-ludarium/ludarium:0.4.1}}"
|
||||
release_version="${LUDARIUM_RELEASE_VERSION:-${application_image##*:}}"
|
||||
cache="${LUDARIUM_RELEASE_TOOL_CACHE:-/mnt/user/appdata/ludarium/release-tools}"
|
||||
mkdir -p "$cache/bin" "$cache/downloads" "$cache/grype-db" "$cache/tmp"
|
||||
TMPDIR="$cache/tmp"
|
||||
export TMPDIR
|
||||
|
||||
scan_source="docker:$application_image"
|
||||
image_archive=""
|
||||
if [ -n "${WSL_INTEROP:-}" ] \
|
||||
&& command -v docker.exe >/dev/null 2>&1 \
|
||||
&& docker.exe image inspect "$application_image" >/dev/null 2>&1 \
|
||||
&& ! docker image inspect "$application_image" >/dev/null 2>&1; then
|
||||
image_archive="$(mktemp "$cache/tmp/ludarium-image.XXXXXX.tar")"
|
||||
trap 'rm -f "$image_archive"' EXIT HUP INT TERM
|
||||
docker.exe save --output "$(wslpath -w "$image_archive")" "$application_image"
|
||||
scan_source="docker-archive:$image_archive"
|
||||
fi
|
||||
|
||||
download_tool() {
|
||||
name="$1" version="$2" archive="$3" checksum="$4" url="$5"
|
||||
binary="$cache/bin/$name-$version"
|
||||
[ -x "$binary" ] && { printf '%s\n' "$binary"; return; }
|
||||
package="$cache/downloads/$archive"
|
||||
if [ ! -f "$package" ] || ! printf '%s %s\n' "$checksum" "$package" | sha256sum -c - >/dev/null 2>&1; then
|
||||
rm -f "$package.part"
|
||||
curl --fail --location --silent --show-error --retry 5 --retry-all-errors \
|
||||
--connect-timeout 15 --max-time 300 "$url" -o "$package.part"
|
||||
printf '%s %s\n' "$checksum" "$package.part" | sha256sum -c - >/dev/null
|
||||
mv "$package.part" "$package"
|
||||
fi
|
||||
work="$(mktemp -d)"
|
||||
tar -xzf "$package" -C "$work" "$name"
|
||||
install -m 0755 "$work/$name" "$binary"
|
||||
rm -rf "$work"
|
||||
printf '%s\n' "$binary"
|
||||
}
|
||||
|
||||
gitleaks="$(download_tool gitleaks 8.30.1 gitleaks_8.30.1_linux_x64.tar.gz \
|
||||
551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb \
|
||||
https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz)"
|
||||
syft="$(download_tool syft 1.50.0 syft_1.50.0_linux_amd64.tar.gz \
|
||||
bf7b29ff57f06da30918266a0e1c2885a8f99784798d1bdb1628886aa015d788 \
|
||||
https://github.com/anchore/syft/releases/download/v1.50.0/syft_1.50.0_linux_amd64.tar.gz)"
|
||||
grype="$(download_tool grype 0.116.1 grype_0.116.1_linux_amd64.tar.gz \
|
||||
0122df7b655981abe547ad3d2190d65551dac6a2bfc80b4dc2a989b5d0587458 \
|
||||
https://github.com/anchore/grype/releases/download/v0.116.1/grype_0.116.1_linux_amd64.tar.gz)"
|
||||
|
||||
# Scan the materialized candidate tree. A linked Git worktree can reference metadata
|
||||
# outside /repo, which would otherwise make a containerized history scan inspect zero commits.
|
||||
"$gitleaks" dir "$repo" --config "$repo/.gitleaks.toml" --redact --report-format json --report-path "$output/gitleaks.json"
|
||||
"$syft" "$scan_source" -o "cyclonedx-json=$output/ludarium-$release_version.cdx.json"
|
||||
if command -v timeout >/dev/null; then
|
||||
timeout 600 env GRYPE_DB_CACHE_DIR="$cache/grype-db" "$grype" db update
|
||||
timeout 600 env GRYPE_DB_CACHE_DIR="$cache/grype-db" "$grype" "$scan_source" -o json --file "$output/grype.json" --fail-on high
|
||||
else
|
||||
GRYPE_DB_CACHE_DIR="$cache/grype-db" "$grype" db update
|
||||
GRYPE_DB_CACHE_DIR="$cache/grype-db" "$grype" "$scan_source" -o json --file "$output/grype.json" --fail-on high
|
||||
fi
|
||||
|
||||
printf 'security gates passed for %s\n' "$application_image"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0"?>
|
||||
<Container version="2">
|
||||
<Name>Ludarium</Name>
|
||||
<Repository>ludarium/ludarium:0.4.19-rc.8</Repository>
|
||||
<Network>ludarium_default</Network>
|
||||
<Shell/>
|
||||
<Privileged>false</Privileged>
|
||||
<Overview>Premium read-only game-library inventory, classification, metadata and integrity management for Unraid.</Overview>
|
||||
<Category>MediaApp:Other</Category>
|
||||
<WebUI>http://[IP]:[PORT:1230]/</WebUI>
|
||||
<Icon>/boot/config/plugins/dockerMan/images/Ludarium-icon.svg</Icon>
|
||||
<ExtraParams>--read-only --cap-drop=ALL --cap-add=CHOWN --cap-add=DAC_OVERRIDE --cap-add=FOWNER --cap-add=KILL --cap-add=SETGID --cap-add=SETUID --security-opt=no-new-privileges --tmpfs=/tmp --tmpfs=/var/run/postgresql</ExtraParams>
|
||||
<PostArgs/>
|
||||
<CPUset/>
|
||||
<DonateText/>
|
||||
<DonateLink/>
|
||||
<Requires/>
|
||||
<Config Name="WebUI" Target="8734" Default="1230" Mode="tcp" Description="Ludarium web interface" Type="Port" Display="always" Required="true" Mask="false">1230</Config>
|
||||
<Config Name="RAWG API key" Target="RAWG_API_KEY" Default="" Mode="" Description="Optional online wishlist discovery" Type="Variable" Display="advanced" Required="false" Mask="true"/>
|
||||
<Config Name="IGDB client ID" Target="IGDB_CLIENT_ID" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="false"/>
|
||||
<Config Name="IGDB client secret" Target="IGDB_CLIENT_SECRET" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="true"/>
|
||||
<Config Name="MobyGames API key" Target="MOBYGAMES_API_KEY" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="true"/>
|
||||
<Config Name="ScreenScraper developer ID" Target="SCREENSCRAPER_DEVID" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="true"/>
|
||||
<Config Name="ScreenScraper developer password" Target="SCREENSCRAPER_DEVPASSWORD" Default="" Mode="" Description="Optional native metadata provider" Type="Variable" Display="advanced" Required="false" Mask="true"/>
|
||||
<Config Name="ScreenScraper software name" Target="SCREENSCRAPER_SOFTNAME" Default="Ludarium" Mode="" Description="Optional native metadata provider client name" Type="Variable" Display="advanced" Required="false" Mask="false"/>
|
||||
<Config Name="ScreenScraper user" Target="SCREENSCRAPER_USER" Default="" Mode="" Description="Optional personal ScreenScraper account" Type="Variable" Display="advanced" Required="false" Mask="false"/>
|
||||
<Config Name="ScreenScraper password" Target="SCREENSCRAPER_PASSWORD" Default="" Mode="" Description="Optional personal ScreenScraper account" Type="Variable" Display="advanced" Required="false" Mask="true"/>
|
||||
<Config Name="RetroAchievements API key" Target="RETROACHIEVEMENTS_API_KEY" Default="" Mode="" Description="Optional native achievements provider" Type="Variable" Display="advanced" Required="false" Mask="true"/>
|
||||
<Config Name="RetroAchievements user" Target="RETROACHIEVEMENTS_USERNAME" Default="" Mode="" Description="Optional personal achievement progress" Type="Variable" Display="advanced" Required="false" Mask="false"/>
|
||||
</Container>
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
repo="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
version="$(tr -d '\r\n' < "$repo/VERSION")"
|
||||
|
||||
printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' || {
|
||||
echo "VERSION is not a semantic version" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_literal() {
|
||||
file="$1"
|
||||
grep -F "$version" "$repo/$file" >/dev/null || {
|
||||
echo "$file does not reference release $version" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
for file in \
|
||||
CHANGELOG.md \
|
||||
deploy/README.md \
|
||||
deploy/compose.dolphin.yml \
|
||||
deploy/compose.yml \
|
||||
deploy/ludarium-unraid.xml \
|
||||
deploy/unraid/my-Ludarium.xml
|
||||
do
|
||||
require_literal "$file"
|
||||
done
|
||||
|
||||
grep -F 'ReadAllText('"'"'$(MSBuildThisFileDirectory)VERSION'"'"')' "$repo/Directory.Build.props" >/dev/null || {
|
||||
echo "Directory.Build.props does not derive the assembly version from VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
for dockerfile in Dockerfile Dockerfile.unraid
|
||||
do
|
||||
grep -E '^COPY .*VERSION.*Directory.Build.props|^COPY .*Directory.Build.props.*VERSION' "$repo/$dockerfile" >/dev/null || {
|
||||
echo "$dockerfile does not copy VERSION into the build context" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
grep -F 'ReleaseIdentity.Version' "$repo/src/Ludarium.Api/Program.cs" >/dev/null
|
||||
grep -F 'release_identity="$(tr' "$repo/deploy/run-candidate-gate.sh" >/dev/null
|
||||
|
||||
if grep -R -n -E --include='*.cs' '0\.4\.[0-9]+-rc\.[0-9]+' \
|
||||
"$repo/src/Ludarium.Api" "$repo/src/Ludarium.Application" "$repo/src/Ludarium.Infrastructure" |
|
||||
grep -v -F "$version"; then
|
||||
echo "A runtime release identifier bypasses ReleaseIdentity" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'release identity verified: %s\n' "$version"
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -lt 1 ]; then
|
||||
echo "usage: $0 ROOT [ROOT ...]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
manifest_file="$(mktemp)"
|
||||
trap 'rm -f "$manifest_file"' EXIT INT TERM
|
||||
|
||||
root_index=0
|
||||
file_count=0
|
||||
for root in "$@"; do
|
||||
if [ ! -d "$root" ]; then
|
||||
echo "library root is unavailable: $root" >&2
|
||||
exit 3
|
||||
fi
|
||||
root_index=$((root_index + 1))
|
||||
count="$(find "$root" -type f -printf . | wc -c)"
|
||||
file_count=$((file_count + count))
|
||||
(cd "$root" && find . -type f -printf "${root_index}\t%P\t%s\t%T@\0") >> "$manifest_file"
|
||||
done
|
||||
|
||||
digest="$(LC_ALL=C sort -z "$manifest_file" | sha256sum | cut -d ' ' -f 1)"
|
||||
printf 'sha256=%s files=%s roots=%s\n' "$digest" "$file_count" "$#"
|
||||
@@ -0,0 +1,31 @@
|
||||
# Public source boundary
|
||||
|
||||
The canonical Ludarium repository is an operational development record. It contains private release
|
||||
evidence, infrastructure topology, historical scan reports and screenshots made against an owned game
|
||||
library. Its Git history must therefore remain private even when the application source is published.
|
||||
|
||||
Public releases are created as a new, parentless repository from `public-source.allowlist`. The export
|
||||
contains the application, tests, safe deployment examples, required asset provenance and one synthetic
|
||||
empty-library screenshot. It excludes production workflows, live validation evidence, security scan
|
||||
artifacts, SBOM archives, internal plans, agent instructions, operational status records and media made
|
||||
from commercial games or firmware.
|
||||
|
||||
Run the exporter only from a clean, reviewed revision after a root `LICENSE` has been committed:
|
||||
|
||||
```sh
|
||||
node scripts/export-public-source.mjs ../ludarium-public
|
||||
```
|
||||
|
||||
The exporter refuses a dirty tree, a missing license, private infrastructure markers, proprietary game
|
||||
or key file extensions outside the small documented synthetic fixture set, oversized files, and any
|
||||
tracked file not explicitly allowlisted. It writes a SHA-256 manifest. In the exported directory, run:
|
||||
|
||||
```sh
|
||||
node scripts/validate-public-source.mjs
|
||||
sh deploy/run-managed-validation.sh source
|
||||
gitleaks git .
|
||||
```
|
||||
|
||||
Initialize and publish that directory as a new repository; never push the canonical branch, tags or
|
||||
history into the public remote. Changes flow from the private canonical repository through another
|
||||
reviewed export, not by merging the two histories.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 899 KiB |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0"?>
|
||||
<datafile>
|
||||
<header><name>Ludarium Synthetic Catalog</name><version>1</version></header>
|
||||
<game name="Synthetic Example">
|
||||
<description>Synthetic Example</description>
|
||||
<rom name="synthetic.nes" size="16" crc="cbf43926" sha1="a9993e364706816aba3e25717850c26c9cd0d89d" sha256="ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" />
|
||||
</game>
|
||||
</datafile>
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{
|
||||
"id": 1074,
|
||||
"name": "Sonic the Hedgehog",
|
||||
"summary": "A blue hedgehog races through six zones.",
|
||||
"first_release_date": 708393600,
|
||||
"genres": [{ "name": "Platform" }],
|
||||
"involved_companies": [
|
||||
{ "developer": true, "publisher": false, "company": { "name": "Sonic Team" } },
|
||||
{ "developer": false, "publisher": true, "company": { "name": "Sega" } }
|
||||
],
|
||||
"franchises": [{ "name": "Sonic the Hedgehog" }],
|
||||
"cover": { "image_id": "co1v7r" },
|
||||
"platforms": [{ "slug": "genesis-slash-megadrive", "name": "Sega Mega Drive/Genesis" }]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"games": [
|
||||
{
|
||||
"game_id": 6225,
|
||||
"title": "Sonic the Hedgehog",
|
||||
"description": "Sonic must stop Dr. Robotnik.",
|
||||
"genres": [{ "genre_name": "Action" }],
|
||||
"platforms": [{ "platform_name": "Genesis" }],
|
||||
"sample_cover": "https://cdn.mobygames.com/covers/sonic.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"ID": 1,
|
||||
"Title": "Sonic the Hedgehog",
|
||||
"ConsoleName": "Mega Drive",
|
||||
"ImageBoxArt": "/Images/051872.png",
|
||||
"Publisher": "Sega",
|
||||
"Developer": "Sonic Team",
|
||||
"Genre": "Platform",
|
||||
"Released": "1991-06-23",
|
||||
"Achievements": {
|
||||
"9": {
|
||||
"ID": 9,
|
||||
"Title": "That Was Easy",
|
||||
"Description": "Complete Green Hill Zone Act 1.",
|
||||
"Points": 3,
|
||||
"BadgeName": "250336",
|
||||
"DateEarnedHardcore": "2026-01-02 12:30:00"
|
||||
},
|
||||
"10": {
|
||||
"ID": 10,
|
||||
"Title": "Marble Master",
|
||||
"Description": "Complete Marble Zone.",
|
||||
"Points": 5,
|
||||
"BadgeName": "250337"
|
||||
}
|
||||
},
|
||||
"NumAwardedToUser": 1,
|
||||
"NumAchievements": 2
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Data><jeu id="1"><noms><nom region="us">Sonic the Hedgehog</nom><nom region="jp">Sonic the Hedgehog</nom></noms><synopsis><synopsis langue="en">Sonic crosses South Island.</synopsis></synopsis><genres><genre><noms><nom langue="en">Platform</nom></noms></genre></genres><developpeur>Sonic Team</developpeur><editeur>Sega</editeur><joueurs>2</joueurs><dates><date region="us">19910623</date></dates><medias><media type="box-2D" region="us">https://www.screenscraper.fr/media/sonic.png</media></medias></jeu></Data>
|
||||
@@ -0,0 +1,3 @@
|
||||
# Synthetic fixtures
|
||||
|
||||
All files in this tree are generated text or minimal deterministic headers and contain no copyrighted game data. The executable-looking fixture uses `.exe.fixture`, so it cannot be selected as an executable by normal platform behavior. Tests construct binary headers in memory and never launch fixture content.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Browser-play fixture
|
||||
|
||||
`minimal-ines.base64` decodes to a deterministic 16-byte iNES header with one declared PRG bank and no copyrighted program data. Tests use it only to prove the allowlisted `.nes` format boundary; it is intentionally not a playable commercial game.
|
||||
|
||||
`playable-nrom.nes.gz.base64` is a gzip-compressed, deterministic 24,592-byte
|
||||
iNES/NROM-128 cartridge created specifically for Ludarium. Its original 6502
|
||||
program initializes the PPU, selects a blue backdrop and loops forever; the CHR
|
||||
bank is empty. It contains no third-party game code, graphics, audio or data.
|
||||
The decompressed SHA-256 is
|
||||
`6b87142fb0a7e125b13c5fcd7afa8b13066a44bc97150d24ade8bd2d0e5fd5e7`.
|
||||
The live browser-play gate materializes it only below isolated candidate
|
||||
appdata and mounts that directory read-only.
|
||||
|
||||
Browser-play integration tests create the decoded bytes in an isolated temporary read-only fixture root. Executable, script, traversal, oversized and unsupported requests are generated separately and must fail closed.
|
||||
|
||||
`generate-fixtures.mjs` deterministically creates original, minimal fixtures for
|
||||
SNES, GB, GBC, GBA, N64, NDS and PSP. They contain only headers and an architecture-specific
|
||||
infinite loop authored for Ludarium; there is no third-party game program, artwork, audio,
|
||||
firmware or BIOS. A fixture is not sufficient to enable a core by itself: the candidate gate
|
||||
must still prove a local core canvas, scoped Range delivery and non-empty savestate capture.
|
||||
The generator refuses output if any pinned SHA-256 changes. The N64 fixture additionally embeds
|
||||
libdragon's open-source IPL3 compatibility bootcode from commit
|
||||
`35f85a0797324a5ed0c723203e33ab3c1da94fdd` under the Unlicense. It remains a negative fixture:
|
||||
the bundled Mupen core did not complete the live gate, so Ludarium continues to fail closed for N64.
|
||||
|
||||
The positive N64 release gate downloads Mupen64Plus' own GPL-licensed 2008 demo ROM from the
|
||||
historical `mupen64plus-core` commit `57d08111ae9e95938c43ec160ac9b5a50f9da089`.
|
||||
The compressed SHA-256 is `9dbe0a7b4d2473060a1745c0424abad5278d40f2ff99995a4142bb07286183af`;
|
||||
the 1,048,576-byte `.v64` SHA-256 is
|
||||
`b5fe9d650a67091c97838386f5102ad94c79232240f9c5bcc72334097d76224c`.
|
||||
It is materialized only below isolated candidate appdata and proves N64Wasm WebGL startup,
|
||||
capability-scoped delivery, state capture, cancellation and evidence-bound restore.
|
||||
|
||||
The PSP fixture is an original open homebrew program in `psp/main.c`, built as `EBOOT.PBP`
|
||||
with the official `pspdev/pspdev` container pinned to
|
||||
`sha256:c1dd948b190e9141242cf321316eb2b54562b274c9be78e24a66fe6d30a5dc24`.
|
||||
`SOURCE_DATE_EPOCH=0` and gzip `-n -9` make the checked-in payload reproducible. The PBP is
|
||||
130,008 bytes with SHA-256 `2ff952ec933c67ee6911049d77201da572aa7241e3f2ee7ff935ed61646964f4`;
|
||||
the compressed file SHA-256 is `6e405203a2bab4a7ab3d705e0b937d2ecc8350489f051dbfb58d194e577b02f4`.
|
||||
It contains no Sony firmware, keys, SDK data or commercial content.
|
||||
@@ -0,0 +1,170 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
// libdragon open-source IPL3 compatibility bootcode, pinned to commit
|
||||
// 35f85a0797324a5ed0c723203e33ab3c1da94fdd (Unlicense), SHA-256
|
||||
// f522db2e31a701f82597f399e76d55c9487760d015aff9d90176b463ae39a068.
|
||||
const libdragonIpl3 = gunzipSync(Buffer.from("H4sIAAAAAAACCu1WX2xbVxn/+dxrxw6mvTFu6rUpvW7OTaNuk9xitghd0SvbMdnUh6xKxxAS8ux0LatoBzywSUO9hFKKZJFqE4KnYeWfRuU2gaXAUKpF2Ui0qRUSTxWqRCiI9GkEAVIGXS+/79qhRVCJd2rp+Jx7zvm+832/76//eMoDfz5M/LffoS9WR7/y7LFTJ+27v3vPc6VfP+bumsLjV7au770SrALXndm30DP7Y2Rm55A2u1TVM4d403K8sePwxoY4nuE4D1dN5epPwdQKcwf2Yz21B1/g3gv1ApKWhy5K5bgqlXdjDejC0y+6UdiuCV/FMtlmBWhWgXoNGKniFOlMFfWz3WbwgZxbwPOy55pTMLsirj5jTMEjP2MO+rmPwI1OVeoTsHIFGGkv+FB0qVe531GBUnava/jQa4hjJZfFN/neJN+bgenGpsz6L5FJN4I7wkMXgqA5gaTnr5L9LepEukEbC09AhsVhc+R0AV56MPi7vCN33CjvlT0sEBoOm8PjID/46XLrnuByeA8seac5b6D5hmGmKJ4bnSP9TMinOWjEm0eMpB7k+WeNdHPYyCxcJm3BsBaO4F49I5t6uup0kC8E6xizs6hQR/8WcW6gTtr6iIE6+bkKfkrhUc5et0KSstiuQV7Vu5i5ag60VZxYeShaDw97iLgWfLkL2A7fztUn8QhM28vVEBGsSPcPjNoObXKjJQvxLBLPSnBHG9S3FynuQRsWmkWeG+RXs2VtamNrwDnuRoNAT78M12xARfuzZhe69RkTFoLviH05/5ZyvKyLFnEgLX1F12COVIM7sub5uvCQ9+27Dv7E8KFP2Hbx1OjRUbv6kn342a+eeMkWXIiTM+cD8z46+ieQSHt4SE8HG5iysnOMnnkPHW4Clj6DuO5cp8xTpo7TA3+U22ctIkoMrf4ZC+lFKDQsBxcsXT+GdHqVOn+Gckz09zaP079GYTaPIdnTCP4stjFWaZvQD4jbGJL/jtX98dmkSzcY1KYDiRvqfC3EO4rdXN/OT/vIzSAyTPmMkm1KLGoj5AP5xrf6s4j2+66aXMegleW7oC6GNq0wbpslMjP6OOgdJepk7FG5M4Q0XO+gbRBgNKNDWaqWo1UEbfvfETk2ZUTs4xzkUSRdbIfK1cgjXB8Q3QK8ndNYyjmCwcgYOkLZWnJtiFzN8j282jJTvw9EbuajWwMrOAjfk5yURmPIsegyOoYyx7Jr0Hgd232+p9GRWYKxe1Hepuy06C5P5A9l6diJXM1D+4xe0LN492yHn6v5LZnLlDMKw+wK3ovQ/7ts7Bznu+29t2SP80Ro09beD2UP6uAY1K4CY0Tl1hDB7we4t5NY0H7heres4611zxLXFlRqTL4ZizZuDjjyDlYtR7DYewUZ6j5HP3yBsehR9/zsPB6ZfQP9zMs283LP2s9Qmr2M3OxPmObexKG1n2No7afw1i7DXZvHAHOrSfleaeehNOMmzr2kxA/36+39LZKD+X1OG5GtoV+IT6pM3LUmN3Shf+CSkvzyAwfod7QZBKqykdA7GMMxGPll/zZxiMg+eXzdTU+RtoTl7ydukC4JnHMu0Y2lDhyg/Zl5fKguYvWxxdwyfWMpxDtC2pPajAShXVu4Ps8UtGmLsqyJk2Lc9orch4EoLL8otCJPn0mflG/Fb2WZ4qf0vRdJIf6guY7p2mN5sTFxzguu1NuSXDhODMeJ7TixHSeW48R2hXiuEM8VYrxCTFeI6QoxVTbpiX/bRsMpuq7gsilDyW9IbMFNNfDO5xKr6sD5bBnTZ4HXnTJmYqLHgRkEJf91wXwoxNRAp+B3ifHIYb5SQJS6BpPM+a+28j5jjjlY7FVj/FWCv8m6vG3RlXlQIVp+6GLhuyWxN74WnvG79L1WfS/7sMvdF79RxuWxZT9xo7wNnw7pIqTrnkbfZdqPPvb0RHC9jItnRf53vMQfZKbsiutbnLlufLTU2eK5zaP/ouK0feCvLRve+VD0Y+y+JrZoyx4ZYU0pb7/oberC83ehrAJwnD71jKO3wBO6/c9BfMgQXngy0wtj79us231dFXyKsdqLL9n7Rug9kUrFiTQyTqRg7SMuYdPjnEBk28ngfVSOO11+8Jv9ZxG0ZfqT8BacuZ4P1x5jrrhO32c+rwa/yt3cylqVQLoYXM3dxLrUMK7f5dq0vGCFfBKknQ5pVVrxPGoVsd2NmZ0S6+lV5tTlHovfKb2cjPN+jPd/IfdZe1hzEVd7rCztA7GRKp7PdheC263aCbuPmYCYDKg959jzBO8JrpzfF54HmZbcQ1evecmr1w6G96++pguPPoUxZJjNU8wxrEjpQpjXjJ3f1rVPvopi++zzVh/PPZ4vSf6GyvioZrKu+uOILpwYbeX161npQepFI7PZ//wvvY8evE/f8687rHODrI+tHot+S5pp1rtBhHWOmETytb9IrdusiWmpiYzL+/cMrMmM18xm/WEWj7db2A35k1pB2U7niz7yhfOQerHw5bBvsGjndt/EfqeEOHsei71Op9R1N7o7yE+H+eI064/OSy04Sn+rZh4eqbAfkjvSNx2l/06zb2qwb4paoT5h7zhKnY6y7jO3/md/hQvU38wXLmxhj2lR13iTZajZQE9Yr6PRI8QkySF+o8hH3vKZozOchzkntRFvYULZdaHTg7eRwIPfg9+D3//578nfHa2cffOf1j/uqQAQAAA=", "base64"));
|
||||
|
||||
const output = process.argv[2];
|
||||
if (!output) throw new Error("output directory is required");
|
||||
mkdirSync(output, { recursive: true });
|
||||
|
||||
const expected = new Map([
|
||||
["Ludarium SNES Fixture.sfc", "c5458603faf6e04e550d73c4724bb948fb75c5b54bf24280a006501c2ace52ed"],
|
||||
["Ludarium GB Fixture.gb", "7ed74ff088ce50d18ecd37847e3c0b81774e9b54e254149fc6502ed8ec084870"],
|
||||
["Ludarium GBC Fixture.gbc", "59111888ffff8c92c3f5bbd6bbe0f9182a7a53c634ec6b00d2ce8d9bd49f9f5a"],
|
||||
["Ludarium GBA Fixture.gba", "5a393a7b3a5b9beeae7f9a5e0495f1669e1855dcf6379a73bd97e7a974787813"],
|
||||
["Ludarium N64 Fixture.z64", "4f6e7fcb1962f02e59b9f896591254e9f1b68273e2179fc7a0c26e681500c326"],
|
||||
["Ludarium NDS Fixture.nds", "b797bfaa0e5e20cf2091f8268975bfa3cb820dd88c2e06134f0c9ce2fb1180cf"],
|
||||
["Ludarium PSP Fixture.pbp", "2ff952ec933c67ee6911049d77201da572aa7241e3f2ee7ff935ed61646964f4"],
|
||||
["Ludarium PSX Fixture.chd", "58e9754dd2c868d091cbd2401182b5026e436a7b199437fb7dfab039fccb89e7"],
|
||||
["Ludarium Genesis Fixture.md", "b07f1e19de6d517399ee398295e08d4b94f3cad8f5b0470b46ce37bdae0d508e"],
|
||||
["Ludarium Master System Fixture.sms", "0302cdf0f3e67488cf2ad11b655ea73d0c66e60b9e35153e0fe2b626cf358a0e"],
|
||||
["Ludarium Game Gear Fixture.gg", "f5fb90060122ec533a2200103b4d78c254f6bce72deb04ca11c065cb1d61af17"]
|
||||
]);
|
||||
const write = (name, bytes) => {
|
||||
const digest = createHash("sha256").update(bytes).digest("hex");
|
||||
if (digest !== expected.get(name)) throw new Error(`${name} checksum changed: ${digest}`);
|
||||
writeFileSync(join(output, name), bytes, { mode: 0o444 });
|
||||
};
|
||||
const ascii = (buffer, offset, value, length) => buffer.write(value.slice(0, length), offset, "ascii");
|
||||
const u32le = (buffer, offset, value) => buffer.writeUInt32LE(value >>> 0, offset);
|
||||
const u32be = (buffer, offset, value) => buffer.writeUInt32BE(value >>> 0, offset);
|
||||
const crc16 = bytes => {
|
||||
let crc = 0xffff;
|
||||
for (const byte of bytes) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit++) crc = (crc & 1) ? (crc >>> 1) ^ 0xa001 : crc >>> 1;
|
||||
}
|
||||
return crc;
|
||||
};
|
||||
|
||||
const pspFixture = gunzipSync(readFileSync(join(import.meta.dirname, "psp", "ludarium-psp-fixture.pbp.gz")));
|
||||
const psxFixture = gunzipSync(readFileSync(join(import.meta.dirname, "psx", "ludarium-psx-fixture.chd.gz")));
|
||||
|
||||
function gameBoy(color) {
|
||||
const rom = Buffer.alloc(32 * 1024, 0);
|
||||
rom.set([0xc3, 0x50, 0x01], 0x100); // JP $0150; no boot ROM or native code is required.
|
||||
rom.set([0xce, 0xed, 0x66, 0x66], 0x104); // Deterministic cartridge signature used by the analyzer.
|
||||
ascii(rom, 0x134, color ? "LUDARIUM GBC" : "LUDARIUM GB", 15);
|
||||
rom[0x143] = color ? 0x80 : 0x00;
|
||||
rom[0x147] = 0x00; // ROM-only cartridge.
|
||||
rom[0x148] = 0x00; // 32 KiB.
|
||||
rom[0x149] = 0x00;
|
||||
rom.set([0xf3, 0x31, 0xfe, 0xff, 0x18, 0xfe], 0x150); // DI; LD SP,$FFFE; JR forever.
|
||||
let checksum = 0;
|
||||
for (let index = 0x134; index <= 0x14c; index++) checksum = (checksum - rom[index] - 1) & 0xff;
|
||||
rom[0x14d] = checksum;
|
||||
return rom;
|
||||
}
|
||||
|
||||
function snes() {
|
||||
const rom = Buffer.alloc(32 * 1024, 0);
|
||||
rom.set([0x78, 0x18, 0xfb, 0xc2, 0x30, 0x80, 0xfe], 0); // Native-mode loop at $008000.
|
||||
ascii(rom, 0x7fc0, "LUDARIUM SNES FIXTURE", 21);
|
||||
rom[0x7fd5] = 0x20; // LoROM, slow ROM.
|
||||
rom[0x7fd6] = 0x00;
|
||||
rom[0x7fd7] = 0x05;
|
||||
rom[0x7fd9] = 0x01;
|
||||
for (const vector of [0x7fea, 0x7fec, 0x7fee, 0x7ffa, 0x7ffc, 0x7ffe]) rom.writeUInt16LE(0x8000, vector);
|
||||
let checksum = 0;
|
||||
for (const byte of rom) checksum = (checksum + byte) & 0xffff;
|
||||
rom.writeUInt16LE(checksum ^ 0xffff, 0x7fdc);
|
||||
rom.writeUInt16LE(checksum, 0x7fde);
|
||||
return rom;
|
||||
}
|
||||
|
||||
function gba() {
|
||||
const rom = Buffer.alloc(32 * 1024, 0);
|
||||
u32le(rom, 0, 0xeafffffe); // ARM B .
|
||||
rom.set([0x24, 0xff, 0xae, 0x51], 0x04); // Deterministic cartridge signature used by the analyzer.
|
||||
ascii(rom, 0xa0, "LUDARIUMGBA", 12);
|
||||
ascii(rom, 0xac, "LUDE", 4);
|
||||
ascii(rom, 0xb0, "00", 2);
|
||||
rom[0xb2] = 0x96;
|
||||
let checksum = 0;
|
||||
for (let index = 0xa0; index <= 0xbc; index++) checksum = (checksum - rom[index]) & 0xff;
|
||||
rom[0xbd] = (checksum - 0x19) & 0xff;
|
||||
return rom;
|
||||
}
|
||||
|
||||
function n64() {
|
||||
const rom = Buffer.alloc(1024 * 1024, 0);
|
||||
libdragonIpl3.copy(rom, 0);
|
||||
u32be(rom, 8, 0x80000400);
|
||||
u32be(rom, 0x10, 8);
|
||||
ascii(rom, 0x20, "LUDARIUM N64 FIXTURE", 20);
|
||||
ascii(rom, 0x3b, "LU", 2);
|
||||
rom[0x3e] = 0x45;
|
||||
u32be(rom, 0x1000, 0x1000ffff); // MIPS BEQ $zero,$zero,-1.
|
||||
u32be(rom, 0x1004, 0x00000000);
|
||||
return rom;
|
||||
}
|
||||
|
||||
function nds() {
|
||||
const rom = Buffer.alloc(8 * 1024, 0);
|
||||
ascii(rom, 0x00, "LUDARIUM NDS", 12);
|
||||
ascii(rom, 0x0c, "LUDE", 4);
|
||||
ascii(rom, 0x10, "00", 2);
|
||||
u32le(rom, 0x20, 0x200); u32le(rom, 0x24, 0x02000000); u32le(rom, 0x28, 0x02000000); u32le(rom, 0x2c, 4);
|
||||
u32le(rom, 0x30, 0x400); u32le(rom, 0x34, 0x02380000); u32le(rom, 0x38, 0x02380000); u32le(rom, 0x3c, 4);
|
||||
u32le(rom, 0x80, rom.length); u32le(rom, 0x84, 0x200);
|
||||
rom.set([0x24, 0xff, 0xae, 0x51], 0xc0); // Deterministic cartridge signature used by the analyzer.
|
||||
u32le(rom, 0x200, 0xeafffffe); u32le(rom, 0x400, 0xeafffffe);
|
||||
rom.writeUInt16LE(crc16(rom.subarray(0, 0x15e)), 0x15e);
|
||||
return rom;
|
||||
}
|
||||
|
||||
// Sega hardware. A Mega Drive cartridge is identified by its console name at $100 and boots from the
|
||||
// 68000 vector table at $0; an 8-bit Sega ROM is identified by the "TMR SEGA" header near the end of
|
||||
// its first bank, whose region nibble separates Master System from Game Gear.
|
||||
function megaDrive() {
|
||||
const rom = Buffer.alloc(64 * 1024, 0);
|
||||
u32be(rom, 0x00, 0x00fffe00); // Initial stack pointer.
|
||||
u32be(rom, 0x04, 0x00000200); // Reset vector -> $200.
|
||||
for (let vector = 0x08; vector < 0x100; vector += 4) u32be(rom, vector, 0x00000200);
|
||||
ascii(rom, 0x100, "SEGA MEGA DRIVE ", 16);
|
||||
ascii(rom, 0x110, "(C)LUDARIUM 2026", 16);
|
||||
ascii(rom, 0x120, "LUDARIUM GENESIS FIXTURE".padEnd(48), 48);
|
||||
ascii(rom, 0x150, "LUDARIUM GENESIS FIXTURE".padEnd(48), 48);
|
||||
ascii(rom, 0x180, "GM 00000000-00", 14);
|
||||
u32be(rom, 0x1a0, 0x00000000);
|
||||
u32be(rom, 0x1a4, rom.length - 1);
|
||||
u32be(rom, 0x1a8, 0x00ff0000);
|
||||
u32be(rom, 0x1ac, 0x00ffffff);
|
||||
ascii(rom, 0x1f0, "JUE", 3);
|
||||
rom.writeUInt16BE(0x60fe, 0x200); // BRA.S * — a deterministic idle loop.
|
||||
let checksum = 0;
|
||||
for (let index = 0x200; index < rom.length; index += 2) checksum = (checksum + rom.readUInt16BE(index)) & 0xffff;
|
||||
rom.writeUInt16BE(checksum, 0x18e);
|
||||
return rom;
|
||||
}
|
||||
|
||||
function segaEightBit(gameGear) {
|
||||
const rom = Buffer.alloc(32 * 1024, 0);
|
||||
rom.set([0xf3, 0xed, 0x56, 0x18, 0xfe], 0x00); // DI; IM 1; JR * — a deterministic idle loop.
|
||||
ascii(rom, 0x7ff0, "TMR SEGA", 8);
|
||||
rom[0x7ffc] = 0x23; // Product code, low BCD pair.
|
||||
rom[0x7ffd] = 0x01; // Product code, high BCD pair.
|
||||
rom[0x7ffe] = 0x51; // Top product digit and revision 1.
|
||||
rom[0x7fff] = (gameGear ? 0x60 : 0x40) | 0x0c; // Region nibble plus the 32 KiB size nibble.
|
||||
let checksum = 0;
|
||||
for (let index = 0; index < 0x7ff0; index++) checksum = (checksum + rom[index]) & 0xffff;
|
||||
rom.writeUInt16LE(checksum, 0x7ffa);
|
||||
return rom;
|
||||
}
|
||||
|
||||
write("Ludarium SNES Fixture.sfc", snes());
|
||||
write("Ludarium GB Fixture.gb", gameBoy(false));
|
||||
write("Ludarium GBC Fixture.gbc", gameBoy(true));
|
||||
write("Ludarium GBA Fixture.gba", gba());
|
||||
write("Ludarium N64 Fixture.z64", n64());
|
||||
write("Ludarium NDS Fixture.nds", nds());
|
||||
write("Ludarium Genesis Fixture.md", megaDrive());
|
||||
write("Ludarium Master System Fixture.sms", segaEightBit(false));
|
||||
write("Ludarium Game Gear Fixture.gg", segaEightBit(true));
|
||||
write("Ludarium PSP Fixture.pbp", pspFixture);
|
||||
write("Ludarium PSX Fixture.chd", psxFixture);
|
||||
@@ -0,0 +1 @@
|
||||
TkVTGgEAAAAAAAAAAAAAAA==
|
||||
@@ -0,0 +1 @@
|
||||
H4sIAAAAAAACCu3QoQ0CQRRF0T8IsOtIUINH4hmDIxiaoYmZApDrtospgxLoAQFZBBVgSM4RN0+/8/GySSm+yvU+lrYu4+v2aJFbym0ou0Uenp9Mh7rMU8zZ1tW8IteUT/seAAAAAAAAAAAAAAAAAAAAwA/01KO7AQAAAAAAAAD+3xuAOQilEGAAAA==
|
||||
@@ -0,0 +1,12 @@
|
||||
EE_BIN = ludarium.elf
|
||||
EE_OBJS = main.o
|
||||
EE_LIBS = -lpacket -ldma -lgraph -ldraw -lc
|
||||
|
||||
all: $(EE_BIN)
|
||||
$(EE_STRIP) --strip-all $(EE_BIN)
|
||||
|
||||
clean:
|
||||
rm -f $(EE_BIN) $(EE_OBJS)
|
||||
|
||||
include $(PS2SDK)/samples/Makefile.pref
|
||||
include $(PS2SDK)/samples/Makefile.eeglobal
|
||||
@@ -0,0 +1,3 @@
|
||||
BOOT2 = cdrom0:\LUDARIUM.ELF;1
|
||||
VER = 1.00
|
||||
VMODE = NTSC
|
||||
@@ -0,0 +1,32 @@
|
||||
#include <kernel.h>
|
||||
#include <dma.h>
|
||||
#include <draw.h>
|
||||
#include <graph.h>
|
||||
#include <gs_psm.h>
|
||||
#include <packet.h>
|
||||
|
||||
int main(void)
|
||||
{
|
||||
framebuffer_t frame = {
|
||||
.address = 0,
|
||||
.width = 640,
|
||||
.height = 448,
|
||||
.psm = GS_PSM_32,
|
||||
.mask = 0
|
||||
};
|
||||
zbuffer_t depth = { .enable = 0, .address = 0, .zsm = 0, .mask = 0 };
|
||||
packet_t *packet = packet_init(32, PACKET_NORMAL);
|
||||
|
||||
frame.address = graph_vram_allocate(frame.width, frame.height, frame.psm, GRAPH_ALIGN_PAGE);
|
||||
dma_channel_initialize(DMA_CHANNEL_GIF, NULL, 0);
|
||||
dma_channel_fast_waits(DMA_CHANNEL_GIF);
|
||||
graph_initialize(frame.address, frame.width, frame.height, frame.psm, 0, 0);
|
||||
|
||||
qword_t *command = draw_setup_environment(packet->data, 0, &frame, &depth);
|
||||
command = draw_clear(command, 0, 0, 0, frame.width, frame.height, 18, 42, 96);
|
||||
command = draw_finish(command);
|
||||
dma_channel_send_normal(DMA_CHANNEL_GIF, packet->data, command - packet->data, 0, 0);
|
||||
draw_wait_finish();
|
||||
|
||||
for (;;) graph_wait_vsync();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
TARGET = ludarium-fixture
|
||||
OBJS = main.o
|
||||
|
||||
CFLAGS = -O2 -G0 -Wall -Wextra
|
||||
CXXFLAGS = $(CFLAGS) -fno-exceptions -fno-rtti
|
||||
ASFLAGS = $(CFLAGS)
|
||||
LIBS = -lpspdebug
|
||||
|
||||
EXTRA_TARGETS = EBOOT.PBP
|
||||
PSP_EBOOT_TITLE = Ludarium PSP Fixture
|
||||
|
||||
PSPSDK = $(shell psp-config --pspsdk-path)
|
||||
include $(PSPSDK)/lib/build.mak
|
||||
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
#include <pspkernel.h>
|
||||
#include <pspdebug.h>
|
||||
|
||||
PSP_MODULE_INFO("LudariumFixture", PSP_MODULE_USER, 1, 0);
|
||||
PSP_MAIN_THREAD_ATTR(PSP_THREAD_ATTR_USER);
|
||||
|
||||
int main(void)
|
||||
{
|
||||
pspDebugScreenInit();
|
||||
pspDebugScreenSetBackColor(0x00302010);
|
||||
pspDebugScreenSetTextColor(0x00ffffff);
|
||||
pspDebugScreenClear();
|
||||
pspDebugScreenSetXY(8, 10);
|
||||
pspDebugScreenPrintf("Ludarium PSP release fixture\n");
|
||||
pspDebugScreenSetXY(8, 12);
|
||||
pspDebugScreenPrintf("Original open homebrew - no firmware or game data");
|
||||
|
||||
for (;;)
|
||||
sceKernelDelayThread(1000000);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
project(LudariumPsxFixture LANGUAGES C ASM)
|
||||
|
||||
psn00bsdk_add_executable(ludarium GPREL main.c)
|
||||
psn00bsdk_add_cd_image(fixture fixture iso.xml DEPENDS ludarium system.cnf)
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": 3,
|
||||
"cmakeMinimumRequired": { "major": 3, "minor": 21, "patch": 0 },
|
||||
"configurePresets": [
|
||||
{
|
||||
"name": "default",
|
||||
"generator": "Ninja",
|
||||
"toolchainFile": "$env{PSN00BSDK_LIBS}/cmake/sdk.cmake",
|
||||
"binaryDir": "${sourceDir}/build",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "MinSizeRel",
|
||||
"PSN00BSDK_TC": "",
|
||||
"PSN00BSDK_TARGET": "mipsel-none-elf"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# Original PSX browser-play fixture
|
||||
|
||||
This fixture is original Ludarium test code. It initializes the PlayStation display to a
|
||||
solid blue background and waits for vertical blank forever. It contains no Sony BIOS,
|
||||
license sector, SDK binary, retail executable or game asset.
|
||||
|
||||
Build provenance:
|
||||
|
||||
- PSn00bSDK `v0.24`, commit `06e65bea3a778b2dae5af77a7935ae3868ddd4d3`;
|
||||
- official `PSn00bSDK-0.24-Linux.zip`, SHA-256
|
||||
`5ada7c9478d795b22bde96ee23d6869290c14a740bcde3d57c01c342a26ded35`;
|
||||
- official `gcc-mipsel-none-elf-12.3.0-linux.zip`, SHA-256
|
||||
`228f031a25cf2687d8845fd1421f625bafc211fa27da428e458e80d030a726f8`;
|
||||
- PSn00bSDK `mkpsxiso`, followed by MAME `chdman` 0.264;
|
||||
- final 15,411-byte CHD SHA-256
|
||||
`58e9754dd2c868d091cbd2401182b5026e436a7b199437fb7dfab039fccb89e7`;
|
||||
- checked-in gzip SHA-256
|
||||
`7b1af8fcfcaf052d40fac4696710b1f8b17bc3d6d32f36abd04e4c564d2d0f9f`.
|
||||
|
||||
`generate-fixtures.mjs` inflates the checked-in payload and refuses to emit it when the CHD
|
||||
checksum differs. The live gate then verifies the exact 15,411-byte artifact via scoped
|
||||
Range delivery before starting the actual pinned `pcsx_rearmed` core.
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<iso_project>
|
||||
<track type="data">
|
||||
<identifiers system="PLAYSTATION" volume="LUDARIUM_FIXTURE"
|
||||
volume_set="LUDARIUM_FIXTURE" publisher="LUDARIUM"
|
||||
data_preparer="PSN00BSDK" application="PLAYSTATION" copyright="README.TXT;1" />
|
||||
<directory_tree>
|
||||
<file name="SYSTEM.CNF" type="data" source="${PROJECT_SOURCE_DIR}/system.cnf" />
|
||||
<file name="LUDARIUM.EXE" type="data" source="ludarium.exe" />
|
||||
<dummy sectors="1024" />
|
||||
</directory_tree>
|
||||
</track>
|
||||
</iso_project>
|
||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
#include <psxgpu.h>
|
||||
|
||||
int main(void)
|
||||
{
|
||||
DISPENV display;
|
||||
DRAWENV draw;
|
||||
|
||||
ResetGraph(0);
|
||||
SetDefDispEnv(&display, 0, 0, 320, 240);
|
||||
SetDefDrawEnv(&draw, 0, 0, 320, 240);
|
||||
setRGB0(&draw, 18, 42, 96);
|
||||
draw.isbg = 1;
|
||||
PutDispEnv(&display);
|
||||
PutDrawEnv(&draw);
|
||||
SetDispMask(1);
|
||||
|
||||
for (;;)
|
||||
VSync(0);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
BOOT=cdrom:\LUDARIUM.EXE;1
|
||||
TCB=4
|
||||
EVENT=10
|
||||
STACK=801FFFF0
|
||||
@@ -0,0 +1 @@
|
||||
LUDARIUM SYNTHETIC TRACK — NOT GAME CONTENT
|
||||
@@ -0,0 +1,3 @@
|
||||
FILE "game (Track 01).bin" BINARY
|
||||
TRACK 01 MODE1/2352
|
||||
INDEX 01 00:00:00
|
||||
@@ -0,0 +1,3 @@
|
||||
FILE "absent.bin" BINARY
|
||||
TRACK 01 MODE1/2352
|
||||
INDEX 01 00:00:00
|
||||
@@ -0,0 +1 @@
|
||||
FILE "../../outside.bin" BINARY
|
||||
@@ -0,0 +1 @@
|
||||
REM SYNTHETIC DISC 1
|
||||
@@ -0,0 +1 @@
|
||||
REM SYNTHETIC DISC 2
|
||||
@@ -0,0 +1,3 @@
|
||||
#EXTM3U
|
||||
Disc 1.cue
|
||||
Disc 2.cue
|
||||
@@ -0,0 +1 @@
|
||||
LUDARIUM SYNTHETIC PAYLOAD 1
|
||||
@@ -0,0 +1 @@
|
||||
LUDARIUM SYNTHETIC PAYLOAD 2
|
||||
@@ -0,0 +1 @@
|
||||
MZLUDARIUM SYNTHETIC INVALID PE FIXTURE — NEVER EXECUTE
|
||||
@@ -0,0 +1 @@
|
||||
MZ — deterministic inert text fixture; extension intentionally prevents execution.
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.302",
|
||||
"rollForward": "latestPatch",
|
||||
"allowPrerelease": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# Files that may enter the parentless public source repository. Keep this list explicit.
|
||||
.dockerignore
|
||||
.gitattributes
|
||||
.gitignore
|
||||
.gitleaks.toml
|
||||
.gitea/workflows/public-validation.yml
|
||||
CHANGELOG.md
|
||||
CONTRIBUTING.md
|
||||
Directory.Build.props
|
||||
Dockerfile
|
||||
Dockerfile.dolphin-controller
|
||||
Dockerfile.eden-controller
|
||||
Dockerfile.unraid
|
||||
LICENSE
|
||||
Ludarium.slnx
|
||||
NuGet.config
|
||||
README.md
|
||||
SECURITY.md
|
||||
THIRD_PARTY_NOTICES.md
|
||||
VERSION
|
||||
deploy/.env.example
|
||||
deploy/README.md
|
||||
deploy/audit-eden-runtime.sh
|
||||
deploy/compose.dolphin.yml
|
||||
deploy/compose.switch.yml
|
||||
deploy/compose.yml
|
||||
deploy/dolphin-controller-run
|
||||
deploy/dolphin-controller.py
|
||||
deploy/eden-controller-run
|
||||
deploy/eden-controller.py
|
||||
deploy/ludarium-entrypoint.sh
|
||||
deploy/ludarium-unraid.xml
|
||||
deploy/ludarium_sidecar.py
|
||||
deploy/prepare-switch-appdata.sh
|
||||
deploy/repair-postgres-collation.sh
|
||||
deploy/run-candidate-gate.sh
|
||||
deploy/run-eden-security-gate.sh
|
||||
deploy/run-managed-validation.sh
|
||||
deploy/run-security-gates.sh
|
||||
deploy/unraid/Ludarium-icon.png
|
||||
deploy/unraid/my-Ludarium.xml
|
||||
deploy/verify-release-identity.sh
|
||||
deploy/verify-source-manifest.sh
|
||||
docs/PUBLIC_SOURCE_BOUNDARY.md
|
||||
docs/screenshots/living-archive-audit/audit-home-desktop.png
|
||||
fixtures/**
|
||||
global.json
|
||||
public-source.allowlist
|
||||
scripts/export-public-source.mjs
|
||||
scripts/validate-public-source.mjs
|
||||
src/**
|
||||
tests/**
|
||||
@@ -0,0 +1,65 @@
|
||||
import { copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const root = resolve(new URL("..", import.meta.url).pathname.replace(/^\/(.:)/, "$1"));
|
||||
const destination = resolve(process.argv[2] ?? "");
|
||||
if (!process.argv[2] || destination === root || relative(root, destination).split(sep).includes("..") === false)
|
||||
throw new Error("Choose an empty export directory outside the canonical repository.");
|
||||
if (execFileSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8" }).trim())
|
||||
throw new Error("The canonical worktree must be clean before export.");
|
||||
await stat(join(root, "LICENSE")).catch(() => { throw new Error("Confirm and commit a root LICENSE before public export."); });
|
||||
|
||||
const rules = (await readFile(join(root, "public-source.allowlist"), "utf8"))
|
||||
.split(/\r?\n/).map(line => line.trim()).filter(line => line && !line.startsWith("#"));
|
||||
const tracked = execFileSync("git", ["ls-files", "-z"], { cwd: root, encoding: "utf8" })
|
||||
.split("\0").filter(Boolean).map(path => path.replaceAll("\\", "/"));
|
||||
const allowed = path => rules.some(rule => rule.endsWith("/**") ?
|
||||
path.startsWith(rule.slice(0, -3) + "/") : path === rule);
|
||||
const files = tracked.filter(allowed).sort();
|
||||
const missing = rules.filter(rule => !rule.endsWith("/**") && !tracked.includes(rule));
|
||||
if (missing.length) throw new Error(`Allowlisted files are missing: ${missing.join(", ")}`);
|
||||
|
||||
const proprietaryPayload = /\.(?:iso|rom|bin|cue|chd|rvz|wbfs|xci|nsp|keys|exe|nes|pbp)(?:\.|$)/i;
|
||||
const privateMarkers = [
|
||||
["192", "168", "10", "150"].join("."),
|
||||
["ludarium", "itworx", "tech"].join("."),
|
||||
["player", "ludarium", "itworx", "tech"].join("."),
|
||||
];
|
||||
const binaryExtensions = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".ico", ".mp4", ".woff", ".woff2", ".7z"]);
|
||||
const extension = path => path.slice(path.lastIndexOf(".")).toLowerCase();
|
||||
|
||||
await rm(destination, { recursive: true, force: true });
|
||||
await mkdir(destination, { recursive: true });
|
||||
const manifestFiles = [];
|
||||
for (const path of files) {
|
||||
if (isAbsolute(path) || path.split("/").includes("..")) throw new Error(`Unsafe allowlisted path: ${path}`);
|
||||
const syntheticFixture = path.startsWith("fixtures/synthetic/");
|
||||
if (proprietaryPayload.test(path) && !syntheticFixture)
|
||||
throw new Error(`Proprietary payload extension is not publishable: ${path}`);
|
||||
const source = join(root, path);
|
||||
const metadata = await stat(source);
|
||||
if (!metadata.isFile() || metadata.size > 50 * 1024 * 1024 || (syntheticFixture && metadata.size > 1024 * 1024))
|
||||
throw new Error(`Irregular or oversized public file: ${path}`);
|
||||
const data = await readFile(source);
|
||||
if (!binaryExtensions.has(extension(path))) {
|
||||
const text = data.toString("utf8");
|
||||
const marker = privateMarkers.find(value => text.toLowerCase().includes(value));
|
||||
if (marker) throw new Error(`Private infrastructure marker found in ${path}`);
|
||||
}
|
||||
const target = join(destination, path);
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await copyFile(source, target);
|
||||
manifestFiles.push({ path, bytes: metadata.size, sha256: createHash("sha256").update(data).digest("hex") });
|
||||
}
|
||||
|
||||
const sourceRevision = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim();
|
||||
await writeFile(join(destination, "PUBLIC_SOURCE_MANIFEST.json"), JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
sourceRevision,
|
||||
generatedAt: new Date().toISOString(),
|
||||
files: manifestFiles,
|
||||
}, null, 2) + "\n");
|
||||
console.log(`Exported ${files.length} reviewed files from ${sourceRevision} to ${destination}`);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
const root = resolve(new URL("..", import.meta.url).pathname.replace(/^\/(.:)/, "$1"));
|
||||
const manifest = JSON.parse(await readFile(join(root, "PUBLIC_SOURCE_MANIFEST.json"), "utf8"));
|
||||
if (manifest.schemaVersion !== 1 || !/^[0-9a-f]{40}$/.test(manifest.sourceRevision) || !Array.isArray(manifest.files))
|
||||
throw new Error("The public source manifest is malformed.");
|
||||
|
||||
const expected = new Map(manifest.files.map(entry => [entry.path, entry]));
|
||||
if (expected.size !== manifest.files.length || !expected.has("LICENSE"))
|
||||
throw new Error("The public source manifest has duplicate entries or no LICENSE.");
|
||||
async function walk(directory, prefix = "") {
|
||||
const found = [];
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (!prefix && entry.name === ".git") continue;
|
||||
const path = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) found.push(...await walk(join(directory, entry.name), path));
|
||||
else if (entry.isFile() && path !== "PUBLIC_SOURCE_MANIFEST.json") found.push(path);
|
||||
else if (!entry.isFile()) throw new Error(`Irregular public entry: ${path}`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
const actual = (await walk(root)).sort();
|
||||
if (actual.length !== expected.size || actual.some(path => !expected.has(path)))
|
||||
throw new Error("Files outside the reviewed public manifest are present.");
|
||||
for (const path of actual) {
|
||||
const data = await readFile(join(root, path));
|
||||
const entry = expected.get(path);
|
||||
if ((await stat(join(root, path))).size !== entry.bytes || createHash("sha256").update(data).digest("hex") !== entry.sha256)
|
||||
throw new Error(`Public source integrity check failed: ${path}`);
|
||||
}
|
||||
console.log(`Validated ${actual.length} public files from canonical revision ${manifest.sourceRevision}`);
|
||||
@@ -0,0 +1,35 @@
|
||||
using Ludarium.Application;
|
||||
using Ludarium.Infrastructure;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Ludarium.Api;
|
||||
|
||||
public sealed class ApiExceptionHandler(ILogger<ApiExceptionHandler> logger) : IExceptionHandler
|
||||
{
|
||||
public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (status, title) = exception switch
|
||||
{
|
||||
ArgumentException or MediaValidationException or ArtworkValidationException or GameDataVaultValidationException or FirmwareValidationException =>
|
||||
(StatusCodes.Status400BadRequest, "The request was not accepted"),
|
||||
KeyNotFoundException => (StatusCodes.Status404NotFound, "The requested resource was not found"),
|
||||
ResourceConflictException or ConcurrencyException or SnapshotPreconditionException or GameDataIntegrityException or FirmwareIntegrityException =>
|
||||
(StatusCodes.Status409Conflict, "The request conflicts with current state"),
|
||||
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred")
|
||||
};
|
||||
if (status >= 500) logger.LogError(exception, "Unhandled API request failure");
|
||||
else if (logger.IsEnabled(LogLevel.Information))
|
||||
logger.LogInformation("API request rejected with {StatusCode}: {Message}", status, exception.Message);
|
||||
context.Response.StatusCode = status;
|
||||
await context.Response.WriteAsJsonAsync(new ProblemDetails
|
||||
{
|
||||
Status = status,
|
||||
Title = title,
|
||||
Detail = status >= 500 ? "The operation could not be completed." : exception.Message,
|
||||
Instance = context.Request.Path
|
||||
}, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Ludarium.Api;
|
||||
|
||||
public sealed record ApiRateLimitProfile(string Partition, int PermitLimit, TimeSpan Window);
|
||||
|
||||
public static class ApiRateLimits
|
||||
{
|
||||
public static ApiRateLimitProfile For(string method, PathString path)
|
||||
{
|
||||
var expensive = path.StartsWithSegments("/api/v1/games/artwork/enrich") ||
|
||||
path.StartsWithSegments("/api/v1/games/metadata/enrich") ||
|
||||
path.StartsWithSegments("/api/v1/system/cheats/refresh") ||
|
||||
path.StartsWithSegments("/api/v1/exports");
|
||||
if (expensive) return new("expensive", 12, TimeSpan.FromMinutes(1));
|
||||
return HttpMethods.IsGet(method)
|
||||
? new("read", 1200, TimeSpan.FromMinutes(1))
|
||||
: new("mutation", 120, TimeSpan.FromMinutes(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
|
||||
<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" />
|
||||
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Ludarium.Application\Ludarium.Application.csproj" />
|
||||
<ProjectReference Include="..\Ludarium.Infrastructure\Ludarium.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@Ludarium.Api_HostAddress = http://localhost:5135
|
||||
|
||||
GET {{Ludarium.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,126 @@
|
||||
using Ludarium.Application;
|
||||
using Ludarium.Infrastructure;
|
||||
|
||||
namespace Ludarium.Api;
|
||||
|
||||
/// <summary>The exact title a native player may start, or the reason it may not.</summary>
|
||||
public sealed record NativeLaunchResolution(string? RuntimePath, string? Platform,
|
||||
string? RejectTitle = null, string? RejectDetail = null, int RejectStatus = 409)
|
||||
{
|
||||
public bool Rejected => RuntimePath is null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-player rules for turning a game id into one exact runtime path.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything a native player shares — configuration, sessions, cookies, proxying, control,
|
||||
/// error mapping — lives in <see cref="NativeRemotePlayerRuntime"/>. What genuinely differs is
|
||||
/// which linked file may start and which prerequisites apply, and only that belongs here.
|
||||
/// </remarks>
|
||||
public interface INativeLaunchResolver
|
||||
{
|
||||
string Key { get; }
|
||||
|
||||
Task<NativeRemotePlayCapability> EvaluateAsync(Guid gameId, bool runtimeReady, bool automaticRestore,
|
||||
DateTimeOffset checkedAt, CancellationToken ct);
|
||||
|
||||
Task<NativeLaunchResolution> ResolveAsync(Guid gameId, NativeRemotePlayerControlClient control,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class SwitchLaunchResolver(
|
||||
ILudariumStore store,
|
||||
IBrowserPlayStore browserPlayStore,
|
||||
IFirmwareStore firmware,
|
||||
SwitchRuntimeProvisioner provisioner) : INativeLaunchResolver
|
||||
{
|
||||
public string Key => NativeRemotePlayerRegistry.Switch.Key;
|
||||
|
||||
/// <summary>The Switch runtime also needs operator-supplied keys before it is usable at all.</summary>
|
||||
public bool RuntimeProvisioned => provisioner.Configured;
|
||||
|
||||
public async Task<NativeRemotePlayCapability> EvaluateAsync(Guid gameId, bool runtimeReady,
|
||||
bool automaticRestore, DateTimeOffset checkedAt, CancellationToken ct)
|
||||
{
|
||||
var isSwitchGame = await IsSwitchGameAsync(gameId, ct);
|
||||
var candidates = isSwitchGame ? await browserPlayStore.ListSwitchLaunchCandidatesAsync(gameId, ct) : [];
|
||||
var keysReady = await ProductionKeysReadyAsync(ct);
|
||||
return SwitchLaunchPolicy.EvaluateCapability(gameId, isSwitchGame,
|
||||
runtimeReady && provisioner.Configured, keysReady, candidates, checkedAt, automaticRestore);
|
||||
}
|
||||
|
||||
public async Task<NativeLaunchResolution> ResolveAsync(Guid gameId,
|
||||
NativeRemotePlayerControlClient control, CancellationToken ct)
|
||||
{
|
||||
if (!await IsSwitchGameAsync(gameId, ct))
|
||||
return new(null, null, "Unsupported platform",
|
||||
"Only a Nintendo Switch game can start an Eden session.", StatusCodes.Status400BadRequest);
|
||||
if (!await ProductionKeysReadyAsync(ct))
|
||||
return new(null, "switch", "Switch production keys required",
|
||||
"Upload and select your own prod.keys in Settings.", StatusCodes.Status409Conflict);
|
||||
var baseGame = SwitchLaunchPolicy.SelectBaseGame(
|
||||
await browserPlayStore.ListSwitchLaunchCandidatesAsync(gameId, ct));
|
||||
if (baseGame is null)
|
||||
return new(null, "switch", "Exact Switch base game is ambiguous",
|
||||
"Ludarium could not identify one unique read-only base XCI/NSP. Review the game's linked Switch files.",
|
||||
StatusCodes.Status409Conflict);
|
||||
return new(SwitchLaunchPolicy.ToRuntimePath(baseGame.RelativePath, control.CatalogPrefix("switch")), "switch");
|
||||
}
|
||||
|
||||
private async Task<bool> IsSwitchGameAsync(Guid gameId, CancellationToken ct) =>
|
||||
(await store.ListReleasesAsync(gameId, ct))
|
||||
.Any(release => string.Equals(release.Platform, "switch", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private async Task<bool> ProductionKeysReadyAsync(CancellationToken ct) =>
|
||||
await firmware.GetSelectedFirmwareAssetAsync("switch", "prod-keys", ct) is not null;
|
||||
}
|
||||
|
||||
public sealed class DolphinLaunchResolver(ILudariumStore store, IBrowserPlayStore browserPlayStore)
|
||||
: INativeLaunchResolver
|
||||
{
|
||||
public string Key => NativeRemotePlayerRegistry.Dolphin.Key;
|
||||
|
||||
public async Task<NativeRemotePlayCapability> EvaluateAsync(Guid gameId, bool runtimeReady,
|
||||
bool automaticRestore, DateTimeOffset checkedAt, CancellationToken ct)
|
||||
{
|
||||
var platform = await ResolvePlatformAsync(gameId, ct);
|
||||
var candidates = platform is null ? []
|
||||
: await browserPlayStore.ListDolphinLaunchCandidatesAsync(gameId, platform, ct);
|
||||
return DolphinLaunchPolicy.EvaluateCapability(gameId, platform, runtimeReady, candidates,
|
||||
checkedAt, automaticRestore);
|
||||
}
|
||||
|
||||
public async Task<NativeLaunchResolution> ResolveAsync(Guid gameId,
|
||||
NativeRemotePlayerControlClient control, CancellationToken ct)
|
||||
{
|
||||
var platform = await ResolvePlatformAsync(gameId, ct);
|
||||
if (platform is null)
|
||||
return new(null, null, "Unsupported platform",
|
||||
"Only a GameCube or Wii game can start a Dolphin session.", StatusCodes.Status400BadRequest);
|
||||
var selected = DolphinLaunchPolicy.SelectGame(
|
||||
await browserPlayStore.ListDolphinLaunchCandidatesAsync(gameId, platform, ct));
|
||||
if (selected is null)
|
||||
return new(null, platform, "Exact Dolphin game is ambiguous",
|
||||
"Ludarium could not identify one unique read-only compatible image.", StatusCodes.Status409Conflict);
|
||||
return new(DolphinLaunchPolicy.ToRuntimePath(platform, selected.RelativePath, control.CatalogPrefix(platform)),
|
||||
platform);
|
||||
}
|
||||
|
||||
private async Task<string?> ResolvePlatformAsync(Guid gameId, CancellationToken ct)
|
||||
{
|
||||
var platforms = (await store.ListReleasesAsync(gameId, ct)).Select(release => release.Platform);
|
||||
var resolved = NativeRemotePlayerRegistry.ResolvePlatform(platforms);
|
||||
return NativeRemotePlayerRegistry.Dolphin.Owns(resolved) ? resolved : null;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NativeLaunchResolvers(IEnumerable<INativeLaunchResolver> resolvers)
|
||||
{
|
||||
private readonly Dictionary<string, INativeLaunchResolver> byKey =
|
||||
resolvers.ToDictionary(resolver => resolver.Key, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public INativeLaunchResolver this[string key] => byKey.TryGetValue(key, out var resolver)
|
||||
? resolver
|
||||
: throw new KeyNotFoundException($"No native launch resolver is registered for '{key}'.");
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
using Ludarium.Application;
|
||||
using Ludarium.Domain;
|
||||
using Ludarium.Infrastructure;
|
||||
using Yarp.ReverseProxy.Forwarder;
|
||||
|
||||
namespace Ludarium.Api;
|
||||
|
||||
public sealed record NativeRemotePlayerAction(string? Action);
|
||||
|
||||
/// <summary>
|
||||
/// Maps the identical route surface every native remote player exposes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Routes, cookies and status codes are derived from <see cref="NativeRemotePlayer"/>, so a new
|
||||
/// player gets its capability, session, action, exit and proxy endpoints from its description alone.
|
||||
/// Only <see cref="INativeLaunchResolver"/> is written per player.
|
||||
/// </remarks>
|
||||
public static class NativeRemotePlayerEndpoints
|
||||
{
|
||||
private const int MaximumCapabilityBatch = 50;
|
||||
|
||||
/// <summary>How many restore points a player offers. The vault keeps the full history.</summary>
|
||||
private const int MaximumRestorePoints = 10;
|
||||
|
||||
public static void MapNativeRemotePlayers(this WebApplication app, RouteGroupBuilder api)
|
||||
{
|
||||
var runtimes = app.Services.GetRequiredService<NativeRemotePlayerRuntimes>();
|
||||
foreach (var runtime in runtimes.All)
|
||||
{
|
||||
MapCapabilities(api, runtime);
|
||||
MapSessions(app, api, runtime);
|
||||
MapSaveData(app, api, runtime);
|
||||
MapProxy(app, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Source type recorded on every vault revision captured from a native sidecar.</summary>
|
||||
public const string SaveDataSourceType = "NativeRemotePlayerV1";
|
||||
|
||||
/// <summary>
|
||||
/// Brings native remote players into the Game Data Vault.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Browser saves have always been versioned, SHA-256 evidenced revisions with a download history.
|
||||
/// Native players kept theirs inside the sidecar's own volume, where Ludarium could neither back
|
||||
/// them up nor restore them to a point. These two routes close that gap using the same vault.
|
||||
/// </remarks>
|
||||
private static void MapSaveData(WebApplication app, RouteGroupBuilder api, NativeRemotePlayerRuntime runtime)
|
||||
{
|
||||
var player = runtime.Player;
|
||||
var entryName = $"{player.Emulator} save data";
|
||||
|
||||
api.MapGet($"/games/{{id:guid}}/{player.Key}-save-data", async (Guid id,
|
||||
IGameDataVaultStore store, CancellationToken ct) =>
|
||||
{
|
||||
var entryId = BrowserGameDataPolicy.StableEntryId(id, player.Key, GameDataKind.Save);
|
||||
var entry = await store.GetGameDataAsync(entryId, ct);
|
||||
if (entry is null || entry.Entry.GameId != id)
|
||||
return Results.Ok(new { entryId, revisionCount = 0, revisions = Array.Empty<object>() });
|
||||
var revisions = await store.ListGameDataRevisionsAsync(entryId, 1, MaximumRestorePoints, ct);
|
||||
return Results.Ok(new
|
||||
{
|
||||
entryId,
|
||||
entry.Entry.RevisionCount,
|
||||
revisions = revisions.Items.Select(revision => new
|
||||
{
|
||||
revision.Id,
|
||||
revision.Sequence,
|
||||
revision.Length,
|
||||
revision.Sha256,
|
||||
revision.CreatedAt,
|
||||
current = revision.Id == entry.Entry.CurrentRevisionId
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
api.MapPost($"/games/{{id:guid}}/{player.Key}-save-data", async (Guid id, ILudariumStore catalog,
|
||||
IGameDataVaultStore store, GameDataVaultFileStore files, CancellationToken ct) =>
|
||||
{
|
||||
if (await catalog.GetGameAsync(id, ct) is null) return Results.NotFound();
|
||||
if (!runtime.Configured)
|
||||
return Results.Problem(title: $"Embedded {player.Emulator} player unavailable",
|
||||
detail: $"Enable the optional isolated {player.Emulator} deployment profile.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
// A capture taken while a title runs sees whatever the emulator has flushed. That is
|
||||
// usually the last in-game save, but it can be a partial write, so the revision says so
|
||||
// rather than presenting every capture as equally trustworthy.
|
||||
var running = (await runtime.Control.GetStatusAsync(ct)).Running;
|
||||
|
||||
await using var archive = await runtime.Control.DownloadSaveDataAsync(ct);
|
||||
if (archive is null)
|
||||
return Results.Problem(title: $"{player.Emulator} save data is unavailable",
|
||||
detail: $"The isolated {player.Emulator} runtime did not return its save directory, or its configured save directories are still empty.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
var entryId = BrowserGameDataPolicy.StableEntryId(id, player.Key, GameDataKind.Save);
|
||||
var existing = await store.GetGameDataAsync(entryId, ct);
|
||||
if (existing is not null && existing.Entry.GameId != id) return Results.Conflict();
|
||||
var uploaded = await files.SaveAsync(id, entryId, Guid.NewGuid(),
|
||||
$"{player.Key}-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}.tar.gz", archive, null,
|
||||
$"{player.Key}-player", ct, SaveDataSourceType, player.Key);
|
||||
try
|
||||
{
|
||||
var saved = await store.SaveGameDataRevisionAsync(id, entryId,
|
||||
new(GameDataKind.Save, entryName, player.Emulator, "remote-player",
|
||||
running
|
||||
? $"Captured from the isolated {player.Emulator} runtime while a title was running."
|
||||
: $"Captured from the isolated {player.Emulator} runtime."),
|
||||
existing?.Entry.Version ?? 0, uploaded, $"{player.Key}-player", ct);
|
||||
if (saved.CurrentRevision.Id != uploaded.Id) files.Delete(uploaded);
|
||||
if (app.Logger.IsEnabled(LogLevel.Information))
|
||||
app.Logger.LogInformation("Captured {Emulator} save data revision {Sequence} for game {GameId}",
|
||||
player.Emulator, saved.CurrentRevision.Sequence, id);
|
||||
return Results.Ok(new
|
||||
{
|
||||
saved.Entry.Id,
|
||||
saved.Entry.Version,
|
||||
saved.Entry.RevisionCount,
|
||||
saved.CurrentRevision.Sequence,
|
||||
saved.CurrentRevision.Length,
|
||||
saved.CurrentRevision.Sha256,
|
||||
capturedWhileRunning = running
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
files.Delete(uploaded);
|
||||
throw;
|
||||
}
|
||||
});
|
||||
|
||||
api.MapPost($"/games/{{id:guid}}/{player.Key}-save-data/restore", async (Guid id, Guid? revisionId,
|
||||
IGameDataVaultStore store, GameDataVaultFileStore files, CancellationToken ct) =>
|
||||
{
|
||||
if (!runtime.Configured)
|
||||
return Results.Problem(title: $"Embedded {player.Emulator} player unavailable",
|
||||
detail: $"Enable the optional isolated {player.Emulator} deployment profile.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
var entryId = BrowserGameDataPolicy.StableEntryId(id, player.Key, GameDataKind.Save);
|
||||
var entry = await store.GetGameDataAsync(entryId, ct);
|
||||
if (entry is null || entry.Entry.GameId != id) return Results.NotFound();
|
||||
|
||||
var revision = revisionId is null ? entry.CurrentRevision
|
||||
: await store.GetGameDataRevisionAsync(revisionId.Value, ct);
|
||||
// A revision from another entry must never be pushed into this player's save directory.
|
||||
if (revision is null || revision.EntryId != entryId) return Results.NotFound();
|
||||
|
||||
var stored = await files.OpenAsync(revision, ct);
|
||||
if (stored is null)
|
||||
return Results.Problem(title: "Stored save data is missing",
|
||||
detail: "The vault no longer holds the payload for this revision.",
|
||||
statusCode: StatusCodes.Status410Gone);
|
||||
|
||||
await using var content = stored.Content;
|
||||
if (!await runtime.Control.UploadSaveDataAsync(content, stored.Length, ct))
|
||||
return Results.Problem(title: $"{player.Emulator} save data could not be restored",
|
||||
detail: $"The isolated {player.Emulator} runtime rejected the stored revision.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
if (app.Logger.IsEnabled(LogLevel.Information))
|
||||
app.Logger.LogInformation("Restored {Emulator} save data revision {Sequence} for game {GameId}",
|
||||
player.Emulator, revision.Sequence, id);
|
||||
return Results.Ok(new { revision.Id, revision.Sequence, revision.Sha256, revision.Length });
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A player is only usable when it is configured <em>and</em> its sidecar answers a live probe.
|
||||
/// Configuration alone proves intent, not that the container is up.
|
||||
/// </summary>
|
||||
private static async Task<(bool Ready, bool AutomaticRestore)> ProbeAsync(
|
||||
NativeRemotePlayerRuntime runtime, CancellationToken ct)
|
||||
{
|
||||
if (!runtime.Configured) return (false, false);
|
||||
var status = await runtime.Control.GetStatusAsync(ct);
|
||||
return (status.Reachable, status.Reachable && status.SaveDataSupported);
|
||||
}
|
||||
|
||||
private static void MapCapabilities(RouteGroupBuilder api, NativeRemotePlayerRuntime runtime)
|
||||
{
|
||||
var player = runtime.Player;
|
||||
|
||||
api.MapGet($"/games/{{id:guid}}/{player.Key}-play-capability",
|
||||
async (Guid id, NativeLaunchResolvers resolvers, CancellationToken ct) =>
|
||||
{
|
||||
var (ready, automaticRestore) = await ProbeAsync(runtime, ct);
|
||||
return Results.Ok(await resolvers[player.Key]
|
||||
.EvaluateAsync(id, ready, automaticRestore, DateTimeOffset.UtcNow, ct));
|
||||
});
|
||||
|
||||
api.MapGet($"/games/{player.Key}-play-capabilities",
|
||||
async (string? ids, NativeLaunchResolvers resolvers, CancellationToken ct) =>
|
||||
{
|
||||
if (!TryParseBatch(ids, out var gameIds))
|
||||
return Results.BadRequest(new { message = $"Supply between 1 and {MaximumCapabilityBatch} valid game ids." });
|
||||
var (ready, automaticRestore) = await ProbeAsync(runtime, ct);
|
||||
var checkedAt = DateTimeOffset.UtcNow;
|
||||
var resolver = resolvers[player.Key];
|
||||
var capabilities = await Task.WhenAll(gameIds.Select(gameId =>
|
||||
resolver.EvaluateAsync(gameId, ready, automaticRestore, checkedAt, ct)));
|
||||
return Results.Ok(capabilities);
|
||||
});
|
||||
}
|
||||
|
||||
private static void MapSessions(WebApplication app, RouteGroupBuilder api, NativeRemotePlayerRuntime runtime)
|
||||
{
|
||||
var player = runtime.Player;
|
||||
|
||||
api.MapPost($"/games/{{id:guid}}/{player.SessionRoute}", async (Guid id, HttpContext context,
|
||||
ILibraryExperienceStore experience, NativeLaunchResolvers resolvers, CancellationToken ct) =>
|
||||
{
|
||||
if (!runtime.Configured)
|
||||
return Results.Problem(title: $"Embedded {player.Emulator} player unavailable",
|
||||
detail: $"Enable the optional isolated {player.Emulator} deployment profile and complete its runtime gate.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
var resolution = await resolvers[player.Key].ResolveAsync(id, runtime.Control, ct);
|
||||
if (resolution.Rejected)
|
||||
return resolution.RejectStatus == StatusCodes.Status400BadRequest
|
||||
? Results.BadRequest(new { message = resolution.RejectDetail })
|
||||
: Results.Problem(title: resolution.RejectTitle, detail: resolution.RejectDetail,
|
||||
statusCode: resolution.RejectStatus);
|
||||
|
||||
try
|
||||
{
|
||||
var ticket = runtime.Sessions.Create(id, runtime.Proxy.BuildLaunchUrl(context.Request),
|
||||
runtime.Proxy.BuildExternalUrl(context.Request), resolution.Platform);
|
||||
try
|
||||
{
|
||||
await runtime.Control.LaunchAsync(resolution.RuntimePath!, ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
runtime.Sessions.End(ticket.Session.Id);
|
||||
throw;
|
||||
}
|
||||
context.Response.Cookies.Append(player.CookieName, ticket.Token,
|
||||
runtime.Sessions.Cookie(ticket.Session.ExpiresAt,
|
||||
runtime.Proxy.SecureCookies(context.Request),
|
||||
runtime.Proxy.CookieDomain(context.Request)));
|
||||
if (app.Logger.IsEnabled(LogLevel.Information))
|
||||
app.Logger.LogInformation("Embedded {Emulator} player session {SessionId} started for game {GameId}",
|
||||
player.Emulator, ticket.Session.Id, id);
|
||||
try { await experience.RecordGamePlayedAsync(id, "local-admin", ct); }
|
||||
catch (Exception exception)
|
||||
{
|
||||
app.Logger.LogWarning(exception, "Could not record {Emulator} play history for game {GameId}",
|
||||
player.Emulator, id);
|
||||
}
|
||||
return Results.Created($"/api/v1/{player.SessionRoute}/{ticket.Session.Id}", ticket.Session);
|
||||
}
|
||||
catch (NativeRemotePlayerCapacityException exception)
|
||||
{
|
||||
return Results.Problem(title: $"{player.Emulator} player capacity reached",
|
||||
detail: exception.Message, statusCode: StatusCodes.Status429TooManyRequests);
|
||||
}
|
||||
catch (NativeRemotePlayerControlException exception)
|
||||
{
|
||||
return Results.Problem(title: $"{player.Emulator} title could not start",
|
||||
detail: exception.Message, statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
});
|
||||
|
||||
api.MapPost($"/{player.SessionRoute}/{{id:guid}}/actions",
|
||||
async (Guid id, NativeRemotePlayerAction input, CancellationToken ct) =>
|
||||
{
|
||||
if (runtime.Sessions.Get(id) is null) return Results.NotFound();
|
||||
var action = input.Action?.Trim().ToLowerInvariant();
|
||||
if (action is null || !player.Actions.Contains(action))
|
||||
return Results.BadRequest(new { message = $"Unsupported {player.Emulator} player action." });
|
||||
try
|
||||
{
|
||||
await runtime.Control.ActionAsync(action, ct);
|
||||
return Results.Accepted();
|
||||
}
|
||||
catch (NativeRemotePlayerControlException exception)
|
||||
{
|
||||
return Results.Problem(title: $"{player.Emulator} player control failed",
|
||||
detail: exception.Message, statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
});
|
||||
|
||||
// This route is protected by the administrator bearer token. The player capability cookie is
|
||||
// intentionally scoped to the proxy path and therefore cannot authorize an API request.
|
||||
api.MapDelete($"/{player.SessionRoute}/{{id:guid}}", async (Guid id, HttpContext context,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var ended = runtime.Sessions.End(id);
|
||||
context.Response.Cookies.Delete(player.CookieName,
|
||||
runtime.Sessions.ExpiredCookie(runtime.Proxy.SecureCookies(context.Request),
|
||||
runtime.Proxy.CookieDomain(context.Request)));
|
||||
// Revoking the capability must also end the title. A running emulator that outlives its
|
||||
// session keeps a render device and the disc image busy with no way left to reach it.
|
||||
if (ended && !runtime.Sessions.HasActiveSession)
|
||||
{
|
||||
var stopped = await runtime.Control.TryStopAsync(ct);
|
||||
if (!stopped && app.Logger.IsEnabled(LogLevel.Warning))
|
||||
app.Logger.LogWarning("Could not stop the {Emulator} title after session {SessionId} ended",
|
||||
player.Emulator, id);
|
||||
}
|
||||
if (ended && app.Logger.IsEnabled(LogLevel.Information))
|
||||
app.Logger.LogInformation("Embedded {Emulator} player session {SessionId} ended",
|
||||
player.Emulator, id);
|
||||
return ended ? Results.NoContent() : Results.NotFound();
|
||||
});
|
||||
}
|
||||
|
||||
private static void MapProxy(WebApplication app, NativeRemotePlayerRuntime runtime)
|
||||
{
|
||||
var player = runtime.Player;
|
||||
app.Map($"{player.ProxyPath}/{{**catchAll}}", async (HttpContext context) =>
|
||||
{
|
||||
if (!runtime.Proxy.Configured)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||
await context.Response.WriteAsync($"The embedded {player.Emulator} player is not configured.",
|
||||
context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
if (!runtime.Proxy.AcceptsProxyRequest(context.Request))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
if (!runtime.Sessions.Authorizes(context.Request.Cookies[player.CookieName]))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
context.Response.ContentType = "text/plain; charset=utf-8";
|
||||
await context.Response.WriteAsync(
|
||||
$"This {player.Emulator} player session is missing, expired or closed.", context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
var error = await runtime.Forwarder.SendAsync(context, runtime.Proxy);
|
||||
if (error != ForwarderError.None && !context.Response.HasStarted)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
await context.Response.WriteAsync($"The isolated {player.Emulator} runtime could not be reached.",
|
||||
context.RequestAborted);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static bool TryParseBatch(string? ids, out Guid[] gameIds)
|
||||
{
|
||||
var requested = (ids ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (requested.Length is 0 or > MaximumCapabilityBatch || requested.Any(value => !Guid.TryParse(value, out _)))
|
||||
{
|
||||
gameIds = [];
|
||||
return false;
|
||||
}
|
||||
gameIds = requested.Select(Guid.Parse).Distinct().ToArray();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Ludarium.Application;
|
||||
using Yarp.ReverseProxy.Forwarder;
|
||||
|
||||
namespace Ludarium.Api;
|
||||
|
||||
/// <summary>
|
||||
/// The transport every isolated native remote player shares: configuration, capability-scoped
|
||||
/// sessions, the authenticated exact-title control client and the certificate-pinned proxy.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This used to exist once per emulator. That is how the live Eden display fix never reached
|
||||
/// Dolphin, and it is why adding 3DS, Xbox, Wii U or Vita meant copying a vertical slice. A player
|
||||
/// is now described by <see cref="NativeRemotePlayer"/> and everything below is shared.
|
||||
/// </remarks>
|
||||
public sealed record NativeRemotePlayerSessionDescriptor(Guid Id, Guid GameId, string Player, string Emulator,
|
||||
string? Platform, string LaunchUrl, string? ExternalUrl, DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt,
|
||||
bool ExactGameLaunch, string SaveMode);
|
||||
|
||||
public sealed record NativeRemotePlayerSessionTicket(NativeRemotePlayerSessionDescriptor Session, string Token);
|
||||
|
||||
public sealed class NativeRemotePlayerCapacityException(NativeRemotePlayer player)
|
||||
: Exception($"The isolated {player.Emulator} player is already in use.");
|
||||
|
||||
public sealed class NativeRemotePlayerControlException(NativeRemotePlayer player)
|
||||
: Exception($"The isolated {player.Emulator} controller rejected the requested operation.");
|
||||
|
||||
public sealed class NativeRemotePlayerProxyOptions
|
||||
{
|
||||
public NativeRemotePlayer Player { get; }
|
||||
public Uri? Destination { get; }
|
||||
public Uri? ExternalPlayer { get; }
|
||||
public Uri? PublicOrigin { get; }
|
||||
public string? Username { get; }
|
||||
public string? Password { get; }
|
||||
public byte[]? CertificateSha256 { get; }
|
||||
public int PublicPort { get; }
|
||||
|
||||
public bool Configured => Destination is not null && !string.IsNullOrWhiteSpace(Username) &&
|
||||
!string.IsNullOrWhiteSpace(Password) && CertificateSha256 is { Length: 32 } &&
|
||||
PublicPort is > 0 and <= 65535;
|
||||
|
||||
public NativeRemotePlayerProxyOptions(NativeRemotePlayer player, Uri? destination, Uri? externalPlayer,
|
||||
string? username, string? password, int? publicPort = null, string? certificateSha256 = null,
|
||||
Uri? publicOrigin = null)
|
||||
{
|
||||
Player = player;
|
||||
// Only the sidecar's own internal name is ever a valid destination: the proxy must never be
|
||||
// pointed at an arbitrary host by configuration.
|
||||
Destination = destination is { Scheme: "https", UserInfo.Length: 0, Query.Length: 0, Fragment.Length: 0, AbsolutePath: "/" } &&
|
||||
string.Equals(destination.Host, player.ProxyHost, StringComparison.OrdinalIgnoreCase) ? destination : null;
|
||||
ExternalPlayer = externalPlayer is { UserInfo.Length: 0 } && externalPlayer.Scheme is "http" or "https"
|
||||
? externalPlayer : null;
|
||||
PublicOrigin = publicOrigin is { Scheme: "https", UserInfo.Length: 0, Query.Length: 0, Fragment.Length: 0, AbsolutePath: "/" }
|
||||
? publicOrigin : null;
|
||||
Username = string.IsNullOrWhiteSpace(username) ? null : username;
|
||||
Password = string.IsNullOrWhiteSpace(password) ? null : password;
|
||||
PublicPort = publicPort ?? player.DefaultPublicPort;
|
||||
CertificateSha256 = ParseSha256(certificateSha256);
|
||||
}
|
||||
|
||||
public static NativeRemotePlayerProxyOptions FromEnvironment(NativeRemotePlayer player)
|
||||
{
|
||||
string? Value(string name) => Environment.GetEnvironmentVariable($"{player.EnvironmentPrefix}_{name}");
|
||||
_ = Uri.TryCreate(Value("PROXY_URL"), UriKind.Absolute, out var destination);
|
||||
_ = Uri.TryCreate(Value("PLAYER_URL"), UriKind.Absolute, out var external);
|
||||
_ = Uri.TryCreate(Value("EMBED_ORIGIN"), UriKind.Absolute, out var origin);
|
||||
var port = int.TryParse(Value("EMBED_PORT"), out var parsed) ? parsed : player.DefaultPublicPort;
|
||||
return new(player, destination, external, Value("PROXY_USERNAME"), Value("PROXY_PASSWORD"), port,
|
||||
Value("PROXY_CERT_SHA256"), origin);
|
||||
}
|
||||
|
||||
public string BuildLaunchUrl(HttpRequest request)
|
||||
{
|
||||
if (!Configured) throw new InvalidOperationException($"The embedded {Player.Emulator} player is not configured.");
|
||||
if (PublicOrigin is not null)
|
||||
{
|
||||
if (!IsChildHost(PublicOrigin.Host, request.Host.Host))
|
||||
throw new InvalidOperationException($"The {Player.Emulator} player origin must be a subdomain of the Ludarium request host.");
|
||||
return new Uri(PublicOrigin, Player.ProxyPath + "/").ToString();
|
||||
}
|
||||
return new UriBuilder(request.Scheme, request.Host.Host, PublicPort, Player.ProxyPath + "/").Uri.ToString();
|
||||
}
|
||||
|
||||
public string? BuildExternalUrl(HttpRequest request) =>
|
||||
PublicOrigin is null ? ExternalPlayer?.ToString() : BuildLaunchUrl(request);
|
||||
|
||||
public string? CookieDomain(HttpRequest request) => PublicOrigin is null ? null :
|
||||
IsChildHost(PublicOrigin.Host, request.Host.Host) ? request.Host.Host :
|
||||
throw new InvalidOperationException($"The {Player.Emulator} player origin must be a subdomain of the Ludarium request host.");
|
||||
|
||||
public bool AcceptsProxyRequest(HttpRequest request) => PublicOrigin is null ||
|
||||
string.Equals(request.Host.Host, PublicOrigin.Host, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool SecureCookies(HttpRequest request) =>
|
||||
request.IsHttps || PublicOrigin?.Scheme == Uri.UriSchemeHttps;
|
||||
|
||||
private static bool IsChildHost(string child, string parent) =>
|
||||
child.EndsWith('.' + parent, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static byte[]? ParseSha256(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || value.Length != 64) return null;
|
||||
try { return Convert.FromHexString(value); }
|
||||
catch (FormatException) { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NativeRemotePlayerSessionStore(NativeRemotePlayer player, TimeProvider timeProvider)
|
||||
{
|
||||
public static readonly TimeSpan SessionLifetime = TimeSpan.FromHours(8);
|
||||
private readonly ConcurrentDictionary<Guid, Entry> sessions = new();
|
||||
private readonly object gate = new();
|
||||
|
||||
public NativeRemotePlayer Player { get; } = player;
|
||||
public string CookieName => Player.CookieName;
|
||||
public string ProxyPath => Player.ProxyPath;
|
||||
|
||||
public NativeRemotePlayerSessionTicket Create(Guid gameId, string launchUrl, string? externalUrl,
|
||||
string? platform = null, bool exactGameLaunch = true)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
RemoveExpired();
|
||||
if (sessions.Count >= Player.MaximumSessions) throw new NativeRemotePlayerCapacityException(Player);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var token = Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
var descriptor = new NativeRemotePlayerSessionDescriptor(Guid.NewGuid(), gameId, Player.Key,
|
||||
Player.Emulator, platform, launchUrl, externalUrl, now, now.Add(SessionLifetime),
|
||||
exactGameLaunch, Player.SaveMode);
|
||||
sessions[descriptor.Id] = new Entry(descriptor, SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||
return new(descriptor, token);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Authorizes(string? token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token)) return false;
|
||||
var supplied = SHA256.HashData(Encoding.UTF8.GetBytes(token));
|
||||
var now = timeProvider.GetUtcNow();
|
||||
foreach (var entry in sessions.Values)
|
||||
if (entry.Descriptor.ExpiresAt > now &&
|
||||
CryptographicOperations.FixedTimeEquals(supplied, entry.TokenHash))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public NativeRemotePlayerSessionDescriptor? Get(Guid id) => sessions.TryGetValue(id, out var entry) &&
|
||||
entry.Descriptor.ExpiresAt > timeProvider.GetUtcNow() ? entry.Descriptor : null;
|
||||
|
||||
public bool End(Guid id) => sessions.TryRemove(id, out _);
|
||||
|
||||
/// <summary>True while any capability this store issued is still live.</summary>
|
||||
public bool HasActiveSession
|
||||
{
|
||||
get
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return sessions.Values.Any(entry => entry.Descriptor.ExpiresAt > now);
|
||||
}
|
||||
}
|
||||
|
||||
public CookieOptions Cookie(DateTimeOffset expires, bool secure, string? domain = null) => new()
|
||||
{
|
||||
HttpOnly = true,
|
||||
IsEssential = true,
|
||||
Path = ProxyPath,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Secure = secure,
|
||||
Domain = domain,
|
||||
Expires = expires
|
||||
};
|
||||
|
||||
public CookieOptions ExpiredCookie(bool secure, string? domain = null) =>
|
||||
Cookie(DateTimeOffset.UnixEpoch, secure, domain);
|
||||
|
||||
private void RemoveExpired()
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
foreach (var entry in sessions)
|
||||
if (entry.Value.Descriptor.ExpiresAt <= now) sessions.TryRemove(entry.Key, out _);
|
||||
}
|
||||
|
||||
private static string Base64Url(byte[] value) =>
|
||||
Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
private sealed record Entry(NativeRemotePlayerSessionDescriptor Descriptor, byte[] TokenHash);
|
||||
}
|
||||
|
||||
/// <summary>Live state reported by a sidecar's own control endpoint.</summary>
|
||||
public sealed record NativeRemotePlayerRuntimeStatus(bool Reachable, bool Running, string? SaveMode,
|
||||
bool SaveDataSupported = false);
|
||||
|
||||
public sealed class NativeRemotePlayerControlClient
|
||||
{
|
||||
private readonly HttpClient client;
|
||||
private readonly string? token;
|
||||
private readonly TimeProvider time;
|
||||
private readonly Lock probeGate = new();
|
||||
private NativeRemotePlayerRuntimeStatus? cachedStatus;
|
||||
private DateTimeOffset cachedAt = DateTimeOffset.MinValue;
|
||||
|
||||
/// <summary>How long a live probe result is reused, so capability reads stay cheap.</summary>
|
||||
public static readonly TimeSpan HealthCacheLifetime = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// A readiness probe must never hold up a catalog page. A sidecar that accepts the connection but
|
||||
/// does not answer would otherwise stall every capability read for the full control timeout.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan HealthProbeTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
public NativeRemotePlayer Player { get; }
|
||||
public IReadOnlyDictionary<string, string> CatalogPrefixes { get; }
|
||||
public string? QualifiedFixtureSha256 { get; }
|
||||
|
||||
public bool Configured =>
|
||||
client.BaseAddress is { Scheme: "http", AbsolutePath: "/" } address &&
|
||||
string.Equals(address.Host, Player.ControlHost, StringComparison.OrdinalIgnoreCase) &&
|
||||
token is not null && (!Player.RequiresFixtureDigest || QualifiedFixtureSha256 is not null);
|
||||
|
||||
public NativeRemotePlayerControlClient(NativeRemotePlayer player, HttpClient client, string? token,
|
||||
IReadOnlyDictionary<string, string> catalogPrefixes, string? fixtureSha256 = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
Player = player;
|
||||
this.client = client;
|
||||
this.token = string.IsNullOrWhiteSpace(token) ? null : token;
|
||||
this.time = timeProvider ?? TimeProvider.System;
|
||||
CatalogPrefixes = catalogPrefixes;
|
||||
QualifiedFixtureSha256 = IsValidSha256(fixtureSha256) ? fixtureSha256!.ToLowerInvariant() : null;
|
||||
}
|
||||
|
||||
public static NativeRemotePlayerControlClient FromEnvironment(NativeRemotePlayer player)
|
||||
{
|
||||
string? Value(string name) => Environment.GetEnvironmentVariable($"{player.EnvironmentPrefix}_{name}");
|
||||
_ = Uri.TryCreate(Value("CONTROL_URL"), UriKind.Absolute, out var address);
|
||||
var prefixes = player.Platforms.ToDictionary(
|
||||
platform => platform,
|
||||
platform => Value(CatalogPrefixVariable(player, platform)) ?? $"roms/{platform}",
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
return new(player, new HttpClient { BaseAddress = address, Timeout = TimeSpan.FromSeconds(15) },
|
||||
Value("CONTROL_TOKEN"), prefixes, Value("FIXTURE_SHA256"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single-platform player keeps the flat variable it already ships with; a multi-platform
|
||||
/// player names its platform, matching the deployed Dolphin configuration.
|
||||
/// </summary>
|
||||
public static string CatalogPrefixVariable(NativeRemotePlayer player, string platform) =>
|
||||
player.Platforms.Count == 1 ? "CATALOG_PREFIX" : $"{platform.ToUpperInvariant()}_CATALOG_PREFIX";
|
||||
|
||||
public static bool IsValidSha256(string? value) => value is { Length: 64 } && value.All(Uri.IsHexDigit);
|
||||
|
||||
public string CatalogPrefix(string platform) => CatalogPrefixes.TryGetValue(platform, out var prefix)
|
||||
? prefix
|
||||
: throw new InvalidOperationException($"No catalog prefix is configured for {platform}.");
|
||||
|
||||
public Task LaunchAsync(string path, CancellationToken ct) => SendAsync("v1/launch", new { path }, ct);
|
||||
|
||||
public Task ActionAsync(string action, CancellationToken ct) => SendAsync("v1/action", new { action }, ct);
|
||||
|
||||
/// <summary>Best-effort stop used when a player session ends, so no title outlives its capability.</summary>
|
||||
public async Task<bool> TryStopAsync(CancellationToken ct)
|
||||
{
|
||||
if (!Configured) return false;
|
||||
try { await ActionAsync("stop", ct); return true; }
|
||||
catch (NativeRemotePlayerControlException) { return false; }
|
||||
catch (HttpRequestException) { return false; }
|
||||
catch (TaskCanceledException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ask the sidecar whether it is actually alive. Configuration alone only proves an operator
|
||||
/// intended a runtime; it does not prove the container is up, so a capability that claims
|
||||
/// availability must be backed by a recent live answer.
|
||||
/// </summary>
|
||||
public async Task<NativeRemotePlayerRuntimeStatus> GetStatusAsync(CancellationToken ct)
|
||||
{
|
||||
if (!Configured) return new(false, false, null);
|
||||
var now = time.GetUtcNow();
|
||||
lock (probeGate)
|
||||
if (cachedStatus is not null && now - cachedAt < HealthCacheLifetime) return cachedStatus;
|
||||
|
||||
NativeRemotePlayerRuntimeStatus status;
|
||||
using var probe = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
probe.CancelAfter(HealthProbeTimeout);
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "v1/status");
|
||||
request.Headers.Add("X-Ludarium-Control-Token", token);
|
||||
using var response = await client.SendAsync(request, probe.Token);
|
||||
status = response.IsSuccessStatusCode
|
||||
? Parse(await response.Content.ReadAsStringAsync(probe.Token))
|
||||
: new(false, false, null);
|
||||
}
|
||||
catch (Exception exception) when (exception is HttpRequestException or OperationCanceledException or JsonException)
|
||||
{
|
||||
// A caller-cancelled request must not be cached as an unreachable runtime.
|
||||
if (ct.IsCancellationRequested) throw;
|
||||
status = new(false, false, null);
|
||||
}
|
||||
|
||||
lock (probeGate)
|
||||
{
|
||||
cachedStatus = status;
|
||||
cachedAt = now;
|
||||
}
|
||||
return status;
|
||||
|
||||
static NativeRemotePlayerRuntimeStatus Parse(string body)
|
||||
{
|
||||
using var document = JsonDocument.Parse(body);
|
||||
var root = document.RootElement;
|
||||
var running = root.TryGetProperty("running", out var value) && value.ValueKind == JsonValueKind.True;
|
||||
var saveMode = root.TryGetProperty("saveMode", out var mode) ? mode.GetString() : null;
|
||||
var saveData = root.TryGetProperty("saveData", out var data) && data.ValueKind == JsonValueKind.True;
|
||||
return new(true, running, saveMode, saveData);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the sidecar's current app-owned save directory as one bounded archive.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Native player saves live inside the sidecar's own volume, which the Ludarium container cannot
|
||||
/// read. Moving them over the existing authenticated control channel is what lets them become
|
||||
/// versioned, SHA-256 evidenced vault revisions like browser saves, instead of an opaque blob
|
||||
/// with no export and no restore point.
|
||||
/// </remarks>
|
||||
public async Task<Stream?> DownloadSaveDataAsync(CancellationToken ct)
|
||||
{
|
||||
if (!Configured) return null;
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "v1/save-data");
|
||||
request.Headers.Add("X-Ludarium-Control-Token", token);
|
||||
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
response.Dispose();
|
||||
return null;
|
||||
}
|
||||
return await response.Content.ReadAsStreamAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Push a stored revision back into the sidecar's save directory.</summary>
|
||||
public async Task<bool> UploadSaveDataAsync(Stream content, long length, CancellationToken ct)
|
||||
{
|
||||
if (!Configured) return false;
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "v1/save-data")
|
||||
{ Content = new StreamContent(content) };
|
||||
request.Content.Headers.ContentLength = length;
|
||||
request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/gzip");
|
||||
request.Headers.Add("X-Ludarium-Control-Token", token);
|
||||
using var response = await client.SendAsync(request, ct);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
private async Task SendAsync(string path, object body, CancellationToken ct)
|
||||
{
|
||||
if (!Configured) throw new InvalidOperationException($"The isolated {Player.Emulator} controller is not configured.");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, path)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json")
|
||||
};
|
||||
request.Headers.Add("X-Ludarium-Control-Token", token);
|
||||
using var response = await client.SendAsync(request, ct);
|
||||
if (!response.IsSuccessStatusCode) throw new NativeRemotePlayerControlException(Player);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NativeRemotePlayerForwarder : IDisposable
|
||||
{
|
||||
private static readonly ForwarderRequestConfig RequestConfig = new() { ActivityTimeout = TimeSpan.FromHours(12) };
|
||||
private readonly IHttpForwarder forwarder;
|
||||
private readonly HttpMessageInvoker client;
|
||||
private readonly Transformer transformer;
|
||||
|
||||
public NativeRemotePlayerForwarder(IHttpForwarder forwarder, NativeRemotePlayerProxyOptions options)
|
||||
{
|
||||
this.forwarder = forwarder;
|
||||
client = new HttpMessageInvoker(new SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
AutomaticDecompression = DecompressionMethods.None,
|
||||
EnableMultipleHttp2Connections = true,
|
||||
UseCookies = false,
|
||||
UseProxy = false,
|
||||
SslOptions = new System.Net.Security.SslClientAuthenticationOptions
|
||||
{
|
||||
RemoteCertificateValidationCallback = (_, certificate, _, _) => certificate is not null &&
|
||||
options.CertificateSha256 is not null &&
|
||||
CryptographicOperations.FixedTimeEquals(
|
||||
certificate.GetCertHash(HashAlgorithmName.SHA256), options.CertificateSha256)
|
||||
}
|
||||
});
|
||||
transformer = new(options.Username, options.Password);
|
||||
}
|
||||
|
||||
public ValueTask<ForwarderError> SendAsync(HttpContext context, NativeRemotePlayerProxyOptions options) =>
|
||||
forwarder.SendAsync(context, options.Destination!.ToString(), client, RequestConfig, transformer);
|
||||
|
||||
public void Dispose() => client.Dispose();
|
||||
|
||||
private sealed class Transformer : HttpTransformer
|
||||
{
|
||||
private readonly AuthenticationHeaderValue authorization;
|
||||
|
||||
public Transformer(string? username, string? password) => authorization = new("Basic",
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")));
|
||||
|
||||
public override async ValueTask TransformRequestAsync(HttpContext context, HttpRequestMessage request,
|
||||
string destinationPrefix, CancellationToken ct)
|
||||
{
|
||||
await base.TransformRequestAsync(context, request, destinationPrefix, ct);
|
||||
request.Headers.Host = null;
|
||||
request.Headers.Remove("Cookie");
|
||||
request.Headers.Authorization = authorization;
|
||||
}
|
||||
|
||||
public override async ValueTask<bool> TransformResponseAsync(HttpContext context,
|
||||
HttpResponseMessage? response, CancellationToken ct)
|
||||
{
|
||||
var body = await base.TransformResponseAsync(context, response, ct);
|
||||
context.Response.Headers.Remove("WWW-Authenticate");
|
||||
context.Response.Headers.Remove("X-Frame-Options");
|
||||
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
return body;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Everything one configured native remote player needs at request time.</summary>
|
||||
public sealed class NativeRemotePlayerRuntime(
|
||||
NativeRemotePlayer player,
|
||||
NativeRemotePlayerProxyOptions proxy,
|
||||
NativeRemotePlayerSessionStore sessions,
|
||||
NativeRemotePlayerControlClient control,
|
||||
Func<NativeRemotePlayerProxyOptions, NativeRemotePlayerForwarder> forwarderFactory) : IDisposable
|
||||
{
|
||||
private readonly Lock gate = new();
|
||||
private NativeRemotePlayerForwarder? forwarder;
|
||||
|
||||
public NativeRemotePlayer Player { get; } = player;
|
||||
public NativeRemotePlayerProxyOptions Proxy { get; } = proxy;
|
||||
public NativeRemotePlayerSessionStore Sessions { get; } = sessions;
|
||||
public NativeRemotePlayerControlClient Control { get; } = control;
|
||||
|
||||
/// <summary>Configuration is complete for both the embedded proxy and exact-title control.</summary>
|
||||
public bool Configured => Proxy.Configured && Control.Configured;
|
||||
|
||||
public NativeRemotePlayerForwarder Forwarder
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate) return forwarder ??= forwarderFactory(Proxy);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => forwarder?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>All native remote players this deployment knows about, configured or not.</summary>
|
||||
public sealed class NativeRemotePlayerRuntimes(IReadOnlyList<NativeRemotePlayerRuntime> runtimes) : IDisposable
|
||||
{
|
||||
public IReadOnlyList<NativeRemotePlayerRuntime> All { get; } = runtimes;
|
||||
|
||||
public NativeRemotePlayerRuntime this[string key] =>
|
||||
All.FirstOrDefault(runtime => runtime.Player.Key.Equals(key, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new KeyNotFoundException($"No native remote player is registered for '{key}'.");
|
||||
|
||||
public NativeRemotePlayerRuntime? ForPlatform(string? platform) =>
|
||||
platform is null ? null : All.FirstOrDefault(runtime => runtime.Player.Owns(platform));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var runtime in All) runtime.Dispose();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5135",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Microsoft.AspNetCore.OpenApi": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.11, )",
|
||||
"resolved": "10.0.11",
|
||||
"contentHash": "R/1EATnPLU+gRfB6lwVkMcymmyAY5ppBBdRN/5lhNEiT3xP1sWccuSFkU/f1lQvN/WgRq5Vn8AhCdE3fqgsL/w==",
|
||||
"dependencies": {
|
||||
"Microsoft.OpenApi": "[2.7.5, 3.0.0)"
|
||||
}
|
||||
},
|
||||
"Microsoft.OpenApi": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.11.0, )",
|
||||
"resolved": "2.11.0",
|
||||
"contentHash": "/ignjfdeKT2SGLIR7QEv19KnI0rvoxRG/TYDOZdK9EsWLjKK9IK8i1Mo5NRm9PRV3i64DzlTqnIflWvoyfljLg=="
|
||||
},
|
||||
"Yarp.ReverseProxy": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.3.0, )",
|
||||
"resolved": "2.3.0",
|
||||
"contentHash": "gxtkN3a+9biu9V9Zd5NaTO6VZWXAnS2mhQ0R/VXmSPoTuiQNZsakKikrKpDtKxrL5nUYzbRsHtl40WNq+ZBKKg==",
|
||||
"dependencies": {
|
||||
"System.IO.Hashing": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Npgsql": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.3",
|
||||
"contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w=="
|
||||
},
|
||||
"OpenMcdf": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.2.0",
|
||||
"contentHash": "n/iojS7V77YjM6IBbXaP0ZI8EELhJ2j6XRjx9NxMyUtjcM4a0yr0fWqO1y7lgd3CgYwnDugaKlO9c7Os8VxNtg=="
|
||||
},
|
||||
"SharpCompress": {
|
||||
"type": "Transitive",
|
||||
"resolved": "0.50.4",
|
||||
"contentHash": "/hxjUR7DEX6mky8/LQXyrnrKioOL6D6veAID1EZpro+q4s02x5dHEYBV3qjEN6lDYYilDoQQ76BcmQj+lRx51w=="
|
||||
},
|
||||
"System.IO.Hashing": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.11",
|
||||
"contentHash": "OzKDcIRkeNJeC8qAsbn8yJXnfTLP1dtkWILe+T56Gf/z+IkAASi7sMqLqJQat08j5z/mRN5xVtoAwbkMNMoBUQ=="
|
||||
},
|
||||
"ludarium.application": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Ludarium.Domain": "[1.0.0, )",
|
||||
"OpenMcdf": "[3.2.0, )",
|
||||
"SharpCompress": "[0.50.4, )",
|
||||
"System.IO.Hashing": "[10.0.11, )"
|
||||
}
|
||||
},
|
||||
"ludarium.domain": {
|
||||
"type": "Project"
|
||||
},
|
||||
"ludarium.infrastructure": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Ludarium.Application": "[1.0.0, )",
|
||||
"Ludarium.Domain": "[1.0.0, )",
|
||||
"Npgsql": "[10.0.3, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.IO.Hashing;
|
||||
using System.Reflection.Metadata;
|
||||
using System.Reflection.PortableExecutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Ludarium.Domain;
|
||||
using OpenMcdf;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public static partial class ArtifactAnalysis
|
||||
{
|
||||
private static readonly Dictionary<string, string> PlatformDirectories = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["pc"] = "windows",
|
||||
["windows"] = "windows",
|
||||
["nes"] = "nes",
|
||||
["snes"] = "snes",
|
||||
["n64"] = "n64",
|
||||
["gb"] = "gb",
|
||||
["gbc"] = "gbc",
|
||||
["gba"] = "gba",
|
||||
["nds"] = "nds",
|
||||
["3ds"] = "3ds",
|
||||
["ngc"] = "gamecube",
|
||||
["gamecube"] = "gamecube",
|
||||
["wii"] = "wii",
|
||||
["switch"] = "switch",
|
||||
["ps1"] = "psx",
|
||||
["psx"] = "psx",
|
||||
["ps2"] = "ps2",
|
||||
["ps3"] = "ps3",
|
||||
["ps4"] = "ps4",
|
||||
["ps5"] = "ps5",
|
||||
["psp"] = "psp",
|
||||
["psv"] = "psvita",
|
||||
["psvita"] = "psvita",
|
||||
["wiiu"] = "wiiu",
|
||||
["xbox"] = "xbox",
|
||||
["xbox360"] = "xbox360",
|
||||
// Sega directories vary widely between collections; every common spelling resolves to the
|
||||
// canonical platform so a hardware-named folder is never silently ignored.
|
||||
["genesis"] = "genesis",
|
||||
["megadrive"] = "genesis",
|
||||
["mega-drive"] = "genesis",
|
||||
["sega-genesis"] = "genesis",
|
||||
["segagenesis"] = "genesis",
|
||||
["md"] = "genesis",
|
||||
["sms"] = "mastersystem",
|
||||
["mastersystem"] = "mastersystem",
|
||||
["master-system"] = "mastersystem",
|
||||
["gg"] = "gamegear",
|
||||
["gamegear"] = "gamegear",
|
||||
["game-gear"] = "gamegear",
|
||||
["32x"] = "sega32x",
|
||||
["sega32x"] = "sega32x",
|
||||
["segacd"] = "segacd",
|
||||
["saturn"] = "saturn",
|
||||
["dreamcast"] = "dreamcast"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Platform values written by earlier Ludarium versions, mapped to their canonical name so a
|
||||
/// catalog scanned before the Sega platforms existed keeps resolving after an upgrade.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> PlatformAliases = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sega-genesis"] = "genesis",
|
||||
["megadrive"] = "genesis",
|
||||
["sms"] = "mastersystem",
|
||||
["gg"] = "gamegear"
|
||||
};
|
||||
|
||||
/// <summary>Resolve any historical or shorthand platform value to its canonical name.</summary>
|
||||
public static string? CanonicalPlatform(string? platform) => platform is null ? null
|
||||
: PlatformAliases.TryGetValue(platform, out var canonical) ? canonical : platform;
|
||||
|
||||
private static readonly Dictionary<string, (MediaType Type, string? Platform)> Extensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[".nes"] = (MediaType.Rom, "nes"),
|
||||
[".sfc"] = (MediaType.Rom, "snes"),
|
||||
[".smc"] = (MediaType.Rom, "snes"),
|
||||
[".z64"] = (MediaType.Rom, "n64"),
|
||||
[".n64"] = (MediaType.Rom, "n64"),
|
||||
[".v64"] = (MediaType.Rom, "n64"),
|
||||
[".gb"] = (MediaType.Rom, "gb"),
|
||||
[".gbc"] = (MediaType.Rom, "gbc"),
|
||||
[".gba"] = (MediaType.Rom, "gba"),
|
||||
[".nds"] = (MediaType.Rom, "nds"),
|
||||
[".cue"] = (MediaType.DiscDescriptor, null),
|
||||
[".m3u"] = (MediaType.DiscDescriptor, null),
|
||||
[".iso"] = (MediaType.DiscImage, null),
|
||||
[".chd"] = (MediaType.DiscImage, null),
|
||||
[".cso"] = (MediaType.DiscImage, null),
|
||||
[".isz"] = (MediaType.DiscImage, null),
|
||||
[".rvz"] = (MediaType.DiscImage, null),
|
||||
[".wbfs"] = (MediaType.DiscImage, null),
|
||||
// GameCube and Wii container formats the Dolphin player accepts. Without these a linked
|
||||
// .gcm/.gcz/.wia disc classified as Unknown and could never become a launch candidate.
|
||||
[".gcm"] = (MediaType.DiscImage, null),
|
||||
[".gcz"] = (MediaType.DiscImage, null),
|
||||
[".wia"] = (MediaType.DiscImage, null),
|
||||
[".wud"] = (MediaType.DiscImage, "wiiu"),
|
||||
[".wux"] = (MediaType.DiscImage, "wiiu"),
|
||||
[".gdi"] = (MediaType.DiscDescriptor, null),
|
||||
[".cdi"] = (MediaType.DiscImage, "dreamcast"),
|
||||
[".pbp"] = (MediaType.DiscImage, null),
|
||||
[".3ds"] = (MediaType.Rom, "3ds"),
|
||||
[".cia"] = (MediaType.Rom, "3ds"),
|
||||
[".vpk"] = (MediaType.Archive, "psvita"),
|
||||
[".md"] = (MediaType.Rom, "genesis"),
|
||||
[".gen"] = (MediaType.Rom, "genesis"),
|
||||
[".smd"] = (MediaType.Rom, "genesis"),
|
||||
[".32x"] = (MediaType.Rom, "sega32x"),
|
||||
[".sms"] = (MediaType.Rom, "mastersystem"),
|
||||
[".gg"] = (MediaType.Rom, "gamegear"),
|
||||
[".sg"] = (MediaType.Rom, "sg1000"),
|
||||
[".xci"] = (MediaType.DiscImage, "switch"),
|
||||
[".nsp"] = (MediaType.DiscImage, "switch"),
|
||||
[".zip"] = (MediaType.Archive, null),
|
||||
[".7z"] = (MediaType.Archive, null),
|
||||
[".exe"] = (MediaType.WindowsPackage, "windows"),
|
||||
[".msi"] = (MediaType.WindowsPackage, "windows"),
|
||||
[".msix"] = (MediaType.WindowsPackage, "windows"),
|
||||
[".appx"] = (MediaType.WindowsPackage, "windows"),
|
||||
[".pdf"] = (MediaType.Document, null),
|
||||
[".txt"] = (MediaType.Document, null)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Platforms whose dumps are raw disc tracks. A bare BIN or IMG is only a disc track inside one
|
||||
/// of these libraries; elsewhere it is far more likely to be an installer payload, so it stays
|
||||
/// unclassified rather than being promoted to a disc image on a guess.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> DiscTrackPlatforms = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"psx", "ps2", "segacd", "saturn", "dreamcast", "3do", "pcecd"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Every extension the identifier recognises, without its leading dot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A derived title has to strip the same set, and that list used to be maintained separately in
|
||||
/// SQL. It drifted: a Mega Drive ROM became "Sonic md" because the query had never heard of the
|
||||
/// extension the identifier reads a Mega Drive header from.
|
||||
/// </remarks>
|
||||
public static IReadOnlyCollection<string> KnownExtensions { get; } =
|
||||
Extensions.Keys.Select(key => key.TrimStart('.')).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static Classification Classify(string relativePath, ReadOnlySpan<byte> header, string? platformHint = null)
|
||||
{
|
||||
platformHint = CanonicalPlatform(platformHint);
|
||||
var ext = Path.GetExtension(relativePath);
|
||||
var evidence = new List<Evidence>();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
MediaType? signatureType = null;
|
||||
string? signature = null;
|
||||
if (header.StartsWith("MZ"u8)) { signatureType = MediaType.WindowsPackage; signature = "PE/MZ"; }
|
||||
else if (header.StartsWith("PK\x03\x04"u8)) { signatureType = MediaType.Archive; signature = "ZIP"; }
|
||||
else if (header.StartsWith("NES\x1A"u8)) { signatureType = MediaType.Rom; signature = "iNES"; }
|
||||
else if (header.StartsWith("MComprHD"u8)) { signatureType = MediaType.DiscImage; signature = "CHD"; }
|
||||
else if (header.StartsWith("CISO"u8)) { signatureType = MediaType.DiscImage; signature = "PSP CSO"; }
|
||||
else if (header.StartsWith("\0PBP"u8)) { signatureType = MediaType.DiscImage; signature = "PlayStation PBP"; }
|
||||
else if (header.StartsWith("PFS0"u8)) { signatureType = MediaType.DiscImage; signature = "Switch PFS0"; }
|
||||
else if (header.Length >= 0x104 && header.Slice(0x100, 4).SequenceEqual("HEAD"u8)) { signatureType = MediaType.DiscImage; signature = "Switch XCI"; }
|
||||
else if (header.Length >= 8 && header.Slice(4, 4).SequenceEqual(new byte[] { 0x24, 0xFF, 0xAE, 0x51 })) { signatureType = MediaType.Rom; signature = "GBA"; }
|
||||
else if (header.Length >= 4 && header[..4].SequenceEqual(new byte[] { 0x80, 0x37, 0x12, 0x40 })) { signatureType = MediaType.Rom; signature = "N64 big-endian"; }
|
||||
else if (header.Length >= 4 && header[..4].SequenceEqual(new byte[] { 0x37, 0x80, 0x40, 0x12 })) { signatureType = MediaType.Rom; signature = "N64 byte-swapped"; }
|
||||
else if (header.Length >= 4 && header[..4].SequenceEqual(new byte[] { 0x40, 0x12, 0x37, 0x80 })) { signatureType = MediaType.Rom; signature = "N64 little-endian"; }
|
||||
// Nintendo optical media carry an exact magic word. Without it a bare .iso can only be told
|
||||
// apart by its directory name, which is what left GameCube and Wii games unresolvable.
|
||||
else if (header.Length >= 0x20 && BinaryPrimitives.ReadUInt32BigEndian(header.Slice(0x18, 4)) == 0x5D1C9EA3)
|
||||
{ signatureType = MediaType.DiscImage; signature = "Wii disc"; }
|
||||
else if (header.Length >= 0x20 && BinaryPrimitives.ReadUInt32BigEndian(header.Slice(0x1C, 4)) == 0xC2339F3D)
|
||||
{ signatureType = MediaType.DiscImage; signature = "GameCube disc"; }
|
||||
else if (header.Length >= 0x108 && header.Slice(0x100, 4).SequenceEqual("SEGA"u8)) { signatureType = MediaType.Rom; signature = "Sega cartridge"; }
|
||||
// Master System and Game Gear share the "TMR SEGA" header. Its offset depends on ROM size,
|
||||
// and the region nibble at +0x0F is what separates the two systems.
|
||||
else if (TryFindSegaEightBitHeader(header, out var segaEightBit)) { signatureType = MediaType.Rom; signature = segaEightBit; }
|
||||
else if (header.Length >= 0x150 && header.Slice(0x104, 4).SequenceEqual(new byte[] { 0xCE, 0xED, 0x66, 0x66 })) { signatureType = MediaType.Rom; signature = "Game Boy cartridge"; }
|
||||
else if (header.Length >= 0x160 && header.Slice(0xC0, 4).SequenceEqual(new byte[] { 0x24, 0xFF, 0xAE, 0x51 })) { signatureType = MediaType.Rom; signature = "Nintendo DS cartridge"; }
|
||||
else if (TryFindSnesHeader(header, out _)) { signatureType = MediaType.Rom; signature = "SNES cartridge"; }
|
||||
if (signature is not null) evidence.Add(new("signature", signature, "builtin", "1", Confidence.Deterministic, now));
|
||||
if (signature is not null) AddRomSemanticEvidence(signature, header, evidence, now);
|
||||
// MediaType.Rom is the default enum value, so a missing extension must be detected by the
|
||||
// lookup result rather than by comparing the media type against default. Conflating the two
|
||||
// classified every ROM extension without a matching header signature as Unknown.
|
||||
var knownExtension = Extensions.TryGetValue(ext, out var byExtension);
|
||||
if (!knownExtension &&
|
||||
(ext.Equals(".bin", StringComparison.OrdinalIgnoreCase) || ext.Equals(".img", StringComparison.OrdinalIgnoreCase)) &&
|
||||
platformHint is not null && DiscTrackPlatforms.Contains(platformHint))
|
||||
{
|
||||
byExtension = (MediaType.DiscImage, platformHint);
|
||||
knownExtension = true;
|
||||
}
|
||||
if (knownExtension) evidence.Add(new("extension", ext.ToLowerInvariant(), "builtin", "1", Confidence.Medium, now));
|
||||
var extensionConflictsWithDirectory = signatureType is null && platformHint is not null && byExtension.Platform is not null &&
|
||||
!platformHint.Equals(byExtension.Platform, StringComparison.OrdinalIgnoreCase);
|
||||
if (extensionConflictsWithDirectory)
|
||||
evidence.Add(new("platform.conflict", $"directory:{platformHint};extension:{byExtension.Platform}", "builtin", "1", Confidence.High, now));
|
||||
var type = signatureType ?? (!knownExtension || extensionConflictsWithDirectory ? MediaType.Unknown : byExtension.Type);
|
||||
var signaturePlatform = signature switch
|
||||
{
|
||||
"iNES" => "nes",
|
||||
"GBA" => "gba",
|
||||
"N64 big-endian" or "N64 byte-swapped" or "N64 little-endian" => "n64",
|
||||
"Sega cartridge" => SegaCartridgePlatform(header),
|
||||
"Sega Master System cartridge" => "mastersystem",
|
||||
"Sega Game Gear cartridge" => "gamegear",
|
||||
"GameCube disc" => "gamecube",
|
||||
"Wii disc" => "wii",
|
||||
"Game Boy cartridge" => ext.Equals(".gbc", StringComparison.OrdinalIgnoreCase) ? "gbc" : "gb",
|
||||
"Nintendo DS cartridge" => "nds",
|
||||
"SNES cartridge" => "snes",
|
||||
"PSP CSO" => "psp",
|
||||
"Switch PFS0" or "Switch XCI" => "switch",
|
||||
_ => null
|
||||
};
|
||||
var platform = signaturePlatform ?? platformHint ?? byExtension.Platform;
|
||||
if (signaturePlatform is not null && platformHint is not null &&
|
||||
!signaturePlatform.Equals(platformHint, StringComparison.OrdinalIgnoreCase))
|
||||
evidence.Add(new("platform.conflict", $"directory:{platformHint};signature:{signaturePlatform}",
|
||||
"builtin", "1", Confidence.High, now));
|
||||
if (platformHint is not null)
|
||||
evidence.Add(new("directory.platform", platformHint, "configured-path", "1",
|
||||
signaturePlatform is null ? Confidence.High : Confidence.Medium, now));
|
||||
var confidence = signatureType is not null ? Confidence.Deterministic
|
||||
: extensionConflictsWithDirectory ? Confidence.Low
|
||||
: !knownExtension ? Confidence.None : Confidence.Medium;
|
||||
return new(type, platform, confidence, evidence, type != MediaType.Unknown, $"classify:{ext.TrimStart('.').ToLowerInvariant()}");
|
||||
}
|
||||
|
||||
private static void AddRomSemanticEvidence(string signature, ReadOnlySpan<byte> header, List<Evidence> evidence, DateTimeOffset observedAt)
|
||||
{
|
||||
void Add(string kind, string value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value)) evidence.Add(new($"rom.{kind}", value, "builtin-header", "1", Confidence.Deterministic, observedAt));
|
||||
}
|
||||
switch (signature)
|
||||
{
|
||||
case "iNES" when header.Length >= 16:
|
||||
Add("mapper", (((header[7] & 0xF0) | (header[6] >> 4))).ToString(CultureInfo.InvariantCulture));
|
||||
Add("prgBytes", (header[4] * 16 * 1024).ToString(CultureInfo.InvariantCulture));
|
||||
Add("chrBytes", (header[5] * 8 * 1024).ToString(CultureInfo.InvariantCulture));
|
||||
Add("mirroring", (header[6] & 0x08) != 0 ? "four-screen" : (header[6] & 1) != 0 ? "vertical" : "horizontal");
|
||||
break;
|
||||
case "GBA" when header.Length >= 0xBD:
|
||||
Add("title", HeaderText(header.Slice(0xA0, 12))); Add("gameCode", HeaderText(header.Slice(0xAC, 4)));
|
||||
Add("makerCode", HeaderText(header.Slice(0xB0, 2))); Add("revision", header[0xBC].ToString(CultureInfo.InvariantCulture));
|
||||
Add("region", NintendoRegion(header[0xAF]));
|
||||
break;
|
||||
case "Nintendo DS cartridge" when header.Length >= 0x1F:
|
||||
Add("title", HeaderText(header[..12])); Add("gameCode", HeaderText(header.Slice(12, 4)));
|
||||
Add("makerCode", HeaderText(header.Slice(16, 2))); Add("unitCode", header[18].ToString(CultureInfo.InvariantCulture));
|
||||
Add("revision", header[30].ToString(CultureInfo.InvariantCulture)); Add("region", NintendoRegion(header[15]));
|
||||
break;
|
||||
case "Game Boy cartridge" when header.Length >= 0x150:
|
||||
Add("title", HeaderText(header.Slice(0x134, 15))); Add("makerCode", HeaderText(header.Slice(0x144, 2)));
|
||||
Add("cartridgeType", $"0x{header[0x147]:x2}"); Add("revision", header[0x14C].ToString(CultureInfo.InvariantCulture));
|
||||
Add("region", header[0x14A] == 0 ? "japan" : "world");
|
||||
break;
|
||||
case "N64 big-endian" when header.Length >= 0x40:
|
||||
Add("title", HeaderText(header.Slice(0x20, 20))); Add("gameCode", HeaderText(header.Slice(0x3B, 4)));
|
||||
Add("region", NintendoRegion(header[0x3E]));
|
||||
break;
|
||||
case "Sega cartridge" when header.Length >= 0x200:
|
||||
Add("console", HeaderText(header.Slice(0x100, 16)));
|
||||
Add("domesticTitle", HeaderText(header.Slice(0x120, 48))); Add("internationalTitle", HeaderText(header.Slice(0x150, 48)));
|
||||
Add("serial", HeaderText(header.Slice(0x180, 14))); Add("region", HeaderText(header.Slice(0x1F0, 3)));
|
||||
break;
|
||||
case "Sega Master System cartridge" or "Sega Game Gear cartridge"
|
||||
when TryFindSegaEightBitHeader(header, out _) && TryFindSegaEightBitOffset(header, out var segaOffset):
|
||||
// "TMR SEGA" is followed by a reserved word, a checksum, a five-digit BCD product
|
||||
// code whose top digit shares a byte with the version, and the region/size byte.
|
||||
Add("checksum", $"{header[segaOffset + 0x0B]:x2}{header[segaOffset + 0x0A]:x2}");
|
||||
Add("productCode", $"{header[segaOffset + 0x0E] >> 4:x1}{header[segaOffset + 0x0D]:x2}{header[segaOffset + 0x0C]:x2}");
|
||||
Add("revision", (header[segaOffset + 0x0E] & 0x0F).ToString(CultureInfo.InvariantCulture));
|
||||
Add("region", SegaEightBitRegion(header[segaOffset + 0x0F] >> 4));
|
||||
break;
|
||||
case "GameCube disc" or "Wii disc" when header.Length >= 0x500:
|
||||
Add("gameCode", HeaderText(header[..4]));
|
||||
Add("makerCode", HeaderText(header.Slice(4, 2)));
|
||||
Add("discNumber", header[6].ToString(CultureInfo.InvariantCulture));
|
||||
Add("revision", header[7].ToString(CultureInfo.InvariantCulture));
|
||||
Add("title", HeaderText(header.Slice(0x20, 64)));
|
||||
Add("region", NintendoDiscRegion(header[3]));
|
||||
break;
|
||||
case "SNES cartridge" when TryFindSnesHeader(header, out var offset):
|
||||
Add("title", HeaderText(header.Slice(offset, 21))); Add("mapMode", $"0x{header[offset + 0x15]:x2}");
|
||||
Add("cartridgeType", $"0x{header[offset + 0x16]:x2}"); Add("region", SnesRegion(header[offset + 0x19]));
|
||||
Add("makerCode", $"0x{header[offset + 0x1A]:x2}"); Add("revision", header[offset + 0x1B].ToString(CultureInfo.InvariantCulture));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Separate a Mega Drive/Genesis cartridge from a 32X or Sega CD dump. All three carry "SEGA"
|
||||
/// at 0x100; the rest of the console name field names the actual hardware.
|
||||
/// </summary>
|
||||
private static string SegaCartridgePlatform(ReadOnlySpan<byte> header)
|
||||
{
|
||||
if (header.Length < 0x110) return "genesis";
|
||||
var console = HeaderText(header.Slice(0x100, 16)).ToUpperInvariant();
|
||||
if (console.Contains("32X", StringComparison.Ordinal)) return "sega32x";
|
||||
if (console.Contains("SEGACD", StringComparison.Ordinal) ||
|
||||
console.Contains("MEGA CD", StringComparison.Ordinal)) return "segacd";
|
||||
return "genesis";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locate the "TMR SEGA" header an 8-bit Sega ROM places near the end of its first bank. The
|
||||
/// region nibble in the byte after the header separates Master System from Game Gear.
|
||||
/// </summary>
|
||||
private static bool TryFindSegaEightBitOffset(ReadOnlySpan<byte> header, out int offset)
|
||||
{
|
||||
foreach (var candidate in new[] { 0x7FF0, 0x3FF0, 0x1FF0 })
|
||||
if (candidate + 0x10 <= header.Length && header.Slice(candidate, 8).SequenceEqual("TMR SEGA"u8))
|
||||
{ offset = candidate; return true; }
|
||||
offset = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string SegaEightBitRegion(int region) => region switch
|
||||
{
|
||||
0x3 => "japan",
|
||||
0x4 => "export",
|
||||
0x5 => "japan",
|
||||
0x6 => "export",
|
||||
0x7 => "international",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
/// <summary>The fourth character of a GameCube or Wii game code is its region.</summary>
|
||||
private static string NintendoDiscRegion(byte code) => (char)code switch
|
||||
{
|
||||
'E' => "usa",
|
||||
'J' => "japan",
|
||||
'P' => "europe",
|
||||
'D' => "germany",
|
||||
'F' => "france",
|
||||
'I' => "italy",
|
||||
'S' => "spain",
|
||||
'K' or 'T' => "korea",
|
||||
'U' => "australia",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
private static bool TryFindSegaEightBitHeader(ReadOnlySpan<byte> header, out string signature)
|
||||
{
|
||||
foreach (var candidate in new[] { 0x7FF0, 0x3FF0, 0x1FF0 })
|
||||
{
|
||||
if (candidate + 0x10 > header.Length) continue;
|
||||
if (!header.Slice(candidate, 8).SequenceEqual("TMR SEGA"u8)) continue;
|
||||
var region = header[candidate + 0x0F] >> 4;
|
||||
signature = region is 0x5 or 0x6 or 0x7
|
||||
? "Sega Game Gear cartridge"
|
||||
: "Sega Master System cartridge";
|
||||
return true;
|
||||
}
|
||||
signature = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryFindSnesHeader(ReadOnlySpan<byte> header, out int offset)
|
||||
{
|
||||
foreach (var candidate in new[] { 0x7FC0, 0x81C0, 0xFFC0, 0x101C0 })
|
||||
{
|
||||
if (candidate + 0x20 > header.Length) continue;
|
||||
var mapMode = header[candidate + 0x15] & 0x3f;
|
||||
var complement = BinaryPrimitives.ReadUInt16LittleEndian(header.Slice(candidate + 0x1C, 2));
|
||||
var checksum = BinaryPrimitives.ReadUInt16LittleEndian(header.Slice(candidate + 0x1E, 2));
|
||||
if (mapMode is 0x20 or 0x21 or 0x22 or 0x23 or 0x25 or 0x30 or 0x31 or 0x32 or 0x35 &&
|
||||
checksum is not 0 and not 0xffff && (checksum ^ complement) == 0xffff)
|
||||
{ offset = candidate; return true; }
|
||||
}
|
||||
offset = -1;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string HeaderText(ReadOnlySpan<byte> bytes) =>
|
||||
new string(Encoding.ASCII.GetString(bytes).Where(character => character is >= ' ' and not '\u007f').ToArray()).Trim(' ', '\xff');
|
||||
private static string NintendoRegion(byte code) => char.ToUpperInvariant((char)code) switch
|
||||
{
|
||||
'J' => "japan",
|
||||
'E' => "usa",
|
||||
'P' or 'D' or 'F' or 'I' or 'S' or 'H' or 'X' or 'Y' => "europe",
|
||||
'K' => "korea",
|
||||
'C' => "china",
|
||||
'A' => "world",
|
||||
_ => "unknown"
|
||||
};
|
||||
private static string SnesRegion(byte code) => code switch
|
||||
{
|
||||
0 => "japan",
|
||||
1 or 13 or 15 => "usa",
|
||||
2 or 3 or 6 or 7 or 8 or 9 or 10 or 11 => "europe",
|
||||
4 => "scandinavia",
|
||||
5 => "sweden",
|
||||
12 => "canada",
|
||||
14 => "korea",
|
||||
16 => "brazil",
|
||||
17 => "australia",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
public static string? InferPlatformHint(string libraryName, string libraryPath, string relativePath)
|
||||
{
|
||||
foreach (var candidate in new[] { Path.GetFileName(Path.TrimEndingDirectorySeparator(libraryPath)), libraryName })
|
||||
if (candidate is not null && PlatformDirectories.TryGetValue(NormalizePlatformDirectory(candidate), out var rootPlatform))
|
||||
return rootPlatform;
|
||||
|
||||
var segments = relativePath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length == 0) return null;
|
||||
var platformIndex = segments[0].Equals("roms", StringComparison.OrdinalIgnoreCase) ? 1 : 0;
|
||||
return platformIndex < segments.Length && PlatformDirectories.TryGetValue(NormalizePlatformDirectory(segments[platformIndex]), out var platform)
|
||||
? platform : null;
|
||||
}
|
||||
|
||||
private static string NormalizePlatformDirectory(string value) => value.Trim().Replace("-Games", string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static async Task<HashResult> HashAsync(Stream stream, bool compatibilityHashes, CancellationToken cancellationToken)
|
||||
{
|
||||
using var sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
using var sha1 = IncrementalHash.CreateHash(HashAlgorithmName.SHA1);
|
||||
var crc = new Crc32();
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(1024 * 1024);
|
||||
long total = 0;
|
||||
try
|
||||
{
|
||||
int read;
|
||||
while ((read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0)
|
||||
{
|
||||
sha256.AppendData(buffer, 0, read);
|
||||
if (compatibilityHashes) { sha1.AppendData(buffer, 0, read); crc.Append(buffer.AsSpan(0, read)); }
|
||||
total += read;
|
||||
}
|
||||
return new(Convert.ToHexString(sha256.GetHashAndReset()).ToLowerInvariant(),
|
||||
compatibilityHashes ? Convert.ToHexString(sha1.GetHashAndReset()).ToLowerInvariant() : string.Empty,
|
||||
compatibilityHashes ? Convert.ToHexString(crc.GetCurrentHash()).ToLowerInvariant() : string.Empty, total);
|
||||
}
|
||||
finally { ArrayPool<byte>.Shared.Return(buffer); }
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> ParseCueReferences(string text) => CueFileRegex().Matches(text)
|
||||
.Select(m => m.Groups[1].Success ? m.Groups[1].Value : m.Groups[2].Value).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray();
|
||||
|
||||
public static IReadOnlyList<string> ParseM3uReferences(string text) => text.Replace("\r", string.Empty, StringComparison.Ordinal)
|
||||
.Split('\n').Select(x => x.Trim()).Where(x => x.Length > 0 && !x.StartsWith('#')).ToArray();
|
||||
|
||||
public static bool IsSafeRelativeReference(string value) => !string.IsNullOrWhiteSpace(value) && value[0] is not '/' and not '\\' && !Path.IsPathFullyQualified(value) &&
|
||||
!value.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Any(x => x == "..");
|
||||
|
||||
[GeneratedRegex("(?im)^\\s*FILE\\s+(?:\"([^\"]+)\"|(\\S+))")]
|
||||
private static partial Regex CueFileRegex();
|
||||
}
|
||||
|
||||
public sealed record PeInspection(string Machine, string Kind, bool IsDll, bool IsManaged,
|
||||
bool HasResources, string? Subsystem, IReadOnlyDictionary<string, string> VersionInfo,
|
||||
Confidence Confidence, IReadOnlyList<Evidence> Evidence);
|
||||
|
||||
public static partial class WindowsPackageAnalysis
|
||||
{
|
||||
public static IReadOnlyDictionary<string, string> InspectMsiSummary(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path)) throw new ArgumentException("MSI path must be absolute.", nameof(path));
|
||||
using var root = RootStorage.OpenRead(path);
|
||||
using var summary = root.OpenStream("\u0005SummaryInformation");
|
||||
if (summary.Length > 1024 * 1024) throw new InvalidDataException("MSI summary information exceeds the configured limit.");
|
||||
var bytes = new byte[summary.Length];
|
||||
summary.ReadExactly(bytes);
|
||||
return ParsePropertySet(bytes);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParsePropertySet(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length < 48 || BinaryPrimitives.ReadUInt16LittleEndian(bytes) != 0xfffe)
|
||||
throw new InvalidDataException("MSI summary information has an invalid property-set header.");
|
||||
var sectionOffset = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(44, 4));
|
||||
if (sectionOffset < 0 || sectionOffset + 8 > bytes.Length) throw new InvalidDataException("MSI summary section is out of bounds.");
|
||||
var propertyCount = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(sectionOffset + 4, 4));
|
||||
if (propertyCount is < 0 or > 256 || sectionOffset + 8 + propertyCount * 8 > bytes.Length)
|
||||
throw new InvalidDataException("MSI summary property table exceeds its bounds.");
|
||||
var names = new Dictionary<int, string>
|
||||
{
|
||||
[2] = "Title",
|
||||
[3] = "Subject",
|
||||
[4] = "Author",
|
||||
[5] = "Keywords",
|
||||
[6] = "Comments",
|
||||
[7] = "Template",
|
||||
[8] = "LastSavedBy",
|
||||
[9] = "PackageCode",
|
||||
[18] = "CreatingApplication"
|
||||
};
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
for (var index = 0; index < propertyCount; index++)
|
||||
{
|
||||
var entry = sectionOffset + 8 + index * 8;
|
||||
var propertyId = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(entry, 4));
|
||||
if (!names.TryGetValue(propertyId, out var name)) continue;
|
||||
var valueOffset = sectionOffset + BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(entry + 4, 4));
|
||||
if (valueOffset < 0 || valueOffset + 8 > bytes.Length) continue;
|
||||
var type = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(valueOffset, 4));
|
||||
var length = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(valueOffset + 4, 4));
|
||||
if (length is <= 0 or > 65536) continue;
|
||||
string? value = type switch
|
||||
{
|
||||
30 when valueOffset + 8 + length <= bytes.Length => Encoding.Latin1.GetString(bytes.Slice(valueOffset + 8, length)).TrimEnd('\0'),
|
||||
31 when valueOffset + 8 + length * 2 <= bytes.Length => Encoding.Unicode.GetString(bytes.Slice(valueOffset + 8, length * 2)).TrimEnd('\0'),
|
||||
_ => null
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(value)) result[name] = value;
|
||||
}
|
||||
if (result.TryGetValue("Template", out var template))
|
||||
{
|
||||
var parts = template.Split(';', 2);
|
||||
if (parts[0].Length > 0) result["Architecture"] = parts[0];
|
||||
if (parts.Length > 1 && parts[1].Length > 0) result["Language"] = parts[1];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static PeInspection InspectPe(Stream stream)
|
||||
{
|
||||
if (!stream.CanSeek) throw new ArgumentException("PE inspection requires a bounded seekable stream.", nameof(stream));
|
||||
var original = stream.Position;
|
||||
try
|
||||
{
|
||||
using var reader = new PEReader(stream, PEStreamOptions.LeaveOpen);
|
||||
if (!reader.HasMetadata && reader.PEHeaders.PEHeader is null) throw new BadImageFormatException("Not a valid PE image.");
|
||||
var coff = reader.PEHeaders.CoffHeader;
|
||||
var pe = reader.PEHeaders.PEHeader;
|
||||
var machine = coff.Machine switch
|
||||
{
|
||||
Machine.I386 => "x86",
|
||||
Machine.Amd64 => "x64",
|
||||
Machine.Arm => "arm",
|
||||
Machine.Arm64 => "arm64",
|
||||
Machine.IA64 => "ia64",
|
||||
_ => $"unknown-0x{(ushort)coff.Machine:x4}"
|
||||
};
|
||||
var isDll = (coff.Characteristics & Characteristics.Dll) != 0;
|
||||
var evidence = new List<Evidence>
|
||||
{
|
||||
new("pe.machine", machine, "pe-reader", "1", Confidence.Deterministic, DateTimeOffset.UtcNow),
|
||||
new("pe.kind", isDll ? "library" : "executable", "pe-reader", "1", Confidence.Deterministic, DateTimeOffset.UtcNow)
|
||||
};
|
||||
if (pe is not null) evidence.Add(new("pe.subsystem", pe.Subsystem.ToString(), "pe-reader", "1", Confidence.Deterministic, DateTimeOffset.UtcNow));
|
||||
var versionInfo = ReadVersionInfo(reader);
|
||||
foreach (var (key, value) in versionInfo)
|
||||
evidence.Add(new($"pe.version.{key}", value, "pe-version-resource", "1", Confidence.Deterministic, DateTimeOffset.UtcNow));
|
||||
return new(machine, isDll ? "library" : "executable", isDll, reader.HasMetadata,
|
||||
pe?.ResourceTableDirectory.Size > 0, pe?.Subsystem.ToString(), versionInfo, Confidence.Deterministic, evidence);
|
||||
}
|
||||
finally { stream.Position = original; }
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ReadVersionInfo(PEReader reader)
|
||||
{
|
||||
const int maxResourceBytes = 4 * 1024 * 1024;
|
||||
var directory = reader.PEHeaders.PEHeader?.ResourceTableDirectory;
|
||||
if (directory is null || directory.Value.RelativeVirtualAddress == 0 || directory.Value.Size < 16) return new Dictionary<string, string>();
|
||||
try
|
||||
{
|
||||
var rootRva = directory.Value.RelativeVirtualAddress;
|
||||
var resource = reader.GetSectionData(rootRva).GetContent(0, Math.Min(directory.Value.Size, maxResourceBytes));
|
||||
var bytes = resource.AsSpan();
|
||||
var typeDirectory = FindDirectoryEntry(bytes, 0, 16);
|
||||
if (typeDirectory < 0) return new Dictionary<string, string>();
|
||||
var nameDirectory = FirstDirectoryChild(bytes, typeDirectory);
|
||||
var dataEntry = nameDirectory < 0 ? -1 : FirstDataChild(bytes, nameDirectory);
|
||||
if (dataEntry < 0 || dataEntry + 16 > bytes.Length) return new Dictionary<string, string>();
|
||||
var dataRva = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(dataEntry, 4));
|
||||
var size = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(dataEntry + 4, 4));
|
||||
if (size <= 0 || size > maxResourceBytes) return new Dictionary<string, string>();
|
||||
var versionBytes = reader.GetSectionData(dataRva).GetContent(0, size).AsSpan();
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var key in new[] { "ProductName", "ProductVersion", "FileVersion", "CompanyName", "FileDescription", "InternalName", "OriginalFilename" })
|
||||
if (ReadVersionString(versionBytes, key) is { Length: > 0 } value) result[key] = value;
|
||||
return result;
|
||||
}
|
||||
catch (BadImageFormatException) { return new Dictionary<string, string>(); }
|
||||
catch (ArgumentOutOfRangeException) { return new Dictionary<string, string>(); }
|
||||
}
|
||||
|
||||
private static int FindDirectoryEntry(ReadOnlySpan<byte> bytes, int directoryOffset, int id)
|
||||
{
|
||||
if (directoryOffset < 0 || directoryOffset + 16 > bytes.Length) return -1;
|
||||
var count = BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(directoryOffset + 12, 2)) +
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(directoryOffset + 14, 2));
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
var entry = directoryOffset + 16 + index * 8;
|
||||
if (entry + 8 > bytes.Length) return -1;
|
||||
var name = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(entry, 4));
|
||||
if ((name & 0x80000000) == 0 && name == id)
|
||||
return (int)(BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(entry + 4, 4)) & 0x7fffffff);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int FirstDirectoryChild(ReadOnlySpan<byte> bytes, int directoryOffset) => FirstChild(bytes, directoryOffset, true);
|
||||
private static int FirstDataChild(ReadOnlySpan<byte> bytes, int directoryOffset) => FirstChild(bytes, directoryOffset, false);
|
||||
private static int FirstChild(ReadOnlySpan<byte> bytes, int directoryOffset, bool directory)
|
||||
{
|
||||
if (directoryOffset < 0 || directoryOffset + 24 > bytes.Length) return -1;
|
||||
var count = BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(directoryOffset + 12, 2)) +
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(directoryOffset + 14, 2));
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
var entry = directoryOffset + 16 + index * 8;
|
||||
if (entry + 8 > bytes.Length) return -1;
|
||||
var offset = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(entry + 4, 4));
|
||||
if (((offset & 0x80000000) != 0) == directory) return (int)(offset & 0x7fffffff);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static string? ReadVersionString(ReadOnlySpan<byte> bytes, string key)
|
||||
{
|
||||
var keyBytes = Encoding.Unicode.GetBytes(key + '\0');
|
||||
var keyOffset = bytes.IndexOf(keyBytes);
|
||||
if (keyOffset < 6) return null;
|
||||
var valueCharacters = BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(keyOffset - 4, 2));
|
||||
var valueOffset = (keyOffset + keyBytes.Length + 3) & ~3;
|
||||
var valueBytes = Math.Min(valueCharacters * 2, bytes.Length - valueOffset);
|
||||
if (valueOffset < 0 || valueBytes <= 0) return null;
|
||||
return Encoding.Unicode.GetString(bytes.Slice(valueOffset, valueBytes)).TrimEnd('\0').Trim();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<int> MissingNumberedPayloads(IEnumerable<string> fileNames)
|
||||
{
|
||||
var numbers = fileNames.Select(Path.GetFileName).Select(x => NumberedPayloadRegex().Match(x ?? string.Empty))
|
||||
.Where(x => x.Success).Select(x => int.Parse(x.Groups[1].Value, CultureInfo.InvariantCulture)).Distinct().Order().ToArray();
|
||||
if (numbers.Length < 2) return [];
|
||||
return Enumerable.Range(numbers[0], numbers[^1] - numbers[0] + 1).Except(numbers).ToArray();
|
||||
}
|
||||
|
||||
[GeneratedRegex("(?:^|[-_.])(\\d{1,4})\\.(?:bin|cab|dat)$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex NumberedPayloadRegex();
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Ludarium.Domain;
|
||||
using SharpCompress.Archives;
|
||||
using SharpCompress.Archives.SevenZip;
|
||||
using SharpCompress.Common;
|
||||
using SharpCompress.Readers;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public sealed record ArchiveLimits(int MaxMembers = 10_000, long MaxMetadataBytes = 4 * 1024 * 1024,
|
||||
long MaxExpandedBytes = 8L * 1024 * 1024 * 1024, decimal MaxExpansionRatio = 1_000,
|
||||
int MaxPathLength = 1_024);
|
||||
|
||||
public sealed record ArchiveInspection(IReadOnlyList<ArchiveMember> Members,
|
||||
IReadOnlyList<string> Findings, bool Complete);
|
||||
|
||||
public static class ArchiveAnalysis
|
||||
{
|
||||
public static ArchiveInspection InspectSevenZip(Guid artifactId, Stream stream, ArchiveLimits? limits = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
limits ??= new ArchiveLimits();
|
||||
if (!stream.CanRead || !stream.CanSeek) throw new ArgumentException("7z inspection requires a readable, seekable stream.", nameof(stream));
|
||||
stream.Position = 0;
|
||||
using var archive = SevenZipArchive.OpenArchive(stream, new ReaderOptions { LeaveStreamOpen = true });
|
||||
return InspectEntries(artifactId, archive.Entries.Select(entry => new MemberHeader(
|
||||
entry.Key ?? string.Empty, entry.CompressedSize, entry.Size, entry.IsDirectory)), limits, cancellationToken);
|
||||
}
|
||||
|
||||
public static ArchiveInspection InspectZip(Guid artifactId, Stream stream, ArchiveLimits? limits = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
limits ??= new ArchiveLimits();
|
||||
if (!stream.CanRead || !stream.CanSeek) throw new ArgumentException("ZIP inspection requires a readable, seekable stream.", nameof(stream));
|
||||
stream.Position = 0;
|
||||
using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
return InspectEntries(artifactId, archive.Entries.Select(entry => new MemberHeader(
|
||||
entry.FullName, entry.CompressedLength, entry.Length, entry.FullName.EndsWith('/'))), limits, cancellationToken);
|
||||
}
|
||||
|
||||
private static ArchiveInspection InspectEntries(Guid artifactId, IEnumerable<MemberHeader> entries,
|
||||
ArchiveLimits limits, CancellationToken cancellationToken)
|
||||
{
|
||||
var members = new List<ArchiveMember>();
|
||||
var findings = new List<string>();
|
||||
long metadataBytes = 0;
|
||||
long expandedBytes = 0;
|
||||
long compressedBytes = 0;
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (members.Count >= limits.MaxMembers)
|
||||
{
|
||||
findings.Add($"Archive member limit of {limits.MaxMembers} was reached.");
|
||||
break;
|
||||
}
|
||||
|
||||
metadataBytes = checked(metadataBytes + Encoding.UTF8.GetByteCount(entry.Path));
|
||||
if (metadataBytes > limits.MaxMetadataBytes)
|
||||
{
|
||||
findings.Add("Archive metadata exceeds the configured byte limit.");
|
||||
break;
|
||||
}
|
||||
if (entry.Path.Length > limits.MaxPathLength)
|
||||
{
|
||||
findings.Add($"Archive member path exceeds {limits.MaxPathLength} characters.");
|
||||
continue;
|
||||
}
|
||||
|
||||
expandedBytes = checked(expandedBytes + entry.UncompressedSize);
|
||||
compressedBytes = checked(compressedBytes + entry.CompressedSize);
|
||||
if (expandedBytes > limits.MaxExpandedBytes)
|
||||
{
|
||||
findings.Add("Declared expanded archive size exceeds the configured limit.");
|
||||
break;
|
||||
}
|
||||
// Solid formats such as 7z may not expose a meaningful compressed size per member.
|
||||
// Apply the ratio gate only when the archive supplied that evidence; the absolute
|
||||
// expanded-size and member-count limits still bound header-only inspection.
|
||||
if (expandedBytes > 0 && compressedBytes > 0 && (decimal)expandedBytes / compressedBytes > limits.MaxExpansionRatio)
|
||||
{
|
||||
findings.Add("Declared archive expansion ratio exceeds the configured limit.");
|
||||
break;
|
||||
}
|
||||
|
||||
var normalized = entry.Path.Replace('\\', '/');
|
||||
var unsafePath = IsUnsafeMemberPath(normalized);
|
||||
if (unsafePath) findings.Add("Archive contains a member path that would escape an extraction root; it was recorded but never extracted.");
|
||||
var sequence = members.Count;
|
||||
var memberId = new Guid(SHA256.HashData(Encoding.UTF8.GetBytes($"{artifactId:N}|{sequence}|{normalized}"))[..16]);
|
||||
members.Add(new(memberId, artifactId, sequence, normalized, entry.CompressedSize, entry.UncompressedSize,
|
||||
entry.IsDirectory, unsafePath, null));
|
||||
}
|
||||
|
||||
return new(members, findings.Distinct(StringComparer.Ordinal).ToArray(), findings.Count == 0);
|
||||
}
|
||||
|
||||
private static bool IsUnsafeMemberPath(string path) =>
|
||||
Path.IsPathFullyQualified(path) || path.Split('/', StringSplitOptions.RemoveEmptyEntries).Any(x => x == "..");
|
||||
|
||||
private sealed record MemberHeader(string Path, long CompressedSize, long UncompressedSize, bool IsDirectory);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public static class ArtworkQuality
|
||||
{
|
||||
public const decimal VerifiedThreshold = 80m;
|
||||
|
||||
// Width / height of the platform's canonical retail front. Keep this list in canonical-id order
|
||||
// with PostgresStore.BuiltInPlatforms: the web stylesheet has a deterministic parity test that
|
||||
// compares both surfaces. Platforms without a stable physical format use an explicit 2:3
|
||||
// archival-poster profile instead of silently falling through to an unrelated case family.
|
||||
private static readonly Dictionary<string, decimal> RetailRatios =
|
||||
new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["windows"] = 5m / 7m,
|
||||
["dos"] = 4m / 5m,
|
||||
["linux"] = 2m / 3m,
|
||||
["macintosh"] = 4m / 5m,
|
||||
["amiga"] = 4m / 5m,
|
||||
["amiga-cd32"] = 1m / 1m,
|
||||
["c64"] = 4m / 5m,
|
||||
["zx-spectrum"] = 4m / 5m,
|
||||
["atari-st"] = 4m / 5m,
|
||||
["pc-98"] = 5m / 7m,
|
||||
["flash"] = 2m / 3m,
|
||||
["scummvm"] = 4m / 5m,
|
||||
["nes"] = 5m / 7m,
|
||||
["fds"] = 3m / 4m,
|
||||
["snes"] = 7m / 5m,
|
||||
["n64"] = 7m / 5m,
|
||||
["n64dd"] = 129m / 112m,
|
||||
["gamecube"] = 5m / 7m,
|
||||
["wii"] = 5m / 7m,
|
||||
["wii-u"] = 5m / 7m,
|
||||
["switch"] = 5m / 8m,
|
||||
["gb"] = 1m / 1m,
|
||||
["gbc"] = 1m / 1m,
|
||||
["gba"] = 1m / 1m,
|
||||
["nds"] = 135m / 122m,
|
||||
["3ds"] = 130m / 116m,
|
||||
["virtual-boy"] = 1m / 1m,
|
||||
["pokemon-mini"] = 1m / 1m,
|
||||
["psx"] = 1m / 1m,
|
||||
["ps2"] = 5m / 7m,
|
||||
["ps3"] = 27m / 34m,
|
||||
["ps4"] = 27m / 34m,
|
||||
["ps5"] = 27m / 34m,
|
||||
["psp"] = 3m / 5m,
|
||||
["psvita"] = 25m / 32m,
|
||||
["xbox"] = 5m / 7m,
|
||||
["xbox-360"] = 5m / 7m,
|
||||
["xbox-one"] = 27m / 34m,
|
||||
["xbox-series"] = 27m / 34m,
|
||||
["master-system"] = 5m / 7m,
|
||||
["genesis"] = 5m / 7m,
|
||||
["sega-cd"] = 5m / 7m,
|
||||
["sega-32x"] = 5m / 7m,
|
||||
["saturn"] = 5m / 7m,
|
||||
["dreamcast"] = 1m / 1m,
|
||||
["game-gear"] = 5m / 7m,
|
||||
["sg-1000"] = 5m / 7m,
|
||||
["atari-2600"] = 5m / 7m,
|
||||
["atari-5200"] = 5m / 7m,
|
||||
["atari-7800"] = 5m / 7m,
|
||||
["atari-jaguar"] = 5m / 7m,
|
||||
["atari-lynx"] = 13m / 16m,
|
||||
["neo-geo"] = 4m / 5m,
|
||||
["neo-geo-cd"] = 1m / 1m,
|
||||
["neo-geo-pocket"] = 7m / 8m,
|
||||
["neo-geo-pocket-color"] = 1m / 1m,
|
||||
["pc-engine"] = 1m / 1m,
|
||||
["pc-engine-cd"] = 1m / 1m,
|
||||
["pc-fx"] = 1m / 1m,
|
||||
["3do"] = 1m / 1m,
|
||||
["colecovision"] = 5m / 7m,
|
||||
["intellivision"] = 5m / 7m,
|
||||
["odyssey2"] = 5m / 7m,
|
||||
["vectrex"] = 5m / 7m,
|
||||
["wonderswan"] = 4m / 5m,
|
||||
["wonderswan-color"] = 2m / 3m,
|
||||
["arcade"] = 2m / 3m,
|
||||
["pico-8"] = 2m / 3m
|
||||
};
|
||||
|
||||
// Historical scan output predates the hyphenated ids in the built-in catalog. It remains
|
||||
// readable, but aliases do not inflate the canonical profile matrix or its coverage count.
|
||||
private static readonly Dictionary<string, string> RetailRatioAliases =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["wiiu"] = "wii-u",
|
||||
["xbox360"] = "xbox-360",
|
||||
["xboxone"] = "xbox-one",
|
||||
["xboxseries"] = "xbox-series",
|
||||
["mastersystem"] = "master-system",
|
||||
["gamegear"] = "game-gear",
|
||||
["sega32x"] = "sega-32x",
|
||||
["segacd"] = "sega-cd"
|
||||
};
|
||||
|
||||
public static IReadOnlyDictionary<string, decimal> RetailProfileRatios { get; } =
|
||||
new ReadOnlyDictionary<string, decimal>(RetailRatios);
|
||||
|
||||
public static decimal Score(int? width, int? height, decimal? ratio, string source, Confidence confidence,
|
||||
string? platform = null)
|
||||
{
|
||||
if (width is null || height is null || width <= 0 || height <= 0) return 0;
|
||||
// Steam library art is digital key art, not a photographed/scanned retail box front.
|
||||
// It remains useful provenance, but must never satisfy Ludarium's retail-cover contract.
|
||||
if (source.Equals("Steam", StringComparison.OrdinalIgnoreCase)) return 0;
|
||||
ratio ??= (decimal)width.Value / height.Value;
|
||||
var expected = source.Equals("SteamDigital", StringComparison.OrdinalIgnoreCase)
|
||||
? 2m / 3m
|
||||
: ExpectedRatio(platform);
|
||||
var deviation = Math.Abs(ratio.Value - expected) / expected;
|
||||
decimal score = 0;
|
||||
if (deviation <= 0.06m) score += 50;
|
||||
else if (deviation <= 0.12m) score += 20;
|
||||
else return source.Equals("Uploaded", StringComparison.OrdinalIgnoreCase) ? 35 : 0;
|
||||
if (width >= 500) score += 20;
|
||||
else if (width >= 300) score += 10;
|
||||
if (height >= 700) score += 20;
|
||||
else if (height >= 450) score += 10;
|
||||
score += confidence switch { Confidence.Deterministic => 15, Confidence.High => 12, Confidence.Medium => 6, _ => 0 };
|
||||
if (source.Equals("Generated", StringComparison.OrdinalIgnoreCase)) score = Math.Min(score, 25);
|
||||
if (source.Equals("Library", StringComparison.OrdinalIgnoreCase) && deviation > 0.06m) score = Math.Min(score, 45);
|
||||
return Math.Min(score, 100);
|
||||
}
|
||||
|
||||
public static decimal ExpectedRatio(string? platform)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(platform)) return 2m / 3m;
|
||||
var id = platform.Trim();
|
||||
if (RetailRatioAliases.TryGetValue(id, out var canonical)) id = canonical;
|
||||
return RetailRatios.TryGetValue(id, out var ratio) ? ratio : 2m / 3m;
|
||||
}
|
||||
|
||||
public static bool MatchesRetailProfile(GameArtwork artwork, string? platform) =>
|
||||
!artwork.Source.Equals("Steam", StringComparison.OrdinalIgnoreCase) &&
|
||||
(artwork.Source.Equals("Uploaded", StringComparison.OrdinalIgnoreCase) ||
|
||||
artwork.AspectRatio is { } ratio && Math.Abs(ratio - ExpectedRatio(platform)) / ExpectedRatio(platform) <= 0.12m);
|
||||
|
||||
public static GameArtwork EvaluateForPlatform(GameArtwork artwork, string? platform)
|
||||
{
|
||||
if (artwork.Source.Equals("Uploaded", StringComparison.OrdinalIgnoreCase)) return artwork;
|
||||
var score = Score(artwork.Width, artwork.Height, artwork.AspectRatio, artwork.Source,
|
||||
artwork.Confidence, platform);
|
||||
var verified = score >= VerifiedThreshold;
|
||||
return artwork with
|
||||
{
|
||||
Platform = platform,
|
||||
QualityScore = score,
|
||||
Role = artwork.Source.Equals("SteamDigital", StringComparison.OrdinalIgnoreCase)
|
||||
? ArtworkRole.Poster
|
||||
: verified ? ArtworkRole.VerifiedBoxFront : ArtworkRole.BoxFront,
|
||||
VerificationStatus = verified
|
||||
? ArtworkVerificationStatus.AutomaticallyVerified
|
||||
: ArtworkVerificationStatus.NeedsReview
|
||||
};
|
||||
}
|
||||
|
||||
public static GameArtwork? SelectBest(IEnumerable<GameArtwork?> candidates) => candidates
|
||||
.Where(candidate => candidate is not null && candidate.VerificationStatus != ArtworkVerificationStatus.Rejected &&
|
||||
(candidate.QualityScore > 0 || candidate.Source.Equals("Uploaded", StringComparison.OrdinalIgnoreCase)))
|
||||
.Cast<GameArtwork>()
|
||||
.OrderByDescending(candidate => candidate.QualityScore)
|
||||
.ThenByDescending(candidate => candidate.Confidence)
|
||||
.ThenByDescending(candidate => SourcePriority(candidate.Source))
|
||||
.ThenByDescending(candidate => candidate.RetrievedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
private static int SourcePriority(string source) => source.ToLowerInvariant() switch
|
||||
{
|
||||
"uploaded" => 5,
|
||||
"worldofgames" => 4,
|
||||
"launchbox" => 3,
|
||||
"libretro" => 2,
|
||||
"psxdatacenter" => 2,
|
||||
"steamdigital" => 1,
|
||||
"nintendo" => 1,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public sealed record BrowserGameDataBinding(Guid ArtifactId, long ArtifactVersion, string Core,
|
||||
string? ArtifactSha256);
|
||||
|
||||
public static class BrowserGameDataPolicy
|
||||
{
|
||||
public const string SourceType = "BrowserPlayerV1";
|
||||
public static IReadOnlyList<string> AutomaticRestoreCores { get; } = Array.AsReadOnly([
|
||||
"fceumm",
|
||||
"gambatte",
|
||||
"melonds",
|
||||
"mgba",
|
||||
"snes9x",
|
||||
"ppsspp",
|
||||
"pcsx_rearmed",
|
||||
"n64wasm",
|
||||
"genesis_plus_gx"
|
||||
]);
|
||||
|
||||
public static bool SupportsAutomaticRestore(string? core) =>
|
||||
core is not null && AutomaticRestoreCores.Contains(core, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static Guid StableEntryId(Guid gameId, string core, GameDataKind kind)
|
||||
{
|
||||
if (gameId == Guid.Empty || string.IsNullOrWhiteSpace(core))
|
||||
throw new ArgumentException("A game and emulator core are required for browser game data.");
|
||||
var input = Encoding.UTF8.GetBytes($"ludarium-browser-data:{gameId:D}:{core.Trim().ToLowerInvariant()}:{kind}");
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(input);
|
||||
return new Guid(hash.AsSpan(0, 16));
|
||||
}
|
||||
|
||||
public static string EncodeBinding(Guid artifactId, long artifactVersion, string core, string? artifactSha256)
|
||||
{
|
||||
if (!SupportsAutomaticRestore(core))
|
||||
throw new ArgumentException("Automatic restore is not fixture-verified for this core.", nameof(core));
|
||||
if (artifactId == Guid.Empty || artifactVersion < 1)
|
||||
throw new ArgumentException("A versioned artifact identity is required for automatic restore.");
|
||||
var binding = new BrowserGameDataBinding(artifactId, artifactVersion, core,
|
||||
NormalizeSha256(artifactSha256));
|
||||
return Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(binding));
|
||||
}
|
||||
|
||||
public static bool Matches(string? sourceType, string? sourceId, BrowserPlaySession session,
|
||||
Artifact artifact)
|
||||
{
|
||||
if (!string.Equals(sourceType, SourceType, StringComparison.Ordinal) || string.IsNullOrWhiteSpace(sourceId) ||
|
||||
sourceId.Length > 2048 || !SupportsAutomaticRestore(session.Core)) return false;
|
||||
try
|
||||
{
|
||||
var binding = JsonSerializer.Deserialize<BrowserGameDataBinding>(Convert.FromBase64String(sourceId));
|
||||
if (binding is null || binding.ArtifactId != session.ArtifactId || binding.ArtifactId != artifact.Id ||
|
||||
binding.ArtifactVersion != artifact.Version ||
|
||||
!string.Equals(binding.Core, session.Core, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
var expectedSha = NormalizeSha256(binding.ArtifactSha256);
|
||||
if (binding.ArtifactSha256 is not null && expectedSha is null) return false;
|
||||
return expectedSha is null || expectedSha.Equals(NormalizeSha256(artifact.Sha256), StringComparison.Ordinal);
|
||||
}
|
||||
catch (Exception exception) when (exception is FormatException or JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeSha256(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var normalized = value.Trim().ToLowerInvariant();
|
||||
return normalized.Length == 64 && normalized.All(Uri.IsHexDigit) ? normalized : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
/// <summary>
|
||||
/// One browser-playable platform.
|
||||
/// </summary>
|
||||
/// <param name="Extensions">Container formats a deterministic fixture has proven in the real core.</param>
|
||||
/// <param name="CandidateExtensions">
|
||||
/// Formats whose delivery path is implemented but not yet fixture-proven. They are recognised so the
|
||||
/// interface can explain them exactly, and they fail closed until their qualification pass has run.
|
||||
/// </param>
|
||||
public sealed record BrowserPlayPlatform(string Platform, string Core, bool DesktopRecommended,
|
||||
IReadOnlySet<string> Extensions, bool ReleaseQualified, long MaximumBytes = 512L * 1024 * 1024,
|
||||
IReadOnlySet<string>? CandidateExtensions = null)
|
||||
{
|
||||
public bool Accepts(string? extension) => extension is not null &&
|
||||
(Extensions.Contains(extension) || CandidateExtensions?.Contains(extension) == true);
|
||||
|
||||
public bool IsQualified(string? extension) => extension is not null && Extensions.Contains(extension);
|
||||
}
|
||||
|
||||
public static class BrowserPlayPolicy
|
||||
{
|
||||
public const long MaximumRomBytes = 512L * 1024 * 1024;
|
||||
|
||||
private static readonly Dictionary<string, BrowserPlayPlatform> Platforms =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// A platform may only become release-qualified after a deterministic ROM fixture has
|
||||
// completed the real bundled core in the browser. Keep future mappings here so they
|
||||
// remain explicit and fail closed until that evidence exists.
|
||||
["nes"] = new("nes", "fceumm", false, Set(".nes"), true),
|
||||
["snes"] = new("snes", "snes9x", false, Set(".sfc"), true,
|
||||
CandidateExtensions: Set(".smc")),
|
||||
["gb"] = new("gb", "gambatte", false, Set(".gb"), true),
|
||||
["gbc"] = new("gbc", "gambatte", false, Set(".gbc"), true),
|
||||
["gba"] = new("gba", "mgba", false, Set(".gba"), true),
|
||||
["n64"] = new("n64", "n64wasm", true, Set(".n64", ".z64", ".v64", ".zip"), true),
|
||||
["nds"] = new("nds", "melonds", true, Set(".nds"), true),
|
||||
// A CUE sheet stands for its whole disc set; Ludarium streams the sheet and its tracks
|
||||
// together as one archive, so the size limit applies to the resolved set, not to the sheet.
|
||||
// EmulatorJS mounts that archive, but the delivery has not completed a live fixture yet.
|
||||
["psx"] = new("psx", "pcsx_rearmed", true, Set(".chd"), true,
|
||||
4L * 1024 * 1024 * 1024, Set(".pbp", ".cue", ".m3u")),
|
||||
["psp"] = new("psp", "ppsspp", true, Set(".pbp"), true,
|
||||
CandidateExtensions: Set(".iso", ".cso")),
|
||||
// Play!.js range-reads the session content as a raw block device and boots it as an ISO,
|
||||
// so it cannot mount an archive: no CUE set or playlist is offered here. Only the ISO
|
||||
// fixture has ever completed the live gate, so every compressed container and the raw
|
||||
// BIN track stay candidates until one proves the runtime decompresses them itself.
|
||||
["ps2"] = new("ps2", "playjs", true, Set(".iso"), true,
|
||||
8L * 1024 * 1024 * 1024, Set(".cso", ".chd", ".isz", ".bin")),
|
||||
|
||||
// Sega hardware the bundled genesis_plus_gx core covers. Each completed the live core gate
|
||||
// on its own pinned fixture: the core booted, ran, produced a savestate and restored it
|
||||
// into a relaunched session. That is evidence for these fixtures, not a compatibility
|
||||
// claim for every cartridge ever pressed.
|
||||
["genesis"] = new("genesis", "genesis_plus_gx", false, Set(".md"), true,
|
||||
CandidateExtensions: Set(".gen", ".smd", ".bin")),
|
||||
["mastersystem"] = new("mastersystem", "genesis_plus_gx", false, Set(".sms"), true),
|
||||
["gamegear"] = new("gamegear", "genesis_plus_gx", false, Set(".gg"), true)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// EmulatorJS cores that must stay in the image because a mapped platform uses them. The image
|
||||
/// build deletes every other core, so this list and that build step must never drift apart.
|
||||
/// Ludarium's own players (Play!.js, N64Wasm) are not EmulatorJS cores and are excluded.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> RequiredCores { get; } =
|
||||
Platforms.Values.Select(platform => platform.Core)
|
||||
.Where(core => core is not "playjs" and not "n64wasm")
|
||||
.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray();
|
||||
|
||||
public static IReadOnlyList<BrowserPlayPlatform> DescribePlatforms() =>
|
||||
Platforms.Values.OrderBy(platform => platform.Platform, StringComparer.Ordinal).ToArray();
|
||||
|
||||
public static BrowserPlayPlatform? GetPlatform(string? platform) =>
|
||||
platform is not null && Platforms.TryGetValue(platform, out var value) ? value : null;
|
||||
|
||||
public static BrowserPlayCapability Evaluate(BrowserPlayCandidate? candidate, bool configured,
|
||||
bool emulatorHealthy, DateTimeOffset now)
|
||||
{
|
||||
if (candidate is null)
|
||||
return new(Guid.Empty, false, BrowserPlayState.MissingRom, "No present ROM is linked to this game.", CheckedAt: now);
|
||||
var platform = GetPlatform(candidate.Platform);
|
||||
if (!configured)
|
||||
return Unavailable(candidate, BrowserPlayState.Disabled, "Browser play is not configured.", now, platform);
|
||||
if (!candidate.SourceReadOnly)
|
||||
return Unavailable(candidate, BrowserPlayState.Disabled, "The source library is not verified read-only.", now, platform);
|
||||
if (!emulatorHealthy)
|
||||
return Unavailable(candidate, BrowserPlayState.EmulatorUnavailable, "The isolated emulator service is unavailable.", now, platform);
|
||||
if (platform is null)
|
||||
return Unavailable(candidate, BrowserPlayState.UnsupportedPlatform, "This platform is not allowlisted for browser play.", now);
|
||||
if (!platform.ReleaseQualified)
|
||||
return Unavailable(candidate, BrowserPlayState.UnsupportedPlatform,
|
||||
"This browser-emulation core is bundled but is not release-qualified by a deterministic playback fixture.", now, platform);
|
||||
if (!IsSafeRelativePath(candidate.RelativePath))
|
||||
return Unavailable(candidate, BrowserPlayState.UnsupportedFormat, "The linked ROM path is not a safe relative source path.", now, platform);
|
||||
var extension = Path.GetExtension(candidate.RelativePath);
|
||||
if (!platform.Accepts(extension) || candidate.Size <= 0 || candidate.Size > platform.MaximumBytes)
|
||||
return Unavailable(candidate, BrowserPlayState.UnsupportedFormat, "This ROM format or size is not allowlisted for browser play.", now, platform);
|
||||
if (!platform.IsQualified(extension))
|
||||
return Unavailable(candidate, BrowserPlayState.UnsupportedFormat,
|
||||
"Ludarium can deliver this container format, but it is not release-qualified by a deterministic playback fixture.",
|
||||
now, platform);
|
||||
return new(candidate.GameId, true, BrowserPlayState.Available, "Ready to play in Ludarium's isolated browser player.",
|
||||
platform.Platform, EmulatorName(platform.Core), platform.Core, platform.DesktopRecommended, now,
|
||||
BrowserGameDataPolicy.SupportsAutomaticRestore(platform.Core));
|
||||
}
|
||||
|
||||
/// <summary>Every container format any platform accepts, qualified or not.</summary>
|
||||
public static IReadOnlySet<string> KnownExtensions { get; } = Platforms.Values
|
||||
.SelectMany(platform => platform.Extensions.Concat(platform.CandidateExtensions ?? new HashSet<string>()))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>A CUE sheet stands for a whole disc rather than for a single playable file.</summary>
|
||||
public static bool IsCueSheet(string? relativePath) => relativePath is not null &&
|
||||
Path.GetExtension(relativePath).Equals(".cue", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>An M3U playlist stands for a whole multi-disc game.</summary>
|
||||
public static bool IsPlaylist(string? relativePath) => relativePath is not null &&
|
||||
Path.GetExtension(relativePath).Equals(".m3u", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Choose the one artifact a browser session may stream.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Descriptors outrank what they describe, most general first: a playlist stands for the whole
|
||||
/// game, a CUE sheet for one disc, and the track files for neither. Without that order a
|
||||
/// multi-disc game looks ambiguous, because every disc and track is linked to the same game.
|
||||
/// Two playlists, two sheets, or two unrelated playable files stay deliberately unresolvable —
|
||||
/// Ludarium must never guess which disc to load.
|
||||
/// </remarks>
|
||||
public static BrowserPlayCandidate? SelectCandidate(IReadOnlyList<BrowserPlayCandidate> candidates)
|
||||
{
|
||||
var playlists = candidates.Where(candidate => IsPlaylist(candidate.RelativePath)).ToArray();
|
||||
if (playlists.Length > 0) return playlists.Length == 1 ? playlists[0] : null;
|
||||
var sheets = candidates.Where(candidate => IsCueSheet(candidate.RelativePath)).ToArray();
|
||||
if (sheets.Length > 0) return sheets.Length == 1 ? sheets[0] : null;
|
||||
return candidates.Count == 1 ? candidates[0] : null;
|
||||
}
|
||||
|
||||
public static string SafeFileName(string relativePath) => Path.GetFileName(relativePath.Replace('\\', '/'));
|
||||
|
||||
private static bool IsSafeRelativePath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || path.StartsWith('/') || path.StartsWith('\\')
|
||||
|| Path.IsPathRooted(path) || path.Contains(':') || path.Contains('\0'))
|
||||
return false;
|
||||
var parts = path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length > 0 && parts.All(part => part is not "." and not "..");
|
||||
}
|
||||
|
||||
private static BrowserPlayCapability Unavailable(BrowserPlayCandidate candidate, BrowserPlayState state,
|
||||
string message, DateTimeOffset now, BrowserPlayPlatform? platform = null) =>
|
||||
new(candidate.GameId, false, state, message, candidate.Platform, EmulatorName(platform?.Core), platform?.Core,
|
||||
platform?.DesktopRecommended ?? false, now);
|
||||
|
||||
public static string EmulatorName(string? core) => core switch
|
||||
{
|
||||
"playjs" => "Ludarium Play!.js",
|
||||
"n64wasm" => "Ludarium N64Wasm",
|
||||
_ => "Ludarium EmulatorJS"
|
||||
};
|
||||
|
||||
private static HashSet<string> Set(params string[] values) => new(values, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user