mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-19 02:20:02 -05:00
Open a save decrypted and edit its files (save.mount / save.unmount in the helper, pfsmgr + sceFsMountSaveData from a /data copy, write-back only when changed); verified on FW 13.60
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik
This commit is contained in:
1 parent
b74365dbc0
commit
679d005958
36 files changed
+867
-1
No files matched your search
@@ -4,6 +4,17 @@ What's new in ps5upload, written for humans.
|
||||
|
||||
---
|
||||
|
||||
## 7.0.17
|
||||
|
||||
- **Open a save and edit its files.** Save data has an Open save button per save: the helper
|
||||
copies the save image under `/data`, unseals its key through the console's own pfsmgr and
|
||||
mounts the image decrypted, and Files opens on it. Read, replace or add files; Close &
|
||||
write back unmounts it and copies the image back into the real save only if something
|
||||
changed, so a look leaves the save untouched. One save open at a time. PS5 saves verified
|
||||
on FW 13.60 (an untouched open keeps the image's timestamp); PS4 saves (`sdimg_*` with the
|
||||
key in `<name>.bin`) follow the same path but have not been tried. The recipe is
|
||||
earthonion's garlic-savemgr, credited in the README. Needs the 7.0.17 helper.
|
||||
|
||||
## 7.0.16
|
||||
|
||||
- **Measure how a game plays, then pack it that way.** A game's page has a new card for a
|
||||
|
||||
@@ -424,6 +424,7 @@ This software builds on the following open-source projects:
|
||||
* [elf-arsenal](https://git.etawen.dev/soniciso/elf-arsenal) (soniciso, Sanad) — parts of the drive sensor, cheat and wake-watchdog code
|
||||
* [PS5 Dump Forge](https://github.com/quer3q/ps5-dump-forge) (quer3q) — the UFS2 (`.ffpkg`) and PFS (`.ffpfs`) image writers, the streaming `.ffpfsc` container, reader and safety fixes to the package crates, and the ideas behind hash-checked images and spare space for read-write mounts
|
||||
* [ampr_emu](https://github.com/drakmor/ampr_emu) and [ShadowMountPlus](https://github.com/drakmor/ShadowMountPlus) (drakmor) — the AMPR emulator that plays LZ4 asset packs, and the mount tool that shows our images on the home screen. Our packer is a port of its `ampr_pack.py`, the index writer follows `build_ampr_index.py`, and "Measure how it plays" is a port of `ampr_pack_profile.py` fed by the debug build's command journal
|
||||
* [garlic-savemgr](https://github.com/earthonion/garlic-savemgr) (earthonion) — how a save image is opened on the console: the sealed key unsealed through `/dev/pfsmgr` and the image mounted with `sceFsMountSaveData` from a copy under `/data`, which "Open save" follows
|
||||
* [PROSPEROPatches](https://prosperopatches.com/), [ORBISPatches](https://orbispatches.com/) and [TMDB](https://www.themoviedb.org/) — game details and artwork
|
||||
* [etaHEN PS5_Cheats](https://github.com/etaHEN/PS5_Cheats) and GoldHEN — cheat files
|
||||
* Everyone who contributed code, translations and docs, and the Discord testers
|
||||
|
||||
@@ -2380,6 +2380,39 @@ export interface SaveList {
|
||||
saves: SaveEntry[];
|
||||
}
|
||||
|
||||
/** What the helper has open as a decrypted save (`/api/ps5/saves/open`, `/close`). */
|
||||
export interface OpenSave {
|
||||
mounted: boolean;
|
||||
/** The save image under /user/home; "" when nothing is open. */
|
||||
image: string;
|
||||
mount_point: string;
|
||||
kind: "ps5" | "ps4" | "";
|
||||
was_open?: boolean | null;
|
||||
written_back?: boolean | null;
|
||||
}
|
||||
|
||||
async function engineJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${getEngineUrl()}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const out = (await res.json().catch(() => ({}))) as T & { error?: string };
|
||||
if (!res.ok) throw new Error(out.error || `${path} failed (${res.status})`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Mount a save image decrypted on the PS5 (the helper copies it under /data first); an
|
||||
* empty `image` reports what is open. Its files then show under `mount_point` in Files. */
|
||||
export function savesOpen(addr: string, image: string): Promise<OpenSave> {
|
||||
return engineJson<OpenSave>("/api/ps5/saves/open", { addr, image });
|
||||
}
|
||||
|
||||
/** Unmount the open save and write the image back into the real save when it changed. */
|
||||
export function savesClose(addr: string): Promise<OpenSave> {
|
||||
return engineJson<OpenSave>("/api/ps5/saves/close", { addr });
|
||||
}
|
||||
|
||||
/** List save data folders. user_id=0 lists every user's saves. */
|
||||
export async function savesList(
|
||||
addr: string,
|
||||
|
||||
@@ -1903,6 +1903,19 @@ saves_backup_usb_summary: "تم نسخ {ok}/{total} إلى USB",
|
||||
saves_backup_usb_summary_failed: "تم نسخ {ok}/{total} إلى USB؛ فشل {failed}",
|
||||
saves_backup_usb_tooltip: "ينسخ بيانات الحفظ هذه إلى مسار USB المحدد في الإعدادات، دون مغادرة PS5.",
|
||||
saves_open_folder: "فتح المجلد",
|
||||
saves_open: "فتح الحفظ",
|
||||
saves_open_hint: "يركّب هذا الحفظ مفكوك التشفير على PS5 ويتيح تصفح ملفاته في الملفات. تُكتب التغييرات عند الإغلاق.",
|
||||
saves_open_pick_title: "أي حفظ من {title}؟",
|
||||
saves_open_none: "لا توجد صورة حفظ في هذا المجلد.",
|
||||
saves_open_banner: "{name} مفتوح في {path}. تُكتب التغييرات في الحفظ عند الإغلاق.",
|
||||
saves_open_in_files: "فتح في الملفات",
|
||||
saves_close: "إغلاق وكتابة",
|
||||
saves_closed: "أُغلق الحفظ",
|
||||
saves_closed_written: "كُتبت الصورة مجددًا في الحفظ.",
|
||||
saves_closed_unchanged: "لم يتغير شيء؛ الحفظ كما كان.",
|
||||
saves_open_failed: "تعذر فتح الحفظ: {error}",
|
||||
saves_opened: "الحفظ مفتوح",
|
||||
saves_opened_body: "تصفحه وعدّله تحت {path} في الملفات. أغلقه من بيانات الحفظ عند الانتهاء.",
|
||||
saves_open_folder_hint: "يفتح مجلد بيانات الحفظ هذه في متصفح نظام الملفات",
|
||||
saves_ps4_warning_body: "قد لا يعمل نسخ واستعادة بيانات حفظ PS4 القديمة (مثل ألعاب PS4 عبر التوافق العكسي) بشكل موثوق — إذ يخزّن محاكي PS4 بيانات الحفظ داخليًا، ونسخ الملفات إلى الجهاز لا يؤدي دائمًا إلى إعادة التحميل. إذا لم تظهر بيانات حفظ PS4 المستعادة داخل اللعبة، جرّب إغلاق اللعبة وفتحها من جديد، أو إعادة تشغيل الجهاز. هذا قيد معروف يُتابَع في المشكلة #198.",
|
||||
saves_ps4_warning_title: "بيانات الحفظ بصيغة PS4:",
|
||||
|
||||
@@ -1903,6 +1903,19 @@ saves_backup_usb_summary: "{ok}/{total} USB-তে ব্যাকআপ হয
|
||||
saves_backup_usb_summary_failed: "{ok}/{total} USB-তে ব্যাকআপ হয়েছে; {failed}টি ব্যর্থ",
|
||||
saves_backup_usb_tooltip: "PS5 না ছেড়েই সেটিংসে নির্ধারিত USB পাথে এই সেভডেটা ব্যাকআপ করুন।",
|
||||
saves_open_folder: "ফোল্ডার খুলুন",
|
||||
saves_open: "সেভ খুলুন",
|
||||
saves_open_hint: "এই সেভটি PS5-এ ডিক্রিপ্ট করে মাউন্ট করে এবং ফাইলসে এর ফাইল দেখায়। বন্ধ করলে পরিবর্তন ফেরত লেখা হয়।",
|
||||
saves_open_pick_title: "{title}-এর কোন সেভ?",
|
||||
saves_open_none: "এই ফোল্ডারে কোনো সেভ ইমেজ নেই।",
|
||||
saves_open_banner: "{name} {path}-এ খোলা আছে। বন্ধ করলে পরিবর্তন সেভে লেখা হয়।",
|
||||
saves_open_in_files: "ফাইলসে খুলুন",
|
||||
saves_close: "বন্ধ করে লিখুন",
|
||||
saves_closed: "সেভ বন্ধ হয়েছে",
|
||||
saves_closed_written: "ইমেজ সেভে ফেরত লেখা হয়েছে।",
|
||||
saves_closed_unchanged: "কিছু বদলায়নি; সেভ আগের মতোই আছে।",
|
||||
saves_open_failed: "সেভ খোলা যায়নি: {error}",
|
||||
saves_opened: "সেভ খোলা",
|
||||
saves_opened_body: "ফাইলসে {path}-এর নিচে দেখুন ও সম্পাদনা করুন। শেষ হলে সেভ ডেটা থেকে বন্ধ করুন।",
|
||||
saves_open_folder_hint: "ফাইল সিস্টেম ব্রাউজারে এই সেভডেটার ফোল্ডার খুলুন",
|
||||
saves_ps4_warning_body: "PS4-ফরম্যাটের সেভডেটা (যেমন ব্যাকওয়ার্ড কম্প্যাটিবিলিটিতে চালানো PS4 গেম) ব্যাকআপ ও পুনরুদ্ধার নির্ভরযোগ্যভাবে নাও চলতে পারে — PS4 এমুলেটর সেভডেটা অভ্যন্তরীণভাবে ক্যাশ করে, আর ফাইল কনসোলে ফিরিয়ে দিলেই তা সবসময় পুনরায় মাউন্ট হয় না। পুনরুদ্ধার করা PS4 সেভ গেমে না দেখা গেলে গেমটি বন্ধ করে আবার চালু করুন, বা কনসোল রিস্টার্ট করুন। এটি একটি জানা সীমাবদ্ধতা এবং ইস্যু #198-এ ট্র্যাক করা হচ্ছে।",
|
||||
saves_ps4_warning_title: "PS4-ফরম্যাটের সেভডেটা:",
|
||||
|
||||
@@ -1911,6 +1911,19 @@ saves_backup_usb_summary: "{ok}/{total} auf USB gesichert",
|
||||
saves_backup_usb_summary_failed: "{ok}/{total} auf USB gesichert; {failed} fehlgeschlagen",
|
||||
saves_backup_usb_tooltip: "Sichert diesen Spielstand auf den in den Einstellungen konfigurierten USB-Pfad, ohne die PS5 zu verlassen.",
|
||||
saves_open_folder: "Ordner öffnen",
|
||||
saves_open: "Spielstand öffnen",
|
||||
saves_open_hint: "Diesen Spielstand entschlüsselt auf der PS5 einbinden und seine Dateien unter Dateien durchsuchen. Änderungen werden beim Schließen zurückgeschrieben.",
|
||||
saves_open_pick_title: "Welcher Spielstand von {title}?",
|
||||
saves_open_none: "Kein Spielstand-Image in diesem Ordner.",
|
||||
saves_open_banner: "{name} ist unter {path} geöffnet. Änderungen werden beim Schließen in den Spielstand zurückgeschrieben.",
|
||||
saves_open_in_files: "In Dateien öffnen",
|
||||
saves_close: "Schließen & zurückschreiben",
|
||||
saves_closed: "Spielstand geschlossen",
|
||||
saves_closed_written: "Das Image wurde in den Spielstand zurückgeschrieben.",
|
||||
saves_closed_unchanged: "Nichts geändert; der Spielstand ist wie zuvor.",
|
||||
saves_open_failed: "Spielstand konnte nicht geöffnet werden: {error}",
|
||||
saves_opened: "Spielstand geöffnet",
|
||||
saves_opened_body: "Unter {path} in Dateien durchsuchen und bearbeiten. Danach unter Spielstände schließen.",
|
||||
saves_open_folder_hint: "Öffnet den Ordner dieses Spielstands im Dateisystem-Browser",
|
||||
saves_ps4_warning_body: "Sichern und Wiederherstellen von PS4-Altspielständen (etwa PS4-Spiele über Abwärtskompatibilität) funktioniert möglicherweise nicht zuverlässig — der PS4-Emulator hält Spielstanddaten intern im Cache, und ein reines Zurückkopieren löst nicht immer ein Neueinhängen aus. Erscheint ein wiederhergestellter PS4-Spielstand nicht im Spiel, schließe und öffne das Spiel erneut oder starte die Konsole neu. Bekannte Einschränkung, verfolgt in Issue #198.",
|
||||
saves_ps4_warning_title: "Spielstände im PS4-Format:",
|
||||
|
||||
@@ -1991,6 +1991,19 @@ installed_open_folder: "Open folder",
|
||||
search_scope_all: "All content",
|
||||
search_scope_tooltip: "Limit the search to one game's files",
|
||||
saves_open_folder: "Open folder",
|
||||
saves_open: "Open save",
|
||||
saves_open_hint: "Mount this save decrypted on the PS5 and browse its files in Files. Changes are written back when you close it.",
|
||||
saves_open_pick_title: "Which save of {title}?",
|
||||
saves_open_none: "No save image in this folder.",
|
||||
saves_open_banner: "{name} is open at {path}. Changes are written back into the save when you close it.",
|
||||
saves_open_in_files: "Open in Files",
|
||||
saves_close: "Close & write back",
|
||||
saves_closed: "Save closed",
|
||||
saves_closed_written: "The image was written back into the save.",
|
||||
saves_closed_unchanged: "Nothing changed; the save is as it was.",
|
||||
saves_open_failed: "Could not open the save: {error}",
|
||||
saves_opened: "Save open",
|
||||
saves_opened_body: "Browse and edit it under {path} in Files. Close it from Save data when done.",
|
||||
saves_open_folder_hint: "Open this save's folder in the File System browser",
|
||||
installed_error_title: "Couldn't read installed apps",
|
||||
installed_empty_title: "No installed titles found",
|
||||
|
||||
@@ -1853,6 +1853,19 @@ saves_backup_usb_summary: "Copiadas {ok}/{total} a USB",
|
||||
saves_backup_usb_summary_failed: "Copiadas {ok}/{total} a USB; {failed} fallaron",
|
||||
saves_backup_usb_tooltip: "Copia esta partida guardada a la ruta USB configurada en Ajustes, sin salir de la PS5.",
|
||||
saves_open_folder: "Abrir carpeta",
|
||||
saves_open: "Abrir partida",
|
||||
saves_open_hint: "Monta esta partida descifrada en la PS5 y explora sus archivos en Archivos. Los cambios se escriben al cerrarla.",
|
||||
saves_open_pick_title: "¿Qué partida de {title}?",
|
||||
saves_open_none: "No hay imagen de partida en esta carpeta.",
|
||||
saves_open_banner: "{name} está abierta en {path}. Los cambios se escriben en la partida al cerrarla.",
|
||||
saves_open_in_files: "Abrir en Archivos",
|
||||
saves_close: "Cerrar y escribir",
|
||||
saves_closed: "Partida cerrada",
|
||||
saves_closed_written: "La imagen se escribió de vuelta en la partida.",
|
||||
saves_closed_unchanged: "Nada cambió; la partida sigue igual.",
|
||||
saves_open_failed: "No se pudo abrir la partida: {error}",
|
||||
saves_opened: "Partida abierta",
|
||||
saves_opened_body: "Explórala y edítala en {path} desde Archivos. Ciérrala desde Partidas guardadas al terminar.",
|
||||
saves_open_folder_hint: "Abre la carpeta de esta partida guardada en el explorador de archivos",
|
||||
saves_ps4_warning_body: "la copia y restauración de partidas antiguas de PS4 (como juegos de PS4 en retrocompatibilidad) puede no funcionar de forma fiable — el emulador de PS4 guarda los datos en caché internamente y una copia de archivos sin más no siempre fuerza un remontaje. Si una partida de PS4 restaurada no aparece en el juego, prueba a cerrarlo y abrirlo de nuevo, o a reiniciar la consola. Es una limitación conocida, seguida en la incidencia #198.",
|
||||
saves_ps4_warning_title: "Partidas en formato PS4:",
|
||||
|
||||
@@ -1842,6 +1842,19 @@ installed_open_folder: "باز کردن پوشه",
|
||||
search_scope_all: "همهی محتوا",
|
||||
search_scope_tooltip: "محدود کردن جستجو به فایلهای یک بازی",
|
||||
saves_open_folder: "باز کردن پوشه",
|
||||
saves_open: "باز کردن سیو",
|
||||
saves_open_hint: "این سیو را روی PS5 رمزگشایی و مونت میکند و فایلهایش در بخش فایلها دیده میشود. تغییرات هنگام بستن نوشته میشود.",
|
||||
saves_open_pick_title: "کدام سیو از {title}؟",
|
||||
saves_open_none: "در این پوشه تصویر سیوی نیست.",
|
||||
saves_open_banner: "{name} در {path} باز است. تغییرات هنگام بستن در سیو نوشته میشود.",
|
||||
saves_open_in_files: "باز کردن در فایلها",
|
||||
saves_close: "بستن و نوشتن",
|
||||
saves_closed: "سیو بسته شد",
|
||||
saves_closed_written: "تصویر دوباره در سیو نوشته شد.",
|
||||
saves_closed_unchanged: "چیزی تغییر نکرد؛ سیو همانطور که بود مانده.",
|
||||
saves_open_failed: "سیو باز نشد: {error}",
|
||||
saves_opened: "سیو باز است",
|
||||
saves_opened_body: "زیر {path} در فایلها مرور و ویرایش کنید. در پایان از بخش سیوها ببندید.",
|
||||
saves_open_folder_hint: "باز کردن پوشهی این ذخیره در مرورگر سیستم فایل",
|
||||
installed_error_title: "خواندن اپلیکیشنهای نصبشده ناموفق بود",
|
||||
installed_empty_title: "هیچ عنوان نصبشدهای یافت نشد",
|
||||
|
||||
@@ -1921,6 +1921,19 @@ saves_backup_usb_summary: "{ok}/{total} sauvegardés sur USB",
|
||||
saves_backup_usb_summary_failed: "{ok}/{total} sauvegardés sur USB ; {failed} en échec",
|
||||
saves_backup_usb_tooltip: "Sauvegarde cette partie vers le chemin USB configuré dans les Réglages, sans quitter la PS5.",
|
||||
saves_open_folder: "Ouvrir le dossier",
|
||||
saves_open: "Ouvrir la sauvegarde",
|
||||
saves_open_hint: "Monte cette sauvegarde déchiffrée sur la PS5 et parcourt ses fichiers dans Fichiers. Les changements sont réécrits à la fermeture.",
|
||||
saves_open_pick_title: "Quelle sauvegarde de {title} ?",
|
||||
saves_open_none: "Aucune image de sauvegarde dans ce dossier.",
|
||||
saves_open_banner: "{name} est ouverte dans {path}. Les changements sont réécrits dans la sauvegarde à la fermeture.",
|
||||
saves_open_in_files: "Ouvrir dans Fichiers",
|
||||
saves_close: "Fermer et réécrire",
|
||||
saves_closed: "Sauvegarde fermée",
|
||||
saves_closed_written: "L'image a été réécrite dans la sauvegarde.",
|
||||
saves_closed_unchanged: "Rien n'a changé ; la sauvegarde est telle quelle.",
|
||||
saves_open_failed: "Impossible d'ouvrir la sauvegarde : {error}",
|
||||
saves_opened: "Sauvegarde ouverte",
|
||||
saves_opened_body: "Parcourez-la et modifiez-la sous {path} dans Fichiers. Fermez-la depuis Sauvegardes quand vous avez fini.",
|
||||
saves_open_folder_hint: "Ouvrir le dossier de cette sauvegarde dans l'explorateur de fichiers",
|
||||
saves_ps4_warning_body: "la sauvegarde et la restauration des sauvegardes PS4 (par ex. des jeux PS4 en rétrocompatibilité) peuvent être peu fiables — l'émulateur PS4 met les données en cache en interne et une simple copie de fichiers ne déclenche pas toujours un remontage. Si une sauvegarde PS4 restaurée n'apparaît pas dans le jeu, essayez de fermer puis rouvrir le jeu, ou de redémarrer la console. Limitation connue, suivie dans le ticket #198.",
|
||||
saves_ps4_warning_title: "Sauvegardes au format PS4 :",
|
||||
|
||||
@@ -1919,6 +1919,19 @@ saves_backup_usb_summary: "{ok}/{total} USB में बैकअप किए
|
||||
saves_backup_usb_summary_failed: "{ok}/{total} USB में बैकअप किए गए; {failed} विफल",
|
||||
saves_backup_usb_tooltip: "इस सेव को सेटिंग्स में तय USB पथ पर बैकअप करता है, PS5 से बाहर गए बिना।",
|
||||
saves_open_folder: "फ़ोल्डर खोलें",
|
||||
saves_open: "सेव खोलें",
|
||||
saves_open_hint: "इस सेव को PS5 पर डिक्रिप्ट करके माउंट करता है और फ़ाइलें में इसकी फ़ाइलें दिखाता है। बंद करने पर बदलाव वापस लिखे जाते हैं।",
|
||||
saves_open_pick_title: "{title} का कौन-सा सेव?",
|
||||
saves_open_none: "इस फ़ोल्डर में कोई सेव इमेज नहीं है।",
|
||||
saves_open_banner: "{name} {path} पर खुला है। बंद करने पर बदलाव सेव में लिखे जाते हैं।",
|
||||
saves_open_in_files: "फ़ाइलें में खोलें",
|
||||
saves_close: "बंद करें और लिखें",
|
||||
saves_closed: "सेव बंद",
|
||||
saves_closed_written: "इमेज सेव में वापस लिखी गई।",
|
||||
saves_closed_unchanged: "कुछ नहीं बदला; सेव जैसा था वैसा है।",
|
||||
saves_open_failed: "सेव नहीं खुल सका: {error}",
|
||||
saves_opened: "सेव खुला",
|
||||
saves_opened_body: "फ़ाइलें में {path} के अंदर देखें और संपादित करें। पूरा होने पर सेव डेटा से बंद करें।",
|
||||
saves_open_folder_hint: "इस सेव का फ़ोल्डर फ़ाइल सिस्टम ब्राउज़र में खोलता है",
|
||||
saves_ps4_warning_body: "PS4 के पुराने सेव (जैसे बैकवर्ड कम्पैटिबिलिटी से चलने वाले PS4 गेम) का बैकअप और पुनर्स्थापन भरोसेमंद ढंग से काम न कर सकता है — PS4 एमुलेटर सेव डेटा को आंतरिक रूप से कैश करता है और फ़ाइलें वापस कॉपी करने भर से हमेशा रीमाउंट नहीं होता। यदि पुनर्स्थापित PS4 सेव गेम में न दिखे, तो गेम बंद करके फिर खोलें, या कंसोल पुनः आरंभ करें। यह ज्ञात सीमा है, जिसे मुद्दा #198 में देखा जा रहा है।",
|
||||
saves_ps4_warning_title: "PS4-प्रारूप सेव:",
|
||||
|
||||
@@ -1782,6 +1782,19 @@ installed_open_folder: "Mappa megnyitása",
|
||||
search_scope_all: "Minden tartalom",
|
||||
search_scope_tooltip: "A keresés egyetlen játék fájljaira korlátozása",
|
||||
saves_open_folder: "Mappa megnyitása",
|
||||
saves_open: "Mentés megnyitása",
|
||||
saves_open_hint: "Ezt a mentést visszafejtve csatolja a PS5-ön, és a fájljai a Fájlok alatt böngészhetők. A változások bezáráskor íródnak vissza.",
|
||||
saves_open_pick_title: "Melyik {title} mentés?",
|
||||
saves_open_none: "Nincs mentésképfájl ebben a mappában.",
|
||||
saves_open_banner: "{name} meg van nyitva itt: {path}. A változások bezáráskor íródnak vissza a mentésbe.",
|
||||
saves_open_in_files: "Megnyitás a Fájlokban",
|
||||
saves_close: "Bezárás és visszaírás",
|
||||
saves_closed: "Mentés bezárva",
|
||||
saves_closed_written: "A képfájl visszaíródott a mentésbe.",
|
||||
saves_closed_unchanged: "Semmi sem változott; a mentés olyan, mint volt.",
|
||||
saves_open_failed: "A mentést nem sikerült megnyitni: {error}",
|
||||
saves_opened: "Mentés megnyitva",
|
||||
saves_opened_body: "Böngészd és szerkeszd itt: {path} a Fájlok alatt. Ha kész, zárd be a Mentések alatt.",
|
||||
saves_open_folder_hint: "Ennek a mentésnek a mappáját megnyitja a Fájlrendszer böngészőben",
|
||||
installed_error_title: "Nem sikerült beolvasni a telepített alkalmazásokat",
|
||||
installed_empty_title: "Nem található telepített cím",
|
||||
|
||||
@@ -1912,6 +1912,19 @@ saves_backup_usb_summary: "{ok}/{total} dicadangkan ke USB",
|
||||
saves_backup_usb_summary_failed: "{ok}/{total} dicadangkan ke USB; {failed} gagal",
|
||||
saves_backup_usb_tooltip: "Cadangkan simpanan ini ke jalur USB yang diatur di Pengaturan, tanpa meninggalkan PS5.",
|
||||
saves_open_folder: "Buka folder",
|
||||
saves_open: "Buka save",
|
||||
saves_open_hint: "Memasang save ini terdekripsi di PS5 dan menelusuri berkasnya di Berkas. Perubahan ditulis kembali saat ditutup.",
|
||||
saves_open_pick_title: "Save {title} yang mana?",
|
||||
saves_open_none: "Tidak ada image save di folder ini.",
|
||||
saves_open_banner: "{name} terbuka di {path}. Perubahan ditulis kembali ke save saat ditutup.",
|
||||
saves_open_in_files: "Buka di Berkas",
|
||||
saves_close: "Tutup & tulis kembali",
|
||||
saves_closed: "Save ditutup",
|
||||
saves_closed_written: "Image ditulis kembali ke save.",
|
||||
saves_closed_unchanged: "Tidak ada perubahan; save tetap seperti semula.",
|
||||
saves_open_failed: "Tidak bisa membuka save: {error}",
|
||||
saves_opened: "Save terbuka",
|
||||
saves_opened_body: "Telusuri dan sunting di {path} pada Berkas. Tutup dari Data save bila selesai.",
|
||||
saves_open_folder_hint: "Buka folder simpanan ini di penjelajah Sistem Berkas",
|
||||
saves_ps4_warning_body: "pencadangan dan pemulihan simpanan lawas PS4 (seperti gim PS4 yang berjalan lewat kompatibilitas mundur) mungkin tidak andal — emulator PS4 menyimpan data simpanan di cache internal, dan menyalin berkas kembali tidak selalu memicu pemasangan ulang. Jika simpanan PS4 yang dipulihkan tidak muncul di gim, coba tutup dan buka lagi gimnya, atau mulai ulang konsol. Ini keterbatasan yang diketahui dan dilacak di isu #198.",
|
||||
saves_ps4_warning_title: "Simpanan format PS4:",
|
||||
|
||||
@@ -1853,6 +1853,19 @@ saves_backup_usb_summary: "{ok}/{total} salvati su USB",
|
||||
saves_backup_usb_summary_failed: "{ok}/{total} salvati su USB; {failed} non riusciti",
|
||||
saves_backup_usb_tooltip: "Esegue il backup di questo salvataggio nel percorso USB configurato nelle Impostazioni, senza uscire dalla PS5.",
|
||||
saves_open_folder: "Apri cartella",
|
||||
saves_open: "Apri salvataggio",
|
||||
saves_open_hint: "Monta questo salvataggio decifrato sulla PS5 e sfoglia i suoi file in File. Le modifiche vengono riscritte alla chiusura.",
|
||||
saves_open_pick_title: "Quale salvataggio di {title}?",
|
||||
saves_open_none: "Nessuna immagine di salvataggio in questa cartella.",
|
||||
saves_open_banner: "{name} è aperto in {path}. Le modifiche vengono riscritte nel salvataggio alla chiusura.",
|
||||
saves_open_in_files: "Apri in File",
|
||||
saves_close: "Chiudi e riscrivi",
|
||||
saves_closed: "Salvataggio chiuso",
|
||||
saves_closed_written: "L'immagine è stata riscritta nel salvataggio.",
|
||||
saves_closed_unchanged: "Nulla è cambiato; il salvataggio è com'era.",
|
||||
saves_open_failed: "Impossibile aprire il salvataggio: {error}",
|
||||
saves_opened: "Salvataggio aperto",
|
||||
saves_opened_body: "Sfoglialo e modificalo in {path} da File. Chiudilo da Salvataggi quando hai finito.",
|
||||
saves_open_folder_hint: "Apre la cartella di questo salvataggio nel browser del file system",
|
||||
saves_ps4_warning_body: "il backup e il ripristino dei salvataggi PS4 legacy (come i giochi PS4 in retrocompatibilità) potrebbero non funzionare in modo affidabile — l'emulatore PS4 mette in cache i dati internamente e una semplice copia dei file non sempre attiva un rimontaggio. Se un salvataggio PS4 ripristinato non compare nel gioco, prova a chiudere e riaprire il gioco, o a riavviare la console. È una limitazione nota, tracciata nella issue #198.",
|
||||
saves_ps4_warning_title: "Salvataggi in formato PS4:",
|
||||
|
||||
@@ -1924,6 +1924,19 @@ saves_backup_usb_summary: "{ok}/{total} をUSBにバックアップしました"
|
||||
saves_backup_usb_summary_failed: "{ok}/{total} をUSBにバックアップしました。{failed}件が失敗しました",
|
||||
saves_backup_usb_tooltip: "このセーブデータを、設定で指定したUSBパスにPS5内だけでバックアップします。",
|
||||
saves_open_folder: "フォルダーを開く",
|
||||
saves_open: "セーブを開く",
|
||||
saves_open_hint: "このセーブを PS5 上で復号してマウントし、ファイル画面で中身を見られます。閉じると変更が書き戻されます。",
|
||||
saves_open_pick_title: "{title} のどのセーブ?",
|
||||
saves_open_none: "このフォルダーにセーブイメージがありません。",
|
||||
saves_open_banner: "{name} を {path} で開いています。閉じると変更がセーブに書き戻されます。",
|
||||
saves_open_in_files: "ファイルで開く",
|
||||
saves_close: "閉じて書き戻す",
|
||||
saves_closed: "セーブを閉じました",
|
||||
saves_closed_written: "イメージをセーブに書き戻しました。",
|
||||
saves_closed_unchanged: "変更なし。セーブは元のままです。",
|
||||
saves_open_failed: "セーブを開けませんでした: {error}",
|
||||
saves_opened: "セーブを開きました",
|
||||
saves_opened_body: "ファイル画面の {path} で閲覧・編集できます。終わったらセーブデータ画面で閉じてください。",
|
||||
saves_open_folder_hint: "このセーブデータのフォルダーをファイルシステムブラウザーで開きます",
|
||||
saves_ps4_warning_body: "PS4形式の旧セーブデータ(下位互換で動作するPS4ゲームなど)のバックアップと復元は、確実に動作しない場合があります。PS4エミュレーターがセーブデータを内部でキャッシュしており、本体へのファイルコピーだけでは再マウントが起きないことがあるためです。復元したPS4セーブがゲーム内に表示されない場合は、ゲームを閉じて開き直すか、本体を再起動してください。既知の制限で、issue #198 で追跡しています。",
|
||||
saves_ps4_warning_title: "PS4形式のセーブデータ:",
|
||||
|
||||
@@ -1922,6 +1922,19 @@ saves_backup_usb_summary: "{ok}/{total}을(를) USB에 백업했습니다",
|
||||
saves_backup_usb_summary_failed: "{ok}/{total}을(를) USB에 백업했습니다. {failed}개 실패",
|
||||
saves_backup_usb_tooltip: "이 세이브를 설정에 지정한 USB 경로로 백업합니다. PS5를 벗어나지 않습니다.",
|
||||
saves_open_folder: "폴더 열기",
|
||||
saves_open: "세이브 열기",
|
||||
saves_open_hint: "이 세이브를 PS5에서 복호화해 마운트하고 파일 화면에서 내용을 봅니다. 닫으면 변경 사항이 다시 기록됩니다.",
|
||||
saves_open_pick_title: "{title}의 어느 세이브?",
|
||||
saves_open_none: "이 폴더에 세이브 이미지가 없습니다.",
|
||||
saves_open_banner: "{name}이(가) {path}에 열려 있습니다. 닫으면 변경 사항이 세이브에 기록됩니다.",
|
||||
saves_open_in_files: "파일에서 열기",
|
||||
saves_close: "닫고 기록",
|
||||
saves_closed: "세이브 닫힘",
|
||||
saves_closed_written: "이미지를 세이브에 다시 기록했습니다.",
|
||||
saves_closed_unchanged: "변경 없음. 세이브는 그대로입니다.",
|
||||
saves_open_failed: "세이브를 열 수 없습니다: {error}",
|
||||
saves_opened: "세이브 열림",
|
||||
saves_opened_body: "파일 화면의 {path}에서 보고 편집하세요. 끝나면 세이브 데이터 화면에서 닫으세요.",
|
||||
saves_open_folder_hint: "이 세이브의 폴더를 파일 시스템 브라우저에서 엽니다",
|
||||
saves_ps4_warning_body: "PS4 구형 세이브(하위 호환으로 실행되는 PS4 게임 등)의 백업과 복원은 안정적이지 않을 수 있습니다. PS4 에뮬레이터가 세이브 데이터를 내부에 캐시하므로 파일을 그대로 되돌려도 재마운트가 항상 일어나지는 않습니다. 복원한 PS4 세이브가 게임에 나타나지 않으면 게임을 닫았다 다시 열거나 콘솔을 재시작해 보세요. 알려진 제한이며 이슈 #198로 추적 중입니다.",
|
||||
saves_ps4_warning_title: "PS4 형식 세이브:",
|
||||
|
||||
@@ -1585,6 +1585,19 @@ installed_open_folder: "Otwórz folder",
|
||||
search_scope_all: "Cała zawartość",
|
||||
search_scope_tooltip: "Ogranicz wyszukiwanie do plików jednej gry",
|
||||
saves_open_folder: "Otwórz folder",
|
||||
saves_open: "Otwórz zapis",
|
||||
saves_open_hint: "Montuje ten zapis odszyfrowany na PS5 i pokazuje jego pliki w Plikach. Zmiany są zapisywane z powrotem przy zamknięciu.",
|
||||
saves_open_pick_title: "Który zapis {title}?",
|
||||
saves_open_none: "Brak obrazu zapisu w tym folderze.",
|
||||
saves_open_banner: "{name} jest otwarty w {path}. Zmiany trafią do zapisu przy zamknięciu.",
|
||||
saves_open_in_files: "Otwórz w Plikach",
|
||||
saves_close: "Zamknij i zapisz",
|
||||
saves_closed: "Zapis zamknięty",
|
||||
saves_closed_written: "Obraz zapisano z powrotem do zapisu.",
|
||||
saves_closed_unchanged: "Nic się nie zmieniło; zapis jest taki jak był.",
|
||||
saves_open_failed: "Nie udało się otworzyć zapisu: {error}",
|
||||
saves_opened: "Zapis otwarty",
|
||||
saves_opened_body: "Przeglądaj i edytuj w {path} w Plikach. Zamknij z poziomu Zapisów, gdy skończysz.",
|
||||
saves_open_folder_hint: "Otwórz folder tego zapisu w przeglądarce systemu plików",
|
||||
installed_error_title: "Nie można odczytać zainstalowanych aplikacji",
|
||||
installed_empty_title: "Nie znaleziono zainstalowanych tytułów",
|
||||
|
||||
@@ -1920,6 +1920,19 @@ saves_backup_usb_summary: "{ok}/{total} salvos no USB",
|
||||
saves_backup_usb_summary_failed: "{ok}/{total} salvos no USB; {failed} falharam",
|
||||
saves_backup_usb_tooltip: "Faz backup deste save no caminho USB configurado em Ajustes, sem sair do PS5.",
|
||||
saves_open_folder: "Abrir pasta",
|
||||
saves_open: "Abrir save",
|
||||
saves_open_hint: "Monta este save descriptografado no PS5 e navega pelos arquivos em Arquivos. As alterações são gravadas de volta ao fechar.",
|
||||
saves_open_pick_title: "Qual save de {title}?",
|
||||
saves_open_none: "Nenhuma imagem de save nesta pasta.",
|
||||
saves_open_banner: "{name} está aberto em {path}. As alterações são gravadas no save ao fechar.",
|
||||
saves_open_in_files: "Abrir em Arquivos",
|
||||
saves_close: "Fechar e gravar",
|
||||
saves_closed: "Save fechado",
|
||||
saves_closed_written: "A imagem foi gravada de volta no save.",
|
||||
saves_closed_unchanged: "Nada mudou; o save está como estava.",
|
||||
saves_open_failed: "Não foi possível abrir o save: {error}",
|
||||
saves_opened: "Save aberto",
|
||||
saves_opened_body: "Navegue e edite em {path} pelo Arquivos. Feche em Saves quando terminar.",
|
||||
saves_open_folder_hint: "Abre a pasta deste save no navegador do sistema de arquivos",
|
||||
saves_ps4_warning_body: "o backup e a restauração de saves antigos de PS4 (como jogos de PS4 rodando por retrocompatibilidade) podem não funcionar de forma confiável — o emulador de PS4 mantém os dados em cache internamente e copiar os arquivos de volta nem sempre força uma remontagem. Se um save de PS4 restaurado não aparecer no jogo, tente fechar e reabrir o jogo, ou reiniciar o console. É uma limitação conhecida, acompanhada na issue #198.",
|
||||
saves_ps4_warning_title: "Saves em formato PS4:",
|
||||
|
||||
@@ -1853,6 +1853,19 @@ saves_backup_usb_summary: "Скопировано на USB: {ok}/{total}",
|
||||
saves_backup_usb_summary_failed: "Скопировано на USB: {ok}/{total}; с ошибкой: {failed}",
|
||||
saves_backup_usb_tooltip: "Сохраняет это сохранение по USB-пути, заданному в настройках, не выходя из PS5.",
|
||||
saves_open_folder: "Открыть папку",
|
||||
saves_open: "Открыть сохранение",
|
||||
saves_open_hint: "Монтирует это сохранение в расшифрованном виде на PS5, файлы видны в разделе Файлы. Изменения записываются обратно при закрытии.",
|
||||
saves_open_pick_title: "Какое сохранение {title}?",
|
||||
saves_open_none: "В этой папке нет образа сохранения.",
|
||||
saves_open_banner: "{name} открыто в {path}. Изменения записываются в сохранение при закрытии.",
|
||||
saves_open_in_files: "Открыть в Файлах",
|
||||
saves_close: "Закрыть и записать",
|
||||
saves_closed: "Сохранение закрыто",
|
||||
saves_closed_written: "Образ записан обратно в сохранение.",
|
||||
saves_closed_unchanged: "Ничего не изменилось; сохранение осталось прежним.",
|
||||
saves_open_failed: "Не удалось открыть сохранение: {error}",
|
||||
saves_opened: "Сохранение открыто",
|
||||
saves_opened_body: "Просматривайте и правьте его в {path} в разделе Файлы. Закройте его в разделе Сохранения, когда закончите.",
|
||||
saves_open_folder_hint: "Открывает папку этого сохранения в файловом менеджере",
|
||||
saves_ps4_warning_body: "резервное копирование и восстановление старых сохранений PS4 (например, игр PS4 в режиме обратной совместимости) может работать нестабильно — эмулятор PS4 кеширует данные сохранений внутри, и простое копирование файлов обратно не всегда вызывает перемонтирование. Если восстановленное сохранение PS4 не появляется в игре, попробуйте закрыть и снова открыть игру или перезагрузить консоль. Это известное ограничение, отслеживается в issue #198.",
|
||||
saves_ps4_warning_title: "Сохранения в формате PS4:",
|
||||
|
||||
@@ -1912,6 +1912,19 @@ saves_backup_usb_summary: "สำรอง {ok}/{total} ลง USB แล้ว"
|
||||
saves_backup_usb_summary_failed: "สำรอง {ok}/{total} ลง USB แล้ว; ล้มเหลว {failed}",
|
||||
saves_backup_usb_tooltip: "สำรองข้อมูลบันทึกนี้ไปยังเส้นทาง USB ที่ตั้งไว้ในการตั้งค่า โดยไม่ต้องออกจาก PS5",
|
||||
saves_open_folder: "เปิดโฟลเดอร์",
|
||||
saves_open: "เปิดเซฟ",
|
||||
saves_open_hint: "เมานต์เซฟนี้แบบถอดรหัสบน PS5 และดูไฟล์ในหน้าไฟล์ การเปลี่ยนแปลงจะถูกเขียนกลับเมื่อปิด",
|
||||
saves_open_pick_title: "เซฟไหนของ {title}?",
|
||||
saves_open_none: "ไม่มีอิมเมจเซฟในโฟลเดอร์นี้",
|
||||
saves_open_banner: "{name} เปิดอยู่ที่ {path} การเปลี่ยนแปลงจะถูกเขียนกลับลงเซฟเมื่อปิด",
|
||||
saves_open_in_files: "เปิดในไฟล์",
|
||||
saves_close: "ปิดและเขียนกลับ",
|
||||
saves_closed: "ปิดเซฟแล้ว",
|
||||
saves_closed_written: "เขียนอิมเมจกลับลงเซฟแล้ว",
|
||||
saves_closed_unchanged: "ไม่มีอะไรเปลี่ยน เซฟยังเหมือนเดิม",
|
||||
saves_open_failed: "เปิดเซฟไม่ได้: {error}",
|
||||
saves_opened: "เปิดเซฟแล้ว",
|
||||
saves_opened_body: "ดูและแก้ไขได้ที่ {path} ในหน้าไฟล์ เสร็จแล้วปิดจากหน้าเซฟ",
|
||||
saves_open_folder_hint: "เปิดโฟลเดอร์ของข้อมูลบันทึกนี้ในตัวเรียกดูระบบไฟล์",
|
||||
saves_ps4_warning_body: "การสำรองและกู้คืนข้อมูลบันทึกรูปแบบ PS4 (เช่น เกม PS4 ที่เล่นผ่านความเข้ากันได้ย้อนหลัง) อาจทำงานไม่น่าเชื่อถือ — ตัวจำลอง PS4 แคชข้อมูลบันทึกไว้ภายใน และการคัดลอกไฟล์กลับไปยังเครื่องไม่ได้ทำให้เมานต์ใหม่เสมอไป หากข้อมูลบันทึก PS4 ที่กู้คืนไม่ปรากฏในเกม ให้ลองปิดแล้วเปิดเกมใหม่ หรือรีสตาร์ตเครื่อง นี่เป็นข้อจำกัดที่ทราบแล้วและติดตามอยู่ในประเด็น #198",
|
||||
saves_ps4_warning_title: "ข้อมูลบันทึกรูปแบบ PS4:",
|
||||
|
||||
@@ -1910,6 +1910,19 @@ saves_backup_usb_summary: "{ok}/{total} USB'ye yedeklendi",
|
||||
saves_backup_usb_summary_failed: "{ok}/{total} USB'ye yedeklendi; {failed} başarısız",
|
||||
saves_backup_usb_tooltip: "Bu kaydı, PS5'ten çıkmadan Ayarlar'da yapılandırılan USB yoluna yedekler.",
|
||||
saves_open_folder: "Klasörü aç",
|
||||
saves_open: "Kaydı aç",
|
||||
saves_open_hint: "Bu kaydı PS5'te çözülmüş olarak bağlar ve dosyalarını Dosyalar'da gösterir. Değişiklikler kapatınca geri yazılır.",
|
||||
saves_open_pick_title: "{title} için hangi kayıt?",
|
||||
saves_open_none: "Bu klasörde kayıt kalıbı yok.",
|
||||
saves_open_banner: "{name} {path} konumunda açık. Değişiklikler kapatınca kayda geri yazılır.",
|
||||
saves_open_in_files: "Dosyalar'da aç",
|
||||
saves_close: "Kapat ve geri yaz",
|
||||
saves_closed: "Kayıt kapatıldı",
|
||||
saves_closed_written: "Kalıp kayda geri yazıldı.",
|
||||
saves_closed_unchanged: "Hiçbir şey değişmedi; kayıt olduğu gibi.",
|
||||
saves_open_failed: "Kayıt açılamadı: {error}",
|
||||
saves_opened: "Kayıt açık",
|
||||
saves_opened_body: "Dosyalar'da {path} altında gözat ve düzenle. Bitince Kayıt verileri'nden kapat.",
|
||||
saves_open_folder_hint: "Bu kaydın klasörünü Dosya Sistemi tarayıcısında açar",
|
||||
saves_ps4_warning_body: "PS4 eski kayıtlarının (geriye dönük uyumlulukla çalışan PS4 oyunları gibi) yedeklenmesi ve geri yüklenmesi güvenilir çalışmayabilir — PS4 öykünücüsü kayıt verisini içeride önbelleğe alır ve dosyaları konsola geri kopyalamak her zaman yeniden bağlamayı tetiklemez. Geri yüklenen bir PS4 kaydı oyunda görünmüyorsa oyunu kapatıp yeniden açmayı ya da konsolu yeniden başlatmayı deneyin. Bu bilinen bir kısıt olup #198 numaralı konuda izleniyor.",
|
||||
saves_ps4_warning_title: "PS4 biçimli kayıtlar:",
|
||||
|
||||
@@ -1905,6 +1905,19 @@ saves_backup_usb_summary: "Đã sao lưu {ok}/{total} vào USB",
|
||||
saves_backup_usb_summary_failed: "Đã sao lưu {ok}/{total} vào USB; {failed} thất bại",
|
||||
saves_backup_usb_tooltip: "Sao lưu dữ liệu lưu này vào đường dẫn USB đã đặt trong Cài đặt, không cần rời khỏi PS5.",
|
||||
saves_open_folder: "Mở thư mục",
|
||||
saves_open: "Mở bản lưu",
|
||||
saves_open_hint: "Gắn bản lưu này đã giải mã trên PS5 và xem các tệp trong Tệp. Thay đổi được ghi lại khi đóng.",
|
||||
saves_open_pick_title: "Bản lưu nào của {title}?",
|
||||
saves_open_none: "Không có ảnh bản lưu trong thư mục này.",
|
||||
saves_open_banner: "{name} đang mở tại {path}. Thay đổi được ghi vào bản lưu khi đóng.",
|
||||
saves_open_in_files: "Mở trong Tệp",
|
||||
saves_close: "Đóng và ghi lại",
|
||||
saves_closed: "Đã đóng bản lưu",
|
||||
saves_closed_written: "Ảnh đã được ghi lại vào bản lưu.",
|
||||
saves_closed_unchanged: "Không có gì thay đổi; bản lưu vẫn như cũ.",
|
||||
saves_open_failed: "Không mở được bản lưu: {error}",
|
||||
saves_opened: "Bản lưu đang mở",
|
||||
saves_opened_body: "Xem và chỉnh sửa tại {path} trong Tệp. Đóng từ Dữ liệu lưu khi xong.",
|
||||
saves_open_folder_hint: "Mở thư mục của dữ liệu lưu này trong trình duyệt Hệ thống tệp",
|
||||
saves_ps4_warning_body: "việc sao lưu và khôi phục dữ liệu lưu cũ của PS4 (như trò chơi PS4 chạy nhờ tương thích ngược) có thể không ổn định — trình giả lập PS4 lưu đệm dữ liệu bên trong, và việc chép tệp ngược về máy không phải lúc nào cũng kích hoạt gắn lại. Nếu dữ liệu lưu PS4 đã khôi phục không hiện trong trò chơi, hãy thử đóng rồi mở lại trò chơi, hoặc khởi động lại máy. Đây là hạn chế đã biết, được theo dõi ở issue #198.",
|
||||
saves_ps4_warning_title: "Dữ liệu lưu định dạng PS4:",
|
||||
|
||||
@@ -1859,6 +1859,19 @@ saves_backup_usb_summary: "已备份 {ok}/{total} 到 USB",
|
||||
saves_backup_usb_summary_failed: "已备份 {ok}/{total} 到 USB;{failed} 个失败",
|
||||
saves_backup_usb_tooltip: "把此存档备份到设置中配置的 USB 存档路径,无需离开 PS5。",
|
||||
saves_open_folder: "打开文件夹",
|
||||
saves_open: "打开存档",
|
||||
saves_open_hint: "在 PS5 上解密挂载此存档,并在“文件”中浏览其中文件。关闭时写回更改。",
|
||||
saves_open_pick_title: "{title} 的哪个存档?",
|
||||
saves_open_none: "此文件夹中没有存档镜像。",
|
||||
saves_open_banner: "{name} 已在 {path} 打开。关闭时更改会写回存档。",
|
||||
saves_open_in_files: "在文件中打开",
|
||||
saves_close: "关闭并写回",
|
||||
saves_closed: "存档已关闭",
|
||||
saves_closed_written: "镜像已写回存档。",
|
||||
saves_closed_unchanged: "没有更改,存档保持原样。",
|
||||
saves_open_failed: "无法打开存档:{error}",
|
||||
saves_opened: "存档已打开",
|
||||
saves_opened_body: "在“文件”的 {path} 下浏览和编辑。完成后在“存档数据”中关闭。",
|
||||
saves_open_folder_hint: "在文件系统浏览器中打开此存档所在文件夹",
|
||||
saves_ps4_warning_body: "PS4 旧版存档(例如通过向下兼容运行的 PS4 游戏)的备份与恢复可能不稳定——PS4 模拟器会在内部缓存存档数据,直接把文件拷回主机并不总能触发重新挂载。若恢复后的 PS4 存档在游戏中未出现,可尝试关闭并重新打开游戏,或重启主机。这是已知限制,记录在问题 #198。",
|
||||
saves_ps4_warning_title: "PS4 格式存档:",
|
||||
|
||||
@@ -1853,6 +1853,19 @@ saves_backup_usb_summary: "已備份 {ok}/{total} 到 USB",
|
||||
saves_backup_usb_summary_failed: "已備份 {ok}/{total} 到 USB;{failed} 個失敗",
|
||||
saves_backup_usb_tooltip: "將此存檔備份到設定中指定的 USB 路徑,不需離開 PS5。",
|
||||
saves_open_folder: "開啟資料夾",
|
||||
saves_open: "開啟存檔",
|
||||
saves_open_hint: "在 PS5 上解密掛載此存檔,並在「檔案」中瀏覽其中檔案。關閉時寫回變更。",
|
||||
saves_open_pick_title: "{title} 的哪個存檔?",
|
||||
saves_open_none: "此資料夾中沒有存檔映像。",
|
||||
saves_open_banner: "{name} 已在 {path} 開啟。關閉時變更會寫回存檔。",
|
||||
saves_open_in_files: "在檔案中開啟",
|
||||
saves_close: "關閉並寫回",
|
||||
saves_closed: "存檔已關閉",
|
||||
saves_closed_written: "映像已寫回存檔。",
|
||||
saves_closed_unchanged: "沒有變更,存檔保持原樣。",
|
||||
saves_open_failed: "無法開啟存檔:{error}",
|
||||
saves_opened: "存檔已開啟",
|
||||
saves_opened_body: "在「檔案」的 {path} 下瀏覽與編輯。完成後在「存檔資料」中關閉。",
|
||||
saves_open_folder_hint: "在檔案系統瀏覽器中開啟此存檔所在的資料夾",
|
||||
saves_ps4_warning_body: "PS4 舊版存檔(例如透過向下相容執行的 PS4 遊戲)的備份與還原可能不穩定——PS4 模擬器會在內部快取存檔資料,直接把檔案複製回主機並不一定會觸發重新掛載。若還原後的 PS4 存檔在遊戲中未出現,可嘗試關閉並重新開啟遊戲,或重新啟動主機。這是已知限制,記錄在問題 #198。",
|
||||
saves_ps4_warning_title: "PS4 格式存檔:",
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
Upload as UploadIcon,
|
||||
FolderOpen,
|
||||
HardDrive,
|
||||
LockOpen,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { openInFileSystem } from "../../state/fsNavigation";
|
||||
import {
|
||||
@@ -26,6 +28,9 @@ import {
|
||||
saveArchiveBackupFinalize,
|
||||
saveArchiveRestorePrepare,
|
||||
type SaveEntry,
|
||||
savesOpen,
|
||||
savesClose,
|
||||
type OpenSave,
|
||||
} from "../../api/ps5";
|
||||
import { beginTask, type TaskHandle } from "../../state/trackTask";
|
||||
import { localFs } from "../../api/localFs";
|
||||
@@ -41,6 +46,7 @@ import {
|
||||
GameIcon,
|
||||
OverflowMenu,
|
||||
Spinner,
|
||||
Modal,
|
||||
} from "../../components";
|
||||
// Direct import to avoid the barrel's circular-dep warning at build.
|
||||
import { useConfirm } from "../../components/ConfirmDialog";
|
||||
@@ -77,6 +83,78 @@ export default function SavesScreen() {
|
||||
const navigate = useNavigate();
|
||||
const host = useConnectionStore((s) => s.host);
|
||||
const payloadStatus = useConnectionStore((s) => s.payloadStatus);
|
||||
// A save opened decrypted on the console (one at a time), and the picker for a folder
|
||||
// holding more than one save image.
|
||||
const [openSave, setOpenSave] = useState<OpenSave | null>(null);
|
||||
const [openBusy, setOpenBusy] = useState(false);
|
||||
const [picker, setPicker] = useState<{ entry: SaveEntry; images: string[] } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!host?.trim() || payloadStatus !== "up") {
|
||||
setOpenSave(null);
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
savesOpen(consoleAddr(host.trim()), "")
|
||||
.then((r) => live && setOpenSave(r.mounted ? r : null))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [host, payloadStatus]);
|
||||
const doOpenSave = async (image: string) => {
|
||||
if (!host?.trim()) return;
|
||||
setOpenBusy(true);
|
||||
try {
|
||||
const r = await savesOpen(consoleAddr(host.trim()), image);
|
||||
setOpenSave(r.mounted ? r : null);
|
||||
pushNotification("success", tr("saves_opened", undefined, "Save open"), {
|
||||
body: tr("saves_opened_body", { path: r.mount_point }, "Browse and edit it under {path} in Files. Close it from Save data when done."),
|
||||
});
|
||||
openInFileSystem(navigate, r.mount_point);
|
||||
} catch (e) {
|
||||
pushNotification("error", tr("saves_open_failed", { error: e instanceof Error ? e.message : String(e) }, "Could not open the save: {error}"));
|
||||
} finally {
|
||||
setOpenBusy(false);
|
||||
}
|
||||
};
|
||||
const handleOpenSave = async (entry: SaveEntry) => {
|
||||
if (!host?.trim() || openBusy) return;
|
||||
setOpenBusy(true);
|
||||
try {
|
||||
const children = await fsListDir(consoleAddr(host.trim()), entry.path, { limit: 4096 });
|
||||
const images = children
|
||||
.filter((c) => c.kind === "file" && !/\.bin$/i.test(c.name) && (entry.kind !== "ps4" || c.name.startsWith("sdimg_")))
|
||||
.map((c) => (entry.path.endsWith("/") ? `${entry.path}${c.name}` : `${entry.path}/${c.name}`));
|
||||
if (images.length === 0) {
|
||||
pushNotification("info", tr("saves_open_none", undefined, "No save image in this folder."));
|
||||
return;
|
||||
}
|
||||
setOpenBusy(false);
|
||||
if (images.length === 1) await doOpenSave(images[0]);
|
||||
else setPicker({ entry, images });
|
||||
} catch (e) {
|
||||
pushNotification("error", tr("saves_open_failed", { error: e instanceof Error ? e.message : String(e) }, "Could not open the save: {error}"));
|
||||
} finally {
|
||||
setOpenBusy(false);
|
||||
}
|
||||
};
|
||||
const handleCloseSave = async () => {
|
||||
if (!host?.trim() || openBusy) return;
|
||||
setOpenBusy(true);
|
||||
try {
|
||||
const r = await savesClose(consoleAddr(host.trim()));
|
||||
setOpenSave(null);
|
||||
pushNotification("success", tr("saves_closed", undefined, "Save closed"), {
|
||||
body: r.written_back
|
||||
? tr("saves_closed_written", undefined, "The image was written back into the save.")
|
||||
: tr("saves_closed_unchanged", undefined, "Nothing changed; the save is as it was."),
|
||||
});
|
||||
} catch (e) {
|
||||
pushNotification("error", tr("saves_open_failed", { error: e instanceof Error ? e.message : String(e) }, "Could not close the save: {error}"));
|
||||
} finally {
|
||||
setOpenBusy(false);
|
||||
}
|
||||
};
|
||||
const guard = useStaleHostGuard();
|
||||
const [saves, setSaves] = useState<SaveEntry[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -894,6 +972,52 @@ export default function SavesScreen() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{openSave?.mounted && (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-[var(--radius-card)] border border-[color-mix(in_srgb,var(--color-accent)_45%,transparent)] bg-[var(--color-accent-soft)] p-3 text-xs"
|
||||
data-testid="save-open-banner"
|
||||
>
|
||||
<LockOpen size={14} className="shrink-0 text-[var(--color-accent-bright)]" aria-hidden />
|
||||
<span className="min-w-0 flex-1">
|
||||
{tr(
|
||||
"saves_open_banner",
|
||||
{ name: openSave.image.split("/").pop() ?? openSave.image, path: openSave.mount_point },
|
||||
"{name} is open at {path}. Changes are written back into the save when you close it.",
|
||||
)}
|
||||
</span>
|
||||
<Button size="sm" variant="secondary" leftIcon={<FolderOpen size={11} />} onClick={() => openInFileSystem(navigate, openSave.mount_point)}>
|
||||
{tr("saves_open_in_files", undefined, "Open in Files")}
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" leftIcon={<Lock size={11} />} disabled={openBusy} onClick={() => void handleCloseSave()}>
|
||||
{tr("saves_close", undefined, "Close & write back")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{picker && (
|
||||
<Modal
|
||||
open
|
||||
onClose={() => setPicker(null)}
|
||||
title={tr("saves_open_pick_title", { title: picker.entry.title_id }, "Which save of {title}?")}
|
||||
size="md"
|
||||
>
|
||||
<ul className="flex flex-col gap-1" data-testid="save-open-picker">
|
||||
{picker.images.map((img) => (
|
||||
<li key={img}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-[var(--radius-field)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-left font-mono text-xs hover:bg-[var(--color-surface-raised)]"
|
||||
onClick={() => {
|
||||
setPicker(null);
|
||||
void doOpenSave(img);
|
||||
}}
|
||||
>
|
||||
{img.split("/").pop()}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Modal>
|
||||
)}
|
||||
{grouped.map(({ title_id, entries }) => (
|
||||
<section
|
||||
key={title_id}
|
||||
@@ -924,6 +1048,20 @@ export default function SavesScreen() {
|
||||
{formatDate(e.mtime * 1000, "date")}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftIcon={<LockOpen size={11} />}
|
||||
onClick={() => void handleOpenSave(e)}
|
||||
disabled={isBusy(e.path) || openBusy}
|
||||
title={tr(
|
||||
"saves_open_hint",
|
||||
undefined,
|
||||
"Mount this save decrypted on the PS5 and browse its files in Files. Changes are written back when you close it.",
|
||||
)}
|
||||
>
|
||||
{tr("saves_open", undefined, "Open save")}
|
||||
</Button>
|
||||
{isTauriEnv() && (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -163,6 +163,8 @@ pub const METHOD_RP_DEVICES: u16 = 141;
|
||||
pub const METHOD_APP_ICON_SET: u16 = 142;
|
||||
pub const METHOD_APP_HIDDEN_LIST: u16 = 143;
|
||||
pub const METHOD_USB_EJECT: u16 = 144;
|
||||
pub const METHOD_SAVE_MOUNT: u16 = 145;
|
||||
pub const METHOD_SAVE_UNMOUNT: u16 = 146;
|
||||
pub const JOB_OP_DELETE: u8 = 1;
|
||||
pub const JOB_OP_CHMOD_R: u8 = 2;
|
||||
pub const JOB_OP_HASH: u8 = 3;
|
||||
|
||||
@@ -161,6 +161,8 @@ methods! {
|
||||
(APP_ICON_SET, 142, "APP_ICON_SET"),
|
||||
(APP_HIDDEN_LIST, 143, "APP_HIDDEN_LIST"),
|
||||
(USB_EJECT, 144, "USB_EJECT"),
|
||||
(SAVE_MOUNT, 145, "SAVE_MOUNT"),
|
||||
(SAVE_UNMOUNT, 146, "SAVE_UNMOUNT"),
|
||||
}
|
||||
|
||||
/// A management call the payload refused.
|
||||
|
||||
@@ -40,6 +40,45 @@ pub fn list_saves(addr: &str, user_id: i32) -> Result<SaveList> {
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// What the helper has open (`save.mount` / `save.unmount`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct OpenSave {
|
||||
pub mounted: bool,
|
||||
/// The real image under /user/home ("" when nothing is open).
|
||||
pub image: String,
|
||||
pub mount_point: String,
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub was_open: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub written_back: Option<bool>,
|
||||
}
|
||||
|
||||
fn open_save_reply(resp: &[u8]) -> Result<OpenSave> {
|
||||
let v: serde_json::Value = serde_json::from_slice(resp)?;
|
||||
if v.get("ok").and_then(|b| b.as_bool()) != Some(true) {
|
||||
anyhow::bail!(
|
||||
"{}",
|
||||
v.get("err")
|
||||
.and_then(|e| e.as_str())
|
||||
.unwrap_or("save_failed")
|
||||
);
|
||||
}
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
/// Opens `image` (a save image under /user/home) decrypted at the helper's mount point; an
|
||||
/// empty `image` only reports what is open.
|
||||
pub fn save_open(addr: &str, image: &str) -> Result<OpenSave> {
|
||||
let body = serde_json::to_vec(&serde_json::json!({ "image": image }))?;
|
||||
open_save_reply(&mgmt::call(addr, m::SAVE_MOUNT, &body)?)
|
||||
}
|
||||
|
||||
/// Closes the open save and writes its image back.
|
||||
pub fn save_close(addr: &str) -> Result<OpenSave> {
|
||||
open_save_reply(&mgmt::call(addr, m::SAVE_UNMOUNT, b"{}")?)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScreenshotEntry {
|
||||
pub path: String,
|
||||
|
||||
@@ -3462,6 +3462,51 @@ async fn ps5_usb_eject(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SaveOpenReq {
|
||||
addr: Option<String>,
|
||||
/// The save image under /user/home; empty or absent only reports what is open.
|
||||
#[serde(default)]
|
||||
image: String,
|
||||
}
|
||||
|
||||
/// POST /api/ps5/saves/open `{addr, image}` — the helper mounts the save decrypted (copied
|
||||
/// under /data first) and says where; the Files screen then reads and writes it. One save
|
||||
/// open at a time; another one closes the first. Empty `image` reports what is open.
|
||||
async fn ps5_save_open(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SaveOpenReq>,
|
||||
) -> impl IntoResponse {
|
||||
let addr = console_addr_or_default(req.addr, &state.default_ps5_addr);
|
||||
let image = req.image;
|
||||
crate::log_info!("save_open: addr={addr} image={image}");
|
||||
let r = tokio::task::spawn_blocking(move || ps5upload_core::saves::save_open(&addr, &image))
|
||||
.await
|
||||
.map_err(anyhow::Error::from)
|
||||
.and_then(|r| r);
|
||||
match r {
|
||||
Ok(v) => (StatusCode::OK, Json(v)).into_response(),
|
||||
Err(e) => json_err(StatusCode::BAD_GATEWAY, format!("{e:#}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/ps5/saves/close `{addr}` — unmounts the open save and writes it back.
|
||||
async fn ps5_save_close(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SaveOpenReq>,
|
||||
) -> impl IntoResponse {
|
||||
let addr = console_addr_or_default(req.addr, &state.default_ps5_addr);
|
||||
crate::log_info!("save_close: addr={addr}");
|
||||
let r = tokio::task::spawn_blocking(move || ps5upload_core::saves::save_close(&addr))
|
||||
.await
|
||||
.map_err(anyhow::Error::from)
|
||||
.and_then(|r| r);
|
||||
match r {
|
||||
Ok(v) => (StatusCode::OK, Json(v)).into_response(),
|
||||
Err(e) => json_err(StatusCode::BAD_GATEWAY, format!("{e:#}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetIconHiddenReq {
|
||||
addr: Option<String>,
|
||||
@@ -9933,6 +9978,8 @@ async fn run(cfg: EngineConfig) -> anyhow::Result<()> {
|
||||
.route("/api/ps5/fs/mount", post(ps5_fs_mount))
|
||||
.route("/api/ps5/fs/unmount", post(ps5_fs_unmount))
|
||||
.route("/api/ps5/usb/eject", post(ps5_usb_eject))
|
||||
.route("/api/ps5/saves/open", post(ps5_save_open))
|
||||
.route("/api/ps5/saves/close", post(ps5_save_close))
|
||||
.route("/api/ps5/app/launch", post(ps5_app_launch))
|
||||
.route("/api/ps5/app/register", post(ps5_app_register))
|
||||
.route("/api/ps5/app/unregister", post(ps5_app_unregister))
|
||||
|
||||
@@ -1002,6 +1002,17 @@ fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
match rest[0].as_str() {
|
||||
"save-open" => {
|
||||
let image = rest.get(1).map(String::as_str).unwrap_or("");
|
||||
let r = ps5upload_core::saves::save_open(addr, image)?;
|
||||
println!("{}", serde_json::to_string_pretty(&r)?);
|
||||
Ok(())
|
||||
}
|
||||
"save-close" => {
|
||||
let r = ps5upload_core::saves::save_close(addr)?;
|
||||
println!("{}", serde_json::to_string_pretty(&r)?);
|
||||
Ok(())
|
||||
}
|
||||
"ava1-ping" | "ava1-lanes" | "ava1-cryptobench" | "ava1-pairing-open" | "chaos-proxy" => {
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(async {
|
||||
|
||||
+1
-1
@@ -155,7 +155,7 @@ CFLAGS := -Wall -Wextra -Werror -g $(INCLUDES)
|
||||
# it and work, so this is hardening that follows upstream, not a proven cause
|
||||
# of any specific report.
|
||||
LIBS := -lSceSystemService -lSceUserService -lSceIpmi -lSceAppInstUtil \
|
||||
-lSceNotification -lSceRegMgr -lkernel_sys
|
||||
-lSceNotification -lSceRegMgr -lSceFsInternalForVsh -lkernel_sys
|
||||
|
||||
SRCS := src/main.c src/runtime.c src/shell_builtin.c src/takeover.c src/takeover_flag.c src/ownership_record.c src/ava1_stop.c src/register.c src/hw_info.c \
|
||||
src/drive_sensors.c \
|
||||
|
||||
@@ -167,6 +167,8 @@
|
||||
#define AVA1_METHOD_APP_ICON_SET 142ULL
|
||||
#define AVA1_METHOD_APP_HIDDEN_LIST 143ULL
|
||||
#define AVA1_METHOD_USB_EJECT 144ULL
|
||||
#define AVA1_METHOD_SAVE_MOUNT 145ULL
|
||||
#define AVA1_METHOD_SAVE_UNMOUNT 146ULL
|
||||
#define AVA1_JOB_OP_DELETE 1ULL
|
||||
#define AVA1_JOB_OP_CHMOD_R 2ULL
|
||||
#define AVA1_JOB_OP_HASH 3ULL
|
||||
|
||||
@@ -148,3 +148,5 @@ MGMT_H1(AVA1_METHOD_FS_MOUNT_LWFS, MGMT_FRAME_LWFS_MOUNT, MGMT_FRAME_LWFS_MOUNT_
|
||||
MGMT_H1(AVA1_METHOD_APP_ICON_SET, MGMT_FRAME_APP_ICON_SET, MGMT_FRAME_APP_ICON_SET_ACK, MGMT_SONY, handle_app_icon_set, mgmt_call_text)
|
||||
MGMT_H0(AVA1_METHOD_APP_HIDDEN_LIST, MGMT_FRAME_APP_HIDDEN_LIST, MGMT_FRAME_APP_HIDDEN_LIST_ACK, 0, handle_app_hidden_list, mgmt_call_text)
|
||||
MGMT_H1(AVA1_METHOD_USB_EJECT, MGMT_FRAME_USB_EJECT, MGMT_FRAME_USB_EJECT_ACK, MGMT_SONY, handle_usb_eject, mgmt_call_text)
|
||||
MGMT_H1(AVA1_METHOD_SAVE_MOUNT, MGMT_FRAME_SAVE_MOUNT, MGMT_FRAME_SAVE_MOUNT_ACK, MGMT_SONY, handle_save_mount, mgmt_call_text)
|
||||
MGMT_H1(AVA1_METHOD_SAVE_UNMOUNT, MGMT_FRAME_SAVE_UNMOUNT, MGMT_FRAME_SAVE_UNMOUNT_ACK, MGMT_SONY, handle_save_unmount, mgmt_call_text)
|
||||
@@ -261,6 +261,10 @@
|
||||
#define MGMT_FRAME_APP_HIDDEN_LIST_ACK 258u
|
||||
#define MGMT_FRAME_USB_EJECT 259u
|
||||
#define MGMT_FRAME_USB_EJECT_ACK 260u
|
||||
#define MGMT_FRAME_SAVE_MOUNT 261u
|
||||
#define MGMT_FRAME_SAVE_MOUNT_ACK 262u
|
||||
#define MGMT_FRAME_SAVE_UNMOUNT 263u
|
||||
#define MGMT_FRAME_SAVE_UNMOUNT_ACK 264u
|
||||
#define MGMT_FRAME_SDK_SCAN 214u
|
||||
#define MGMT_FRAME_SDK_SCAN_ACK 215u
|
||||
#define MGMT_FRAME_SDK_PATCH 216u
|
||||
@@ -7018,6 +7022,297 @@ static int handle_app_hidden_list(runtime_state_t *state) {
|
||||
* after a sync(): the cache is flushed and the exFAT volume marked clean, which is
|
||||
* what makes pulling the stick safe; the caller is told to close any game or app
|
||||
* that reads from it first. */
|
||||
/* ── Open a save (mount its image decrypted) ─────────────────────────────────
|
||||
*
|
||||
* A PS5 save is one PFS image per save slot under
|
||||
* /user/home/<uid>/savedata_prospero/<TITLE>/<name> (its sealed key sits in the image at
|
||||
* 0x800); a PS4 save is /user/home/<uid>/savedata/<TITLE>/sdimg_<name> with the key beside
|
||||
* it in <name>.bin. /dev/pfsmgr unseals the key (ioctl 0xc0845302, the same call the shell
|
||||
* makes) and sceFsMountSaveData (libSceFsInternalForVsh) mounts the image with it, which
|
||||
* gives plain files the app's Files screen can read and write. Mounting an image in place
|
||||
* under /user/home fails with EPIPE, so the image is copied under /data first and copied
|
||||
* back when the save is closed; that also means a crash mid-edit leaves the real save as it
|
||||
* was. One save open at a time. What earthonion's garlic-savemgr does, in our helper. */
|
||||
typedef struct {
|
||||
uint8_t reserved;
|
||||
char *budgetid;
|
||||
} SceFsMountSaveDataOpt;
|
||||
typedef struct {
|
||||
uint8_t dummy;
|
||||
} SceFsUmountSaveDataOpt;
|
||||
int sceFsInitMountSaveDataOpt(SceFsMountSaveDataOpt *opt);
|
||||
int sceFsMountSaveData(SceFsMountSaveDataOpt *opt, const char *image, const char *mount_point,
|
||||
uint8_t *key);
|
||||
int sceFsInitUmountSaveDataOpt(SceFsUmountSaveDataOpt *opt);
|
||||
int sceFsUmountSaveData(SceFsUmountSaveDataOpt *opt, const char *mount_point, int handle,
|
||||
int ignore);
|
||||
|
||||
#define SAVE_MOUNT_POINT "/data/ps5upload/save_mount"
|
||||
#define SAVE_COPY_DIR "/data/ps5upload/save_open"
|
||||
|
||||
static pthread_mutex_t g_save_mtx = PTHREAD_MUTEX_INITIALIZER;
|
||||
static int g_save_mounted = 0;
|
||||
static char g_save_image[1024]; /* the real image, under /user/home */
|
||||
static char g_save_copy[1024]; /* the copy that is mounted */
|
||||
static char g_save_copy_key[1024]; /* PS4: the copied .bin, "" for PS5 */
|
||||
static int g_save_is_ps4 = 0;
|
||||
/* The mounted copy's mtime and size right after mounting: unchanged at unmount means the
|
||||
* save was only looked at, and the real image is left as it was. */
|
||||
static time_t g_save_copy_mtime;
|
||||
static off_t g_save_copy_size;
|
||||
|
||||
static int save_copy_file(const char *src, const char *dst) {
|
||||
int sfd = open(src, O_RDONLY);
|
||||
if (sfd < 0) return -1;
|
||||
int dfd = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
||||
if (dfd < 0) { close(sfd); return -1; }
|
||||
/* Heap, never the stack: a large stack buffer on a worker thread has wedged the console. */
|
||||
const size_t bufsz = 256 * 1024;
|
||||
char *buf = malloc(bufsz);
|
||||
if (!buf) { close(sfd); close(dfd); return -1; }
|
||||
ssize_t n;
|
||||
int rc = 0;
|
||||
while ((n = read(sfd, buf, bufsz)) > 0) {
|
||||
ssize_t off = 0;
|
||||
while (off < n) {
|
||||
ssize_t w = write(dfd, buf + off, (size_t)(n - off));
|
||||
if (w <= 0) { rc = -1; break; }
|
||||
off += w;
|
||||
}
|
||||
if (rc) break;
|
||||
}
|
||||
if (n < 0) rc = -1;
|
||||
if (fsync(dfd) != 0) rc = -1;
|
||||
free(buf);
|
||||
close(sfd);
|
||||
close(dfd);
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* The unsealed key of an image: PS5 from the image at 0x800, PS4 from `key_path`. */
|
||||
static int save_unseal_key(const char *image, const char *key_path, int is_ps4,
|
||||
uint8_t out[32], int *why_errno) {
|
||||
/* The ioctl writes past the 0x60 input (the key at 0x60..0x80): give it room on the
|
||||
* heap, as the shell does. */
|
||||
uint8_t *data = calloc(1, 0x100);
|
||||
if (!data) return -1;
|
||||
int fd = open(is_ps4 ? key_path : image, O_RDONLY);
|
||||
if (fd < 0) { *why_errno = errno; free(data); return -2; }
|
||||
ssize_t r = is_ps4 ? read(fd, data, 0x60) : pread(fd, data, 0x60, 0x800);
|
||||
close(fd);
|
||||
if (r != 0x60) { *why_errno = EIO; free(data); return -3; }
|
||||
int pfsmgr = open("/dev/pfsmgr", O_RDWR);
|
||||
if (pfsmgr < 0) { *why_errno = errno; free(data); return -4; }
|
||||
int ret = ioctl(pfsmgr, 0xc0845302, data);
|
||||
int e = errno;
|
||||
close(pfsmgr);
|
||||
if (ret < 0) { *why_errno = e; free(data); return -5; }
|
||||
memcpy(out, data + 0x60, 32);
|
||||
free(data);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int save_do_unmount(int *written_back) {
|
||||
*written_back = 0;
|
||||
if (!g_save_mounted) return 0;
|
||||
SceFsUmountSaveDataOpt uopt;
|
||||
memset(&uopt, 0, sizeof uopt);
|
||||
sceFsInitUmountSaveDataOpt(&uopt);
|
||||
int rc = sceFsUmountSaveData(&uopt, SAVE_MOUNT_POINT, 0, 0);
|
||||
sync();
|
||||
if (rc < 0 && errno != EINVAL && errno != ENOENT) {
|
||||
/* Still mounted: do not touch the real save. */
|
||||
return -1;
|
||||
}
|
||||
if (g_save_copy[0] && g_save_image[0]) {
|
||||
struct stat cst;
|
||||
int changed = stat(g_save_copy, &cst) != 0 || cst.st_mtime != g_save_copy_mtime ||
|
||||
cst.st_size != g_save_copy_size;
|
||||
if (!changed) {
|
||||
unlink(g_save_copy);
|
||||
} else if (save_copy_file(g_save_copy, g_save_image) == 0) {
|
||||
*written_back = 1;
|
||||
unlink(g_save_copy);
|
||||
}
|
||||
}
|
||||
if (g_save_copy_key[0]) unlink(g_save_copy_key);
|
||||
sync();
|
||||
g_save_mounted = 0;
|
||||
g_save_image[0] = g_save_copy[0] = g_save_copy_key[0] = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int save_do_mount(const char *image, char *err, size_t err_sz, int *is_ps4_out) {
|
||||
const char *bname = strrchr(image, '/');
|
||||
bname = bname ? bname + 1 : image;
|
||||
if (strncmp(image, "/user/home/", 11) != 0 || strstr(image, "/../") != NULL ||
|
||||
bname[0] == 0 || strlen(bname) >= 200) {
|
||||
snprintf(err, err_sz, "save_image_path: must be a save image under /user/home");
|
||||
return -1;
|
||||
}
|
||||
struct stat st;
|
||||
if (stat(image, &st) != 0 || !S_ISREG(st.st_mode)) {
|
||||
snprintf(err, err_sz, "save_image_missing: %s", strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
int is_ps4 = 0;
|
||||
{
|
||||
int fd = open(image, O_RDONLY);
|
||||
uint8_t h = 0;
|
||||
if (fd >= 0) {
|
||||
if (pread(fd, &h, 1, 0) == 1 && h == 0x01) is_ps4 = 1;
|
||||
close(fd);
|
||||
}
|
||||
}
|
||||
if (is_ps4 && strncmp(bname, "sdimg_", 6) != 0) {
|
||||
snprintf(err, err_sz, "save_image_not_sdimg: a PS4 save image is named sdimg_<name>");
|
||||
return -1;
|
||||
}
|
||||
*is_ps4_out = is_ps4;
|
||||
mkdir("/data/ps5upload", 0777);
|
||||
mkdir(SAVE_COPY_DIR, 0777);
|
||||
mkdir(SAVE_MOUNT_POINT, 0777);
|
||||
char copy[1024], copy_key[1024] = {0};
|
||||
snprintf(copy, sizeof copy, SAVE_COPY_DIR "/%s", bname);
|
||||
if (save_copy_file(image, copy) != 0) {
|
||||
snprintf(err, err_sz, "save_copy_failed: %s", strerror(errno));
|
||||
return -1;
|
||||
}
|
||||
chmod(copy, 0755);
|
||||
char real_key[1024] = {0};
|
||||
if (is_ps4) {
|
||||
const char *name = bname + 6;
|
||||
size_t dirlen = (size_t)(bname - image);
|
||||
snprintf(real_key, sizeof real_key, "%.*s%s.bin", (int)dirlen, image, name);
|
||||
snprintf(copy_key, sizeof copy_key, SAVE_COPY_DIR "/%s.bin", name);
|
||||
if (save_copy_file(real_key, copy_key) != 0) {
|
||||
snprintf(err, err_sz, "save_key_missing: %s has no %s.bin beside it", bname, name);
|
||||
unlink(copy);
|
||||
return -1;
|
||||
}
|
||||
chmod(copy_key, 0755);
|
||||
}
|
||||
uint8_t key[32];
|
||||
int why = 0;
|
||||
int kr = save_unseal_key(copy, copy_key, is_ps4, key, &why);
|
||||
if (kr != 0) {
|
||||
snprintf(err, err_sz, "save_key_unseal_failed: step %d, %s", kr, strerror(why));
|
||||
unlink(copy);
|
||||
if (copy_key[0]) unlink(copy_key);
|
||||
return -1;
|
||||
}
|
||||
SceFsMountSaveDataOpt mopt;
|
||||
memset(&mopt, 0, sizeof mopt);
|
||||
sceFsInitMountSaveDataOpt(&mopt);
|
||||
mopt.budgetid = "system";
|
||||
/* The mount talks to a shell service over a pipe; with SIGPIPE ignored (as our server
|
||||
* runs) the call reports EPIPE instead of completing. */
|
||||
void (*old)(int) = signal(SIGPIPE, SIG_DFL);
|
||||
int ret = sceFsMountSaveData(&mopt, copy, SAVE_MOUNT_POINT, key);
|
||||
int e = errno;
|
||||
signal(SIGPIPE, old);
|
||||
memset(key, 0, sizeof key);
|
||||
if (ret < 0) {
|
||||
snprintf(err, err_sz, "save_mount_failed: 0x%x, %s", (unsigned)ret, strerror(e));
|
||||
unlink(copy);
|
||||
if (copy_key[0]) unlink(copy_key);
|
||||
return -1;
|
||||
}
|
||||
g_save_mounted = 1;
|
||||
g_save_is_ps4 = is_ps4;
|
||||
{
|
||||
struct stat cst;
|
||||
if (stat(copy, &cst) == 0) {
|
||||
g_save_copy_mtime = cst.st_mtime;
|
||||
g_save_copy_size = cst.st_size;
|
||||
} else {
|
||||
g_save_copy_mtime = 0;
|
||||
g_save_copy_size = -1;
|
||||
}
|
||||
}
|
||||
snprintf(g_save_image, sizeof g_save_image, "%s", image);
|
||||
snprintf(g_save_copy, sizeof g_save_copy, "%s", copy);
|
||||
snprintf(g_save_copy_key, sizeof g_save_copy_key, "%s", copy_key);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int save_status_json(char *resp, size_t sz, const char *extra) {
|
||||
char esc[2100];
|
||||
json_escape_into(g_save_image, esc, sizeof esc);
|
||||
return snprintf(resp, sz,
|
||||
"{\"ok\":true,\"err\":null,\"mounted\":%s,\"image\":\"%s\","
|
||||
"\"mount_point\":\"%s\",\"kind\":\"%s\"%s}",
|
||||
g_save_mounted ? "true" : "false", g_save_mounted ? esc : "",
|
||||
g_save_mounted ? SAVE_MOUNT_POINT : "",
|
||||
g_save_mounted ? (g_save_is_ps4 ? "ps4" : "ps5") : "",
|
||||
extra ? extra : "");
|
||||
}
|
||||
|
||||
/* save.mount {image}: opens the save, replacing one already open (which is closed and
|
||||
* written back first). An empty `image` only reports what is open. */
|
||||
static int handle_save_mount(runtime_state_t *state, const char *body, uint64_t body_len) {
|
||||
if (!state) return -1;
|
||||
char image[1024] = {0};
|
||||
char resp[2600];
|
||||
int n;
|
||||
(void)parse_json_string_field_local(body, body_len, "image", image, sizeof(image));
|
||||
pthread_mutex_lock(&g_save_mtx);
|
||||
if (image[0] == 0) {
|
||||
n = save_status_json(resp, sizeof resp, NULL);
|
||||
} else if (g_save_mounted && strcmp(image, g_save_image) == 0) {
|
||||
n = save_status_json(resp, sizeof resp, NULL);
|
||||
} else {
|
||||
char err[512] = {0};
|
||||
int wb = 0;
|
||||
if (g_save_mounted && save_do_unmount(&wb) != 0) {
|
||||
n = snprintf(resp, sizeof resp,
|
||||
"{\"ok\":false,\"err\":\"save_unmount_failed: the open save could not be closed\"}");
|
||||
} else {
|
||||
int is_ps4 = 0;
|
||||
if (save_do_mount(image, err, sizeof err, &is_ps4) != 0) {
|
||||
char eesc[1100];
|
||||
json_escape_into(err, eesc, sizeof eesc);
|
||||
n = snprintf(resp, sizeof resp, "{\"ok\":false,\"err\":\"%s\"}", eesc);
|
||||
} else {
|
||||
n = save_status_json(resp, sizeof resp, NULL);
|
||||
}
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&g_save_mtx);
|
||||
int rc = mgmt_reply(MGMT_FRAME_SAVE_MOUNT_ACK, resp, (uint64_t)(n > 0 ? n : 0));
|
||||
pthread_mutex_lock(&state->state_mtx);
|
||||
state->command_count += 1;
|
||||
pthread_mutex_unlock(&state->state_mtx);
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* save.unmount {}: closes the open save and writes the image back. */
|
||||
static int handle_save_unmount(runtime_state_t *state, const char *body, uint64_t body_len) {
|
||||
(void)body;
|
||||
(void)body_len;
|
||||
if (!state) return -1;
|
||||
char resp[2600];
|
||||
int n;
|
||||
pthread_mutex_lock(&g_save_mtx);
|
||||
int wb = 0;
|
||||
int was = g_save_mounted;
|
||||
if (save_do_unmount(&wb) != 0) {
|
||||
n = snprintf(resp, sizeof resp,
|
||||
"{\"ok\":false,\"err\":\"save_unmount_failed: the save is still in use (a game open?)\"}");
|
||||
} else {
|
||||
char extra[64];
|
||||
snprintf(extra, sizeof extra, ",\"was_open\":%s,\"written_back\":%s", was ? "true" : "false",
|
||||
wb ? "true" : "false");
|
||||
n = save_status_json(resp, sizeof resp, extra);
|
||||
}
|
||||
pthread_mutex_unlock(&g_save_mtx);
|
||||
int rc = mgmt_reply(MGMT_FRAME_SAVE_UNMOUNT_ACK, resp, (uint64_t)(n > 0 ? n : 0));
|
||||
pthread_mutex_lock(&state->state_mtx);
|
||||
state->command_count += 1;
|
||||
pthread_mutex_unlock(&state->state_mtx);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int handle_usb_eject(runtime_state_t *state, const char *body,
|
||||
uint64_t body_len) {
|
||||
if (!state) return -1;
|
||||
|
||||
@@ -120,6 +120,8 @@ ones (`ava1-ctest/tests/mgmt*.rs`, `ps5upload-ava1/tests/mgmt*.rs`). Methods 119
|
||||
| ActivityReset 253 | 128 `activity.reset` | `MgmtText` | `src/runtime.c` `handle_activity_reset` | `ps5upload-core/src/activity.rs` | |
|
||||
| AppIconSet 255 | 142 `app.icon_set` | `MgmtText` | `src/runtime.c` `handle_app_icon_set` | `ps5upload-core/src/diagnostics.rs` | hides or shows a title's home-screen icon (#407) |
|
||||
| AppHiddenList 257 | 143 `app.hidden_list` | `MgmtText` | `src/runtime.c` `handle_app_hidden_list` | `ps5upload-core/src/diagnostics.rs` | |
|
||||
| SaveMount 261 | 145 `save.mount` | `MgmtText` {image} | `src/runtime.c` `handle_save_mount` | `ps5upload-core/src/saves.rs` | opens a save: copies its image under /data, unseals the key through /dev/pfsmgr, mounts it with sceFsMountSaveData at /data/ps5upload/save_mount; an empty image reports what is open |
|
||||
| SaveUnmount 263 | 146 `save.unmount` | `MgmtText` {} | `src/runtime.c` `handle_save_unmount` | `ps5upload-core/src/saves.rs` | closes the open save and copies the image back over the real one (`written_back`) |
|
||||
| UsbEject 259 | 144 `usb.eject` | `MgmtText` | `src/runtime.c` `handle_usb_eject` | `ps5upload-core/src/fs_ops.rs` | unmounts `/mnt/usbN` so the drive can be pulled; plain unmount, or `force` (after a sync) since the shell's own services keep the drive open (EBUSY on FW 13.60) |
|
||||
| — | 21 `job.list` | empty -> `JobListResult` | `ava1/ava1_data.c` (the data plane) | none | `ava1-ctest/tests/job_run.rs` `repeat_job_run_is_idempotent`, `at_most_eight_operations_run_at_once_and_an_unknown_op_is_refused` (listing); no engine caller yet |
|
||||
| — | 34 `fs.stat` | `FsPath` -> `FsStat` | native `src/mgmt_fs.c` `mgmt_run_fs_stat` | none | host-tested |
|
||||
|
||||
@@ -1060,6 +1060,14 @@ name = "METHOD_USB_EJECT"
|
||||
ty = "u16"
|
||||
value = 144
|
||||
[[const]]
|
||||
name = "METHOD_SAVE_MOUNT"
|
||||
ty = "u16"
|
||||
value = 145
|
||||
[[const]]
|
||||
name = "METHOD_SAVE_UNMOUNT"
|
||||
ty = "u16"
|
||||
value = 146
|
||||
[[const]]
|
||||
name = "JOB_OP_DELETE"
|
||||
ty = "u8"
|
||||
value = 1
|
||||
|
||||
Reference in new issue
Block a user