feat: Added more ways to detect duplicates

feat: Added resolution and widescreen settings
feat: Added Xenia and Xemu integration
This commit is contained in:
Simeon Radivoev 2026-04-06 00:05:00 +03:00
parent 764691fc86
commit 05fafced07
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
25 changed files with 407 additions and 49 deletions

View file

@ -25,6 +25,8 @@
"node-stream-zip": "^1.15.0", "node-stream-zip": "^1.15.0",
"open": "^11.0.0", "open": "^11.0.0",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"slugify": "^1.6.9",
"smol-toml": "^1.6.1",
"systeminformation": "^5.31.5", "systeminformation": "^5.31.5",
"tapable": "^2.3.0", "tapable": "^2.3.0",
"tough-cookie": "^6.0.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=="], "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": ["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=="], "socket.io-adapter": ["socket.io-adapter@2.5.6", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.18.3" } }, "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ=="],

View file

@ -64,6 +64,8 @@
"node-stream-zip": "^1.15.0", "node-stream-zip": "^1.15.0",
"open": "^11.0.0", "open": "^11.0.0",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"slugify": "^1.6.9",
"smol-toml": "^1.6.1",
"systeminformation": "^5.31.5", "systeminformation": "^5.31.5",
"tapable": "^2.3.0", "tapable": "^2.3.0",
"tough-cookie": "^6.0.0", "tough-cookie": "^6.0.0",

View file

@ -201,31 +201,68 @@ export default new Elysia()
.groupBy(schema.games.id) .groupBy(schema.games.id)
.where(and(...where)); .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 => games.push(...localGames.slice(query.offset, query.limit ? query.offset ?? 0 + query.limit : undefined).map(g =>
{ {
return convertLocalToFrontend(g); return convertLocalToFrontend(g);
})); }));
const remoteGames: FrontEndGameType[] = []; const remoteGames: FrontEndGameTypeWithIds[] = [];
const remoteGameSet = new Set<string>();
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e)); 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}`))); games.push(...remoteGames.filter(g =>
} else
{ {
const remoteGames: FrontEndGameType[] = []; if (localGameExistsPredicate(g))
await plugins.hooks.games.fetchGames.promise({ query, games: remoteGames }).catch(e => console.error(e));
games.push(...remoteGames.map(g =>
{ {
if (localGamesSet?.has(`${g.id.source}@${g.id.id}`)) return false;
{
return convertLocalToFrontend(localGames.find(l => l.source === g.id.source && l.source_id === g.id.id)!);
} else
{
return g;
} }
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;
})); }));
} }
} }

View file

@ -35,7 +35,7 @@ export class GameHooks
*/ */
fetchGames = new AsyncSeriesHook<[ctx: { fetchGames = new AsyncSeriesHook<[ctx: {
query: GameListFilterType; query: GameListFilterType;
games: FrontEndGameType[]; games: FrontEndGameTypeWithIds[];
}]>(['ctx']); }]>(['ctx']);
fetchGame = new AsyncSeriesBailHook<[ctx: { fetchGame = new AsyncSeriesBailHook<[ctx: {
source: string; source: string;

View file

@ -15,6 +15,7 @@ import z from "zod";
import { checkFiles } from "../games/services/utils"; import { checkFiles } from "../games/services/utils";
import { ensureDir } from "fs-extra"; import { ensureDir } from "fs-extra";
import { path7za } from "7zip-bin"; import { path7za } from "7zip-bin";
import slugify from 'slugify';
interface JobConfig interface JobConfig
{ {
@ -70,8 +71,8 @@ export class InstallJob implements IJob<never, InstallJobStates>
name: game.title, name: game.title,
summary: game.description, summary: game.description,
system_slug: gameId.system, system_slug: gameId.system,
path_fs: path.join('roms', gameId.system, game.title), path_fs: path.join('roms', gameId.system, slugify(game.title)),
extract_path: path.join('roms', gameId.system, game.title), extract_path: '.',
}; };
break; break;
@ -104,13 +105,17 @@ export class InstallJob implements IJob<never, InstallJobStates>
}); });
const downloadedFiles = await downloader.start(); const downloadedFiles = await downloader.start();
if (!downloadedFiles)
{
return;
}
if (info.extract_path && downloadedFiles) if (info.extract_path && downloadedFiles)
{ {
let progress = 0; let progress = 0;
const progressDelta = 1 / downloadedFiles.length; const progressDelta = 1 / downloadedFiles.length;
for (const filePath of downloadedFiles) 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) => await new Promise((resolve, reject) =>
{ {
const seven = Seven.extractFull(filePath, extractPath, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true }); const seven = Seven.extractFull(filePath, extractPath, { $bin: process.env.ZIP7_PATH ?? path7za, $progress: true });
@ -119,7 +124,10 @@ export class InstallJob implements IJob<never, InstallJobStates>
cx.setProgress(progress + p.percent * progressDelta, "extract"); cx.setProgress(progress + p.percent * progressDelta, "extract");
}); });
seven.on('error', e => reject(e)); seven.on('error', e =>
{
reject(e);
});
seven.on('end', async () => seven.on('end', async () =>
{ {
await fs.rm(filePath); await fs.rm(filePath);

View file

@ -3,7 +3,7 @@ import desc from './package.json';
import path from 'node:path'; import path from 'node:path';
import { config } from "@/bun/api/app"; import { config } from "@/bun/api/app";
export default class DOLPHINIntegration implements PluginType export default class CEMUIntegration implements PluginType
{ {
emulator = 'CEMU'; emulator = 'CEMU';
@ -11,7 +11,7 @@ export default class DOLPHINIntegration implements PluginType
{ {
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => 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) => 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"}`); 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}`); args.push(`--mlc=${savesPath}`);

View file

@ -13,7 +13,7 @@ export default class DOLPHINIntegration implements PluginType
{ {
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) => 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) => 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.Interface.SkipNKitWarning=True`);
args.push(`--config=Dolphin.Analytics.PermissionAsked=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); const savesPath = path.join(config.get('downloadPath'), "saves", this.emulator);
args.push(`--config=Dolphin.General.WiiSDCardPath=${path.join(savesPath, 'WiiSD.raw')}`); args.push(`--config=Dolphin.General.WiiSDCardPath=${path.join(savesPath, 'WiiSD.raw')}`);

View file

@ -21,7 +21,7 @@ CdvdShareWrite = false
EnablePatches = true EnablePatches = true
EnableCheats = false EnableCheats = false
EnablePINE = false EnablePINE = false
EnableWideScreenPatches = false EnableWideScreenPatches = {{ENABLE_WIDESCREEN}}
EnableNoInterlacingPatches = false EnableNoInterlacingPatches = false
EnableRecordingTools = true EnableRecordingTools = true
EnableGameFixes = true EnableGameFixes = true
@ -92,7 +92,7 @@ VsyncEnable = 0
FramerateNTSC = 59.94 FramerateNTSC = 59.94
FrameratePAL = 50 FrameratePAL = 50
SyncToHostRefreshRate = false SyncToHostRefreshRate = false
AspectRatio = Auto 4:3/3:2 AspectRatio = {{ASPECT_RATIO}}
FMVAspectRatioSwitch = Off FMVAspectRatioSwitch = Off
ScreenshotSize = 0 ScreenshotSize = 0
ScreenshotFormat = 0 ScreenshotFormat = 0
@ -168,7 +168,7 @@ linear_present_mode = 1
deinterlace_mode = 0 deinterlace_mode = 0
OsdScale = 100 OsdScale = 100
Renderer = 14 Renderer = 14
upscale_multiplier = 1 upscale_multiplier = {{UPSCALE_MULTIPLIER}}
mipmap_hw = -1 mipmap_hw = -1
accurate_blending_unit = 1 accurate_blending_unit = 1
crc_hack_level = -1 crc_hack_level = -1

View file

@ -22,7 +22,7 @@ export default class PCSX2Integration implements PluginType
return { return {
id: desc.name, id: desc.name,
supportLevel: "full", supportLevel: "full",
capabilities: [...baseCapabilities, "resolution", "config"] capabilities: [...baseCapabilities, "config", "resolution"]
}; };
} }
else else
@ -52,6 +52,12 @@ export default class PCSX2Integration implements PluginType
const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator); const biosFolder = path.join(config.get('downloadPath'), "bios", this.emulator);
const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator); const storageFolder = path.join(config.get('downloadPath'), "storage", this.emulator);
const savesFolder = path.join(config.get('downloadPath'), "saves", this.emulator); const savesFolder = path.join(config.get('downloadPath'), "saves", this.emulator);
const resolutionMapping = {
"720p": 2,
"1080p": 3,
"1440p": 4,
"4k": 6,
};
const view = { const view = {
BIOS_PATH: biosFolder, BIOS_PATH: biosFolder,
@ -62,6 +68,9 @@ export default class PCSX2Integration implements PluginType
COVERS_PATH: path.join(storageFolder, 'covers'), COVERS_PATH: path.join(storageFolder, 'covers'),
TEXTURES_PATH: path.join(storageFolder, 'textures'), TEXTURES_PATH: path.join(storageFolder, 'textures'),
RECURSIVE_PATHS: path.join(config.get('downloadPath'), 'roms', 'PS2'), 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))); await Promise.all(Object.values(view).map(p => ensureDir(p)));

View file

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

View file

@ -10,12 +10,21 @@ import Mustache from "mustache";
import { ensureDir } from "fs-extra"; import { ensureDir } from "fs-extra";
import { homedir } from "node:os"; import { homedir } from "node:os";
export default class PCSX2Integration implements PluginType export default class PPSSPPIntegration implements PluginType
{ {
emulator = "PPSSPP"; emulator = "PPSSPP";
load (ctx: PluginContextType) 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) => ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
{ {
const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"]; const baseCapabilities: EmulatorCapabilities[] = ["batch", "fullscreen", "saves", "states"];
@ -25,7 +34,7 @@ export default class PCSX2Integration implements PluginType
return { return {
id: desc.name, id: desc.name,
supportLevel: "full", supportLevel: "full",
capabilities: [...baseCapabilities, "resolution", "config"] capabilities: [...baseCapabilities, "config", "resolution"]
}; };
} }
else else
@ -68,7 +77,7 @@ export default class PCSX2Integration implements PluginType
let ppssppPath = ''; let ppssppPath = '';
if (process.platform === 'win32') 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 } else
{ {
//TODO: Use way to set custom memstick path when they support it //TODO: Use way to set custom memstick path when they support it
@ -80,8 +89,17 @@ export default class PCSX2Integration implements PluginType
if (confPath) if (confPath)
{ {
const resolutionMapping = {
"720p": "2",
"1080p": "4",
"1440p": "6",
"4k": "8"
};
const configFileContents = await Bun.file(confPath).text(); 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) if (controlsPath)

View file

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

View file

@ -0,0 +1,14 @@
{
"name": "com.simeonradivoev.gameflow.xemu",
"displayName": "XEMU Integration",
"version": "0.0.1",
"description": "XEMU Emulator Integration",
"main": "./xemu.ts",
"icon": "https://upload.wikimedia.org/wikipedia/commons/8/8e/Xemu_logo_green.svg",
"keywords": [
"integration",
"emulator",
"xbox",
"xemu"
]
}

View file

@ -0,0 +1,76 @@
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';
import bin from './eeprom.bin' with { type: 'file' };
export default class XEMUIntegration implements PluginType
{
emulator = 'XEMU';
load (ctx: PluginContextType)
{
ctx.hooks.games.emulatorLaunchSupport.tap({ name: desc.name, emulator: this.emulator }, (ctx) =>
{
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;
});
}
}

View file

@ -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"
]
}

View file

@ -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<typeof GameflowHooks.prototype.emulators.emulatorPostInstall.callAsync>['0'])
{
await Bun.write(path.join(ctx.path, "portable.txt"), "");
}
async handleLaunch (ctx: Parameters<typeof GameflowHooks.prototype.games.emulatorLaunch.callAsync>['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<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"] };
}
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);
}
}

View file

@ -2,7 +2,7 @@
import { PluginContextType, PluginType } from "@/bun/types/typesc.schema"; import { PluginContextType, PluginType } from "@/bun/types/typesc.schema";
import desc from './package.json'; 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 { config } from "@/bun/api/app";
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
@ -138,7 +138,9 @@ export default class RommIntegration implements PluginType
}); });
games.push(...rommGames.data.items.map(g => 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 => const files = await Promise.all(rom.files.map(async f =>
{ {
getRomContentApiRomsIdContentFileNameGet;
const file: DownloadFileEntry = { 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_name: f.file_name,
file_path: f.file_path, file_path: f.file_path,
size: f.file_size_bytes, size: f.file_size_bytes,
@ -198,8 +201,8 @@ export default class RommIntegration implements PluginType
const name = files[0].file_name.toLocaleLowerCase(); const name = files[0].file_name.toLocaleLowerCase();
if (name.endsWith('.zip') || name.endsWith('.7z') || name.endsWith('.rar')) if (name.endsWith('.zip') || name.endsWith('.7z') || name.endsWith('.rar'))
{ {
extract_path = rom.name ?? path.parse(name).name; extract_path = '.';
path_fs = path.join(rom.fs_path, extract_path); path_fs = path.join(rom.fs_path, rom.slug ?? rom.fs_name_no_ext);
} }
} }

View file

@ -3,6 +3,9 @@ import { PluginManager } from "./plugin-manager";
import pcsx2 from './builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json'; import pcsx2 from './builtin/emulators/com.simeonradivoev.gameflow.pcsx2/package.json';
import ppsspp from './builtin/emulators/com.simeonradivoev.gameflow.ppsspp/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 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 romm from './builtin/sources/com.simeonradivoev.gameflow.romm/package.json';
import { PluginDescriptionSchema, PluginDescriptionType, PluginSchema } from "@/bun/types/typesc.schema"; 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') }, { ...pcsx2, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.pcsx2/pcsx2') },
{ ...ppsspp, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp') }, { ...ppsspp, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.ppsspp/ppsspp') },
{ ...dolphin, load: () => import('./builtin/emulators/com.simeonradivoev.gameflow.dolphin/dolphin') }, { ...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') }, { ...romm, load: () => import('./builtin/sources/com.simeonradivoev.gameflow.romm/romm') },
]; ];

View file

@ -105,7 +105,7 @@ export class TaskQueue
{ {
this.queue = []; this.queue = [];
this.activeQueue.forEach(c => c.abort()); 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<T extends IJob<TData, TState>, TData, TState extends str
{ {
this.events.emit('started', { id: this.m_id, job: this }); this.events.emit('started', { id: this.m_id, job: this });
await this.m_job.start(this); await this.m_job.start(this);
if (!this.abortSignal.aborted)
{
this.completed = true; this.completed = true;
this.events.emit('completed', { id: this.m_id, job: this }); this.events.emit('completed', { id: this.m_id, job: this });
this.m_promise.resolve(this.m_job.exposeData?.()); this.m_promise.resolve(this.m_job.exposeData?.());
} else
{
this.m_promise.resolve(undefined);
}
} catch (error) } catch (error)
{ {
if (error !== 'cancel') if (error !== 'cancel')
@ -225,7 +230,7 @@ export class JobContext<T extends IJob<TData, TState>, TData, TState extends str
this.events.emit('error', { id: this.m_id, job: this, error }); this.events.emit('error', { id: this.m_id, job: this, error });
this.error = error; this.error = error;
this.m_promise.reject(error); this.m_promise.resolve(undefined);
} finally } finally
{ {
this.running = false; this.running = false;

View file

@ -8,7 +8,6 @@ import { oneShot } from "@/mainview/scripts/audio/audio";
export function OptionDropdown (data: { export function OptionDropdown (data: {
name: string; name: string;
type: HTMLInputTypeAttribute;
className?: string; className?: string;
placeholder?: string; placeholder?: string;
icon?: JSX.Element; icon?: JSX.Element;

View file

@ -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<SettingsType, string>;
values: string[];
placeholder?: string;
icon?: JSX.Element;
children?: any;
})
{
const [dirty, setDirty] = useState(false);
const [localValue, setLocalValue] = useState<string | undefined>();
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 (
<OptionSpace id={`${data.id}-space`} label={data.label}>
<OptionDropdown
icon={data.icon}
name={data.id ?? ""}
placeholder={data.placeholder}
onBlur={handleSave}
onChange={(v) =>
{
setLocalValue(v);
setMutation.mutate(v);
}}
value={localValue} values={data.values}
/>
{data.children}
</OptionSpace>
);
}

View file

@ -8,7 +8,7 @@ import { Check, ChevronDown, FileQuestion, FolderSearch, Plug, SearchAlert, Stor
import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog'; import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog';
import classNames from 'classnames'; import classNames from 'classnames';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { RPC_URL } from '../../../shared/constants'; import { RPC_URL, SettingsSchema } from '../../../shared/constants';
import emulators from '@emulators'; import emulators from '@emulators';
import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts'; 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 { FOCUS_KEYS } from '@/mainview/scripts/types';
import { scrollIntoNearestParent, scrollIntoViewHandler, useDragScroll } from '@/mainview/scripts/utils'; import { scrollIntoNearestParent, scrollIntoViewHandler, useDragScroll } from '@/mainview/scripts/utils';
import { SettingsOption } from '@/mainview/components/options/SettingsOption'; import { SettingsOption } from '@/mainview/components/options/SettingsOption';
import { SettingsDropdown } from '@/mainview/components/options/SettingsDropdown';
export const Route = createFileRoute('/settings/emulators')({ export const Route = createFileRoute('/settings/emulators')({
component: RouteComponent, component: RouteComponent,
@ -328,6 +329,8 @@ function RouteComponent ()
<EmulatorBadges addOverride={addOverrideMutation.mutate} onFocus={scrollIntoViewHandler({ block: 'center' })} /> <EmulatorBadges addOverride={addOverrideMutation.mutate} onFocus={scrollIntoViewHandler({ block: 'center' })} />
<div className="divider text-base-content/40">Preferences</div> <div className="divider text-base-content/40">Preferences</div>
<SettingsOption label="Launch In Fullscreen" id="launchInFullscreen" type="checkbox" /> <SettingsOption label="Launch In Fullscreen" id="launchInFullscreen" type="checkbox" />
<SettingsOption label="Widescreen" id="emulatorWidescreen" type="checkbox" />
<SettingsDropdown label='Resolution' id='emulatorResolution' values={SettingsSchema.shape.emulatorResolution.unwrap().options} />
<div className="divider text-base-content/40">Overrides</div> <div className="divider text-base-content/40">Overrides</div>
<NewEmulatorPath isAddingOverride={addOverrideMutation.isPending} addOverride={addOverrideMutation.mutate} /> <NewEmulatorPath isAddingOverride={addOverrideMutation.isPending} addOverride={addOverrideMutation.mutate} />
{!!customEmulators && customEmulators.map((key) => <EmulatorPath key={key} id={key} />)} {!!customEmulators && customEmulators.map((key) => <EmulatorPath key={key} id={key} />)}

View file

@ -1,5 +1,6 @@
import { emulators } from '@/bun/api/schema/emulators';
import { FocusDetails } from '@noriginmedia/norigin-spatial-navigation'; import { FocusDetails } from '@noriginmedia/norigin-spatial-navigation';
import { JSX } from 'react'; import { JSX } from 'react';
import * as z from 'zod'; import * as z from 'zod';
@ -35,7 +36,9 @@ export const SettingsSchema = z.object({
windowPosition: z.object({ x: z.number(), y: z.number() }).optional(), windowPosition: z.object({ x: z.number(), y: z.number() }).optional(),
downloadPath: z.string(), downloadPath: z.string(),
launchInFullscreen: z.boolean().default(true), 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({ export const LocalSettingsSchema = z.object({

View file

@ -153,6 +153,12 @@ declare interface FrontEndPlatformType
paths_screenshots: string[]; paths_screenshots: string[];
} }
declare interface FrontEndGameTypeWithIds extends FrontEndGameType
{
igdb_id: number | null;
ra_id: number | null;
}
declare interface FrontEndGameType declare interface FrontEndGameType
{ {
platform_display_name: string | null, platform_display_name: string | null,