Merge pull request 'Restore Flash and Shockwave streaming on main' (#7) from flash-shockwave-streaming into main

Reviewed-on: branderson/flashpoint-ultimate-server#7
Reviewed-by: branderson <brad@branderson.io>
This commit is contained in:
2026-10-13 11:11:31 -07:00
9 changed files with 596 additions and 75 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` |
| `/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, 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
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`
+1
View File
@@ -13,6 +13,7 @@ services:
- FLASHPOINT_HOST_PATH
- API_TOKEN=${API_TOKEN:?API_TOKEN is required}
- MAX_SESSIONS=${MAX_SESSIONS:-4}
- STREAM_PLATFORMS=${STREAM_PLATFORMS:-Java,Shockwave,Flash}
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
networks:
+249 -20
View File
@@ -1,16 +1,18 @@
import { readFile } from 'node:fs/promises';
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { buffer } from 'node:stream/consumers';
import { constants as zlibConstants, inflateSync } from 'node:zlib';
import { DatabaseSync } from 'node:sqlite';
import yauzl from 'yauzl';
/** Java launch applications fp-launch can stream, lowercased. */
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 BROWSER_SIZE: Size = { width: 1024, height: 768 };
/** A page's default body margin around embedded content. */
const PAGE_MARGIN = 8;
export interface Size {
width: number;
@@ -18,6 +20,7 @@ export interface Size {
}
interface LaunchRow {
platform: string;
app: string;
cmd: string;
dateAdded: string | null;
@@ -26,10 +29,13 @@ interface LaunchRow {
/** Looks up streamable games in a Flashpoint install. */
export class GameCatalog {
private readonly root: string;
private readonly platforms: Set<string>;
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.platforms = new Set(platforms.map((p) => p.toLowerCase()));
this.db = new DatabaseSync(path.join(root, 'Data', 'flashpoint.sqlite'), { readOnly: true });
}
@@ -37,36 +43,161 @@ export class GameCatalog {
async streamSize(gameId: string): Promise<Size | null> {
const row = this.db
.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,
d.dateAdded as dateAdded
from game g left join game_data d on d.id = g.activeDataId
where g.id = ?`,
)
.get(gameId) as unknown as LaunchRow | undefined;
if (row && LOCAL_EXE.test(row.app)) {
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())) {
if (!row || !this.platforms.has((row.platform ?? '').toLowerCase())) {
return null;
}
const page = await this.readLaunchPage(gameId, row).catch(() => null);
return (page && appletSize(page.toString('latin1'))) ?? DEFAULT_SIZE;
if (row.platform.toLowerCase() === 'java') {
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') {
// The exact stage: in full screen, games that set noScale keep their native size
return even((content && swfStageSize(content)) ?? DEFAULT_SIZE);
}
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 file = url.hostname + decodeURIComponent(page);
if (row.dateAdded) {
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));
}
}
/** 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) };
}
/** 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. */
export function appletSize(html: string): Size | null {
const tag = html.match(/<applet\b[^>]*>/i)?.[0];
@@ -81,12 +212,13 @@ export function appletSize(html: string): Size | null {
/**
* 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
* starts with its own length, the file version, then the stage rect as
* big-endian top, left, bottom, right.
* chunk: DRCF (VWCF before Director 7), with the tag reversed in little-endian
* movies. The chunk body starts with its own length, the file version, then the
* stage rect as big-endian top, left, bottom, right.
*/
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)) {
const size = littleEndian ? exe.readUInt32LE(i + 4) : exe.readUInt32BE(i + 4);
const body = i + 8;
@@ -104,6 +236,103 @@ export function projectorStageSize(exe: Buffer): Size | 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. */
function readZipEntry(zipPath: string, name: string): Promise<Buffer | null> {
const wanted = name.toLowerCase();
+4 -1
View File
@@ -18,7 +18,10 @@ const driver = new DockerDriver({
uid: Number(env('PUID', '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')));
await driver.removeAll();
+142 -25
View File
@@ -5,21 +5,81 @@ import path from 'node:path';
import { finished } from 'node:stream/promises';
import { test } from 'node:test';
import { DatabaseSync } from 'node:sqlite';
import { deflateSync } from 'node:zlib';
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. */
function projector(tag: 'FCRD' | 'DRCF', rect: [number, number, number, number]): Buffer {
/** A Director config chunk body with the given stage rect. */
function config(rect: [number, number, number, number]): Buffer {
const body = Buffer.alloc(84);
body.writeUInt16BE(84, 0);
body.writeUInt16BE(0x163c, 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);
header.write(tag, 0, 'latin1');
if (tag === 'FCRD') header.writeUInt32LE(84, 4);
else header.writeUInt32BE(84, 4);
// 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', () => {
@@ -29,46 +89,103 @@ test('appletSize reads the first applet tag', () => {
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', () => {
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('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 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-'));
mkdirSync(path.join(root, 'Data', 'Games'), { recursive: true });
mkdirSync(path.join(root, 'Legacy', 'htdocs', 'example.com', 'my game'), { recursive: true });
mkdirSync(path.join(root, 'LocalGames', 'aus'), { recursive: true });
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>');
const file = (relative: string, data: string | Buffer = '') => {
mkdirSync(path.dirname(path.join(root, relative)), { recursive: true });
writeFileSync(path.join(root, relative), data);
};
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();
zip.addBuffer(Buffer.from('<applet code="x" width="472" height="354">'), 'content/www.popcap.com/Insaniquarium/Java/index.html');
zip.end();
mkdirSync(path.join(root, 'Data', 'Games'), { recursive: true });
await finished(zip.outputStream.pipe(createWriteStream(path.join(root, 'Data', 'Games', 'zip-1690495544502.zip'))));
const db = new DatabaseSync(path.join(root, 'Data', 'flashpoint.sqlite'));
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);
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 values ('legacy', '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 ('flash', 'FPSoftware\\Flash\\flashplayer_32_sa.exe', 'http://example.com/a.swf', null);
insert into game values ('exe', 'LocalGames\\aus\\Game.exe', '', null);
insert into game values ('missing-exe', 'LocalGames\\gone\\Game.exe', '', null);
insert into game values ('legacy', 'Java', 'FPSoftware\\startJava.bat', 'http://example.com/my%20game/', null);
insert into game values ('missing', 'Java', 'FPSoftware\\startJava.bat', 'http://example.com/none.html', null);
insert into game values ('webstart', 'Java', 'FPSoftware\\startJavaWebStart.bat', 'http://example.com/a.jnlp', null);
insert into game values ('spr', 'Shockwave', 'FPSoftware\\Shockwave\\PJ101\\SPR.exe', 'http://example.com/game.dcr --setTheRunMode "Plugin"', 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();
const catalog = new GameCatalog(root);
assert.deepEqual(await catalog.streamSize('zip'), { width: 472, height: 354 });
assert.deepEqual(await catalog.streamSize('legacy'), { width: 300, height: 200 });
assert.deepEqual(await catalog.streamSize('missing'), { width: 800, height: 600 });
assert.equal(await catalog.streamSize('flash'), null);
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);
const catalog = new GameCatalog(root, ['Java', 'Shockwave', 'Flash']);
const sizes = Object.fromEntries(
await Promise.all(
['zip', 'legacy', 'missing', 'webstart', 'spr', 'oldcpu', 'secure', 'navigator', 'flash', 'unity', 'no-program', 'exe', 'missing-exe', 'unknown'].map(
async (id) => [id, await catalog.streamSize(id)],
),
),
);
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: 500, height: 376 },
unity: null,
'no-program': null,
exe: { width: 800, height: 600 },
'missing-exe': null,
unknown: null,
});
});
+5 -1
View File
@@ -8,12 +8,15 @@ FROM ghcr.io/linuxserver/baseimage-selkies:debiantrixie
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
cpulimit \
fontconfig \
fonts-dejavu-core \
libasound2-plugins \
libfontconfig1 \
libfreetype6 \
libgl1 \
libgtk2.0-0t64 \
libnss3 \
libpulse0 \
libx11-6 \
libxcomposite1 \
@@ -26,7 +29,8 @@ RUN apt-get update && \
libxrender1 \
libxtst6 \
libxxf86vm1 \
sqlite3 && \
sqlite3 \
xdotool && \
rm -rf /var/lib/apt/lists/*
COPY root/ /
+17 -9
View File
@@ -2,9 +2,10 @@
Runs a Flashpoint game natively on the server and streams it to the browser with
[Selkies](https://github.com/selkies-project/selkies), for platforms that cannot
run in a browser. One container plays one game, set by `GAME_ID`. It supports
Java applets, and Windows games installed under `LocalGames/` (see
[local-games](../local-games/README.md)).
run in a browser. One container plays one game, set by `GAME_ID`. It launches
games the way Flashpoint's Linux launcher does, so Java applets, Shockwave,
Flash and Windows games installed under `LocalGames/` (see
[local-games](../local-games/README.md)) all run.
## Requirements
@@ -40,10 +41,17 @@ Selkies restarts the game when it exits (`RESTART_APP`).
`appletviewer` lacks two things applets got from the browser's Java plugin, so
the image adds them: a `netscape.javascript.JSObject` stand-in whose window has
a `document.cookie` that persists to `/config/saves/cookies.txt`, and a policy
allowing HTTP requests through the game server's proxy. The same jar (`applet-support/`) is loaded as a Java agent
that removes appletviewer's menu bar and status line.
allowing HTTP requests through the game server's proxy. The same jar
(`applet-support/`) is loaded as a Java agent that removes appletviewer's menu
bar and status line.
Windows games run under Flashpoint's bundled Wine from a writable copy of the
game's directory. Its `Prefs` directory, where Director projectors save, is
linked into `/config/saves`, so the session manager syncs it to the player's
browser. A player without a save starts from the `Prefs` the game ships with.
Other games go through `fp-command`, which applies the launcher's rules: it maps
the application path through `Data/execs.json`, swaps `.bat` for `.sh`, and runs
`.exe` files under Flashpoint's bundled Wine. Windows programs run from a
writable mirror of the install, since they write beside themselves; a
Director projector's `Prefs`, where it saves, is linked into `/config/saves`, as
is Flash Player's save directory, so the session manager syncs both to the
player's browser. OldCPUSimulator hangs under Wine, so its target runs under
`cpulimit` at the same share of the CPU. Flash projectors switch to full screen,
which hides their menu bar and scales the movie to the display, and Flashpoint
Navigator's toolbars are hidden.
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""Prints, NUL-separated, the working directory and command line the Flashpoint
Linux launcher runs for an application path and launch command.
Usage: fp-command APPLICATION_PATH LAUNCH_COMMAND (with FP_PATH set)
Follows the launcher's rules: map the path through Data/execs.json, swap .bat
for .sh, and run .exe files under Wine. An .exe the Linux build replaces with a
script of the same name, like FlashpointSecurePlayer, runs that script.
OldCPUSimulator, which hangs under Wine, is replaced by running its target
under cpulimit at the same share of the host CPU. Exits 1 if the program
doesn't exist.
With FP_WRITABLE set, an .exe runs from a mirror of FP_PATH under it: symlinks
to the read-only install, except the program's directory, which is copied
since Windows programs write beside themselves. Its Prefs directory, where
Director projectors save, is linked to ~/saves/Prefs, starting from the Prefs it
ships with. Flashpoint Navigator's profile gets a stylesheet hiding its
toolbars.
"""
import json
import os
import shlex
import shutil
import sys
fp_path = os.environ['FP_PATH']
app, cmd = sys.argv[1:3]
def mapped(app):
try:
with open(os.path.join(fp_path, 'Data', 'execs.json')) as f:
execs = json.load(f)['execs']
except OSError:
return app
for e in execs:
if e['win32'].lower() == app.lower() and e.get('linux'):
return e['linux']
return app
def resolve(relative):
"""Finds a path under FP_PATH case-insensitively, as Windows and Wine would."""
path = fp_path
for part in relative.replace('\\', '/').split('/'):
if part in ('', '.'):
continue
try:
names = os.listdir(path)
except OSError:
return None
match = next((n for n in names if n == part), None) or next((n for n in names if n.lower() == part.lower()), None)
if match is None:
return None
path = os.path.join(path, match)
return path
def split(cmd):
"""Splits a launch command the way the launcher's escaped shell command is parsed:
double quotes group, paired single quotes group, and nothing else is special."""
lexer = shlex.shlex(cmd, posix=True)
lexer.whitespace_split = True
lexer.escape = ''
lexer.escapedquotes = ''
lexer.quotes = '"\'' if cmd.count("'") % 2 == 0 else '"'
return list(lexer)
def host_mhz():
"""The CPU clock OldCPUSimulator's target rate is measured against."""
try:
with open('/proc/cpuinfo') as f:
info = f.read().splitlines()
except OSError:
info = []
mhz = [float(line.split(':')[1]) for line in info if line.startswith('cpu MHz')]
if mhz:
return max(mhz)
try:
with open('/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq') as f:
return int(f.read()) / 1000
except (OSError, ValueError):
pass
bogomips = [float(line.split(':')[1]) for line in info if line.lower().startswith('bogomips')]
return bogomips[0] / 2 if bogomips else 3000
def old_cpu_simulator(args):
"""Translates OldCPUSimulator's '-t RATE [options] -sw SOFTWARE ARGS...'."""
rate, i = None, 0
while i < len(args) and args[i] not in ('-sw', '--software'):
if args[i] in ('-t', '--target-rate', '-r', '--refresh-rate'):
if args[i] in ('-t', '--target-rate'):
rate = float(args[i + 1])
i += 1
i += 1
if rate is None or i + 1 >= len(args):
sys.exit(1)
return rate, args[i + 1], args[i + 2:]
relative = mapped(app).replace('\\', '/')
args = split(cmd)
limit = None
if relative.lower().endswith('oldcpusimulator/oldcpusimulator.exe'):
rate, software, args = old_cpu_simulator(args)
relative = os.path.normpath(os.path.join(os.path.dirname(relative), software.replace('\\', '/')))
limit = max(1, round(100 * rate / host_mhz()))
if relative.lower().endswith('.bat'):
relative = relative[:-4] + '.sh'
program = resolve(relative)
if program is None and relative.lower().endswith('.exe'):
program = resolve(relative[:-4] + '.sh')
if program is None or not os.path.isfile(program):
sys.exit(1)
def writable_copy(program, root):
"""Mirrors FP_PATH under root, copying only the program's directory."""
parts = os.path.relpath(os.path.dirname(program), fp_path).split(os.sep)
src, dst = fp_path, root
for part in parts:
os.makedirs(dst, exist_ok=True)
for name in os.listdir(src):
if name != part and not os.path.lexists(os.path.join(dst, name)):
os.symlink(os.path.join(src, name), os.path.join(dst, name))
src, dst = os.path.join(src, part), os.path.join(dst, part)
if not os.path.exists(dst):
shutil.copytree(src, dst, symlinks=True)
link_saves(os.path.join(dst, 'Prefs'), os.path.expanduser('~/saves/Prefs'))
profile = os.path.join(dst, 'User', 'flashpointnavigator', 'Profiles', 'Default')
if os.path.isdir(profile):
os.makedirs(os.path.join(profile, 'chrome'), exist_ok=True)
with open(os.path.join(profile, 'chrome', 'userChrome.css'), 'w') as f:
f.write('#toolbar-menubar, #TabsToolbar, #nav-bar, #PersonalToolbar { visibility: collapse !important; }\n')
return os.path.join(dst, os.path.basename(program))
def link_saves(path, saves):
"""Replaces path with a link to saves, which starts from path's contents."""
if os.path.islink(path):
return
os.makedirs(saves, exist_ok=True)
if os.path.isdir(path):
if not os.listdir(saves):
shutil.copytree(path, saves, dirs_exist_ok=True)
shutil.rmtree(path)
os.symlink(saves, path)
if program.lower().endswith('.exe') and os.environ.get('FP_WRITABLE'):
program = writable_copy(program, os.environ['FP_WRITABLE'])
argv = [program, *args]
if program.lower().endswith('.exe'):
argv = ['wine', 'start', '/wait', '/unix', *argv]
if limit:
argv = ['cpulimit', '--include-children', '--limit', str(limit), '--', *argv]
sys.stdout.write('\0'.join([os.path.dirname(program), *argv]) + '\0')
+13 -19
View File
@@ -38,24 +38,18 @@ case ${app,,} in
# and a window holding only the applet
export JAVA_TOOL_OPTIONS="-javaagent:/opt/flashpoint/applet-support.jar -Djava.security.policy=/opt/flashpoint/applet.policy"
FP_PATH=$FP_PATH exec "$FP_PATH/FPSoftware/startJava.sh" "$cmd" ;;
'localgames\'*.exe)
# Windows games run under Flashpoint's bundled Wine from a writable copy.
# Prefs, where Director projectors save, lives in the synced saves directory.
exe=${app//\\//}
work=$HOME/game
[[ -d $work ]] || cp -r "$FP_PATH/$(dirname "$exe")" "$work"
if [[ ! -L $work/Prefs ]]; then
mkdir -p "$HOME/saves/Prefs"
# Players without a save start from the one the game ships with
if [[ -d $work/Prefs && -z $(ls -A "$HOME/saves/Prefs") ]]; then
cp -r "$work/Prefs/." "$HOME/saves/Prefs"
fi
rm -rf "$work/Prefs"
ln -s "$HOME/saves/Prefs" "$work/Prefs"
fi
export WINEPREFIX=$HOME/.wine WINEDEBUG=-all WINEDLLOVERRIDES='mscoree,mshtml='
cd "$work"
exec "$FP_PATH/FPSoftware/Wine/bin/wine" "$(basename "$exe")" ;;
*)
echo "Unsupported application: $app" >&2; exit 1 ;;
mapfile -d '' argv < <(FP_PATH=$FP_PATH FP_WRITABLE=$HOME/flashpoint fp-command "$app" "$cmd") || true
(( ${#argv[@]} > 1 )) || { echo "Unsupported application: $app" >&2; exit 1; }
export FP_PATH HTTP_PROXY=http://127.0.0.1:22500/ http_proxy=http://127.0.0.1:22500/
export PATH=$FP_PATH/FPSoftware/Wine/bin:$PATH WINEDEBUG=-all WINEDLLOVERRIDES='mscoree,mshtml='
# Flash Player keeps saves (SharedObjects) here
mkdir -p "$HOME/saves/Flash_Player" "$HOME/.macromedia"
[[ -e $HOME/.macromedia/Flash_Player ]] || ln -s "$HOME/saves/Flash_Player" "$HOME/.macromedia/Flash_Player"
if [[ ${app,,} == 'fpsoftware\flash\'* ]]; then
# Full screen hides the projector's menu bar and scales the movie to the display
(xdotool search --sync --onlyvisible --name 'Flash Player' windowactivate --sync key ctrl+f) &
fi
cd "${argv[0]}"
exec "${argv[@]:1}" ;;
esac