fix(saves): keep Ruffle's copy of a Flash save current in both directions

The stream starts with Ruffle's copy of each save, but Ruffle starts
first, and a game with progress only in the stream would write a fresh
save there that then replaced it. Before Ruffle starts a game it now
gets the stream's saves it doesn't have yet. This replaces the
first-switch rule, which kept a stale stream save over Ruffle progress.
Moving the Flash content type above GamePlayer's early return also
fixes a conditional hook.
This commit is contained in:
2026-10-14 16:45:47 -07:00
parent 3d93ef7304
commit a236d7a1b0
5 changed files with 100 additions and 53 deletions
+12 -2
View File
@@ -37,8 +37,18 @@ 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.
carried over.
Ruffle's copy of a save is kept up to date in both directions, so it's the one
the stream starts with:
- Before Ruffle starts a game, it's given any of the stream's saves it doesn't
have yet, such as progress made only in the stream. The game then loads that
progress instead of starting a fresh save, which would otherwise replace the
stream's progress on the next switch. Saves Ruffle already has are left alone.
- While the stream plays, its saves are copied into Ruffle as they change.
A stream save replaced this way is still in the account's backups.
## Sync
@@ -17,14 +17,15 @@ const flashGame = {
describe('GamePlayer player choice', () => {
it('switches a streamable Flash game between Ruffle and the original Flash Player', async () => {
render(<GamePlayer {...flashGame} streamable />);
expect(screen.getByText('ruffle')).toBeInTheDocument();
// Ruffle starts once any streamed saves have been handed to it
expect(await screen.findByText('ruffle')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Use original Flash Player' }));
expect(screen.getByText('stream')).toBeInTheDocument();
expect(screen.queryByText('ruffle')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Use Ruffle' }));
expect(screen.getByText('ruffle')).toBeInTheDocument();
expect(await screen.findByText('ruffle')).toBeInTheDocument();
});
it('offers no choice when the game cannot be streamed', () => {
+37 -17
View File
@@ -14,6 +14,7 @@ import { RufflePlayer } from './RufflePlayer';
import { StreamPlayer } from './StreamPlayer';
import { SavesDialog } from './SavesDialog';
import { useGameSaveSync } from '@/hooks/useGameSaveSync';
import { addStreamSavesToRuffle } from '@/lib/saves/flashSaves';
import { useAuthStore } from '@/store/auth';
import { PlayerErrorBoundary } from './PlayerErrorBoundary';
import { PlatformIcon } from '@/components/ui/platform-icon';
@@ -137,6 +138,41 @@ function GamePlayerInternal({
}
}, [contentUrl, platform]);
// Determine Flash content type: direct SWF vs HTML wrapper
// HTML wrappers (index.html, .html, .htm) need iframe, not Ruffle
const flashContentType = useMemo(() => {
if (platform !== 'Flash' || !contentUrl) return null;
const urlPath = contentUrl.toLowerCase();
const isSwfUrl = urlPath.includes('.swf');
const isHtmlWrapper =
urlPath.includes('.html') || urlPath.includes('.htm') || urlPath.endsWith('/');
if (isSwfUrl && !isHtmlWrapper) {
return 'swf' as const;
}
return 'html-wrapper' as const;
}, [platform, contentUrl]);
// Before Ruffle starts, it gets any saves the streamed Flash Player made that
// it doesn't have yet. Otherwise a game played only streamed would start from
// scratch here, and that fresh save would replace its progress on the next switch.
const ruffleSwf = gameId && flashContentType === 'swf' && contentUrl ? contentUrl : null;
const [ruffleSavesEpoch, setRuffleSavesEpoch] = useState<number | null>(null);
useEffect(() => {
if (!saveSync.ready || !ruffleSwf || !gameId) return;
let cancelled = false;
addStreamSavesToRuffle(gameId, ruffleSwf)
.catch((error) => logger.error('[GamePlayer] Failed to copy stream saves to Ruffle:', error))
.finally(() => {
if (!cancelled) setRuffleSavesEpoch(saveSync.epoch);
});
return () => {
cancelled = true;
};
}, [saveSync.ready, saveSync.epoch, ruffleSwf, gameId]);
const ruffleSavesReady = !ruffleSwf || ruffleSavesEpoch === saveSync.epoch;
const canSwitchPlayer = canPlayInBrowser && streamable && Boolean(gameId);
const isStreamed = streamable && Boolean(gameId) && (!canPlayInBrowser || preferStream);
@@ -178,22 +214,6 @@ function GamePlayerInternal({
const isAspectVideo = height === 'aspect-video';
// Determine Flash content type: direct SWF vs HTML wrapper
// HTML wrappers (index.html, .html, .htm) need iframe, not Ruffle
const flashContentType = useMemo(() => {
if (platform !== 'Flash' || !contentUrl) return null;
const urlPath = contentUrl.toLowerCase();
const isSwfUrl = urlPath.includes('.swf');
const isHtmlWrapper =
urlPath.includes('.html') || urlPath.includes('.htm') || urlPath.endsWith('/');
if (isSwfUrl && !isHtmlWrapper) {
return 'swf' as const;
}
return 'html-wrapper' as const;
}, [platform, contentUrl]);
return (
<div className={`${containerClasses} ${isAspectVideo ? 'flex flex-col' : ''}`}>
{showControls && (isFullscreen || !renderControls) ? (
@@ -327,7 +347,7 @@ function GamePlayerInternal({
}
}
>
{!saveSync.ready ? (
{!saveSync.ready || (!isStreamed && !ruffleSavesReady) ? (
<div className="flex items-center justify-center h-full gap-3 text-muted-foreground">
<Loader2 className="animate-spin" aria-hidden="true" />
<span>Loading your save...</span>
+24 -4
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
addStreamSavesToRuffle,
flashSaveContext,
flashToRuffle,
mirrorToRuffle,
@@ -8,6 +9,8 @@ import {
} from './flashSaves';
const syncState = vi.hoisted(() => ({ keys: [] as string[] }));
const streamSaves = vi.hoisted(() => ({ files: {} as Record<string, string> }));
vi.mock('@/lib/streamSaves', () => ({ loadSaves: async () => streamSaves.files }));
vi.mock('./gameSaves', () => ({
loadSyncState: async (gameId: string) => ({
gameId,
@@ -36,6 +39,7 @@ if (!context) throw new Error('expected a context');
beforeEach(() => {
localStorage.clear();
syncState.keys = [];
streamSaves.files = {};
});
describe('flash save names', () => {
@@ -80,11 +84,13 @@ describe('carrying saves between players', () => {
});
});
it("keeps the stream's copy the first time, then prefers Ruffle's", async () => {
it("replaces the stream's copy of a save with Ruffle's, in the stream's folder", 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 });
const streamCopy = { [PATH_FILE]: 'AL8stalecopy', [ROOT_FILE]: ROOT_SOL };
expect(await withRuffleSaves('game', streamCopy, context)).toEqual({
[PATH_FILE]: PATH_SOL,
[ROOT_FILE]: ROOT_SOL,
});
});
it("copies the stream's saves into Ruffle's storage", () => {
@@ -92,4 +98,18 @@ describe('carrying saves between players', () => {
expect(localStorage.getItem(PATH_KEY)).toBe(PATH_SOL);
expect(localStorage.getItem(ROOT_KEY)).toBe(ROOT_SOL);
});
it('gives Ruffle the stream-only saves before it starts, keeping its own', async () => {
window.history.replaceState(null, '', '/browse/game');
const host = window.location.hostname;
streamSaves.files = { [PATH_FILE]: PATH_SOL, [ROOT_FILE]: ROOT_SOL };
localStorage.setItem(`${host}//rootsave`, 'AL8ruffleprogress');
await addStreamSavesToRuffle('game', '/game-proxy/http://savetest.example/dir/test.swf');
expect(
localStorage.getItem(`${host}/game-proxy/http://savetest.example/dir/test.swf/pathsave`)
).toBe(PATH_SOL);
expect(localStorage.getItem(`${host}//rootsave`)).toBe('AL8ruffleprogress');
});
});
+24 -28
View File
@@ -1,5 +1,5 @@
import { loadSyncState } from './gameSaves';
import type { SaveFiles } from '@/lib/streamSaves';
import { loadSaves, type SaveFiles } from '@/lib/streamSaves';
/**
* Ruffle and the original Flash Player (streamed) store SharedObjects in the
@@ -20,8 +20,6 @@ const SHARED_OBJECTS = 'Flash_Player/#SharedObjects/';
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 */
@@ -103,19 +101,11 @@ function readLocalStorage(): Record<string, string> {
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.
* The save files to start a streamed Flash game with: its stream saves, with
* its Ruffle saves on top. Ruffle is where a game is usually played, and the
* stream's own changes are copied back into Ruffle as they happen, so Ruffle's
* copy is the latest.
*/
export async function withRuffleSaves(
gameId: string,
@@ -123,19 +113,10 @@ export async function withRuffleSaves(
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 };
return {
...files,
...ruffleToFlash(readLocalStorage(), context, new Set(keys), sharedObjectsFolder(files)),
};
}
/** Copies a streamed game's Flash Player saves into Ruffle's storage. */
@@ -146,3 +127,18 @@ export function mirrorToRuffle(files: SaveFiles, context: FlashSaveContext): voi
}
}
}
/**
* Before Ruffle plays a game, gives it the streamed Flash Player's saves it
* doesn't have yet, so progress made only in the stream isn't started over.
* Saves Ruffle already has are left alone.
*/
export async function addStreamSavesToRuffle(gameId: string, swfUrl: string): Promise<void> {
const context = flashSaveContext(swfUrl, window.location.hostname);
if (!context) return;
for (const [key, value] of Object.entries(flashToRuffle(await loadSaves(gameId), context))) {
if (localStorage.getItem(key) === null) {
localStorage.setItem(key, value);
}
}
}