Stream Shockwave and Flash games, sized from their content

A game is streamable when its platform is allowed (STREAM_PLATFORMS,
default Java, Shockwave and Flash) and its launcher resolves under the same
rules fp-command applies. The display is sized from what it launches:
Director movies including Afterburner .dcr and pre-7 VWCF configs, SWF
headers scaled up to fit 1280x960, a page's largest embed, or a projector's
stage. Dimensions are rounded up to even for H.264.
This commit is contained in:
2026-10-13 11:10:34 -07:00
parent 046495f3c3
commit f6cb5555b6
5 changed files with 409 additions and 46 deletions
+6
View File
@@ -50,6 +50,12 @@ in secure contexts.
| `GET /stream/<id>/_fp/saves` | browser | Server-sent events carrying `{files}` whenever saves change, then `end` | | `GET /stream/<id>/_fp/saves` | browser | Server-sent events carrying `{files}` whenever saves change, then `end` |
| `/stream/<id>/*` | browser | Proxied to the session's Selkies, including WebSockets | | `/stream/<id>/*` | browser | Proxied to the session's Selkies, including WebSockets |
A game is streamable when its platform is in `STREAM_PLATFORMS` (default
`Java,Shockwave,Flash`) and the streamer can resolve its launcher. The display
is sized from the content it launches: a Director movie's or projector's stage,
a SWF's stage scaled up to fit 1280x960, a page's applet or largest embed, with
fallbacks of 800x600, or 1024x768 for pages.
Saves are `{relative path: base64 contents}` for the files under the Saves are `{relative path: base64 contents}` for the files under the
container's `/config/saves`. A session is removed if nothing connects within container's `/config/saves`. A session is removed if nothing connects within
2 minutes or 30 seconds after its last WebSocket closes. At most `MAX_SESSIONS` 2 minutes or 30 seconds after its last WebSocket closes. At most `MAX_SESSIONS`
+1
View File
@@ -13,6 +13,7 @@ services:
- FLASHPOINT_HOST_PATH - FLASHPOINT_HOST_PATH
- API_TOKEN=${API_TOKEN:?API_TOKEN is required} - API_TOKEN=${API_TOKEN:?API_TOKEN is required}
- MAX_SESSIONS=${MAX_SESSIONS:-4} - MAX_SESSIONS=${MAX_SESSIONS:-4}
- STREAM_PLATFORMS=${STREAM_PLATFORMS:-Java,Shockwave,Flash}
- PUID=${PUID:-1000} - PUID=${PUID:-1000}
- PGID=${PGID:-1000} - PGID=${PGID:-1000}
networks: networks:
+256 -20
View File
@@ -1,16 +1,21 @@
import { readFile } from 'node:fs/promises'; import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { buffer } from 'node:stream/consumers'; import { buffer } from 'node:stream/consumers';
import { constants as zlibConstants, inflateSync } from 'node:zlib';
import { DatabaseSync } from 'node:sqlite'; import { DatabaseSync } from 'node:sqlite';
import yauzl from 'yauzl'; import yauzl from 'yauzl';
/** Java launch applications fp-launch can stream, lowercased. */ /** Java launch applications fp-launch can stream, lowercased. */
const JAVA_APPS = new Set(['fpsoftware\\startjava.bat', 'fpsoftware\\startjavainbrowser.bat']); const JAVA_APPS = new Set(['fpsoftware\\startjava.bat', 'fpsoftware\\startjavainbrowser.bat']);
/** Windows games installed under LocalGames, which fp-launch runs under Wine. */
const LOCAL_EXE = /^localgames\\.+\.exe$/i;
const DEFAULT_SIZE: Size = { width: 800, height: 600 }; const DEFAULT_SIZE: Size = { width: 800, height: 600 };
const BROWSER_SIZE: Size = { width: 1024, height: 768 };
/** Flash is vector art, so it renders sharper at a larger display than its stage. */
const FLASH_MAX: Size = { width: 1280, height: 960 };
/** A page's default body margin around embedded content. */
const PAGE_MARGIN = 8;
export interface Size { export interface Size {
width: number; width: number;
@@ -18,6 +23,7 @@ export interface Size {
} }
interface LaunchRow { interface LaunchRow {
platform: string;
app: string; app: string;
cmd: string; cmd: string;
dateAdded: string | null; dateAdded: string | null;
@@ -26,10 +32,13 @@ interface LaunchRow {
/** Looks up streamable games in a Flashpoint install. */ /** Looks up streamable games in a Flashpoint install. */
export class GameCatalog { export class GameCatalog {
private readonly root: string; private readonly root: string;
private readonly platforms: Set<string>;
private readonly db: DatabaseSync; private readonly db: DatabaseSync;
constructor(root: string) { /** @param platforms Platforms to stream, such as Java, Shockwave and Flash. */
constructor(root: string, platforms: string[]) {
this.root = root; this.root = root;
this.platforms = new Set(platforms.map((p) => p.toLowerCase()));
this.db = new DatabaseSync(path.join(root, 'Data', 'flashpoint.sqlite'), { readOnly: true }); this.db = new DatabaseSync(path.join(root, 'Data', 'flashpoint.sqlite'), { readOnly: true });
} }
@@ -37,36 +46,165 @@ export class GameCatalog {
async streamSize(gameId: string): Promise<Size | null> { async streamSize(gameId: string): Promise<Size | null> {
const row = this.db const row = this.db
.prepare( .prepare(
`select coalesce(nullif(d.applicationPath, ''), g.applicationPath) as app, `select g.platformName as platform,
coalesce(nullif(d.applicationPath, ''), g.applicationPath) as app,
coalesce(nullif(d.launchCommand, ''), g.launchCommand) as cmd, coalesce(nullif(d.launchCommand, ''), g.launchCommand) as cmd,
d.dateAdded as dateAdded d.dateAdded as dateAdded
from game g left join game_data d on d.id = g.activeDataId from game g left join game_data d on d.id = g.activeDataId
where g.id = ?`, where g.id = ?`,
) )
.get(gameId) as unknown as LaunchRow | undefined; .get(gameId) as unknown as LaunchRow | undefined;
if (row && LOCAL_EXE.test(row.app)) { if (!row || !this.platforms.has((row.platform ?? '').toLowerCase())) {
const exe = await readFile(path.join(this.root, ...row.app.split('\\'))).catch(() => null);
return exe && (projectorStageSize(exe) ?? DEFAULT_SIZE);
}
if (!row || !JAVA_APPS.has(row.app.toLowerCase())) {
return null; return null;
} }
const page = await this.readLaunchPage(gameId, row).catch(() => null); if (row.platform.toLowerCase() === 'java') {
return (page && appletSize(page.toString('latin1'))) ?? DEFAULT_SIZE; if (!JAVA_APPS.has(row.app.toLowerCase())) {
return null;
}
const page = await this.readContent(gameId, row, row.cmd).catch(() => null);
return even((page && appletSize(page.toString('latin1'))) ?? DEFAULT_SIZE);
}
const program = await this.launchProgram(row);
if (!program) {
return null;
}
const url = row.cmd.match(/https?:\/\/[^\s"']+/)?.[0];
if (!url) {
const exe = await readFile(program).catch(() => null);
return even((exe && projectorStageSize(exe)) ?? DEFAULT_SIZE);
}
const content = await this.readContent(gameId, row, url).catch(() => null);
const extension = path.extname(new URL(url).pathname).toLowerCase();
if (['.dcr', '.dir', '.dxr', '.cct', '.cst'].includes(extension)) {
return even((content && directorStageSize(content)) ?? DEFAULT_SIZE);
}
if (extension === '.swf') {
return even(scaleToFit((content && swfStageSize(content)) ?? DEFAULT_SIZE, FLASH_MAX));
}
const embed = content && embedSize(content.toString('latin1'));
return even(embed ? { width: embed.width + 2 * PAGE_MARGIN, height: embed.height + 2 * PAGE_MARGIN } : BROWSER_SIZE);
} }
private async readLaunchPage(gameId: string, row: LaunchRow): Promise<Buffer | null> { /**
const url = new URL(row.cmd); * Resolves the program fp-command would run, mirroring its rules: execs.json
* mappings, .bat to .sh, an .exe replaced by a same-named script, and
* OldCPUSimulator's target. Returns null if it doesn't exist.
*/
private async launchProgram(row: LaunchRow): Promise<string | null> {
let relative = (await this.mappedApp(row.app)).replace(/\\/g, '/');
if (/oldcpusimulator\/oldcpusimulator\.exe$/i.test(relative)) {
const software = row.cmd.match(/(?:-sw|--software)\s+("[^"]+"|\S+)/)?.[1]?.replace(/"/g, '');
if (!software) {
return null;
}
relative = path.posix.join(path.posix.dirname(relative), software.replace(/\\/g, '/'));
}
if (/\.bat$/i.test(relative)) {
relative = relative.slice(0, -4) + '.sh';
}
const program = await resolveCaseInsensitive(this.root, relative);
if (program || !/\.exe$/i.test(relative)) {
return program;
}
return resolveCaseInsensitive(this.root, relative.slice(0, -4) + '.sh');
}
private async mappedApp(app: string): Promise<string> {
try {
const { execs } = JSON.parse(await readFile(path.join(this.root, 'Data', 'execs.json'), 'utf8'));
const mapping = execs.find((e: { win32: string; linux?: string }) => e.win32.toLowerCase() === app.toLowerCase());
return mapping?.linux || app;
} catch {
return app;
}
}
/** Reads a URL's file from the game's GameZIP, or from Legacy htdocs. */
private async readContent(gameId: string, row: LaunchRow, launchUrl: string): Promise<Buffer | null> {
const url = new URL(launchUrl);
const page = url.pathname.endsWith('/') ? `${url.pathname}index.html` : url.pathname; const page = url.pathname.endsWith('/') ? `${url.pathname}index.html` : url.pathname;
const file = url.hostname + decodeURIComponent(page); const file = url.hostname + decodeURIComponent(page);
if (row.dateAdded) { if (row.dateAdded) {
const zip = path.join(this.root, 'Data', 'Games', `${gameId}-${new Date(row.dateAdded).getTime()}.zip`); const zip = path.join(this.root, 'Data', 'Games', `${gameId}-${new Date(row.dateAdded).getTime()}.zip`);
return readZipEntry(zip, `content/${file}`); const entry = await readZipEntry(zip, `content/${file}`).catch(() => null);
if (entry) {
return entry;
}
} }
return readFile(path.join(this.root, 'Legacy', 'htdocs', file)); return readFile(path.join(this.root, 'Legacy', 'htdocs', file));
} }
} }
/** Finds a path under root, matching each part case-insensitively as Windows would. */
async function resolveCaseInsensitive(root: string, relative: string): Promise<string | null> {
let current = root;
for (const part of relative.split('/').filter((p) => p && p !== '.')) {
if (part === '..') {
current = path.dirname(current);
continue;
}
const names = await readdir(current).catch(() => [] as string[]);
const match = names.find((n) => n === part) ?? names.find((n) => n.toLowerCase() === part.toLowerCase());
if (!match) {
return null;
}
current = path.join(current, match);
}
return (await stat(current).catch(() => null))?.isFile() ? current : null;
}
/** H.264 needs even dimensions. */
function even({ width, height }: Size): Size {
return { width: width + (width % 2), height: height + (height % 2) };
}
function scaleToFit(size: Size, max: Size): Size {
const scale = Math.min(max.width / size.width, max.height / size.height);
return scale > 1 ? { width: Math.round(size.width * scale), height: Math.round(size.height * scale) } : size;
}
/** Reads the stage size from a SWF header: a rect of twips after the 8-byte header. */
export function swfStageSize(swf: Buffer): Size | null {
const signature = swf.toString('latin1', 0, 3);
let body: Buffer;
if (signature === 'FWS') {
body = swf.subarray(8);
} else if (signature === 'CWS') {
try {
body = inflateSync(swf.subarray(8), { finishFlush: zlibConstants.Z_SYNC_FLUSH });
} catch {
return null;
}
} else {
return null;
}
const bits = body[0] >> 3;
let value = 0n;
for (const byte of body.subarray(0, Math.ceil((5 + 4 * bits) / 8))) {
value = (value << 8n) | BigInt(byte);
}
const totalBits = BigInt(Math.ceil((5 + 4 * bits) / 8) * 8);
const field = (i: number) =>
Number((value >> (totalBits - 5n - BigInt((i + 1) * bits))) & ((1n << BigInt(bits)) - 1n));
const width = Math.round((field(1) - field(0)) / 20);
const height = Math.round((field(3) - field(2)) / 20);
return width > 0 && height > 0 ? { width, height } : null;
}
/** Reads the size of the largest embed or object on a page, ignoring percentages. */
export function embedSize(html: string): Size | null {
let best: Size | null = null;
for (const tag of html.match(/<(?:embed|object)\b[^>]*>/gi) ?? []) {
const attr = (name: string) => Number(tag.match(new RegExp(`\\b${name}\\s*=\\s*["']?(\\d+)(?![\\d%])`, 'i'))?.[1]);
const width = attr('width');
const height = attr('height');
if (width > 0 && height > 0 && (!best || width * height > best.width * best.height)) {
best = { width, height };
}
}
return best;
}
/** Reads the width and height of the first applet on a page. */ /** Reads the width and height of the first applet on a page. */
export function appletSize(html: string): Size | null { export function appletSize(html: string): Size | null {
const tag = html.match(/<applet\b[^>]*>/i)?.[0]; const tag = html.match(/<applet\b[^>]*>/i)?.[0];
@@ -81,12 +219,13 @@ export function appletSize(html: string): Size | null {
/** /**
* Reads the stage size of the movie in a Director projector from its config * Reads the stage size of the movie in a Director projector from its config
* chunk: DRCF in big-endian movies, FCRD in little-endian ones. The chunk body * chunk: DRCF (VWCF before Director 7), with the tag reversed in little-endian
* starts with its own length, the file version, then the stage rect as * movies. The chunk body starts with its own length, the file version, then the
* big-endian top, left, bottom, right. * stage rect as big-endian top, left, bottom, right.
*/ */
export function projectorStageSize(exe: Buffer): Size | null { export function projectorStageSize(exe: Buffer): Size | null {
for (const [tag, littleEndian] of [['FCRD', true], ['DRCF', false]] as const) { const tags = [['FCRD', true], ['DRCF', false], ['FCWV', true], ['VWCF', false]] as const;
for (const [tag, littleEndian] of tags) {
for (let i = exe.indexOf(tag); i >= 0 && i + 8 <= exe.length; i = exe.indexOf(tag, i + 1)) { for (let i = exe.indexOf(tag); i >= 0 && i + 8 <= exe.length; i = exe.indexOf(tag, i + 1)) {
const size = littleEndian ? exe.readUInt32LE(i + 4) : exe.readUInt32BE(i + 4); const size = littleEndian ? exe.readUInt32LE(i + 4) : exe.readUInt32BE(i + 4);
const body = i + 8; const body = i + 8;
@@ -104,6 +243,103 @@ export function projectorStageSize(exe: Buffer): Size | null {
return null; return null;
} }
/**
* Reads the stage size of a Director movie: .dir, .dxr, .cst, or a projector
* embedding one, and Afterburner-compressed Shockwave (.dcr, .cct).
*/
export function directorStageSize(file: Buffer): Size | null {
const codec = file.toString('latin1', 8, 12);
if (['FGDC', 'FGDM', 'CDGF', 'MDGF'].includes(codec)) {
try {
return afterburnerStageSize(file);
} catch {
return null;
}
}
return projectorStageSize(file);
}
/** Afterburner layout, after ProjectorRays' DirectorFile::readAfterburnerMap. */
function afterburnerStageSize(file: Buffer): Size | null {
const littleEndian = file.toString('latin1', 0, 4) === 'XFIR';
let pos = 12;
const varInt = (buf: Buffer, at: { pos: number }) => {
let value = 0;
let byte;
do {
byte = buf[at.pos++];
value = value * 128 + (byte & 0x7f);
} while (byte & 0x80);
return value;
};
const tag = (buf: Buffer, at: number) => {
const text = buf.toString('latin1', at, at + 4);
return littleEndian ? [...text].reverse().join('') : text;
};
const section = (name: string) => {
if (tag(file, pos) !== name) {
throw new Error(`expected ${name}`);
}
const at = { pos: pos + 4 };
const length = varInt(file, at);
return { start: at.pos, length };
};
for (const name of ['Fver', 'Fcdr']) {
const { start, length } = section(name);
pos = start + length;
}
const abmp = section('ABMP');
const abmpAt = { pos: abmp.start };
varInt(file, abmpAt); // compression type
varInt(file, abmpAt); // uncompressed length
const map = inflateSync(file.subarray(abmpAt.pos, abmp.start + abmp.length));
const at = { pos: 0 };
varInt(map, at);
varInt(map, at);
const resources = new Map<number, { offset: number; length: number; tag: string }>();
for (let count = varInt(map, at); count > 0; count--) {
const id = varInt(map, at);
const offset = varInt(map, at);
const length = varInt(map, at);
varInt(map, at); // uncompressed length
varInt(map, at); // compression type
resources.set(id, { offset, length, tag: tag(map, at.pos) });
at.pos += 4;
}
pos = abmp.start + abmp.length;
if (tag(file, pos) !== 'FGEI') {
throw new Error('expected FGEI');
}
const ilsAt = { pos: pos + 4 };
varInt(file, ilsAt);
const ilsBody = ilsAt.pos;
const configId = [...resources].find(([, r]) => r.tag === 'DRCF' || r.tag === 'VWCF')?.[0];
if (configId === undefined) {
return null;
}
let config: Buffer | undefined;
const ils = inflateSync(file.subarray(ilsBody, ilsBody + resources.get(2)!.length));
for (const ilsAtPos = { pos: 0 }; ilsAtPos.pos < ils.length && !config; ) {
const id = varInt(ils, ilsAtPos);
const length = resources.get(id)?.length ?? 0;
if (id === configId) {
config = ils.subarray(ilsAtPos.pos, ilsAtPos.pos + length);
}
ilsAtPos.pos += length;
}
const { offset, length } = resources.get(configId)!;
config ??= file.subarray(ilsBody + offset, ilsBody + offset + length);
try {
config = inflateSync(config);
} catch {
// Stored uncompressed
}
const [top, left, bottom, right] = [4, 6, 8, 10].map((o) => config.readInt16BE(o));
return right > left && bottom > top ? { width: right - left, height: bottom - top } : null;
}
/** Reads one entry from a zip, matching its name case-insensitively. */ /** Reads one entry from a zip, matching its name case-insensitively. */
function readZipEntry(zipPath: string, name: string): Promise<Buffer | null> { function readZipEntry(zipPath: string, name: string): Promise<Buffer | null> {
const wanted = name.toLowerCase(); const wanted = name.toLowerCase();
+4 -1
View File
@@ -18,7 +18,10 @@ const driver = new DockerDriver({
uid: Number(env('PUID', '1000')), uid: Number(env('PUID', '1000')),
gid: Number(env('PGID', '1000')), gid: Number(env('PGID', '1000')),
}); });
const catalog = new GameCatalog(env('FLASHPOINT_PATH', '/flashpoint')); const catalog = new GameCatalog(
env('FLASHPOINT_PATH', '/flashpoint'),
env('STREAM_PLATFORMS', 'Java,Shockwave,Flash').split(','),
);
const sessions = new SessionManager(driver, catalog, Number(env('MAX_SESSIONS', '4'))); const sessions = new SessionManager(driver, catalog, Number(env('MAX_SESSIONS', '4')));
await driver.removeAll(); await driver.removeAll();
+142 -25
View File
@@ -5,21 +5,81 @@ import path from 'node:path';
import { finished } from 'node:stream/promises'; import { finished } from 'node:stream/promises';
import { test } from 'node:test'; import { test } from 'node:test';
import { DatabaseSync } from 'node:sqlite'; import { DatabaseSync } from 'node:sqlite';
import { deflateSync } from 'node:zlib';
import yazl from 'yazl'; import yazl from 'yazl';
import { appletSize, GameCatalog, projectorStageSize } from '../src/games.ts'; import {
appletSize,
directorStageSize,
embedSize,
GameCatalog,
projectorStageSize,
swfStageSize,
} from '../src/games.ts';
/** A fake projector holding a Director config chunk with the given stage rect. */ /** A Director config chunk body with the given stage rect. */
function projector(tag: 'FCRD' | 'DRCF', rect: [number, number, number, number]): Buffer { function config(rect: [number, number, number, number]): Buffer {
const body = Buffer.alloc(84); const body = Buffer.alloc(84);
body.writeUInt16BE(84, 0); body.writeUInt16BE(84, 0);
body.writeUInt16BE(0x163c, 2); body.writeUInt16BE(0x163c, 2);
rect.forEach((value, i) => body.writeInt16BE(value, 4 + i * 2)); rect.forEach((value, i) => body.writeInt16BE(value, 4 + i * 2));
return body;
}
/** A fake projector holding a Director config chunk with the given stage rect. */
function projector(tag: 'FCRD' | 'DRCF' | 'VWCF', rect: [number, number, number, number]): Buffer {
const header = Buffer.alloc(8); const header = Buffer.alloc(8);
header.write(tag, 0, 'latin1'); header.write(tag, 0, 'latin1');
if (tag === 'FCRD') header.writeUInt32LE(84, 4); if (tag === 'FCRD') header.writeUInt32LE(84, 4);
else header.writeUInt32BE(84, 4); else header.writeUInt32BE(84, 4);
// A stray tag with a nonsense size must be skipped // A stray tag with a nonsense size must be skipped
return Buffer.concat([Buffer.from('MZ junk FCRD\xff\xff\xff\xff junk', 'latin1'), header, body]); return Buffer.concat([Buffer.from('MZ junk FCRD\xff\xff\xff\xff junk', 'latin1'), header, config(rect)]);
}
function varInt(value: number): Buffer {
const bytes = [value & 0x7f];
for (value = Math.floor(value / 128); value > 0; value = Math.floor(value / 128)) {
bytes.unshift((value & 0x7f) | 0x80);
}
return Buffer.from(bytes);
}
/** A big-endian Afterburner (.dcr) movie whose initial load segment holds a zlib-compressed config. */
function afterburner(configTag: 'DRCF' | 'VWCF', rect: [number, number, number, number]): Buffer {
const compressed = deflateSync(config(rect));
const resource = (id: number, length: number, tag: string) =>
Buffer.concat([varInt(id), varInt(0xffffffff), varInt(length), varInt(length), varInt(0), Buffer.from(tag, 'latin1')]);
const ils = Buffer.concat([varInt(3), compressed]);
const ilsCompressed = deflateSync(ils);
const map = deflateSync(
Buffer.concat([
varInt(0),
varInt(0),
varInt(2),
resource(2, ilsCompressed.length, 'ILS '),
resource(3, compressed.length, configTag),
]),
);
const section = (tag: string, body: Buffer) => Buffer.concat([Buffer.from(tag, 'latin1'), varInt(body.length), body]);
return Buffer.concat([
Buffer.from('RIFX\0\0\0\0FGDM', 'latin1'),
section('Fver', varInt(0x400)),
section('Fcdr', deflateSync(Buffer.alloc(2))),
section('ABMP', Buffer.concat([varInt(0), varInt(0), map])),
Buffer.from('FGEI', 'latin1'),
varInt(0),
ilsCompressed,
]);
}
/** A SWF header with the given stage size, uncompressed or zlib-compressed. */
function swf(signature: 'FWS' | 'CWS', width: number, height: number): Buffer {
const bits = 16;
const fields = [0, width * 20, 0, height * 20].map((v) => v.toString(2).padStart(bits, '0')).join('');
const rectBits = (bits.toString(2).padStart(5, '0') + fields).padEnd(Math.ceil((5 + 4 * bits) / 8) * 8, '0');
const rect = Buffer.from(rectBits.match(/.{8}/g)!.map((b) => parseInt(b, 2)));
const body = Buffer.concat([rect, Buffer.alloc(32)]);
const header = Buffer.from(`${signature}\x0a\0\0\0\0`, 'latin1');
return Buffer.concat([header, signature === 'CWS' ? deflateSync(body) : body]);
} }
test('appletSize reads the first applet tag', () => { test('appletSize reads the first applet tag', () => {
@@ -29,46 +89,103 @@ test('appletSize reads the first applet tag', () => {
assert.equal(appletSize('<embed src="game.swf" width="550" height="400">'), null); assert.equal(appletSize('<embed src="game.swf" width="550" height="400">'), null);
}); });
test('embedSize reads the largest embed or object', () => {
assert.deepEqual(
embedSize('<object width="640" height="480"><embed src="a.dcr" width=640 height=480></object><embed width=10 height=10>'),
{ width: 640, height: 480 },
);
assert.equal(embedSize('<embed src="a.swf" width="100%" height="100%">'), null);
});
test('projectorStageSize reads the stage rect from either byte order', () => { test('projectorStageSize reads the stage rect from either byte order', () => {
assert.deepEqual(projectorStageSize(projector('FCRD', [108, 112, 708, 912])), { width: 800, height: 600 }); assert.deepEqual(projectorStageSize(projector('FCRD', [108, 112, 708, 912])), { width: 800, height: 600 });
assert.deepEqual(projectorStageSize(projector('DRCF', [0, 0, 480, 640])), { width: 640, height: 480 }); assert.deepEqual(projectorStageSize(projector('DRCF', [0, 0, 480, 640])), { width: 640, height: 480 });
assert.deepEqual(projectorStageSize(projector('VWCF', [0, 0, 300, 400])), { width: 400, height: 300 });
assert.equal(projectorStageSize(Buffer.from('MZ no config here')), null); assert.equal(projectorStageSize(Buffer.from('MZ no config here')), null);
assert.equal(projectorStageSize(Buffer.from('MZ truncated FCRD\x54')), null); assert.equal(projectorStageSize(Buffer.from('MZ truncated FCRD\x54')), null);
}); });
test('GameCatalog sizes streamable games from their GameZIP or htdocs', async () => { test('directorStageSize reads Afterburner-compressed Shockwave movies', () => {
assert.deepEqual(directorStageSize(afterburner('DRCF', [0, 0, 425, 500])), { width: 500, height: 425 });
assert.deepEqual(directorStageSize(afterburner('VWCF', [0, 0, 300, 400])), { width: 400, height: 300 });
assert.equal(directorStageSize(Buffer.from('RIFX\0\0\0\0FGDMgarbage', 'latin1')), null);
});
test('swfStageSize reads uncompressed and zlib-compressed headers', () => {
assert.deepEqual(swfStageSize(swf('FWS', 500, 375)), { width: 500, height: 375 });
assert.deepEqual(swfStageSize(swf('CWS', 550, 400)), { width: 550, height: 400 });
assert.equal(swfStageSize(Buffer.from('ZWS lzma')), null);
});
test('GameCatalog streams allowed platforms whose launcher resolves, sized from their content', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'fp-')); const root = mkdtempSync(path.join(tmpdir(), 'fp-'));
mkdirSync(path.join(root, 'Data', 'Games'), { recursive: true }); const file = (relative: string, data: string | Buffer = '') => {
mkdirSync(path.join(root, 'Legacy', 'htdocs', 'example.com', 'my game'), { recursive: true }); mkdirSync(path.dirname(path.join(root, relative)), { recursive: true });
mkdirSync(path.join(root, 'LocalGames', 'aus'), { recursive: true }); writeFileSync(path.join(root, relative), data);
writeFileSync(path.join(root, 'LocalGames', 'aus', 'Game.exe'), projector('FCRD', [108, 112, 708, 912])); };
writeFileSync(path.join(root, 'Legacy', 'htdocs', 'example.com', 'my game', 'index.html'), '<applet width=300 height=200>'); file('Data/execs.json', JSON.stringify({ execs: [{ win32: 'FPSoftware\\Flash\\flashplayer_32_sa.exe', linux: 'FPSoftware/Flash/flash32.sh' }] }));
file('FPSoftware/Flash/flash32.sh');
file('FPSoftware/Shockwave/PJ101/SPR.exe');
file('FPSoftware/fpnavigator-portable/FPNavigator.exe');
file('FPSoftware/OldCPUSimulator/OldCPUSimulator.exe');
file('FPSoftware/FlashpointSecurePlayer.sh');
file('FPSoftware/startUnity.sh');
file('LocalGames/aus/Game.exe', projector('FCRD', [108, 112, 708, 912]));
file('Legacy/htdocs/example.com/my game/index.html', '<applet width=300 height=200>');
file('Legacy/htdocs/example.com/page.html', '<embed src="game.dcr" width="512" height="384">');
file('Legacy/htdocs/example.com/game.dcr', afterburner('DRCF', [0, 0, 425, 500]));
file('Legacy/htdocs/example.com/game.swf', swf('CWS', 500, 375));
file('Legacy/htdocs/example.com/movie.dir', projector('DRCF', [0, 0, 480, 640]));
const zip = new yazl.ZipFile(); const zip = new yazl.ZipFile();
zip.addBuffer(Buffer.from('<applet code="x" width="472" height="354">'), 'content/www.popcap.com/Insaniquarium/Java/index.html'); zip.addBuffer(Buffer.from('<applet code="x" width="472" height="354">'), 'content/www.popcap.com/Insaniquarium/Java/index.html');
zip.end(); zip.end();
mkdirSync(path.join(root, 'Data', 'Games'), { recursive: true });
await finished(zip.outputStream.pipe(createWriteStream(path.join(root, 'Data', 'Games', 'zip-1690495544502.zip')))); await finished(zip.outputStream.pipe(createWriteStream(path.join(root, 'Data', 'Games', 'zip-1690495544502.zip'))));
const db = new DatabaseSync(path.join(root, 'Data', 'flashpoint.sqlite')); const db = new DatabaseSync(path.join(root, 'Data', 'flashpoint.sqlite'));
db.exec(` db.exec(`
create table game (id, applicationPath, launchCommand, activeDataId); create table game (id, platformName, applicationPath, launchCommand, activeDataId);
create table game_data (id integer primary key, applicationPath, launchCommand, dateAdded); create table game_data (id integer primary key, applicationPath, launchCommand, dateAdded);
insert into game values ('zip', 'FPSoftware\\startJavaInBrowser.bat', 'http://www.popcap.com/Insaniquarium/Java/INDEX.html', 1); insert into game values ('zip', 'Java', 'FPSoftware\\startJavaInBrowser.bat', 'http://www.popcap.com/Insaniquarium/Java/INDEX.html', 1);
insert into game_data values (1, '', '', '2023-07-27T22:05:44.502567Z'); insert into game_data values (1, '', '', '2023-07-27T22:05:44.502567Z');
insert into game values ('legacy', 'FPSoftware\\startJava.bat', 'http://example.com/my%20game/', null); insert into game values ('legacy', 'Java', 'FPSoftware\\startJava.bat', 'http://example.com/my%20game/', null);
insert into game values ('missing', 'FPSoftware\\startJava.bat', 'http://example.com/none.html', null); insert into game values ('missing', 'Java', 'FPSoftware\\startJava.bat', 'http://example.com/none.html', null);
insert into game values ('flash', 'FPSoftware\\Flash\\flashplayer_32_sa.exe', 'http://example.com/a.swf', null); insert into game values ('webstart', 'Java', 'FPSoftware\\startJavaWebStart.bat', 'http://example.com/a.jnlp', null);
insert into game values ('exe', 'LocalGames\\aus\\Game.exe', '', null); insert into game values ('spr', 'Shockwave', 'FPSoftware\\Shockwave\\PJ101\\SPR.exe', 'http://example.com/game.dcr --setTheRunMode "Plugin"', null);
insert into game values ('missing-exe', 'LocalGames\\gone\\Game.exe', '', null); insert into game values ('oldcpu', 'Shockwave', 'FPSoftware\\OldCPUSimulator\\OldCPUSimulator.exe', '-t 366 -sw ..\\Shockwave\\PJ101\\SPR.exe http://example.com/game.dcr', null);
insert into game values ('secure', 'Shockwave', 'FPSoftware\\FlashpointSecurePlayer.exe', 'shockwave256color "http://example.com/movie.dir"', null);
insert into game values ('navigator', 'Shockwave', 'FPSoftware\\fpnavigator-portable\\fpnavigator.exe', 'http://example.com/page.html', null);
insert into game values ('flash', 'Flash', 'FPSoftware\\Flash\\flashplayer_32_sa.exe', 'http://example.com/game.swf', null);
insert into game values ('unity', 'Unity', 'FPSoftware\\startUnity.bat', 'http://example.com/a.unity3d', null);
insert into game values ('no-program', 'Shockwave', 'FPSoftware\\Shockwave\\PJ9\\SPR.exe', 'http://example.com/game.dcr', null);
insert into game values ('exe', 'Shockwave', 'LocalGames\\aus\\Game.exe', '', null);
insert into game values ('missing-exe', 'Shockwave', 'LocalGames\\gone\\Game.exe', '', null);
`); `);
db.close(); db.close();
const catalog = new GameCatalog(root); const catalog = new GameCatalog(root, ['Java', 'Shockwave', 'Flash']);
assert.deepEqual(await catalog.streamSize('zip'), { width: 472, height: 354 }); const sizes = Object.fromEntries(
assert.deepEqual(await catalog.streamSize('legacy'), { width: 300, height: 200 }); await Promise.all(
assert.deepEqual(await catalog.streamSize('missing'), { width: 800, height: 600 }); ['zip', 'legacy', 'missing', 'webstart', 'spr', 'oldcpu', 'secure', 'navigator', 'flash', 'unity', 'no-program', 'exe', 'missing-exe', 'unknown'].map(
assert.equal(await catalog.streamSize('flash'), null); async (id) => [id, await catalog.streamSize(id)],
assert.deepEqual(await catalog.streamSize('exe'), { width: 800, height: 600 }); ),
assert.equal(await catalog.streamSize('missing-exe'), null); ),
assert.equal(await catalog.streamSize('unknown'), null); );
assert.deepEqual(sizes, {
zip: { width: 472, height: 354 },
legacy: { width: 300, height: 200 },
missing: { width: 800, height: 600 },
webstart: null,
spr: { width: 500, height: 426 },
oldcpu: { width: 500, height: 426 },
secure: { width: 640, height: 480 },
navigator: { width: 528, height: 400 },
flash: { width: 1280, height: 960 },
unity: null,
'no-program': null,
exe: { width: 800, height: 600 },
'missing-exe': null,
unknown: null,
});
}); });