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');
+ });
});