From bbafb7ed77552daf9fbd877405d5a61ec538d5a8 Mon Sep 17 00:00:00 2001 From: ApolloF Date: Thu, 29 Oct 2026 14:20:26 +0100 Subject: [PATCH 1/4] Add console games from emulators (RPCS3, Dolphin, Cemu) --- .../Seaglass/internal/app/libraryservice.ts | 20 + .../Seaglass/internal/library/index.ts | 1 + .../Seaglass/internal/library/models.ts | 64 +++ .../Seaglass/internal/settings/models.ts | 6 + frontend/src/bigpicture/GameSheet.svelte | 2 +- frontend/src/desktop/Details.svelte | 134 ++++- frontend/src/desktop/Settings.svelte | 1 + frontend/src/desktop/Sidebar.svelte | 1 + frontend/src/lib/api.mock.ts | 38 +- frontend/src/lib/api.real.ts | 2 + frontend/src/lib/api.ts | 4 + frontend/src/lib/route.ts | 2 + frontend/src/lib/store.svelte.ts | 1 + frontend/src/lib/types.ts | 21 + internal/achievements/resolve.go | 6 + internal/app/console_test.go | 90 +++ internal/app/core.go | 35 +- internal/app/external.go | 3 +- internal/app/launch.go | 63 +- internal/app/metadata.go | 16 +- internal/app/profile.go | 10 + internal/app/saves.go | 2 +- internal/app/services.go | 40 ++ internal/emulated/cemu.go | 221 +++++++ internal/emulated/disc.go | 137 +++++ internal/emulated/dolphin.go | 117 ++++ internal/emulated/emulated.go | 541 ++++++++++++++++++ internal/emulated/emulated_test.go | 374 ++++++++++++ internal/emulated/fuzz_test.go | 60 ++ internal/emulated/live_test.go | 27 + internal/emulated/rpcs3.go | 184 ++++++ internal/emulated/sfo.go | 56 ++ internal/emulated/walk.go | 182 ++++++ internal/launch/launch.go | 12 +- internal/launch/launch_test.go | 26 + internal/library/library.go | 56 +- internal/library/library_test.go | 38 ++ internal/meta/fetch.go | 12 +- internal/meta/local.go | 71 +++ internal/meta/local_test.go | 71 +++ internal/platform/platform.go | 9 + internal/settings/settings.go | 16 +- internal/settings/settings_test.go | 19 + 43 files changed, 2744 insertions(+), 47 deletions(-) create mode 100644 internal/app/console_test.go create mode 100644 internal/emulated/cemu.go create mode 100644 internal/emulated/disc.go create mode 100644 internal/emulated/dolphin.go create mode 100644 internal/emulated/emulated.go create mode 100644 internal/emulated/emulated_test.go create mode 100644 internal/emulated/fuzz_test.go create mode 100644 internal/emulated/live_test.go create mode 100644 internal/emulated/rpcs3.go create mode 100644 internal/emulated/sfo.go create mode 100644 internal/emulated/walk.go create mode 100644 internal/meta/local.go create mode 100644 internal/meta/local_test.go diff --git a/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/libraryservice.ts b/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/libraryservice.ts index c8e9865..89741cd 100644 --- a/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/libraryservice.ts +++ b/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/libraryservice.ts @@ -108,6 +108,15 @@ export function Rescan(): $CancellablePromise { return $Call.ByID(3200454951); } +/** + * ResetLaunch goes back to starting the game the way the scan found + * (a console game: its emulator), forgetting the chosen program and + * arguments. + */ +export function ResetLaunch(id: number): $CancellablePromise { + return $Call.ByID(2299542789, id); +} + /** * ScanState reports what the scanner is doing. */ @@ -151,6 +160,17 @@ export function SetHidden(id: number, on: boolean): $CancellablePromise { + return $Call.ByID(1871014939, id, args, quitArgs); +} + /** * SetMatch says which game this is: a Steam app and its name. The choice * is kept across scans, and metadata is fetched for it. diff --git a/frontend/bindings/github.com/ApolloF/Seaglass/internal/library/index.ts b/frontend/bindings/github.com/ApolloF/Seaglass/internal/library/index.ts index bf2fa1c..e1931b6 100644 --- a/frontend/bindings/github.com/ApolloF/Seaglass/internal/library/index.ts +++ b/frontend/bindings/github.com/ApolloF/Seaglass/internal/library/index.ts @@ -2,6 +2,7 @@ // This file is automatically generated. DO NOT EDIT export type { + Console, Game, Meta } from "./models.js"; diff --git a/frontend/bindings/github.com/ApolloF/Seaglass/internal/library/models.ts b/frontend/bindings/github.com/ApolloF/Seaglass/internal/library/models.ts index 1bf635e..14172d1 100644 --- a/frontend/bindings/github.com/ApolloF/Seaglass/internal/library/models.ts +++ b/frontend/bindings/github.com/ApolloF/Seaglass/internal/library/models.ts @@ -1,6 +1,59 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +/** + * Console is what's known about a console game played on an emulator. + * The game's Exe and Args start the emulator with it. + */ +export interface Console { + /** + * "PS3", "GameCube", "Wii", "Wii U" + */ + "platform": string; + + /** + * "RPCS3", "Dolphin", "Cemu" + */ + "emulator": string; + + /** + * EmuDir is the emulator's folder: its processes are the game while + * it runs, whatever started it. + */ + "emuDir": string; + + /** + * what the emulator boots + */ + "file": string; + + /** + * BLES01689, RMCE01, … + */ + "serial"?: string; + + /** + * Title is the game's name without the serial a second copy gets, + * for looking up its art. + */ + "title": string; + + /** + * How the emulator starts it, for going back to that after the user + * picked another program. + */ + "exe": string; + "args": string; + + /** + * Pictures that came with the game or the emulator downloaded, for + * when the art sources have none (local PNG files). + */ + "cover"?: string; + "backdrop"?: string; + "icon"?: string; +} + /** * Game is one game in the library. */ @@ -50,6 +103,12 @@ export interface Game { */ "userExe"?: boolean; + /** + * QuitArgs, with a program the user picked, make that program close + * the game (a launcher's quit command) instead of Seaglass ending it. + */ + "quitArgs"?: string; + /** * a connected store account owns it */ @@ -126,6 +185,11 @@ export interface Game { * can be in several. */ "collections"?: string[] | null; + + /** + * Console is set for a console game played on an emulator. + */ + "console"?: Console | null; "meta"?: Meta | null; } diff --git a/frontend/bindings/github.com/ApolloF/Seaglass/internal/settings/models.ts b/frontend/bindings/github.com/ApolloF/Seaglass/internal/settings/models.ts index 2afc021..94b44aa 100644 --- a/frontend/bindings/github.com/ApolloF/Seaglass/internal/settings/models.ts +++ b/frontend/bindings/github.com/ApolloF/Seaglass/internal/settings/models.ts @@ -22,6 +22,12 @@ export interface Settings { */ "detectExternal": boolean; + /** + * DetectEmulators adds the console games of emulators installed here + * (RPCS3, Dolphin, Cemu). Per PC, like the emulators. + */ + "detectEmulators": boolean; + /** * keep low-confidence matches in New on this PC for a check */ diff --git a/frontend/src/bigpicture/GameSheet.svelte b/frontend/src/bigpicture/GameSheet.svelte index fac2743..57e066b 100644 --- a/frontend/src/bigpicture/GameSheet.svelte +++ b/frontend/src/bigpicture/GameSheet.svelte @@ -29,7 +29,7 @@ $effect(() => { const id = game.id; let live = true; - if (game.installed) api.saves.get(id).then((s) => live && (saves = s)).catch(() => {}); + if (game.installed && !game.console) api.saves.get(id).then((s) => live && (saves = s)).catch(() => {}); return () => (live = false); }); diff --git a/frontend/src/desktop/Details.svelte b/frontend/src/desktop/Details.svelte index 07d55f8..7355f4d 100644 --- a/frontend/src/desktop/Details.svelte +++ b/frontend/src/desktop/Details.svelte @@ -23,6 +23,7 @@ const gid = $derived(game.id); const installed = $derived(game.installed); const owned = $derived(!!game.owned); + const isConsole = $derived(!!game.console); const logo = $derived(game.meta?.logo); const phase = $derived(lib.session?.phase); @@ -72,7 +73,7 @@ const savesInfo = $derived(savesSummary(saves)); $effect(() => { const id = gid; - const inst = installed; + const inst = installed && !isConsole; // Syncer doesn't know console games phase; // ask again once a game session ends saves = null; if (!inst) return; @@ -175,6 +176,32 @@ await lib.run(() => api.launch.quitGame()); } + // Launch options: the program's arguments, and ones that make it close + // the game (a launcher or script that wants to be told). + let launchOpen = $state(false); + let argsDraft = $state(""); + let quitDraft = $state(""); + $effect(() => { + gid; + launchOpen = false; + }); + function openLaunch() { + menuOpen = false; + argsDraft = game.args ?? ""; + quitDraft = game.quitArgs ?? ""; + launchOpen = true; + } + async function saveLaunch() { + if ((await lib.run(() => api.setLaunchArgs(game.id, argsDraft, quitDraft))) !== undefined) launchOpen = false; + } + async function resetLaunch() { + if ((await lib.run(() => api.resetLaunch(game.id))) !== undefined) launchOpen = false; + } + async function chooseLaunchExe() { + const g = await lib.run(() => api.chooseExe(game.id)); + if (g) argsDraft = g.args ?? ""; + } + function onmenukey(e: KeyboardEvent) { if (e.key === "Escape") menuOpen = false; } @@ -257,6 +284,9 @@ {#if game.installed} + {#if game.exe && !game.launchUri} + + {/if} {/if} @@ -323,6 +353,35 @@ + {#if launchOpen} +
(e.preventDefault(), saveLaunch())}> +
+ + Launch options +
+
+ {game.exe?.split("\\").pop()} + +
+ + +

Quit game runs the program again with these, then closes the game itself if it's still running 20 seconds later.

+
+ + + {#if game.userExe} + + {/if} +
+
+ {/if} + {#if game.installed}
@@ -336,25 +395,27 @@

{padNote}

-
-
- - Saves - {#if savesInfo?.action === "get"} - - {:else if savesInfo?.action === "open"} - + {#if !game.console} +
+
+ + Saves + {#if savesInfo?.action === "get"} + + {:else if savesInfo?.action === "open"} + + {/if} +
+ {#if savesInfo} + {savesInfo.text} +

{savesInfo.detail}

+ {:else} +

Asking Syncer…

{/if}
- {#if savesInfo} - {savesInfo.text} -

{savesInfo.detail}

- {:else} -

Asking Syncer…

- {/if} -
+ {/if} {/if} {#if achOn && (game.installed || game.owned) && !(ach && ach.total === 0 && !ach.source)} @@ -394,13 +455,17 @@
Identified
{game.matchHow}{game.confidence < 100 ? ` (${game.confidence}% sure)` : ""}
{#if game.emulator}
Emulator
{game.emulator}
{/if} + {#if game.console} +
Plays on
+
{game.console.emulator}{game.console.serial ? ` · ${game.console.serial}` : ""}
+ {/if} {#if game.repacker}
Installer
{game.repacker}
{/if} {#if game.steamAppId}
Steam app
{game.steamAppId}
{/if} {#if game.installed}
Folder
Starts
-
{game.launchUri ? game.launchUri.split("?")[0] : game.exe ? game.exe.split("\\").pop() : "Nothing found yet"}
+
{game.launchUri ? game.launchUri.split("?")[0] : game.exe ? game.exe.split("\\").pop() : "Nothing found yet"}
{/if} {#if game.sizeBytes}
Size
{bytes(game.sizeBytes)}
{/if} @@ -703,6 +768,37 @@ font: inherit; font-size: 13px; } + .launch .prog { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + color: var(--text-2); + } + .launch .prog span { + flex: 1; + min-width: 0; + } + .field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; + color: var(--muted); + } + .field input { + padding: 7px 10px; + border-radius: 8px; + border: 1px solid var(--line-strong); + background: var(--surface); + color: var(--text); + font: inherit; + font-size: 13px; + } + .field input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; + } .card p { margin: 0; font-size: 13.5px; diff --git a/frontend/src/desktop/Settings.svelte b/frontend/src/desktop/Settings.svelte index aebcac4..e18a00d 100644 --- a/frontend/src/desktop/Settings.svelte +++ b/frontend/src/desktop/Settings.svelte @@ -140,6 +140,7 @@ {/if} set({ detectExternal: v })} /> + set({ detectEmulators: v })} /> set({ reviewUncertain: v })} /> set({ showNotInstalled: v })} />
diff --git a/frontend/src/desktop/Sidebar.svelte b/frontend/src/desktop/Sidebar.svelte index dfc7977..5ca35f7 100644 --- a/frontend/src/desktop/Sidebar.svelte +++ b/frontend/src/desktop/Sidebar.svelte @@ -31,6 +31,7 @@ external: "oklch(0.78 0.14 60)", standalone: "oklch(0.72 0.05 200)", folder: "oklch(0.7 0.02 240)", + emulated: "oklch(0.72 0.15 350)", }; const isActive = (kind: string, source?: string) => diff --git a/frontend/src/lib/api.mock.ts b/frontend/src/lib/api.mock.ts index 30b9011..91a7dc0 100644 --- a/frontend/src/lib/api.mock.ts +++ b/frontend/src/lib/api.mock.ts @@ -44,8 +44,36 @@ let games: Game[] = [ game({ title: "Tidebreaker", source: "installer", sourceLabel: "Standalone", external: true, steamAppId: 2840770, how: "Installer entry in Windows", matchHow: "Matched by title", confidence: 85, playtime: 2 * 3600, lastPlayed: now - 2 * day, sizeBytes: 90e9 }), game({ title: "Brass Circuit", source: "folder", sourceLabel: "Folder", how: "Game folder in D:\\Games", matchHow: "Matched by title", confidence: 80, playtime: 3 * 3600, lastPlayed: now - 5 * day, padMode: "xbox", sizeBytes: 12e9 }), game({ title: "Copper Fields", source: "gog", sourceLabel: "GOG", installed: false, playtime: 21 * 3600, lastPlayed: now - 700 * day }), + // Console games on emulators, with two copies of one game (two regions). + consoleGame("Tidewater Legends (BLES00001)", "Tidewater Legends", "PS3", "RPCS3", "BLES00001", { playtime: 5 * 3600, lastPlayed: now - 4 * day }), + consoleGame("Tidewater Legends (BLES00002)", "Tidewater Legends", "PS3", "RPCS3", "BLES00002", {}), + consoleGame("Kite Rally", "Kite Rally", "Wii", "Dolphin", "RKRE01", { playtime: 11 * 3600, lastPlayed: now - 6 * day, favorite: true }), + consoleGame("Moonlit Derby", "Moonlit Derby", "GameCube", "Dolphin", "GMDE01", { addedAt: now - 1800 }), + consoleGame("Harbor Karts 8", "Harbor Karts 8", "Wii U", "Cemu", "000500001010EC00", { playtime: 3 * 3600, lastPlayed: now - 20 * day }), ]; +function consoleGame(t: string, base: string, platform: string, emulator: string, serial: string, p: Partial): Game { + const emuDir = `C:\\Emulators\\${emulator}`; + const exe = `${emuDir}\\${emulator.toLowerCase()}.exe`; + const dir = `R:\\Games\\${platform}\\${base}`; + const file = `${dir}\\${platform === "PS3" ? "PS3_GAME\\USRDIR\\EBOOT.BIN" : serial + ".iso"}`; + const args = `--fullscreen "${file}"`; + return game({ + title: t, + source: "emulator", + sourceLabel: platform, + dir, + exe, + args, + workDir: emuDir, + how: `${emulator} game list`, + matchHow: "Title from the game's files", + sizeBytes: 4e9, + console: { platform, emulator, emuDir, file, serial, title: base, exe, args }, + ...p, + }); +} + // ?games=N in the mock URL adds made-up games, to try the layouts with a // bigger library; ?layout=console|orbit|deck picks the big picture layout. const mockParams = new URLSearchParams(typeof location !== "undefined" ? location.search : ""); @@ -94,6 +122,7 @@ let settings: Settings = { folders: ["D:\\Games"], autoFolders: true, detectExternal: true, + detectEmulators: true, reviewUncertain: true, showNotInstalled: false, theme: "system", @@ -397,7 +426,14 @@ export const mockApi: Api = { setPadMode: (id, mode) => update(id, (g) => (g.padMode = mode === "auto" ? "" : mode)), rename: (id, t) => update(id, (g) => (g.customTitle = t.trim())), confirmMatch: (id) => update(id, (g) => ((g.confirmed = true), (g.needsReview = false))), - chooseExe: (id) => update(id, (g) => ((g.exe = g.dir + "\\Game.exe"), (g.userExe = true))), + chooseExe: (id) => update(id, (g) => ((g.exe = g.dir + "\\Game.exe"), (g.args = ""), (g.userExe = true))), + setLaunchArgs: (id, args, quitArgs) => update(id, (g) => ((g.args = args.trim()), (g.quitArgs = quitArgs.trim()), (g.userExe = true))), + resetLaunch: (id) => + update(id, (g) => { + g.userExe = false; + g.quitArgs = ""; + if (g.console) (g.exe = g.console.exe), (g.args = g.console.args), (g.workDir = g.console.emuDir); + }), async openFolder() {}, async install() {}, async metaState(): Promise { diff --git a/frontend/src/lib/api.real.ts b/frontend/src/lib/api.real.ts index 5d52eac..1c7a195 100644 --- a/frontend/src/lib/api.real.ts +++ b/frontend/src/lib/api.real.ts @@ -16,6 +16,8 @@ export const realApi: Api = { rename: (id, title) => g(LibraryService.Rename(id, title)), confirmMatch: (id) => g(LibraryService.ConfirmMatch(id)), chooseExe: (id) => g(LibraryService.ChooseExe(id)), + setLaunchArgs: (id, args, quitArgs) => g(LibraryService.SetLaunchArgs(id, args, quitArgs)), + resetLaunch: (id) => g(LibraryService.ResetLaunch(id)), openFolder: (id) => LibraryService.OpenFolder(id), install: (id) => LibraryService.Install(id), metaState: () => LibraryService.MetaState() as Promise as Promise, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 68329b5..72906e9 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -15,6 +15,10 @@ export interface Api { rename(id: number, title: string): Promise; confirmMatch(id: number): Promise; chooseExe(id: number): Promise; + /** The arguments the game's program starts with, and ones that make it close the game ("" for none). */ + setLaunchArgs(id: number, args: string, quitArgs: string): Promise; + /** Back to starting the game the way the scan found (a console game: its emulator). */ + resetLaunch(id: number): Promise; openFolder(id: number): Promise; /** Asks the game's store to install it. */ install(id: number): Promise; diff --git a/frontend/src/lib/route.ts b/frontend/src/lib/route.ts index e9dd4f7..1da8eb0 100644 --- a/frontend/src/lib/route.ts +++ b/frontend/src/lib/route.ts @@ -15,6 +15,7 @@ export function translatorOf(status: ControllerStatus | null, settings: Settings /** Auto mode would hand the game an Xbox controller: a PlayStation controller * is in use and the game supports controllers but not PlayStation ones. */ function wantsXbox(g: Game, pad: PadState): boolean { + if (g.console) return false; // the emulator reads the controller itself if (!pad.connected || pad.kind !== "playstation" || g.padHint || !g.meta) return false; const ds = g.meta.dualSense ?? ""; if (ds && ds !== "no") return false; @@ -53,6 +54,7 @@ export function padExplain(g: Game, pad: PadState, tr: Translator): { short: str short: "Xbox controller", long: "Always starts with Seaglass's Xbox controller, so your controller works as an Xbox one." + (tr.viGEm ? "" : " Set it up in Settings first."), }; + if (g.console) return { short: "Emulator", long: `Auto: ${g.console.emulator} reads the controller itself, so the game starts directly.` }; const ds = g.meta?.dualSense ?? ""; if (ds === "yes") return { short: "DualSense", long: "Auto: Steam lists DualSense support, so it starts directly." }; if (g.padHint === "libScePad") return { short: "DualSense", long: "Auto: the game ships Sony's DualSense library, so it starts directly." }; diff --git a/frontend/src/lib/store.svelte.ts b/frontend/src/lib/store.svelte.ts index e9f4f19..c05d1eb 100644 --- a/frontend/src/lib/store.svelte.ts +++ b/frontend/src/lib/store.svelte.ts @@ -32,6 +32,7 @@ export const SOURCE_GROUPS: { id: string; label: string; match: (g: Game) => boo { id: "external", label: "External", match: (g) => g.external }, { id: "standalone", label: "Standalone", match: (g) => !g.external && (g.source === "installer" || g.source === "shortcut") }, { id: "folder", label: "Folders", match: (g) => !g.external && g.source === "folder" }, + { id: "emulated", label: "Consoles", match: (g) => !!g.console }, ]; const WEEK = 7 * 86400; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 52ee238..0c84f8a 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -68,15 +68,36 @@ export interface Game { collections?: string[]; hidden?: boolean; padMode?: string; // "" | "native" | "xbox" | "steam" + /** With a program the user chose: the arguments that make it close the game. */ + quitArgs?: string; + /** Set for a console game played on an emulator. */ + console?: Console; meta?: Meta; } +/** A console game on an emulator. Mirrors internal/library.Console. */ +export interface Console { + platform: string; // "PS3", "GameCube", "Wii", "Wii U" + emulator: string; // "RPCS3", "Dolphin", "Cemu" + emuDir: string; + /** What the emulator boots. */ + file: string; + serial?: string; + /** The title without the serial a second copy gets. */ + title: string; + /** How the emulator starts it (the game's exe and args, unless the user picked others). */ + exe: string; + args: string; +} + export type Layout = "deck" | "console" | "orbit"; export interface Settings { folders: string[]; autoFolders: boolean; detectExternal: boolean; + /** Add the console games of emulators installed on this PC (per PC). */ + detectEmulators: boolean; reviewUncertain: boolean; showNotInstalled: boolean; showOwned: boolean; diff --git a/internal/achievements/resolve.go b/internal/achievements/resolve.go index 24f975b..1cf9ee3 100644 --- a/internal/achievements/resolve.go +++ b/internal/achievements/resolve.go @@ -79,6 +79,9 @@ func gogGame(g library.Game) bool { // emulated reports whether the game's achievements come from emulator files. func emulated(g library.Game) bool { + if g.Console != nil { + return false + } switch g.Source { case "steam", "epic", "gog": return g.External @@ -132,6 +135,9 @@ func Resolve(ctx context.Context, g library.Game, d Deps) (l *List, net bool) { app := steamApp(g) steamIDs := false // the items are keyed by Steam's API names switch { + case g.Console != nil: + // Console games' trophies are in the emulator's own files. + l.Hint = "Seaglass can't read " + g.Console.Emulator + "'s achievements yet." case gogGame(g): net = resolveGOG(ctx, g, d, l) case emulated(g): diff --git a/internal/app/console_test.go b/internal/app/console_test.go new file mode 100644 index 0000000..15a2742 --- /dev/null +++ b/internal/app/console_test.go @@ -0,0 +1,90 @@ +package app + +import ( + "slices" + "testing" + + "github.com/ApolloF/Seaglass/internal/emulated" + "github.com/ApolloF/Seaglass/internal/library" + "github.com/ApolloF/Seaglass/internal/pad" +) + +func consoleGame() library.Game { + f := consoleFound(emulated.Game{ + Title: "Skylanders SWAP Force (BLES01966)", BaseTitle: "Skylanders SWAP Force", Platform: "PS3", Emulator: "RPCS3", + EmuDir: `C:\Emulators\RPCS3`, Exe: `C:\Emulators\RPCS3\rpcs3.exe`, Args: `--no-gui --fullscreen "R:\PS3\SF\PS3_GAME\USRDIR\EBOOT.BIN"`, + File: `R:\PS3\SF\PS3_GAME\USRDIR\EBOOT.BIN`, Dir: `R:\PS3\SF`, Serial: "BLES01966", How: "RPCS3 game folder", TitleFrom: "PARAM.SFO", + }) + return library.Game{ID: 1, Key: f.Key, Title: f.Title, Source: f.Source, SourceLabel: f.SourceLabel, Installed: true, + Dir: f.Dir, Exe: f.Exe, Args: f.Args, WorkDir: f.WorkDir, Console: f.Console, Meta: &library.Meta{Controller: "full"}} +} + +func TestConsoleFound(t *testing.T) { + g := consoleGame() + if g.Key != `emu:r:\ps3\sf\ps3_game\usrdir\eboot.bin` || g.Source != SourceEmulator || g.SourceLabel != "PS3" { + t.Fatalf("got %+v", g) + } + if g.Console.Title != "Skylanders SWAP Force" || g.Console.Exe != g.Exe || g.WorkDir != g.Console.EmuDir { + t.Fatalf("console: %+v", g.Console) + } +} + +// The emulator reads the controller itself: Auto never hands a console +// game Seaglass's or Steam's Xbox controller, even when its metadata says +// it only knows Xbox pads. A mode the user chose still applies. +func TestConsoleRoute(t *testing.T) { + ds := pad.State{Connected: true, Kind: pad.PlayStation, DualSense: true} + g := consoleGame() + for _, tr := range []translator{{}, {viGEm: true}, {steam: true}} { + if r := route(g, ds, tr); r != RouteDirect { + t.Errorf("%+v: route %s, want direct", tr, r) + } + if needsSetup(g, ds, tr) { + t.Errorf("%+v: asks to set up an Xbox controller", tr) + } + } + g.PadMode = "steam" + if r := route(g, ds, translator{}); r != RouteSteamInput { + t.Errorf("chosen Steam Input: %s", r) + } +} + +func TestConsoleExeAndTracking(t *testing.T) { + g := consoleGame() + if !consoleExeOK(g) { + t.Fatal("the scan's emulator should be allowed to start the game") + } + other := g + other.Exe = `C:\Elsewhere\evil.exe` + if consoleExeOK(other) { + t.Fatal("a program outside the emulator's folder isn't the game's") + } + if d := consoleDirs(g); !slices.Equal(d, []string{`C:\Emulators\RPCS3`}) { + t.Fatalf("dirs %v", d) + } + // Started through PowerShell (a boot script): PowerShell's folder + // doesn't make every PowerShell window the game. + ps := g + ps.UserExe, ps.Exe = true, `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe` + if d := consoleDirs(ps); len(d) != 1 { + t.Fatalf("dirs %v", d) + } + tool := g + tool.UserExe, tool.Exe = true, `C:\Tools\Launcher\launch.exe` + if d := consoleDirs(tool); len(d) != 2 || d[1] != `C:\Tools\Launcher` { + t.Fatalf("dirs %v", d) + } +} + +// Playtime of a console game is its own: by its serial, never a PC game +// of the same name. +func TestConsoleProfileKey(t *testing.T) { + g := consoleGame() + if ks := profileKeys(&g); !slices.Equal(ks, []string{"console:ps3:bles01966"}) { + t.Fatalf("keys %v", ks) + } + g.Console.Serial, g.Console.Platform = "", "Wii U" + if k := profileKey(&g); k != "console:wiiu:title:skylanderswapforce" { + t.Fatalf("key %q", k) + } +} diff --git a/internal/app/core.go b/internal/app/core.go index 8e32167..008f7e8 100644 --- a/internal/app/core.go +++ b/internal/app/core.go @@ -11,6 +11,7 @@ import ( "sync/atomic" "time" + "github.com/ApolloF/Seaglass/internal/emulated" "github.com/ApolloF/Seaglass/internal/identify" "github.com/ApolloF/Seaglass/internal/launch" "github.com/ApolloF/Seaglass/internal/library" @@ -235,15 +236,28 @@ func (c *Core) scanNow() { defer c.scanMu.Unlock() c.setState(func(s *ScanState) { s.Running, s.Error = true, "" }) cfg := c.Settings.Get() + // Console games on emulators are looked for alongside the PC games. + var consoles []emulated.Game + done := make(chan struct{}) + go func() { + defer close(done) + if cfg.DetectEmulators { + consoles = emulated.Find(c.ctx) + } + }() res := scan.Run(c.ctx, scan.Options{Folders: cfg.Folders, AutoFolders: cfg.AutoFolders, DetectExternal: cfg.DetectExternal}) + <-done if c.ctx.Err() != nil { return } ix := c.Manifest.Index() - found := make([]library.Found, 0, len(res.Games)) + found := make([]library.Found, 0, len(res.Games)+len(consoles)) for _, g := range res.Games { found = append(found, toFound(g, ix.Identify(g), cfg)) } + for _, g := range consoles { + found = append(found, consoleFound(g)) + } added, removed := c.Lib.ApplyScan(found, time.Now()) logx.Printf("scan: %d games (%d new, %d gone) in %v", len(found), added, removed, res.Took.Round(time.Millisecond)) c.setState(func(s *ScanState) { @@ -274,6 +288,25 @@ func toFound(g scan.Candidate, m identify.Match, cfg settings.Settings) library. return f } +// SourceEmulator is the library source of console games on emulators. +const SourceEmulator = "emulator" + +// consoleFound turns a console game into a library record. Its key is +// the file the emulator boots, so it never meets a PC game's folder key. +// The emulator knows which game it is: there's nothing to identify. +func consoleFound(g emulated.Game) library.Found { + return library.Found{ + Key: g.Key(), Title: g.Title, SortTitle: scan.SortTitle(g.Title), + Source: SourceEmulator, SourceLabel: g.Platform, + Dir: g.Dir, Exe: g.Exe, Args: g.Args, WorkDir: g.EmuDir, SizeBytes: g.SizeBytes, + How: g.How, MatchHow: "Title from " + g.TitleFrom, Confidence: 100, + Console: &library.Console{ + Platform: g.Platform, Emulator: g.Emulator, EmuDir: g.EmuDir, File: g.File, Serial: g.Serial, + Title: g.BaseTitle, Exe: g.Exe, Args: g.Args, Cover: g.Cover, Backdrop: g.Backdrop, Icon: g.Icon, + }, + } +} + var storeLabels = map[scan.Source]string{ scan.Steam: "Steam", scan.Epic: "Epic", scan.GOG: "GOG", scan.EA: "EA", scan.Ubisoft: "Ubisoft", scan.BattleNet: "Battle.net", scan.Xbox: "Xbox", scan.Installer: "Standalone", scan.Shortcut: "Shortcut", scan.Folder: "Folder", diff --git a/internal/app/external.go b/internal/app/external.go index 3f89d3c..4985743 100644 --- a/internal/app/external.go +++ b/internal/app/external.go @@ -110,7 +110,8 @@ func gameForPath(games []library.Game, path string) (library.Game, bool) { var best library.Game bestLen := 0 for _, g := range games { - if !g.Installed || g.Hidden || g.Dir == "" { + // An emulator started outside Seaglass could be playing any of its games. + if !g.Installed || g.Hidden || g.Dir == "" || g.Console != nil { continue } for _, d := range launch.UsableDirs([]string{g.Dir}) { diff --git a/internal/app/launch.go b/internal/app/launch.go index e415123..d53688d 100644 --- a/internal/app/launch.go +++ b/internal/app/launch.go @@ -62,7 +62,7 @@ func (s *LaunchService) play(id int64, from string) error { if g.Exe == "" { return errors.New("no program found to start " + g.DisplayTitle() + ". Choose one in the game's options.") } - if !platform.Within(g.Dir, g.Exe) && !g.UserExe { + if !platform.Within(g.Dir, g.Exe) && !g.UserExe && !consoleExeOK(g) { return errors.New("the game's program is outside its folder") } } @@ -91,6 +91,18 @@ func (s *LaunchService) Cancel() { s.c.Launch.Cancel() } // QuitGame ends the running game's processes right away. func (s *LaunchService) QuitGame() error { + cur := s.c.Launch.Current() + if g, ok := s.c.Lib.Get(cur.GameID); ok && cur.Phase == launch.Running && g.UserExe && g.QuitArgs != "" { + // The program that started the game knows how to close it (a + // launcher that wants to be told); Seaglass ends it if that doesn't. + _, err := platform.StartProcess(g.Exe, g.QuitArgs, g.WorkDir) + if err == nil { + logx.Printf("quit game %q with its quit arguments", cur.Title) + go s.c.quitIfStillRunning(cur.ID, 20*time.Second) + return nil + } + logx.Printf("quit game %q with its quit arguments: %v", cur.Title, err) + } err := s.c.Launch.Quit() if err == nil { logx.Printf("quit game %q", s.c.Launch.Current().Title) @@ -98,6 +110,20 @@ func (s *LaunchService) QuitGame() error { return err } +// quitIfStillRunning ends session id's game if it's still running after d. +func (c *Core) quitIfStillRunning(id int64, d time.Duration) { + select { + case <-c.ctx.Done(): + return + case <-time.After(d): + } + if cur := c.Launch.Current(); cur.ID == id && cur.Phase == launch.Running { + if err := c.Launch.Quit(); err == nil { + logx.Printf("quit game %q: still running after its quit arguments", cur.Title) + } + } +} + // SetUIMode tells the core which mode the interface shows. func (s *LaunchService) SetUIMode(mode string) { s.c.shell.SetUIMode(mode) } @@ -163,6 +189,11 @@ func route(g library.Game, ps pad.State, tr translator) string { // controller: a PlayStation controller is in use, and the game is known to // support controllers but not PlayStation ones. func wantsXbox(g library.Game, ps pad.State) bool { + // An emulator reads the controller itself (a DualSense too) and hands + // the game the console's own pad. + if g.Console != nil { + return false + } if !ps.Connected || ps.Kind != pad.PlayStation || g.PadHint != "" || g.Meta == nil { return false } @@ -191,7 +222,8 @@ func (c *Core) plan(g library.Game) launch.Plan { bridged := false var before, after []launch.Step cfg := c.Settings.Get() - if _, ok := syncer.Installed(); ok { + // Syncer doesn't know console games: their saves are the emulator's. + if _, ok := syncer.Installed(); ok && g.Console == nil { if cfg.AskWhoPlays { before = append(before, c.whoPlaysStep(g)) } else if !cfg.SyncSavesBefore && c.profile != nil { @@ -219,12 +251,16 @@ func (c *Core) plan(g library.Game) launch.Plan { if g.Exe != "" && !platform.Within(g.Dir, g.Exe) { dirs = append(dirs, filepath.Dir(g.Exe)) } + folderOnly := false + if g.Console != nil { + dirs, folderOnly = consoleDirs(g), true + } detect := 2 * time.Minute // a UAC prompt can wait a while if r == RouteStore || r == RouteSteamInput { detect = 5 * time.Minute // stores update, sign in, build shader caches } return launch.Plan{ - GameID: g.ID, Title: title, Dirs: dirs, Before: before, After: after, DetectTimeout: detect, + GameID: g.ID, Title: title, Dirs: dirs, FolderOnly: folderOnly, Before: before, After: after, DetectTimeout: detect, Start: func() (uint32, string, error) { var err error var pid int @@ -476,3 +512,24 @@ func (s *LaunchService) PlayFromArgs(args []string) bool { } return true } + +// consoleExeOK reports whether a console game starts its own emulator: +// the program in the emulator's folder that the scan found, booting a +// file in the game's folder. +func consoleExeOK(g library.Game) bool { + k := g.Console + return k != nil && k.EmuDir != "" && platform.Within(k.EmuDir, g.Exe) && strings.EqualFold(g.Exe, k.Exe) && + platform.Within(g.Dir, k.File) +} + +// consoleDirs are where a console game's processes run from: the +// emulator's folder, and the folder of a program the user picked to start +// it, unless that's a part of Windows (PowerShell running a script) whose +// other processes aren't the game. +func consoleDirs(g library.Game) []string { + dirs := []string{g.Console.EmuDir} + if g.UserExe && g.Exe != "" && !platform.Within(g.Console.EmuDir, g.Exe) && !platform.Within(platform.WindowsDir, g.Exe) { + dirs = append(dirs, filepath.Dir(g.Exe)) + } + return dirs +} diff --git a/internal/app/metadata.go b/internal/app/metadata.go index 92086e0..0ae32eb 100644 --- a/internal/app/metadata.go +++ b/internal/app/metadata.go @@ -180,7 +180,10 @@ func (w *metaWorker) fetch(ctx context.Context, id int64) error { // Unknown to the game database: the Steam store usually still knows the // game by name, for art and details. A clear hit also settles which game // it is (unless the user already said), so it doesn't wait for a check. - if g.SteamAppID == 0 && g.GogID == "" && (appID == 0 || !g.Confirmed && g.Confidence < storeMatchConfidence) && looksLikeGameName(g.DisplayTitle()) { + // (A console game isn't on the PC stores: one with the same name is a + // PC game.) + pc := g.Console == nil + if pc && g.SteamAppID == 0 && g.GogID == "" && (appID == 0 || !g.Confirmed && g.Confidence < storeMatchConfidence) && looksLikeGameName(g.DisplayTitle()) { h, ok, err := w.searchStore(ctx, g.DisplayTitle()) if errors.Is(err, meta.ErrRateLimited) { return err @@ -196,7 +199,7 @@ func (w *metaWorker) fetch(ctx context.Context, id int64) error { // including those only Epic sells, and often its Steam app. var wiki *meta.PCGW gogID := g.GogID - if appID == 0 && gogID == "" && looksLikeGameName(g.DisplayTitle()) { + if pc && appID == 0 && gogID == "" && looksLikeGameName(g.DisplayTitle()) { p, err := w.client.PCGamingWiki(ctx, g.DisplayTitle()) if errors.Is(err, meta.ErrRateLimited) { return err @@ -208,7 +211,14 @@ func (w *metaWorker) fetch(ctx context.Context, id int64) error { } } } - m, err := w.client.Fetch(ctx, meta.Request{Title: g.DisplayTitle(), SteamAppID: appID, GogID: gogID, EpicApp: g.EpicApp, Keep: g.Meta, PCGW: wiki}) + req := meta.Request{Title: g.DisplayTitle(), SteamAppID: appID, GogID: gogID, EpicApp: g.EpicApp, Keep: g.Meta, PCGW: wiki} + if !pc { + req.Console = g.Console + if g.CustomTitle == "" { + req.Title = g.Console.Title // without the serial a second copy has + } + } + m, err := w.client.Fetch(ctx, req) if err != nil { if !errors.Is(err, meta.ErrRateLimited) { w.mu.Lock() diff --git a/internal/app/profile.go b/internal/app/profile.go index 8e0e3af..6696149 100644 --- a/internal/app/profile.go +++ b/internal/app/profile.go @@ -174,6 +174,16 @@ func profileKeys(g *library.Game) []string { ks = append(ks, k) } } + // A console game is its console's game, never a PC game of the same + // name; its serial follows it to another copy or emulator. + if k := g.Console; k != nil { + id := strings.ToLower(k.Serial) + if id == "" { + id = "title:" + scan.LooseKey(k.Title) + } + add("console:" + strings.ToLower(strings.ReplaceAll(k.Platform, " ", "")) + ":" + id) + return ks + } if g.SteamAppID > 0 { add("steam:" + strconv.Itoa(g.SteamAppID)) } diff --git a/internal/app/saves.go b/internal/app/saves.go index 53cd37b..38f5f23 100644 --- a/internal/app/saves.go +++ b/internal/app/saves.go @@ -388,7 +388,7 @@ func syncerGame(g library.Game) syncer.Game { func (c *Core) syncerGames() []syncer.Game { var out []syncer.Game for _, g := range c.Lib.Games() { - if g.Installed && !g.Hidden { + if g.Installed && !g.Hidden && g.Console == nil { out = append(out, syncerGame(g)) } } diff --git a/internal/app/services.go b/internal/app/services.go index a653cce..1090a72 100644 --- a/internal/app/services.go +++ b/internal/app/services.go @@ -123,6 +123,46 @@ func (s *LibraryService) ChooseExe(id int64) (library.Game, error) { }) } +// SetLaunchArgs sets the command-line arguments the game's program gets, +// and the ones that make it close the game again (quit, "" for none: then +// Seaglass ends the game itself). Scans keep them, as they keep a chosen +// program. A console game can be started through a launcher or script +// this way (a figure portal's boot script, say). +func (s *LibraryService) SetLaunchArgs(id int64, args, quitArgs string) (library.Game, error) { + args, quitArgs = strings.TrimSpace(args), strings.TrimSpace(quitArgs) + for _, a := range []string{args, quitArgs} { + if len(a) > 2000 || strings.ContainsAny(a, "\x00\r\n") { + return library.Game{}, errors.New("arguments must be one line, at most 2000 characters") + } + } + g, ok := s.c.Lib.Get(id) + if !ok { + return g, library.ErrNotFound + } + if g.Exe == "" || g.LaunchURI != "" { + return g, errors.New(g.DisplayTitle() + " starts through its store: choose a program first") + } + return s.update(id, func(g *library.Game) { + g.Args, g.QuitArgs, g.UserExe = args, quitArgs, true + }) +} + +// ResetLaunch goes back to starting the game the way the scan found +// (a console game: its emulator), forgetting the chosen program and +// arguments. +func (s *LibraryService) ResetLaunch(id int64) (library.Game, error) { + g, err := s.update(id, func(g *library.Game) { + g.UserExe, g.QuitArgs = false, "" + if k := g.Console; k != nil { + g.Exe, g.Args, g.WorkDir = k.Exe, k.Args, k.EmuDir + } + }) + if err == nil && g.Console == nil { + s.c.RequestScan() // the scan finds the game's own program again + } + return g, err +} + // Install asks the game's store to install it (owned, not installed). func (s *LibraryService) Install(id int64) error { g, ok := s.c.Lib.Get(id) diff --git a/internal/emulated/cemu.go b/internal/emulated/cemu.go new file mode 100644 index 0000000..da2cd3b --- /dev/null +++ b/internal/emulated/cemu.go @@ -0,0 +1,221 @@ +package emulated + +import ( + "bytes" + "encoding/xml" + "io" + "path/filepath" + "strings" + + "github.com/ApolloF/Seaglass/internal/platform" +) + +// cemu is the Wii U emulator. Its settings.xml (in portable\ next to it, +// else in %APPDATA%\Cemu) lists its game folders and where its internal +// storage (mlc01) is; title_list_cache.xml beside it names the games it +// has seen. Games are disc images (.wud, .wux, .wua) or unpacked games +// (code\*.rpx with meta\meta.xml), and those installed to mlc01. +var cemu = &emulator{ + name: "Cemu", + exes: []string{"cemu.exe"}, + listed: func(in *install) ([]Game, []root) { + dir := cemuSettingsDir(in.dir) + var games []Game + var roots []root + mlc := filepath.Join(in.dir, "mlc01") + if b, err := readSmall(filepath.Join(dir, "settings.xml"), 1<<20); err == nil { + s := parseCemuSettings(b) + for _, p := range s.paths { + if p = pathFrom(in.dir, p); p != "" { + roots = append(roots, root{path: p, depth: maxDepth}) + } + } + if p := pathFrom(in.dir, s.mlc); p != "" { + mlc = p + } + } + if b, err := readSmall(filepath.Join(dir, "title_list_cache.xml"), 4<<20); err == nil { + for _, t := range parseCemuTitles(b) { + p := pathFrom(in.dir, t.path) + if p == "" || !platform.IsFile(p) { + continue + } + g, ok := wiiUFile(p, fileSize(p)) + if !ok { + continue + } + if name := cleanTitle(t.name); name != "" { + g.Title, g.BaseTitle, g.TitleFrom = titleCase(name), titleCase(name), "Cemu game list" + } + if isTitleID(t.id) { + g.Serial = strings.ToUpper(t.id) + } + g.How = "Cemu game list" + games = append(games, g) + } + } + // Games installed to Cemu's storage: usr\title\00050000\. + base := filepath.Join(mlc, "usr", "title", "00050000") + if ents, err := readDir(base, 2000); err == nil { + for _, e := range ents { + if !e.IsDir() { + continue + } + d := filepath.Join(base, e.Name()) + if g, ok := wiiUFolder(d, nameSet(d, 100)); ok { + g.How = "Cemu installed games" + games = append(games, g) + } + } + } + return games, roots + }, + exts: map[string]bool{".wud": true, ".wux": true, ".wua": true}, + file: func(in *install, path string, size int64) (Game, bool) { return wiiUFile(path, size) }, + folder: func(in *install, dir string, names map[string]bool) (Game, bool) { + return wiiUFolder(dir, names) + }, + args: func(file string) string { return "-f -g " + quote(file) }, +} + +// cemuSettingsDir is where Cemu keeps settings.xml: a portable folder +// beside it (2.0 and later), beside it (1.x), or in AppData. +func cemuSettingsDir(dir string) string { + if p := filepath.Join(dir, "portable"); platform.IsFile(filepath.Join(p, "settings.xml")) { + return p + } + if platform.IsFile(filepath.Join(dir, "settings.xml")) { + return dir + } + return filepath.Join(platform.Roaming, "Cemu") +} + +// wiiUFile is a Wii U disc image. Its contents are encrypted, so its +// title comes from Cemu's list or the file and folder names. +func wiiUFile(path string, size int64) (Game, bool) { + if size < 1<<20 { + return Game{}, false + } + return Game{Platform: "Wii U", File: path, SizeBytes: size, Title: cleanName(filepath.Base(path))}, true +} + +// wiiUFolder is an unpacked or installed Wii U game: code\.rpx, and +// meta\meta.xml with its name and title id. +func wiiUFolder(dir string, names map[string]bool) (Game, bool) { + if !names["code"] || !names["meta"] { + return Game{}, false + } + b, err := readSmall(filepath.Join(dir, "meta", "meta.xml"), 256<<10) + if err != nil { + return Game{}, false + } + m := parseWiiUMeta(b) + rpx := "" + if ents, err := readDir(filepath.Join(dir, "code"), 500); err == nil { + for _, e := range ents { + if !e.IsDir() && strings.EqualFold(filepath.Ext(e.Name()), ".rpx") { + rpx = filepath.Join(dir, "code", e.Name()) + break + } + } + } + if rpx == "" { + return Game{}, false + } + g := Game{Platform: "Wii U", File: rpx, Dir: dir, Title: cleanTitle(m.name), TitleFrom: "meta.xml"} + if isTitleID(m.id) { + g.Serial = strings.ToUpper(m.id) + } + if g.Title == "" { + g.Title, g.TitleFrom = cleanName(filepath.Base(dir)), "folder name" + } + g.BaseTitle = g.Title + return g, g.Title != "" +} + +func isTitleID(s string) bool { + if len(s) != 16 { + return false + } + for _, r := range strings.ToLower(s) { + if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') { + return false + } + } + return true +} + +type cemuSettings struct { + paths []string + mlc string +} + +// parseCemuSettings reads the game folders (GamePaths) and the storage +// folder (mlc_path) from Cemu's settings.xml. +func parseCemuSettings(b []byte) cemuSettings { + var s struct { + MLC string `xml:"mlc_path"` + Paths []string `xml:"GamePaths>Entry"` + // Cemu 1.x kept them under content. + Content struct { + MLC string `xml:"mlc_path"` + Paths []string `xml:"GamePaths>Entry"` + } `xml:"content"` + } + if decodeXML(b, &s) != nil { + return cemuSettings{} + } + out := cemuSettings{paths: append(s.Paths, s.Content.Paths...), mlc: s.MLC} + if out.mlc == "" { + out.mlc = s.Content.MLC + } + if len(out.paths) > 64 { + out.paths = out.paths[:64] + } + return out +} + +type cemuTitle struct{ id, name, path string } + +// parseCemuTitles reads title_list_cache.xml: each game Cemu has seen, +// with its title id, name and file. +func parseCemuTitles(b []byte) []cemuTitle { + var l struct { + Titles []struct { + ID string `xml:"titleId,attr"` + Name string `xml:"name"` + Path string `xml:"path"` + } `xml:"title"` + } + if decodeXML(b, &l) != nil { + return nil + } + var out []cemuTitle + for _, t := range l.Titles[:min(len(l.Titles), 2000)] { + out = append(out, cemuTitle{id: t.ID, name: t.Name, path: t.Path}) + } + return out +} + +type wiiUMeta struct{ id, name string } + +// parseWiiUMeta reads a Wii U game's meta.xml (longname_en, title_id). +func parseWiiUMeta(b []byte) wiiUMeta { + var m struct { + ID string `xml:"title_id"` + Name string `xml:"longname_en"` + } + if decodeXML(b, &m) != nil { + return wiiUMeta{} + } + return wiiUMeta{id: strings.TrimSpace(m.ID), name: m.Name} +} + +// decodeXML decodes a small XML file; entities and other character sets +// than UTF-8 aren't followed anywhere. +func decodeXML(b []byte, v any) error { + d := xml.NewDecoder(bytes.NewReader(b)) + d.Strict = false + d.CharsetReader = func(_ string, r io.Reader) (io.Reader, error) { return r, nil } + return d.Decode(v) +} diff --git a/internal/emulated/disc.go b/internal/emulated/disc.go new file mode 100644 index 0000000..73b3f0e --- /dev/null +++ b/internal/emulated/disc.go @@ -0,0 +1,137 @@ +package emulated + +import ( + "bufio" + "bytes" + "encoding/binary" + "strings" +) + +// disc is what a GameCube or Wii disc image says about itself. +type disc struct { + id string // RMCE01; "" when the image's header is compressed (GCZ) + title string // the disc's own name, often without spaces ("MarioKartWii") + platform string // "GameCube" or "Wii" +} + +const ( + wiiMagic = 0x5D1C9EA3 + gcMagic = 0xC2339F3D +) + +// discHeader reads the start of a disc (0x60 bytes are enough). +func discHeader(h []byte) (disc, bool) { + if len(h) < 0x60 { + return disc{}, false + } + d := disc{} + switch { + case binary.BigEndian.Uint32(h[0x18:]) == wiiMagic: + d.platform = "Wii" + case binary.BigEndian.Uint32(h[0x1C:]) == gcMagic: + d.platform = "GameCube" + default: + return disc{}, false + } + if id := string(h[:6]); isDiscID(id) { + d.id = id + } + name := h[0x20:0x60] + if z := bytes.IndexByte(name, 0); z >= 0 { + name = name[:z] + } + // Japanese discs name themselves in Shift JIS; only plain text is kept. + ascii := true + for _, c := range name { + if c < 0x20 || c > 0x7E { + ascii = false + break + } + } + if ascii { + d.title = cleanTitle(string(name)) + } + return d, true +} + +// readDisc reads a GameCube or Wii disc image's header, in whichever of +// the formats Dolphin plays it is: a plain image (.iso, .gcm), WBFS, CISO, +// WIA or RVZ (which keep the disc's header uncompressed), or GCZ (which +// only says which console). +func readDisc(path string) (disc, bool) { + h, err := readAt(path, 0, 0x100) + if err != nil { + return disc{}, false + } + return parseDisc(h, func(off int64) ([]byte, error) { return readAt(path, off, 0x60) }) +} + +// parseDisc works out the format from the first 0x100 bytes of an image; +// at reads 0x60 bytes further in, for the formats whose disc header isn't +// at the start. +func parseDisc(h []byte, at func(off int64) ([]byte, error)) (disc, bool) { + if len(h) < 0x100 { + return disc{}, false + } + switch string(h[:4]) { + case "WBFS": + // The disc's header follows the first hard disk sector. + shift := h[8] + if shift < 9 || shift > 16 { + return disc{}, false + } + b, err := at(1 << shift) + if err != nil { + return disc{}, false + } + return discHeader(b) + case "CISO": + b, err := at(0x8000) + if err != nil { + return disc{}, false + } + return discHeader(b) + case "WIA\x01", "RVZ\x01": + // WIA header 1 is 0x48 bytes; header 2 starts with the disc type + // and keeps the disc's first 0x80 bytes at 0x10. + d, ok := discHeader(h[0x58:0xD8]) + if !ok { + switch binary.BigEndian.Uint32(h[0x48:]) { + case 1: + return disc{platform: "GameCube"}, true + case 2: + return disc{platform: "Wii"}, true + } + } + return d, ok + } + if binary.LittleEndian.Uint32(h) == 0xB10BC001 { // GCZ + switch binary.LittleEndian.Uint32(h[4:]) { + case 0: + return disc{platform: "GameCube"}, true + case 1: + return disc{platform: "Wii"}, true + } + return disc{}, false + } + return discHeader(h) +} + +// parseTitleDB reads one of the GameTDB title lists Dolphin ships +// (Sys\wiitdb-en.txt): "RMCE01 = Mario Kart Wii" per line. +func parseTitleDB(b []byte) map[string]string { + m := map[string]string{} + sc := bufio.NewScanner(bytes.NewReader(b)) + sc.Buffer(make([]byte, 1024), 1024) + for sc.Scan() && len(m) < 100000 { + id, title, ok := strings.Cut(sc.Text(), " = ") + id = strings.TrimSpace(id) + if !ok || len(id) != 4 && len(id) != 6 || id == "TITLES" { + continue + } + if t := cleanTitle(title); t != "" { + m[strings.ToUpper(id)] = t + } + } + return m +} diff --git a/internal/emulated/dolphin.go b/internal/emulated/dolphin.go new file mode 100644 index 0000000..a71acd1 --- /dev/null +++ b/internal/emulated/dolphin.go @@ -0,0 +1,117 @@ +package emulated + +import ( + "bufio" + "bytes" + "path/filepath" + "strconv" + "strings" + + "github.com/ApolloF/Seaglass/internal/platform" +) + +// dolphin is the GameCube and Wii emulator. Its game folders are in +// Config\Dolphin.ini ([General] ISOPath0…, RecursiveISOPaths); its user +// folder is next to it when it's portable (portable.txt), else in the +// user's Documents or AppData. +var dolphin = &emulator{ + name: "Dolphin", + exes: []string{"dolphin.exe"}, + listed: func(in *install) ([]Game, []root) { + b, err := readSmall(filepath.Join(dolphinUserDir(in.dir), "Config", "Dolphin.ini"), 256<<10) + if err != nil { + return nil, nil + } + general := parseINI(b)["general"] + depth := 0 // only the files in each folder + if strings.EqualFold(general["recursiveisopaths"], "true") { + depth = maxDepth + } + var roots []root + n, _ := strconv.Atoi(general["isopaths"]) + for i := 0; i < min(n, 64); i++ { + if p := pathFrom("", general["isopath"+strconv.Itoa(i)]); p != "" { + roots = append(roots, root{path: p, depth: depth}) + } + } + return nil, roots + }, + exts: map[string]bool{".iso": true, ".gcm": true, ".wbfs": true, ".ciso": true, ".gcz": true, ".wia": true, ".rvz": true}, + file: func(in *install, path string, size int64) (Game, bool) { + d, ok := readDisc(path) + if !ok { + return Game{}, false // a PS2 or PC disc image, say + } + g := Game{Platform: d.platform, File: path, Serial: d.id, SizeBytes: size} + if t := in.titleDB()[d.id]; t != "" { + g.Title, g.TitleFrom = t, "Dolphin game database" + } else if t := in.titleDB()[d.id[:min(4, len(d.id))]]; d.id != "" && t != "" { + g.Title, g.TitleFrom = t, "Dolphin game database" + } else if strings.Contains(d.title, " ") { + g.Title, g.TitleFrom = titleCase(d.title), "disc" + } else { + g.Title = cleanName(filepath.Base(path)) + } + g.BaseTitle = g.Title + if d.id != "" { + if p := filepath.Join(dolphinUserDir(in.dir), "Cache", "GameCovers", d.id+".png"); platform.IsFile(p) { + g.Cover = p + } + } + return g, g.Title != "" + }, + // -b closes Dolphin when the game stops; -C applies only to this run. + args: func(file string) string { return "-b -C Main.Display.Fullscreen=True -e " + quote(file) }, +} + +// dolphinUserDir is where Dolphin keeps its settings. +func dolphinUserDir(dir string) string { + if platform.IsFile(filepath.Join(dir, "portable.txt")) { + return filepath.Join(dir, "User") + } + if platform.Documents != "" { + if d := filepath.Join(platform.Documents, "Dolphin Emulator"); platform.IsDir(d) { + return d + } + } + return filepath.Join(platform.Roaming, "Dolphin Emulator") +} + +// titleDB is Dolphin's English GameTDB title list, read once. +func (in *install) titleDB() map[string]string { + if !in.loaded { + in.loaded = true + if b, err := readSmall(filepath.Join(in.dir, "Sys", "wiitdb-en.txt"), 8<<20); err == nil { + in.titles = parseTitleDB(b) + } + } + return in.titles +} + +// parseINI reads an ini file into sections of keys, all lower-cased. +func parseINI(b []byte) map[string]map[string]string { + out := map[string]map[string]string{} + sec := "" + sc := bufio.NewScanner(bytes.NewReader(bytes.TrimPrefix(b, []byte("\xef\xbb\xbf")))) + sc.Buffer(make([]byte, 4096), 4096) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + switch { + case line == "" || line[0] == ';' || line[0] == '#': + case line[0] == '[' && strings.HasSuffix(line, "]"): + sec = strings.ToLower(strings.TrimSpace(line[1 : len(line)-1])) + default: + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + if out[sec] == nil { + out[sec] = map[string]string{} + } + if len(out[sec]) < 1000 { + out[sec][strings.ToLower(strings.TrimSpace(k))] = strings.TrimSpace(v) + } + } + } + return out +} diff --git a/internal/emulated/emulated.go b/internal/emulated/emulated.go new file mode 100644 index 0000000..9771dfb --- /dev/null +++ b/internal/emulated/emulated.go @@ -0,0 +1,541 @@ +// Package emulated finds console games played on emulators installed on +// this PC: the emulators (RPCS3, Dolphin, Cemu), the games their own +// settings and caches list, the game folders those settings point at, and +// how to start each game full screen. +// +// Everything read here is a file anyone could have written: each one is +// read with a size limit, folder walks are bounded, and paths found in +// files are cleaned and checked before they're used. +package emulated + +import ( + "context" + "io" + "os" + "path/filepath" + "sort" + "strings" + "syscall" + "time" + "unicode" + + "github.com/ApolloF/Seaglass/internal/logx" + "github.com/ApolloF/Seaglass/internal/platform" + "github.com/ApolloF/Seaglass/internal/scan" +) + +// Game is one console game an installed emulator can play. +type Game struct { + Title string // shown; a second copy of the same game gets its serial added + BaseTitle string // the title without that, for art lookups + Platform string // "PS3", "GameCube", "Wii", "Wii U" + Emulator string // "RPCS3", "Dolphin", "Cemu" + EmuDir string // the emulator's folder: its processes are the game while it runs + Exe string // the emulator's program + Args string // starts File full screen, without the emulator's own interface + File string // what the emulator boots (EBOOT.BIN, a disc image, an .rpx) + Dir string // the game's own folder + Serial string // the game's id on its console (BLES01689, RMCE01, 000500001010EC00) + SizeBytes int64 + How string // how it was found + TitleFrom string // where the title came from + // Pictures that came with the game (PS3 discs carry a 1920×1080 + // background and an icon) or that the emulator downloaded (Dolphin's + // covers). Paths of PNG files; "" when there's none. + Cover, Backdrop, Icon string + + listed bool // the emulator's own list has it (not only a folder walk) + remote bool // on a network drive +} + +// Key is the game's identity in the library: what's booted, so two +// copies of a game, or two games in one folder, stay apart. +func (g Game) Key() string { return "emu:" + platform.Key(g.File) } + +// install is one emulator found on this PC. +type install struct { + emu *emulator + dir string // its folder + exe string // its program + // titles is the emulator's own title database (Dolphin's GameTDB + // lists), read once when a game needs it. + titles map[string]string + loaded bool +} + +// root is a folder an emulator's settings say games are in. +type root struct { + path string + depth int // how many folders down to look: 0 is only its files +} + +// emulator is one emulator Seaglass knows. Supporting another one +// (RetroArch, Ryujinx, PCSX2) means adding one of these to emulators. +type emulator struct { + name string + exes []string // its program, as named in its folder (lower-case) + // listed returns the games the emulator's own settings and caches + // name, and the folders it looks for more in. + listed func(in *install) ([]Game, []root) + // exts are the files it plays, for the walk through the folders. + exts map[string]bool + // file reads one of those; ok is false when it isn't a game it plays. + file func(in *install, path string, size int64) (Game, bool) + // folder recognises a folder that is a game in itself (an unpacked + // PS3 disc, a Wii U game's code, content and meta folders). + folder func(in *install, dir string, names map[string]bool) (Game, bool) + // args are the command-line arguments that boot file full screen. + args func(file string) string +} + +// emulators are the ones Seaglass knows, in the order a file two of them +// could play is offered to them. +var emulators = []*emulator{rpcs3, dolphin, cemu} + +// Find returns the console games of the emulators installed on this PC. +// It never fails: what can't be read is left out. +func Find(ctx context.Context) []Game { + return find(ctx, findInstalls(installDirs()), time.Now().Add(walkBudget)) +} + +func find(ctx context.Context, ins []*install, deadline time.Time) []Game { + var games []Game + var roots []root + for _, in := range ins { + func() { + defer recoverLog(in.emu.name) + gs, rs := in.emu.listed(in) + for i := range gs { + gs[i].listed = true + games = append(games, in.fill(gs[i])) + } + roots = append(roots, rs...) + }() + } + w := &walker{ctx: ctx, ins: ins, deadline: deadline, seen: map[string]bool{}} + for _, g := range games { + w.seen[platform.Key(g.File)] = true + } + for _, r := range mergeRoots(roots) { + func() { + defer recoverLog("walk " + r.path) + w.walk(r) + }() + } + if w.stopped != "" { + logx.Printf("emulated games: folder walk stopped early (%s)", w.stopped) + } + games = append(games, w.found...) + games = collapse(games) + nameCopies(games) + sort.Slice(games, func(i, j int) bool { + if a, b := scan.SortTitle(games[i].Title), scan.SortTitle(games[j].Title); a != b { + return a < b + } + return platform.Key(games[i].File) < platform.Key(games[j].File) + }) + return games +} + +// fill completes a game an emulator found with what's the same for all +// of its games. +func (in *install) fill(g Game) Game { + g.Emulator, g.EmuDir, g.Exe = in.emu.name, in.dir, in.exe + g.Args = in.emu.args(g.File) + if g.Dir == "" { + g.Dir = filepath.Dir(g.File) + } + if g.BaseTitle == "" { + g.BaseTitle = g.Title + } + g.remote = isRemote(g.File) + return g +} + +// collapse keeps one of several copies of the same game: two files with +// the same serial, or with the same name and size (a game copied from a +// network drive to a local one). A local copy beats one on the network, +// then a copy the emulator lists beats one only the walk found. What the +// kept copy lacks (its serial, a title from the emulator, pictures) comes +// from the others. +func collapse(games []Game) []Game { + var out []Game + byFile := map[string]bool{} + for _, g := range games { + k := platform.Key(g.File) + if byFile[k] { + continue // two installs of an emulator listing the same file + } + byFile[k] = true + i := -1 + for j := range out { + if sameGame(out[j], g) { + i = j + break + } + } + if i < 0 { + out = append(out, g) + continue + } + keep, other := out[i], g + if better(other, keep) { + keep, other = other, keep + } + if keep.Serial == "" { + keep.Serial = other.Serial + } + if !keep.listed && other.listed && other.TitleFrom != "" { + keep.Title, keep.BaseTitle, keep.TitleFrom = other.Title, other.BaseTitle, other.TitleFrom + } + if keep.Cover == "" { + keep.Cover = other.Cover + } + if keep.Backdrop == "" { + keep.Backdrop = other.Backdrop + } + if keep.Icon == "" { + keep.Icon = other.Icon + } + out[i] = keep + } + return out +} + +func sameGame(a, b Game) bool { + if a.Platform != b.Platform { + return false + } + if a.Serial != "" && b.Serial != "" { + return strings.EqualFold(a.Serial, b.Serial) + } + return a.SizeBytes > 0 && a.SizeBytes == b.SizeBytes && strings.EqualFold(filepath.Base(a.File), filepath.Base(b.File)) +} + +// better says whether a is the copy to keep rather than b. +func better(a, b Game) bool { + if a.remote != b.remote { + return !a.remote + } + if a.listed != b.listed { + return a.listed + } + return len(a.File) < len(b.File) +} + +// nameCopies adds the serial to the titles of different copies of one +// game that are still both there (two regions' discs), so they can be +// told apart. +func nameCopies(games []Game) { + n := map[string]int{} + key := func(g Game) string { return g.Platform + "|" + scan.LooseKey(g.BaseTitle) } + for _, g := range games { + n[key(g)]++ + } + for i, g := range games { + if n[key(g)] > 1 && g.Serial != "" { + games[i].Title = g.BaseTitle + " (" + g.Serial + ")" + } + } +} + +// mergeRoots drops folders another one already covers. +func mergeRoots(rs []root) []root { + sort.SliceStable(rs, func(i, j int) bool { + if rs[i].depth != rs[j].depth { + return rs[i].depth > rs[j].depth + } + return len(rs[i].path) < len(rs[j].path) + }) + var out []root + for _, r := range rs { + if r.path == "" || !filepath.IsAbs(r.path) { + continue + } + r.path = filepath.Clean(r.path) + covered := false + for i, o := range out { + if platform.Key(o.path) == platform.Key(r.path) { + out[i].depth = max(o.depth, r.depth) + covered = true + break + } + if o.depth >= maxDepth && platform.Within(o.path, r.path) { + covered = true + break + } + } + if !covered { + out = append(out, r) + } + } + return out +} + +// findInstalls looks for each emulator's program in the folders given, +// and in their subfolders (C:\Emulators\RPCS3\rpcs3.exe). The first +// folder an emulator is found in is the one used. +func findInstalls(dirs []string) []*install { + var out []*install + have := map[*emulator]bool{} + check := func(dir string) { + names := lowerNames(dir, 400) + for _, e := range emulators { + if have[e] { + continue + } + for _, x := range e.exes { + if real, ok := names[x]; ok { + exe := filepath.Join(dir, real) + if platform.IsFile(exe) { + have[e] = true + out = append(out, &install{emu: e, dir: filepath.Clean(dir), exe: exe}) + break + } + } + } + } + } + for _, d := range dirs { + if !platform.IsDir(d) { + continue + } + check(d) + ents, err := readDir(d, 400) + if err != nil { + continue + } + for _, e := range ents { + if e.IsDir() && looksLikeEmulator(e.Name()) { + check(filepath.Join(d, e.Name())) + } + } + } + return out +} + +// looksLikeEmulator keeps the walk for emulators away from folders that +// are clearly something else (Program Files holds hundreds). +func looksLikeEmulator(name string) bool { + n := strings.ToLower(name) + for _, e := range emulators { + if strings.Contains(n, strings.ToLower(e.name)) { + return true + } + } + return strings.Contains(n, "emu") +} + +// installDirs are the folders emulators are usually unpacked or installed +// in: an Emulators (or Emulation, Emu) folder on any fixed drive, also +// inside a Games folder, Program Files, the user's programs and Scoop. +func installDirs() []string { + var out []string + for _, d := range platform.FixedDrives() { + for _, rel := range []string{"Emulators", "Emulation", "Emu", `Games\Emulators`, `Games\Emulation`} { + out = append(out, filepath.Join(d, rel)) + } + } + for _, d := range []string{platform.ProgramFiles, platform.ProgramFilesX86} { + if d != "" { + out = append(out, d) + } + } + if platform.Local != "" { + out = append(out, filepath.Join(platform.Local, "Programs")) + } + if platform.Profile != "" { + scoop := filepath.Join(platform.Profile, "scoop", "apps") + for _, e := range []string{"rpcs3", "dolphin", "dolphin-dev", "cemu"} { + out = append(out, filepath.Join(scoop, e, "current")) + } + } + return out +} + +// lowerNames lists a folder's entries by lower-cased name. +func lowerNames(dir string, limit int) map[string]string { + ents, err := readDir(dir, limit) + if err != nil { + return nil + } + m := make(map[string]string, len(ents)) + for _, e := range ents { + m[strings.ToLower(e.Name())] = e.Name() + } + return m +} + +// readDir lists at most limit entries of a folder. +func readDir(dir string, limit int) ([]os.DirEntry, error) { + f, err := os.Open(dir) + if err != nil { + return nil, err + } + defer f.Close() + ents, err := f.ReadDir(limit) + if err == io.EOF { + err = nil + } + return ents, err +} + +// readSmall reads a whole file of at most limit bytes; larger files are +// refused rather than cut short. +func readSmall(p string, limit int64) ([]byte, error) { + f, err := os.Open(p) + if err != nil { + return nil, err + } + defer f.Close() + b, err := io.ReadAll(io.LimitReader(f, limit+1)) + if err != nil { + return nil, err + } + if int64(len(b)) > limit { + return nil, errTooLarge + } + return b, nil +} + +// readAt reads n bytes at off (a disc image's header). +func readAt(p string, off int64, n int) ([]byte, error) { + f, err := os.Open(p) + if err != nil { + return nil, err + } + defer f.Close() + b := make([]byte, n) + if _, err := f.ReadAt(b, off); err != nil { + return nil, err + } + return b, nil +} + +type tooLarge struct{} + +func (tooLarge) Error() string { return "file too large" } + +var errTooLarge error = tooLarge{} + +// pathFrom turns a path an emulator's settings hold (often with forward +// slashes, sometimes relative to the emulator) into a clean absolute one, +// or "" when it isn't one. +func pathFrom(base, p string) string { + p = strings.TrimSpace(p) + if p == "" || strings.ContainsAny(p, "\x00\r\n") || len(p) > 1024 { + return "" + } + p = filepath.FromSlash(p) + if !filepath.IsAbs(p) { + if base == "" || strings.HasPrefix(p, `\`) { + return "" + } + p = filepath.Join(base, p) + } + p = filepath.Clean(p) + if strings.HasPrefix(p, `\\?\`) || strings.HasPrefix(p, `\\.\`) { + return "" // device paths + } + return p +} + +// quote makes one command-line argument of a path. +func quote(p string) string { return syscall.EscapeArg(p) } + +// titleCase turns an all-capitals title ("MARIO KART 8", as discs and +// Cemu's cache often have them) into "Mario Kart 8". Titles with any +// lower-case letter are left as they are. +func titleCase(s string) string { + s = strings.Join(strings.Fields(s), " ") + for _, r := range s { + if unicode.IsLower(r) { + return s + } + } + words := strings.Fields(strings.ToLower(s)) + for i, w := range words { + rs := []rune(w) + rs[0] = unicode.ToUpper(rs[0]) + words[i] = string(rs) + } + return strings.Join(words, " ") +} + +// cleanName makes a title of a file or folder name: "RMCE01 Mario Kart +// Wii" → "Mario Kart Wii", "Skylanders Giants [BLES01689]" → "Skylanders +// Giants", "Mario Kart Double Dash (USA).nkit.iso" → "Mario Kart Double Dash". +func cleanName(name string) string { + low := strings.ToLower(name) + for _, ext := range []string{".nkit.iso", ".nkit.gcz", ".nkit.rvz"} { + if strings.HasSuffix(low, ext) { + name = name[:len(name)-len(ext)] + } + } + if ext := filepath.Ext(name); ext != "" && len(ext) <= 5 && !strings.Contains(ext, " ") { + name = strings.TrimSuffix(name, ext) + } + if f := strings.Fields(name); len(f) > 1 && isDiscID(f[0]) { + name = strings.Join(f[1:], " ") + } + return titleCase(scan.CleanTitle(name)) +} + +// isDiscID reports whether s looks like a GameCube or Wii game id (RMCE01). +func isDiscID(s string) bool { + if len(s) != 6 { + return false + } + for _, r := range s { + if !(r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') { + return false + } + } + return true +} + +// cleanTitle tidies a title read from a game's own files: no control +// characters or trademark signs, single spaces (a PS3 title can have a +// line break in it). +func cleanTitle(s string) string { + s = strings.Map(func(r rune) rune { + switch { + case r == '™' || r == '®' || r == '©': + return -1 + case unicode.IsControl(r) || r == unicode.ReplacementChar: + return ' ' + } + return r + }, s) + s = strings.Join(strings.Fields(s), " ") + if len(s) > 200 { + s = s[:200] + } + return strings.ToValidUTF8(s, "") +} + +func isRemote(p string) bool { + if strings.HasPrefix(p, `\\`) { + return true + } + vol := filepath.VolumeName(p) + if vol == "" { + return false + } + return platform.DriveRemote(vol + `\`) +} + +func recoverLog(what string) { + if r := recover(); r != nil { + logx.Printf("emulated games %s: panic: %v", what, r) + } +} + +// nameSet is the set of a folder's entries, by lower-cased name. +func nameSet(dir string, limit int) map[string]bool { + m := map[string]bool{} + for k := range lowerNames(dir, limit) { + m[k] = true + } + return m +} diff --git a/internal/emulated/emulated_test.go b/internal/emulated/emulated_test.go new file mode 100644 index 0000000..320c838 --- /dev/null +++ b/internal/emulated/emulated_test.go @@ -0,0 +1,374 @@ +package emulated + +import ( + "bytes" + "context" + "encoding/binary" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// write creates files under root; a value of nil bytes makes a folder. +func write(t *testing.T, root string, files map[string][]byte) { + t.Helper() + for rel, b := range files { + p := filepath.Join(root, filepath.FromSlash(rel)) + if b == nil { + if err := os.MkdirAll(p, 0o755); err != nil { + t.Fatal(err) + } + continue + } + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, b, 0o644); err != nil { + t.Fatal(err) + } + } +} + +// sfo builds a PARAM.SFO with text values. +func sfo(vals map[string]string) []byte { + var keys, data bytes.Buffer + type entry struct{ k, d, n uint32 } + var es []entry + for _, k := range []string{"CATEGORY", "TITLE", "TITLE_ID"} { + v, ok := vals[k] + if !ok { + continue + } + es = append(es, entry{uint32(keys.Len()), uint32(data.Len()), uint32(len(v) + 1)}) + keys.WriteString(k + "\x00") + data.WriteString(v + "\x00") + } + le := binary.LittleEndian + hdr := 20 + 16*len(es) + out := make([]byte, hdr) + copy(out, "\x00PSF") + le.PutUint32(out[4:], 0x101) + le.PutUint32(out[8:], uint32(hdr)) + le.PutUint32(out[12:], uint32(hdr+keys.Len())) + le.PutUint32(out[16:], uint32(len(es))) + for i, e := range es { + b := out[20+16*i:] + le.PutUint16(b[0:], uint16(e.k)) + le.PutUint16(b[2:], 0x0204) + le.PutUint32(b[4:], e.n) + le.PutUint32(b[8:], e.n) + le.PutUint32(b[12:], e.d) + } + return append(append(out, keys.Bytes()...), data.Bytes()...) +} + +// gcDisc is the start of a GameCube (or, with wii, a Wii) disc image. +func gcDisc(id, name string, wii bool, size int) []byte { + b := make([]byte, max(size, 0x400)) + copy(b, id) + if wii { + binary.BigEndian.PutUint32(b[0x18:], wiiMagic) + } else { + binary.BigEndian.PutUint32(b[0x1C:], gcMagic) + } + copy(b[0x20:], name) + return b +} + +func ps3Disc(serial, title, category string) map[string][]byte { + return map[string][]byte{ + "PS3_GAME/PARAM.SFO": sfo(map[string]string{"CATEGORY": category, "TITLE": title, "TITLE_ID": serial}), + "PS3_GAME/USRDIR/EBOOT.BIN": []byte("eboot"), + "PS3_GAME/PIC1.PNG": []byte("png"), + } +} + +func under(prefix string, files map[string][]byte) map[string][]byte { + out := map[string][]byte{} + for k, v := range files { + out[prefix+"/"+k] = v + } + return out +} + +func findIn(t *testing.T, dirs ...string) []Game { + t.Helper() + return find(context.Background(), findInstalls(dirs), time.Now().Add(time.Minute)) +} + +func byTitle(games []Game) map[string]Game { + m := map[string]Game{} + for _, g := range games { + m[g.Title] = g + } + return m +} + +func TestRPCS3GameListAndCopies(t *testing.T) { + root := t.TempDir() + emu := filepath.Join(root, "Emulators") + games := filepath.Join(root, "Games", "PS3") + write(t, emu, map[string][]byte{"RPCS3/rpcs3.exe": []byte("x")}) + write(t, games, under("Swap Force [BLES01860]", ps3Disc("BLES01860", "Skylanders SWAP Force", "DG"))) + // A second region's copy RPCS3 doesn't list, next to the one it does. + write(t, games, under("Swap Force [BLES01966]", ps3Disc("BLES01966", "Skylanders SWAP Force", "DG"))) + write(t, games, under("Super [BLES02172]", ps3Disc("BLES02172", "Skylanders™\nSuperChargers", "DG"))) + gy := "BLES01860: \"" + filepath.ToSlash(filepath.Join(games, "Swap Force [BLES01860]")) + "/\"\n" + + "BLES02172: " + filepath.ToSlash(filepath.Join(games, "Super [BLES02172]")) + "/\n" + + "BLUS00000: \"Z:/gone/\"\n" + write(t, emu, map[string][]byte{"RPCS3/config/games.yml": []byte(gy)}) + // Installed: a game (HG) and an update (GD), which can't be booted. + write(t, emu, map[string][]byte{ + "RPCS3/dev_hdd0/game/NPEB00001/PARAM.SFO": sfo(map[string]string{"CATEGORY": "HG", "TITLE": "Flower", "TITLE_ID": "NPEB00001"}), + "RPCS3/dev_hdd0/game/NPEB00001/USRDIR/EBOOT.BIN": []byte("x"), + "RPCS3/dev_hdd0/game/BLES01860/PARAM.SFO": sfo(map[string]string{"CATEGORY": "GD", "TITLE": "Update", "TITLE_ID": "BLES01860"}), + "RPCS3/dev_hdd0/game/BLES01860/USRDIR/EBOOT.BIN": []byte("x"), + }) + + got := byTitle(findIn(t, emu)) + if len(got) != 4 { + t.Fatalf("want 4 games, got %v", got) + } + a, b := got["Skylanders SWAP Force (BLES01860)"], got["Skylanders SWAP Force (BLES01966)"] + if a.File == "" || b.File == "" { + t.Fatalf("both copies should be there, told apart by serial: %v", got) + } + if a.BaseTitle != "Skylanders SWAP Force" || !a.listed || b.listed { + t.Fatalf("copies: %+v %+v", a, b) + } + s := got["Skylanders SuperChargers"] + if s.Platform != "PS3" || s.Serial != "BLES02172" || !strings.HasSuffix(s.File, `PS3_GAME\USRDIR\EBOOT.BIN`) || s.Backdrop == "" { + t.Fatalf("superchargers: %+v", s) + } + if want := "--no-gui --fullscreen " + quote(s.File); s.Args != want { + t.Fatalf("args %q, want %q", s.Args, want) + } + if got["Flower"].How != "RPCS3 installed games" { + t.Fatalf("installed game: %+v", got["Flower"]) + } + if _, ok := got["Update"]; ok { + t.Fatal("game data isn't a game") + } +} + +func TestDolphinFoldersAndTitles(t *testing.T) { + root := t.TempDir() + emu := filepath.Join(root, "Emulators", "Dolphin-x64") + lib := filepath.Join(root, "Games") + ini := "[General]\nISOPaths = 1\nISOPath0 = " + filepath.ToSlash(lib) + "\nRecursiveISOPaths = True\n" + write(t, emu, map[string][]byte{ + "Dolphin.exe": []byte("x"), + "portable.txt": []byte(""), + "User/Config/Dolphin.ini": []byte(ini), + "Sys/wiitdb-en.txt": []byte("TITLES = https://www.gametdb.com\nGM4E01 = Mario Kart: Double Dash!!\n"), + "User/Cache/GameCovers/GM4E01.png": []byte("png"), + }) + wbfs := make([]byte, 0x400) + copy(wbfs, "WBFS") + wbfs[8] = 9 + copy(wbfs[0x200:], gcDisc("RMCE01", "MarioKartWii", true, 0x60)[:0x60]) + write(t, lib, map[string][]byte{ + "GameCube/Double Dash/Double Dash (USA).iso": gcDisc("GM4E01", "Mario Kart Double Dash!", false, 0), + "Wii/RMCE01 Mario Kart Wii/RMCE01.wbfs": wbfs, + "Wii/Flat/Zelda Twilight Princess.iso": gcDisc("RZDE01", "ZeldaTP", true, 0), + "Wii/Flat/Other Game.iso": gcDisc("RXXE01", "Other Game Disc", true, 0), + "PS2/Ratchet.iso": make([]byte, 0x400), // not a GameCube or Wii disc + // A PC game's folder: its files aren't looked at. + "Some PC Game/game.exe": []byte("x"), + "Some PC Game/extra.iso": gcDisc("GXXE01", "Hidden", false, 0), + "a/b/c/d/e/f/g/too deep.iso": gcDisc("GDPE01", "Deep", false, 0), + }) + + got := byTitle(findIn(t, filepath.Join(root, "Emulators"))) + dd := got["Mario Kart: Double Dash!!"] + if dd.Platform != "GameCube" || dd.Serial != "GM4E01" || dd.TitleFrom != "Dolphin game database" || dd.Cover == "" { + t.Fatalf("double dash: %+v (all: %v)", dd, got) + } + if want := "-b -C Main.Display.Fullscreen=True -e " + quote(dd.File); dd.Args != want { + t.Fatalf("args %q", dd.Args) + } + if w := got["Mario Kart Wii"]; w.Platform != "Wii" || w.Serial != "RMCE01" || w.TitleFrom != "folder name" { + t.Fatalf("wii (folder named after the game, file by its id): %+v", w) + } + // Two games in one folder are named by their files, or their disc's + // own name when it reads like a title. + if g := got["Zelda Twilight Princess"]; g.TitleFrom != "file name" { + t.Fatalf("zelda: %+v", g) + } + if g := got["Other Game Disc"]; g.TitleFrom != "disc" { + t.Fatalf("other: %+v", g) + } + for _, bad := range []string{"Ratchet", "Hidden", "Deep"} { + if _, ok := got[bad]; ok { + t.Fatalf("%s shouldn't be found: %v", bad, got) + } + } + if len(got) != 4 { + t.Fatalf("want 4 games, got %v", got) + } +} + +func TestCemuListAndCopies(t *testing.T) { + root := t.TempDir() + emu := filepath.Join(root, "Emu", "Cemu") + local := filepath.Join(root, "Games", "Wii U") + elsewhere := filepath.Join(root, "Share", "Wii U") + wud := bytes.Repeat([]byte{1}, 2<<20) + write(t, local, map[string][]byte{"Mario Kart 8/fk-mk8.wud": wud}) + write(t, elsewhere, map[string][]byte{"Mario Kart 8/fk-mk8.wud": wud}) + cache := ` + + <name>MARIO KART 8</name><path>` + filepath.ToSlash(filepath.Join(elsewhere, "Mario Kart 8", "fk-mk8.wud")) + `</path> + <name>Gone</name><path>C:/nowhere/gone.wud</path> +` + settings := `` + local + `` + write(t, emu, map[string][]byte{ + "Cemu.exe": []byte("x"), + "portable/settings.xml": []byte(settings), + "portable/title_list_cache.xml": []byte(cache), + // Installed to Cemu's storage. + "mlc01/usr/title/00050000/10144f00/code/Zelda.rpx": []byte("rpx"), + "mlc01/usr/title/00050000/10144f00/meta/meta.xml": []byte(`0005000010144F00The Legend of Zelda Breath of the Wild`), + "mlc01/usr/title/00050000/10144f00/content/data.bin": []byte("x"), + }) + + games := findIn(t, filepath.Join(root, "Emu")) + got := byTitle(games) + if len(games) != 2 { + t.Fatalf("the two copies of Mario Kart 8 are one game: %v", got) + } + mk := got["Mario Kart 8"] + if mk.Serial != "000500001010EC00" || mk.TitleFrom != "Cemu game list" || mk.Platform != "Wii U" { + t.Fatalf("mario kart 8: %+v", mk) + } + if want := "-f -g " + quote(mk.File); mk.Args != want { + t.Fatalf("args %q", mk.Args) + } + z := got["The Legend of Zelda Breath of the Wild"] + if z.Serial != "0005000010144F00" || !strings.HasSuffix(z.File, "Zelda.rpx") { + t.Fatalf("zelda: %+v (all %v)", z, got) + } +} + +func TestCollapsePrefersLocalListedCopy(t *testing.T) { + a := Game{Platform: "Wii U", File: `Z:\a\mk8.wud`, SizeBytes: 5, Serial: "X", Title: "Mario Kart 8", BaseTitle: "Mario Kart 8", TitleFrom: "Cemu game list", listed: true, remote: true} + b := Game{Platform: "Wii U", File: `R:\Games\mk8.wud`, SizeBytes: 5, Title: "mk8", BaseTitle: "mk8"} + got := collapse([]Game{a, b}) + if len(got) != 1 || got[0].File != b.File || got[0].Title != "Mario Kart 8" || got[0].Serial != "X" { + t.Fatalf("got %+v", got) + } + // Same name and size on another console isn't the same game. + c := b + c.Platform = "Wii" + if n := len(collapse([]Game{a, c})); n != 2 { + t.Fatalf("want 2, got %d", n) + } +} + +func TestCleanName(t *testing.T) { + for in, want := range map[string]string{ + "RMCE01 Mario Kart Wii": "Mario Kart Wii", + "Skylanders Giants [BLES01689]": "Skylanders Giants", + "Mario Kart Double Dash (USA).nkit.iso": "Mario Kart Double Dash", + "MARIO KART 8.wud": "Mario Kart 8", + "Metroid Prime (Europe) (En,Fr,De).rvz": "Metroid Prime", + } { + if got := cleanName(in); got != want { + t.Errorf("cleanName(%q) = %q, want %q", in, got, want) + } + } +} + +func TestParseDiscFormats(t *testing.T) { + at := func(b []byte) func(int64) ([]byte, error) { + return func(off int64) ([]byte, error) { + if off+0x60 > int64(len(b)) { + return nil, os.ErrNotExist + } + return b[off : off+0x60], nil + } + } + rvz := make([]byte, 0x100) + copy(rvz, "RVZ\x01") + copy(rvz[0x58:], gcDisc("GALE01", "Super Smash Bros Melee", false, 0)[:0x80]) + gcz := make([]byte, 0x100) + binary.LittleEndian.PutUint32(gcz, 0xB10BC001) + binary.LittleEndian.PutUint32(gcz[4:], 1) + wbfsBad := make([]byte, 0x100) + copy(wbfsBad, "WBFS") + wbfsBad[8] = 40 // a sector size no WBFS has + for name, c := range map[string]struct { + b []byte + want disc + ok bool + }{ + "rvz": {rvz, disc{id: "GALE01", title: "Super Smash Bros Melee", platform: "GameCube"}, true}, + "gcz": {gcz, disc{platform: "Wii"}, true}, + "bad wbfs": {wbfsBad, disc{}, false}, + "zeros": {make([]byte, 0x100), disc{}, false}, + "short": {[]byte("WBFS"), disc{}, false}, + } { + got, ok := parseDisc(c.b, at(c.b)) + if ok != c.ok || got != c.want { + t.Errorf("%s: got %+v %v, want %+v %v", name, got, ok, c.want, c.ok) + } + } +} + +func TestParseSFORejectsBrokenTables(t *testing.T) { + good := sfo(map[string]string{"CATEGORY": "DG", "TITLE": "Game", "TITLE_ID": "BLES00001"}) + if m, err := parseSFO(good); err != nil || m["TITLE"] != "Game" { + t.Fatalf("good file: %v %v", m, err) + } + huge := append([]byte(nil), good...) + binary.LittleEndian.PutUint32(huge[16:], 1<<30) // more entries than the file holds + if _, err := parseSFO(huge); err == nil { + t.Fatal("entry count past the end accepted") + } + off := append([]byte(nil), good...) + binary.LittleEndian.PutUint32(off[20+12:], 1<<31) // value past the end + if _, err := parseSFO(off); err == nil { + t.Fatal("value past the end accepted") + } +} + +func TestOversizedFilesAreIgnored(t *testing.T) { + root := t.TempDir() + emu := filepath.Join(root, "Emulators") + games := filepath.Join(root, "PS3") + write(t, games, under("A", ps3Disc("BLES00001", "Game A", "DG"))) + line := "BLES00001: " + filepath.ToSlash(filepath.Join(games, "A")) + "\n" + big := strings.Repeat("# padding\n", (1<<20)/10+1) + line + write(t, emu, map[string][]byte{"RPCS3/rpcs3.exe": []byte("x"), "RPCS3/config/games.yml": []byte(big)}) + if got := findIn(t, emu); len(got) != 0 { + t.Fatalf("a games.yml over 1 MB is not read: %v", got) + } +} + +func TestPathFrom(t *testing.T) { + for _, c := range []struct{ base, in, want string }{ + {`C:\Emu`, "R:/Games/", `R:\Games`}, + {`C:\Emu`, "games", `C:\Emu\games`}, + {`C:\Emu`, `\\?\C:\x`, ""}, + {"", "relative", ""}, + {`C:\Emu`, "a\x00b", ""}, + {`C:\Emu`, `\rooted`, ""}, + } { + if got := pathFrom(c.base, c.in); got != c.want { + t.Errorf("pathFrom(%q, %q) = %q, want %q", c.base, c.in, got, c.want) + } + } +} + +func TestWalkStopsAtItsBounds(t *testing.T) { + dir := t.TempDir() + write(t, dir, map[string][]byte{"a/x.iso": gcDisc("GM4E01", "Mario Kart", false, 0)}) + in := &install{emu: dolphin, dir: dir, exe: filepath.Join(dir, "Dolphin.exe")} + w := &walker{ctx: context.Background(), ins: []*install{in}, deadline: time.Now().Add(-time.Second), seen: map[string]bool{}} + w.walk(root{path: dir, depth: maxDepth}) + if w.stopped == "" || len(w.found) != 0 { + t.Fatalf("a walk past its deadline should stop: %q %v", w.stopped, w.found) + } +} diff --git a/internal/emulated/fuzz_test.go b/internal/emulated/fuzz_test.go new file mode 100644 index 0000000..da031e1 --- /dev/null +++ b/internal/emulated/fuzz_test.go @@ -0,0 +1,60 @@ +package emulated + +import ( + "testing" +) + +// The emulators' files and the games' own are untrusted: none of their +// parsers may panic or loop on any input. + +func FuzzSFO(f *testing.F) { + f.Add(sfo(map[string]string{"CATEGORY": "DG", "TITLE": "A", "TITLE_ID": "BLES00001"})) + f.Add([]byte("\x00PSF\x01\x01\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff")) + f.Fuzz(func(t *testing.T, b []byte) { _, _ = parseSFO(b) }) +} + +func FuzzDisc(f *testing.F) { + f.Add(gcDisc("GM4E01", "Mario Kart", false, 0)[:0x100]) + wbfs := make([]byte, 0x100) + copy(wbfs, "WBFS") + wbfs[8] = 9 + f.Add(wbfs) + f.Add(append([]byte("RVZ\x01"), make([]byte, 0xFC)...)) + f.Fuzz(func(t *testing.T, b []byte) { + _, _ = parseDisc(b, func(off int64) ([]byte, error) { + if off < 0 || off+0x60 > int64(len(b)) { + return make([]byte, 0x60), nil + } + return b[off : off+0x60], nil + }) + }) +} + +func FuzzGamesYML(f *testing.F) { + f.Add([]byte("BLES01689: \"R:/Games/PS3/A/\"\nX: 'it''s'\n")) + f.Fuzz(func(t *testing.T, b []byte) { + _ = parseGamesYML(b) + _ = vfsHDD(b, `C:\Emu`) + }) +} + +func FuzzTitleDB(f *testing.F) { + f.Add([]byte("TITLES = x\nRMCE01 = Mario Kart Wii\n")) + f.Fuzz(func(t *testing.T, b []byte) { _ = parseTitleDB(b) }) +} + +func FuzzINI(f *testing.F) { + f.Add([]byte("\xef\xbb\xbf[General]\nISOPaths = 1\nISOPath0 = R:/Games\n")) + f.Fuzz(func(t *testing.T, b []byte) { _ = parseINI(b) }) +} + +func FuzzCemuXML(f *testing.F) { + f.Add([]byte(`Z:\xC:\m`)) + f.Add([]byte(`<name>A</name><path>B</path>`)) + f.Add([]byte(`0005000010144F00Zelda`)) + f.Fuzz(func(t *testing.T, b []byte) { + _ = parseCemuSettings(b) + _ = parseCemuTitles(b) + _ = parseWiiUMeta(b) + }) +} diff --git a/internal/emulated/live_test.go b/internal/emulated/live_test.go new file mode 100644 index 0000000..ed52c40 --- /dev/null +++ b/internal/emulated/live_test.go @@ -0,0 +1,27 @@ +package emulated + +import ( + "context" + "os" + "testing" + "time" +) + +// TestRealFind lists the emulators and console games on this PC: +// WL_REAL_EMU=1 go test -run RealFind -v ./internal/emulated +func TestRealFind(t *testing.T) { + if os.Getenv("WL_REAL_EMU") == "" { + t.Skip("set WL_REAL_EMU=1 to scan this PC") + } + start := time.Now() + ins := findInstalls(installDirs()) + for _, in := range ins { + t.Logf("emulator %s: %s", in.emu.name, in.exe) + } + games := find(context.Background(), ins, time.Now().Add(walkBudget)) + t.Logf("%d games in %v", len(games), time.Since(start).Round(time.Millisecond)) + for _, g := range games { + t.Logf("%-8s %-36q %-16s %s (%s; title: %s)\n %s %s\n art: cover %q backdrop %q icon %q", + g.Platform, g.Title, g.Serial, g.File, g.How, g.TitleFrom, g.Exe, g.Args, g.Cover, g.Backdrop, g.Icon) + } +} diff --git a/internal/emulated/rpcs3.go b/internal/emulated/rpcs3.go new file mode 100644 index 0000000..c8b7a8e --- /dev/null +++ b/internal/emulated/rpcs3.go @@ -0,0 +1,184 @@ +package emulated + +import ( + "bufio" + "bytes" + "path/filepath" + "strings" + + "github.com/ApolloF/Seaglass/internal/platform" +) + +// rpcs3 is the PlayStation 3 emulator. Its game list is config\games.yml +// (serial: folder), plus the games installed to its virtual hard disk +// (dev_hdd0\game). Games are unpacked discs (PS3_GAME\USRDIR\EBOOT.BIN) +// or installed packages (USRDIR\EBOOT.BIN), booted by their EBOOT.BIN. +var rpcs3 = &emulator{ + name: "RPCS3", + exes: []string{"rpcs3.exe"}, + listed: func(in *install) ([]Game, []root) { + var games []Game + var roots []root + cfg := filepath.Join(in.dir, "config") + if !platform.IsDir(cfg) { + cfg = in.dir // before 0.0.30 its files were next to the program + } + if b, err := readSmall(filepath.Join(cfg, "games.yml"), 1<<20); err == nil { + for _, p := range parseGamesYML(b) { + dir := pathFrom(in.dir, p) + if dir == "" { + continue + } + if g, ok := ps3Game(dir); ok { + g.How = "RPCS3 game list" + games = append(games, g) + } + // Other games are often unpacked next to the ones RPCS3 has + // booted (a second copy of a disc RPCS3 forgot). + roots = append(roots, root{path: filepath.Dir(dir), depth: 1}) + } + } + hdd := filepath.Join(in.dir, "dev_hdd0") + if b, err := readSmall(filepath.Join(cfg, "vfs.yml"), 64<<10); err == nil { + if p := vfsHDD(b, in.dir); p != "" { + hdd = p + } + } + for _, sub := range []string{"game", "disc"} { + base := filepath.Join(hdd, sub) + ents, err := readDir(base, 2000) + if err != nil { + continue + } + for _, e := range ents { + if !e.IsDir() { + continue + } + if g, ok := ps3Game(filepath.Join(base, e.Name())); ok { + g.How = "RPCS3 installed games" + games = append(games, g) + } + } + } + return games, roots + }, + folder: func(in *install, dir string, names map[string]bool) (Game, bool) { + if !names["ps3_game"] && !(names["param.sfo"] && names["usrdir"]) { + return Game{}, false + } + return ps3Game(dir) + }, + args: func(file string) string { return "--no-gui --fullscreen " + quote(file) }, +} + +// ps3Game reads a PS3 game's folder: an unpacked disc, or a game +// installed from a package. Game data and updates (which can't be booted) +// aren't games. +func ps3Game(dir string) (Game, bool) { + base := dir + if platform.IsDir(filepath.Join(dir, "PS3_GAME")) { + base = filepath.Join(dir, "PS3_GAME") + } + eboot := filepath.Join(base, "USRDIR", "EBOOT.BIN") + if !platform.IsFile(eboot) { + return Game{}, false + } + b, err := readSmall(filepath.Join(base, "PARAM.SFO"), maxSFO) + if err != nil { + return Game{}, false + } + sfo, err := parseSFO(b) + if err != nil { + return Game{}, false + } + switch sfo["CATEGORY"] { + case "DG", "HG": // disc game, hard disk game + default: + return Game{}, false + } + serial := sfo["TITLE_ID"] + if !isSerial(serial) { + serial = "" + } + g := Game{Platform: "PS3", File: eboot, Dir: dir, Serial: serial, Title: cleanTitle(sfo["TITLE"]), TitleFrom: "PARAM.SFO"} + if g.Title == "" { + g.Title, g.TitleFrom = cleanName(filepath.Base(dir)), "folder name" + } + g.BaseTitle = g.Title + if p := filepath.Join(base, "PIC1.PNG"); platform.IsFile(p) { + g.Backdrop = p + } + if p := filepath.Join(base, "ICON0.PNG"); platform.IsFile(p) { + g.Icon = p + } + return g, g.Title != "" +} + +// isSerial reports whether s looks like a PS3 title id (BLES01689, NPEB00123). +func isSerial(s string) bool { + if len(s) != 9 { + return false + } + for i, r := range s { + if i < 4 && !(r >= 'A' && r <= 'Z') || i >= 4 && !(r >= '0' && r <= '9') { + return false + } + } + return true +} + +// parseGamesYML reads RPCS3's games.yml, one "SERIAL: path" per line, +// and returns the paths. +func parseGamesYML(b []byte) []string { + var out []string + sc := bufio.NewScanner(bytes.NewReader(b)) + sc.Buffer(make([]byte, 4096), 4096) + for sc.Scan() && len(out) < 2000 { + line := strings.TrimSpace(sc.Text()) + if line == "" || line[0] == '#' { + continue + } + k, v, ok := strings.Cut(line, ":") + if !ok || strings.TrimSpace(k) == "" { + continue + } + if v = yamlString(v); v != "" { + out = append(out, v) + } + } + return out +} + +// vfsHDD finds where RPCS3's virtual hard disk is in its vfs.yml +// ("/dev_hdd0/: $(EmulatorDir)dev_hdd0/"). +func vfsHDD(b []byte, emuDir string) string { + sc := bufio.NewScanner(bytes.NewReader(b)) + sc.Buffer(make([]byte, 4096), 4096) + for sc.Scan() { + k, v, ok := strings.Cut(strings.TrimSpace(sc.Text()), ": ") + if !ok || strings.Trim(k, `"'`) != "/dev_hdd0/" { + continue + } + v = yamlString(v) + v = strings.ReplaceAll(v, "$(EmulatorDir)", filepath.ToSlash(emuDir)+"/") + return pathFrom(emuDir, v) + } + return "" +} + +// yamlString reads a YAML scalar as RPCS3 writes them: plain, or in +// single or double quotes. +func yamlString(v string) string { + v = strings.TrimSpace(v) + if len(v) >= 2 && (v[0] == '"' && v[len(v)-1] == '"' || v[0] == '\'' && v[len(v)-1] == '\'') { + q := v[0] + v = v[1 : len(v)-1] + if q == '"' { + v = strings.ReplaceAll(v, `\\`, `\`) + v = strings.ReplaceAll(v, `\"`, `"`) + } else { + v = strings.ReplaceAll(v, `''`, `'`) + } + } + return v +} diff --git a/internal/emulated/sfo.go b/internal/emulated/sfo.go new file mode 100644 index 0000000..4a15b3c --- /dev/null +++ b/internal/emulated/sfo.go @@ -0,0 +1,56 @@ +package emulated + +import ( + "encoding/binary" + "errors" + "strings" +) + +// maxSFO bounds a PARAM.SFO file (real ones are about 1 KB). +const maxSFO = 64 << 10 + +var errSFO = errors.New("not a PARAM.SFO file") + +// parseSFO reads a PS3 game's PARAM.SFO: a table of keys (TITLE, +// TITLE_ID, CATEGORY, …) with text or number values. Only text values are +// returned. +func parseSFO(b []byte) (map[string]string, error) { + if len(b) < 20 || string(b[:4]) != "\x00PSF" { + return nil, errSFO + } + le := binary.LittleEndian + keys, data, n := le.Uint32(b[8:]), le.Uint32(b[12:]), le.Uint32(b[16:]) + if n > 256 || uint64(20)+uint64(n)*16 > uint64(len(b)) || uint64(keys) > uint64(len(b)) || uint64(data) > uint64(len(b)) { + return nil, errSFO + } + out := map[string]string{} + for i := uint32(0); i < n; i++ { + e := b[20+i*16:] + keyOff, format := uint64(le.Uint16(e[0:])), le.Uint16(e[2:]) + size, off := uint64(le.Uint32(e[4:])), uint64(le.Uint32(e[12:])) + k := uint64(keys) + keyOff + if k >= uint64(len(b)) { + return nil, errSFO + } + end := strings.IndexByte(string(b[k:]), 0) + if end <= 0 || end > 64 { + return nil, errSFO + } + key := string(b[k : k+uint64(end)]) + // 0x0204 is UTF-8 text ending in a zero, 0x0004 the same without + // one; 0x0404 is a number. + if format != 0x0204 && format != 0x0004 { + continue + } + v := uint64(data) + off + if size > 4096 || v+size > uint64(len(b)) { + return nil, errSFO + } + val := string(b[v : v+size]) + if z := strings.IndexByte(val, 0); z >= 0 { + val = val[:z] + } + out[key] = val + } + return out, nil +} diff --git a/internal/emulated/walk.go b/internal/emulated/walk.go new file mode 100644 index 0000000..0a0e694 --- /dev/null +++ b/internal/emulated/walk.go @@ -0,0 +1,182 @@ +package emulated + +import ( + "context" + "os" + "path/filepath" + "strings" + "time" + + "github.com/ApolloF/Seaglass/internal/platform" +) + +// Bounds of the walk through the emulators' game folders. Dolphin looks +// through its folders without a depth limit; games sit a few folders down +// at most, and a folder of PC games next to them mustn't make a scan slow. +const ( + walkBudget = 4 * time.Second + maxEntries = 60000 // folder entries looked at, over all folders + maxDepth = 5 // below a folder searched with its subfolders + maxDirLimit = 5000 // entries read from one folder +) + +// walker goes through the emulators' game folders once, offering each +// file and folder to every installed emulator. +type walker struct { + ctx context.Context + ins []*install + deadline time.Time + entries int + stopped string + seen map[string]bool // files already found (listed by an emulator) + found []Game +} + +func (w *walker) walk(r root) { + if w.stopped != "" || !platform.IsDir(r.path) { + return + } + // A network folder could take minutes to walk, or hang while its + // server is away; the games an emulator lists there are still found. + if isRemote(r.path) { + return + } + w.dir(r.path, min(r.depth, maxDepth), true) +} + +func (w *walker) dir(dir string, depth int, top bool) { + switch { + case w.stopped != "": + return + case w.ctx.Err() != nil: + w.stopped = "cancelled" + return + case time.Now().After(w.deadline): + w.stopped = "took too long" + return + case w.entries >= maxEntries: + w.stopped = "too many files" + return + } + ents, err := readDir(dir, maxDirLimit) + if err != nil { + return + } + w.entries += len(ents) + names := make(map[string]bool, len(ents)) + for _, e := range ents { + names[strings.ToLower(e.Name())] = true + } + // The folder may be a game in itself (an unpacked disc). + for _, in := range w.ins { + if in.emu.folder == nil { + continue + } + if g, ok := in.emu.folder(in, dir, names); ok { + w.add(in, g) + return + } + } + // A folder with programs in it is a PC game or an app: its files and + // subfolders aren't console games. + if !top && hasPrograms(names) { + return + } + var files []Game + var owners []*install + for _, e := range ents { + if e.IsDir() || !e.Type().IsRegular() { + continue + } + ext := strings.ToLower(filepath.Ext(e.Name())) + for _, in := range w.ins { + if in.emu.file == nil || !in.emu.exts[ext] { + continue + } + p := filepath.Join(dir, e.Name()) + if w.seen[platform.Key(p)] { + break + } + fi, err := e.Info() + if err != nil { + break + } + if g, ok := in.emu.file(in, p, fi.Size()); ok { + files = append(files, g) + owners = append(owners, in) + break + } + } + } + // A game alone in its own folder is best named by the folder: files + // are often named by a code ("fk-mk8.wud", "RMCE01.wbfs"). + for i, g := range files { + if g.TitleFrom == "" && len(files) == 1 && !top { + if t := cleanName(filepath.Base(dir)); t != "" && !isPlatformName(t) { + g.Title, g.BaseTitle, g.TitleFrom = t, t, "folder name" + } + } + if g.TitleFrom == "" { + g.TitleFrom = "file name" + } + w.add(owners[i], g) + } + if depth <= 0 { + return + } + for _, e := range ents { + if e.IsDir() && !skipDir(e.Name()) { + w.dir(filepath.Join(dir, e.Name()), depth-1, false) + } + } +} + +func (w *walker) add(in *install, g Game) { + k := platform.Key(g.File) + if w.seen[k] { + return + } + w.seen[k] = true + g.How = in.emu.name + " game folder" + w.found = append(w.found, in.fill(g)) +} + +func hasPrograms(names map[string]bool) bool { + for n := range names { + if strings.HasSuffix(n, ".exe") || strings.HasSuffix(n, ".dll") { + return true + } + } + return false +} + +// skipDir leaves out folders no game is in. +func skipDir(name string) bool { + if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "$") { + return true + } + switch strings.ToLower(name) { + case "system volume information", "windows", "node_modules", "__pycache__", "shadercache", "shaders", "cache", "logs": + return true + } + return false +} + +// isPlatformName reports whether a folder is named after a console +// ("Wii", "PS3"): a folder of games, not one game. +func isPlatformName(s string) bool { + switch strings.ToLower(strings.NewReplacer(" ", "", "-", "").Replace(s)) { + case "wii", "wiiu", "gamecube", "gc", "ngc", "ps3", "playstation3", "games", "roms", "isos", "wbfs": + return true + } + return false +} + +// fileSize is a file's size, 0 when it can't be read. +func fileSize(p string) int64 { + fi, err := os.Stat(p) + if err != nil { + return 0 + } + return fi.Size() +} diff --git a/internal/launch/launch.go b/internal/launch/launch.go index 997172f..ce054a5 100644 --- a/internal/launch/launch.go +++ b/internal/launch/launch.go @@ -137,7 +137,11 @@ type Plan struct { Title string From string // where it was started (Session.From) Dirs []string // folders the game's processes run from - Before []Step + // FolderOnly counts only processes running from Dirs as the game, not + // everything the started program starts: an emulator is the game, + // whatever started it (a script that also starts a helper that stays). + FolderOnly bool + Before []Step // Start starts the game. It runs after the Before steps, which may // change what it does (the route). pid is 0 when the game was handed // to a store or Steam. @@ -356,7 +360,11 @@ func (m *Manager) run(ctx context.Context, cancel context.CancelFunc, p Plan) { // playtime. It returns the seconds played, or -1 when the session ended // without the game being seen. func (m *Manager) follow(ctx context.Context, p Plan, pid uint32) int64 { - t := newTracker(UsableDirs(p.Dirs), pid, uint32(os.Getpid()), m.image) + root := pid + if p.FolderOnly { + root = 0 + } + t := newTracker(UsableDirs(p.Dirs), root, uint32(os.Getpid()), m.image) timeout := p.DetectTimeout if timeout <= 0 { timeout = time.Minute diff --git a/internal/launch/launch_test.go b/internal/launch/launch_test.go index 93b086e..74fd56b 100644 --- a/internal/launch/launch_test.go +++ b/internal/launch/launch_test.go @@ -495,3 +495,29 @@ func TestLookupRetriedAfterAFailure(t *testing.T) { t.Errorf("readable on the next poll: %v, want 300", got) } } + +// A script that boots an emulator through another program (and starts a +// helper that keeps running) plays as long as the emulator runs: only +// processes from the emulator's folder are the game. +func TestFolderOnlyFollowsTheEmulator(t *testing.T) { + const emuDir = `C:\Emulators\RPCS3` + script := platform.Proc{PID: 100, PPID: 1, Name: "powershell.exe"} + helper := platform.Proc{PID: 150, PPID: 100, Name: "portal.exe"} + emu := platform.Proc{PID: 200, PPID: 300, Name: "rpcs3.exe"} + f := &fakePC{ + paths: map[uint32]string{100: `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`, 150: `C:\Tools\portal.exe`, 200: emuDir + `\rpcs3.exe`}, + frames: [][]platform.Proc{ + {script, helper}, {script, helper, emu}, {script, helper, emu}, {script, helper, emu}, + {helper}, {helper}, {helper}, {helper}, {helper}, {helper}, + }, + } + m, r := newTest(f) + var played int64 + _ = m.Launch(context.Background(), Plan{Dirs: []string{emuDir}, FolderOnly: true, + Start: func() (uint32, string, error) { return 100, "direct", nil }, + Played: func(s int64) { played += s }}) + s := wait(t, r) + if s.Phase != Ended || played != 4 { + t.Errorf("phase %s, played %d; want ended after the emulator's 4 s", s.Phase, played) + } +} diff --git a/internal/library/library.go b/internal/library/library.go index ad5dc90..115e23e 100644 --- a/internal/library/library.go +++ b/internal/library/library.go @@ -33,14 +33,17 @@ type Game struct { Repacker string `json:"repacker,omitempty"` DRMFree string `json:"drmFree,omitempty"` - Installed bool `json:"installed"` - PadHint string `json:"padHint,omitempty"` // the game ships libScePad or SDL - Dir string `json:"dir"` - Exe string `json:"exe,omitempty"` - Args string `json:"args,omitempty"` - WorkDir string `json:"workDir,omitempty"` - LaunchURI string `json:"launchUri,omitempty"` - UserExe bool `json:"userExe,omitempty"` // the user picked the executable; scans leave it alone + Installed bool `json:"installed"` + PadHint string `json:"padHint,omitempty"` // the game ships libScePad or SDL + Dir string `json:"dir"` + Exe string `json:"exe,omitempty"` + Args string `json:"args,omitempty"` + WorkDir string `json:"workDir,omitempty"` + LaunchURI string `json:"launchUri,omitempty"` + UserExe bool `json:"userExe,omitempty"` // the user picked the executable; scans leave it alone + // QuitArgs, with a program the user picked, make that program close + // the game (a launcher's quit command) instead of Seaglass ending it. + QuitArgs string `json:"quitArgs,omitempty"` Owned bool `json:"owned,omitempty"` // a connected store account owns it InstallURI string `json:"installUri,omitempty"` // asks the store to install it SizeBytes int64 `json:"sizeBytes,omitempty"` @@ -74,9 +77,36 @@ type Game struct { // can be in several. Collections []string `json:"collections,omitempty"` + // Console is set for a console game played on an emulator. + Console *Console `json:"console,omitempty"` + Meta *Meta `json:"meta,omitempty"` } +// Console is what's known about a console game played on an emulator. +// The game's Exe and Args start the emulator with it. +type Console struct { + Platform string `json:"platform"` // "PS3", "GameCube", "Wii", "Wii U" + Emulator string `json:"emulator"` // "RPCS3", "Dolphin", "Cemu" + // EmuDir is the emulator's folder: its processes are the game while + // it runs, whatever started it. + EmuDir string `json:"emuDir"` + File string `json:"file"` // what the emulator boots + Serial string `json:"serial,omitempty"` // BLES01689, RMCE01, … + // Title is the game's name without the serial a second copy gets, + // for looking up its art. + Title string `json:"title"` + // How the emulator starts it, for going back to that after the user + // picked another program. + Exe string `json:"exe"` + Args string `json:"args"` + // Pictures that came with the game or the emulator downloaded, for + // when the art sources have none (local PNG files). + Cover string `json:"cover,omitempty"` + Backdrop string `json:"backdrop,omitempty"` + Icon string `json:"icon,omitempty"` +} + // Meta is store metadata and art, filled in by the metadata service. type Meta struct { Description string `json:"description,omitempty"` @@ -126,6 +156,7 @@ type Found struct { StorePlaytime int64 StoreLastPlayed int64 PadHint string + Console *Console } type fileData struct { @@ -335,6 +366,11 @@ func (s *Store) ApplyScan(found []Found, now time.Time) (added, removed int) { } g.EpicApp, g.How, g.SeenAt = f.EpicApp, f.How, ts g.PadHint = f.PadHint + g.Console = nil + if f.Console != nil { + c := *f.Console + g.Console = &c + } if g.Owned { // The owned sync may know more (GOG Galaxy, Steam's web API); // store playtime only goes up. @@ -417,6 +453,10 @@ func writeAtomic(path string, b []byte) error { func copyGame(g *Game) Game { c := *g c.Collections = append([]string(nil), g.Collections...) + if g.Console != nil { + k := *g.Console + c.Console = &k + } if g.Meta != nil { m := *g.Meta m.Developers = append([]string(nil), g.Meta.Developers...) diff --git a/internal/library/library_test.go b/internal/library/library_test.go index 7ad4659..61b697a 100644 --- a/internal/library/library_test.go +++ b/internal/library/library_test.go @@ -332,3 +332,41 @@ func TestLegacyUnofficial(t *testing.T) { t.Errorf("b: external %v, label %q", b.External, b.SourceLabel) } } + +// A console game's details come with every scan; a program and +// arguments the user chose to start it with stay, and it's saved and +// read back with the game. +func TestConsoleGameScan(t *testing.T) { + path := filepath.Join(t.TempDir(), "library.json") + s, _ := Open(path) + f := Found{Key: `emu:r:\ps3\a\eboot.bin`, Title: "A", SortTitle: "a", Source: "emulator", SourceLabel: "PS3", + Exe: `C:\Emu\rpcs3.exe`, Args: "--no-gui a", Dir: `R:\PS3\A`, + Console: &Console{Platform: "PS3", Emulator: "RPCS3", Serial: "BLES00001", Exe: `C:\Emu\rpcs3.exe`, Args: "--no-gui a"}} + s.ApplyScan([]Found{f}, time.Now()) + g := s.Games()[0] + if g.Console == nil || g.Console.Serial != "BLES00001" { + t.Fatalf("console not kept: %+v", g) + } + if _, err := s.Update(g.ID, func(g *Game) { + g.Exe, g.Args, g.QuitArgs, g.UserExe = `C:\Windows\powershell.exe`, "-File boot.ps1", "-File boot.ps1 -Quit", true + g.Console.Serial = "changed through a copy" + }); err != nil { + t.Fatal(err) + } + f.Console.Serial = "BLES00002" + s.ApplyScan([]Found{f}, time.Now()) + g, _ = s.Get(g.ID) + if g.Exe != `C:\Windows\powershell.exe` || g.Args != "-File boot.ps1" || g.QuitArgs != "-File boot.ps1 -Quit" { + t.Fatalf("the user's launch was replaced: %+v", g) + } + if g.Console.Serial != "BLES00002" { + t.Fatalf("scan's details not taken: %+v", g.Console) + } + if err := s.Flush(); err != nil { + t.Fatal(err) + } + r, _ := Open(path) + if g2, _ := r.Get(g.ID); g2.Console == nil || g2.Console.Platform != "PS3" || g2.QuitArgs == "" { + t.Fatalf("not read back: %+v", g2) + } +} diff --git a/internal/meta/fetch.go b/internal/meta/fetch.go index db49c6c..3d1ad86 100644 --- a/internal/meta/fetch.go +++ b/internal/meta/fetch.go @@ -34,6 +34,10 @@ type Request struct { // PCGW is the game's PCGamingWiki page when the caller already looked // it up; otherwise Fetch looks it up by Title. PCGW *PCGW + // Console is set for a console game on an emulator: PC stores and + // PCGamingWiki don't have it, so only SteamGridDB (by Title) and the + // pictures that came with the game are used. + Console *library.Console } // Fetch gathers metadata and art for one game. Missing pieces are simply @@ -46,7 +50,7 @@ func (c *Client) Fetch(ctx context.Context, r Request) (*library.Meta, error) { var errs []error found := false wiki := r.PCGW - if wiki == nil { + if wiki == nil && r.Console == nil { if p, err := c.PCGamingWiki(ctx, r.Title); err == nil { wiki = p } @@ -214,6 +218,12 @@ func (c *Client) Fetch(ctx context.Context, r Request) (*library.Meta, error) { m.Backdrop = c.pickBackdrop(ctx, backdrops) m.Logo, logoImg = c.firstImage(ctx, urls.logo, Logo) m.Icon, _ = c.firstImage(ctx, urls.icon, Icon) + if r.Console != nil && c.addLocal(m, r.Console, &coverImg, &heroImg) { + found = true + if m.Source == "" { + m.Source = r.Console.Emulator + } + } if !found { // Only Steam's art files answered (a game its store no longer // lists): that's still worth keeping. diff --git a/internal/meta/local.go b/internal/meta/local.go new file mode 100644 index 0000000..9c063b3 --- /dev/null +++ b/internal/meta/local.go @@ -0,0 +1,71 @@ +package meta + +import ( + "image" + "os" + "path/filepath" + "strings" + + "github.com/ApolloF/Seaglass/internal/library" +) + +// addLocal fills art the sources didn't have from the pictures a console +// game came with: a PS3 disc's 1920×1080 background (also the best +// backdrop: the game's own key art, without text), its icon, and covers +// an emulator downloaded. It reports whether any was used. +func (c *Client) addLocal(m *library.Meta, k *library.Console, coverImg, heroImg *image.Image) bool { + used := false + if img := loadLocal(k.Backdrop); img != nil { + if b := crop16x9(img); b.Bounds().Dx() >= minBackdropWidth { + if art, _, err := c.keepImage(b, Backdrop); err == nil { + m.Backdrop, used = art, true + } + } + if m.Hero == "" { + if art, kept, err := c.keepImage(img, Hero); err == nil { + m.Hero, *heroImg, used = art, kept, true + } + } + } + if m.Cover == "" { + if img := loadLocal(k.Cover); img != nil { + if art, kept, err := c.keepImage(img, Cover); err == nil { + m.Cover, *coverImg, used = art, kept, true + } + } + } + if m.Icon == "" { + if img := loadLocal(k.Icon); img != nil { + if art, _, err := c.keepImage(img, Icon); err == nil { + m.Icon, used = art, true + } + } + } + return used +} + +// loadLocal reads a picture from this PC, within the same limits as a +// download. Only PNG and JPEG files are read; nil when it isn't one. +func loadLocal(p string) image.Image { + if p == "" || !filepath.IsAbs(p) { + return nil + } + switch strings.ToLower(filepath.Ext(p)) { + case ".png", ".jpg", ".jpeg": + default: + return nil + } + fi, err := os.Stat(p) + if err != nil || !fi.Mode().IsRegular() || fi.Size() > maxImageBytes { + return nil + } + b, err := os.ReadFile(p) + if err != nil { + return nil + } + img, err := decodeChecked(b) + if err != nil { + return nil + } + return img +} diff --git a/internal/meta/local_test.go b/internal/meta/local_test.go new file mode 100644 index 0000000..e616bb7 --- /dev/null +++ b/internal/meta/local_test.go @@ -0,0 +1,71 @@ +package meta + +import ( + "context" + "errors" + "image" + "image/color" + "image/png" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/ApolloF/Seaglass/internal/library" +) + +func writePNG(t *testing.T, p string, w, h int) { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for x := 0; x < w; x += 7 { + img.Set(x, x%h, color.RGBA{200, 40, 40, 255}) + } + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := png.Encode(f, img); err != nil { + t.Fatal(err) + } +} + +// A console game without a SteamGridDB key still gets art: its disc's +// background becomes the backdrop and hero (and the cover, cropped), with +// nothing asked of PC stores. +func TestConsoleGameUsesItsOwnPictures(t *testing.T) { + dir := t.TempDir() + pic, icon := filepath.Join(dir, "PIC1.PNG"), filepath.Join(dir, "ICON0.PNG") + writePNG(t, pic, 1920, 1080) + writePNG(t, icon, 320, 176) + c := NewClient(filepath.Join(dir, "art"), func() string { return "" }) + c.UseTransport(failingTransport{t}) + m, err := c.Fetch(context.Background(), Request{Title: "Skylanders Giants", Console: &library.Console{Emulator: "RPCS3", Backdrop: pic, Icon: icon, Cover: filepath.Join(dir, "missing.png")}}) + if err != nil { + t.Fatal(err) + } + if m.Backdrop == "" || m.Hero == "" || m.Cover == "" || m.Icon == "" || m.Source != "RPCS3" { + t.Fatalf("got %+v", m) + } +} + +func TestLoadLocalRefusesOtherFiles(t *testing.T) { + dir := t.TempDir() + txt := filepath.Join(dir, "a.txt") + _ = os.WriteFile(txt, []byte("x"), 0o644) + fake := filepath.Join(dir, "fake.png") + _ = os.WriteFile(fake, []byte("not a picture"), 0o644) + for _, p := range []string{"", "relative.png", txt, fake, filepath.Join(dir, "missing.png"), dir} { + if loadLocal(p) != nil { + t.Errorf("%q was read", p) + } + } +} + +// failingTransport fails the test on any request: nothing may go online. +type failingTransport struct{ t *testing.T } + +func (f failingTransport) RoundTrip(r *http.Request) (*http.Response, error) { + f.t.Errorf("request to %s", r.URL) + return nil, errors.New("offline") +} diff --git a/internal/platform/platform.go b/internal/platform/platform.go index 76beb0f..4d055be 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -124,6 +124,15 @@ func FixedDrives() []string { return out } +// DriveRemote reports whether a drive root ("Z:\") is a network drive. +func DriveRemote(root string) bool { + p, err := windows.UTF16PtrFromString(root) + if err != nil { + return false + } + return windows.GetDriveType(p) == windows.DRIVE_REMOTE +} + // RealCase returns p with the capitalisation the file system uses (Steam // keeps its path lower-cased in the registry). p is returned unchanged // when it can't be opened or resolves somewhere else (a junction). diff --git a/internal/settings/settings.go b/internal/settings/settings.go index c7b17a8..22b2710 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -17,9 +17,12 @@ import ( // an older settings file doesn't have them. type Settings struct { // Library - Folders []string `json:"folders"` // extra folders whose subfolders are games - AutoFolders bool `json:"autoFolders"` // also look in common game folders on every drive - DetectExternal bool `json:"detectExternal"` // recognise games on Steam API emulators and from repacks + Folders []string `json:"folders"` // extra folders whose subfolders are games + AutoFolders bool `json:"autoFolders"` // also look in common game folders on every drive + DetectExternal bool `json:"detectExternal"` // recognise games on Steam API emulators and from repacks + // DetectEmulators adds the console games of emulators installed here + // (RPCS3, Dolphin, Cemu). Per PC, like the emulators. + DetectEmulators bool `json:"detectEmulators"` ReviewUncertain bool `json:"reviewUncertain"` // keep low-confidence matches in New on this PC for a check ShowNotInstalled bool `json:"showNotInstalled"` // list games that aren't installed (anymore) ShowOwned bool `json:"showOwned"` // list games connected store accounts own but aren't installed @@ -73,16 +76,17 @@ type Settings struct { } // Sources are the libraries that can be hidden, as the interface groups -// games: by store, then external copies, standalone installs and folders. +// games: by store, then external copies, standalone installs, folders and +// console games on emulators. var Sources = map[string]bool{ "steam": true, "epic": true, "gog": true, "ea": true, "ubisoft": true, "battlenet": true, "xbox": true, - "external": true, "standalone": true, "folder": true, + "external": true, "standalone": true, "folder": true, "emulated": true, } // Defaults are the settings on first start. func Defaults() Settings { return Settings{ - Folders: []string{}, HiddenSources: []string{}, AutoFolders: true, DetectExternal: true, ReviewUncertain: true, + Folders: []string{}, HiddenSources: []string{}, AutoFolders: true, DetectExternal: true, DetectEmulators: true, ReviewUncertain: true, Theme: "system", BigPictureLayout: "deck", OpenBigPictureOnController: true, Haptics: true, Lightbar: true, PSButton: true, Glyphs: "auto", CloseWhilePlaying: true, PadWhilePlaying: "listen", NoticeExternal: true, diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index 570da6b..e0f2d71 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -168,3 +168,22 @@ func TestControllerTranslatorSettings(t *testing.T) { t.Error("steam isn't kept") } } + +// Console games are a new library: on for settings saved before it +// existed, per PC, and its library can be hidden like the others. +func TestEmulatedGamesSettings(t *testing.T) { + old := filepath.Join(t.TempDir(), "old.json") + if err := os.WriteFile(old, []byte(`{"detectExternal":false,"hiddenSources":["emulated"]}`), 0o644); err != nil { + t.Fatal(err) + } + v := Open(old).Get() + if !v.DetectEmulators || len(v.HiddenSources) != 1 || v.HiddenSources[0] != "emulated" { + t.Fatalf("got %+v", v) + } + off := Defaults() + off.DetectEmulators = false + b, _ := json.Marshal(off.Portable()) + if got, _ := Defaults().WithPortable(b); !got.DetectEmulators { + t.Fatal("another PC turned this PC's emulators off") + } +} From 52eec711d1dc8a2adc269ad9e9742b1c05290dbc Mon Sep 17 00:00:00 2001 From: ApolloF Date: Thu, 29 Oct 2026 14:22:35 +0100 Subject: [PATCH 2/4] Docs for emulated games: changelog, release notes, roadmap, privacy --- PRIVACY.md | 4 ++-- README.md | 3 ++- docs/CHANGELOG.md | 14 ++++++++++++++ docs/ROADMAP.md | 6 ++++-- docs/releases/v1.13.0.md | 4 ++++ 5 files changed, 26 insertions(+), 5 deletions(-) create mode 100644 docs/releases/v1.13.0.md diff --git a/PRIVACY.md b/PRIVACY.md index c2603fc..8ccf565 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -12,7 +12,7 @@ Seaglass is made and published by ApolloF, in the Netherlands. Contact: me@apoll ## What Seaglass reads on your PC -To find your games, Seaglass reads the libraries of the launchers installed on your PC (such as Steam, Epic, GOG Galaxy, EA, Ubisoft, Battle.net and Xbox), the folders you add, and achievement files games and launchers keep locally. It records playtime for games you start. All of this stays on your PC. +To find your games, Seaglass reads the libraries of the launchers installed on your PC (such as Steam, Epic, GOG Galaxy, EA, Ubisoft, Battle.net and Xbox), the settings and game lists of the emulators installed on your PC (RPCS3, Dolphin, Cemu) and the game folders they point at, the folders you add, and achievement files games and launchers keep locally. It records playtime for games you start. All of this stays on your PC. Seaglass also reads which Steam account is signed in on this PC, from Steam's local files, and the Windows machine ID (only as a hash, to name this PC's files in the profile described under With Syncer). @@ -57,7 +57,7 @@ If you use [Syncer](https://apps.apollof.nl/syncer/), Seaglass talks to it on yo Settings, your library, the profile, the log and crash logs stay in `%APPDATA%\Seaglass`. The log records events such as the titles of games started, play times and errors. Cached art, achievement data, the game database, update downloads and the data of the WebView2 component that draws Seaglass's window stay in `%LOCALAPPDATA%\Seaglass`. WebView2 is a Microsoft component; its own data handling follows Microsoft's and Windows' privacy settings, not Seaglass. Seaglass's interface loads no remote code (it has a strict Content Security Policy). -Outside those folders Seaglass writes only when you turn the feature on: a value under `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` for *Start with Windows*, and Seaglass shortcuts in Steam's `shortcuts.vdf` for the Steam Input route for controllers. With HidHide installed, Seaglass adds your controller to HidHide's block list while a game runs and adds itself to HidHide's allow list; it notes the controllers it hid in `%APPDATA%\Seaglass\hidden-controllers.json` and takes exactly those off again when the game ends. It reads, but doesn't change, the store launchers' own files and registry entries. +Outside those folders Seaglass writes only when you turn the feature on: a value under `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` for *Start with Windows*, and Seaglass shortcuts in Steam's `shortcuts.vdf` for the Steam Input route for controllers. With HidHide installed, Seaglass adds your controller to HidHide's block list while a game runs and adds itself to HidHide's allow list; it notes the controllers it hid in `%APPDATA%\Seaglass\hidden-controllers.json` and takes exactly those off again when the game ends. It reads, but doesn't change, the store launchers' and emulators' own files and registry entries. *Copy diagnostics* (or `Seaglass.exe --diagnostics`, which writes a file to your desktop) creates a report only when you ask for it. It holds Seaglass, Windows and WebView2 versions, your settings (including game folders), library counts, the last crash and the end of the log. It holds no keys or tokens, and your user folder is shortened to `%USERPROFILE%`. It goes only where you paste or send it. diff --git a/README.md b/README.md index 139acbf..e5aa8d9 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,10 @@ go test ./... # backend tests cd frontend && npm run check # type-check the interface cd frontend && npm run dev:mock # interface in a browser with made-up games WL_REAL_SCAN=1 go test -run RealScan -v ./internal/scan # scan this PC and print what was found +WL_REAL_EMU=1 go test -run RealFind -v ./internal/emulated # list the emulators and console games on this PC ``` -The backend lives in `internal/`: `scan` (sources, external-copy detection, executable picking), `identify` (Ludusavi matching), `library` (the game library), `settings`, `meta` (metadata and art), `pad` (controllers through SDL3), `launch` (game sessions: hooks, process tracking, playtime), `syncer` (client for Syncer's launcher API), `owned` (owned games from Steam, GOG and Epic accounts), `update` (updates from GitHub releases), `sqlite` (read-only SQLite reader for GOG Galaxy), `steaminput` (Steam shortcuts for the Steam Input route), `app` (services the interface calls, windows and tray), plus small helpers (`lnk`, `platform`, `logx`). The installer is `build/windows/nsis/project.nsi`; signing is described in [docs/SIGNING.md](docs/SIGNING.md). Steam and game-database parsing comes from [gamekit](https://github.com/ApolloF/gamekit). The Svelte 5 interface is in `frontend/src`: `desktop/` for desktop mode, `bigpicture/` for the controller layouts, `overlay/` for the in-game overlay. +The backend lives in `internal/`: `scan` (sources, external-copy detection, executable picking), `identify` (Ludusavi matching), `library` (the game library), `settings`, `meta` (metadata and art), `pad` (controllers through SDL3), `launch` (game sessions: hooks, process tracking, playtime), `syncer` (client for Syncer's launcher API), `owned` (owned games from Steam, GOG and Epic accounts), `emulated` (console games on RPCS3, Dolphin and Cemu), `update` (updates from GitHub releases), `sqlite` (read-only SQLite reader for GOG Galaxy), `steaminput` (Steam shortcuts for the Steam Input route), `app` (services the interface calls, windows and tray), plus small helpers (`lnk`, `platform`, `logx`). The installer is `build/windows/nsis/project.nsi`; signing is described in [docs/SIGNING.md](docs/SIGNING.md). Steam and game-database parsing comes from [gamekit](https://github.com/ApolloF/gamekit). The Svelte 5 interface is in `frontend/src`: `desktop/` for desktop mode, `bigpicture/` for the controller layouts, `overlay/` for the in-game overlay. The DLSS Updater add-on host lives on the [`feature/dlss-addon`](https://github.com/ApolloF/Seaglass/tree/feature/dlss-addon) branch; [docs/dlss-addon.md](https://github.com/ApolloF/Seaglass/blob/feature/dlss-addon/docs/dlss-addon.md) there has ideas for bringing it back. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6095372..34f73aa 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,20 @@ What each version of Seaglass (WaterLauncher until 1.4) changed and why, newest Alongside Seaglass: [gamekit](https://github.com/ApolloF/gamekit) v0.1.0, Syncer 0.15.0 and DLSS Updater 1.4.1. +## 1.13.0 (unreleased): emulated games + +Console games played on emulators are a new library next to the PC games, so Seaglass can be the one launcher at the desk and on a stream. Nothing about PC games changes: console games get keys of their own (`emu:` and the file the emulator boots), so they never meet a PC game's folder. + +- **New `internal/emulated`**: finds RPCS3, Dolphin and Cemu (an `Emulators`, `Emulation` or `Emu` folder on any fixed drive, also under `Games`, Program Files, `%LOCALAPPDATA%\Programs`, Scoop) and the games their own files list: RPCS3's `config\games.yml`, `dev_hdd0\game` and `dev_hdd0\disc`, and the folders next to its listed games (a second copy of a disc it forgot); Dolphin's `ISOPath`s from `Dolphin.ini`, walked like Dolphin does; Cemu's `GamePaths`, `title_list_cache.xml` and games installed to `mlc01`. Another emulator (RetroArch, Ryujinx, PCSX2) is one more `emulator` value in that package. +- **Titles** come from the game or the emulator, never the file name when there's better: `PARAM.SFO`, Dolphin's GameTDB list (`Sys\wiitdb-en.txt`) by disc id, Cemu's game list and `meta.xml`, then the disc's own name, then the folder (when the game is alone in it), then the file. The platform is the source label (PS3, GameCube, Wii, Wii U). +- **Copies**: two files with the same serial, or the same name and size, are one game; a local copy beats one on a network drive (Cemu's list pointed at the server, the local copy is what plays). Different copies that are both there (two regions' discs) keep both, with the serial after the title. +- **Bounded and untrusted**: every file is read with a size limit (`PARAM.SFO` 64 KB, `games.yml` 1 MB, title list 8 MB, …) and the parsers are fuzzed; paths from the emulators' files are cleaned, and device paths refused. The walk stops after 4 s or 60,000 entries, goes at most 5 folders down, skips folders with programs in them (PC games) and doesn't walk network drives (the games an emulator lists there are still found). On this PC: 7 games in about 110 ms. +- **Launching**: RPCS3 `--no-gui --fullscreen`, Dolphin `-b -C Main.Display.Fullscreen=True -e`, Cemu `-f -g`. While a console game runs, only processes from the emulator's folder are the game (`launch.Plan.FolderOnly`), so a boot script and a helper it starts don't keep the session going. Auto never hands a console game an Xbox controller (the emulator reads the DualSense itself); saves steps and Syncer registration skip them; a running emulator isn't taken for one of its games. +- **Launch options** (any game): the arguments its program starts with, and arguments that make it close the game again (`quitArgs`). Quit game then runs the program with those and ends the game itself after 20 s if it's still running. Combined with *Choose program…* this starts a console game through a launcher or script (a figure portal's boot script, say). Scans keep them; *Start with * goes back. +- **Art**: console games skip the Steam store and PCGamingWiki (a PC game of the same name isn't them). SteamGridDB is searched by the title without the serial, and the pictures the game came with fill the rest: a PS3 disc's 1920×1080 `PIC1.PNG` is the backdrop (and the hero and cover when there's no other), `ICON0.PNG` the icon, Dolphin's downloaded covers the cover. Local files are only read as PNG or JPEG, within the download limits. +- **Playtime** is kept per console and serial (`console:ps3:bles01689`), never under a PC game's title. +- **Settings**: *Add console games from emulators* (`detectEmulators`, on, per PC like the emulators), and a *Consoles* library that can be hidden (`hiddenSources: emulated`). + ## 1.12.0 (unreleased): big picture for game streaming For streaming to another screen (Sunshine, Moonlight), Seaglass can open big picture from the command line without touching the settings, so desktop use stays as it was. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index cc6390e..c498149 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -18,6 +18,7 @@ These need the maintainer's accounts or decisions; nothing else can do them. - **Theme packs**: user themes as token sets only (no JavaScript), on top of the per-layout CSS tokens. - **Memory while playing**: the goal was a Go core under 50 MB with the interface closed. 1.1 got it from 79 MB to 62 MB; a bare Wails v3 app is 46 MB here, so what's left of ours is ~16 MB, nearly all the game database index. An interned or on-disk index would be the next step. - **Wails**: v3 is still beta; keep the pin, and when upgrading recheck the single-instance mutex name, event payloads and bindings. +- **More emulators**: RetroArch (its playlists), Ryujinx, PCSX2, DuckStation and PPSSPP, each one `emulator` value in `internal/emulated`. Also: RPCS3's and Cemu's own playtime and trophies, and Dolphin's compressed GCZ images (only named by file today). - **Syncer** (in Syncer's repo): use `gamekit/steam.Accounts`, and let games the launcher registers feed Syncer's discovery (for now they only help `gameStatus`). ## Goals @@ -28,7 +29,7 @@ These need the maintainer's accounts or decisions; nothing else can do them. - Syncer integration: save status per game, sync before launch, back up after exit, conflicts, and each person's playtime, achievements and settings following them between PCs. - Pretty, responsive, secure and light: the interface unloads while you play. -**Not planned:** downloading or modifying games, or anything that bypasses copy protection (Seaglass only manages what's installed). Also not planned for now: emulators and ROMs, macOS or Linux, importing from Playnite. +**Not planned:** downloading or modifying games, or anything that bypasses copy protection (Seaglass only manages what's installed). Also not planned for now: macOS or Linux, importing from Playnite. Console games on emulators are in (1.13), for the emulators and games already on the PC: Seaglass doesn't download, set up or configure emulators, BIOS files, keys or games. ## Decisions @@ -41,6 +42,7 @@ These need the maintainer's accounts or decisions; nothing else can do them. | Shared code | `github.com/ApolloF/gamekit` (public, MIT): VDF (text and binary), Steam (folder, libraries, installed apps, account, Authenticode, tamper and emulator checks) and the Ludusavi manifest parser. Store detection (Epic, GOG, Xbox, …) stays in each app: Syncer only needs names, Seaglass needs launch details. | | Syncer | Stays a separate app with a local API (named pipe, current user only). Needs Syncer 0.11+; see [syncer-api.md](syncer-api.md) | | Store games not installed | Optional, **off by default** | +| Console games | Emulators found on the PC (RPCS3, Dolphin, Cemu) and the games their own settings point at; **on by default**, per PC. Started through the emulator full screen; the emulator handles the controller. Keys of their own (`emu:` + the booted file), so PC games are never touched | | Desktop theme | Follows the Windows light or dark setting (Mica). Big picture is always dark. | | Releases | Signed with the release key outside GitHub ([RELEASING.md](RELEASING.md)); the updater installs only signed releases | | License | AGPL-3.0 (MIT until 1.4) | @@ -50,7 +52,7 @@ These need the maintainer's accounts or decisions; nothing else can do them. - **WebView:** strict CSP with no remote scripts. Navigation away from the app is blocked, devtools and the context menu are off in release, only bound services are exposed, and every value from the UI is validated in Go (paths must resolve inside known roots). - **Network:** allowlisted hosts, HTTPS, size limits, and images re-encoded. The only executables ever downloaded are Seaglass's own update from GitHub Releases (checked against the signed `SHA256SUMS`) and Syncer's installer (checked against the SHA-256 GitHub computed on upload, never the same or an older version, and, while Syncer's releases are unsigned, only after a Yes and with the installer's own windows). - **Secrets:** the SteamGridDB key, Steam Web API key, Epic token and GOG token are stored with DPAPI and never logged. -- **Parsers:** VDF, ini, JSON, lnk and ACF files, and emulators' achievement files, are treated as untrusted, with size limits and fuzz tests. +- **Parsers:** VDF, ini, JSON, lnk and ACF files, emulators' achievement files, and console emulators' settings and game files (PARAM.SFO, disc headers, games.yml, Dolphin.ini, Cemu's XML) are treated as untrusted, with size limits and fuzz tests; walks through their game folders are bounded in time, entries and depth. - **Privileges:** Seaglass never elevates itself, and nothing is ever injected into games. The Syncer pipe is current-user only. ## Performance budget (Ryzen 7 7840HS, 16 GB) diff --git a/docs/releases/v1.13.0.md b/docs/releases/v1.13.0.md new file mode 100644 index 0000000..5ee135a --- /dev/null +++ b/docs/releases/v1.13.0.md @@ -0,0 +1,4 @@ +**New** + +- **Console games from your emulators.** Seaglass now finds RPCS3 (PS3), Dolphin (GameCube and Wii) and Cemu (Wii U) and the games they can play, and lists them next to your PC games, in desktop mode and every big picture layout. They get their real names, the console as their label, art where SteamGridDB or the game itself has some, and playtime like any other game. Play starts the emulator full screen, and you're back in Seaglass when it closes. The emulator handles your controller itself. Two copies of the same game (two regions) show their serial so you can tell them apart. Turn it off in Settings → Library, or hide the *Consoles* library. +- **Launch options.** In a game's menu, *Launch options…* sets the arguments its program starts with, and optionally the ones that make it close the game again. With *Choose program…* you can start a game through a script or another launcher. From a932f4adcb704e5ce15d014fb327b69f64bd4340 Mon Sep 17 00:00:00 2001 From: ApolloF Date: Thu, 29 Oct 2026 14:51:20 +0100 Subject: [PATCH 3/4] Console games: fix review findings - Scan: the PC games never wait for the console search. It runs beside the scan, one at a time with a deadline; results that come later are applied on their own, and console games a search didn't finish looking for stay as they were. Nothing on a network drive or UNC path is opened anywhere in internal/emulated; Cemu-listed games there come from its list alone. Panics in Find are recovered. - Toggling "Add console games from emulators" rescans. - Launch options: the program is only taken from the native picker, the exact command lines are confirmed in a Windows dialog before saving, changing only arguments keeps the program and its folder check (userArgs), and Cancel drops a program picked in the form. ChooseExe clears quit arguments. - Console tracking: only the emulator's folder, only processes started after Play; a boot script that's slow to start the emulator is waited for while it runs (up to 15 min). - Quit arguments run only when the program isn't the game itself. - Ask who plays asks for console games too. - Synced settings carry hideConsoles, so an older Seaglass dropping "emulated" from hidden sources doesn't un-hide consoles. --- docs/CHANGELOG.md | 9 +- .../Seaglass/internal/app/libraryservice.ts | 25 +- .../Seaglass/internal/library/models.ts | 6 + frontend/src/desktop/Details.svelte | 13 +- frontend/src/lib/api.mock.ts | 12 +- frontend/src/lib/api.real.ts | 3 +- frontend/src/lib/api.ts | 6 +- frontend/src/lib/types.ts | 4 +- internal/app/console_test.go | 288 +++++++++++++++++- internal/app/consoles.go | 97 ++++++ internal/app/consoles_test.go | 90 ++++++ internal/app/core.go | 29 +- internal/app/launch.go | 82 +++-- internal/app/services.go | 100 +++++- internal/emulated/cemu.go | 19 +- internal/emulated/dolphin.go | 6 +- internal/emulated/emulated.go | 73 ++++- internal/emulated/emulated_test.go | 54 +++- internal/emulated/live_test.go | 4 +- internal/emulated/rpcs3.go | 12 +- internal/emulated/walk.go | 10 +- internal/launch/launch.go | 56 +++- internal/launch/launch_test.go | 70 +++++ internal/launch/track.go | 6 + internal/library/library.go | 22 +- internal/library/library_test.go | 29 ++ internal/platform/shell.go | 15 +- internal/settings/settings.go | 15 + internal/settings/settings_test.go | 27 ++ 29 files changed, 1043 insertions(+), 139 deletions(-) create mode 100644 internal/app/consoles.go create mode 100644 internal/app/consoles_test.go diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 34f73aa..baf9a6e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,12 +11,13 @@ Console games played on emulators are a new library next to the PC games, so Sea - **New `internal/emulated`**: finds RPCS3, Dolphin and Cemu (an `Emulators`, `Emulation` or `Emu` folder on any fixed drive, also under `Games`, Program Files, `%LOCALAPPDATA%\Programs`, Scoop) and the games their own files list: RPCS3's `config\games.yml`, `dev_hdd0\game` and `dev_hdd0\disc`, and the folders next to its listed games (a second copy of a disc it forgot); Dolphin's `ISOPath`s from `Dolphin.ini`, walked like Dolphin does; Cemu's `GamePaths`, `title_list_cache.xml` and games installed to `mlc01`. Another emulator (RetroArch, Ryujinx, PCSX2) is one more `emulator` value in that package. - **Titles** come from the game or the emulator, never the file name when there's better: `PARAM.SFO`, Dolphin's GameTDB list (`Sys\wiitdb-en.txt`) by disc id, Cemu's game list and `meta.xml`, then the disc's own name, then the folder (when the game is alone in it), then the file. The platform is the source label (PS3, GameCube, Wii, Wii U). - **Copies**: two files with the same serial, or the same name and size, are one game; a local copy beats one on a network drive (Cemu's list pointed at the server, the local copy is what plays). Different copies that are both there (two regions' discs) keep both, with the serial after the title. -- **Bounded and untrusted**: every file is read with a size limit (`PARAM.SFO` 64 KB, `games.yml` 1 MB, title list 8 MB, …) and the parsers are fuzzed; paths from the emulators' files are cleaned, and device paths refused. The walk stops after 4 s or 60,000 entries, goes at most 5 folders down, skips folders with programs in them (PC games) and doesn't walk network drives (the games an emulator lists there are still found). On this PC: 7 games in about 110 ms. -- **Launching**: RPCS3 `--no-gui --fullscreen`, Dolphin `-b -C Main.Display.Fullscreen=True -e`, Cemu `-f -g`. While a console game runs, only processes from the emulator's folder are the game (`launch.Plan.FolderOnly`), so a boot script and a helper it starts don't keep the session going. Auto never hands a console game an Xbox controller (the emulator reads the DualSense itself); saves steps and Syncer registration skip them; a running emulator isn't taken for one of its games. -- **Launch options** (any game): the arguments its program starts with, and arguments that make it close the game again (`quitArgs`). Quit game then runs the program with those and ends the game itself after 20 s if it's still running. Combined with *Choose program…* this starts a console game through a launcher or script (a figure portal's boot script, say). Scans keep them; *Start with * goes back. +- **Bounded and untrusted**: every file is read with a size limit (`PARAM.SFO` 64 KB, `games.yml` 1 MB, title list 8 MB, …) and the parsers are fuzzed; paths from the emulators' files are cleaned, and device paths refused. The walk stops after 4 s or 60,000 entries, goes at most 5 folders down, skips folders with programs in them (PC games). Nothing on a network drive (mapped or `\\server\share`) is ever opened, since one whose server is away can hang for minutes: a game Cemu's list names there still shows, from the list alone; RPCS3 games there don't. On this PC: 7 games in about 20 ms. +- **The PC games never wait**: the search for console games runs beside the scan (`app.consoleSearch`, one at a time, 15 s deadline). When it isn't done as the PC games are, they're shown first and the console games come in after (`library.ApplyScanOf`); console games a search couldn't finish looking for (cancelled, out of time, a panic) are left as they were instead of turning not installed. Turning the setting on or off rescans. +- **Launching**: RPCS3 `--no-gui --fullscreen`, Dolphin `-b -C Main.Display.Fullscreen=True -e`, Cemu `-f -g`. While a console game runs, only processes from the emulator's folder that started after Play are the game (`launch.Plan.FolderOnly`), so a boot script, a launcher that stays in the tray and an emulator that was open already don't keep the session going. A boot script that's slow to start the emulator (a figure portal's controller first) is waited for up to 15 minutes while it runs. *Ask who plays* asks for console games too. Auto never hands a console game an Xbox controller (the emulator reads the DualSense itself); saves steps and Syncer registration skip them; a running emulator isn't taken for one of its games. +- **Launch options** (any game): the program (picked in Windows' file picker), the arguments it starts with, and arguments that make it close the game again (`quitArgs`). Nothing is saved before the person confirms the exact command lines in a Windows dialog, which the interface can't answer for them; Cancel drops a program picked in the form. Changing only the arguments keeps the program and its folder check (`userArgs`, not `userExe`). Quit game runs the program with the quit arguments only when it isn't the game itself (a script or launcher, not the emulator), and ends the game after 20 s if it's still running. This starts a console game through a launcher or script (a figure portal's boot script, say). Scans keep them; *Start with * goes back. - **Art**: console games skip the Steam store and PCGamingWiki (a PC game of the same name isn't them). SteamGridDB is searched by the title without the serial, and the pictures the game came with fill the rest: a PS3 disc's 1920×1080 `PIC1.PNG` is the backdrop (and the hero and cover when there's no other), `ICON0.PNG` the icon, Dolphin's downloaded covers the cover. Local files are only read as PNG or JPEG, within the download limits. - **Playtime** is kept per console and serial (`console:ps3:bles01689`), never under a PC game's title. -- **Settings**: *Add console games from emulators* (`detectEmulators`, on, per PC like the emulators), and a *Consoles* library that can be hidden (`hiddenSources: emulated`). +- **Settings**: *Add console games from emulators* (`detectEmulators`, on, per PC like the emulators), and a *Consoles* library that can be hidden (`hiddenSources: emulated`). A Seaglass older than 1.13 drops `emulated` from the hidden sources it syncs, so the synced settings carry `hideConsoles` too; when another PC's settings don't have it, the console games stay hidden or shown as they were. ## 1.12.0 (unreleased): big picture for game streaming diff --git a/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/libraryservice.ts b/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/libraryservice.ts index 89741cd..9f36eb7 100644 --- a/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/libraryservice.ts +++ b/frontend/bindings/github.com/ApolloF/Seaglass/internal/app/libraryservice.ts @@ -79,6 +79,15 @@ export function OpenFolder(id: number): $CancellablePromise { return $Call.ByID(1976215147, id); } +/** + * PickProgram lets the user pick a program for the game's launch options + * without saving it: SetLaunch takes it when they save. "" when they + * cancel. + */ +export function PickProgram(id: number): $CancellablePromise { + return $Call.ByID(3276071030, id); +} + /** * RefreshMetadata fetches a game's metadata and art again. */ @@ -161,14 +170,16 @@ export function SetHidden(id: number, on: boolean): $CancellablePromise { - return $Call.ByID(1871014939, id, args, quitArgs); +export function SetLaunch(id: number, exe: string, args: string, quitArgs: string): $CancellablePromise { + return $Call.ByID(844615898, id, exe, args, quitArgs); } /** diff --git a/frontend/bindings/github.com/ApolloF/Seaglass/internal/library/models.ts b/frontend/bindings/github.com/ApolloF/Seaglass/internal/library/models.ts index 14172d1..6186fcf 100644 --- a/frontend/bindings/github.com/ApolloF/Seaglass/internal/library/models.ts +++ b/frontend/bindings/github.com/ApolloF/Seaglass/internal/library/models.ts @@ -109,6 +109,12 @@ export interface Game { */ "quitArgs"?: string; + /** + * UserArgs: the user set Args and QuitArgs; scans leave them alone, + * with the program the scan found (UserExe keeps that too). + */ + "userArgs"?: boolean; + /** * a connected store account owns it */ diff --git a/frontend/src/desktop/Details.svelte b/frontend/src/desktop/Details.svelte index 7355f4d..cbbbb1c 100644 --- a/frontend/src/desktop/Details.svelte +++ b/frontend/src/desktop/Details.svelte @@ -179,6 +179,7 @@ // Launch options: the program's arguments, and ones that make it close // the game (a launcher or script that wants to be told). let launchOpen = $state(false); + let exeDraft = $state(""); let argsDraft = $state(""); let quitDraft = $state(""); $effect(() => { @@ -187,19 +188,21 @@ }); function openLaunch() { menuOpen = false; + exeDraft = game.exe ?? ""; argsDraft = game.args ?? ""; quitDraft = game.quitArgs ?? ""; launchOpen = true; } async function saveLaunch() { - if ((await lib.run(() => api.setLaunchArgs(game.id, argsDraft, quitDraft))) !== undefined) launchOpen = false; + if ((await lib.run(() => api.setLaunch(game.id, exeDraft, argsDraft, quitDraft))) !== undefined) launchOpen = false; } async function resetLaunch() { if ((await lib.run(() => api.resetLaunch(game.id))) !== undefined) launchOpen = false; } + // The program picked here is saved with the rest, or dropped on Cancel. async function chooseLaunchExe() { - const g = await lib.run(() => api.chooseExe(game.id)); - if (g) argsDraft = g.args ?? ""; + const p = await lib.run(() => api.pickProgram(game.id)); + if (p) (exeDraft = p), (argsDraft = ""), (quitDraft = ""); } function onmenukey(e: KeyboardEvent) { @@ -360,7 +363,7 @@ Launch options
- {game.exe?.split("\\").pop()} + {exeDraft.split("\\").pop()}