fix(emulation): make save state capture and restore reliable

Save states were inconsistently saved and frequently failed to load,
especially on threaded cores (SNES, N64). Two timing races were the
cause:

- On game start, a selected state was applied after a fixed 10ms delay
  with no check that the core was ready. Heavier cores aren't booted in
  that window, so the state applied partially and left a black screen.
  Replace the fixed delay with a gameManager readiness poll plus a short
  settle window before applying the state (mirrors the console player).

- The Save & Quit handler read the state from a running emulator. On
  threaded cores the worker thread mutates emulator memory while
  getState() reads it, producing torn/corrupt buffers that are then
  stored permanently. Capture the screenshot while running, then pause
  before serializing the state and save file.

Also refuse to upload zero-length state buffers so a failed capture
can't overwrite good states on the server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PoAdK2fmqHGmGuXWaKN1HT
This commit is contained in:
Claude
2026-06-28 20:00:38 +00:00
parent 699d9ee670
commit e8a7ae3fee
2 changed files with 55 additions and 9 deletions

View File

@@ -250,6 +250,24 @@ function displayMessage(
}
}
// Poll until EmulatorJS' gameManager is ready to accept save/state
// injection. A fixed delay is unreliable: heavier/threaded cores (SNES with
// enhancement chips, N64, DS) need longer than a few ms to boot, and applying
// a state before the core is ready leaves it broken (black screen).
async function waitForGameManager(timeoutMs = 5000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const gameManager = window.EJS_emulator?.gameManager;
if (gameManager?.FS && gameManager.getSaveFilePath) return true;
await new Promise((resolve) => setTimeout(resolve, 100));
}
return false;
}
// Settle window after boot before applying a state. Some cores need a few
// frames rendered before loadState takes cleanly.
const STATE_APPLY_SETTLE_MS = 500;
// Saves management
async function loadSave(save: SaveSchema) {
saveRef.value = save;
@@ -360,15 +378,28 @@ window.EJS_onSaveState = async function ({
window.EJS_onGameStart = async () => {
sessionStartTime.value = new Date();
setTimeout(async () => {
if (props.save) await loadSave(props.save);
if (props.state) await loadState(props.state);
window.EJS_emulator.settings = {
...window.EJS_emulator.settings,
"save-state-location": "browser",
};
}, 10);
void (async () => {
const ready = await waitForGameManager();
if (!ready) {
console.warn("Game manager not ready for save/state injection");
} else {
if (props.save) await loadSave(props.save);
if (props.state) {
await new Promise((resolve) =>
setTimeout(resolve, STATE_APPLY_SETTLE_MS),
);
await loadState(props.state);
}
}
if (window.EJS_emulator) {
window.EJS_emulator.settings = {
...window.EJS_emulator.settings,
"save-state-location": "browser",
};
}
})();
const quickLoad = createQuickLoadButton();
quickLoad.addEventListener("click", () => {
@@ -399,9 +430,16 @@ window.EJS_onGameStart = async () => {
saveAndQuit.addEventListener("click", async () => {
if (!romRef.value || !window.EJS_emulator) return immediateExit();
// Grab the screenshot while the game is still running (EmulatorJS reads
// the live canvas), then pause before serializing state/save. Reading
// state from a running threaded core (SNES, N64) races the worker thread
// and yields torn buffers, producing corrupt states that never load.
const screenshotFile = await window.EJS_emulator.gameManager.screenshot();
window.EJS_emulator.pause();
await new Promise((resolve) => setTimeout(resolve, 50));
const stateFile = window.EJS_emulator.gameManager.getState();
const saveFile = window.EJS_emulator.gameManager.getSaveFile();
const screenshotFile = await window.EJS_emulator.gameManager.screenshot();
// Force a save of the current state
await saveState({

View File

@@ -24,6 +24,14 @@ export async function saveState({
stateFile: ArrayBuffer;
screenshotFile?: ArrayBuffer;
}): Promise<StateSchema | null> {
// A zero-length buffer means the core failed to serialize its state (a torn
// read from a running threaded core). Refuse to upload it so a broken
// capture can't overwrite the user's good states on the server.
if (stateFile.byteLength === 0) {
console.error("Refusing to upload empty state file");
return null;
}
const filename = buildStateName(rom);
try {
const uploadedStates = await stateApi.uploadStates({