From 05fafced07c853deb656d7c17d05184c42ee507c Mon Sep 17 00:00:00 2001 From: Simeon Radivoev Date: Mon, 6 Apr 2026 00:05:00 +0300 Subject: [PATCH] feat: Added more ways to detect duplicates feat: Added resolution and widescreen settings feat: Added Xenia and Xemu integration --- bun.lock | 6 ++ package.json | 2 + src/bun/api/games/games.ts | 65 +++++++++++--- src/bun/api/hooks/games.ts | 2 +- src/bun/api/jobs/install-job.ts | 16 +++- .../com.simeonradivoev.gameflow.cemu/cemu.ts | 6 +- .../dolphin.ts | 13 ++- .../PCSX2.ini | 6 +- .../pcsx2.ts | 11 ++- .../linux/ppsspp.ini | 4 +- .../ppsspp.ts | 26 +++++- .../win32/ppsspp.ini | 4 +- .../eeprom.bin | Bin 0 -> 256 bytes .../package.json | 14 +++ .../com.simeonradivoev.gameflow.xemu/xemu.ts | 76 ++++++++++++++++ .../package.json | 15 ++++ .../xenia.ts | 82 ++++++++++++++++++ .../com.simeonradivoev.gameflow.romm/romm.ts | 13 +-- src/bun/api/plugins/register-plugins.ts | 6 ++ src/bun/api/task-queue.ts | 17 ++-- .../components/options/OptionDropdown.tsx | 1 - .../components/options/SettingsDropdown.tsx | 55 ++++++++++++ src/mainview/routes/settings/emulators.tsx | 5 +- src/shared/constants.ts | 5 +- src/shared/types..d.ts | 6 ++ 25 files changed, 407 insertions(+), 49 deletions(-) create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/eeprom.bin create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu.ts create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json create mode 100644 src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts create mode 100644 src/mainview/components/options/SettingsDropdown.tsx diff --git a/bun.lock b/bun.lock index b111ebe..1514d12 100644 --- a/bun.lock +++ b/bun.lock @@ -25,6 +25,8 @@ "node-stream-zip": "^1.15.0", "open": "^11.0.0", "pathe": "^2.0.3", + "slugify": "^1.6.9", + "smol-toml": "^1.6.1", "systeminformation": "^5.31.5", "tapable": "^2.3.0", "tough-cookie": "^6.0.0", @@ -1516,6 +1518,10 @@ "simple-xml-to-json": ["simple-xml-to-json@1.2.3", "", {}, "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="], + "slugify": ["slugify@1.6.9", "", {}, "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg=="], + + "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], + "socket.io": ["socket.io@4.8.3", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A=="], "socket.io-adapter": ["socket.io-adapter@2.5.6", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.18.3" } }, "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ=="], diff --git a/package.json b/package.json index 7b6b2e6..fc73e47 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,8 @@ "node-stream-zip": "^1.15.0", "open": "^11.0.0", "pathe": "^2.0.3", + "slugify": "^1.6.9", + "smol-toml": "^1.6.1", "systeminformation": "^5.31.5", "tapable": "^2.3.0", "tough-cookie": "^6.0.0", diff --git a/src/bun/api/games/games.ts b/src/bun/api/games/games.ts index 1618c52..531dc93 100644 --- a/src/bun/api/games/games.ts +++ b/src/bun/api/games/games.ts @@ -201,31 +201,68 @@ export default new Elysia() .groupBy(schema.games.id) .where(and(...where)); - localGamesSet = new Set(localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`)); + localGamesSet = new Set( + localGames.filter(g => !!g.source_id && !!g.source).map(g => `${g.source}@${g.source_id}`) + .concat(localGames.filter(g => !!g.igdb_id).map(g => `igdb@${g.igdb_id}`)) + ); - if (!query.collection_id) + function localGameExistsPredicate (game: { id: FrontEndId, igdb_id?: number | null, ra_id?: number | null; }) + { + if (localGamesSet?.has(`${game.id.source}@${game.id.id}`)) return true; + if (game.igdb_id && localGamesSet?.has(`igdb@${game.igdb_id}`)) return true; + if (game.ra_id && localGamesSet?.has(`ra@${game.ra_id}`)) return true; + return false; + } + + if (query.collection_id) + { + // Collections are just a remote thing for now. + const remoteGames: FrontEndGameTypeWithIds[] = []; + await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); + games.push(...remoteGames.map(g => + { + if (localGameExistsPredicate(g)) + { + return convertLocalToFrontend(localGames.find(g => localGameExistsPredicate({ id: { id: g.source_id ?? '', source: g.source ?? '' }, igdb_id: g.igdb_id, ra_id: g.ra_id }))!); + } + else + { + return g; + } + })); + + } else { games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).map(g => { return convertLocalToFrontend(g); })); - const remoteGames: FrontEndGameType[] = []; + const remoteGames: FrontEndGameTypeWithIds[] = []; + const remoteGameSet = new Set(); await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); - games.push(...remoteGames.filter(g => !localGamesSet?.has(`${g.id.source}@${g.id.id}`))); - } else - { - const remoteGames: FrontEndGameType[] = []; - await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); - games.push(...remoteGames.map(g => + games.push(...remoteGames.filter(g => { - if (localGamesSet?.has(`${g.id.source}@${g.id.id}`)) + if (localGameExistsPredicate(g)) { - return convertLocalToFrontend(localGames.find(l => l.source === g.id.source && l.source_id === g.id.id)!); - } else - { - return g; + return false; } + + if (g.igdb_id) + { + const igdbId = `igdb@${g.igdb_id}`; + if (remoteGameSet.has(igdbId)) return false; + remoteGameSet.add(igdbId); + } + + if (g.ra_id) + { + const raId = `ra@${g.ra_id}`; + if (remoteGameSet.has(raId)) return false; + remoteGameSet.add(raId); + } + + return true; })); } } diff --git a/src/bun/api/hooks/games.ts b/src/bun/api/hooks/games.ts index dd893d1..d276463 100644 --- a/src/bun/api/hooks/games.ts +++ b/src/bun/api/hooks/games.ts @@ -35,7 +35,7 @@ export class GameHooks */ fetchGames = new AsyncSeriesHook<[ctx: { query: GameListFilterType; - games: FrontEndGameType[]; + games: FrontEndGameTypeWithIds[]; }]>(['ctx']); fetchGame = new AsyncSeriesBailHook<[ctx: { source: string; diff --git a/src/bun/api/jobs/install-job.ts b/src/bun/api/jobs/install-job.ts index 39c4687..18407ea 100644 --- a/src/bun/api/jobs/install-job.ts +++ b/src/bun/api/jobs/install-job.ts @@ -15,6 +15,7 @@ import z from "zod"; import { checkFiles } from "../games/services/utils"; import { ensureDir } from "fs-extra"; import { path7za } from "7zip-bin"; +import slugify from 'slugify'; interface JobConfig { @@ -70,8 +71,8 @@ export class InstallJob implements IJob name: game.title, summary: game.description, system_slug: gameId.system, - path_fs: path.join('roms', gameId.system, game.title), - extract_path: path.join('roms', gameId.system, game.title), + path_fs: path.join('roms', gameId.system, slugify(game.title)), + extract_path: '.', }; break; @@ -104,13 +105,17 @@ export class InstallJob implements IJob }); const downloadedFiles = await downloader.start(); + if (!downloadedFiles) + { + return; + } if (info.extract_path && downloadedFiles) { let progress = 0; const progressDelta = 1 / downloadedFiles.length; for (const filePath of downloadedFiles) { - const extractPath = path.join(config.get('downloadPath'), info.extract_path); + const extractPath = path.join(config.get('downloadPath'), info.path_fs ?? '', info.extract_path); await new Promise((resolve, reject) => { const seven = Seven.extractFull(filePath, extractPath, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true }); @@ -119,7 +124,10 @@ export class InstallJob implements IJob cx.setProgress(progress + p.percent * progressDelta, "extract"); }); - seven.on('error', e => reject(e)); + seven.on('error', e => + { + reject(e); + }); seven.on('end', async () => { await fs.rm(filePath); diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts index 60f8973..f42e221 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu.ts @@ -3,7 +3,7 @@ import desc from './package.json'; import path from 'node:path'; import { config } from "@/bun/api/app"; -export default class DOLPHINIntegration implements PluginType +export default class CEMUIntegration implements PluginType { emulator = 'CEMU'; @@ -11,7 +11,7 @@ export default class DOLPHINIntegration implements PluginType { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "fullscreen", "resolution", "saves", "states"] }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] }; }); ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => @@ -20,7 +20,7 @@ export default class DOLPHINIntegration implements PluginType args.push(`--fullscreen=${config.get('launchInFullscreen') ? "True" : "False"}`); - const savesPath = path.join(config.get('downloadPath'), "saves", 'DOLPHIN'); + const savesPath = path.join(config.get('downloadPath'), "saves", this.emulator); args.push(`--mlc=${savesPath}`); diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts index d4ec3ca..05fde39 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin.ts @@ -13,7 +13,7 @@ export default class DOLPHINIntegration implements PluginType { ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { - return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "fullscreen", "resolution", "saves", "states"] }; + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "config", "resolution", "fullscreen", "states"] }; }); ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => @@ -35,6 +35,17 @@ export default class DOLPHINIntegration implements PluginType args.push(`--config=Dolphin.Interface.SkipNKitWarning=True`); args.push(`--config=Dolphin.Analytics.PermissionAsked=True`); + const resolution = config.get('emulatorResolution'); + const resolutionMapping = { + "720p": 2, + "1080p": 3, + "1440p": 4, + "4k": 6 + }; + args.push(`--config=GFX.Settings.InternalResolution=${resolutionMapping[resolution] ?? 1}`); + args.push(`--config=GFX.Settings.wideScreenHack=${config.get('emulatorWidescreen') ? "True" : "False"}`); + args.push(`--config=GFX.Settings.AspectRatio=${config.get('emulatorWidescreen') ? "1" : "0"}`); + const savesPath = path.join(config.get('downloadPath'), "saves", this.emulator); args.push(`--config=Dolphin.General.WiiSDCardPath=${path.join(savesPath, 'WiiSD.raw')}`); diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini index cbafaf8..72985fb 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/PCSX2.ini @@ -21,7 +21,7 @@ CdvdShareWrite = false EnablePatches = true EnableCheats = false EnablePINE = false -EnableWideScreenPatches = false +EnableWideScreenPatches = {{ENABLE_WIDESCREEN}} EnableNoInterlacingPatches = false EnableRecordingTools = true EnableGameFixes = true @@ -92,7 +92,7 @@ VsyncEnable = 0 FramerateNTSC = 59.94 FrameratePAL = 50 SyncToHostRefreshRate = false -AspectRatio = Auto 4:3/3:2 +AspectRatio = {{ASPECT_RATIO}} FMVAspectRatioSwitch = Off ScreenshotSize = 0 ScreenshotFormat = 0 @@ -168,7 +168,7 @@ linear_present_mode = 1 deinterlace_mode = 0 OsdScale = 100 Renderer = 14 -upscale_multiplier = 1 +upscale_multiplier = {{UPSCALE_MULTIPLIER}} mipmap_hw = -1 accurate_blending_unit = 1 crc_hack_level = -1 diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts index bd3b78f..a5fa18f 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2.ts @@ -22,7 +22,7 @@ export default class PCSX2Integration implements PluginType return { id: desc.name, supportLevel: "full", - capabilities: [...baseCapabilities, "resolution", "config"] + capabilities: [...baseCapabilities, "config", "resolution"] }; } else @@ -52,6 +52,12 @@ export default class PCSX2Integration implements PluginType const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator); const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator); const savesFolder = path.join(config.get('downloadPath'), "saves", this.emulator); + const resolutionMapping = { + "720p": 2, + "1080p": 3, + "1440p": 4, + "4k": 6, + }; const view = { BIOS_PATH: biosFolder, @@ -62,6 +68,9 @@ export default class PCSX2Integration implements PluginType COVERS_PATH: path.join(storageFolder, 'covers'), TEXTURES_PATH: path.join(storageFolder, 'textures'), RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), + ENABLE_WIDESCREEN: config.get('emulatorWidescreen'), + ASPECT_RATIO: config.get('emulatorWidescreen') ? "16:9" : "Auto 4:3/3:2", + UPSCALE_MULTIPLIER: resolutionMapping[config.get('emulatorResolution')] ?? 1 }; await Promise.all(Object.values(view).map(p => ensureDir(p))); diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini index edd196b..afc914c 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/linux/ppsspp.ini @@ -96,7 +96,7 @@ HardwareTransform = True SoftwareSkinning = True TextureFiltering = 1 BufferFiltering = 1 -InternalResolution = 3 +InternalResolution = {{RESOLUTION}} AndroidHwScale = 1 HighQualityDepth = 1 FrameSkip = 0 @@ -109,7 +109,7 @@ AnisotropyLevel = 4 VertexDecCache = False TextureBackoffCache = False TextureSecondaryCache = False -FullScreen = True +FullScreen = {{FULLSCREEN}} FullScreenMulti = False SmallDisplayZoomType = 2 SmallDisplayOffsetX = 0.500000 diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts index b0d0a44..1f6572f 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp.ts @@ -10,12 +10,21 @@ import Mustache from "mustache"; import { ensureDir } from "fs-extra"; import { homedir } from "node:os"; -export default class PCSX2Integration implements PluginType +export default class PPSSPPIntegration implements PluginType { emulator = "PPSSPP"; load (ctx: PluginContextType) { + ctx.hooks.emulators.emulatorPostInstall.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => + { + await Bun.write(path.join(ctx.path, "portable.txt"), ""); + if (process.platform === 'win32') + { + await Bun.write(path.join(ctx.path, "installed.txt"), path.join(config.get('downloadPath'), 'saves', this.emulator)); + } + }); + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => { const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; @@ -25,7 +34,7 @@ export default class PCSX2Integration implements PluginType return { id: desc.name, supportLevel: "full", - capabilities: [...baseCapabilities, "resolution", "config"] + capabilities: [...baseCapabilities, "config", "resolution"] }; } else @@ -68,7 +77,7 @@ export default class PCSX2Integration implements PluginType let ppssppPath = ''; if (process.platform === 'win32') { - ppssppPath = path.join(ctx.autoValidCommand.metadata.emulatorDir, 'memstick', 'PSP', 'SYSTEM'); + ppssppPath = path.join(config.get('downloadPath'), 'saves', this.emulator, 'PSP', 'SYSTEM'); } else { //TODO: Use way to set custom memstick path when they support it @@ -80,8 +89,17 @@ export default class PCSX2Integration implements PluginType if (confPath) { + const resolutionMapping = { + "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, {})); + await Bun.write(path.join(ppssppPath, 'ppsspp.ini'), Mustache.render(configFileContents, { + RESOLUTION: resolutionMapping[config.get('emulatorResolution')] ?? 0, + FULLSCREEN: config.get('launchInFullscreen') ? "True" : "False" + })); } if (controlsPath) diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini index f24ea4b..21a71c3 100644 --- a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.ppsspp/win32/ppsspp.ini @@ -96,7 +96,7 @@ HardwareTransform = True SoftwareSkinning = True TextureFiltering = 1 BufferFiltering = 1 -InternalResolution = 3 +InternalResolution = {{RESOLUTION}} AndroidHwScale = 1 HighQualityDepth = 1 FrameSkip = 0 @@ -109,7 +109,7 @@ AnisotropyLevel = 4 VertexDecCache = False TextureBackoffCache = False TextureSecondaryCache = False -FullScreen = True +FullScreen = {{FULLSCREEN}} FullScreenMulti = False SmallDisplayZoomType = 2 SmallDisplayOffsetX = 0.500000 diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/eeprom.bin b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xemu/eeprom.bin new file mode 100644 index 0000000000000000000000000000000000000000..55874b0f314b7e6741c4d0b632dbc235c14dbc26 GIT binary patch literal 256 zcmXR{kM}L+Eiz$Z<=*&3$H2+>`~OMi!t#r+CwM z?q$bGQDzpVh9;&KM&<^lMhpR;Qol+wFidRsa!8r>@pnM4h$f$I5hkZ mh<5i4VQ>l#0WraVi + { + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] }; + }); + + ctx.hooks.games.emulatorLaunch.tapPromise({ name: desc.name, emulator: this.emulator }, async (ctx) => + { + const args: string[] = []; + + if (config.get('launchInFullscreen')) + { + args.push("-full-screen"); + } + + if (ctx.autoValidCommand.metadata.romPath) + { + args.push("-dvd_path"); + args.push(ctx.autoValidCommand.metadata.romPath); + } + + const configPath = path.join(config.get('downloadPath'), 'storage', this.emulator, 'xemu.toml'); + let configFile: { general: TomlTable & { misc: TomlTable; }, sys: TomlTable & { files: TomlTable; }; } = { general: { misc: {} }, sys: { files: {} } }; + if (await Bun.file(configPath).exists()) + { + configFile = toml.parse(await Bun.file(configPath).text()) as any; + } + + configFile.general.misc ??= {}; + configFile.general.misc.skip_boot_anim = true; + configFile.general.show_welcome = false; + configFile.general.games_dir = path.join(config.get('downloadPath'), 'roms', 'xbox'); + configFile.sys.mem_limit = '128'; + const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator); + if (await fs.exists(biosFolder)) + { + const biosPaths = (await fs.readdir(biosFolder)); + const flash = biosPaths.find(f => f.endsWith('.bin') && !f.includes('mcpx')); + const bootrom = biosPaths.find(f => f.endsWith('.bin') && f.includes('mcpx')); + const hardDrive = biosPaths.find(f => f.endsWith('qcow2')); + if (flash) configFile.sys.files.flashrom_path = path.join(biosFolder, flash); + if (bootrom) configFile.sys.files.bootrom_path = path.join(biosFolder, bootrom); + if (hardDrive) configFile.sys.files.hdd_path = path.join(biosFolder, hardDrive); + } + + if (!ctx.dryRun) + { + const eepromPath = path.join(config.get('downloadPath'), "storage", this.emulator, 'eeprom.bin'); + await Bun.write(eepromPath, await Bun.file(bin).arrayBuffer()); + configFile.sys.files.eeprom_path = eepromPath; + + await Bun.write(configPath, toml.stringify(configFile)); + args.push("-config_path"); + args.push(configPath); + } + + + return args; + }); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json new file mode 100644 index 0000000..a6b3d25 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json @@ -0,0 +1,15 @@ +{ + "name": "com.simeonradivoev.gameflow.xenia", + "displayName": "XENIA Integration", + "version": "0.0.1", + "description": "XENIA Emulator Integration", + "main": "./xenia.ts", + "icon": "https://xenia.jp/images/logo-256x256.png", + "keywords": [ + "integration", + "emulator", + "xbox360", + "xenia", + "xenia-edge" + ] +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts new file mode 100644 index 0000000..7257559 --- /dev/null +++ b/src/bun/api/plugins/builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia.ts @@ -0,0 +1,82 @@ +import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; +import desc from './package.json'; +import { GameflowHooks } from "@/bun/api/hooks/app"; +import { config } from "@/bun/api/app"; +import path from "node:path"; +import { ensureDir } from "fs-extra"; +import toml, { TomlTable } from 'smol-toml'; +import fs from 'node:fs/promises'; + +export default class XENIAIntegration implements PluginType +{ + emulator = 'XENIA'; + emulatorEdge = 'XENIA-EDGE'; + + async handlePostInstall (ctx: Parameters['0']) + { + await Bun.write(path.join(ctx.path, "portable.txt"), ""); + } + + async handleLaunch (ctx: Parameters['0']) + { + const args: string[] = []; + + if (ctx.autoValidCommand.metadata.romPath) + { + args.push(ctx.autoValidCommand.metadata.romPath); + } + + const configPath = path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!, `${ctx.autoValidCommand.emulator}.toml`); + + if (!ctx.dryRun) + { + await ensureDir(path.join(config.get('downloadPath'), 'storage', ctx.autoValidCommand.emulator!)); + let configFile: TomlTable & { Storage: TomlTable, GPU: TomlTable, Display: TomlTable; } = { Storage: {}, GPU: {}, Display: {} }; + if (await fs.exists(configPath)) + { + configFile = toml.parse(await Bun.file(configPath).text()) as any; + } + + const resolutionMapping = { + "720p": 1, + "1080p": 2, + "1440p": 3, + "4k": 3 + }; + + 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!)); + 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)); + }; + + args.push(`--config`, configPath); + + if (config.get('launchInFullscreen')) + { + args.push(`--fullscreen`); + } + + return args; + } + + handleEmulatorLaunchSupport (ctx: Parameters['0']): + ReturnType + { + return { id: desc.name, supportLevel: "full", capabilities: ["batch", "fullscreen", "saves", "states"] }; + } + + load (ctx: PluginContextType) + { + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, this.handleEmulatorLaunchSupport); + ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulatorEdge }, this.handleEmulatorLaunchSupport); + + 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); + } +} \ No newline at end of file diff --git a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts index 644a505..ac5439b 100644 --- a/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts +++ b/src/bun/api/plugins/builtin/sources/com.simeonradivoev.gameflow.romm/romm.ts @@ -2,7 +2,7 @@ import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import desc from './package.json'; -import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomsApiRomsGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; +import { DetailedRomSchema, getCollectionApiCollectionsIdGet, getCollectionsApiCollectionsGet, getCurrentUserApiUsersMeGet, getPlatformApiPlatformsIdGet, getPlatformFirmwareApiFirmwareGet, getPlatformsApiPlatformsGet, getRomApiRomsIdGet, getRomContentApiRomsIdContentFileNameGet, getRomsApiRomsGet, SimpleRomSchema, updateRomUserApiRomsIdPropsPut } from "@/clients/romm"; import { config } from "@/bun/api/app"; import path from 'node:path'; import fs from 'node:fs/promises'; @@ -138,7 +138,9 @@ export default class RommIntegration implements PluginType }); games.push(...rommGames.data.items.map(g => { - return this.convertRomToFrontend(g); + const game: FrontEndGameType & { igdb_id?: number; } = this.convertRomToFrontend(g); + game.igdb_id = g.igdb_id ?? undefined; + return game; })); } }); @@ -181,8 +183,9 @@ export default class RommIntegration implements PluginType const files = await Promise.all(rom.files.map(async f => { + getRomContentApiRomsIdContentFileNameGet; const file: DownloadFileEntry = { - url: new URL(`${config.get('rommAddress')}/api/romsfiles/${f.id}/content/${f.file_name}`), + url: new URL(`${config.get('rommAddress')}/api/roms/${f.id}/files/content/${f.file_name}`), file_name: f.file_name, file_path: f.file_path, size: f.file_size_bytes, @@ -198,8 +201,8 @@ export default class RommIntegration implements PluginType const name = files[0].file_name.toLocaleLowerCase(); if (name.endsWith('.zip') || name.endsWith('.7z') || name.endsWith('.rar')) { - extract_path = rom.name ?? path.parse(name).name; - path_fs = path.join(rom.fs_path, extract_path); + extract_path = '.'; + path_fs = path.join(rom.fs_path, rom.slug ?? rom.fs_name_no_ext); } } diff --git a/src/bun/api/plugins/register-plugins.ts b/src/bun/api/plugins/register-plugins.ts index 99b9d17..3c49311 100644 --- a/src/bun/api/plugins/register-plugins.ts +++ b/src/bun/api/plugins/register-plugins.ts @@ -3,6 +3,9 @@ import { PluginManager } from "./plugin-manager"; import pcsx2 from './builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json'; import ppsspp from './builtin/emulators/com.simeonradivoev.gameflow.ppsspp/package.json'; import dolphin from './builtin/emulators/com.simeonradivoev.gameflow.dolphin/package.json'; +import cemu from './builtin/emulators/com.simeonradivoev.gameflow.cemu/package.json'; +import xenia from './builtin/emulators/com.simeonradivoev.gameflow.xenia/package.json'; +import xemu from './builtin/emulators/com.simeonradivoev.gameflow.xemu/package.json'; import romm from './builtin/sources/com.simeonradivoev.gameflow.romm/package.json'; import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/typesc.schema"; @@ -13,6 +16,9 @@ export default async function register (pluginManager: PluginManager) { ...pcsx2, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2') }, { ...ppsspp, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp') }, { ...dolphin, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin') }, + { ...cemu, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.cemu/cemu') }, + { ...xenia, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.xenia/xenia') }, + { ...xemu, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.xemu/xemu') }, { ...romm, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.romm/romm') }, ]; diff --git a/src/bun/api/task-queue.ts b/src/bun/api/task-queue.ts index 2ef241c..f331bb6 100644 --- a/src/bun/api/task-queue.ts +++ b/src/bun/api/task-queue.ts @@ -105,7 +105,7 @@ export class TaskQueue { this.queue = []; this.activeQueue.forEach(c => c.abort()); - return Promise.all(this.activeQueue.map(c => c.promise.promise)); + return Promise.all(this.activeQueue.map(c => c.promise.promise.catch(e => console.error("Error During Task Queue Closing")))); } } @@ -212,10 +212,15 @@ export class JobContext, TData, TState extends str { this.events.emit('started', { id: this.m_id, job: this }); await this.m_job.start(this); - this.completed = true; - this.events.emit('completed', { id: this.m_id, job: this }); - this.m_promise.resolve(this.m_job.exposeData?.()); - + if (!this.abortSignal.aborted) + { + this.completed = true; + this.events.emit('completed', { id: this.m_id, job: this }); + this.m_promise.resolve(this.m_job.exposeData?.()); + } else + { + this.m_promise.resolve(undefined); + } } catch (error) { if (error !== 'cancel') @@ -225,7 +230,7 @@ export class JobContext, TData, TState extends str this.events.emit('error', { id: this.m_id, job: this, error }); this.error = error; - this.m_promise.reject(error); + this.m_promise.resolve(undefined); } finally { this.running = false; diff --git a/src/mainview/components/options/OptionDropdown.tsx b/src/mainview/components/options/OptionDropdown.tsx index a661739..239f70c 100644 --- a/src/mainview/components/options/OptionDropdown.tsx +++ b/src/mainview/components/options/OptionDropdown.tsx @@ -8,7 +8,6 @@ import { oneShot } from "@/mainview/scripts/audio/audio"; export function OptionDropdown (data: { name: string; - type: HTMLInputTypeAttribute; className?: string; placeholder?: string; icon?: JSX.Element; diff --git a/src/mainview/components/options/SettingsDropdown.tsx b/src/mainview/components/options/SettingsDropdown.tsx new file mode 100644 index 0000000..91f8451 --- /dev/null +++ b/src/mainview/components/options/SettingsDropdown.tsx @@ -0,0 +1,55 @@ +import { HTMLInputTypeAttribute, JSX, useCallback, useEffect, useState } from "react"; +import { SettingsType } from "../../../shared/constants"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { OptionSpace } from "./OptionSpace"; +import { OptionInput } from "./OptionInput"; +import { getSettingQuery, setSettingMutation } from "@queries/settings"; +import { OptionDropdown } from "./OptionDropdown"; + +export function SettingsDropdown (data: { + label: string; + id: KeysWithValueAssignableTo; + values: string[]; + placeholder?: string; + icon?: JSX.Element; + children?: any; +}) +{ + const [dirty, setDirty] = useState(false); + const [localValue, setLocalValue] = useState(); + const { data: serverValue } = useQuery(getSettingQuery(data.id)); + const setMutation = useMutation(setSettingMutation(data.id)); + + useEffect(() => + { + setLocalValue(serverValue as any); + setDirty(false); + }, [serverValue]); + + const handleSave = useCallback(() => + { + if (dirty) + { + setDirty(false); + setMutation.mutate(localValue); + } + }, [dirty, setDirty, localValue]); + + return ( + + + { + setLocalValue(v); + setMutation.mutate(v); + }} + value={localValue} values={data.values} + /> + {data.children} + + ); +} \ No newline at end of file diff --git a/src/mainview/routes/settings/emulators.tsx b/src/mainview/routes/settings/emulators.tsx index b5e25c8..6a09f01 100644 --- a/src/mainview/routes/settings/emulators.tsx +++ b/src/mainview/routes/settings/emulators.tsx @@ -8,7 +8,7 @@ import { Check, ChevronDown, FileQuestion, FolderSearch, Plug, SearchAlert, Stor import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog'; import classNames from 'classnames'; import { twMerge } from 'tailwind-merge'; -import { RPC_URL } from '../../../shared/constants'; +import { RPC_URL, SettingsSchema } from '../../../shared/constants'; import emulators from '@emulators'; import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts'; @@ -19,6 +19,7 @@ import Carousel from '@/mainview/components/Carousel'; import { FOCUS_KEYS } from '@/mainview/scripts/types'; import { scrollIntoNearestParent, scrollIntoViewHandler, useDragScroll } from '@/mainview/scripts/utils'; import { SettingsOption } from '@/mainview/components/options/SettingsOption'; +import { SettingsDropdown } from '@/mainview/components/options/SettingsDropdown'; export const Route = createFileRoute('/settings/emulators')({ component: RouteComponent, @@ -328,6 +329,8 @@ function RouteComponent ()
Preferences
+ +
Overrides
{!!customEmulators && customEmulators.map((key) => )} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 3eff7d5..dfcd52a 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,5 +1,6 @@ +import { emulators } from '@/bun/api/schema/emulators'; import { FocusDetails } from '@noriginmedia/norigin-spatial-navigation'; import { JSX } from 'react'; import * as z from 'zod'; @@ -35,7 +36,9 @@ export const SettingsSchema = z.object({ windowPosition: z.object({ x: z.number(), y: z.number() }).optional(), downloadPath: z.string(), launchInFullscreen: z.boolean().default(true), - disabledPlugins: z.array(z.string()).default([]) + disabledPlugins: z.array(z.string()).default([]), + emulatorResolution: z.enum(['720p', '1080p', '1440p', '4k']).default('720p'), + emulatorWidescreen: z.boolean().default(true) }); export const LocalSettingsSchema = z.object({ diff --git a/src/shared/types..d.ts b/src/shared/types..d.ts index e41afc6..a417dce 100644 --- a/src/shared/types..d.ts +++ b/src/shared/types..d.ts @@ -153,6 +153,12 @@ declare interface FrontEndPlatformType paths_screenshots: string[]; } +declare interface FrontEndGameTypeWithIds extends FrontEndGameType +{ + igdb_id: number | null; + ra_id: number | null; +} + declare interface FrontEndGameType { platform_display_name: string | null,