Merge pull request 'Stream Windows games under Wine, starting with Australia Wildlife Rescue' (#4) from wine-games into local-games

Reviewed-on: branderson/flashpoint-ultimate-server#4
Reviewed-by: branderson <brad@branderson.io>
This commit is contained in:
2026-10-11 17:26:12 -07:00
5 changed files with 105 additions and 9 deletions
+35 -3
View File
@@ -4,8 +4,11 @@ import { buffer } from 'node:stream/consumers';
import { DatabaseSync } from 'node:sqlite'; import { DatabaseSync } from 'node:sqlite';
import yauzl from 'yauzl'; import yauzl from 'yauzl';
/** Launch applications fp-launch can stream, lowercased. */ /** Java launch applications fp-launch can stream, lowercased. */
const STREAMABLE_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 };
@@ -41,7 +44,11 @@ export class GameCatalog {
where g.id = ?`, where g.id = ?`,
) )
.get(gameId) as unknown as LaunchRow | undefined; .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; return null;
} }
const page = await this.readLaunchPage(gameId, row).catch(() => 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; 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. */ /** 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();
+28 -1
View File
@@ -6,7 +6,21 @@ 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 yazl from 'yazl'; 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', () => { test('appletSize reads the first applet tag', () => {
assert.deepEqual(appletSize('<APPLET code="a.class" WIDTH=472 height="354" mayscript>'), { width: 472, height: 354 }); assert.deepEqual(appletSize('<APPLET code="a.class" WIDTH=472 height="354" mayscript>'), { width: 472, height: 354 });
@@ -15,10 +29,19 @@ 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('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 () => { test('GameCatalog sizes streamable games from their GameZIP or htdocs', async () => {
const root = mkdtempSync(path.join(tmpdir(), 'fp-')); const root = mkdtempSync(path.join(tmpdir(), 'fp-'));
mkdirSync(path.join(root, 'Data', 'Games'), { recursive: true }); mkdirSync(path.join(root, 'Data', 'Games'), { recursive: true });
mkdirSync(path.join(root, 'Legacy', 'htdocs', 'example.com', 'my game'), { 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>'); writeFileSync(path.join(root, 'Legacy', 'htdocs', 'example.com', 'my game', 'index.html'), '<applet width=300 height=200>');
const zip = new yazl.ZipFile(); 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 ('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 ('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 ('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(); 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('legacy'), { width: 300, height: 200 });
assert.deepEqual(await catalog.streamSize('missing'), { width: 800, height: 600 }); assert.deepEqual(await catalog.streamSize('missing'), { width: 800, height: 600 });
assert.equal(await catalog.streamSize('flash'), null); 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); assert.equal(await catalog.streamSize('unknown'), null);
}); });
+12
View File
@@ -11,9 +11,21 @@ RUN apt-get update && \
fontconfig \ fontconfig \
fonts-dejavu-core \ fonts-dejavu-core \
libasound2-plugins \ libasound2-plugins \
libfontconfig1 \
libfreetype6 \
libgl1 \
libpulse0 \
libx11-6 \
libxcomposite1 \
libxcursor1 \
libxext6 \
libxfixes3 \
libxi6 \ libxi6 \
libxinerama1 \
libxrandr2 \
libxrender1 \ libxrender1 \
libxtst6 \ libxtst6 \
libxxf86vm1 \
sqlite3 && \ sqlite3 && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
+12 -5
View File
@@ -2,13 +2,15 @@
Runs a Flashpoint game natively on the server and streams it to the browser with 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 [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 run in a browser. One container plays one game, set by `GAME_ID`. It supports
applets are supported so far. Java applets, and Windows games installed under `LocalGames/` (see
[local-games](../local-games/README.md)).
## Requirements ## Requirements
A Linux Flashpoint install at `FLASHPOINT_HOST_PATH`. The streamer uses the 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's packed zips do not include on Linux. To add them to an extracted
Ultimate without overwriting its data, copy an extracted Ultimate without overwriting its data, copy an extracted
[Infinity for Linux](https://flashpointarchive.org/downloads) over it: [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 `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 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 a `document.cookie` that persists to `/config/saves/cookies.txt`, and a policy
game server's proxy. The same jar (`applet-support/`) is loaded as a Java agent 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. 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.
@@ -38,6 +38,24 @@ case ${app,,} in
# and a window holding only the applet # and a window holding only the applet
export JAVA_TOOL_OPTIONS="-javaagent:/opt/flashpoint/applet-support.jar -Djava.security.policy=/opt/flashpoint/applet.policy" 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" ;; 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 ;; echo "Unsupported application: $app" >&2; exit 1 ;;
esac esac