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
26 changes: 17 additions & 9 deletions public/ioquake3/player.html
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@
}
const archives = [...stored.archives];
if (launch.packagePk3) archives.push({ name: `${launch.mapName}.pk3`, data: launch.packagePk3 });
if (launch.previewPk3) archives.push({ name: 'zzzz-q3edit-preview.pk3', data: launch.previewPk3 });
return { archives, gameDir: configuredGameDir };
}

Expand All @@ -302,13 +303,18 @@
? stored.archives.filter((archive) => /^pak[0-9]+\.pk3$/i.test(safeFileName(archive.name)))
: stored.archives;
if (launch.packagePk3) archives.push({ name: `${launch.mapName}.pk3`, data: launch.packagePk3 });
if (launch.previewPk3) archives.push({ name: 'zzzz-q3edit-preview.pk3', data: launch.previewPk3 });
return { archives, gameDir: 'baseq3' };
}

if (stored.openArenaEnabled) {
const archives = await loadOpenArenaArchives();
if (launch.packagePk3) archives.push({ name: `${launch.mapName}.pk3`, data: launch.packagePk3 });
else archives.push(...stored.archives);
if (launch.packagePk3) {
archives.push({ name: `${launch.mapName}.pk3`, data: launch.packagePk3 });
} else {
archives.push(...stored.archives);
if (launch.previewPk3) archives.push({ name: 'zzzz-q3edit-preview.pk3', data: launch.previewPk3 });
}
return { archives, gameDir: 'baseoa' };
}

Expand All @@ -329,12 +335,12 @@
setStatus(`Installing ${name} (${index + 1} of ${archives.length})…`);
module.FS.writeFile(`${root}/${name}`, new Uint8Array(archive.data));
}
// Package preview must resolve the BSP and AAS from the produced PK3.
// Writing loose copies here would hide missing package entries.
if (!launch.packagePk3) {
module.FS.writeFile(`${root}/maps/${launch.mapName}.bsp`, new Uint8Array(launch.bsp));
// Package and direct Quick Play previews resolve the BSP from their
// generated PK3, keeping lookup priority consistent across games.
if (!launch.packagePk3 && !launch.previewPk3) {
module.FS.writeFile(`${root}/maps/${launch.runtimeMapName}.bsp`, new Uint8Array(launch.bsp));
if (launch.aas) {
module.FS.writeFile(`${root}/maps/${launch.mapName}.aas`, new Uint8Array(launch.aas));
module.FS.writeFile(`${root}/maps/${launch.runtimeMapName}.aas`, new Uint8Array(launch.aas));
}
}
}
Expand Down Expand Up @@ -387,7 +393,7 @@
'+set', 'bot_minplayers', String(launch.botCount + 1),
] : []),
'+set', 'com_basegame', assets.gameDir,
'+devmap', launch.mapName,
'+devmap', launch.runtimeMapName,
...((launch.botCount > 0 || launch.noclip || (launch.commands || []).length > 0) ? ['+wait', '30'] : []),
...botArguments,
...(launch.noclip ? ['+noclip'] : []),
Expand Down Expand Up @@ -451,6 +457,7 @@
if (event.data?.type !== 'q3edit-player:launch' || started) return;
started = true;
const mapName = String(event.data.mapName || 'compile').replace(/[^a-zA-Z0-9_-]/g, '') || 'compile';
const runtimeMapName = String(event.data.runtimeMapName || mapName).replace(/[^a-zA-Z0-9_-]/g, '') || mapName;
const bsp = event.data.bsp;
if (!(bsp instanceof ArrayBuffer)) {
fail(new Error('The compiled BSP data was not provided'));
Expand All @@ -466,10 +473,11 @@
Array.isArray(command.args) && command.args.every(argument => typeof argument === 'string')
) : [];
const packagePk3 = event.data.packagePk3 instanceof ArrayBuffer ? event.data.packagePk3 : null;
const previewPk3 = event.data.previewPk3 instanceof ArrayBuffer ? event.data.previewPk3 : null;
// 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 });
void start({ mapName, runtimeMapName, gameDirectory, bsp, aas, packagePk3, previewPk3, botCount, botSkill, noclip: event.data.noclip === true, commands });
});

window.addEventListener('error', (event) => fail(event.error ?? event.message));
Expand Down
26 changes: 26 additions & 0 deletions src/game-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { zipSync } from 'fflate';

/**
* Give loose Quick Play builds a content-addressed name so an older BSP with
* the document's filename in an enabled PK3 cannot win Quake's search lookup.
*/
export function quickPlayRuntimeMapName(mapName: string, bsp: Uint8Array): string {
let hash = 2166136261;
for (const byte of bsp) hash = Math.imul(hash ^ byte, 16777619);

const safeBase = mapName.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 38) || 'compile';
const fingerprint = (hash >>> 0).toString(16).padStart(8, '0');
return `${safeBase}_q3e_${fingerprint}`;
}

/** Package the current compile as the highest-priority archive in the preview filesystem. */
export function createQuickPlayPk3(
runtimeMapName: string,
bsp: Uint8Array,
aas: Uint8Array | null,
): Uint8Array {
return zipSync({
[`maps/${runtimeMapName}.bsp`]: bsp,
...(aas ? { [`maps/${runtimeMapName}.aas`]: aas } : {}),
}, { level: 0 });
}
18 changes: 18 additions & 0 deletions src/release-notes/2026-08-05-game-export-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
id: 2026-08-05-game-export-preview
title: August 5, 2026 — Game Export and Preview
date: 2026-08-05
order: 2
---

Packaging and testing maps now fit more naturally into both Maker projects and the editor's Quick Play workflow.

## Maker export target

- Release Package can export a game-level bundle for Maker projects.
- The export includes the compiled level and the supporting files needed by the game project.

## Reliable Quick Play updates

- Unsaved previews use a content-addressed runtime map name, preventing an older BSP in an enabled archive from taking precedence.
- Preview files are delivered as a temporary PK3 and the player page is cache-busted for each launch.
12 changes: 11 additions & 1 deletion src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { MapOrganizationController, type NavigationState } from './map-organizat
import { openMapOrganizationDialog } from './map-organization-dialog';
import { SurfaceInspector } from './surface-inspector';
import { validateProjectShaderFiles } from './q3-shader-source';
import { createQuickPlayPk3, quickPlayRuntimeMapName } from './game-preview';

export interface AssetLoadingHandle {
ready: Promise<void>;
Expand Down Expand Up @@ -2311,6 +2312,12 @@ export class UI {
this.editor.runtimeEntityMessages = [];

const safeMapName = mapName.replace(/[^a-zA-Z0-9_-]/g, '') || 'compile';
const runtimeMapName = packagePk3
? safeMapName
: quickPlayRuntimeMapName(safeMapName, bsp);
const previewPk3 = packagePk3
? null
: createQuickPlayPk3(runtimeMapName, bsp, aas);
const bspCopy = new Uint8Array(bsp.byteLength);
bspCopy.set(bsp);
const retainedBsp = new Uint8Array(bsp.byteLength);
Expand Down Expand Up @@ -2372,7 +2379,7 @@ export class UI {
const frame = document.createElement('iframe');
frame.className = 'game-preview-frame';
frame.title = `ioquake3 preview of ${safeMapName}`;
frame.src = '/ioquake3/player.html';
frame.src = `/ioquake3/player.html?v=${Date.now()}`;
frame.allow = 'autoplay; fullscreen';

const actions = document.createElement('div');
Expand Down Expand Up @@ -2403,6 +2410,7 @@ export class UI {
const launchMessage = {
type: 'q3edit-player:launch',
mapName: safeMapName,
runtimeMapName,
gameDirectory: this.gamePreviewLaunch?.gameDirectory ?? 'baseq3',
bsp: bspCopy.buffer,
aas: aasCopy?.buffer ?? null,
Expand All @@ -2411,11 +2419,13 @@ export class UI {
noclip,
commands,
packagePk3: packageCopy?.buffer ?? null,
previewPk3: previewPk3?.buffer ?? null,
};
const transfer = [
bspCopy.buffer,
...(aasCopy ? [aasCopy.buffer] : []),
...(packageCopy ? [packageCopy.buffer] : []),
...(previewPk3 ? [previewPk3.buffer] : []),
];
frame.contentWindow?.postMessage(launchMessage, window.location.origin, transfer);
} else if (message?.type === 'q3edit-player:status') {
Expand Down
32 changes: 32 additions & 0 deletions tests/game-preview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { unzipSync } from 'fflate';
import { describe, expect, it } from 'vitest';
import { createQuickPlayPk3, quickPlayRuntimeMapName } from '../src/game-preview';

describe('Quick Play runtime map names', () => {
it('uses BSP contents to avoid collisions with maps in enabled PK3 files', () => {
const first = quickPlayRuntimeMapName('test-map', new Uint8Array([1, 2, 3]));
const same = quickPlayRuntimeMapName('test-map', new Uint8Array([1, 2, 3]));
const rebuilt = quickPlayRuntimeMapName('test-map', new Uint8Array([1, 2, 4]));

expect(first).toBe(same);
expect(first).toMatch(/^test-map_q3e_[0-9a-f]{8}$/);
expect(rebuilt).not.toBe(first);
});

it('produces a valid Quake path component within MAX_QPATH', () => {
const name = quickPlayRuntimeMapName('a very/long map name!'.repeat(8), new Uint8Array([9]));

expect(name).toMatch(/^[a-zA-Z0-9_-]+$/);
expect(`maps/${name}.bsp`.length).toBeLessThan(64);
});

it('packages the current BSP and AAS under the runtime name', () => {
const bsp = new Uint8Array([1, 2, 3]);
const aas = new Uint8Array([4, 5]);
const files = unzipSync(createQuickPlayPk3('test-map_q3e_12345678', bsp, aas));

expect(files['maps/test-map_q3e_12345678.bsp']).toEqual(bsp);
expect(files['maps/test-map_q3e_12345678.aas']).toEqual(aas);
});

});
Loading