Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/05-native-filesystem.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Native filesystem and pause safety

The application stores cartridge bytes, battery saves and snapshots in its own IndexedDB database
through `storage.ts`. The emulator filesystem is the working copy used by the native core.

## Why native filesystem operations must be synchronous

The pinned mGBA 2.5.1 bundle uses Emscripten 6.0.5. Its `fd_sync` proxies from the emulator thread to
the browser main thread in promise mode and waits for an asynchronous IDBFS flush. A simultaneous
`pauseGame` or thread interrupt blocks the main thread until the emulator thread stops. The browser
cannot deliver the IndexedDB completion while that synchronous call is waiting, so both threads
wait indefinitely. Frame counters and save notifications alone do not prevent this cycle.

`scripts/mgba-memory-filesystem.mjs` adapts the same-origin runtime during `setup-emulator`. It
checks the exact version and SHA-256 of the installed JavaScript before changing the reviewed
filesystem initializer and `fd_sync` proxy. SDK drift fails the build and requires a new review.
The WebAssembly binary, worker loading through `import.meta.url`, and runtime version remain intact.

## Existing data and persistence

Before loading a cartridge or starting emulation, `FSInit` awaits a read of both legacy SDK IDBFS
mounts, `/data` and `/autosave`. It collects their complete file bytes and directory structure, then
recreates them under MEMFS mounts. It neither deletes nor writes back the legacy IndexedDB databases.
Read failures, unsupported entries and copy failures reject initialization instead of starting with
missing data. This also preserves legacy native saves when the application's battery record is absent.

During emulation, native `fd_sync` uses a synchronous proxy and returns a numeric result only for a
memory filesystem. An asynchronous backing store returns `ENOTSUP`; real filesystem errors propagate.
It never acknowledges an unfinished persistent-store flush. Application battery and snapshot writes
still await their existing IndexedDB transactions, and persistence failures remain visible. `FSSync`
continues to be awaited by the application, but the native working filesystem has no asynchronous
backing mount.

Startup also pauses the core while replacing callbacks and configuring direction policy, volume and
speed. These structures are read by the native thread during frames. Configuration's internal
interrupt/continue preserves the native pause request; the application resumes only after setup
succeeds. A setup error leaves the core paused. Snapshot restore retains mask `61` and its existing
active-SRAM and battery-file semantics.

## Verification

Unit tests execute the emitted proxy, reject SDK drift and asynchronous backends, preserve syscall
errors, and verify legacy cache import without persistent writes. Emulator tests enforce paused setup
and cartridge switching. The full browser suite verifies GB/GBC/GBA pause/resume, snapshot restoration,
exact battery bytes, reload persistence, and access isolation without altered assertions or timeouts.
A real-browser legacy-cache regression brings the mandatory exact count to 31 and verifies that
working-copy writes leave the original SDK IndexedDB bytes intact.
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
1. [6DQ adoption plan](01-6dq-adoption-plan.md) — requirements, baseline evidence, and accepted design.
2. [6DQ Tier S execution](02-6dq-tier-s-execution.md) — atomic implementation batches, reviews, final local acceptance, and hosted-validation limits.
3. [Workspace recovery](03-workspace-recovery.md) — verified recovery, backup inventory, and browser-test containment.
4. [Native filesystem and pause safety](05-native-filesystem.md) — deadlock prevention, legacy cache import and durable application storage.
2 changes: 1 addition & 1 deletion scripts/browser-shared.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const ENV_BROWSER_PORT = 'POKEPOCKET_BROWSER_PORT';
export const ENV_BROWSER_PID = 'POKEPOCKET_BROWSER_PID';
export const ENV_BROWSER_WS = 'POKEPOCKET_BROWSER_WS';

export const REQUIRED_TEST_COUNT = 30; // 28 core/layout/export journeys + 2 authorization tests
export const REQUIRED_TEST_COUNT = 31; // 28 core/layout/export journeys + 2 authorization + legacy SDK cache import
export const OPTIONAL_TEST_COUNT = 16; // 13 series + 3 commercial

export const FORBIDDEN_CLI_FLAGS = new Set([
Expand Down
88 changes: 88 additions & 0 deletions scripts/mgba-memory-filesystem.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { createHash } from 'node:crypto';

const SDK_SHA256 = '98e6f460fa2f95846bf79c3b1c42ecf3acca0ce11937ba0d4aa693438402a473';

export function initializeMemoryFilesystem(FS) {
return new Promise((resolve, reject) => {
const mounts = ['/data', '/autosave'];
for (const mount of mounts) {
FS.mkdir(mount);
FS.mount(FS.filesystems.IDBFS, {}, mount);
}
// Read legacy SDK caches before emulation; never delete or write back their databases.
FS.syncfs(true, (error) => {
if (error) {
reject(new Error(`Cannot read legacy emulator cache: ${error}`));
return;
}
try {
const entries = [];
const collect = (directory) => {
for (const name of FS.readdir(directory)) {
if (name === '.' || name === '..') continue;
const file = `${directory}/${name}`;
const mode = FS.lstat(file).mode;
if (FS.isDir(mode)) {
entries.push({ file });
collect(file);
} else if (FS.isFile(mode)) {
entries.push({ file, data: FS.readFile(file) });
} else {
throw new Error(`Unsupported legacy emulator cache entry: ${file}`);
}
}
};
for (const mount of mounts) collect(mount);
for (const mount of mounts) {
FS.unmount(mount);
FS.mount(FS.filesystems.MEMFS, {}, mount);
}
for (const { file, data } of entries) {
if (data) FS.writeFile(file, data);
else FS.mkdir(file);
}
for (const name of ['saves', 'states', 'games', 'cheats', 'screenshots', 'patches']) {
const directory = `/data/${name}`;
if (!FS.analyzePath(directory).exists) FS.mkdir(directory);
}
resolve();
} catch (error) {
reject(error);
}
});
});
}

export function syncMemoryFile(fd, FS, SYSCALLS) {
try {
const stream = SYSCALLS.getStreamFromFD(fd);
// A synchronous native caller must never acknowledge an asynchronous backing store.
if (stream.node.mount.type !== FS.filesystems.MEMFS) return 58; // WASI ENOTSUP
const result = stream.stream_ops?.fsync?.(stream);
if (result === undefined) return 0;
return typeof result === 'number' ? result : 29; // WASI EIO for a non-synchronous operation
} catch (error) {
if (error.name !== 'ErrnoError') throw error;
return error.errno;
}
}

export function patchMgbaFilesystem(source, version) {
if (version !== '2.5.1' || createHash('sha256').update(source).digest('hex') !== SDK_SHA256) {
throw new Error(
'Unreviewed mGBA runtime: review the filesystem adapter before updating the SDK',
);
}
const initStart = source.indexOf('Module.FSInit=');
const initEnd = source.indexOf('Module.FSSync=', initStart);
const syncStart = source.indexOf('var _fd_sync=');
const syncEnd = source.indexOf('var doWritev=', syncStart);
// The digest binds these boundaries and proxy index to the reviewed SDK artifact.
return (
source.slice(0, initStart) +
`Module.FSInit=()=>(${initializeMemoryFilesystem.toString()})(FS);` +
source.slice(initEnd, syncStart) +
`var _fd_sync=function(fd){if(ENVIRONMENT_IS_PTHREAD)return proxyToMainThread(80,0,1,fd);return (${syncMemoryFile.toString()})(fd,FS,SYSCALLS);};` +
source.slice(syncEnd)
);
}
10 changes: 7 additions & 3 deletions scripts/setup-emulator.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';
import { patchMgbaFilesystem } from './mgba-memory-filesystem.mjs';

export const REQUIRED_DEPENDENCIES = [
'react',
Expand All @@ -17,13 +18,16 @@ export async function setupEmulator(options = {}) {
const root = options.root ?? fileURLToPath(new URL('../', import.meta.url));
const source = options.source ?? path.join(root, 'node_modules/@thenick775/mgba-wasm');
const pkg = JSON.parse(await readFile(path.join(source, 'package.json'), 'utf8'));
const runtime = patchMgbaFilesystem(
await readFile(path.join(source, 'dist/mgba.js'), 'utf8'),
pkg.version,
);
const target = path.join(root, 'public/emulator', pkg.version);
await mkdir(target, { recursive: true });

// Preserve import.meta.url: the threaded Emscripten runtime loads itself as a worker.
for (const file of ['mgba.js', 'mgba.wasm']) {
await copyFile(path.join(source, 'dist', file), path.join(target, file));
}
await writeFile(path.join(target, 'mgba.js'), runtime);
await copyFile(path.join(source, 'dist/mgba.wasm'), path.join(target, 'mgba.wasm'));
await writeFile(
path.join(root, 'public/emulator/version.json'),
JSON.stringify({ version: pkg.version }),
Expand Down
4 changes: 3 additions & 1 deletion src/lib/emulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ export class PocketEmulator {
}
this.savedataDirty = false;

// The native thread reads both the callback list and configuration during frames.
core.pauseGame();
core.addCoreCallbacks({
videoFrameEndedCallback: () => {
if (this.loadGeneration === currentGeneration) {
Expand All @@ -246,10 +248,10 @@ export class PocketEmulator {
}, 0);
},
});

core.setCoreSettings({ allowOpposingDirections: false });
core.setVolume(this.volume);
core.setFastForwardMultiplier(this.state.speed);
core.resumeGame();

const previous = snapshots.find((snapshot) => snapshot.slot === 0);
let resumedAutomatically = false;
Expand Down
92 changes: 92 additions & 0 deletions tests/e2e/native-filesystem.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { mGBAEmulator } from '@thenick775/mgba-wasm';
import { CORE_VERSION } from '../../src/lib/emulator';
import { test, expect } from '../fixtures/browser-harness';

test('imports legacy native save bytes into memory without rewriting the old IndexedDB caches', async ({
page,
}) => {
await page.goto('/');
const result = await page.evaluate(async (version) => {
const fixtures = [
{
database: '/data',
directory: '/data/saves',
file: '/data/saves/legacy.sav',
bytes: [0, 128, 255],
},
{
database: '/autosave',
directory: '/autosave/old',
file: '/autosave/old/game.ss0',
bytes: [9, 8, 7],
},
];
const open = (name: string) =>
new Promise<IDBDatabase>((resolve, reject) => {
// The reviewed SDK's IDBFS database schema is version 21.
const request = indexedDB.open(name, 21);
request.onupgradeneeded = () =>
request.result.createObjectStore('FILE_DATA').createIndex('timestamp', 'timestamp');
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
for (const fixture of fixtures) {
const db = await open(fixture.database);
try {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('FILE_DATA', 'readwrite');
const store = tx.objectStore('FILE_DATA');
const timestamp = new Date('2026-01-01T00:00:00Z');
store.put({ timestamp, mode: 0o40755 }, fixture.directory);
store.put(
{ timestamp, mode: 0o100644, contents: new Uint8Array(fixture.bytes) },
fixture.file,
);
tx.oncomplete = () => resolve();
tx.onabort = () => reject(tx.error);
});
} finally {
db.close();
}
}

const url = new URL(`/emulator/${version}/mgba.js`, window.location.origin).href;
const { default: createCore } = (await import(/* @vite-ignore */ url)) as {
default: (options: { canvas: HTMLCanvasElement }) => Promise<mGBAEmulator>;
};
const core = await createCore({ canvas: document.createElement('canvas') });
await core.FSInit();
const imported = fixtures.map(({ file }) => [...core.FS.readFile(file)]);
for (const fixture of fixtures) core.FS.writeFile(fixture.file, new Uint8Array([42]));
await core.FSSync();

const oldDatabases = [];
for (const fixture of fixtures) {
const db = await open(fixture.database);
try {
oldDatabases.push(
await new Promise<number[]>((resolve, reject) => {
const tx = db.transaction('FILE_DATA', 'readonly');
const request = tx.objectStore('FILE_DATA').get(fixture.file);
tx.oncomplete = () => resolve([...request.result.contents]);
tx.onabort = () => reject(tx.error);
}),
);
} finally {
db.close();
}
}
return {
imported,
oldDatabases,
working: fixtures.map(({ file }) => [...core.FS.readFile(file)]),
};
}, CORE_VERSION);

expect(result.imported).toEqual([
[0, 128, 255],
[9, 8, 7],
]);
expect(result.working).toEqual([[42], [42]]);
expect(result.oldDatabases).toEqual(result.imported);
});
83 changes: 82 additions & 1 deletion tests/unit/emulator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,80 @@ describe('PocketEmulator with injected dependencies', () => {
expect(core.loadGame).toHaveBeenCalled();
});

it('configures callbacks and settings while paused, including cartridge switches', async () => {
const core = createFakeCore();
let active = false;
const register = core.addCoreCallbacks;
core.loadGame = vi.fn(() => {
active = true;
return true;
});
core.pauseGame = vi.fn(() => {
active = false;
});
core.resumeGame = vi.fn(() => {
active = true;
});
core.addCoreCallbacks = vi.fn((callbacks) => {
if (active) throw new Error('Core thread is reading the callback list');
register(callbacks);
});
core.setCoreSettings = vi.fn(() => {
if (active) throw new Error('Core thread is reading core settings');
});
core.setVolume = vi.fn(() => {
if (active) throw new Error('Core thread is reading volume settings');
});
core.setFastForwardMultiplier = vi.fn(() => {
if (active) throw new Error('Core thread is reading timing settings');
});
const emulator = new PocketEmulator({
createCore: async () => core,
checkCrossOriginIsolated: () => true,
storage: createStorageDouble(),
clock: createClockDouble(),
});

for (const id of ['first-callback-owner', 'second-callback-owner']) {
await emulator.load(createCartridge(id), {} as HTMLCanvasElement);
expect(emulator.getSnapshot().status).toBe('running');
expect(emulator.getSnapshot().cartridge?.id).toBe(id);
expect(active).toBe(true);
}
expect(core.addCoreCallbacks).toHaveBeenCalledTimes(2);
});

it('keeps the core paused if callback registration fails', async () => {
const core = createFakeCore();
let active = false;
core.loadGame = vi.fn(() => {
active = true;
return true;
});
core.pauseGame = vi.fn(() => {
active = false;
});
core.resumeGame = vi.fn(() => {
active = true;
});
core.addCoreCallbacks = vi.fn(() => {
throw new Error('Callback registration failed');
});
const emulator = new PocketEmulator({
createCore: async () => core,
checkCrossOriginIsolated: () => true,
storage: createStorageDouble(),
clock: createClockDouble(),
});

await expect(emulator.load(createCartridge(), {} as HTMLCanvasElement)).rejects.toThrow(
'Callback registration failed',
);
expect(emulator.getSnapshot().status).toBe('error');
expect(active).toBe(false);
expect(core.resumeGame).not.toHaveBeenCalled();
});

it('pauses and resumes cleanly', async () => {
const core = createFakeCore();
const emulator = new PocketEmulator({
Expand Down Expand Up @@ -783,6 +857,7 @@ describe('PocketEmulator with injected dependencies', () => {
});

await emulator.load(createCartridge('cart-deferred-persist'), {} as HTMLCanvasElement);
callOrder.length = 0;
emulator.pause();
expect(emulator.getSnapshot().status).toBe('paused');
expect(corePaused).toBe(true);
Expand Down Expand Up @@ -948,7 +1023,13 @@ describe('PocketEmulator with injected dependencies', () => {
});

await emu1.load(cart, {} as HTMLCanvasElement, true);
expect(callOrder1).toEqual(['pauseGame', 'loadStateSlot:0:61:paused=true', 'resumeGame']);
expect(callOrder1).toEqual([
'pauseGame',
'resumeGame',
'pauseGame',
'loadStateSlot:0:61:paused=true',
'resumeGame',
]);
expect(core1.FS.writeFile).toHaveBeenCalledWith(
'/states/cart-auto-lifecycle.ss0',
new Uint8Array(autoData),
Expand Down
Loading
Loading