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:
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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/*
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user