Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 54 additions & 3 deletions public/ioquake3/player.html
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'];
Expand All @@ -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',
Expand Down Expand Up @@ -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));
Expand Down
9 changes: 8 additions & 1 deletion src/project-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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),
Expand Down
7 changes: 5 additions & 2 deletions src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export interface AssetLoadingHandle {

interface GamePreviewLaunch {
mapName: string;
gameDirectory: string;
bsp: Uint8Array;
aas: Uint8Array | null;
botCount: number;
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions tests/project-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
PROJECT_CONFIG_STORAGE_KEY,
importProjectConfiguration,
loadProjectConfiguration,
normalizeGameDirectory,
resolveProjectPreferences,
saveProjectConfiguration,
} from '../src/project-config';
Expand Down Expand Up @@ -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');
});
});
Loading