From 95d070472754e1076cda46d84cfc42ddf8c9c7e7 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 21 Jun 2026 16:25:13 +0300 Subject: [PATCH 1/6] fix: handle unformatted memory cards on save restore Restoring a cloud save into an unformatted PS1/PS2 memory card failed with a generic "not a writable card" error. Detect the unformatted card before writing, block the restore with guidance to format it first, and log every restore failure path to error.txt. --- .../game/game-settings-modal/cloud-tab.tsx | 8 ++++- .../emulation/cloud-saves-section.tsx | 9 +++-- .../emulation-cloud-restore-modal.tsx | 35 +++++++++++++++++-- .../src/pages/settings/emulation/styles.scss | 7 ++++ src/locales/en/translation.json | 1 + src/locales/es/translation.json | 1 + src/locales/pt-BR/translation.json | 1 + src/locales/ru/translation.json | 1 + src/main/events/emulators/index.ts | 1 + src/main/events/emulators/inspect-memcard.ts | 14 ++++++++ .../emulators/restore-emulation-save.ts | 9 ++++- .../ps1-memory-card/ps1-memory-card-writer.ts | 16 ++++++++- .../ps1-memory-card/ps1-memory-card.ts | 26 ++++++++++++++ .../ps2-memory-card/ps2-memory-card-writer.ts | 10 ++++++ .../ps2-memory-card/ps2-memory-card.ts | 15 ++++++++ src/preload/index.ts | 6 ++++ src/renderer/src/declaration.d.ts | 5 +++ .../emulation/emulation-save-modals.scss | 7 ++++ .../emulation/emulation-save-modals.tsx | 34 ++++++++++++++++-- src/types/emulator.types.ts | 5 +++ 20 files changed, 201 insertions(+), 10 deletions(-) create mode 100644 src/main/events/emulators/inspect-memcard.ts diff --git a/src/big-picture/src/components/pages/game/game-settings-modal/cloud-tab.tsx b/src/big-picture/src/components/pages/game/game-settings-modal/cloud-tab.tsx index c4b61c0efc..1215703d5c 100644 --- a/src/big-picture/src/components/pages/game/game-settings-modal/cloud-tab.tsx +++ b/src/big-picture/src/components/pages/game/game-settings-modal/cloud-tab.tsx @@ -83,7 +83,13 @@ function EmulationRestoreModal({ onClose={onClose} onRestored={onRestored} onRestoreSuccess={() => showSuccessToast("Cloud save restored")} - onRestoreError={() => showErrorToast("Failed to restore cloud save")} + onRestoreError={(reason) => + showErrorToast( + reason === "unformatted" + ? "This memory card isn't formatted. Format it in your emulator before restoring." + : "Failed to restore cloud save" + ) + } regionId={RESTORE_MODAL_REGION_ID} actionsRegionId={RESTORE_MODAL_ACTIONS_REGION_ID} pickButtonId={RESTORE_MODAL_PICK_BUTTON_ID} diff --git a/src/big-picture/src/pages/settings/emulation/cloud-saves-section.tsx b/src/big-picture/src/pages/settings/emulation/cloud-saves-section.tsx index 75448425e2..014fe780e3 100644 --- a/src/big-picture/src/pages/settings/emulation/cloud-saves-section.tsx +++ b/src/big-picture/src/pages/settings/emulation/cloud-saves-section.tsx @@ -94,8 +94,13 @@ function RestoreModal({ onRestoreSuccess={() => showSuccessToast("Cloud save restored", SETTINGS_TOAST_OPTIONS) } - onRestoreError={() => - showErrorToast("Failed to restore cloud save", SETTINGS_TOAST_OPTIONS) + onRestoreError={(reason) => + showErrorToast( + reason === "unformatted" + ? "This memory card isn't formatted. Format it in your emulator before restoring." + : "Failed to restore cloud save", + SETTINGS_TOAST_OPTIONS + ) } regionId={RESTORE_MODAL_REGION_ID} actionsRegionId={RESTORE_MODAL_ACTIONS_REGION_ID} diff --git a/src/big-picture/src/pages/settings/emulation/emulation-cloud-restore-modal.tsx b/src/big-picture/src/pages/settings/emulation/emulation-cloud-restore-modal.tsx index de07bea028..ae98056301 100644 --- a/src/big-picture/src/pages/settings/emulation/emulation-cloud-restore-modal.tsx +++ b/src/big-picture/src/pages/settings/emulation/emulation-cloud-restore-modal.tsx @@ -1,6 +1,8 @@ import type { EmulationCloudSave, EmulationSavePlatform, + MemcardFormatState, + MemcardRestoreErrorReason, MemcardRestoreTarget, } from "@types"; import { useCallback, useEffect, useState } from "react"; @@ -38,7 +40,7 @@ interface EmulationCloudRestoreModalProps { onClose: () => void; onRestored: () => void; onRestoreSuccess: () => void; - onRestoreError: () => void; + onRestoreError: (reason?: MemcardRestoreErrorReason) => void; regionId: string; actionsRegionId: string; pickButtonId: string; @@ -65,6 +67,8 @@ export function EmulationCloudRestoreModal({ const { setFocus } = useNavigation(); const [targets, setTargets] = useState([]); const [selectedTarget, setSelectedTarget] = useState(null); + const [selectedFormat, setSelectedFormat] = + useState(null); const [isBusy, setIsBusy] = useState(false); useEffect(() => { @@ -78,6 +82,25 @@ export function EmulationCloudRestoreModal({ }); }, [platform, save]); + useEffect(() => { + if (!selectedTarget) { + setSelectedFormat(null); + return; + } + + let cancelled = false; + setSelectedFormat(null); + void globalThis.window.electron + .inspectMemcard(platform, selectedTarget) + .then((state) => { + if (!cancelled) setSelectedFormat(state); + }); + + return () => { + cancelled = true; + }; + }, [platform, selectedTarget]); + useEffect(() => { if (!save) return; @@ -134,7 +157,7 @@ export function EmulationCloudRestoreModal({ onRestored(); onClose(); } else { - onRestoreError(); + onRestoreError(result.reason); } } finally { setIsBusy(false); @@ -207,6 +230,12 @@ export function EmulationCloudRestoreModal({ )} + {selectedFormat === "unformatted" && ( +

+ {t("cloud_restore_unformatted")} +

+ )} + {t("cloud_restore_confirm")} diff --git a/src/big-picture/src/pages/settings/emulation/styles.scss b/src/big-picture/src/pages/settings/emulation/styles.scss index 70a9681831..78a6c9f9e0 100644 --- a/src/big-picture/src/pages/settings/emulation/styles.scss +++ b/src/big-picture/src/pages/settings/emulation/styles.scss @@ -20,6 +20,13 @@ .emulator-detail, .emulation-settings__scan-modal, +.emu-save-modal__hint { + margin: 0; + color: var(--warning, #e3b341); + font-size: 13px; + line-height: 1.4; +} + .emu-save-modal__restore, .emu-save-modal__rename { .emu-save-modal__target { diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 58be5cbd46..6f83f639fc 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1078,6 +1078,7 @@ "cloud_restore_no_cards": "No memory cards detected. Pick a card file to restore into.", "cloud_restore_success": "Save restored to your memory card", "cloud_restore_failed": "Could not restore this save", + "cloud_restore_unformatted": "This memory card isn't formatted yet. Format it in your emulator's memory card manager before restoring a save into it.", "cloud_more_actions": "More actions", "cloud_delete": "Delete", "cloud_delete_title": "Delete this cloud save?", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index db25da43e1..ef8f56486d 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -1034,6 +1034,7 @@ "cloud_restore_no_cards": "No se detectaron tarjetas de memoria. Elige un archivo de tarjeta en el que restaurar.", "cloud_restore_success": "Partida restaurada en tu tarjeta de memoria", "cloud_restore_failed": "No se pudo restaurar esta partida", + "cloud_restore_unformatted": "Esta tarjeta de memoria aún no está formateada. Formatéala en el administrador de tarjetas de memoria de tu emulador antes de restaurar una partida en ella.", "cloud_more_actions": "Más acciones", "cloud_delete": "Eliminar", "cloud_delete_title": "¿Eliminar esta partida en la nube?", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 09f118514d..65e53a3edf 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -989,6 +989,7 @@ "cloud_restore_no_cards": "Nenhum memory card detectado. Escolha um arquivo de card para restaurar.", "cloud_restore_success": "Salvamento restaurado no seu memory card", "cloud_restore_failed": "Não foi possível restaurar este salvamento", + "cloud_restore_unformatted": "Este memory card ainda não está formatado. Formate-o no gerenciador de memory cards do seu emulador antes de restaurar um salvamento nele.", "cloud_more_actions": "Mais ações", "cloud_delete": "Excluir", "cloud_delete_title": "Excluir este salvamento na nuvem?", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 66851091f7..f68e890ae8 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -1083,6 +1083,7 @@ "cloud_restore_no_cards": "Карты памяти не обнаружены. Выберите файл карты для восстановления.", "cloud_restore_success": "Сохранение восстановлено на вашу карту памяти", "cloud_restore_failed": "Не удалось восстановить это сохранение", + "cloud_restore_unformatted": "Эта карта памяти ещё не отформатирована. Отформатируйте её в менеджере карт памяти вашего эмулятора, прежде чем восстанавливать на неё сохранение.", "cloud_more_actions": "Больше действий", "cloud_delete": "Удалить", "cloud_delete_title": "Удалить это облачное сохранение?", diff --git a/src/main/events/emulators/index.ts b/src/main/events/emulators/index.ts index 3e0a5ce120..c9a6c89b46 100644 --- a/src/main/events/emulators/index.ts +++ b/src/main/events/emulators/index.ts @@ -28,4 +28,5 @@ import "./ps1-memcard-saves"; import "./export-ps1-save"; import "./upload-emulation-save"; import "./restore-emulation-save"; +import "./inspect-memcard"; import "./emulation-saves"; diff --git a/src/main/events/emulators/inspect-memcard.ts b/src/main/events/emulators/inspect-memcard.ts new file mode 100644 index 0000000000..5929bdc4bb --- /dev/null +++ b/src/main/events/emulators/inspect-memcard.ts @@ -0,0 +1,14 @@ +import { registerEvent } from "../register-event"; +import { emulators } from "@main/services"; +import type { EmulationSavePlatform, MemcardFormatState } from "@types"; + +const inspectMemcard = async ( + _event: Electron.IpcMainInvokeEvent, + platform: EmulationSavePlatform, + cardFilePath: string +): Promise => + platform === "ps2" + ? emulators.inspectPs2Card(cardFilePath) + : emulators.inspectPs1Card(cardFilePath); + +registerEvent("inspectMemcard", inspectMemcard); diff --git a/src/main/events/emulators/restore-emulation-save.ts b/src/main/events/emulators/restore-emulation-save.ts index 98f53a1090..aed0faa4d6 100644 --- a/src/main/events/emulators/restore-emulation-save.ts +++ b/src/main/events/emulators/restore-emulation-save.ts @@ -37,7 +37,14 @@ const restoreEmulationSave = async ( platform === "ps2" ? await emulators.importPsuIntoCard(targetCardFilePath, bytes) : await emulators.importMcsIntoCard(targetCardFilePath, bytes); - return { ok: result.ok, error: result.error }; + if (!result.ok) { + logger.error( + "Failed to restore emulation save", + { platform, saveId, targetCardFilePath, reason: result.reason }, + result.error + ); + } + return { ok: result.ok, error: result.error, reason: result.reason }; } catch (err) { logger.error("Failed to restore emulation save", err); return { diff --git a/src/main/services/emulators/ps1-memory-card/ps1-memory-card-writer.ts b/src/main/services/emulators/ps1-memory-card/ps1-memory-card-writer.ts index 85f3e6655e..3faa6aee69 100644 --- a/src/main/services/emulators/ps1-memory-card/ps1-memory-card-writer.ts +++ b/src/main/services/emulators/ps1-memory-card/ps1-memory-card-writer.ts @@ -2,6 +2,7 @@ import { promises as fs } from "node:fs"; import { findDataOffset, + inspectPs1Card, listPs1Saves, readPs1SaveContents, } from "./ps1-memory-card"; @@ -111,6 +112,7 @@ const chainFrom = (directory: Buffer, firstBlock: number): number[] => { export interface Ps1ImportResult { ok: boolean; error?: string; + reason?: "unformatted"; identifier?: string; } @@ -192,6 +194,14 @@ export const importMcsIntoCard = async ( const mcs = parseMcsBuffer(mcsBuffer); if (!mcs) return { ok: false, error: "Invalid .mcs data" }; + if ((await inspectPs1Card(cardFilePath)) === "unformatted") { + return { + ok: false, + reason: "unformatted", + error: "The memory card is not formatted", + }; + } + const backupPath = `${cardFilePath}.hydra-bak`; try { await fs.copyFile(cardFilePath, backupPath); @@ -212,7 +222,11 @@ export const importMcsIntoCard = async ( const dataOffset = findDataOffset(fileBuf, fileBuf.length); if (dataOffset === null) { await restore(); - return { ok: false, error: "Not a writable PS1 memory card" }; + return { + ok: false, + reason: "unformatted", + error: "Not a writable PS1 memory card", + }; } const applied = applyMcsToCard(fileBuf.subarray(dataOffset), mcs); if (!applied.ok) { diff --git a/src/main/services/emulators/ps1-memory-card/ps1-memory-card.ts b/src/main/services/emulators/ps1-memory-card/ps1-memory-card.ts index fdabedcf4f..4b2de793ea 100644 --- a/src/main/services/emulators/ps1-memory-card/ps1-memory-card.ts +++ b/src/main/services/emulators/ps1-memory-card/ps1-memory-card.ts @@ -49,6 +49,32 @@ export const findDataOffset = ( return null; }; +export const inspectPs1Card = async ( + cardFilePath: string +): Promise<"formatted" | "unformatted" | "unreadable"> => { + let fh: FileHandle | null = null; + try { + fh = await fs.open(cardFilePath, "r"); + const stat = await fh.stat(); + if (stat.size < PS1_BLOCK_BYTES) return "unformatted"; + + const probeLen = Math.min( + stat.size, + (HEADER_OFFSET_CANDIDATES.at(-1) ?? 0) + PS1_BLOCK_BYTES + ); + const probe = Buffer.alloc(probeLen); + await fh.read(probe, 0, probeLen, 0); + + return findDataOffset(probe, stat.size) === null + ? "unformatted" + : "formatted"; + } catch { + return "unreadable"; + } finally { + if (fh) await fh.close().catch(() => undefined); + } +}; + const readFilename = (frame: Buffer): string => { const raw = frame.subarray( PS1_FILENAME_OFFSET, diff --git a/src/main/services/emulators/ps2-memory-card/ps2-memory-card-writer.ts b/src/main/services/emulators/ps2-memory-card/ps2-memory-card-writer.ts index 9f99d30a64..b46dadb9d8 100644 --- a/src/main/services/emulators/ps2-memory-card/ps2-memory-card-writer.ts +++ b/src/main/services/emulators/ps2-memory-card/ps2-memory-card-writer.ts @@ -7,6 +7,7 @@ import { FAT_ALLOCATED_BIT, FAT_VALUE_MASK, detectEcc, + inspectPs2Card, listSaves, parseDirEntry, readSaveContents, @@ -501,6 +502,7 @@ class Ps2CardWriter { export interface Ps2ImportResult { ok: boolean; error?: string; + reason?: "unformatted"; folderName?: string; } @@ -533,6 +535,14 @@ export const importPsuIntoCard = async ( const psu = parsePsuBuffer(psuBuffer); if (!psu) return { ok: false, error: "Invalid .psu data" }; + if ((await inspectPs2Card(cardFilePath)) === "unformatted") { + return { + ok: false, + reason: "unformatted", + error: "The memory card is not formatted", + }; + } + const backupPath = `${cardFilePath}.hydra-bak`; try { await fs.copyFile(cardFilePath, backupPath); diff --git a/src/main/services/emulators/ps2-memory-card/ps2-memory-card.ts b/src/main/services/emulators/ps2-memory-card/ps2-memory-card.ts index 803f9f758b..b8dc98be24 100644 --- a/src/main/services/emulators/ps2-memory-card/ps2-memory-card.ts +++ b/src/main/services/emulators/ps2-memory-card/ps2-memory-card.ts @@ -135,6 +135,21 @@ export const readSuperblock = async ( }; }; +export const inspectPs2Card = async ( + cardFilePath: string +): Promise<"formatted" | "unformatted" | "unreadable"> => { + let fh: FileHandle | null = null; + try { + fh = await fs.open(cardFilePath, "r"); + const sb = await readSuperblock(fh); + return sb ? "formatted" : "unformatted"; + } catch { + return "unreadable"; + } finally { + if (fh) await fh.close().catch(() => undefined); + } +}; + export const detectEcc = ( fileSize: number, sb: Superblock diff --git a/src/preload/index.ts b/src/preload/index.ts index 7102ea2cff..e1f84b3de3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -33,6 +33,7 @@ import type { EmulationBackupProgress, EmulationCloudSave, EmulationSavePlatform, + MemcardFormatState, MemcardRestoreResult, MemcardRestoreTarget, } from "@types"; @@ -359,6 +360,11 @@ contextBridge.exposeInMainWorld("electron", { platform: EmulationSavePlatform ): Promise => ipcRenderer.invoke("getMemcardRestoreTargets", platform), + inspectMemcard: ( + platform: EmulationSavePlatform, + cardFilePath: string + ): Promise => + ipcRenderer.invoke("inspectMemcard", platform, cardFilePath), restoreEmulationSave: ( platform: EmulationSavePlatform, saveId: string, diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index d87d1aac61..e7245b80bf 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -54,6 +54,7 @@ import type { DetectedRom, EmulationCloudSave, EmulationSavePlatform, + MemcardFormatState, MemcardRestoreResult, MemcardRestoreTarget, } from "@types"; @@ -544,6 +545,10 @@ declare global { getMemcardRestoreTargets: ( platform: EmulationSavePlatform ) => Promise; + inspectMemcard: ( + platform: EmulationSavePlatform, + cardFilePath: string + ) => Promise; restoreEmulationSave: ( platform: EmulationSavePlatform, saveId: string, diff --git a/src/renderer/src/pages/settings/emulation/emulation-save-modals.scss b/src/renderer/src/pages/settings/emulation/emulation-save-modals.scss index 8e55cc28c7..91700b482c 100644 --- a/src/renderer/src/pages/settings/emulation/emulation-save-modals.scss +++ b/src/renderer/src/pages/settings/emulation/emulation-save-modals.scss @@ -57,6 +57,13 @@ font-size: 13px; } + &__hint { + margin: 0; + color: var(--warning, #e3b341); + font-size: 13px; + line-height: 1.4; + } + &__actions { display: flex; justify-content: flex-end; diff --git a/src/renderer/src/pages/settings/emulation/emulation-save-modals.tsx b/src/renderer/src/pages/settings/emulation/emulation-save-modals.tsx index 1974f382f2..ebd4a5eb69 100644 --- a/src/renderer/src/pages/settings/emulation/emulation-save-modals.tsx +++ b/src/renderer/src/pages/settings/emulation/emulation-save-modals.tsx @@ -6,6 +6,7 @@ import { useToast } from "@renderer/hooks"; import type { EmulationCloudSave, EmulationSavePlatform, + MemcardFormatState, MemcardRestoreTarget, } from "@types"; @@ -58,6 +59,8 @@ export function RestoreModal({ const { showSuccessToast, showErrorToast } = useToast(); const [targets, setTargets] = useState([]); const [selected, setSelected] = useState(null); + const [selectedFormat, setSelectedFormat] = + useState(null); const [busy, setBusy] = useState(false); useEffect(() => { @@ -68,6 +71,21 @@ export function RestoreModal({ }); }, [save, platform]); + useEffect(() => { + if (!selected) { + setSelectedFormat(null); + return; + } + let cancelled = false; + setSelectedFormat(null); + window.electron.inspectMemcard(platform, selected).then((state) => { + if (!cancelled) setSelectedFormat(state); + }); + return () => { + cancelled = true; + }; + }, [selected, platform]); + const handlePickFile = useCallback(async () => { const result = await window.electron.showOpenDialog({ properties: ["openFile"], @@ -97,7 +115,13 @@ export function RestoreModal({ onRestored(); onClose(); } else { - showErrorToast(t("cloud_restore_failed")); + showErrorToast( + t( + res.reason === "unformatted" + ? "cloud_restore_unformatted" + : "cloud_restore_failed" + ) + ); } } finally { setBusy(false); @@ -149,6 +173,12 @@ export function RestoreModal({ )} + {selectedFormat === "unformatted" && ( +

+ {t("cloud_restore_unformatted")} +

+ )} +
diff --git a/src/types/emulator.types.ts b/src/types/emulator.types.ts index dea6ce6ae3..6d79acbf21 100644 --- a/src/types/emulator.types.ts +++ b/src/types/emulator.types.ts @@ -146,10 +146,15 @@ export interface EmulationCloudSave { updatedAt: string; } +export type MemcardFormatState = "formatted" | "unformatted" | "unreadable"; + +export type MemcardRestoreErrorReason = "unformatted"; + /** Result of writing a downloaded cloud save back into a local card. */ export interface MemcardRestoreResult { ok: boolean; error?: string; + reason?: MemcardRestoreErrorReason; } /** A local memory card a cloud save can be restored into. */ From d1afd0696e5e2ffaa9c4146814648b7ad801eda8 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 21 Jun 2026 16:34:44 +0300 Subject: [PATCH 2/6] fix: address restore modal review feedback Disable the Restore button while card inspection is in flight, treat a rejected inspectMemcard promise as an unreadable card, and route the big-picture restore error toasts through i18n instead of hardcoded English strings. --- .../game/game-settings-modal/cloud-tab.tsx | 9 ++++++--- .../settings/emulation/cloud-saves-section.tsx | 9 ++++++--- .../emulation-cloud-restore-modal.tsx | 11 +++++++++-- .../emulation/emulation-save-modals.tsx | 18 ++++++++++++++---- 4 files changed, 35 insertions(+), 12 deletions(-) diff --git a/src/big-picture/src/components/pages/game/game-settings-modal/cloud-tab.tsx b/src/big-picture/src/components/pages/game/game-settings-modal/cloud-tab.tsx index 1215703d5c..2cda93fb7c 100644 --- a/src/big-picture/src/components/pages/game/game-settings-modal/cloud-tab.tsx +++ b/src/big-picture/src/components/pages/game/game-settings-modal/cloud-tab.tsx @@ -75,6 +75,7 @@ function EmulationRestoreModal({ onClose: () => void; onRestored: () => void; }>) { + const { t } = useTranslation("settings"); const { showErrorToast, showSuccessToast } = useBigPictureToast(); return ( showSuccessToast("Cloud save restored")} onRestoreError={(reason) => showErrorToast( - reason === "unformatted" - ? "This memory card isn't formatted. Format it in your emulator before restoring." - : "Failed to restore cloud save" + t( + reason === "unformatted" + ? "cloud_restore_unformatted" + : "cloud_restore_failed" + ) ) } regionId={RESTORE_MODAL_REGION_ID} diff --git a/src/big-picture/src/pages/settings/emulation/cloud-saves-section.tsx b/src/big-picture/src/pages/settings/emulation/cloud-saves-section.tsx index 014fe780e3..abab8a7aae 100644 --- a/src/big-picture/src/pages/settings/emulation/cloud-saves-section.tsx +++ b/src/big-picture/src/pages/settings/emulation/cloud-saves-section.tsx @@ -84,6 +84,7 @@ function RestoreModal({ onClose, onRestored, }: Readonly) { + const { t } = useTranslation("settings"); const { showErrorToast, showSuccessToast } = useBigPictureToast(); return ( showErrorToast( - reason === "unformatted" - ? "This memory card isn't formatted. Format it in your emulator before restoring." - : "Failed to restore cloud save", + t( + reason === "unformatted" + ? "cloud_restore_unformatted" + : "cloud_restore_failed" + ), SETTINGS_TOAST_OPTIONS ) } diff --git a/src/big-picture/src/pages/settings/emulation/emulation-cloud-restore-modal.tsx b/src/big-picture/src/pages/settings/emulation/emulation-cloud-restore-modal.tsx index ae98056301..698131905f 100644 --- a/src/big-picture/src/pages/settings/emulation/emulation-cloud-restore-modal.tsx +++ b/src/big-picture/src/pages/settings/emulation/emulation-cloud-restore-modal.tsx @@ -90,10 +90,13 @@ export function EmulationCloudRestoreModal({ let cancelled = false; setSelectedFormat(null); - void globalThis.window.electron + globalThis.window.electron .inspectMemcard(platform, selectedTarget) .then((state) => { if (!cancelled) setSelectedFormat(state); + }) + .catch(() => { + if (!cancelled) setSelectedFormat("unreadable"); }); return () => { @@ -257,7 +260,11 @@ export function EmulationCloudRestoreModal({ selectedTarget )} loading={isBusy} - disabled={!selectedTarget || selectedFormat === "unformatted"} + disabled={ + !selectedTarget || + !selectedFormat || + selectedFormat === "unformatted" + } onClick={handleRestore} > {t("cloud_restore_confirm")} diff --git a/src/renderer/src/pages/settings/emulation/emulation-save-modals.tsx b/src/renderer/src/pages/settings/emulation/emulation-save-modals.tsx index ebd4a5eb69..92788dc020 100644 --- a/src/renderer/src/pages/settings/emulation/emulation-save-modals.tsx +++ b/src/renderer/src/pages/settings/emulation/emulation-save-modals.tsx @@ -78,9 +78,14 @@ export function RestoreModal({ } let cancelled = false; setSelectedFormat(null); - window.electron.inspectMemcard(platform, selected).then((state) => { - if (!cancelled) setSelectedFormat(state); - }); + window.electron + .inspectMemcard(platform, selected) + .then((state) => { + if (!cancelled) setSelectedFormat(state); + }) + .catch(() => { + if (!cancelled) setSelectedFormat("unreadable"); + }); return () => { cancelled = true; }; @@ -186,7 +191,12 @@ export function RestoreModal({ From 05a65cee7d988dea32308a380f92c7830888f337 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 21 Jun 2026 16:37:19 +0300 Subject: [PATCH 3/6] fix: translate big-picture restore success toasts Route the onRestoreSuccess toasts through cloud_restore_success so they match the now-translated error path and the renderer modal, instead of a hardcoded English string. --- .../src/components/pages/game/game-settings-modal/cloud-tab.tsx | 2 +- .../src/pages/settings/emulation/cloud-saves-section.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/big-picture/src/components/pages/game/game-settings-modal/cloud-tab.tsx b/src/big-picture/src/components/pages/game/game-settings-modal/cloud-tab.tsx index 2cda93fb7c..8476608a40 100644 --- a/src/big-picture/src/components/pages/game/game-settings-modal/cloud-tab.tsx +++ b/src/big-picture/src/components/pages/game/game-settings-modal/cloud-tab.tsx @@ -83,7 +83,7 @@ function EmulationRestoreModal({ platform={platform} onClose={onClose} onRestored={onRestored} - onRestoreSuccess={() => showSuccessToast("Cloud save restored")} + onRestoreSuccess={() => showSuccessToast(t("cloud_restore_success"))} onRestoreError={(reason) => showErrorToast( t( diff --git a/src/big-picture/src/pages/settings/emulation/cloud-saves-section.tsx b/src/big-picture/src/pages/settings/emulation/cloud-saves-section.tsx index abab8a7aae..063936b878 100644 --- a/src/big-picture/src/pages/settings/emulation/cloud-saves-section.tsx +++ b/src/big-picture/src/pages/settings/emulation/cloud-saves-section.tsx @@ -93,7 +93,7 @@ function RestoreModal({ onClose={onClose} onRestored={onRestored} onRestoreSuccess={() => - showSuccessToast("Cloud save restored", SETTINGS_TOAST_OPTIONS) + showSuccessToast(t("cloud_restore_success"), SETTINGS_TOAST_OPTIONS) } onRestoreError={(reason) => showErrorToast( From 671f50a0019913a6c1ba9d41510d5377d7e79577 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 21 Jun 2026 16:57:06 +0300 Subject: [PATCH 4/6] fix: stop hint styles leaking into emulator detail view The earlier hint rule was inserted into the middle of a shared comma-separated selector group, which split it and applied the warning colour to .emulator-detail and .emulation-settings__scan-modal. Restore the original four-selector focus block and keep .emu-save-modal__hint as its own rule. --- src/big-picture/src/pages/settings/emulation/styles.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/big-picture/src/pages/settings/emulation/styles.scss b/src/big-picture/src/pages/settings/emulation/styles.scss index 78a6c9f9e0..8c8ddb7697 100644 --- a/src/big-picture/src/pages/settings/emulation/styles.scss +++ b/src/big-picture/src/pages/settings/emulation/styles.scss @@ -18,8 +18,6 @@ outline-offset: 2px; } -.emulator-detail, -.emulation-settings__scan-modal, .emu-save-modal__hint { margin: 0; color: var(--warning, #e3b341); @@ -27,6 +25,8 @@ line-height: 1.4; } +.emulator-detail, +.emulation-settings__scan-modal, .emu-save-modal__restore, .emu-save-modal__rename { .emu-save-modal__target { From acdda87bb63dce85b455712ac74b2e181dd103d6 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Mon, 22 Jun 2026 20:54:55 +0300 Subject: [PATCH 5/6] feat: PCSX2-specific message for unformatted PS2 memory cards PS2 cards now point users at PCSX2's auto-format-on-save behaviour instead of the generic memory card manager guidance, which does not apply to PCSX2. PS1 keeps the generic message via a separate key. --- .../pages/game/game-settings-modal/cloud-tab.tsx | 10 +++++----- .../pages/settings/emulation/cloud-saves-section.tsx | 10 +++++----- .../emulation/emulation-cloud-restore-modal.tsx | 9 ++++++--- src/locales/en/translation.json | 1 + src/locales/es/translation.json | 1 + src/locales/pt-BR/translation.json | 1 + src/locales/ru/translation.json | 1 + .../settings/emulation/emulation-save-modals.tsx | 12 ++++++++---- 8 files changed, 28 insertions(+), 17 deletions(-) diff --git a/src/big-picture/src/components/pages/game/game-settings-modal/cloud-tab.tsx b/src/big-picture/src/components/pages/game/game-settings-modal/cloud-tab.tsx index 8476608a40..55fef88b9f 100644 --- a/src/big-picture/src/components/pages/game/game-settings-modal/cloud-tab.tsx +++ b/src/big-picture/src/components/pages/game/game-settings-modal/cloud-tab.tsx @@ -77,6 +77,10 @@ function EmulationRestoreModal({ }>) { const { t } = useTranslation("settings"); const { showErrorToast, showSuccessToast } = useBigPictureToast(); + const unformattedKey = + platform === "ps2" + ? "cloud_restore_unformatted_ps2" + : "cloud_restore_unformatted"; return ( showSuccessToast(t("cloud_restore_success"))} onRestoreError={(reason) => showErrorToast( - t( - reason === "unformatted" - ? "cloud_restore_unformatted" - : "cloud_restore_failed" - ) + t(reason === "unformatted" ? unformattedKey : "cloud_restore_failed") ) } regionId={RESTORE_MODAL_REGION_ID} diff --git a/src/big-picture/src/pages/settings/emulation/cloud-saves-section.tsx b/src/big-picture/src/pages/settings/emulation/cloud-saves-section.tsx index 063936b878..99008e6172 100644 --- a/src/big-picture/src/pages/settings/emulation/cloud-saves-section.tsx +++ b/src/big-picture/src/pages/settings/emulation/cloud-saves-section.tsx @@ -86,6 +86,10 @@ function RestoreModal({ }: Readonly) { const { t } = useTranslation("settings"); const { showErrorToast, showSuccessToast } = useBigPictureToast(); + const unformattedKey = + platform === "ps2" + ? "cloud_restore_unformatted_ps2" + : "cloud_restore_unformatted"; return ( showErrorToast( - t( - reason === "unformatted" - ? "cloud_restore_unformatted" - : "cloud_restore_failed" - ), + t(reason === "unformatted" ? unformattedKey : "cloud_restore_failed"), SETTINGS_TOAST_OPTIONS ) } diff --git a/src/big-picture/src/pages/settings/emulation/emulation-cloud-restore-modal.tsx b/src/big-picture/src/pages/settings/emulation/emulation-cloud-restore-modal.tsx index 698131905f..ffb4932535 100644 --- a/src/big-picture/src/pages/settings/emulation/emulation-cloud-restore-modal.tsx +++ b/src/big-picture/src/pages/settings/emulation/emulation-cloud-restore-modal.tsx @@ -71,6 +71,11 @@ export function EmulationCloudRestoreModal({ useState(null); const [isBusy, setIsBusy] = useState(false); + const unformattedKey = + platform === "ps2" + ? "cloud_restore_unformatted_ps2" + : "cloud_restore_unformatted"; + useEffect(() => { if (!save) return; @@ -234,9 +239,7 @@ export function EmulationCloudRestoreModal({
{selectedFormat === "unformatted" && ( -

- {t("cloud_restore_unformatted")} -

+

{t(unformattedKey)}

)} { const result = await window.electron.showOpenDialog({ properties: ["openFile"], @@ -123,7 +128,7 @@ export function RestoreModal({ showErrorToast( t( res.reason === "unformatted" - ? "cloud_restore_unformatted" + ? unformattedKey : "cloud_restore_failed" ) ); @@ -135,6 +140,7 @@ export function RestoreModal({ save, selected, platform, + unformattedKey, showSuccessToast, showErrorToast, t, @@ -179,9 +185,7 @@ export function RestoreModal({ {selectedFormat === "unformatted" && ( -

- {t("cloud_restore_unformatted")} -

+

{t(unformattedKey)}

)}
From 1a604f2fc14183af778f94c48eefff74dcf79b03 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 23 Jun 2026 01:20:47 +0300 Subject: [PATCH 6/6] fix: guard restore target fetch against stale resolution Add a cancelled flag to the getMemcardRestoreTargets effect in both restore modals so an in-flight fetch from a previous platform/save can't resolve late and overwrite the current targets and selection. --- .../settings/emulation/emulation-cloud-restore-modal.tsx | 6 ++++++ .../src/pages/settings/emulation/emulation-save-modals.tsx | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/src/big-picture/src/pages/settings/emulation/emulation-cloud-restore-modal.tsx b/src/big-picture/src/pages/settings/emulation/emulation-cloud-restore-modal.tsx index ffb4932535..5bfa55b47d 100644 --- a/src/big-picture/src/pages/settings/emulation/emulation-cloud-restore-modal.tsx +++ b/src/big-picture/src/pages/settings/emulation/emulation-cloud-restore-modal.tsx @@ -79,12 +79,18 @@ export function EmulationCloudRestoreModal({ useEffect(() => { if (!save) return; + let cancelled = false; void globalThis.window.electron .getMemcardRestoreTargets(platform) .then((foundTargets) => { + if (cancelled) return; setTargets(foundTargets); setSelectedTarget(foundTargets[0]?.cardFilePath ?? null); }); + + return () => { + cancelled = true; + }; }, [platform, save]); useEffect(() => { diff --git a/src/renderer/src/pages/settings/emulation/emulation-save-modals.tsx b/src/renderer/src/pages/settings/emulation/emulation-save-modals.tsx index df5b919c68..d6fb433641 100644 --- a/src/renderer/src/pages/settings/emulation/emulation-save-modals.tsx +++ b/src/renderer/src/pages/settings/emulation/emulation-save-modals.tsx @@ -65,10 +65,15 @@ export function RestoreModal({ useEffect(() => { if (!save) return; + let cancelled = false; window.electron.getMemcardRestoreTargets(platform).then((found) => { + if (cancelled) return; setTargets(found); setSelected((prev) => prev ?? found[0]?.cardFilePath ?? null); }); + return () => { + cancelled = true; + }; }, [save, platform]); useEffect(() => {