feat(saves): carry Flash saves between Ruffle and the original Flash Player
Ruffle keeps SharedObjects in localStorage and the streamed Flash Player as .sol files, in the same format under different names. Starting the stream now adds the game's Ruffle saves, translated to Flash Player's paths, and stream save updates are copied back into Ruffle's storage. Root-path saves carry over only when this game made them. A game's first switch keeps the stream's copy of a save both sides already had.
This commit is contained in:
@@ -22,6 +22,24 @@ The app works it out by watching instead: whatever changes while a game is
|
||||
playing belongs to that game, and it remembers that for next time. The app's own
|
||||
storage is never included.
|
||||
|
||||
## Ruffle and the original Flash Player
|
||||
|
||||
A Flash game's saves carry over when switching between Ruffle and the original
|
||||
Flash Player (streamed). Both store SharedObjects in the same `.sol` format,
|
||||
under different names:
|
||||
|
||||
- Ruffle keeps them in localStorage, keyed by the site's host and the path it
|
||||
loaded the SWF from.
|
||||
- Flash Player keeps them as files under `Flash_Player/#SharedObjects/` in the
|
||||
streamed game's saves, keyed by the SWF's own host and path.
|
||||
|
||||
Starting the original Flash Player adds the game's Ruffle saves to the files the
|
||||
stream starts with. While it plays, its saves are copied back into Ruffle's
|
||||
storage as they change. Saves made with a `localPath` of `/` are shared by
|
||||
every game in Ruffle, so only those seen changing while this game ran are
|
||||
carried over. The first time a game switches, a save that exists in both places
|
||||
from before this keeps the streamed copy; after that the two stay in step.
|
||||
|
||||
## Sync
|
||||
|
||||
Before a game starts, the app compares the browser's save with the account's
|
||||
|
||||
@@ -336,7 +336,11 @@ function GamePlayerInternal({
|
||||
<Fragment key={saveSync.epoch}>
|
||||
{/* Other platforms - stream from the server */}
|
||||
{isStreamed && gameId ? (
|
||||
<StreamPlayer gameId={gameId} title={title} />
|
||||
<StreamPlayer
|
||||
gameId={gameId}
|
||||
title={title}
|
||||
swfUrl={flashContentType === 'swf' ? contentUrl : undefined}
|
||||
/>
|
||||
) : /* Flash SWF - use Ruffle emulator */ flashContentType === 'swf' && contentUrl ? (
|
||||
<RufflePlayer
|
||||
swfUrl={contentUrl}
|
||||
|
||||
@@ -2,12 +2,15 @@ import { useEffect, useState } from 'react';
|
||||
import { AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { gamesApi } from '@/lib/api/games';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { flashSaveContext, mirrorToRuffle, withRuffleSaves } from '@/lib/saves/flashSaves';
|
||||
import { loadSaves, storeSaves } from '@/lib/streamSaves';
|
||||
import { getErrorMessage } from '@/types/api-error';
|
||||
|
||||
export interface StreamPlayerProps {
|
||||
gameId: string;
|
||||
title: string;
|
||||
/** For a Flash game Ruffle can also play: its SWF, so saves carry between the two */
|
||||
swfUrl?: string;
|
||||
}
|
||||
|
||||
type StreamState =
|
||||
@@ -19,16 +22,18 @@ type StreamState =
|
||||
* Plays a game that can't run in a browser by streaming it from a server-side
|
||||
* container. Saves are kept in this browser and synced with the container.
|
||||
*/
|
||||
export function StreamPlayer({ gameId, title }: StreamPlayerProps) {
|
||||
export function StreamPlayer({ gameId, title, swfUrl }: StreamPlayerProps) {
|
||||
const [state, setState] = useState<StreamState>({ status: 'starting' });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let events: EventSource | undefined;
|
||||
const flash = swfUrl ? flashSaveContext(swfUrl, window.location.hostname) : null;
|
||||
|
||||
(async () => {
|
||||
const session = await gamesApi.createStreamSession(gameId);
|
||||
const files = await loadSaves(gameId);
|
||||
const stored = await loadSaves(gameId);
|
||||
const files = flash ? await withRuffleSaves(gameId, stored, flash) : stored;
|
||||
const response = await fetch(`${session.path}_fp/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -41,9 +46,11 @@ export function StreamPlayer({ gameId, title }: StreamPlayerProps) {
|
||||
|
||||
events = new EventSource(`${session.path}_fp/saves`);
|
||||
events.onmessage = (event) => {
|
||||
storeSaves(gameId, JSON.parse(event.data).files).catch((error) =>
|
||||
const { files: latest } = JSON.parse(event.data);
|
||||
storeSaves(gameId, latest).catch((error) =>
|
||||
logger.error('[StreamPlayer] Failed to store saves:', error)
|
||||
);
|
||||
if (flash) mirrorToRuffle(latest, flash);
|
||||
};
|
||||
events.addEventListener('end', () => events?.close());
|
||||
setState({ status: 'running', path: session.path });
|
||||
@@ -56,7 +63,7 @@ export function StreamPlayer({ gameId, title }: StreamPlayerProps) {
|
||||
cancelled = true;
|
||||
events?.close();
|
||||
};
|
||||
}, [gameId]);
|
||||
}, [gameId, swfUrl]);
|
||||
|
||||
if (state.status === 'error') {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
flashSaveContext,
|
||||
flashToRuffle,
|
||||
mirrorToRuffle,
|
||||
ruffleToFlash,
|
||||
withRuffleSaves,
|
||||
} from './flashSaves';
|
||||
|
||||
const syncState = vi.hoisted(() => ({ keys: [] as string[] }));
|
||||
vi.mock('./gameSaves', () => ({
|
||||
loadSyncState: async (gameId: string) => ({
|
||||
gameId,
|
||||
keys: syncState.keys,
|
||||
databases: [],
|
||||
syncedHash: null,
|
||||
savedAt: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Written by Ruffle for SharedObject.getLocal('pathsave') and getLocal('rootsave', '/')
|
||||
// in a SWF loaded from /game-proxy/http://savetest.example/dir/test.swf
|
||||
const PATH_KEY = 'arcade.example.com/game-proxy/http://savetest.example/dir/test.swf/pathsave';
|
||||
const ROOT_KEY = 'arcade.example.com//rootsave';
|
||||
const PATH_SOL = 'AL8AAAApVENTTwAEAAAAAAAIcGF0aHNhdmUAAAAAAAVzY29yZQBARQAAAAAAAAA=';
|
||||
const ROOT_SOL = 'AL8AAAApVENTTwAEAAAAAAAIcm9vdHNhdmUAAAAAAAVzY29yZQBARQAAAAAAAAA=';
|
||||
const PATH_FILE = 'Flash_Player/#SharedObjects/ABCD1234/savetest.example/dir/test.swf/pathsave.sol';
|
||||
const ROOT_FILE = 'Flash_Player/#SharedObjects/ABCD1234/savetest.example/rootsave.sol';
|
||||
|
||||
const context = flashSaveContext(
|
||||
'/game-proxy/http://savetest.example/dir/test.swf',
|
||||
'arcade.example.com'
|
||||
);
|
||||
if (!context) throw new Error('expected a context');
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
syncState.keys = [];
|
||||
});
|
||||
|
||||
describe('flash save names', () => {
|
||||
it('reads the naming context from a proxied SWF URL only', () => {
|
||||
expect(context).toEqual({
|
||||
siteHost: 'arcade.example.com',
|
||||
proxyRoot: '/game-proxy/http://savetest.example',
|
||||
swfHost: 'savetest.example',
|
||||
});
|
||||
expect(flashSaveContext('/game-zip/abc/test.swf', 'arcade.example.com')).toBeNull();
|
||||
});
|
||||
|
||||
it("maps Ruffle's saves to Flash Player files, root saves only when the game made them", () => {
|
||||
const entries = { [PATH_KEY]: PATH_SOL, [ROOT_KEY]: ROOT_SOL, 'other-game': 'not a save' };
|
||||
expect(ruffleToFlash(entries, context, new Set(), 'ABCD1234')).toEqual({
|
||||
[PATH_FILE]: PATH_SOL,
|
||||
});
|
||||
expect(ruffleToFlash(entries, context, new Set([ROOT_KEY]), 'ABCD1234')).toEqual({
|
||||
[PATH_FILE]: PATH_SOL,
|
||||
[ROOT_FILE]: ROOT_SOL,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps Flash Player files back to Ruffle's keys, ignoring other sites and files", () => {
|
||||
const files = {
|
||||
[PATH_FILE]: PATH_SOL,
|
||||
[ROOT_FILE]: ROOT_SOL,
|
||||
'Flash_Player/#SharedObjects/ABCD1234/elsewhere.example/x.sol': PATH_SOL,
|
||||
'Prefs/data.txt': 'eA==',
|
||||
};
|
||||
expect(flashToRuffle(files, context)).toEqual({ [PATH_KEY]: PATH_SOL, [ROOT_KEY]: ROOT_SOL });
|
||||
});
|
||||
});
|
||||
|
||||
describe('carrying saves between players', () => {
|
||||
it("starts the stream with Ruffle's saves, in the stream's own folder", async () => {
|
||||
localStorage.setItem(PATH_KEY, PATH_SOL);
|
||||
const files = await withRuffleSaves('game', { 'Prefs/x': 'eA==' }, context);
|
||||
expect(files).toEqual({
|
||||
'Prefs/x': 'eA==',
|
||||
'Flash_Player/#SharedObjects/DOTCOMAR/savetest.example/dir/test.swf/pathsave.sol': PATH_SOL,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the stream's copy the first time, then prefers Ruffle's", async () => {
|
||||
localStorage.setItem(PATH_KEY, PATH_SOL);
|
||||
const streamCopy = { [PATH_FILE]: 'AL8streamcopy' };
|
||||
expect(await withRuffleSaves('game', streamCopy, context)).toEqual(streamCopy);
|
||||
expect(await withRuffleSaves('game', streamCopy, context)).toEqual({ [PATH_FILE]: PATH_SOL });
|
||||
});
|
||||
|
||||
it("copies the stream's saves into Ruffle's storage", () => {
|
||||
mirrorToRuffle({ [PATH_FILE]: PATH_SOL, [ROOT_FILE]: ROOT_SOL }, context);
|
||||
expect(localStorage.getItem(PATH_KEY)).toBe(PATH_SOL);
|
||||
expect(localStorage.getItem(ROOT_KEY)).toBe(ROOT_SOL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { loadSyncState } from './gameSaves';
|
||||
import type { SaveFiles } from '@/lib/streamSaves';
|
||||
|
||||
/**
|
||||
* Ruffle and the original Flash Player (streamed) store SharedObjects in the
|
||||
* same .sol format, under different names:
|
||||
*
|
||||
* - Ruffle, in localStorage, base64-encoded, keyed by the site's host and the
|
||||
* path it loaded the SWF from: `<site>/game-proxy/http://<host>/<path>/<name>`,
|
||||
* or `<site>//<name>` for a localPath of "/".
|
||||
* - Flash Player, as files in the streamed game's saves directory:
|
||||
* `Flash_Player/#SharedObjects/<folder>/<host>/<path>/<name>.sol`.
|
||||
*
|
||||
* These helpers translate between the two so a game's saves follow it from one
|
||||
* player to the other.
|
||||
*/
|
||||
|
||||
const SHARED_OBJECTS = 'Flash_Player/#SharedObjects/';
|
||||
/** Flash Player names this folder randomly when it creates it; an existing one is reused */
|
||||
const DEFAULT_FOLDER = 'DOTCOMAR';
|
||||
/** Every .sol file starts with the bytes 00 BF 00, which base64-encode to "AL8" */
|
||||
const SOL_PREFIX = 'AL8';
|
||||
/** Games whose saves have been brought together once already */
|
||||
const LINKED_KEY = 'flashpoint-flash-saves-linked';
|
||||
|
||||
export interface FlashSaveContext {
|
||||
/** Host Ruffle keys saves by: the site's own */
|
||||
siteHost: string;
|
||||
/** Path Ruffle loads the game's SWF host under, e.g. /game-proxy/http://example.com */
|
||||
proxyRoot: string;
|
||||
/** Host Flash Player keys saves by: the SWF's own */
|
||||
swfHost: string;
|
||||
}
|
||||
|
||||
/** The naming context for a SWF played through the game proxy, or null for any other URL. */
|
||||
export function flashSaveContext(swfUrl: string, siteHost: string): FlashSaveContext | null {
|
||||
const match = swfUrl.match(/^(\/game-proxy\/https?:\/\/([^/]+))\//);
|
||||
return match ? { siteHost, proxyRoot: match[1], swfHost: match[2] } : null;
|
||||
}
|
||||
|
||||
function sharedObjectsFolder(files: SaveFiles): string {
|
||||
const existing = Object.keys(files).find((path) => path.startsWith(SHARED_OBJECTS));
|
||||
return existing?.slice(SHARED_OBJECTS.length).split('/')[0] ?? DEFAULT_FOLDER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flash Player save files for a game's Ruffle SharedObjects. Root-path saves
|
||||
* ("<site>//<name>") are shared by every game, so only those in rootKeys, the
|
||||
* keys seen changing while this game ran, are included.
|
||||
*/
|
||||
export function ruffleToFlash(
|
||||
entries: Record<string, string>,
|
||||
context: FlashSaveContext,
|
||||
rootKeys: ReadonlySet<string>,
|
||||
folder: string
|
||||
): SaveFiles {
|
||||
const files: SaveFiles = {};
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (!value.startsWith(SOL_PREFIX) || !key.startsWith(`${context.siteHost}/`)) continue;
|
||||
const rest = key.slice(context.siteHost.length);
|
||||
const slash = rest.lastIndexOf('/');
|
||||
const localPath = rest.slice(0, slash) || '/';
|
||||
const name = rest.slice(slash + 1);
|
||||
let dir: string;
|
||||
if (localPath === '/') {
|
||||
if (!rootKeys.has(key)) continue;
|
||||
dir = context.swfHost;
|
||||
} else if (localPath.startsWith(`${context.proxyRoot}/`)) {
|
||||
dir = context.swfHost + localPath.slice(context.proxyRoot.length);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
files[`${SHARED_OBJECTS}${folder}/${dir}/${name}.sol`] = value;
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Ruffle localStorage entries for a game's Flash Player save files. */
|
||||
export function flashToRuffle(files: SaveFiles, context: FlashSaveContext): Record<string, string> {
|
||||
const entries: Record<string, string> = {};
|
||||
for (const [path, value] of Object.entries(files)) {
|
||||
if (!path.startsWith(SHARED_OBJECTS) || !path.endsWith('.sol')) continue;
|
||||
// folder, host, any directories, name.sol
|
||||
const parts = path.slice(SHARED_OBJECTS.length).split('/');
|
||||
if (parts.length < 3 || parts[1] !== context.swfHost) continue;
|
||||
const name = parts[parts.length - 1].slice(0, -'.sol'.length);
|
||||
const dirs = parts.slice(2, -1);
|
||||
const key = dirs.length
|
||||
? `${context.siteHost}${context.proxyRoot}/${dirs.join('/')}/${name}`
|
||||
: `${context.siteHost}//${name}`;
|
||||
entries[key] = value;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function readLocalStorage(): Record<string, string> {
|
||||
const entries: Record<string, string> = {};
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
const value = key === null ? null : localStorage.getItem(key);
|
||||
if (key !== null && value !== null) entries[key] = value;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function linkedGames(): Set<string> {
|
||||
try {
|
||||
return new Set(JSON.parse(localStorage.getItem(LINKED_KEY) ?? '[]') as string[]);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The save files to start a streamed Flash game with: its stream saves plus its
|
||||
* Ruffle saves. Ruffle's copy wins, being kept up to date with the stream's;
|
||||
* the first time a game is linked, saves from before that are left as the
|
||||
* stream had them.
|
||||
*/
|
||||
export async function withRuffleSaves(
|
||||
gameId: string,
|
||||
files: SaveFiles,
|
||||
context: FlashSaveContext
|
||||
): Promise<SaveFiles> {
|
||||
const { keys } = await loadSyncState(gameId);
|
||||
const fromRuffle = ruffleToFlash(
|
||||
readLocalStorage(),
|
||||
context,
|
||||
new Set(keys),
|
||||
sharedObjectsFolder(files)
|
||||
);
|
||||
const linked = linkedGames();
|
||||
if (linked.has(gameId)) {
|
||||
return { ...files, ...fromRuffle };
|
||||
}
|
||||
linked.add(gameId);
|
||||
localStorage.setItem(LINKED_KEY, JSON.stringify([...linked]));
|
||||
return { ...fromRuffle, ...files };
|
||||
}
|
||||
|
||||
/** Copies a streamed game's Flash Player saves into Ruffle's storage. */
|
||||
export function mirrorToRuffle(files: SaveFiles, context: FlashSaveContext): void {
|
||||
for (const [key, value] of Object.entries(flashToRuffle(files, context))) {
|
||||
if (localStorage.getItem(key) !== value) {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user