From 209434a3f533145585ba52b0cca9cb91ad82fb71 Mon Sep 17 00:00:00 2001 From: Einar Andersson <72999+drdator@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:35:52 +0200 Subject: [PATCH] Fix standalone game Quick Play --- public/ioquake3/player.html | 57 ++++++++++++++++++++++++++++++++++-- src/project-config.ts | 9 +++++- src/ui.ts | 7 +++-- tests/project-config.test.ts | 8 +++++ 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/public/ioquake3/player.html b/public/ioquake3/player.html index 6738117..afbd073 100644 --- a/public/ioquake3/player.html +++ b/public/ioquake3/player.html @@ -71,6 +71,7 @@ const PAK_STORE = 'pk3-files'; const SETTINGS_STORE = 'settings'; const OPENARENA_KEY = 'openarena-enabled'; + const PROJECT_CONFIG_KEY = 'q3edit.project.current.v1'; const loading = document.getElementById('loading'); const canvas = document.getElementById('canvas'); const nativeGetContext = canvas.getContext.bind(canvas); @@ -259,8 +260,35 @@ return basename.replace(/[^a-zA-Z0-9._-]/g, '_'); } + function safeGameDirectory(name) { + const candidate = String(name || '').trim(); + return /^[a-zA-Z0-9_-]+$/.test(candidate) ? candidate : 'baseq3'; + } + + function loadProjectGameDirectory() { + try { + const project = JSON.parse(localStorage.getItem(PROJECT_CONFIG_KEY) || 'null'); + return project?.game?.gameDirectory; + } catch { + return null; + } + } + async function loadLaunchAssets(launch) { const stored = await loadStoredConfiguration(); + const configuredGameDir = safeGameDirectory(launch.gameDirectory); + + // Standalone games provide their own default.cfg and game VMs. Mount + // their archives under the configured base game directory so ioquake3 + // does not validate a custom pak0.pk3 as retail baseq3 data. + if (configuredGameDir !== 'baseq3' && configuredGameDir !== 'baseoa') { + if (stored.archives.length === 0) { + throw new Error(`No PK3 files are enabled for ${configuredGameDir}. Add the game's base archive in the editor.`); + } + const archives = [...stored.archives]; + if (launch.packagePk3) archives.push({ name: `${launch.mapName}.pk3`, data: launch.packagePk3 }); + return { archives, gameDir: configuredGameDir }; + } // A retail pak0 and OpenArena's pak0 have the same filename. Mixing the // two would overwrite one of them in the virtual filesystem and launch @@ -317,6 +345,20 @@ const assets = await loadLaunchAssets(launch); setStatus('Loading ioquake3 WebAssembly runtime…'); const ioquake3 = (await import('./ioquake3.js')).default; + const renderWidth = Math.max(640, Math.floor(canvas.clientWidth || window.innerWidth)); + const renderHeight = Math.max(480, Math.floor(canvas.clientHeight || window.innerHeight)); + canvas.width = renderWidth; + canvas.height = renderHeight; + const standaloneGame = assets.gameDir !== 'baseq3' && assets.gameDir !== 'baseoa'; + const standaloneRendererArguments = standaloneGame + ? [ + '+set', 'r_ext_framebuffer_object', '0', + '+set', 'r_hdr', '0', + '+set', 'r_postProcess', '0', + '+set', 'r_overBrightBits', '0', + '+set', 'r_mapOverBrightBits', '0', + ] + : []; const availableBotNames = assets.gameDir === 'baseoa' ? ['Sarge', 'Grism', 'Sorceress'] : ['Sarge', 'Major', 'Grunt']; @@ -330,9 +372,15 @@ '+set', 'sv_pure', '0', '+set', 'net_enabled', '0', '+set', 'con_notifytime', '0', - '+set', 'r_mode', '-2', + '+set', 'r_customwidth', String(renderWidth), + '+set', 'r_customheight', String(renderHeight), + '+set', 'r_mode', '-1', '+set', 'r_fullscreen', '0', - '+set', 'r_allowResize', '1', + // Standalone renderers may not recover cleanly from ioquake3's + // automatic vid_restart. Keep their framebuffer stable and let + // CSS scale the canvas when the preview or browser is resized. + '+set', 'r_allowResize', standaloneGame ? '0' : '1', + ...standaloneRendererArguments, ...(launch.noclip ? ['+set', 'sv_cheats', '1'] : []), ...(launch.botCount > 0 ? [ '+set', 'bot_enable', '1', @@ -418,7 +466,10 @@ Array.isArray(command.args) && command.args.every(argument => typeof argument === 'string') ) : []; const packagePk3 = event.data.packagePk3 instanceof ArrayBuffer ? event.data.packagePk3 : null; - void start({ mapName, bsp, aas, packagePk3, botCount, botSkill, noclip: event.data.noclip === true, commands }); + // Reading the stored project is also a compatibility fallback for an + // editor tab that was already open when the player shell was updated. + const gameDirectory = safeGameDirectory(event.data.gameDirectory ?? loadProjectGameDirectory()); + void start({ mapName, gameDirectory, bsp, aas, packagePk3, botCount, botSkill, noclip: event.data.noclip === true, commands }); }); window.addEventListener('error', (event) => fail(event.error ?? event.message)); diff --git a/src/project-config.ts b/src/project-config.ts index ab60515..2fec0ca 100644 --- a/src/project-config.ts +++ b/src/project-config.ts @@ -48,6 +48,11 @@ const strings = (value: unknown): string[] => Array.isArray(value) : []; const text = (value: unknown, fallback = ''): string => typeof value === 'string' ? value : fallback; +export function normalizeGameDirectory(value: unknown, fallback = 'baseq3'): string { + const candidate = text(value).trim(); + return /^[a-zA-Z0-9_-]+$/.test(candidate) ? candidate : fallback; +} + export function normalizeProjectConfiguration(value: unknown): ProjectConfiguration { const result = structuredClone(DEFAULT_PROJECT_CONFIGURATION); if (!isRecord(value)) return result; @@ -59,7 +64,9 @@ export function normalizeProjectConfiguration(value: unknown): ProjectConfigurat const overrides = isRecord(value.overrides) ? value.overrides : {}; result.name = text(value.name, result.name).slice(0, 120); result.game = { - basePath: text(game.basePath), gameDirectory: text(game.gameDirectory, result.game.gameDirectory), executable: text(game.executable), + basePath: text(game.basePath), + gameDirectory: normalizeGameDirectory(game.gameDirectory, result.game.gameDirectory), + executable: text(game.executable), }; result.assets = { archives: strings(assets.archives), searchPaths: strings(assets.searchPaths), diff --git a/src/ui.ts b/src/ui.ts index a9d37cd..cdce96e 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -67,6 +67,7 @@ export interface AssetLoadingHandle { interface GamePreviewLaunch { mapName: string; + gameDirectory: string; bsp: Uint8Array; aas: Uint8Array | null; botCount: number; @@ -2319,7 +2320,9 @@ export class UI { const packageCopy = packagePk3 ? new Uint8Array(packagePk3) : null; const retainedPackage = packagePk3 ? new Uint8Array(packagePk3) : null; this.gamePreviewLaunch = { - mapName: safeMapName, bsp: retainedBsp, aas: retainedAas, + mapName: safeMapName, + gameDirectory: this.editor.projectConfiguration.game.gameDirectory, + bsp: retainedBsp, aas: retainedAas, botCount, botSkill, noclip, commands: structuredClone(commands), packagePk3: retainedPackage, }; @@ -2371,7 +2374,6 @@ export class UI { frame.title = `ioquake3 preview of ${safeMapName}`; frame.src = '/ioquake3/player.html'; frame.allow = 'autoplay; fullscreen'; - frame.setAttribute('allowfullscreen', ''); const actions = document.createElement('div'); actions.className = 'editor-dialog-actions game-preview-actions'; @@ -2401,6 +2403,7 @@ export class UI { const launchMessage = { type: 'q3edit-player:launch', mapName: safeMapName, + gameDirectory: this.gamePreviewLaunch?.gameDirectory ?? 'baseq3', bsp: bspCopy.buffer, aas: aasCopy?.buffer ?? null, botCount, diff --git a/tests/project-config.test.ts b/tests/project-config.test.ts index b131678..109b821 100644 --- a/tests/project-config.test.ts +++ b/tests/project-config.test.ts @@ -5,6 +5,7 @@ import { PROJECT_CONFIG_STORAGE_KEY, importProjectConfiguration, loadProjectConfiguration, + normalizeGameDirectory, resolveProjectPreferences, saveProjectConfiguration, } from '../src/project-config'; @@ -48,4 +49,11 @@ describe('project configuration', () => { it('rejects imports from unsupported future versions', () => { expect(() => importProjectConfiguration('{"version":2}')).toThrow('newer than this editor supports'); }); + + it('accepts safe standalone game directories and rejects filesystem paths', () => { + expect(normalizeGameDirectory('tinygame')).toBe('tinygame'); + expect(normalizeGameDirectory('my-game_2')).toBe('my-game_2'); + expect(normalizeGameDirectory('../baseq3')).toBe('baseq3'); + expect(normalizeGameDirectory('mods/tinygame')).toBe('baseq3'); + }); });