mirror of
https://github.com/rommapp/romm.git
synced 2026-07-01 08:16:21 +00:00
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:
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user