feat: Implemented romm saves for dolphin and xenia

feat: Implemented save backups for emulatorjs
fix: Added support for rar archives
fix: Moved to individual ini adjustments for pcsx2 and ppsspp to allow for user editing of configs
This commit is contained in:
Simeon Radivoev 2026-04-09 17:15:37 +03:00
parent 54dd9256e3
commit 7948bd24fa
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
36 changed files with 1103 additions and 243 deletions

View file

@ -181,7 +181,7 @@ export async function tryLoginAndSave ({ host, username, password }: { host: str
body: {
password,
username,
scope: 'me.read roms.read platforms.read assets.read firmware.read roms.user.read collections.read me.write roms.user.write'
scope: 'me.read roms.read platforms.read assets.read assets.write firmware.read roms.user.read collections.read me.write roms.user.write'
}, baseUrl: host
});

View file

@ -5,9 +5,10 @@ import games from "./games/games";
import platforms from "./games/platforms";
import auth from "./auth";
import collections from "./games/collections";
import emulatorjs from "./emulatorjs/emulatorjs";
export default new Elysia({ prefix: "/api/romm" })
.use([games, platforms, collections, auth])
.use([games, platforms, collections, auth, emulatorjs])
.all("/*", async ({ request, set }) =>
{
set.headers["cross-origin-resource-policy"] = 'cross-origin';

View file

@ -14,8 +14,8 @@ export default async function Initialize ()
const launchGameTask = taskQueue.findJob(LaunchGameJob.id, LaunchGameJob);
if (launchGameTask)
{
launchGameTask.abort('exit');
taskQueue.waitForJob(LaunchGameJob.id).then(() => setTimeout(() => events.emit('focus'), 300));
launchGameTask.abort('exit');
} else
{
events.emit('focus');

View file

@ -1,4 +1,11 @@
// ES-DE to emulator JS mapping
import Elysia, { status } from "elysia";
import z from "zod";
import path from 'node:path';
import { config, events, plugins } from "../app";
import { getLocalGame, updateLocalLastPlayed } from "../games/services/statusService";
// TODO: use the retroarch cores based on ES-DE
export const cores: Record<string, string> = {
"atari5200": "atari5200",
@ -43,4 +50,57 @@ export const cores: Record<string, string> = {
"plus4": "plus4",
"vic20": "vic20",
"dos": "dos"
};
};
export default new Elysia({ prefix: '/emulatorjs' })
.put('/save', async ({ body: { save, screenshot } }) =>
{
await Bun.write(path.join(config.get('downloadPath'), 'saves', "EMULATORJS", save.name), save);
}, {
body: z.object({
save: z.file(),
screenshot: z.file().optional()
})
}).get('/load', async ({ query: { filePath } }) =>
{
return Bun.file(path.join(config.get('downloadPath'), 'saves', "EMULATORJS", filePath));
}, { query: z.object({ filePath: z.string() }) })
.post('/post_play/:source/:id', async ({ params: { source, id }, body: { save } }) =>
{
const localGame = await getLocalGame(source, id);
if (!localGame) return status("Not Found");
const changedSaveFiles: SaveFileChange[] = [];
if (save)
{
const savesPath = path.join(config.get('downloadPath'), 'saves', "EMULATORJS");
const saveFile = path.join(savesPath, save.name);
await Bun.write(saveFile, save);
changedSaveFiles.push({ subPath: save.name, cwd: savesPath });
events.emit('notification', { message: "Save Backed Up", type: "success", icon: "save" });
}
await updateLocalLastPlayed(localGame.id);
await plugins.hooks.games.postPlay.promise({
source,
id,
saveFolderPath: path.join(config.get('downloadPath'), "saves", "EMULATORJS"),
gameInfo: { platformSlug: localGame?.platform.slug },
changedSaveFiles: changedSaveFiles,
validChangedSaveFiles: changedSaveFiles,
command: {
id: "EMULATORJS",
command: "",
emulator: "EMULATORJS",
valid: true,
metadata: {
romPath: localGame?.path_fs ?? undefined,
emulatorBin: undefined,
emulatorDir: undefined
}
}
});
}, {
body: z.object({
save: z.file().optional()
})
});

View file

@ -13,6 +13,7 @@ import Elysia from "elysia";
import z from "zod";
import { InstallJob, InstallJobStates } from "../../jobs/install-job";
import { LaunchGameJob } from "../../jobs/launch-game-job";
import * as appSchema from "@schema/app";
class CommandSearchError extends Error
{
@ -26,7 +27,14 @@ class CommandSearchError extends Error
export async function getLocalGame (source: string, id: string)
{
const localGame = await db.query.games.findFirst({
columns: { id: true, path_fs: true, source: true, source_id: true },
columns: {
id: true,
path_fs: true,
source: true,
source_id: true,
igdb_id: true,
ra_id: true
},
where: getLocalGameMatch(id, source),
with: {
platform: { columns: { slug: true } }
@ -36,6 +44,33 @@ export async function getLocalGame (source: string, id: string)
return localGame;
}
export async function validateGameSource (source: string, id: string): Promise<{ valid: boolean, reason?: string; }>
{
const localGame = await getLocalGame(source, id);
if (!localGame) throw new Error("Could not find local game");
if (localGame.source && localGame.source_id)
{
const sourceGame = await plugins.hooks.games.fetchGame.promise({ source: localGame.source, id: localGame.source_id });
if (!sourceGame) return { valid: false, reason: "Source Missing" };
if (sourceGame.imdb_id !== (localGame.igdb_id ?? undefined))
{
return { valid: false, reason: "IGDB Miss Match" };
}
if (sourceGame.ra_id !== (localGame.ra_id ?? undefined))
{
return { valid: false, reason: "RA Miss Match" };
}
}
return { valid: true };
}
export async function updateLocalLastPlayed (id: number)
{
await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, Number(id)));
}
export async function getValidLaunchCommandsForGame (source: string, id: string): Promise<{ commands: CommandEntry[], gameId: FrontEndId, source?: string, sourceId?: string; } | Error | undefined>
{
if (source === 'emulator')

View file

@ -18,8 +18,9 @@ export class GameHooks
source?: string;
sourceId?: string;
id: FrontEndId;
platformSlug?: string;
};
}], string[] | undefined, { emulator: string; }>(['ctx']);
}], { args: string[], savesPath?: string; } | undefined, { emulator: string; }>(['ctx']);
/**
* Is the given emulator for the given command supported
* @returns The current support level. Partial means it can affect some functionality. Full means fully integrated for example with portable ones where you can control all aspects.
@ -69,7 +70,27 @@ export class GameHooks
fetchPlatforms = new AsyncSeriesHook<[ctx: {
platforms: FrontEndPlatformType[];
}]>(['ctx']);
updatePlayed = new AsyncSeriesWaterfallHook<[ctx: { source: string, id: string; }], boolean>(["ctx"]);
prePlay = new AsyncSeriesHook<[ctx: {
source: string,
id: string;
saveFolderPath?: string;
setProgress: (progress: number, state: string) => void,
command: CommandEntry;
gameInfo: {
platformSlug?: string;
};
}]>(["ctx"]);
postPlay = new AsyncSeriesHook<[ctx: {
source: string,
id: string;
saveFolderPath?: string;
changedSaveFiles: SaveFileChange[],
validChangedSaveFiles: SaveFileChange[],
command: CommandEntry;
gameInfo: {
platformSlug?: string;
};
}]>(["ctx"]);
fetchCollections = new AsyncSeriesHook<[ctx: { collections: FrontEndCollection[]; }]>(['ctx']);
fetchCollection = new AsyncSeriesBailHook<[ctx: { source: string, id: string; }], FrontEndCollection | undefined>(['ctx']);

View file

@ -3,7 +3,7 @@ import { and, eq, or } from 'drizzle-orm';
import fs from 'node:fs/promises';
import * as schema from "@schema/app";
import * as emulatorSchema from "@schema/emulators";
import path from 'node:path';
import path, { join } from 'node:path';
import { config, db, emulatorsDb, events, plugins } from "../app";
import { extractStoreGameSourceId, getStoreGameFromId } from "../store/services/gamesService";
import * as igdb from 'ts-igdb-client';
@ -13,9 +13,12 @@ import { Downloader } from "@/bun/utils/downloader";
import Seven from 'node-7z';
import z from "zod";
import { checkFiles } from "../games/services/utils";
import { ensureDir } from "fs-extra";
import { ensureDir, existsSync } from "fs-extra";
import { path7za } from "7zip-bin";
import slugify from 'slugify';
import StreamZip from 'node-stream-zip';
import { createExtractorFromFile } from 'node-unrar-js';
import { which } from "bun";
interface JobConfig
{
@ -116,23 +119,62 @@ export class InstallJob implements IJob<never, InstallJobStates>
for (const filePath of downloadedFiles)
{
const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path);
await new Promise((resolve, reject) =>
await new Promise(async (resolve, reject) =>
{
const seven = Seven.extractFull(filePath, extractPath, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true });
let sevenZipPath = process.env.ZIP7_PATH ?? path7za;
if (filePath.endsWith('.rar'))
{
let newPath: string | undefined;
if (process.platform === 'win32' && await fs.exists("C:\\Program Files\\7-Zip\\7z.exe"))
{
newPath = "C:\\Program Files\\7-Zip\\7z.exe";
} else
{
newPath = which('7z') ?? undefined;
}
if (!newPath)
{
await fs.rm(filePath);
reject(new Error("No RAR Support"));
return;
}
sevenZipPath = newPath;
}
let rejected = false;
const seven = Seven.extractFull(filePath, extractPath, { $bin: sevenZipPath, $progress: true });
seven.on('progress', p =>
{
cx.setProgress(progress + p.percent * progressDelta, "extract");
});
seven.on('error', e =>
{
reject(e);
rejected = true;
});
seven.on('end', async () =>
{
if (rejected) return;
await fs.rm(filePath);
resolve(true);
});
}).catch(async e =>
{
if (filePath.endsWith('.zip'))
{
console.warn("Could not extract", filePath, "with 7zip trying zip extractor");
await ensureDir(extractPath);
const zip = new StreamZip.async({ file: filePath });
const count = await zip.extract(null, extractPath);
console.log(`Extracted ${count} entries`);
await zip.close();
} else
{
throw e;
}
});
progress += progressDelta * 100;
}

View file

@ -5,8 +5,11 @@ import { db, events, plugins } from "../app";
import * as appSchema from "@schema/app";
import { eq } from "drizzle-orm";
import { spawn } from 'node:child_process';
import { watch } from "node:fs";
import fs from "node:fs/promises";
import { updateLocalLastPlayed } from "../games/services/statusService";
export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing">
export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSchema>, string>
{
static id = "launch-game" as const;
static dataSchema = z.nullable(ActiveGameSchema);
@ -16,6 +19,8 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
validCommand: CommandEntry;
gameSource?: string;
gameSourceId?: string;
changedSaveFiles: Map<string, SaveFileChange>;
saveFolderPath?: string;
constructor(gameId: FrontEndId, validCommand: CommandEntry, source?: string, sourceId?: string)
{
@ -24,11 +29,46 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
this.gameSource = source;
this.gameSourceId = sourceId;
this.activeGame = null;
this.changedSaveFiles = new Map();
}
async start (context: JobContext<IJob<z.infer<typeof LaunchGameJob.dataSchema>, "playing">, z.infer<typeof LaunchGameJob.dataSchema>, "playing">)
async postPlay (gameInfo: { platformSlug?: string; })
{
let gameInfo: { name?: string, source_id?: string, source?: string; };
if (this.gameId.source === 'local')
{
await updateLocalLastPlayed(Number(this.gameId.id));
}
const source = this.gameSource ?? this.gameId.source;
const id = this.gameSourceId ?? this.gameId.id;
await plugins.hooks.games.postPlay.promise(
{
source,
id,
command: this.validCommand,
saveFolderPath: this.saveFolderPath,
changedSaveFiles: Array.from(this.changedSaveFiles.values()),
validChangedSaveFiles: [],
gameInfo
}).catch(e => console.error(e));
}
prePlay (setProgress: (progress: number, state: string) => void, gameInfo: { platformSlug?: string; })
{
return plugins.hooks.games.prePlay.promise({
source: this.gameSource ?? this.gameId.source,
id: this.gameSourceId ?? this.gameId.id,
saveFolderPath: this.saveFolderPath,
command: this.validCommand,
setProgress: setProgress,
gameInfo
});
}
async start (context: JobContext<IJob<z.infer<typeof LaunchGameJob.dataSchema>, string>, z.infer<typeof LaunchGameJob.dataSchema>, string>)
{
let gameInfo: { name?: string, source_id?: string, source?: string; platformSlug?: string; } | undefined = undefined;
if (this.gameId.source === 'emulator')
{
gameInfo = { name: this.gameId.id };
@ -38,125 +78,140 @@ export class LaunchGameJob implements IJob<z.infer<typeof LaunchGameJob.dataSche
where: eq(appSchema.games.id, Number(this.gameId.id)), columns: {
name: true,
source_id: true,
source: true
source: true,
},
with: {
platform: {
columns: {
es_slug: true,
slug: true
}
}
}
});
if (localGame)
gameInfo = { name: localGame.name ?? undefined, source_id: localGame.source_id ?? undefined, source: localGame.source ?? undefined };
gameInfo = {
name: localGame.name ?? undefined,
source_id: localGame.source_id ?? undefined,
source: localGame.source ?? undefined,
platformSlug: localGame.platform.es_slug ?? localGame.platform.slug
};
}
const commandArgs = await plugins.hooks.games.emulatorLaunch.promise({
autoValidCommand: this.validCommand,
game: { source: this.gameSource, sourceId: this.gameSourceId, id: this.gameId },
game: {
source: this.gameSource,
sourceId: this.gameSourceId,
id: this.gameId,
platformSlug: gameInfo?.platformSlug
},
dryRun: false
});
await new Promise((resolve, reject) =>
await new Promise(async (resolve, reject) =>
{
let game: any;
if (!commandArgs)
try
{
// ES-DE commands require shell execution. Some emulators fail otherwise.
const spawnGame = spawn(this.validCommand.command, {
shell: true,
cwd: this.validCommand.startDir,
signal: context.abortSignal,
env: {
let game: any;
if (!commandArgs)
{
await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug }).catch(e => reject(e));
// ES-DE commands require shell execution. Some emulators fail otherwise.
const spawnGame = spawn(this.validCommand.command, {
shell: true,
cwd: this.validCommand.startDir,
signal: context.abortSignal,
env: {
}
});
context.setProgress(0, "playing");
spawnGame.stdout.on('data', data => console.log(data));
spawnGame.on('close', (code) =>
{
resolve(code);
});
spawnGame.on('error', e =>
{
console.error(e);
reject(e);
});
game = spawnGame;
}
else if (this.validCommand.metadata.emulatorBin)
{
this.saveFolderPath = commandArgs.savesPath;
await this.prePlay(context.setProgress.bind(context), { platformSlug: gameInfo?.platformSlug });
// We have full control over launching integrated emulators better to use bun spawn
const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs.args], {
cwd: this.validCommand.startDir,
signal: context.abortSignal,
env: {
}
});
context.setProgress(0, "playing");
if (commandArgs.savesPath && await fs.exists(commandArgs.savesPath))
{
const savesWatcher = watch(commandArgs.savesPath, { recursive: true, signal: context.abortSignal });
console.log("Starting To Watch", commandArgs.savesPath, "for save file changes");
savesWatcher.on('change', (type, filename) =>
{
if (typeof filename === 'string')
{
console.log("Save File Changed", filename);
this.changedSaveFiles.set(filename, { subPath: filename, cwd: commandArgs.savesPath! });
}
});
bunGame.exited.then(() =>
{
savesWatcher.close();
console.log("Closing Save File Watching for", commandArgs.savesPath);
});
}
});
spawnGame.stdout.on('data', data => console.log(data));
spawnGame.on('close', (code) =>
bunGame.exited.then(e =>
{
resolve(true);
}).catch(e =>
{
console.error(e);
reject(e);
});
game = bunGame;
} else
{
resolve(code);
});
spawnGame.on('error', e =>
{
console.error(e);
reject(e);
});
game = spawnGame;
}
else if (this.validCommand.metadata.emulatorBin)
{
// We have full control over launching integrated emulators better to use bun spawn
const bunGame = Bun.spawn([this.validCommand.metadata.emulatorBin, ...commandArgs], {
cwd: this.validCommand.startDir,
signal: context.abortSignal,
env: {
}
});
context.abortSignal.addEventListener('abort', reject);
bunGame.exited.then(e =>
{
resolve(true);
}).catch(e =>
{
console.error(e);
reject(e);
});
game = bunGame;
} else
{
reject(new Error("No Emulator Bin"));
return;
}
this.activeGame = {
process: game,
name: gameInfo?.name ?? "Unknown",
gameId: this.gameId,
source: this.gameSource,
sourceId: this.gameSourceId,
command: this.validCommand
};
const updatePlayed = async (id: FrontEndId, source?: string, sourceId?: string) =>
{
if (this.gameId.source === 'local')
{
await db.update(appSchema.games).set({ last_played: new Date() }).where(eq(appSchema.games.id, Number(this.gameId.id)));
reject(new Error("No Emulator Bin"));
return;
}
await plugins.hooks.games.updatePlayed.promise({ source: source ?? id.source, id: sourceId ?? id.id }).then(v =>
{
if (v) events.emit('notification', { message: "Updated Last Played", type: 'success' });
});
};
updatePlayed(this.gameId, this.gameSource, this.gameSourceId);
this.activeGame = {
process: game,
name: gameInfo?.name ?? "Unknown",
gameId: this.gameId,
source: this.gameSource,
sourceId: this.gameSourceId,
command: this.validCommand
};
} catch (e)
{
context.abort(e);
reject(e);
}
});
/* Old spawn lanching, cases issues, needs to be ran as shell
const cmd = Array.from(validCommand.command.command.matchAll(/(".*?"|[^\s"]+)/g)).map(m => m[0]);
const game = setActiveGame({
process: Bun.spawn({
cmd,
env: {
...process.env
},
onExit (subprocess, exitCode, signalCode, error)
{
events.emit('activegameexit', { subprocess, exitCode, signalCode, error });
},
stdin: "ignore",
stdout: "inherit",
stderr: "inherit",
}),
name: localGame?.name ?? "Unknown",
gameId: validCommand.gameId,
command: validCommand.command.command
});
await game.process.exited;
if (game.process.exitCode && game.process.exitCode > 0)
{
return status('Internal Server Error');
}*/
await this.postPlay({ platformSlug: gameInfo?.platformSlug });
}
exposeData ()

View file

@ -11,7 +11,7 @@ export default class CEMUIntegration implements PluginType
{
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
{
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] };
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen"] };
});
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
@ -29,7 +29,7 @@ export default class CEMUIntegration implements PluginType
args.push(`--game=${ctx.autoValidCommand.metadata.romPath}`);
}
return args;
return { args, savesPath: savesPath };
});
}
}

View file

@ -3,17 +3,18 @@ import { config } from "@/bun/api/app";
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
import path from 'node:path';
import desc from './package.json';
import { ensureDir } from "fs-extra";
import { getSavePaths, getType } from "./utils";
export default class DOLPHINIntegration implements PluginType
{
emulator = 'DOLPHIN';
load (ctx: PluginContextType)
{
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
{
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "resolution", "fullscreen", "states"] };
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "resolution", "fullscreen", "saves"] };
});
ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
@ -51,14 +52,33 @@ export default class DOLPHINIntegration implements PluginType
args.push(`--config=Dolphin.General.WiiSDCardPath=${path.join(savesPath, 'WiiSD.raw')}`);
args.push(`--config=Dolphin.General.WiiSDCardSyncFolder=${path.join(savesPath, 'WiiSDSync')}`);
args.push(`--config=Dolphin.GBA.SavesPath=${path.join(savesPath, 'GBA')}`);
args.push(`--config=Dolphin.Core.GCIFolderAPath=${path.join(savesPath, 'GC')}`);
if (!ctx.dryRun)
{
await ensureDir(path.join(savesPath, 'GC', "JAP"));
await ensureDir(path.join(savesPath, 'GC', "EUR"));
await ensureDir(path.join(savesPath, 'GC', "USA"));
}
let finalSavesPath: string | undefined = undefined;
if (ctx.autoValidCommand.metadata.romPath)
{
args.push("--batch");
args.push(`--exec=${ctx.autoValidCommand.metadata.romPath}`);
finalSavesPath = await getType(ctx.autoValidCommand.metadata.romPath, ctx.autoValidCommand.metadata.emulatorDir) === 'gamecube' ? savesPath : storageFolder;
}
return args;
return { args, savesPath: finalSavesPath };
});
ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderPath, command, gameInfo }) =>
{
if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath)
{
validChangedSaveFiles.push(...await getSavePaths(command.metadata.romPath, saveFolderPath, command.metadata.emulatorDir));
}
});
}
}

View file

@ -0,0 +1,164 @@
import { join } from "path";
import { platform } from "os";
import fs from "node:fs/promises";
import path from "node:path";
type DolphinLocation =
| { type: "path"; toolPath: string; }
| { type: "appimage"; appImagePath: string; };
async function findDolphinTool (bundledDir?: string): Promise<DolphinLocation>
{
const os = platform();
const toolName = os === "win32" ? "DolphinTool.exe" : "dolphin-tool";
if (bundledDir)
{
if (os === "linux")
{
const glob = new Bun.Glob("*.AppImage");
for await (const file of glob.scan(bundledDir))
{
return { type: "appimage", appImagePath: join(bundledDir, file) };
}
throw new Error(`No AppImage found in ${bundledDir}`);
} else
{
return { type: "path", toolPath: join(bundledDir, toolName) };
}
}
// Fallback 1: check PATH
const inPath = Bun.which(toolName);
if (inPath) return { type: "path", toolPath: inPath };
// Fallback 2: platform default install locations
if (os === "win32")
{
const candidates = [
"C:/Program Files/Dolphin/DolphinTool.exe",
"C:/Program Files (x86)/Dolphin/DolphinTool.exe",
];
for (const candidate of candidates)
{
if (await Bun.file(candidate).exists())
{
return { type: "path", toolPath: candidate };
}
}
} else if (os === "darwin")
{
const candidate = "/Applications/Dolphin.app/Contents/MacOS/dolphin-tool";
if (await Bun.file(candidate).exists())
{
return { type: "path", toolPath: candidate };
}
} else if (os === "linux")
{
const home = process.env.HOME ?? "";
const candidates = [
join(home, "Applications/Dolphin-x86_64.AppImage"),
join(home, "Applications/Dolphin.AppImage"),
"/opt/Dolphin-x86_64.AppImage",
];
for (const candidate of candidates)
{
if (await Bun.file(candidate).exists())
{
return { type: "appimage", appImagePath: candidate };
}
}
}
throw new Error(`Could not find ${toolName}. Install Dolphin or pass its folder path explicitly.`);
}
async function runDolphinTool (args: string[], location: DolphinLocation): Promise<string>
{
if (location.type === "path")
{
const proc = Bun.spawnSync([location.toolPath, ...args]);
if (!proc.success) throw new Error(`dolphin-tool failed: ${proc.stderr.toString()}`);
return proc.stdout.toString();
} else
{
const mount = Bun.spawn([location.appImagePath, "--appimage-mount"], {
stdout: "pipe",
stderr: "pipe",
});
const mountPoint = (await new Response(mount.stdout).text()).trim();
try
{
const proc = Bun.spawnSync([`${mountPoint}/usr/bin/dolphin-tool`, ...args]);
if (!proc.success) throw new Error(`dolphin-tool failed: ${proc.stderr.toString()}`);
return proc.stdout.toString();
} finally
{
mount.kill();
}
}
}
async function readGameId (romPath: string, location: DolphinLocation): Promise<string>
{
const output = await runDolphinTool(["header", "-i", romPath], location);
const match = output.match(/Game ID:\s*(\w{6})/);
if (!match) throw new Error("Could not read game ID");
return match[1];
}
function getRegion (regionCode: string)
{
switch (regionCode)
{
case "E": return "USA";
case "P": return "EUR";
case "J": return "JAP";
default: return "USA";
}
}
async function getGCSavePaths (romPath: string, savesPath: string, location: DolphinLocation)
{
const gameId = await readGameId(romPath, location);
const region = getRegion(gameId[3]);
const makerCode = gameId.slice(4, 6); // e.g. "01" or "7D" — already the right format
const gameCode = gameId.slice(0, 4); // e.g. "GZLE" or "GM5E"
const cardPath = join(savesPath, "GC", region);
const glob = new Bun.Glob(`${makerCode}-${gameCode}-*.gci`);
const saves: SaveFileChange[] = [];
for await (const file of glob.scan(cardPath))
{
saves.push({ subPath: path.join("GC", region, file), cwd: savesPath, shared: false });
}
return saves;
}
export async function getType (romPath: string, bundledEmulatorDir?: string): Promise<"gamecube" | "wii">
{
const location = await findDolphinTool(bundledEmulatorDir);
const gameId = await readGameId(romPath, location);
const isGameCube = gameId[0] === "G" || gameId[0] === "D";
return isGameCube ? "gamecube" : "wii";
}
export async function getSavePaths (romPath: string, savesPath: string, bundledEmulatorDir?: string): Promise<SaveFileChange[]>
{
const location = await findDolphinTool(bundledEmulatorDir);
const gameId = await readGameId(romPath, location);
const isGameCube = gameId[0] === "G" || gameId[0] === "D";
if (isGameCube)
{
return getGCSavePaths(romPath, savesPath, location);
} else
{
const folder = Buffer.from(gameId.slice(0, 4), "ascii").toString("hex").toUpperCase();
const rootFolder = join(savesPath, "Wii", "title", "00010000", folder);
const files = await fs.readdir(rootFolder, { recursive: true });
return files.map(f => ({ subPath: path.join("Wii", "title", "00010000", f), cwd: savesPath, shared: false }));
}
}

View file

@ -21,7 +21,6 @@ CdvdShareWrite = false
EnablePatches = true
EnableCheats = false
EnablePINE = false
EnableWideScreenPatches = {{ENABLE_WIDESCREEN}}
EnableNoInterlacingPatches = false
EnableRecordingTools = true
EnableGameFixes = true
@ -168,7 +167,6 @@ linear_present_mode = 1
deinterlace_mode = 0
OsdScale = 100
Renderer = 14
upscale_multiplier = {{UPSCALE_MULTIPLIER}}
mipmap_hw = -1
accurate_blending_unit = 1
crc_hack_level = -1
@ -371,18 +369,6 @@ Multitap2_Slot4_Enable = false
Multitap2_Slot4_Filename = Mcd-Multitap2-Slot04.ps2
[Folders]
Bios = {{{BIOS_PATH}}}
Snapshots = {{{SNAPSHOTS_PATH}}}
SaveStates = {{{SAVE_STATES_PATH}}}
MemoryCards = {{{MEMORY_CARDS_PATH}}}
Cache = {{{CACHE_PATH}}}
Covers = {{{COVERS_PATH}}}
Logs = logs
Textures = {{{TEXTURES_PATH}}}
Videos = videos
[InputSources]
Keyboard = true
Mouse = true
@ -488,6 +474,3 @@ RDown = SDL-1/+RightY
RLeft = SDL-1/-RightX
LargeMotor = SDL-1/LargeMotor
SmallMotor = SDL-1/SmallMotor
[GameList]
RecursivePaths = {{{RECURSIVE_PATHS}}}

View file

@ -1,11 +1,11 @@
import { config } from "@/bun/api/app";
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
import configFile from './PCSX2.ini' with { type: 'file' };
import Mustache from 'mustache';
import defaultConfig from './PCSX2.ini' with { type: 'file' };
import path from 'node:path';
import { ensureDir } from "fs-extra";
import desc from './package.json';
import ini from 'ini';
export default class PCSX2Integration implements PluginType
{
@ -15,7 +15,7 @@ export default class PCSX2Integration implements PluginType
{
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
{
const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"];
const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen"];
if (ctx.source?.type === 'store')
{
@ -47,7 +47,16 @@ export default class PCSX2Integration implements PluginType
if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun)
{
const configFileContents = await Bun.file(configFile).text();
let pscx2Path = '';
if (process.platform === 'win32')
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis');
else
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, this.emulator, 'inis');
const configPath = path.join(pscx2Path, 'PCSX2.ini');
const existingConfigFile = Bun.file(configPath);
const configFile = await existingConfigFile.exists() ? ini.parse(await existingConfigFile.text()) : ini.parse(await Bun.file(defaultConfig).text());
const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator);
const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator);
@ -67,28 +76,37 @@ export default class PCSX2Integration implements PluginType
CACHE_PATH: path.join(storageFolder, 'cache'),
COVERS_PATH: path.join(storageFolder, 'covers'),
TEXTURES_PATH: path.join(storageFolder, 'textures'),
VIDEOS_PATH: path.join(storageFolder, 'videos'),
LOGS_PATH: path.join(storageFolder, 'logs'),
RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'),
};
await Promise.all(Object.values(paths).map(p => ensureDir(p)));
const view = {
...paths,
ENABLE_WIDESCREEN: config.get('emulatorWidescreen'),
ASPECT_RATIO: config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2",
UPSCALE_MULTIPLIER: resolutionMapping[config.get('emulatorResolution')] ?? 1
};
configFile.EmuCore ??= {};
configFile.EmuCore.EnableWideScreenPatches = config.get('emulatorWidescreen');
configFile['EmuCore/GS'] ??= {};
configFile['EmuCore/GS'].AspectRatio = config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2";
configFile['EmuCore/GS'].upscale_multiplier = resolutionMapping[config.get('emulatorResolution')] ?? 1;
configFile.Folders ??= {};
configFile.Folders.Bios = paths.BIOS_PATH;
configFile.Folders.Snapshots = paths.SNAPSHOTS_PATH;
configFile.Folders.SaveStates = paths.SAVE_STATES_PATH;
configFile.Folders.MemoryCards = paths.MEMORY_CARDS_PATH;
configFile.Folders.Cache = paths.CACHE_PATH;
configFile.Folders.Covers = paths.COVERS_PATH;
configFile.Folders.Textures = paths.TEXTURES_PATH;
configFile.Folders.Videos = paths.VIDEOS_PATH;
configFile.Folders.Logs = paths.LOGS_PATH;
configFile.GameList ??= {};
configFile.GameList.RecursivePaths = paths.RECURSIVE_PATHS;
let pscx2Path = '';
if (process.platform === 'win32')
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'inis');
else
pscx2Path = path.join(ctx.autoValidCommand.metadata.emulatorDir, this.emulator, 'inis');
await Bun.write(configPath, ini.stringify(configFile));
await Bun.write(path.join(pscx2Path, 'PCSX2.ini'), Mustache.render(configFileContents, view));
return { args, savesPath: paths.MEMORY_CARDS_PATH };
}
return args;
return { args };
});
}
}

View file

@ -96,7 +96,6 @@ HardwareTransform = True
SoftwareSkinning = True
TextureFiltering = 1
BufferFiltering = 1
InternalResolution = {{RESOLUTION}}
AndroidHwScale = 1
HighQualityDepth = 1
FrameSkip = 0
@ -109,7 +108,6 @@ AnisotropyLevel = 4
VertexDecCache = False
TextureBackoffCache = False
TextureSecondaryCache = False
FullScreen = {{FULLSCREEN}}
FullScreenMulti = False
SmallDisplayZoomType = 2
SmallDisplayOffsetX = 0.500000

View file

@ -9,6 +9,7 @@ import path from "node:path";
import Mustache from "mustache";
import { ensureDir } from "fs-extra";
import { homedir } from "node:os";
import ini from 'ini';
export default class PPSSPPIntegration implements PluginType
{
@ -27,7 +28,7 @@ export default class PPSSPPIntegration implements PluginType
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
{
const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"];
const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen"];
if (ctx.source?.type === 'store')
{
@ -59,18 +60,18 @@ export default class PPSSPPIntegration implements PluginType
if (ctx.autoValidCommand.emulatorSource === 'store' && ctx.autoValidCommand.metadata.emulatorDir && !ctx.dryRun)
{
let confPath: string | undefined = undefined;
let controlsPath: string | undefined = undefined;
let defaultConfigPath: string | undefined = undefined;
let defaultControlsPath: string | undefined = undefined;
switch (process.platform)
{
case "win32":
confPath = configFilePathWin32;
controlsPath = configControlsFilePathWin32;
defaultConfigPath = configFilePathWin32;
defaultControlsPath = configControlsFilePathWin32;
break;
case 'linux':
confPath = configFilePathLinux;
controlsPath = configControlsFilePathLinux;
defaultConfigPath = configFilePathLinux;
defaultControlsPath = configControlsFilePathLinux;
break;
}
@ -87,29 +88,36 @@ export default class PPSSPPIntegration implements PluginType
ensureDir(ppssppPath);
if (confPath)
if (defaultConfigPath)
{
const resolutionMapping = {
"720p": "2",
"1080p": "4",
"1440p": "6",
"4k": "8"
const resolutionMapping: Record<string, number> = {
"720p": 2,
"1080p": 4,
"1440p": 6,
"4k": 8
};
const configFileContents = await Bun.file(confPath).text();
await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, {
RESOLUTION: resolutionMapping[config.get('emulatorResolution')] ?? 0,
FULLSCREEN: config.get('launchInFullscreen') ? "True" : "False"
}));
const configPath = path.join(ppssppPath, 'ppsspp.ini');
const configFile = Bun.file(configPath);
const ppssppConfig = await configFile.exists() ? ini.parse(await configFile.text()) : ini.parse(await Bun.file(defaultConfigPath).text());
ppssppConfig.Graphics ??= {};
ppssppConfig.Graphics.InternalResolution = resolutionMapping[config.get('emulatorResolution')] ?? 0;
ppssppConfig.Graphics.FullScreen = config.get('launchInFullscreen');
await Bun.write(configPath, ini.stringify(ppssppConfig));
}
if (controlsPath)
if (defaultControlsPath)
{
const controlsFileContents = await Bun.file(controlsPath).text();
const controlsFileContents = await Bun.file(defaultControlsPath).text();
await Bun.write(path.join(ppssppPath, 'controls.ini'), Mustache.render(controlsFileContents, {}));
}
return { args, savesPath: path.join(config.get('downloadPath'), 'saves', this.emulator, "PSP", "SAVEDATA") };
}
return args;
return { args };
});
}
}

View file

@ -96,7 +96,6 @@ HardwareTransform = True
SoftwareSkinning = True
TextureFiltering = 1
BufferFiltering = 1
InternalResolution = {{RESOLUTION}}
AndroidHwScale = 1
HighQualityDepth = 1
FrameSkip = 0
@ -109,7 +108,6 @@ AnisotropyLevel = 4
VertexDecCache = False
TextureBackoffCache = False
TextureSecondaryCache = False
FullScreen = {{FULLSCREEN}}
FullScreenMulti = False
SmallDisplayZoomType = 2
SmallDisplayOffsetX = 0.500000

View file

@ -14,7 +14,7 @@ export default class XEMUIntegration implements PluginType
{
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
{
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] };
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen"] };
});
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) =>
@ -68,7 +68,7 @@ export default class XEMUIntegration implements PluginType
}
return args;
return { args };
});
}
}

View file

@ -0,0 +1,141 @@
import { join } from "path";
import { platform } from "os";
const SECTOR_SIZE = 0x800;
const MAGIC = "MICROSOFT*XBOX*MEDIA";
const PARTITION_OFFSETS: Record<string, number> = {
XSF: 0x0,
GDF: 0xFD90000,
XGD3: 0x2080000,
};
async function readBytes (file: ReturnType<typeof Bun.file>, offset: number, length: number): Promise<Buffer>
{
return Buffer.from(await file.slice(offset, offset + length).arrayBuffer());
}
async function parseTitleIdFromXexReader (
read: (offset: number, length: number) => Promise<Buffer>
): Promise<string>
{
// Read just the fixed header (magic + flags + offsets + header count)
const header = await read(0, 0x18);
if (header.toString("ascii", 0, 4) !== "XEX2")
{
throw new Error("Not a valid XEX2 file");
}
const headerCount = header.readUInt32BE(0x14);
const EXEC_INFO_KEY = 0x40006;
// Read the optional header table
const table = await read(0x18, headerCount * 8);
for (let i = 0; i < headerCount; i++)
{
const key = table.readUInt32BE(i * 8);
const valueOrOffset = table.readUInt32BE(i * 8 + 4);
if (key === EXEC_INFO_KEY)
{
// valueOrOffset is a file offset — read the exec info struct there
// TitleID is at +0x0C within it
const execInfo = await read(valueOrOffset, 0x18);
return execInfo.readUInt32BE(0x0C)
.toString(16).toUpperCase().padStart(8, "0");
}
}
throw new Error("Execution info header not found in XEX");
}
async function titleIdFromXexFile (xexPath: string): Promise<string>
{
const file = Bun.file(xexPath);
return parseTitleIdFromXexReader((offset, length) =>
readBytes(file, offset, length)
);
}
async function titleIdFromIso (isoPath: string): Promise<string>
{
const file = Bun.file(isoPath);
const fileSize = file.size;
for (const partitionOffset of Object.values(PARTITION_OFFSETS))
{
const vdOffset = partitionOffset + 0x20 * SECTOR_SIZE;
if (vdOffset + 28 > fileSize) continue;
const vd = await readBytes(file, vdOffset, 28);
if (vd.toString("ascii", 0, 20) !== MAGIC) continue;
const rootSector = vd.readUInt32LE(20);
const rootSize = vd.readUInt32LE(24);
const rootOffset = partitionOffset + rootSector * SECTOR_SIZE;
const dir = await readBytes(file, rootOffset, rootSize);
let pos = 0;
while (pos < dir.length)
{
if (dir[pos] === 0xFF) break;
if (pos + 14 > dir.length) break;
const nameLen = dir[pos + 13];
if (nameLen === 0 || nameLen === 0xFF) break;
if (pos + 14 + nameLen > dir.length) break;
const name = dir.toString("ascii", pos + 14, pos + 14 + nameLen);
const fileSector = dir.readUInt32LE(pos + 4);
if (name.toLowerCase() === "default.xex")
{
const xexBase = partitionOffset + fileSector * SECTOR_SIZE;
// Reader that translates relative XEX offsets to absolute ISO offsets
return parseTitleIdFromXexReader((offset, length) =>
readBytes(file, xexBase + offset, length)
);
}
const entryLen = 14 + nameLen;
pos += (entryLen + 3) & ~3;
}
}
throw new Error("Not a valid Xbox 360 ISO or default.xex not found");
}
async function titleIdFromFolder (folderPath: string): Promise<string>
{
return titleIdFromXexFile(join(folderPath, "default.xex"));
}
type XeniaRomType = "iso" | "xex" | "folder";
function detectRomType (romPath: string): XeniaRomType
{
const lower = romPath.toLowerCase();
if (lower.endsWith(".iso")) return "iso";
if (lower.endsWith(".xex")) return "xex";
return "folder"; // extracted game folder containing default.xex
}
async function getTitleId (romPath: string): Promise<string>
{
switch (detectRomType(romPath))
{
case "iso": return titleIdFromIso(romPath);
case "xex": return titleIdFromXexFile(romPath);
case "folder": return titleIdFromFolder(romPath);
}
}
export async function getXeniaSavePaths (
romPath: string,
xeniaDir: string
): Promise<string>
{
const titleId = await getTitleId(romPath);
return join(xeniaDir, titleId);
};

View file

@ -6,6 +6,7 @@ import path from "node:path";
import { ensureDir } from "fs-extra";
import toml, { TomlTable } from 'smol-toml';
import fs from 'node:fs/promises';
import { getXeniaSavePaths } from "./utils";
export default class XENIAIntegration implements PluginType
{
@ -17,7 +18,8 @@ export default class XENIAIntegration implements PluginType
await Bun.write(path.join(ctx.path, "portable.txt"), "");
}
async handleLaunch (ctx: Parameters<typeof GameflowHooks.prototype.games.emulatorLaunch.callAsync>['0'])
async handleLaunch (ctx: Parameters<typeof GameflowHooks.prototype.games.emulatorLaunch.callAsync>['0']):
ReturnType<typeof GameflowHooks.prototype.games.emulatorLaunch.promise>
{
const args: string[] = [];
@ -28,6 +30,13 @@ export default class XENIAIntegration implements PluginType
const configPath = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, `${ctx.autoValidCommand.emulator}.toml`);
args.push(`--config`, configPath);
if (config.get('launchInFullscreen'))
{
args.push(`--fullscreen`);
}
if (!ctx.dryRun)
{
await ensureDir(path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!));
@ -47,28 +56,30 @@ export default class XENIAIntegration implements PluginType
configFile.Display.fullscreen = config.get('launchInFullscreen');
configFile.GPU.draw_resolution_scale_x = resolutionMapping[config.get('emulatorResolution')] ?? 1;
configFile.GPU.draw_resolution_scale_y = resolutionMapping[config.get('emulatorResolution')] ?? 1;
await ensureDir(path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!));
const savesPath = path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!);
await ensureDir(savesPath);
configFile.Storage.content_root = path.join(config.get('downloadPath'), 'saves', ctx.autoValidCommand.emulator!);
configFile.Storage.storage_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'config');
configFile.Storage.cache_root = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, 'cache');
await Bun.write(configPath, toml.stringify(configFile));
let finalSavesPath: string | undefined = undefined;
if (ctx.autoValidCommand.metadata.romPath)
{
finalSavesPath = await getXeniaSavePaths(ctx.autoValidCommand.metadata.romPath, savesPath);
}
return { args, savesPath: finalSavesPath };
};
args.push(`--config`, configPath);
if (config.get('launchInFullscreen'))
{
args.push(`--fullscreen`);
}
return args;
return { args };
}
handleEmulatorLaunchSupport (ctx: Parameters<typeof GameflowHooks.prototype.games.emulatorLaunchSupport.callAsync>['0']):
ReturnType<typeof GameflowHooks.prototype.games.emulatorLaunchSupport.call>
{
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] };
return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves"] };
}
load (ctx: PluginContextType)
@ -78,5 +89,14 @@ export default class XENIAIntegration implements PluginType
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, this.handleLaunch);
ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulatorEdge }, this.handleLaunch);
ctx.hooks.games.postPlay.tap({ name: desc.name, before: "com.simeonradivoev.gameflow.romm" }, async ({ validChangedSaveFiles, saveFolderPath, command, gameInfo }) =>
{
if (command.emulator === this.emulator && saveFolderPath && command.metadata.romPath)
{
const files = await fs.readdir(saveFolderPath, { recursive: true });
validChangedSaveFiles.push(...files.map(f => ({ subPath: f, cwd: saveFolderPath, shared: false } satisfies SaveFileChange)));
}
});
}
}

View file

@ -2,8 +2,8 @@
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
import desc from './package.json';
import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm";
import { config } from "@/bun/api/app";
import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, getSavesSummaryApiSavesSummaryGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm";
import { config, events } from "@/bun/api/app";
import path from 'node:path';
import fs from 'node:fs/promises';
import { hashFile, isSteamDeckGameMode } from "@/bun/utils";
@ -11,6 +11,7 @@ import { CACHE_KEYS, getOrCached } from "@/bun/api/cache";
import secrets from "@/bun/api/secrets";
import { getAuthToken } from "@/clients/romm/core/auth.gen";
import { client } from "@/clients/romm/client.gen";
import { validateGameSource } from "@/bun/api/games/services/statusService";
export default class RommIntegration implements PluginType
{
@ -75,7 +76,9 @@ export default class RommIntegration implements PluginType
missing: rom.missing_from_fs,
genres: rom.metadatum.genres,
companies: rom.metadatum.companies,
release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : undefined
release_date: rom.metadatum.first_release_date ? new Date(rom.metadatum.first_release_date) : undefined,
imdb_id: rom.igdb_id ?? undefined,
ra_id: rom.ra_id ?? undefined
};
const userData = await getCurrentUserApiUsersMeGet();
@ -371,12 +374,143 @@ export default class RommIntegration implements PluginType
}
});
ctx.hooks.games.updatePlayed.tapPromise(desc.name, async ({ source, id }) =>
ctx.hooks.games.prePlay.tapPromise(desc.name, async ({ source, id, saveFolderPath, setProgress }) =>
{
if (source !== 'romm') return false;
if (source !== 'romm') return;
if (saveFolderPath)
{
setProgress(0, "saves");
const saveFiles = await getSavesSummaryApiSavesSummaryGet({ query: { rom_id: Number(id) } });
if (saveFiles.error)
{
console.error(saveFiles.error);
} else
{
for (let i = 0; i < saveFiles.data.slots.length; i++)
{
const slot = saveFiles.data.slots[i];
const savePath = path.join(saveFolderPath, slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`);
if (await fs.exists(savePath))
{
const existingSaveSync = await fs.stat(savePath);
const updatedAtTime = new Date(slot.latest.updated_at).getTime();
if (existingSaveSync.mtimeMs > updatedAtTime)
{
console.log("Newer save file", savePath, "Server:", new Date(slot.latest.updated_at), "Local:", existingSaveSync.mtime);
// Newer file
continue;
} else if (updatedAtTime === existingSaveSync.mtimeMs)
{
//TODO: do checksum comparison when that works on romm
console.log("Same save file", savePath);
continue;
}
}
const auth = await this.getAuthToken();
const headers: Record<string, string> = {};
if (auth)
headers['Authorization'] = auth;
const saveResponse = await fetch(`${config.get('rommAddress')}${slot.latest.download_path}`, { headers });
if (!saveResponse.ok)
{
console.error("Error downloading save", saveResponse.statusText);
break;
}
await Bun.write(savePath, saveResponse);
console.log("Loaded", savePath);
setProgress((i / saveFiles.data.slots.length) * 100, "saves");
}
}
setProgress(1, "saves");
await Bun.sleep(1000);
}
});
ctx.hooks.games.postPlay.tapPromise(desc.name, async ({ source, id, validChangedSaveFiles, saveFolderPath, command }) =>
{
if (source !== 'romm') return;
const sourceValidation = await validateGameSource(source, id);
if (!sourceValidation.valid)
{
console.warn("Invalid Source", sourceValidation.reason, "Skipping updates");
return;
}
const finalSavePaths = validChangedSaveFiles.filter(f => !f.shared);
const saveFiles = await getSavesSummaryApiSavesSummaryGet({ query: { rom_id: Number(id) } });
if (saveFiles.error)
{
console.error(saveFiles.error);
} else if (saveFolderPath)
{
for (let i = 0; i < saveFiles.data.slots.length; i++)
{
const slot = saveFiles.data.slots[i];
const savePath = path.join(saveFolderPath, slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`);
if (await fs.exists(savePath))
{
const stat = await fs.stat(savePath);
if (stat.mtimeMs > new Date(slot.latest.updated_at).getTime())
{
const subPath = path.join(slot.slot ?? '', `${slot.latest.file_name_no_tags}.${slot.latest.file_extension}`);
if (!finalSavePaths.some(f => f.subPath === subPath))
{
// Add newer files to the list, maybe they were changed offscreen.
finalSavePaths.push({ subPath, cwd: saveFolderPath, shared: false });
}
}
}
}
}
if (finalSavePaths.length > 0)
{
console.log("Files Changed:", finalSavePaths.map(f => f.subPath)?.join(", "));
await Promise.all(finalSavePaths.map(async f =>
{
const absolutePath = path.join(f.cwd, f.subPath);
if (!await fs.exists(absolutePath)) return;
const stat = await fs.stat(absolutePath);
if (stat.isDirectory()) return;
const data: FormData = new FormData();
data.append('saveFile', Bun.file(absolutePath), path.basename(f.subPath));
const url = new URL(`${config.get('rommAddress')}/api/saves`);
url.searchParams.set('rom_id', id);
url.searchParams.set('slot', path.dirname(f.subPath));
url.searchParams.set('autocleanup', "true");
url.searchParams.set('autocleanup_limit', "2");
if (command.emulator)
url.searchParams.set('emulator', command.emulator);
url.searchParams.set('overwrite', "true");
const auth = await this.getAuthToken();
const headers: Record<string, string> = {};
if (auth)
headers['Authorization'] = auth;
const response = await fetch(url, {
body: data,
method: "POST",
headers
});
if (!response.ok) console.error(response.statusText);
}));
events.emit('notification', { message: "Saves Uploaded", icon: 'upload', type: "success" });
}
const resp = await updateRomUserApiRomsIdPropsPut({ path: { id: Number(id) }, body: { update_last_played: true } });
if (resp.error) console.error(resp.error);
return resp.response.ok;
events.emit('notification', { message: "Updated Played", type: "success", icon: "clock" });
});
ctx.hooks.games.fetchCollections.tapPromise(desc.name, async ({ collections }) =>

View file

@ -223,14 +223,28 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
}
} catch (error)
{
if (error !== 'cancel')
try
{
console.error(error);
if (error instanceof Event)
{
if (error.target instanceof AbortSignal)
{
} else
{
console.error(error);
}
} else
{
console.error(error);
this.events.emit('error', { id: this.m_id, job: this, error });
this.error = error;
}
} finally
{
this.m_promise.resolve(undefined);
}
this.events.emit('error', { id: this.m_id, job: this, error });
this.error = error;
this.m_promise.resolve(undefined);
} finally
{
this.running = false;