diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..7b1e8b3
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+allow-git=all
\ No newline at end of file
diff --git a/arguments_example.json b/arguments_example.json
new file mode 100644
index 0000000..efa374e
--- /dev/null
+++ b/arguments_example.json
@@ -0,0 +1,57 @@
+[
+ {
+ "key": "DEV_MODE",
+ "description": "Activates the developer mode, enabling additional debug tools and shortcuts.",
+ "type": "boolean"
+ },
+ {
+ "key": "CONFIGURATION_FILE",
+ "description": "Path to the configuration file to use.",
+ "type": "string"
+ },
+ {
+ "key": "NO_CAMERA_MIN_ZOOM",
+ "description": "Disables the minimum zoom restriction on the camera.",
+ "type": "boolean"
+ },
+ {
+ "key": "ONLY_ALLOWED_TEAM_TAB",
+ "description": "The tab number of the only tab allowed in the Fighter Management UI.",
+ "type": "number"
+ },
+ {
+ "key": "ONLY_ALLOWED_LADDER_TAB",
+ "description": "The tab number of the only tab allowed in the Ladder UI one of [ONE_VS_ONE,EVOLUTION,TWO_VS_TWO,GUILD,TOURNAMENT,PRO]",
+ "type": "string"
+ },
+ {
+ "key": "SKIP_TURN_NO_DELAY",
+ "description": "Bypass the delay before ending a turn.",
+ "type": "boolean"
+ },
+ {
+ "key": "REPLAY_FILE_PATH",
+ "description": "Path to a replay file to load and play.",
+ "type": "string"
+ },
+ {
+ "key": "WORLD_FADE",
+ "description": "Activates the world fade effect during transitions.",
+ "type": "boolean"
+ },
+ {
+ "key": "HOT_RELOAD_EFFECT",
+ "description": "Enables hot reloading of visual effects.",
+ "type": "boolean"
+ },
+ {
+ "key": "NO_CLASS_RESTRICTION",
+ "description": "Disables class restrictions for character creation or editing.",
+ "type": "boolean"
+ },
+ {
+ "key": "RESTORE_CONSOLE_PATH",
+ "description": "Restores the last used console path on startup instead of defaulting to server.",
+ "type": "boolean"
+ }
+]
diff --git a/package-lock.json b/package-lock.json
index e0cc8d0..1565856 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "arena-returns-launcher",
- "version": "3.4.2",
+ "version": "3.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "arena-returns-launcher",
- "version": "3.4.2",
+ "version": "3.5.1",
"workspaces": [
"packages/*"
],
diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts
index 9e309a2..e06f0dd 100644
--- a/packages/main/src/index.ts
+++ b/packages/main/src/index.ts
@@ -44,7 +44,7 @@ export async function initApp(initConfig: AppInitConfig) {
createWindowManagerModule({
initConfig,
openDevTools: import.meta.env.DEV,
- })
+ }),
)
.init(disallowMultipleAppInstance())
.init(terminateAppOnLastWindowClose())
@@ -59,16 +59,18 @@ export async function initApp(initConfig: AppInitConfig) {
.init(
allowInternalOrigins(
new Set(
- initConfig.renderer instanceof URL ? [initConfig.renderer.origin] : []
- )
- )
+ initConfig.renderer instanceof URL
+ ? [initConfig.renderer.origin]
+ : [],
+ ),
+ ),
)
.init(
allowExternalUrls(
initConfig.renderer instanceof URL
? ALLOWED_EXTERNAL_ORIGINS
- : new Set()
- )
+ : new Set(),
+ ),
);
await moduleRunner;
diff --git a/packages/main/src/modules/GameClient.ts b/packages/main/src/modules/GameClient.ts
index 35a86fe..68fca6b 100644
--- a/packages/main/src/modules/GameClient.ts
+++ b/packages/main/src/modules/GameClient.ts
@@ -1,14 +1,62 @@
-import { ipcMain, app } from "electron";
+import { app, ipcMain } from "electron";
import { join } from "path";
-import { existsSync, mkdirSync } from "fs";
-import { chmodSync } from "fs";
-import { stat, chmod, readdir, readFile, appendFile } from "fs/promises";
-import { exec } from "child_process";
+import { chmodSync, existsSync, mkdirSync } from "fs";
+import { appendFile, chmod, readdir, readFile, stat } from "fs/promises";
+import { spawn } from "child_process";
import log from "electron-log";
-import { GameUpdater, GameSettings, ReplayFile } from "./GameUpdater.js";
+import {
+ GameSettings,
+ GameUpdater,
+ ReplayFile,
+ getPlatformManifestEntry,
+} from "./GameUpdater.js";
import type { AppModule } from "../AppModule.js";
import type { ModuleContext } from "../ModuleContext.js";
+const splitCommandLineArgs = (argsString: string): string[] => {
+ const args: string[] = [];
+ let current = "";
+ let quote: '"' | "'" | null = null;
+ let hasContent = false;
+
+ for (let index = 0; index < argsString.length; index += 1) {
+ const character = argsString[index];
+
+ if (quote) {
+ if (character === quote) {
+ quote = null;
+ } else if (
+ character === "\\" &&
+ argsString[index + 1] !== undefined &&
+ (argsString[index + 1] === quote || argsString[index + 1] === "\\")
+ ) {
+ current += argsString[index + 1];
+ index += 1;
+ } else {
+ current += character;
+ }
+ continue;
+ }
+
+ if (character === '"' || character === "'") {
+ quote = character;
+ hasContent = true;
+ } else if (/\s/.test(character)) {
+ if (hasContent) {
+ args.push(current);
+ current = "";
+ hasContent = false;
+ }
+ } else {
+ current += character;
+ hasContent = true;
+ }
+ }
+
+ if (hasContent) args.push(current);
+ return args;
+};
+
export class GameClient implements AppModule {
private gameUpdater: GameUpdater | null = null;
private gameClientPath: string;
@@ -31,11 +79,14 @@ export class GameClient implements AppModule {
// Register GameClient-specific IPC handlers
ipcMain.handle("gameClient:launchGame", () => this.launchGame());
ipcMain.handle("gameClient:openReplaysFolder", () =>
- this.openReplaysFolder()
+ this.openReplaysFolder(),
);
ipcMain.handle("gameClient:listReplays", () => this.listReplays());
ipcMain.handle("gameClient:launchReplayOffline", (_e, path) =>
- this.launchReplayOffline(path)
+ this.launchReplayOffline(path),
+ );
+ ipcMain.handle("gameClient:getGameArgumentsDescriptor", () =>
+ this.getGameArgumentsDescriptor(),
);
}
@@ -75,10 +126,6 @@ export class GameClient implements AppModule {
await this.startJavaProcess({
mainClass: "com.ankamagames.dofusarena.client.DofusArenaClient",
settings: this.currentSettings || undefined,
- extraArgs: [
- "-ONLY_ALLOWED_TEAM_TAB=1",
- "-ONLY_ALLOWED_LADDER_TAB=ONE_VS_ONE",
- ],
});
}
@@ -94,7 +141,7 @@ export class GameClient implements AppModule {
throw new Error(
`Failed to open replays folder: ${
error instanceof Error ? error.message : "Unknown error"
- }`
+ }`,
);
}
}
@@ -153,7 +200,7 @@ export class GameClient implements AppModule {
const gameConfigPath = join(
this.gameClientPath,
"game",
- "config.properties"
+ "config.properties",
);
try {
@@ -174,11 +221,11 @@ export class GameClient implements AppModule {
log.info("Adding dev mode proxy settings to config.properties");
await appendFile(
gameConfigPath,
- "\nproxyGroup_2=Localhost\nproxyAddresses_2=localhost:5555\n"
+ "\nproxyGroup_2=Localhost\nproxyAddresses_2=localhost:5555\n",
);
await appendFile(
gameConfigPath,
- "\nproxyGroup_3=Staging\nproxyAddresses_3=minuit-staging.arenareturns.com:6666\n"
+ "\nproxyGroup_3=Staging\nproxyAddresses_3=minuit-staging.arenareturns.com:6666\n",
);
} catch (error) {
log.error("Failed to update config.properties for dev mode:", error);
@@ -192,50 +239,40 @@ export class GameClient implements AppModule {
extraArgs?: string[];
}): Promise {
const { mainClass, settings, extraArgs = [] } = options;
+ const fullGameArgs = [
+ ...(this.currentSettings?.devGameArgs
+ ? splitCommandLineArgs(this.currentSettings.devGameArgs).map(
+ (arg) => `-${arg}`,
+ )
+ : []),
+ ...extraArgs,
+ ];
const gameDir = join(this.gameClientPath, "game");
const libDir = join(this.gameClientPath, "lib");
const jreDir = join(this.gameClientPath, "jre");
const nativesDir = join(this.gameClientPath, "natives");
if (!existsSync(gameDir)) throw new Error("Game directory not found");
- if (!existsSync(libDir)) throw new Error("Library directory not found");
if (!existsSync(jreDir)) throw new Error("JRE directory not found");
- if (!existsSync(nativesDir)) throw new Error("Natives directory not found");
- const libFiles = (await readdir(libDir)).filter((f) => f.endsWith(".jar"));
- // FIXME: Gigahack since darwin relies on wine
- const classpath = libFiles
- .map((jar) => join(libDir, jar))
- .join(
- process.platform === "win32" || process.platform === "darwin"
- ? ";"
- : ":"
- );
const coreJarPath = join(gameDir, "core.jar");
// FIXME: Gigahack since darwin relies on wine
- const fullClasspath =
- classpath +
- (process.platform === "win32" || process.platform === "darwin"
- ? ";"
- : ":") +
- coreJarPath;
-
- let nativesPath: string;
- switch (process.platform) {
- case "win32":
- nativesPath = join(nativesDir, "win32", "x64");
- break;
- case "darwin":
- nativesPath = join(nativesDir, "win32", "x64");
- // FIXME: Native macos build not yet available
- // nativesPath = join(nativesDir, "darwin", "universal");
- break;
- case "linux":
- nativesPath = join(nativesDir, "linux", "x64");
- break;
- default:
- throw new Error(`Unsupported platform: ${process.platform}`);
- }
+ const classpathSeparator =
+ process.platform === "win32" || process.platform === "darwin" ? ";" : ":";
+ const libCP = existsSync(libDir)
+ ? (await readdir(libDir))
+ .filter((file) => file.endsWith(".jar"))
+ .map((jar) => join(libDir, jar))
+ : [];
+
+ const fullClasspath = [
+ ...libCP,
+
+ join(nativesDir, "*"), //pseudo-natives library in jar format; needs to be passed to the cp
+ join(nativesDir, getPlatformManifestEntry(), "*"),
+
+ coreJarPath,
+ ].join(classpathSeparator);
let javaExecutable: string;
switch (process.platform) {
@@ -271,27 +308,26 @@ export class GameClient implements AppModule {
"-XX:+UseG1GC",
"-XX:G1NewSizePercent=20",
"-XX:G1ReservePercent=20",
+ "-XX:ReservedCodeCacheSize=256m",
"--add-exports",
"java.desktop/sun.awt=ALL-UNNAMED",
+ "--enable-native-access=ALL-UNNAMED",
"-Djava.net.preferIPv4Stack=true",
- "-Dsun.awt.noerasebackground=true",
- "-Dsun.java2d.noddraw=true",
"-Dsun.java2d.dpiaware=false",
"-Dsun.java2d.uiScale=1.0",
- "-Djogl.disable.openglarbcontext",
- `-Djava.library.path="${nativesPath}"`,
+ "-Djogl.disable.openglarbcontext=1",
+ "--sun-misc-unsafe-memory-access=allow"
];
if (settings?.devModeEnabled && settings?.devExtraJavaArgs) {
javaArgs.push(
- ...settings.devExtraJavaArgs
- .split(" ")
- .map((arg) => arg.trim())
- .filter((arg) => arg.length > 0)
+ ...splitCommandLineArgs(settings.devExtraJavaArgs),
);
}
- javaArgs.push("-cp", `"${fullClasspath}"`, mainClass, ...extraArgs);
+ javaArgs.push("-cp", fullClasspath, mainClass, ...fullGameArgs);
+
+ log.info("Launching game with ", javaArgs, "in", gameDir, "and exe", javaExecutable)
switch (process.platform) {
case "win32":
@@ -344,97 +380,91 @@ export class GameClient implements AppModule {
private async launchJavaProcessWindows(
javaExecutable: string,
args: string[],
- cwd: string
+ cwd: string,
): Promise {
- return new Promise((resolve, reject) => {
- const child = exec(
- `"${javaExecutable}" ${args.join(" ")}`,
- { cwd },
- (error) => {
- if (error && !error.killed) {
- log.error("Java process error:", error);
- }
- }
- );
- if (child.pid) {
- resolve();
- } else {
- reject(new Error("Failed to start Java process"));
- }
- });
+ return this.spawnJavaProcess(javaExecutable, args, cwd);
}
private async launchJavaProcessLinux(
javaExecutable: string,
args: string[],
- cwd: string
+ cwd: string,
): Promise {
- return new Promise((resolve, reject) => {
- try {
- chmodSync(javaExecutable, 0o755);
- } catch (error) {
- log.warn(
- `Failed to set permissions on Java executable ${javaExecutable}:`,
- error
- );
- }
- const child = exec(
- `"${javaExecutable}" ${args.join(" ")}`,
- { cwd },
- (error) => {
- if (error && !error.killed) {
- log.error("Java process error:", error);
- }
- }
+ try {
+ chmodSync(javaExecutable, 0o755);
+ } catch (error) {
+ log.warn(
+ `Failed to set permissions on Java executable ${javaExecutable}:`,
+ error,
);
- if (child.pid) {
- resolve();
- } else {
- reject(new Error("Failed to start Java process"));
- }
- });
+ }
+
+ return this.spawnJavaProcess(javaExecutable, args, cwd);
}
private async launchJavaProcessDarwin(
javaExecutable: string,
args: string[],
- cwd: string
+ cwd: string,
): Promise {
- return new Promise((resolve, reject) => {
- try {
- chmodSync(javaExecutable, 0o755);
- } catch (error) {
- log.warn(
- `Failed to set permissions on Java executable ${javaExecutable}:`,
- error
- );
- }
+ try {
+ chmodSync(javaExecutable, 0o755);
+ } catch (error) {
+ log.warn(
+ `Failed to set permissions on Java executable ${javaExecutable}:`,
+ error,
+ );
+ }
- // Ensure we have the full system PATH for finding wine
- const env = { ...process.env };
- if (!env.PATH?.includes("/opt/homebrew/bin")) {
- env.PATH = `${
- env.PATH || ""
- }:/opt/homebrew/bin:/usr/local/bin:/opt/local/bin`;
- }
+ // Ensure we have the full system PATH for finding wine
+ const env = { ...process.env };
+ if (!env.PATH?.includes("/opt/homebrew/bin")) {
+ env.PATH = `${
+ env.PATH || ""
+ }:/opt/homebrew/bin:/usr/local/bin:/opt/local/bin`;
+ }
- const child = exec(
- `wine "${javaExecutable}" ${args.join(" ")}`,
- { cwd, env },
- (error) => {
- if (error && !error.killed) {
- log.error("Java process error:", error);
- }
+ return this.spawnJavaProcess("wine", [javaExecutable, ...args], cwd, env);
+ }
+
+ private spawnJavaProcess(
+ executable: string,
+ args: string[],
+ cwd: string,
+ env: NodeJS.ProcessEnv = process.env,
+ ): Promise {
+ return new Promise((resolve, reject) => {
+ const child = spawn(executable, args, { cwd, env, stdio: "ignore" });
+
+ child.once("error", reject);
+ child.once("spawn", resolve);
+ child.once("exit", (code, signal) => {
+ if (code !== 0) {
+ log.error(
+ `Java process exited with code ${code ?? "unknown"}` +
+ (signal ? ` (signal: ${signal})` : ""),
+ );
}
- );
- if (child.pid) {
- resolve();
- } else {
- reject(new Error("Failed to start Java process"));
- }
+ });
});
}
+ async getGameArgumentsDescriptor(): Promise {
+ try {
+ let schemaPath = join(this.gameClientPath, "game", "args");
+ if (existsSync(schemaPath)) {
+ log.info("Loading arguments descriptor from", schemaPath);
+ const content = await readFile(schemaPath, "utf-8");
+ return JSON.parse(content);
+ }
+ log.info("Arguments descriptor not found at", schemaPath);
+ return null;
+ } catch (error) {
+ log.error("Failed to load arguments descriptor:", error);
+ return null;
+ }
+ }
+
private parseReplayFilename(filename: string, fullPath: string): ReplayFile {
const replayFile: ReplayFile = {
filename,
diff --git a/packages/main/src/modules/GameUpdater.ts b/packages/main/src/modules/GameUpdater.ts
index 7042480..f610be4 100644
--- a/packages/main/src/modules/GameUpdater.ts
+++ b/packages/main/src/modules/GameUpdater.ts
@@ -35,10 +35,41 @@ export interface FileManifest {
export interface VersionManifest {
version: string;
base: FileManifest[];
- windows: FileManifest[];
- "macos-intel": FileManifest[];
- "macos-arm": FileManifest[];
- linux: FileManifest[];
+ linux_arm64: FileManifest[];
+ linux_x64: FileManifest[];
+ windows_arm64: FileManifest[];
+ windows_x86: FileManifest[];
+ windows_x64: FileManifest[];
+ macos_x64: FileManifest[];
+ macos_arm64: FileManifest[];
+}
+
+export type PlatformManifestEntry = Exclude<
+ keyof VersionManifest,
+ "version" | "base"
+>;
+
+export function getPlatformManifestEntry(): PlatformManifestEntry {
+ switch (`${process.platform}_${process.arch}`) {
+ case "linux_arm64":
+ return "linux_arm64";
+ case "linux_x64":
+ return "linux_x64";
+ case "win32_arm64":
+ return "windows_arm64";
+ // case "win32_ia32": //no jdk to support it \o/
+ // return "windows_x86";
+ case "win32_x64":
+ return "windows_x64";
+ case "darwin_x64":
+ return "macos_x64";
+ case "darwin_arm64":
+ return "macos_arm64";
+ default:
+ throw new Error(
+ `Unsupported platform or architecture: ${process.platform}/${process.arch}`,
+ );
+ }
}
export interface GameStatus {
@@ -62,6 +93,7 @@ export interface GameSettings {
gameRamAllocation: number;
devModeEnabled: boolean;
devExtraJavaArgs: string;
+ devGameArgs: string;
devForceVersion: string;
devCdnEnvironment: "production" | "staging";
}
@@ -118,12 +150,12 @@ export class GameUpdater implements AppModule {
ipcMain.handle("gameUpdater:checkForUpdates", () => this.checkForUpdates());
ipcMain.handle("gameUpdater:startDownload", () => this.startDownload());
ipcMain.handle("gameUpdater:getDownloadProgress", () =>
- this.getDownloadProgress()
+ this.getDownloadProgress(),
);
ipcMain.handle("gameUpdater:cancelDownload", () => this.cancelDownload());
ipcMain.handle("gameUpdater:repairClient", () => this.repairClient());
ipcMain.handle("gameUpdater:openGameDirectory", () =>
- this.openGameDirectory()
+ this.openGameDirectory(),
);
}
@@ -172,7 +204,7 @@ export class GameUpdater implements AppModule {
if (previousSettings) {
if (previousSettings.devCdnEnvironment !== settings.devCdnEnvironment) {
log.debug(
- `CDN environment changed from ${previousSettings.devCdnEnvironment} to ${settings.devCdnEnvironment}, notifying UI to refresh status`
+ `CDN environment changed from ${previousSettings.devCdnEnvironment} to ${settings.devCdnEnvironment}, notifying UI to refresh status`,
);
this.notifyRenderer("status-changed", {});
@@ -225,7 +257,7 @@ export class GameUpdater implements AppModule {
if (!response.ok) {
throw new Error(
- `Failed to fetch version info: ${response.status} ${response.statusText}`
+ `Failed to fetch version info: ${response.status} ${response.statusText}`,
);
}
@@ -250,7 +282,7 @@ export class GameUpdater implements AppModule {
throw new Error(
`Failed to update local version: ${
error instanceof Error ? error.message : "Unknown error"
- }`
+ }`,
);
}
}
@@ -275,13 +307,13 @@ export class GameUpdater implements AppModule {
this.notifyRenderer("download-started", this.downloadProgress);
const remoteVersion = await this.getRemoteVersion();
- const manifestResponse = await fetch(
- `${this.cdnUrl}/versions/${remoteVersion}.json`
- );
+ let manifestURL = `${this.cdnUrl}/versions/${remoteVersion}.json`;
+ log.info("Fetching manifest from ", manifestURL)
+ const manifestResponse = await fetch(manifestURL);
if (!manifestResponse.ok) {
throw new Error(
- `Failed to fetch version manifest: ${manifestResponse.status}`
+ `Failed to fetch version manifest: ${manifestResponse.status}`,
);
}
@@ -334,12 +366,12 @@ export class GameUpdater implements AppModule {
const remoteVersion = await this.getRemoteVersion();
const manifestResponse = await fetch(
- `${this.cdnUrl}/versions/${remoteVersion}.json`
+ `${this.cdnUrl}/versions/${remoteVersion}.json`,
);
if (!manifestResponse.ok) {
throw new Error(
- `Failed to fetch version manifest: ${manifestResponse.status}`
+ `Failed to fetch version manifest: ${manifestResponse.status}`,
);
}
@@ -372,40 +404,19 @@ export class GameUpdater implements AppModule {
throw new Error(
`Failed to open game directory: ${
error instanceof Error ? error.message : "Unknown error"
- }`
+ }`,
);
}
}
// ---------------- Internal helpers ----------------
private getPlatformFiles(manifest: VersionManifest): FileManifest[] {
- const files: FileManifest[] = [...manifest.base];
-
- switch (process.platform) {
- case "win32":
- files.push(...manifest.windows);
- break;
- case "darwin":
- // FIXME: Gigahack since darwin relies on wine
- files.push(...manifest.windows);
- break;
- /*if (process.arch === "x64") {
- files.push(...manifest["macos-intel"]);
- } else {
- files.push(...manifest["macos-arm"]);
- }*/
- break;
- case "linux":
- files.push(...manifest.linux);
- break;
- }
-
- return files;
+ return [...manifest.base, ...manifest[getPlatformManifestEntry()]];
}
private async checkFiles(
files: FileManifest[],
- forceCheck = false
+ forceCheck = false,
): Promise {
if (!existsSync(this.gameClientPath)) {
mkdirSync(this.gameClientPath, { recursive: true });
@@ -491,7 +502,7 @@ export class GameUpdater implements AppModule {
const downloadPromises = files.map((file) =>
queue.add(() => this.downloadAndSaveFile(file), {
priority: 1,
- })
+ }),
);
try {
@@ -535,7 +546,7 @@ export class GameUpdater implements AppModule {
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(
- `Failed to download file: ${response.status} ${response.statusText}`
+ `Failed to download file: ${response.status} ${response.statusText}`,
);
}
@@ -548,7 +559,7 @@ export class GameUpdater implements AppModule {
const dir = join(
this.gameClientPath,
- file.path.split("/").slice(0, -1).join("/")
+ file.path.split("/").slice(0, -1).join("/"),
);
if (dir && !existsSync(dir)) {
mkdirSync(dir, { recursive: true });
@@ -569,7 +580,7 @@ export class GameUpdater implements AppModule {
throw new Error(
`Failed to download file ${file.path}: ${
error instanceof Error ? error.message : "Unknown error"
- }`
+ }`,
);
}
}
@@ -652,7 +663,7 @@ export class GameUpdater implements AppModule {
private async getAllLocalFiles(
dirPath: string = this.gameClientPath,
relativeTo: string = this.gameClientPath,
- files: string[] = []
+ files: string[] = [],
): Promise {
try {
const entries = await readdir(dirPath);
diff --git a/packages/main/src/services/SettingsManager.ts b/packages/main/src/services/SettingsManager.ts
index 1bd6191..f91036b 100644
--- a/packages/main/src/services/SettingsManager.ts
+++ b/packages/main/src/services/SettingsManager.ts
@@ -21,6 +21,7 @@ export class SettingsManager {
return {
gameRamAllocation: 2,
devModeEnabled: false,
+ devGameArgs: "ONLY_ALLOWED_TEAM_TAB=1 ONLY_ALLOWED_LADDER_TAB=ONE_VS_ONE",
devExtraJavaArgs: "",
devForceVersion: "",
devCdnEnvironment: "production",
@@ -66,7 +67,7 @@ export class SettingsManager {
await writeFile(
this.settingsPath,
JSON.stringify(settings, null, 2),
- "utf-8"
+ "utf-8",
);
log.info("Settings saved successfully:", settings);
@@ -81,7 +82,7 @@ export class SettingsManager {
throw new Error(
`Failed to save settings: ${
error instanceof Error ? error.message : "Unknown error"
- }`
+ }`,
);
}
}
@@ -119,7 +120,7 @@ export class SettingsManager {
*/
private notifySettingsChange(settings: GameSettings): void {
log.info(
- `Notifying ${this.changeCallbacks.size} modules of settings change`
+ `Notifying ${this.changeCallbacks.size} modules of settings change`,
);
for (const callback of this.changeCallbacks) {
diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts
index 8777d36..9d9af93 100644
--- a/packages/preload/src/index.ts
+++ b/packages/preload/src/index.ts
@@ -58,6 +58,8 @@ const gameClient = {
listReplays: () => ipcRenderer.invoke("gameClient:listReplays"),
launchReplayOffline: (replayPath: string) =>
ipcRenderer.invoke("gameClient:launchReplayOffline", replayPath),
+ getGameArgumentsDescriptor: () =>
+ ipcRenderer.invoke("gameClient:getGameArgumentsDescriptor"),
};
// News functions
diff --git a/packages/renderer/src/components/SettingsMenu.tsx b/packages/renderer/src/components/SettingsMenu.tsx
index 255ccbb..f79d678 100644
--- a/packages/renderer/src/components/SettingsMenu.tsx
+++ b/packages/renderer/src/components/SettingsMenu.tsx
@@ -16,12 +16,185 @@ import {
Code,
AlertTriangle,
} from "lucide-react";
-import { SimpleSlider, SimpleSelect } from "./common/FormControls";
+import {
+ SimpleSlider,
+ SimpleSelect,
+ SimpleSwitch,
+} from "./common/FormControls";
import type { SettingsState } from "@/types";
import { gameClient, gameUpdater, system } from "@app/preload";
import { useGameStateContext } from "@/contexts/GameStateContext";
import log from "@/utils/logger";
+interface ArgumentDescriptorItem {
+ key: string;
+ description: string;
+ type: "boolean" | "string" | "number";
+}
+
+type GameArgValue = string | true;
+
+const isArgumentDescriptorItem = (
+ item: unknown,
+): item is ArgumentDescriptorItem => {
+ if (!item || typeof item !== "object") return false;
+
+ const candidate = item as Record;
+ return (
+ typeof candidate.key === "string" &&
+ typeof candidate.description === "string" &&
+ (candidate.type === "boolean" ||
+ candidate.type === "string" ||
+ candidate.type === "number")
+ );
+};
+
+const splitCommandLineArgs = (argsString: string): string[] => {
+ const args: string[] = [];
+ let current = "";
+ let quote: '"' | "'" | null = null;
+ let hasContent = false;
+
+ for (let index = 0; index < argsString.length; index += 1) {
+ const character = argsString[index];
+
+ if (quote) {
+ if (character === quote) {
+ quote = null;
+ } else if (
+ character === "\\" &&
+ argsString[index + 1] !== undefined &&
+ (argsString[index + 1] === quote || argsString[index + 1] === "\\")
+ ) {
+ current += argsString[index + 1];
+ index += 1;
+ } else {
+ current += character;
+ }
+ continue;
+ }
+
+ if (character === '"' || character === "'") {
+ quote = character;
+ hasContent = true;
+ } else if (/\s/.test(character)) {
+ if (hasContent) {
+ args.push(current);
+ current = "";
+ hasContent = false;
+ }
+ } else {
+ current += character;
+ hasContent = true;
+ }
+ }
+
+ if (hasContent) args.push(current);
+ return args;
+};
+
+const serializeGameArg = (key: string, value: GameArgValue): string => {
+ if (value === true) return key;
+
+ const serializedValue = /\s|["']/.test(value)
+ ? JSON.stringify(value)
+ : value;
+ return `${key}=${serializedValue}`;
+};
+
+const parseGameArgs = (
+ argsString: string,
+): Record => {
+ const parsed: Record = {};
+ if (!argsString) return parsed;
+ splitCommandLineArgs(argsString).forEach((part) => {
+ if (!part) return;
+ const [key, ...valParts] = part.split("=");
+ if (valParts.length > 0) {
+ parsed[key] = valParts.join("=");
+ } else {
+ parsed[key] = true;
+ }
+ });
+ return parsed;
+};
+
+const updateDescriptorArg = (
+ currentArgsStr: string,
+ descriptor: ArgumentDescriptorItem[],
+ keyToUpdate: string,
+ newValue: string | boolean,
+): string => {
+ const parsed = parseGameArgs(currentArgsStr);
+ const descriptorKeys = new Set(descriptor.map((s) => s.key));
+
+ if (newValue === false || newValue === "") {
+ delete parsed[keyToUpdate];
+ } else {
+ parsed[keyToUpdate] = newValue;
+ }
+
+ const descriptorParts: string[] = [];
+ const customParts: string[] = [];
+
+ Object.entries(parsed).forEach(([key, val]) => {
+ if (descriptorKeys.has(key)) {
+ if (val !== "") {
+ descriptorParts.push(serializeGameArg(key, val));
+ }
+ } else {
+ customParts.push(serializeGameArg(key, val));
+ }
+ });
+
+ const finalParts = [...descriptorParts];
+ if (customParts.length > 0) {
+ finalParts.push(customParts.join(" "));
+ }
+
+ return finalParts.join(" ");
+};
+
+const updateCustomArgs = (
+ currentArgsStr: string,
+ descriptor: ArgumentDescriptorItem[],
+ newCustomStr: string,
+): string => {
+ const parsed = parseGameArgs(currentArgsStr);
+ const descriptorKeys = new Set(descriptor.map((s) => s.key));
+
+ const descriptorParts: string[] = [];
+ Object.entries(parsed).forEach(([key, val]) => {
+ if (descriptorKeys.has(key)) {
+ if (val !== "") {
+ descriptorParts.push(serializeGameArg(key, val));
+ }
+ }
+ });
+
+ const customArgs = newCustomStr.trim();
+ return customArgs
+ ? [...descriptorParts, customArgs].join(" ")
+ : descriptorParts.join(" ");
+};
+
+const getCustomArgsString = (
+ currentArgsStr: string,
+ descriptor: ArgumentDescriptorItem[],
+): string => {
+ const parsed = parseGameArgs(currentArgsStr);
+ const descriptorKeys = new Set(descriptor.map((s) => s.key));
+ const customParts: string[] = [];
+
+ Object.entries(parsed).forEach(([key, val]) => {
+ if (!descriptorKeys.has(key)) {
+ customParts.push(serializeGameArg(key, val));
+ }
+ });
+
+ return customParts.join(" ");
+};
+
interface SettingsMenuProps {
isOpen: boolean;
onClose: () => void;
@@ -44,6 +217,43 @@ export const SettingsMenu: React.FC = ({
// Local settings state for editing (doesn't affect main UI)
const [localSettings, setLocalSettings] = useState(settings);
+ const [ArgumentsDescriptor, setArgumentsDescriptor] = useState<
+ ArgumentDescriptorItem[] | null
+ >(null);
+ const [descriptorLoading, setDescriptorLoading] = useState(false);
+ const [customGameArgsInput, setCustomGameArgsInput] = useState("");
+ const [rawGameArgsInput, setRawGameArgsInput] = useState("");
+
+ // Fetch arguments.json descriptor when developer mode is enabled and settings menu is open
+ useEffect(() => {
+ if (!isOpen || !localSettings.devModeEnabled) {
+ setArgumentsDescriptor(null);
+ return;
+ }
+
+ const fetchDescriptor = async () => {
+ setDescriptorLoading(true);
+ try {
+ const descriptor = await gameClient.getGameArgumentsDescriptor();
+ if (
+ Array.isArray(descriptor) &&
+ descriptor.every(isArgumentDescriptorItem)
+ ) {
+ setArgumentsDescriptor(descriptor);
+ } else {
+ setArgumentsDescriptor(null);
+ }
+ } catch (error) {
+ log.error("Failed to fetch arguments descriptor:", error);
+ setArgumentsDescriptor(null);
+ } finally {
+ setDescriptorLoading(false);
+ }
+ };
+
+ fetchDescriptor();
+ }, [isOpen, localSettings.devModeEnabled]);
+
// Update local settings when menu opens or settings prop changes
useEffect(() => {
if (isOpen) {
@@ -52,6 +262,20 @@ export const SettingsMenu: React.FC = ({
}
}, [isOpen, settings]);
+ useEffect(() => {
+ if (!ArgumentsDescriptor) return;
+
+ setCustomGameArgsInput(
+ getCustomArgsString(localSettings.devGameArgs, ArgumentsDescriptor),
+ );
+ }, [ArgumentsDescriptor, localSettings.devGameArgs]);
+
+ useEffect(() => {
+ if (ArgumentsDescriptor) return;
+
+ setRawGameArgsInput(localSettings.devGameArgs);
+ }, [ArgumentsDescriptor, localSettings.devGameArgs]);
+
// Fetch app version and log directory when settings menu opens
useEffect(() => {
if (!isOpen) return;
@@ -81,6 +305,19 @@ export const SettingsMenu: React.FC = ({
});
};
+ const handleCustomGameArgsBlur = () => {
+ if (!ArgumentsDescriptor) return;
+
+ setLocalSettings((currentSettings) => ({
+ ...currentSettings,
+ devGameArgs: updateCustomArgs(
+ currentSettings.devGameArgs,
+ ArgumentsDescriptor,
+ customGameArgsInput,
+ ),
+ }));
+ };
+
const handleSave = async () => {
setIsSaving(true);
setSaveError(null);
@@ -267,7 +504,7 @@ export const SettingsMenu: React.FC = ({
onChange={(value) =>
updateSetting(
"devCdnEnvironment",
- value as "production" | "staging"
+ value as "production" | "staging",
)
}
options={[
@@ -309,6 +546,117 @@ export const SettingsMenu: React.FC = ({
jeu.
+
+
+
+ {descriptorLoading ? (
+
+ Chargement des arguments...
+
+ ) : ArgumentsDescriptor ? (
+
+
+ {ArgumentsDescriptor.map((item) => {
+ const parsedArgs = parseGameArgs(
+ localSettings.devGameArgs,
+ );
+ const currentValue = parsedArgs[item.key];
+
+ return (
+
+
+
+ {item.key}
+
+ {item.type === "boolean" ? (
+ {
+ const updated = updateDescriptorArg(
+ localSettings.devGameArgs,
+ ArgumentsDescriptor,
+ item.key,
+ checked,
+ );
+ updateSetting("devGameArgs", updated);
+ }}
+ />
+ ) : (
+ {
+ const updated = updateDescriptorArg(
+ localSettings.devGameArgs,
+ ArgumentsDescriptor,
+ item.key,
+ e.target.value,
+ );
+ updateSetting("devGameArgs", updated);
+ }}
+ className="w-1/2 px-2 py-1 bg-black/40 border border-white/20 rounded text-white text-sm"
+ />
+ )}
+
+
+ {item.description}
+
+
+ );
+ })}
+
+
+
+
+
+
+ ) : (
+ <>
+
)}
diff --git a/packages/renderer/src/types/index.ts b/packages/renderer/src/types/index.ts
index 1e9cae3..5c7045a 100644
--- a/packages/renderer/src/types/index.ts
+++ b/packages/renderer/src/types/index.ts
@@ -35,6 +35,7 @@ export interface SettingsState {
// Developer mode
devModeEnabled: boolean;
devExtraJavaArgs: string;
+ devGameArgs: string;
devForceVersion: string;
devCdnEnvironment: "production" | "staging";
}
diff --git a/packages/renderer/src/vite-env.d.ts b/packages/renderer/src/vite-env.d.ts
index 75a2d5a..10b3c17 100644
--- a/packages/renderer/src/vite-env.d.ts
+++ b/packages/renderer/src/vite-env.d.ts
@@ -21,6 +21,7 @@ interface GameSettings {
gameRamAllocation: number;
devModeEnabled: boolean;
devExtraJavaArgs: string;
+ devGameArgs: string;
devForceVersion: string;
devCdnEnvironment: "production" | "staging";
}