diff --git a/streaming/session-manager/src/games.ts b/streaming/session-manager/src/games.ts index 0ac8036..48736c7 100644 --- a/streaming/session-manager/src/games.ts +++ b/streaming/session-manager/src/games.ts @@ -4,8 +4,11 @@ import { buffer } from 'node:stream/consumers'; import { DatabaseSync } from 'node:sqlite'; import yauzl from 'yauzl'; -/** Launch applications fp-launch can stream, lowercased. */ -const STREAMABLE_APPS = new Set(['fpsoftware\\startjava.bat', 'fpsoftware\\startjavainbrowser.bat']); +/** 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 }; @@ -41,7 +44,11 @@ export class GameCatalog { where g.id = ?`, ) .get(gameId) as unknown as LaunchRow | undefined; - if (!row || !STREAMABLE_APPS.has(row.app.toLowerCase())) { + 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())) { return null; } const page = await this.readLaunchPage(gameId, row).catch(() => null); @@ -72,6 +79,31 @@ export function appletSize(html: string): Size | null { return width > 0 && height > 0 ? { width, height } : 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. + */ +export function projectorStageSize(exe: Buffer): Size | null { + for (const [tag, littleEndian] of [['FCRD', true], ['DRCF', false]] as const) { + 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; + if (size < 12 || body + size > exe.length || exe.readUInt16BE(body) !== size) { + continue; + } + const [top, left, bottom, right] = [4, 6, 8, 10].map((offset) => exe.readInt16BE(body + offset)); + const width = right - left; + const height = bottom - top; + if (width > 0 && height > 0 && width <= 4096 && height <= 4096) { + return { width, height }; + } + } + } + return null; +} + /** Reads one entry from a zip, matching its name case-insensitively. */ function readZipEntry(zipPath: string, name: string): Promise { const wanted = name.toLowerCase(); diff --git a/streaming/session-manager/test/games.test.ts b/streaming/session-manager/test/games.test.ts index 139c457..0d8b518 100644 --- a/streaming/session-manager/test/games.test.ts +++ b/streaming/session-manager/test/games.test.ts @@ -6,7 +6,21 @@ import { finished } from 'node:stream/promises'; import { test } from 'node:test'; import { DatabaseSync } from 'node:sqlite'; import yazl from 'yazl'; -import { appletSize, GameCatalog } from '../src/games.ts'; +import { appletSize, GameCatalog, projectorStageSize } 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 { + const body = Buffer.alloc(84); + body.writeUInt16BE(84, 0); + body.writeUInt16BE(0x163c, 2); + rect.forEach((value, i) => body.writeInt16BE(value, 4 + i * 2)); + 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]); +} test('appletSize reads the first applet tag', () => { assert.deepEqual(appletSize(''), { width: 472, height: 354 }); @@ -15,10 +29,19 @@ test('appletSize reads the first applet tag', () => { assert.equal(appletSize(''), 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.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 () => { 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'), ''); const zip = new yazl.ZipFile(); @@ -35,6 +58,8 @@ test('GameCatalog sizes streamable games from their GameZIP or htdocs', async () 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); `); db.close(); @@ -43,5 +68,7 @@ test('GameCatalog sizes streamable games from their GameZIP or htdocs', async () 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); }); diff --git a/streaming/streamer/Dockerfile b/streaming/streamer/Dockerfile index 9bc8d81..9edcedf 100644 --- a/streaming/streamer/Dockerfile +++ b/streaming/streamer/Dockerfile @@ -11,9 +11,21 @@ RUN apt-get update && \ fontconfig \ fonts-dejavu-core \ libasound2-plugins \ + libfontconfig1 \ + libfreetype6 \ + libgl1 \ + libpulse0 \ + libx11-6 \ + libxcomposite1 \ + libxcursor1 \ + libxext6 \ + libxfixes3 \ libxi6 \ + libxinerama1 \ + libxrandr2 \ libxrender1 \ libxtst6 \ + libxxf86vm1 \ sqlite3 && \ rm -rf /var/lib/apt/lists/* diff --git a/streaming/streamer/README.md b/streaming/streamer/README.md index 556078c..dcdc970 100644 --- a/streaming/streamer/README.md +++ b/streaming/streamer/README.md @@ -2,13 +2,15 @@ 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`. Only Java -applets are supported so far. +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)). ## Requirements A Linux Flashpoint install at `FLASHPOINT_HOST_PATH`. The streamer uses the -Infinity build's `Server/FlashpointGameServer` and `FPSoftware/Java`, which +Infinity build's `Server/FlashpointGameServer`, `FPSoftware/Java` and +`FPSoftware/Wine`, which Ultimate's packed zips do not include on Linux. To add them to an extracted Ultimate without overwriting its data, copy an extracted [Infinity for Linux](https://flashpointarchive.org/downloads) over it: @@ -37,6 +39,11 @@ 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 +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. + +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. diff --git a/streaming/streamer/root/usr/local/bin/fp-launch b/streaming/streamer/root/usr/local/bin/fp-launch index 2a77ab9..c05e90f 100755 --- a/streaming/streamer/root/usr/local/bin/fp-launch +++ b/streaming/streamer/root/usr/local/bin/fp-launch @@ -38,6 +38,24 @@ 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 ;; esac