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..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 @@ -75,15 +75,24 @@ function EmulationRestoreModal({ onClose: () => void; onRestored: () => void; }>) { + const { t } = useTranslation("settings"); const { showErrorToast, showSuccessToast } = useBigPictureToast(); + const unformattedKey = + platform === "ps2" + ? "cloud_restore_unformatted_ps2" + : "cloud_restore_unformatted"; return ( showSuccessToast("Cloud save restored")} - onRestoreError={() => showErrorToast("Failed to restore cloud save")} + onRestoreSuccess={() => showSuccessToast(t("cloud_restore_success"))} + onRestoreError={(reason) => + showErrorToast( + t(reason === "unformatted" ? unformattedKey : "cloud_restore_failed") + ) + } 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..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 @@ -84,7 +84,12 @@ function RestoreModal({ onClose, onRestored, }: Readonly) { + const { t } = useTranslation("settings"); const { showErrorToast, showSuccessToast } = useBigPictureToast(); + const unformattedKey = + platform === "ps2" + ? "cloud_restore_unformatted_ps2" + : "cloud_restore_unformatted"; return ( - showSuccessToast("Cloud save restored", SETTINGS_TOAST_OPTIONS) + showSuccessToast(t("cloud_restore_success"), SETTINGS_TOAST_OPTIONS) } - onRestoreError={() => - showErrorToast("Failed to restore cloud save", SETTINGS_TOAST_OPTIONS) + onRestoreError={(reason) => + showErrorToast( + t(reason === "unformatted" ? unformattedKey : "cloud_restore_failed"), + 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..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 @@ -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,19 +67,54 @@ 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); + const unformattedKey = + platform === "ps2" + ? "cloud_restore_unformatted_ps2" + : "cloud_restore_unformatted"; + 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(() => { + if (!selectedTarget) { + setSelectedFormat(null); + return; + } + + let cancelled = false; + setSelectedFormat(null); + globalThis.window.electron + .inspectMemcard(platform, selectedTarget) + .then((state) => { + if (!cancelled) setSelectedFormat(state); + }) + .catch(() => { + if (!cancelled) setSelectedFormat("unreadable"); + }); + + return () => { + cancelled = true; + }; + }, [platform, selectedTarget]); + useEffect(() => { if (!save) return; @@ -134,7 +171,7 @@ export function EmulationCloudRestoreModal({ onRestored(); onClose(); } else { - onRestoreError(); + onRestoreError(result.reason); } } finally { setIsBusy(false); @@ -207,6 +244,10 @@ export function EmulationCloudRestoreModal({ )} + {selectedFormat === "unformatted" && ( +

{t(unformattedKey)}

+ )} + {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..8c8ddb7697 100644 --- a/src/big-picture/src/pages/settings/emulation/styles.scss +++ b/src/big-picture/src/pages/settings/emulation/styles.scss @@ -18,6 +18,13 @@ outline-offset: 2px; } +.emu-save-modal__hint { + margin: 0; + color: var(--warning, #e3b341); + font-size: 13px; + line-height: 1.4; +} + .emulator-detail, .emulation-settings__scan-modal, .emu-save-modal__restore, diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 7f6cc3e002..8d7fb78d31 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1078,6 +1078,8 @@ "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_restore_unformatted_ps2": "This memory card isn't formatted. Create a save on it so PCSX2 formats it automatically.", "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 069c5f49f8..44a97a4553 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -1034,6 +1034,8 @@ "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_restore_unformatted_ps2": "Esta tarjeta de memoria no está formateada. Crea una partida guardada en ella para que PCSX2 la formatee automáticamente.", "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 e5f30a9c69..0441503752 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -989,6 +989,8 @@ "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_restore_unformatted_ps2": "Este memory card não está formatado. Crie um salvamento nele para que o PCSX2 o formate automaticamente.", "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 3e2d4993eb..a940ef351b 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -1083,6 +1083,8 @@ "cloud_restore_no_cards": "Карты памяти не обнаружены. Выберите файл карты для восстановления.", "cloud_restore_success": "Сохранение восстановлено на вашу карту памяти", "cloud_restore_failed": "Не удалось восстановить это сохранение", + "cloud_restore_unformatted": "Эта карта памяти ещё не отформатирована. Отформатируйте её в менеджере карт памяти вашего эмулятора, прежде чем восстанавливать на неё сохранение.", + "cloud_restore_unformatted_ps2": "Эта карта памяти не отформатирована. Создайте на ней сохранение, чтобы PCSX2 отформатировал её автоматически.", "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 38bca0be64..2e640494e5 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 8ae4adabb5..b423028677 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..d6fb433641 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,16 +59,48 @@ 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(() => { 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(() => { + if (!selected) { + setSelectedFormat(null); + return; + } + let cancelled = false; + setSelectedFormat(null); + window.electron + .inspectMemcard(platform, selected) + .then((state) => { + if (!cancelled) setSelectedFormat(state); + }) + .catch(() => { + if (!cancelled) setSelectedFormat("unreadable"); + }); + return () => { + cancelled = true; + }; + }, [selected, platform]); + + const unformattedKey = + platform === "ps2" + ? "cloud_restore_unformatted_ps2" + : "cloud_restore_unformatted"; + const handlePickFile = useCallback(async () => { const result = await window.electron.showOpenDialog({ properties: ["openFile"], @@ -97,7 +130,13 @@ export function RestoreModal({ onRestored(); onClose(); } else { - showErrorToast(t("cloud_restore_failed")); + showErrorToast( + t( + res.reason === "unformatted" + ? unformattedKey + : "cloud_restore_failed" + ) + ); } } finally { setBusy(false); @@ -106,6 +145,7 @@ export function RestoreModal({ save, selected, platform, + unformattedKey, showSuccessToast, showErrorToast, t, @@ -149,6 +189,10 @@ export function RestoreModal({ )} + {selectedFormat === "unformatted" && ( +

{t(unformattedKey)}

+ )} +
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. */