From f61f5629e73bb58affb97d64a6586cf067318cfa Mon Sep 17 00:00:00 2001 From: phantomptr Date: Thu, 15 Oct 2026 18:05:34 -0700 Subject: [PATCH] Reddit fixes: folder games register with ShadowMount+, resumable stalls, solid-rar retries, PM updates, drag to move - Receiver writes sce_sys/param.json and param.sfo as .ava-hold and puts them in place when the job finishes: ShadowMount+ registered a folder whose eboot.bin was still arriving, failed twice and skipped the title for good (repro on 13.60). - AVA1 SkipProgress (0x30): a sender reading past data the receiver holds (a resumed solid .rar/.7z) keeps the job alive; the console ended it as stalled after 900 s on every retry. - RAR5 unknown-size entries no longer wrap the unpacked total. - Upload queue: stalled rows offer Restart transfer, stopped/interrupted rows offer Resume, "Retry failed uploads" actually runs them and is translated as an action. - Payload Manager tab: Update for payloads stored from the Catalog, keeping the boot-list place; repository installs are left to Payload Manager. - Files: drag folders (and, without drag-out, files) onto a folder or the breadcrumb to move them. - README links the compose file from the feature list. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EU1Z2mdhAxBrH1yb48pxik --- CHANGELOG.md | 26 ++++++ README.md | 3 +- client/src/i18n/locales/ar.ts | 11 ++- client/src/i18n/locales/bn.ts | 11 ++- client/src/i18n/locales/de.ts | 11 ++- client/src/i18n/locales/en.ts | 11 ++- client/src/i18n/locales/es.ts | 11 ++- client/src/i18n/locales/fa.ts | 11 ++- client/src/i18n/locales/fr.ts | 11 ++- client/src/i18n/locales/hi.ts | 11 ++- client/src/i18n/locales/hu.ts | 11 ++- client/src/i18n/locales/id.ts | 11 ++- client/src/i18n/locales/it.ts | 11 ++- client/src/i18n/locales/ja.ts | 11 ++- client/src/i18n/locales/ko.ts | 11 ++- client/src/i18n/locales/pl.ts | 11 ++- client/src/i18n/locales/pt-BR.ts | 11 ++- client/src/i18n/locales/ru.ts | 11 ++- client/src/i18n/locales/th.ts | 11 ++- client/src/i18n/locales/tr.ts | 11 ++- client/src/i18n/locales/vi.ts | 11 ++- client/src/i18n/locales/zh-CN.ts | 11 ++- client/src/i18n/locales/zh-TW.ts | 11 ++- client/src/lib/pmUpdates.test.ts | 53 +++++++++++ client/src/lib/pmUpdates.ts | 79 +++++++++++++++++ client/src/lib/queueOps.test.ts | 5 ++ client/src/lib/queueOps.ts | 4 + client/src/screens/FileSystem/index.tsx | 68 +++++++++++++- client/src/screens/Payloads/CatalogPanel.tsx | 4 + .../screens/Payloads/PayloadManagerPanel.tsx | 88 ++++++++++++++++++- client/src/screens/Upload/QueuePanel.tsx | 44 +++++++++- client/src/state/uploadQueue.ts | 41 ++++++++- engine/crates/ava1-ctest/tests/apply.rs | 60 +++++++++++++ engine/crates/ava1-ctest/tests/stall.rs | 71 +++++++++++++++ engine/crates/ava1/src/gen.rs | 41 ++++++++- engine/crates/ava1/src/recv.rs | 6 ++ engine/crates/ava1/src/send.rs | 58 ++++++++++++ engine/crates/ava1/src/seq.rs | 31 ++++++- engine/crates/ava1/tests/data_rust.rs | 41 +++++++++ .../crates/ps5upload-ava1/src/rar_source.rs | 19 +++- engine/crates/ps5upload-ava1/src/seq.rs | 4 + engine/crates/ps5upload-ava1/tests/rar.rs | 4 + engine/crates/ps5upload-core/src/transfer.rs | 44 ++++++++-- payload/ava1/ava1_apply.c | 57 +++++++++++- payload/ava1/ava1_apply.h | 2 + payload/ava1/ava1_data.c | 12 +++ payload/ava1/gen/ava1_gen.c | 32 ++++++- payload/ava1/gen/ava1_gen.h | 9 ++ payload/include/cross_device.h | 3 +- protocol/ava1/SPEC.md | 9 ++ protocol/ava1/schema/ava1.toml | 8 ++ 51 files changed, 1117 insertions(+), 40 deletions(-) create mode 100644 client/src/lib/pmUpdates.test.ts create mode 100644 client/src/lib/pmUpdates.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aaa39548..cf37fc64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,32 @@ What's new in ps5upload, written for humans. ## 7.0.26 +- **Uploaded game and homebrew folders show up with ShadowMount+ again.** ShadowMount+ + registers a folder as soon as its `sce_sys` has been still for 10 seconds; an upload sends + those small files first, so it tried while `eboot.bin` was still on its way, failed twice and + gave up on the title for good (measured on a 13.60 console). A game's `param.json` (and a PS4 + game's `param.sfo`) is now put in place only when everything else has arrived, so the folder + becomes a game exactly when it is complete. Needs the 7.0.26 helper. +- **A stalled or stopped upload can be continued.** A row that has moved no data for 90 seconds + says so and offers **Restart transfer**; a row that was stopped, or interrupted by closing the + app, says so and offers **Resume**. Both pick up from what the PS5 already has. **Retry + failed uploads** now runs them (it only put them back in the list), and its label no longer + reads "the retry failed" in most languages. +- **Large solid .rar uploads no longer fail over and over on a retry.** Resuming one reads + through everything the PS5 already has before sending anything new; the console took that + silence for a stalled sender and ended the job after 15 minutes, every time. The app now + tells the console it is still working. Needs the 7.0.26 helper. +- **A .rar's unpacked size is right** when it holds entries of unknown size (RAR5), which made + the free-space check before an upload add up to nonsense. +- **Payload Manager: update what you stored.** Payloads stored from the Catalog are remembered, + and the Payload Manager tab offers **Update** when the catalogue has a newer release: it + stores the new one in its place and keeps its spot in the boot list. Payloads Payload Manager + installed from its own repository are left to it. (Desktop and Android, where the catalogue + is.) +- **Files: drag to move.** Drag a folder onto another folder, or onto the path above the list, + to move it there (across drives it copies, then removes). Files move the same way in the web + UI and on Android; on the desktop a dragged file still goes out to your computer. Not in the + Windows app, where the window takes every drag for its own file drop; use Move to… there. - **A deleted Collection folder no longer breaks the Collection (#461).** Saving the folder list checked that every folder still existed, so once one was gone nothing could be saved, removing that folder included. Only a folder being added is checked now. A scan skips a diff --git a/README.md b/README.md index e7322841..9cd2bc40 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,8 @@ - **Power.** Rest mode, reboot, shut down, and wake over the network (optionally straight into your signed-in user). - **Runs everywhere:** macOS, Windows, Linux, Android, or any browser through the self-hosted - Docker web UI. 21 languages. + Docker web UI ([compose file](engine/compose.yaml), see + [Self-hosted engine and web UI](#self-hosted-engine-and-web-ui)). 21 languages. ## What it doesn't do diff --git a/client/src/i18n/locales/ar.ts b/client/src/i18n/locales/ar.ts index 70a8b8bb..3f621ebc 100644 --- a/client/src/i18n/locales/ar.ts +++ b/client/src/i18n/locales/ar.ts @@ -409,7 +409,6 @@ queue_title: "قائمة الانتظار", "queue_links_cleared_title": "تمّ مسح الروابط في قائمة الانتظار", "queue_links_cleared_body": "لم يُحتفظ بـ {n} من روابط التثبيت بعد إعادة التشغيل — لا تُحفظ الروابط على القرص أبدًا. أضفها مجددًا من «تثبيت حزمة».", "console_tab_queue_count": "{n} في قائمة الانتظار", -queue_retry_failed: "فشلت إعادة المحاولة", queue_stop: "التوقف عن استخدام الرد", queue_stop_tooltip: "يوقف الإرسال. ما وصل إلى PS5 يُحتفظ به 7 أيام، ويكمل «بدء» من هناك.", upload_status_stop_tooltip_v2: "يوقف الإرسال. ما وصل إلى PS5 يُحتفظ به 7 أيام؛ ويكمل «استئناف» من هناك.", @@ -3915,6 +3914,16 @@ payloads_description_ports: "المنافذ المستخدمة على PS5 وما "profile.username.addOnConsole": "لإضافة مستخدم، افعل ذلك على PS5: تبديل المستخدم ثم إضافة مستخدم.", "collection.folder_missing": "غير موجود، تم تخطيه", "collection.missing_roots": "تخطّى آخر فحص ما لم يجده: {paths}. إن كان المجلد محذوفًا فأزله من المجلدات؛ وإن كان على قرص أو خادم فأعد توصيله وافحص مجددًا.", +queue_retry_failed_v2: "أعد محاولة الرفع الفاشل", +queue_stalled: "لم يتحرك شيء منذ {time}. إعادة تشغيل النقل تكمل مما لدى PS5 بالفعل.", +queue_restart_transfer: "أعد تشغيل النقل", +queue_interrupted: "توقف في المنتصف. الاستئناف يكمل مما لدى PS5 بالفعل.", +queue_resume: "استئناف", +pm_update: "تحديث", +pm_update_available: "{tag} متاح", +pm_update_tooltip: "ينزّل الإصدار الأحدث ويخزّنه في Payload Manager مكان هذا، مع الإبقاء على ترتيبه في قائمة الإقلاع.", +pm_updated: "تم تحديث {name} إلى {tag}", +pm_update_failed: "تعذّر تحديث {name}", }; export default ar; diff --git a/client/src/i18n/locales/bn.ts b/client/src/i18n/locales/bn.ts index e62af315..365eb547 100644 --- a/client/src/i18n/locales/bn.ts +++ b/client/src/i18n/locales/bn.ts @@ -409,7 +409,6 @@ queue_title: "সারি", "queue_links_cleared_title": "সারিতে থাকা লিংকগুলো মুছে ফেলা হয়েছে", "queue_links_cleared_body": "রিস্টার্টের পর {n}টি ইনস্টল লিংক রাখা হয়নি — লিংক কখনো ডিস্কে সংরক্ষণ করা হয় না। Install Package থেকে আবার যোগ করুন।", "console_tab_queue_count": "সারিতে {n}টি", -queue_retry_failed: "পুনরায় চেষ্টা করতে ব্যর্থ হয়েছে", queue_stop: "থামুন", queue_stop_tooltip: "পাঠানো বন্ধ করে। যা PS5-এ পৌঁছে গেছে তা 7 দিন রাখা হয়, আর শুরু সেখান থেকেই চালিয়ে যায়।", upload_status_stop_tooltip_v2: "পাঠানো বন্ধ করে। যা PS5-এ পৌঁছে গেছে তা 7 দিন রাখা হয়; পুনরায় শুরু সেখান থেকেই চালিয়ে যায়।", @@ -3915,6 +3914,16 @@ payloads_description_ports: "PS5-এ ব্যবহৃত পোর্ট এ "profile.username.addOnConsole": "নতুন ইউজার যোগ করতে PS5-এই করুন: Switch User, তারপর Add User।", "collection.folder_missing": "পাওয়া যায়নি, বাদ দেওয়া হয়েছে", "collection.missing_roots": "শেষ স্ক্যান যা খুঁজে পায়নি তা বাদ দিয়েছে: {paths}। ফোল্ডার মুছে ফেলা হলে Folders থেকে সরান; ড্রাইভ বা সার্ভারে থাকলে আবার সংযোগ করে স্ক্যান করুন।", +queue_retry_failed_v2: "ব্যর্থ আপলোড আবার চেষ্টা করুন", +queue_stalled: "{time} ধরে কিছুই এগোয়নি। ট্রান্সফার আবার শুরু করলে PS5-এ যা আছে সেখান থেকে চলবে।", +queue_restart_transfer: "ট্রান্সফার আবার শুরু করুন", +queue_interrupted: "মাঝপথে থেমেছে। চালিয়ে গেলে PS5-এ যা আছে সেখান থেকে চলবে।", +queue_resume: "চালিয়ে যান", +pm_update: "আপডেট", +pm_update_available: "{tag} পাওয়া যাচ্ছে", +pm_update_tooltip: "নতুন রিলিজ ডাউনলোড করে এটির জায়গায় Payload Manager-এ রাখে, বুট তালিকায় এর জায়গা বজায় রেখে।", +pm_updated: "{name} {tag}-এ আপডেট হয়েছে", +pm_update_failed: "{name} আপডেট করা যায়নি", }; export default bn; diff --git a/client/src/i18n/locales/de.ts b/client/src/i18n/locales/de.ts index 3ca05624..811ae895 100644 --- a/client/src/i18n/locales/de.ts +++ b/client/src/i18n/locales/de.ts @@ -410,7 +410,6 @@ queue_title: "Warteschlange", "queue_links_cleared_title": "Links in der Warteschlange wurden entfernt", "queue_links_cleared_body": "{n} Installationslink(s) wurden nach dem Neustart nicht behalten – Links werden nie auf der Festplatte gespeichert. Füge sie unter „Paket installieren“ erneut hinzu.", "console_tab_queue_count": "{n} in der Warteschlange", -queue_retry_failed: "Erneuter Versuch fehlgeschlagen", queue_stop: "Stopp", queue_stop_tooltip: "Beendet das Senden. Was die PS5 schon erreicht hat, bleibt 7 Tage erhalten; Start macht dort weiter.", upload_status_stop_tooltip_v2: "Beendet das Senden. Was die PS5 schon erreicht hat, bleibt 7 Tage erhalten; Fortsetzen macht dort weiter.", @@ -3923,6 +3922,16 @@ payloads_description_ports: "Die belegten Ports auf der PS5 und was dort üblich "profile.username.addOnConsole": "Einen Benutzer fügst du auf der PS5 hinzu: Benutzer wechseln, dann Benutzer hinzufügen.", "collection.folder_missing": "Nicht gefunden, übersprungen", "collection.missing_roots": "Der letzte Scan hat übersprungen, was er nicht finden konnte: {paths}. Wurde ein Ordner gelöscht, entferne ihn unter Ordner; liegt er auf einem Laufwerk oder Server, verbinde ihn wieder und scanne erneut.", +queue_retry_failed_v2: "Fehlgeschlagene Uploads wiederholen", +queue_stalled: "Seit {time} bewegt sich nichts. Ein Neustart der Übertragung macht dort weiter, wo die PS5 schon Daten hat.", +queue_restart_transfer: "Übertragung neu starten", +queue_interrupted: "Unterwegs angehalten. Fortsetzen macht dort weiter, wo die PS5 schon Daten hat.", +queue_resume: "Fortsetzen", +pm_update: "Aktualisieren", +pm_update_available: "{tag} verfügbar", +pm_update_tooltip: "Lädt die neuere Version herunter und legt sie anstelle dieser in Payload Manager ab; ihr Platz in der Startliste bleibt.", +pm_updated: "{name} auf {tag} aktualisiert", +pm_update_failed: "{name} konnte nicht aktualisiert werden", }; export default de; diff --git a/client/src/i18n/locales/en.ts b/client/src/i18n/locales/en.ts index e9b8b616..aa0bd912 100644 --- a/client/src/i18n/locales/en.ts +++ b/client/src/i18n/locales/en.ts @@ -693,7 +693,6 @@ queue_title: "Queue", "queue_links_cleared_title": "Queued links were cleared", "queue_links_cleared_body": "{n} install link(s) weren't kept after restart — links are never saved to disk. Add them again from Install Package.", "console_tab_queue_count": "{n} in queue", -queue_retry_failed: "Retry failed", queue_stop: "Stop", queue_stop_tooltip: "Stops sending. What already reached the PS5 is kept for 7 days, and Start continues from there.", upload_status_stop_tooltip_v2: "Stops sending. What already reached the PS5 is kept for 7 days; Resume continues from there.", @@ -4238,6 +4237,16 @@ payloads_description_ports: "The ports in use on the PS5 and what usually listen "profile.username.addOnConsole": "To add a user, do it on the PS5: Switch User, then Add User.", "collection.folder_missing": "Not found, skipped", "collection.missing_roots": "The last scan skipped what it could not find: {paths}. If a folder was deleted, remove it in Folders; if it is on a drive or server, reconnect it and scan again.", +queue_retry_failed_v2: "Retry failed uploads", +queue_stalled: "Nothing has moved for {time}. Restarting the transfer picks up from what the PS5 already has.", +queue_restart_transfer: "Restart transfer", +queue_interrupted: "Stopped part-way. Resuming continues from what the PS5 already has.", +queue_resume: "Resume", +pm_update: "Update", +pm_update_available: "{tag} available", +pm_update_tooltip: "Downloads the newer release and stores it in Payload Manager in place of this one, keeping its place in the boot list.", +pm_updated: "Updated {name} to {tag}", +pm_update_failed: "Couldn't update {name}", }; export default en; diff --git a/client/src/i18n/locales/es.ts b/client/src/i18n/locales/es.ts index 2152b0b2..58ca1bd6 100644 --- a/client/src/i18n/locales/es.ts +++ b/client/src/i18n/locales/es.ts @@ -409,7 +409,6 @@ queue_title: "Cola", "queue_links_cleared_title": "Se quitaron los enlaces en cola", "queue_links_cleared_body": "{n} enlace(s) de instalación no se conservaron tras reiniciar: los enlaces nunca se guardan en disco. Vuelve a añadirlos desde Instalar paquete.", "console_tab_queue_count": "{n} en cola", -queue_retry_failed: "Reintento fallido", queue_stop: "Parada", queue_stop_tooltip: "Deja de enviar. Lo que ya llegó a la PS5 se conserva 7 días y Iniciar continúa desde ahí.", upload_status_stop_tooltip_v2: "Deja de enviar. Lo que ya llegó a la PS5 se conserva 7 días; Reanudar continúa desde ahí.", @@ -3865,6 +3864,16 @@ payloads_description_ports: "Los puertos en uso en la PS5 y lo que suele escucha "profile.username.addOnConsole": "Para añadir un usuario, hazlo en la PS5: Cambiar de usuario y luego Añadir usuario.", "collection.folder_missing": "No encontrada, omitida", "collection.missing_roots": "El último análisis omitió lo que no encontró: {paths}. Si una carpeta se borró, quítala en Carpetas; si está en una unidad o servidor, vuelve a conectarla y analiza de nuevo.", +queue_retry_failed_v2: "Reintentar subidas fallidas", +queue_stalled: "No se ha movido nada en {time}. Reiniciar la transferencia continúa desde lo que la PS5 ya tiene.", +queue_restart_transfer: "Reiniciar transferencia", +queue_interrupted: "Se detuvo a medias. Reanudar continúa desde lo que la PS5 ya tiene.", +queue_resume: "Reanudar", +pm_update: "Actualizar", +pm_update_available: "{tag} disponible", +pm_update_tooltip: "Descarga la versión más nueva y la guarda en Payload Manager en lugar de esta, manteniendo su lugar en la lista de arranque.", +pm_updated: "{name} actualizado a {tag}", +pm_update_failed: "No se pudo actualizar {name}", }; export default es; diff --git a/client/src/i18n/locales/fa.ts b/client/src/i18n/locales/fa.ts index 1471ef52..cc45b1bc 100644 --- a/client/src/i18n/locales/fa.ts +++ b/client/src/i18n/locales/fa.ts @@ -646,7 +646,6 @@ queue_title: "صف", "queue_links_cleared_title": "لینک‌های در صف پاک شدند", "queue_links_cleared_body": "{n} لینک نصب پس از ری‌استارت نگه داشته نشد — لینک‌ها هرگز روی دیسک ذخیره نمی‌شوند. آن‌ها را از Install Package دوباره اضافه کنید.", "console_tab_queue_count": "{n} در صف", -queue_retry_failed: "تلاش دوباره ناموفق بود", queue_stop: "توقف", queue_stop_tooltip: "ارسال را متوقف می‌کند. آنچه به PS5 رسیده ۷ روز نگه داشته می‌شود و «شروع» از همان‌جا ادامه می‌دهد.", upload_status_stop_tooltip_v2: "ارسال را متوقف می‌کند. آنچه به PS5 رسیده ۷ روز نگه داشته می‌شود؛ «ادامه» از همان‌جا ادامه می‌دهد.", @@ -4216,6 +4215,16 @@ payloads_description_ports: "پورت‌های در حال استفاده روی "profile.username.addOnConsole": "برای افزودن کاربر، این کار را روی PS5 انجام دهید: تغییر کاربر، سپس افزودن کاربر.", "collection.folder_missing": "پیدا نشد، رد شد", "collection.missing_roots": "آخرین اسکن آنچه را پیدا نکرد رد کرد: {paths}. اگر پوشه‌ای حذف شده، آن را از پوشه‌ها بردارید؛ اگر روی درایو یا سرور است، دوباره وصلش کنید و دوباره اسکن کنید.", +queue_retry_failed_v2: "تلاش دوباره برای آپلودهای ناموفق", +queue_stalled: "{time} است که چیزی جابه‌جا نشده. شروع دوبارهٔ انتقال از آنچه PS5 دارد ادامه می‌دهد.", +queue_restart_transfer: "شروع دوبارهٔ انتقال", +queue_interrupted: "در میانه متوقف شد. ادامه از آنچه PS5 دارد پی گرفته می‌شود.", +queue_resume: "ادامه", +pm_update: "به‌روزرسانی", +pm_update_available: "{tag} موجود است", +pm_update_tooltip: "نسخهٔ جدیدتر را دانلود و به‌جای این یکی در Payload Manager ذخیره می‌کند و جایش را در فهرست بوت نگه می‌دارد.", +pm_updated: "{name} به {tag} به‌روزرسانی شد", +pm_update_failed: "به‌روزرسانی {name} ممکن نشد", }; export default fa; diff --git a/client/src/i18n/locales/fr.ts b/client/src/i18n/locales/fr.ts index fc004558..5c7fb1ea 100644 --- a/client/src/i18n/locales/fr.ts +++ b/client/src/i18n/locales/fr.ts @@ -409,7 +409,6 @@ queue_title: "File d’attente", "queue_links_cleared_title": "Les liens en attente ont été retirés", "queue_links_cleared_body": "{n} lien(s) d’installation n’ont pas été conservés après le redémarrage — les liens ne sont jamais enregistrés sur le disque. Ajoute-les à nouveau depuis Installer un paquet.", "console_tab_queue_count": "{n} en attente", -queue_retry_failed: "Échec de la nouvelle tentative", queue_stop: "Arrêter", queue_stop_tooltip: "Arrête l'envoi. Ce qui est déjà arrivé sur la PS5 est conservé 7 jours, et Démarrer reprend à cet endroit.", upload_status_stop_tooltip_v2: "Arrête l'envoi. Ce qui est déjà arrivé sur la PS5 est conservé 7 jours ; Reprendre repart de là.", @@ -3933,6 +3932,16 @@ payloads_description_ports: "Les ports utilisés sur la PS5 et ce qui y écoute "profile.username.addOnConsole": "Pour ajouter un utilisateur, faites-le sur la PS5 : Changer d'utilisateur, puis Ajouter un utilisateur.", "collection.folder_missing": "Introuvable, ignoré", "collection.missing_roots": "La dernière analyse a ignoré ce qu'elle n'a pas trouvé : {paths}. Si un dossier a été supprimé, retirez-le dans Dossiers ; s'il est sur un disque ou un serveur, reconnectez-le et relancez l'analyse.", +queue_retry_failed_v2: "Relancer les envois échoués", +queue_stalled: "Rien n'a bougé depuis {time}. Relancer le transfert reprend à partir de ce que la PS5 a déjà.", +queue_restart_transfer: "Relancer le transfert", +queue_interrupted: "Arrêté en cours de route. Reprendre continue à partir de ce que la PS5 a déjà.", +queue_resume: "Reprendre", +pm_update: "Mettre à jour", +pm_update_available: "{tag} disponible", +pm_update_tooltip: "Télécharge la version plus récente et la stocke dans Payload Manager à la place de celle-ci, en gardant sa place dans la liste de démarrage.", +pm_updated: "{name} mis à jour en {tag}", +pm_update_failed: "Impossible de mettre à jour {name}", }; export default fr; diff --git a/client/src/i18n/locales/hi.ts b/client/src/i18n/locales/hi.ts index d3975a30..cf1ff7aa 100644 --- a/client/src/i18n/locales/hi.ts +++ b/client/src/i18n/locales/hi.ts @@ -409,7 +409,6 @@ queue_title: "कतार", "queue_links_cleared_title": "कतार में लगे लिंक हटा दिए गए", "queue_links_cleared_body": "रीस्टार्ट के बाद {n} इंस्टॉल लिंक नहीं रखे गए — लिंक कभी डिस्क पर सहेजे नहीं जाते। Install Package से इन्हें फिर जोड़ें।", "console_tab_queue_count": "कतार में {n}", -queue_retry_failed: "फिर से कोशिश नहीं की जा सकी", queue_stop: "रोकें", queue_stop_tooltip: "भेजना रोकता है। जो PS5 तक पहुँच चुका है वह 7 दिन रखा जाता है, और शुरू वहीं से जारी रखता है।", upload_status_stop_tooltip_v2: "भेजना रोकता है। जो PS5 तक पहुँच चुका है वह 7 दिन रखा जाता है; फिर से शुरू वहीं से जारी रखता है।", @@ -3931,6 +3930,16 @@ payloads_description_ports: "PS5 पर उपयोग में पोर् "profile.username.addOnConsole": "नया यूज़र जोड़ने के लिए PS5 पर ही करें: Switch User, फिर Add User।", "collection.folder_missing": "नहीं मिला, छोड़ा गया", "collection.missing_roots": "पिछले स्कैन ने जो नहीं मिला उसे छोड़ दिया: {paths}। अगर फ़ोल्डर हटाया गया है तो Folders में उसे हटाएँ; अगर वह ड्राइव या सर्वर पर है तो फिर से जोड़ें और दोबारा स्कैन करें।", +queue_retry_failed_v2: "विफल अपलोड फिर से आज़माएँ", +queue_stalled: "{time} से कुछ आगे नहीं बढ़ा। ट्रांसफ़र फिर से शुरू करने पर PS5 में जो है वहीं से जारी रहेगा।", +queue_restart_transfer: "ट्रांसफ़र फिर से शुरू करें", +queue_interrupted: "बीच में रुक गया। जारी रखने पर PS5 में जो है वहीं से आगे बढ़ेगा।", +queue_resume: "जारी रखें", +pm_update: "अपडेट करें", +pm_update_available: "{tag} उपलब्ध", +pm_update_tooltip: "नया रिलीज़ डाउनलोड करके इसकी जगह Payload Manager में रखता है, बूट सूची में इसकी जगह बनाए रखते हुए।", +pm_updated: "{name} को {tag} पर अपडेट किया गया", +pm_update_failed: "{name} अपडेट नहीं हो सका", }; export default hi; diff --git a/client/src/i18n/locales/hu.ts b/client/src/i18n/locales/hu.ts index cbe9a5c3..75187007 100644 --- a/client/src/i18n/locales/hu.ts +++ b/client/src/i18n/locales/hu.ts @@ -648,7 +648,6 @@ queue_title: "Sor", "queue_links_cleared_title": "A sorban lévő hivatkozások törlődtek", "queue_links_cleared_body": "{n} telepítési hivatkozás nem maradt meg az újraindítás után – a hivatkozásokat sosem mentjük lemezre. Add hozzá őket újra a Csomag telepítése oldalon.", "console_tab_queue_count": "{n} a sorban", -queue_retry_failed: "Sikertelenek újrapróbálása", queue_stop: "Leállítás", queue_stop_tooltip: "Leállítja a küldést. Ami már a PS5-re ért, 7 napig megmarad, és az Indítás onnan folytatja.", upload_status_stop_tooltip_v2: "Leállítja a küldést. Ami már a PS5-re ért, 7 napig megmarad; a Folytatás onnan folytatja.", @@ -4230,6 +4229,16 @@ payloads_description_ports: "A PS5-ön használt portok és hogy mi szokott rajt "profile.username.addOnConsole": "Felhasználót a PS5-ön adhatsz hozzá: Felhasználóváltás, majd Felhasználó hozzáadása.", "collection.folder_missing": "Nem található, kihagyva", "collection.missing_roots": "A legutóbbi keresés kihagyta, amit nem talált: {paths}. Ha egy mappát töröltél, távolítsd el a Mappák között; ha meghajtón vagy szerveren van, csatlakoztasd újra, és keress újra.", +queue_retry_failed_v2: "Sikertelen feltöltések újra", +queue_stalled: "{time} óta semmi sem mozdult. Az átvitel újraindítása onnan folytatja, amit a PS5 már megkapott.", +queue_restart_transfer: "Átvitel újraindítása", +queue_interrupted: "Félúton megállt. A folytatás onnan halad tovább, amit a PS5 már megkapott.", +queue_resume: "Folytatás", +pm_update: "Frissítés", +pm_update_available: "{tag} elérhető", +pm_update_tooltip: "Letölti az újabb kiadást, és ennek helyére teszi a Payload Managerben, megtartva a helyét az indítási listában.", +pm_updated: "{name} frissítve erre: {tag}", +pm_update_failed: "{name} frissítése nem sikerült", }; export default hu; diff --git a/client/src/i18n/locales/id.ts b/client/src/i18n/locales/id.ts index 2ba0cfbf..09c020fc 100644 --- a/client/src/i18n/locales/id.ts +++ b/client/src/i18n/locales/id.ts @@ -409,7 +409,6 @@ queue_title: "Antrean", "queue_links_cleared_title": "Tautan dalam antrean dihapus", "queue_links_cleared_body": "{n} tautan instal tidak disimpan setelah mulai ulang — tautan tidak pernah disimpan ke disk. Tambahkan lagi dari Instal Paket.", "console_tab_queue_count": "{n} dalam antrean", -queue_retry_failed: "Coba lagi gagal", queue_stop: "Berhenti", queue_stop_tooltip: "Berhenti mengirim. Yang sudah sampai di PS5 disimpan 7 hari, dan Mulai melanjutkan dari sana.", upload_status_stop_tooltip_v2: "Berhenti mengirim. Yang sudah sampai di PS5 disimpan 7 hari; Lanjutkan melanjutkan dari sana.", @@ -3924,6 +3923,16 @@ payloads_description_ports: "Port yang dipakai di PS5 dan apa yang biasanya mend "profile.username.addOnConsole": "Untuk menambah pengguna, lakukan di PS5: Ganti Pengguna, lalu Tambah Pengguna.", "collection.folder_missing": "Tidak ditemukan, dilewati", "collection.missing_roots": "Pemindaian terakhir melewati yang tidak ditemukannya: {paths}. Jika folder sudah dihapus, hapus dari Folder; jika ada di drive atau server, sambungkan lagi lalu pindai ulang.", +queue_retry_failed_v2: "Coba lagi unggahan yang gagal", +queue_stalled: "Tidak ada yang bergerak selama {time}. Memulai ulang transfer melanjutkan dari yang sudah ada di PS5.", +queue_restart_transfer: "Mulai ulang transfer", +queue_interrupted: "Berhenti di tengah jalan. Melanjutkan akan meneruskan dari yang sudah ada di PS5.", +queue_resume: "Lanjutkan", +pm_update: "Perbarui", +pm_update_available: "{tag} tersedia", +pm_update_tooltip: "Mengunduh rilis yang lebih baru dan menyimpannya di Payload Manager menggantikan yang ini, dengan tetap di urutan boot yang sama.", +pm_updated: "{name} diperbarui ke {tag}", +pm_update_failed: "Tidak bisa memperbarui {name}", }; export default id; diff --git a/client/src/i18n/locales/it.ts b/client/src/i18n/locales/it.ts index d584883f..82c98972 100644 --- a/client/src/i18n/locales/it.ts +++ b/client/src/i18n/locales/it.ts @@ -409,7 +409,6 @@ queue_title: "Coda", "queue_links_cleared_title": "I link in coda sono stati rimossi", "queue_links_cleared_body": "{n} link di installazione non sono stati conservati dopo il riavvio: i link non vengono mai salvati su disco. Aggiungili di nuovo da Installa pacchetto.", "console_tab_queue_count": "{n} in coda", -queue_retry_failed: "Nuovo tentativo non riuscito", queue_stop: "Arresta", queue_stop_tooltip: "Interrompe l'invio. Ciò che è già arrivato sulla PS5 resta per 7 giorni e Avvia riprende da lì.", upload_status_stop_tooltip_v2: "Interrompe l'invio. Ciò che è già arrivato sulla PS5 resta per 7 giorni; Riprendi continua da lì.", @@ -3865,6 +3864,16 @@ payloads_description_ports: "Le porte in uso sulla PS5 e cosa di solito vi ascol "profile.username.addOnConsole": "Per aggiungere un utente, fallo sulla PS5: Cambia utente, poi Aggiungi utente.", "collection.folder_missing": "Non trovata, saltata", "collection.missing_roots": "L'ultima scansione ha saltato ciò che non ha trovato: {paths}. Se una cartella è stata eliminata, rimuovila in Cartelle; se è su un'unità o un server, ricollegala e scansiona di nuovo.", +queue_retry_failed_v2: "Riprova i caricamenti non riusciti", +queue_stalled: "Non si muove nulla da {time}. Riavviare il trasferimento riparte da ciò che la PS5 ha già.", +queue_restart_transfer: "Riavvia trasferimento", +queue_interrupted: "Interrotto a metà. Riprendere continua da ciò che la PS5 ha già.", +queue_resume: "Riprendi", +pm_update: "Aggiorna", +pm_update_available: "{tag} disponibile", +pm_update_tooltip: "Scarica la versione più recente e la salva in Payload Manager al posto di questa, mantenendo il suo posto nella lista di avvio.", +pm_updated: "{name} aggiornato a {tag}", +pm_update_failed: "Impossibile aggiornare {name}", }; export default it; diff --git a/client/src/i18n/locales/ja.ts b/client/src/i18n/locales/ja.ts index 905d3bdf..ac24b1ab 100644 --- a/client/src/i18n/locales/ja.ts +++ b/client/src/i18n/locales/ja.ts @@ -410,7 +410,6 @@ queue_title: "キュー", "queue_links_cleared_title": "キュー内のリンクは消去されました", "queue_links_cleared_body": "再起動後、{n} 件のインストールリンクは保持されませんでした。リンクはディスクに保存されません。パッケージのインストールから追加し直してください。", "console_tab_queue_count": "キューに {n} 件", -queue_retry_failed: "再試行に失敗しました", queue_stop: "停止", queue_stop_tooltip: "送信を止めます。PS5 に届いた分は 7 日間保持され、開始でそこから続きます。", upload_status_stop_tooltip_v2: "送信を止めます。PS5 に届いた分は 7 日間保持され、再開でそこから続きます。", @@ -3936,6 +3935,16 @@ payloads_description_ports: "PS5 で使用中のポートと、それぞれで "profile.username.addOnConsole": "ユーザーの追加は PS5 本体で行ってください:「ユーザーを切り替える」→「ユーザーを追加」。", "collection.folder_missing": "見つからないためスキップ", "collection.missing_roots": "前回のスキャンで見つからなかったものはスキップされました:{paths}。フォルダーを削除した場合は「フォルダー」から外してください。ドライブやサーバー上にある場合は接続し直して、もう一度スキャンしてください。", +queue_retry_failed_v2: "失敗したアップロードを再試行", +queue_stalled: "{time} の間、何も進んでいません。転送をやり直すと、PS5 に届いている分の続きから再開します。", +queue_restart_transfer: "転送をやり直す", +queue_interrupted: "途中で止まりました。再開すると PS5 に届いている分の続きから送ります。", +queue_resume: "再開", +pm_update: "更新", +pm_update_available: "{tag} があります", +pm_update_tooltip: "新しいリリースをダウンロードし、これと置き換えて Payload Manager に保存します。起動リストでの順番はそのままです。", +pm_updated: "{name} を {tag} に更新しました", +pm_update_failed: "{name} を更新できませんでした", }; export default ja; diff --git a/client/src/i18n/locales/ko.ts b/client/src/i18n/locales/ko.ts index e721e937..b4eecc35 100644 --- a/client/src/i18n/locales/ko.ts +++ b/client/src/i18n/locales/ko.ts @@ -410,7 +410,6 @@ queue_title: "대기열", "queue_links_cleared_title": "대기열의 링크가 지워졌습니다", "queue_links_cleared_body": "재시작 후 설치 링크 {n}개가 유지되지 않았습니다. 링크는 디스크에 저장되지 않습니다. 패키지 설치에서 다시 추가하세요.", "console_tab_queue_count": "대기열에 {n}개", -queue_retry_failed: "재시도 실패", queue_stop: "정지", queue_stop_tooltip: "전송을 멈춥니다. 이미 PS5에 도착한 부분은 7일간 보관되며, 시작을 누르면 거기서 이어집니다.", upload_status_stop_tooltip_v2: "전송을 멈춥니다. 이미 PS5에 도착한 부분은 7일간 보관되며, 다시 시작하면 거기서 이어집니다.", @@ -3934,6 +3933,16 @@ payloads_description_ports: "PS5에서 사용 중인 포트와 각 포트에서 "profile.username.addOnConsole": "사용자 추가는 PS5에서 하세요: 사용자 전환 → 사용자 추가.", "collection.folder_missing": "찾을 수 없어 건너뜀", "collection.missing_roots": "마지막 검사에서 찾지 못한 항목을 건너뛰었습니다: {paths}. 폴더를 삭제했다면 폴더에서 제거하고, 드라이브나 서버에 있다면 다시 연결한 뒤 다시 검사하세요.", +queue_retry_failed_v2: "실패한 업로드 다시 시도", +queue_stalled: "{time} 동안 진행이 없습니다. 전송을 다시 시작하면 PS5에 이미 있는 부분부터 이어서 보냅니다.", +queue_restart_transfer: "전송 다시 시작", +queue_interrupted: "중간에 멈췄습니다. 이어서 하면 PS5에 이미 있는 부분부터 계속합니다.", +queue_resume: "이어서 하기", +pm_update: "업데이트", +pm_update_available: "{tag} 사용 가능", +pm_update_tooltip: "새 릴리스를 내려받아 이것 대신 Payload Manager에 저장하며, 부팅 목록의 순서는 그대로 둡니다.", +pm_updated: "{name}을(를) {tag}(으)로 업데이트했습니다", +pm_update_failed: "{name}을(를) 업데이트하지 못했습니다", }; export default ko; diff --git a/client/src/i18n/locales/pl.ts b/client/src/i18n/locales/pl.ts index f78acbae..0fe47e56 100644 --- a/client/src/i18n/locales/pl.ts +++ b/client/src/i18n/locales/pl.ts @@ -630,7 +630,6 @@ queue_title: "Kolejka", "queue_links_cleared_title": "Linki w kolejce zostały usunięte", "queue_links_cleared_body": "{n} link(i) instalacyjnych nie zachowano po ponownym uruchomieniu — linki nigdy nie są zapisywane na dysku. Dodaj je ponownie w Instaluj pakiet.", "console_tab_queue_count": "{n} w kolejce", -queue_retry_failed: "Ponów nieudane", queue_stop: "Zatrzymaj", queue_stop_tooltip: "Zatrzymuje wysyłanie. To, co już dotarło na PS5, jest zachowane przez 7 dni, a Start kontynuuje od tego miejsca.", upload_status_stop_tooltip_v2: "Zatrzymuje wysyłanie. To, co już dotarło na PS5, jest zachowane przez 7 dni; Wznów kontynuuje od tego miejsca.", @@ -4170,6 +4169,16 @@ payloads_description_ports: "Porty używane na PS5 i to, co zwykle na nich nasł "profile.username.addOnConsole": "Użytkownika dodasz na PS5: Przełącz użytkownika, a potem Dodaj użytkownika.", "collection.folder_missing": "Nie znaleziono, pominięto", "collection.missing_roots": "Ostatnie skanowanie pominęło to, czego nie znalazło: {paths}. Jeśli folder został usunięty, usuń go w Foldery; jeśli jest na dysku lub serwerze, podłącz go ponownie i skanuj jeszcze raz.", +queue_retry_failed_v2: "Ponów nieudane wysyłki", +queue_stalled: "Od {time} nic się nie rusza. Ponowne uruchomienie transferu wznowi od tego, co PS5 już ma.", +queue_restart_transfer: "Uruchom transfer ponownie", +queue_interrupted: "Zatrzymano w połowie. Wznowienie kontynuuje od tego, co PS5 już ma.", +queue_resume: "Wznów", +pm_update: "Aktualizuj", +pm_update_available: "Dostępna {tag}", +pm_update_tooltip: "Pobiera nowsze wydanie i zapisuje je w Payload Manager zamiast tego, zachowując jego miejsce na liście startowej.", +pm_updated: "Zaktualizowano {name} do {tag}", +pm_update_failed: "Nie udało się zaktualizować {name}", }; export default pl; diff --git a/client/src/i18n/locales/pt-BR.ts b/client/src/i18n/locales/pt-BR.ts index b083bb56..508fb5cd 100644 --- a/client/src/i18n/locales/pt-BR.ts +++ b/client/src/i18n/locales/pt-BR.ts @@ -409,7 +409,6 @@ queue_title: "Fila", "queue_links_cleared_title": "Os links da fila foram removidos", "queue_links_cleared_body": "{n} link(s) de instalação não foram mantidos após reiniciar — links nunca são salvos em disco. Adicione-os de novo em Instalar pacote.", "console_tab_queue_count": "{n} na fila", -queue_retry_failed: "Tentar novamente", queue_stop: "Parar", queue_stop_tooltip: "Para de enviar. O que já chegou ao PS5 fica guardado por 7 dias, e Iniciar continua de lá.", upload_status_stop_tooltip_v2: "Para de enviar. O que já chegou ao PS5 fica guardado por 7 dias; Retomar continua de lá.", @@ -3932,6 +3931,16 @@ payloads_description_ports: "As portas em uso no PS5 e o que costuma escutar em "profile.username.addOnConsole": "Para adicionar um usuário, faça no PS5: Trocar de usuário e depois Adicionar usuário.", "collection.folder_missing": "Não encontrada, ignorada", "collection.missing_roots": "A última verificação ignorou o que não encontrou: {paths}. Se uma pasta foi apagada, remova-a em Pastas; se está em um drive ou servidor, reconecte e verifique de novo.", +queue_retry_failed_v2: "Tentar de novo os envios que falharam", +queue_stalled: "Nada se moveu em {time}. Reiniciar a transferência continua do que o PS5 já tem.", +queue_restart_transfer: "Reiniciar transferência", +queue_interrupted: "Parou no meio. Retomar continua do que o PS5 já tem.", +queue_resume: "Retomar", +pm_update: "Atualizar", +pm_update_available: "{tag} disponível", +pm_update_tooltip: "Baixa a versão mais nova e a guarda no Payload Manager no lugar desta, mantendo a posição na lista de inicialização.", +pm_updated: "{name} atualizado para {tag}", +pm_update_failed: "Não foi possível atualizar {name}", }; export default pt_BR; diff --git a/client/src/i18n/locales/ru.ts b/client/src/i18n/locales/ru.ts index 698869db..1ae229e3 100644 --- a/client/src/i18n/locales/ru.ts +++ b/client/src/i18n/locales/ru.ts @@ -409,7 +409,6 @@ queue_title: "Очередь", "queue_links_cleared_title": "Ссылки в очереди удалены", "queue_links_cleared_body": "После перезапуска {n} ссылок на установку не сохранено — ссылки никогда не записываются на диск. Добавь их снова в «Установить пакет».", "console_tab_queue_count": "В очереди: {n}", -queue_retry_failed: "Повторить попытку не удалось", queue_stop: "Остановить", queue_stop_tooltip: "Останавливает отправку. Что уже дошло до PS5, хранится 7 дней, и «Старт» продолжит с этого места.", upload_status_stop_tooltip_v2: "Останавливает отправку. Что уже дошло до PS5, хранится 7 дней; «Продолжить» возобновит с этого места.", @@ -3865,6 +3864,16 @@ payloads_description_ports: "Занятые порты на PS5 и что обы "profile.username.addOnConsole": "Пользователя добавляют на самой PS5: «Сменить пользователя», затем «Добавить пользователя».", "collection.folder_missing": "Не найдена, пропущена", "collection.missing_roots": "Последнее сканирование пропустило то, что не нашло: {paths}. Если папка удалена, уберите её в разделе «Папки»; если она на диске или сервере, подключите его снова и повторите сканирование.", +queue_retry_failed_v2: "Повторить неудавшиеся загрузки", +queue_stalled: "Уже {time} ничего не передаётся. Перезапуск передачи продолжит с того, что уже есть на PS5.", +queue_restart_transfer: "Перезапустить передачу", +queue_interrupted: "Остановлено на полпути. Возобновление продолжит с того, что уже есть на PS5.", +queue_resume: "Возобновить", +pm_update: "Обновить", +pm_update_available: "Доступна {tag}", +pm_update_tooltip: "Скачивает более новый выпуск и сохраняет его в Payload Manager вместо этого, сохраняя место в списке загрузки.", +pm_updated: "{name} обновлён до {tag}", +pm_update_failed: "Не удалось обновить {name}", }; export default ru; diff --git a/client/src/i18n/locales/th.ts b/client/src/i18n/locales/th.ts index d163bba1..fb689ebd 100644 --- a/client/src/i18n/locales/th.ts +++ b/client/src/i18n/locales/th.ts @@ -409,7 +409,6 @@ queue_title: "คิว", "queue_links_cleared_title": "ลิงก์ในคิวถูกล้างแล้ว", "queue_links_cleared_body": "ไม่ได้เก็บลิงก์ติดตั้ง {n} รายการหลังรีสตาร์ต — ลิงก์จะไม่ถูกบันทึกลงดิสก์ เพิ่มอีกครั้งได้จาก ติดตั้งแพ็กเกจ", "console_tab_queue_count": "{n} รายการในคิว", -queue_retry_failed: "การลองใหม่ล้มเหลว", queue_stop: "หยุด", queue_stop_tooltip: "หยุดส่ง สิ่งที่ถึง PS5 แล้วจะเก็บไว้ 7 วัน และ เริ่ม จะทำต่อจากตรงนั้น", upload_status_stop_tooltip_v2: "หยุดส่ง สิ่งที่ถึง PS5 แล้วจะเก็บไว้ 7 วัน; ดำเนินการต่อ จะทำต่อจากตรงนั้น", @@ -3924,6 +3923,16 @@ payloads_description_ports: "พอร์ตที่ใช้งานอยู "profile.username.addOnConsole": "หากต้องการเพิ่มผู้ใช้ ให้ทำบน PS5: สลับผู้ใช้ แล้วเลือกเพิ่มผู้ใช้", "collection.folder_missing": "ไม่พบ ข้ามไปแล้ว", "collection.missing_roots": "การสแกนครั้งล่าสุดข้ามสิ่งที่หาไม่พบ: {paths} หากลบโฟลเดอร์ไปแล้ว ให้นำออกในโฟลเดอร์ หากอยู่บนไดรฟ์หรือเซิร์ฟเวอร์ ให้เชื่อมต่ออีกครั้งแล้วสแกนใหม่", +queue_retry_failed_v2: "ลองอัปโหลดที่ล้มเหลวอีกครั้ง", +queue_stalled: "ไม่มีความคืบหน้ามา {time} แล้ว การเริ่มส่งใหม่จะต่อจากส่วนที่ PS5 มีอยู่แล้ว", +queue_restart_transfer: "เริ่มส่งใหม่", +queue_interrupted: "หยุดกลางคัน การส่งต่อจะเริ่มจากส่วนที่ PS5 มีอยู่แล้ว", +queue_resume: "ส่งต่อ", +pm_update: "อัปเดต", +pm_update_available: "มี {tag} แล้ว", +pm_update_tooltip: "ดาวน์โหลดรุ่นที่ใหม่กว่าและเก็บใน Payload Manager แทนตัวนี้ โดยคงลำดับในรายการบูตไว้", +pm_updated: "อัปเดต {name} เป็น {tag} แล้ว", +pm_update_failed: "อัปเดต {name} ไม่ได้", }; export default th; diff --git a/client/src/i18n/locales/tr.ts b/client/src/i18n/locales/tr.ts index b3a576a8..e7aac97a 100644 --- a/client/src/i18n/locales/tr.ts +++ b/client/src/i18n/locales/tr.ts @@ -409,7 +409,6 @@ queue_title: "Kuyruk", "queue_links_cleared_title": "Sıradaki bağlantılar temizlendi", "queue_links_cleared_body": "Yeniden başlatmadan sonra {n} kurulum bağlantısı korunmadı — bağlantılar asla diske kaydedilmez. Paket Kur'dan yeniden ekle.", "console_tab_queue_count": "Sırada {n}", -queue_retry_failed: "Yeniden deneme başarısız oldu", queue_stop: "Durdurma", queue_stop_tooltip: "Göndermeyi durdurur. PS5'e ulaşanlar 7 gün saklanır ve Başlat oradan devam eder.", upload_status_stop_tooltip_v2: "Göndermeyi durdurur. PS5'e ulaşanlar 7 gün saklanır; Sürdür oradan devam eder.", @@ -3922,6 +3921,16 @@ payloads_description_ports: "PS5'te kullanılan portlar ve her birinde genellikl "profile.username.addOnConsole": "Kullanıcı eklemek için bunu PS5'te yap: Kullanıcı Değiştir, ardından Kullanıcı Ekle.", "collection.folder_missing": "Bulunamadı, atlandı", "collection.missing_roots": "Son tarama bulamadıklarını atladı: {paths}. Bir klasör silindiyse Klasörler'den kaldır; bir sürücüde veya sunucudaysa yeniden bağla ve tekrar tara.", +queue_retry_failed_v2: "Başarısız yüklemeleri yeniden dene", +queue_stalled: "{time} boyunca hiçbir şey ilerlemedi. Aktarımı yeniden başlatmak PS5'te zaten olandan devam eder.", +queue_restart_transfer: "Aktarımı yeniden başlat", +queue_interrupted: "Yarıda durdu. Devam etmek PS5'te zaten olandan sürdürür.", +queue_resume: "Devam et", +pm_update: "Güncelle", +pm_update_available: "{tag} mevcut", +pm_update_tooltip: "Daha yeni sürümü indirir ve bunun yerine Payload Manager'a kaydeder; açılış listesindeki yeri korunur.", +pm_updated: "{name}, {tag} sürümüne güncellendi", +pm_update_failed: "{name} güncellenemedi", }; export default tr; diff --git a/client/src/i18n/locales/vi.ts b/client/src/i18n/locales/vi.ts index 506ce9a0..bbdff04a 100644 --- a/client/src/i18n/locales/vi.ts +++ b/client/src/i18n/locales/vi.ts @@ -410,7 +410,6 @@ queue_title: "Hàng đợi", "queue_links_cleared_title": "Các liên kết trong hàng đợi đã bị xóa", "queue_links_cleared_body": "{n} liên kết cài đặt không được giữ sau khi khởi động lại — liên kết không bao giờ được lưu vào ổ đĩa. Hãy thêm lại từ Cài đặt gói.", "console_tab_queue_count": "{n} trong hàng đợi", -queue_retry_failed: "Thử lại không thành công.", queue_stop: "Dừng lại", queue_stop_tooltip: "Dừng gửi. Những gì đã đến PS5 được giữ 7 ngày, và Bắt đầu sẽ tiếp tục từ đó.", upload_status_stop_tooltip_v2: "Dừng gửi. Những gì đã đến PS5 được giữ 7 ngày; Tiếp tục sẽ tiếp từ đó.", @@ -3917,6 +3916,16 @@ payloads_description_ports: "Các cổng đang dùng trên PS5 và thứ thườ "profile.username.addOnConsole": "Để thêm người dùng, hãy làm trên PS5: Chuyển người dùng, rồi Thêm người dùng.", "collection.folder_missing": "Không tìm thấy, đã bỏ qua", "collection.missing_roots": "Lần quét gần nhất đã bỏ qua những gì không tìm thấy: {paths}. Nếu thư mục đã bị xóa, hãy gỡ nó trong Thư mục; nếu nằm trên ổ đĩa hoặc máy chủ, hãy kết nối lại rồi quét lại.", +queue_retry_failed_v2: "Thử lại các lần tải lên thất bại", +queue_stalled: "Không có gì di chuyển trong {time}. Khởi động lại việc truyền sẽ tiếp tục từ phần PS5 đã có.", +queue_restart_transfer: "Khởi động lại việc truyền", +queue_interrupted: "Đã dừng giữa chừng. Tiếp tục sẽ nối tiếp từ phần PS5 đã có.", +queue_resume: "Tiếp tục", +pm_update: "Cập nhật", +pm_update_available: "Có {tag}", +pm_update_tooltip: "Tải bản phát hành mới hơn và lưu vào Payload Manager thay cho bản này, giữ nguyên vị trí trong danh sách khởi động.", +pm_updated: "Đã cập nhật {name} lên {tag}", +pm_update_failed: "Không cập nhật được {name}", }; export default vi; diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index aba9cfe8..c10f8d5d 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -416,7 +416,6 @@ queue_title: "队列", "queue_links_cleared_title": "队列中的链接已清除", "queue_links_cleared_body": "重启后未保留 {n} 个安装链接——链接从不保存到磁盘。请在“安装包”中重新添加。", "console_tab_queue_count": "队列中 {n} 项", -queue_retry_failed: "重试失败", queue_stop: "停止", queue_stop_tooltip: "停止发送。已到达 PS5 的部分保留 7 天,“开始”会从那里继续。", upload_status_stop_tooltip_v2: "停止发送。已到达 PS5 的部分保留 7 天;“继续”会从那里继续。", @@ -3871,6 +3870,16 @@ payloads_description_ports: "PS5 上正在使用的端口以及通常在每个 "profile.username.addOnConsole": "要添加用户,请在 PS5 上操作:切换用户,然后选择添加用户。", "collection.folder_missing": "未找到,已跳过", "collection.missing_roots": "上次扫描跳过了找不到的内容:{paths}。如果文件夹已删除,请在“文件夹”中移除;如果它在硬盘或服务器上,请重新连接后再扫描。", +queue_retry_failed_v2: "重试失败的上传", +queue_stalled: "已经 {time} 没有任何进展。重新开始传输会从 PS5 已有的部分继续。", +queue_restart_transfer: "重新开始传输", +queue_interrupted: "中途停止了。继续会从 PS5 已有的部分接着传。", +queue_resume: "继续", +pm_update: "更新", +pm_update_available: "有 {tag}", +pm_update_tooltip: "下载较新的版本并替换此项存入 Payload Manager,开机列表中的位置不变。", +pm_updated: "已将 {name} 更新到 {tag}", +pm_update_failed: "无法更新 {name}", }; export default zh_CN; diff --git a/client/src/i18n/locales/zh-TW.ts b/client/src/i18n/locales/zh-TW.ts index d515ce46..9e4544c1 100644 --- a/client/src/i18n/locales/zh-TW.ts +++ b/client/src/i18n/locales/zh-TW.ts @@ -410,7 +410,6 @@ queue_title: "佇列", "queue_links_cleared_title": "佇列中的連結已清除", "queue_links_cleared_body": "重新啟動後未保留 {n} 個安裝連結——連結從不儲存到磁碟。請在「安裝檔」中重新加入。", "console_tab_queue_count": "佇列中 {n} 項", -queue_retry_failed: "重試失敗", queue_stop: "停止", queue_stop_tooltip: "停止傳送。已到達 PS5 的部分保留 7 天,「開始」會從那裡繼續。", upload_status_stop_tooltip_v2: "停止傳送。已到達 PS5 的部分保留 7 天;「繼續」會從那裡繼續。", @@ -3865,6 +3864,16 @@ payloads_description_ports: "PS5 上正在使用的連接埠,以及通常在 "profile.username.addOnConsole": "要新增使用者,請在 PS5 上操作:切換使用者,然後選擇新增使用者。", "collection.folder_missing": "找不到,已略過", "collection.missing_roots": "上次掃描略過了找不到的內容:{paths}。如果資料夾已刪除,請在「資料夾」中移除;如果它在硬碟或伺服器上,請重新連線後再掃描。", +queue_retry_failed_v2: "重試失敗的上傳", +queue_stalled: "已經 {time} 沒有任何進展。重新開始傳輸會從 PS5 已有的部分繼續。", +queue_restart_transfer: "重新開始傳輸", +queue_interrupted: "中途停止了。繼續會從 PS5 已有的部分接著傳。", +queue_resume: "繼續", +pm_update: "更新", +pm_update_available: "有 {tag}", +pm_update_tooltip: "下載較新的版本並取代此項存入 Payload Manager,開機清單中的位置不變。", +pm_updated: "已將 {name} 更新到 {tag}", +pm_update_failed: "無法更新 {name}", }; export default zh_TW; diff --git a/client/src/lib/pmUpdates.test.ts b/client/src/lib/pmUpdates.test.ts new file mode 100644 index 00000000..196d8a1a --- /dev/null +++ b/client/src/lib/pmUpdates.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import type { PayloadReleaseInfo } from "../api/ps5"; +import { isRepoManaged, pmUpdateFor, swapAutoloadKey } from "./pmUpdates"; + +const rel = (tag: string, url = "https://x/a.elf"): PayloadReleaseInfo => ({ + payload_id: "nanodns", + tag, + name: tag, + body: "", + published_at: "", + html_url: "", + picked_asset_url: url, + picked_asset_name: "a.elf", + picked_asset_size: 1, + cached_age_secs: 0, +}); + +describe("pmUpdateFor", () => { + it("offers the newer release only for something this app stored", () => { + expect(pmUpdateFor({ id: "nanodns", tag: "v0.4" }, rel("v0.5"))?.tag).toBe("v0.5"); + expect(pmUpdateFor({ id: "nanodns", tag: "v0.5" }, rel("v0.5"))).toBeNull(); + expect(pmUpdateFor(undefined, rel("v0.5"))).toBeNull(); + expect(pmUpdateFor({ id: "nanodns", tag: "v0.4" }, rel("v0.5", ""))).toBeNull(); + }); +}); + +describe("swapAutoloadKey", () => { + it("keeps the boot position and the pauses", () => { + expect(swapAutoloadKey(["a.elf", "!3000", "old.elf", "!3000", "c.elf"], "old.elf", "new.elf")).toEqual([ + "a.elf", + "!3000", + "new.elf", + "!3000", + "c.elf", + ]); + }); + it("does not list the new one twice", () => { + expect(swapAutoloadKey(["new.elf", "old.elf"], "old.elf", "new.elf")).toEqual(["new.elf"]); + }); + it("leaves a list without the old one alone", () => { + const l = ["a.elf"]; + expect(swapAutoloadKey(l, "old.elf", "new.elf")).toBe(l); + }); +}); + +describe("isRepoManaged", () => { + it("knows Payload Manager's own repository installs", () => { + expect(isRepoManaged({ slot: "", path: "/data/pldmgr/payloads/repo/nanodns@e8cce5d1/nanoDNS_0.4.elf" })).toBe(true); + expect(isRepoManaged({ slot: "repo/nanodns@x", path: "/data/pldmgr/payloads/repo/x" })).toBe(true); + expect(isRepoManaged({ slot: "local/nanodns", path: "/data/pldmgr/payloads/local/nanodns/n.elf" })).toBe(false); + }); +}); diff --git a/client/src/lib/pmUpdates.ts b/client/src/lib/pmUpdates.ts new file mode 100644 index 00000000..5c51e367 --- /dev/null +++ b/client/src/lib/pmUpdates.ts @@ -0,0 +1,79 @@ +// Keeping what ps5upload stored in Payload Manager up to date (Reddit/Discord ask: "sync with +// the payloads on the device"). Payload Manager 0.5 reports no versions and many assets keep one +// file name across releases, so the app remembers, per console, which catalogue payload and +// release it stored under each file name. The Payload Manager tab compares that with the +// catalogue's current release and offers the update. Payloads Payload Manager installed from +// its own repository are its to update and are never touched. + +import type { PayloadReleaseInfo } from "../api/ps5"; + +export interface PmStoreRecord { + /** Catalogue payload id. */ + id: string; + /** The release tag that was stored. */ + tag: string; +} + +type Records = Record>; + +const KEY = "ps5upload.pmStored.v1"; + +function load(): Records { + try { + const v = JSON.parse(globalThis.localStorage?.getItem(KEY) ?? "{}"); + return v && typeof v === "object" ? (v as Records) : {}; + } catch { + return {}; + } +} + +function save(r: Records): void { + try { + globalThis.localStorage?.setItem(KEY, JSON.stringify(r)); + } catch { + /* private window or blocked storage: updates are simply not offered */ + } +} + +/** What this app stored in `host`'s Payload Manager, by file name. */ +export function pmRecords(host: string): Record { + return load()[host] ?? {}; +} + +/** Remember that `filename` on `host` is catalogue payload `id` at release `tag`. */ +export function recordPmStore(host: string, filename: string, id: string, tag: string): void { + const all = load(); + all[host] = { ...(all[host] ?? {}), [filename]: { id, tag } }; + save(all); +} + +export function forgetPmStore(host: string, filename: string): void { + const all = load(); + if (!all[host]?.[filename]) return; + const { [filename]: _gone, ...rest } = all[host]; + void _gone; + all[host] = rest; + save(all); +} + +/** Stored from Payload Manager's own repository: it updates those itself. */ +export function isRepoManaged(p: { path: string; slot: string }): boolean { + return p.slot.startsWith("repo/") || p.path.includes("/payloads/repo/"); +} + +/** The release to update to, or null when the stored one is current (or unknown). */ +export function pmUpdateFor( + rec: PmStoreRecord | undefined, + latest: PayloadReleaseInfo | null | undefined, +): PayloadReleaseInfo | null { + if (!rec || !latest || !latest.picked_asset_url) return null; + return latest.tag !== rec.tag ? latest : null; +} + +/** `list` with `oldKey` replaced by `newKey` in the same place (pauses kept). When `newKey` is + * already listed elsewhere, `oldKey` just goes. */ +export function swapAutoloadKey(list: string[], oldKey: string, newKey: string): string[] { + if (oldKey === newKey || !list.includes(oldKey)) return list; + if (list.includes(newKey)) return list.filter((e) => e !== oldKey); + return list.map((e) => (e === oldKey ? newKey : e)); +} diff --git a/client/src/lib/queueOps.test.ts b/client/src/lib/queueOps.test.ts index 83b90ec0..e8b9793b 100644 --- a/client/src/lib/queueOps.test.ts +++ b/client/src/lib/queueOps.test.ts @@ -96,6 +96,11 @@ describe("resetRunningToPending", () => { expect(next[1].bytesPerSec).toBe(0); }); + it("marks a stopped running item interrupted, so it offers Resume", () => { + const next = resetRunningToPending([runItem("b", "running", 600, 1024, 1)]); + expect((next[0] as RunItem & { interrupted?: boolean }).interrupted).toBe(true); + }); + it("leaves done items untouched (counters preserved)", () => { const arr: RunItem[] = [runItem("a", "done", 1024, 1024, 0)]; const next = resetRunningToPending(arr); diff --git a/client/src/lib/queueOps.ts b/client/src/lib/queueOps.ts index 8435433f..ccafb6b9 100644 --- a/client/src/lib/queueOps.ts +++ b/client/src/lib/queueOps.ts @@ -144,6 +144,10 @@ export function resetRunningToPending< // persisted as a pending row with `recovering:true`. recovering: false, recoverAttempt: 0, + // It had started: the row says so and offers to resume, instead of + // looking like an item that never ran. + interrupted: true, + stalledForMs: null, }; } return it; diff --git a/client/src/screens/FileSystem/index.tsx b/client/src/screens/FileSystem/index.tsx index 4bea177f..97296817 100644 --- a/client/src/screens/FileSystem/index.tsx +++ b/client/src/screens/FileSystem/index.tsx @@ -68,6 +68,7 @@ import { } from "./fsBrowse"; import { save as saveDialog } from "@tauri-apps/plugin-dialog"; import { isTauriEnv } from "../../lib/tauriEnv"; +import { isWindows } from "../../lib/platform"; import { isInstallPackagePath } from "../../lib/pkgDropDedupe"; import { PageHeader, @@ -322,6 +323,9 @@ export function isSinglePathName(name: string): boolean { ); } +/** The in-app drag of rows to move them (the row names, as JSON). */ +const FS_DRAG_MIME = "application/x-ps5upload-fs-move"; + function crumbs(p: string): { label: string; path: string }[] { const parts = p.split("/").filter(Boolean); const out: { label: string; path: string }[] = [{ label: "/", path: "/" }]; @@ -1524,6 +1528,51 @@ export default function FileSystemScreen() { sourceLabel: path, }); }; + // Drag rows onto a folder (or a breadcrumb) to move them there, like the console's own web + // file manager (Reddit ask). The move is the same cut-and-paste as Move to…, so a drop on + // another drive copies and then removes. On the desktop a dragged FILE still goes out to this + // computer (that drag is native), so files move this way where there is no drag-out. + const [dropTarget, setDropTarget] = useState(null); + // The Windows app's window takes every drag for its own file drop (uploads), so no in-page + // drag reaches a row there: offer none rather than one that does nothing. + const dragMoveWorks = !(isTauriEnv() && isWindows()); + const movesByDrag = (e: DirEntry) => dragMoveWorks && (e.kind === "dir" || !canDragOut); + const dropProps = (dest: string) => ({ + onDragOver: (ev: React.DragEvent) => { + if (!ev.dataTransfer.types.includes(FS_DRAG_MIME)) return; + ev.preventDefault(); + ev.dataTransfer.dropEffect = "move"; + if (dropTarget !== dest) setDropTarget(dest); + }, + onDragLeave: () => setDropTarget((cur) => (cur === dest ? null : cur)), + onDrop: (ev: React.DragEvent) => { + const raw = ev.dataTransfer.getData(FS_DRAG_MIME); + setDropTarget(null); + if (!raw) return; + ev.preventDefault(); + let names: string[]; + try { + names = JSON.parse(raw) as string[]; + } catch { + return; + } + const byName = new Map((entries ?? []).map((x) => [x.name, x])); + const items = names + .map((n) => byName.get(n)) + .filter((x): x is DirEntry => !!x) + .map((x) => ({ + path: joinPath(path, x.name), + name: x.name, + size: x.size, + kind: x.kind === "dir" ? ("dir" as const) : ("file" as const), + })) + // Never into itself or below itself, and never where it already is. + .filter((i) => dest !== i.path && !dest.startsWith(`${i.path}/`) && dest !== path); + if (items.length === 0) return; + setSelected(new Set()); + void runPaste(dest, { op: "cut", items, sourceLabel: path }); + }, + }); // Set permissions to 777 (folders recursively): what games and payload folders need to be // readable and runnable, and what users reached for FileZilla to do (#411). const [chmodBusy, setChmodBusy] = useState(false); @@ -2568,7 +2617,9 @@ export default function FileSystemScreen() { + )} )} @@ -1014,6 +1014,48 @@ export function QueueRow({ )} + {isActive && !isRecovering && !isInstall && item.stalledForMs != null && ( +
+ + {tr( + "queue_stalled", + { time: formatDuration(item.stalledForMs / 1000) }, + "Nothing has moved for {time}. Restarting the transfer picks up from what the PS5 already has.", + )} + + +
+ )} + + {isPending && item.interrupted && ( +
+ + {tr( + "queue_interrupted", + undefined, + "Stopped part-way. Resuming continues from what the PS5 already has.", + )} + + +
+ )} + {showInstallProgress && } {isActive && !showInstallProgress && !isRecovering && ( diff --git a/client/src/state/uploadQueue.ts b/client/src/state/uploadQueue.ts index 52b153d0..98d3845b 100644 --- a/client/src/state/uploadQueue.ts +++ b/client/src/state/uploadQueue.ts @@ -274,6 +274,12 @@ export interface QueueItem { * this id, so Stop and Start continue from the console's journal. */ txIdHex: string; status: QueueItemStatus; + /** It was running when it was stopped or the app closed: it is waiting with its console-side + * progress kept, and starting it continues from there. Cleared when it runs again. */ + interrupted?: boolean; + /** While running: how long nothing has moved, once that is STALL_AFTER_MS or more (the row then + * offers to restart the transfer, which resumes). Null otherwise. */ + stalledForMs?: number | null; /** Live progress while running, final count when done, 0 otherwise. */ bytesSent: number; /** Total bytes the engine pre-stat'd for this source. 0 until first @@ -427,6 +433,9 @@ interface QueueState { moveDown: (id: string) => void; clear: () => void; retryFailed: () => void; + /** Stops a running (stalled) upload and starts it again; it continues from what the console + * already holds. Not for installs, which cannot be stopped. */ + restartItem: (id: string) => void; /** Retry one failed row. Returns false when the row is missing/not failed. */ retryItem: (id: string) => boolean; /** Retry one archive row that failed for a missing or wrong password, with the password @@ -470,6 +479,8 @@ interface QueueDocument { } const POLL_INTERVAL_MS = 500; +/** No new bytes for this long while sending (not while the console finalizes): say so on the row. */ +const STALL_AFTER_MS = 90_000; const SAVE_DEBOUNCE_MS = 300; function newId(): string { @@ -1174,6 +1185,9 @@ export const useUploadQueueStore = create((set, get) => { // next runOne builds a fresh array. const startedAtMs = Date.now(); const samples: RateSample[] = [{ ts: startedAtMs, bytes: 0 }]; + // When bytes last moved, for the "nothing is moving" note on the row. + let lastBytes = -1; + let lastMoveAt = startedAtMs; while (isLive()) { const snap = await jobStatus(jobId, item.addr); @@ -1564,11 +1578,20 @@ export const useUploadQueueStore = create((set, get) => { const bytesSent = snap.bytes_sent ?? 0; pushRateSample(samples, now, bytesSent); const bytesPerSec = computeRate(samples, now); + if (bytesSent !== lastBytes) { + lastBytes = bytesSent; + lastMoveAt = now; + } + const totalNow = snap.total_bytes ?? 0; + // Everything sent and the console making it durable is not a stall. + const sending = !(totalNow > 0 && bytesSent >= totalNow); + const stalledForMs = sending && now - lastMoveAt >= STALL_AFTER_MS ? now - lastMoveAt : null; set((s) => ({ items: patchItem(s.items, item.id, { bytesSent, - totalBytes: snap.total_bytes ?? 0, + totalBytes: totalNow, bytesPerSec, + stalledForMs, // P3 / v2.18.0 — forward apply-phase counters so the // QueueRow's finalize pill can show "Finalized N of M files" // (analogous to transfer.ts's single-shot path). Defaults to @@ -1644,6 +1667,8 @@ export const useUploadQueueStore = create((set, get) => { set((s) => ({ items: patchItem(s.items, next.id, { status: "running", + interrupted: false, + stalledForMs: null, startedAt, // Reset live counters so a previously-failed-then-retried // item starts the bar + speed readout from zero instead @@ -1924,6 +1949,8 @@ export const useUploadQueueStore = create((set, get) => { if (!isTauriEnv()) reattaching.add(next.id); } next.status = "pending"; + next.interrupted = true; + next.stalledForMs = null; } if (!next.txIdHex) next.txIdHex = generateTxIdHex(); // Back-fill the bytes/sec field added in 2.2.22 — older @@ -2264,6 +2291,17 @@ export const useUploadQueueStore = create((set, get) => { ), })); scheduleSave(); + // The button says Retry: run them, don't just put them back in the list. + void get().start(); + }, + + restartItem(id) { + const item = get().items.find((candidate) => candidate.id === id); + if (!item || item.status !== "running" || item.sourceKind === "install") return; + const host = hostOf(item.addr); + get().stopHost(host); + // Let the engine let go of the job; the next run resumes it from the console's journal. + setTimeout(() => void get().startHost(host, { onlyIds: [id] }), 1500); }, retryItem(id) { @@ -2524,6 +2562,7 @@ export const useUploadQueueStore = create((set, get) => { moveDown: () => undefined, clear: () => undefined, retryFailed: () => undefined, + restartItem: () => undefined, retryItem: () => false, retryWithPassword: () => false, discardPartial: () => Promise.resolve(), diff --git a/engine/crates/ava1-ctest/tests/apply.rs b/engine/crates/ava1-ctest/tests/apply.rs index b9b8589f..6ec1951b 100644 --- a/engine/crates/ava1-ctest/tests/apply.rs +++ b/engine/crates/ava1-ctest/tests/apply.rs @@ -756,3 +756,63 @@ fn a_sync_batch_that_runs_out_of_memory_gives_its_pending_slots_back() { "the failed batch leaked its pending-fd reservations" ); } + +/// Waits up to 5 s for `p` to exist. +fn appears(p: &Path) -> bool { + let t = std::time::Instant::now(); + while t.elapsed().as_secs() < 5 { + if p.exists() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + false +} + +/// A game's param.json is what makes a folder a game to ShadowMount+, which registered a folder +/// whose eboot.bin was still arriving and then gave up on it. It appears only when the rest has. +fn param_json_is_placed_last(merge: bool) { + let t = tmp(if merge { "held-merge" } else { "held-staged" }); + let root = t.join("PPSA00001-app"); + if merge { + std::fs::create_dir_all(&root).unwrap(); + } + let base = if merge { + root.clone() + } else { + std::path::PathBuf::from(format!("{}.ava-part", root.display())) + }; + let d = data(3 * GROUP as usize + 5, 7); + let m = Manifest { + entries: vec![ + dir("sce_sys"), + file("sce_sys/param.json", 2), + file("eboot.bin", d.len() as u64), + ], + }; + let job = CApplyJob::begin(&t.join("jobs"), &root, 0, &m, 0); + job.record(1, b"{}", *blake3::hash(b"{}").as_bytes()); + assert!(appears(&base.join("sce_sys/param.json.ava-hold"))); + assert!( + !base.join("sce_sys/param.json").exists(), + "not before eboot.bin" + ); + send_large(&job, 2, &d, false); + assert_eq!(job.wait(10_000), 0, "{}", job.events()); + assert_eq!( + std::fs::read(root.join("sce_sys/param.json")).unwrap(), + b"{}" + ); + assert_eq!(std::fs::read(root.join("eboot.bin")).unwrap(), d); + assert!(!root.join("sce_sys/param.json.ava-hold").exists()); +} + +#[test] +fn a_games_param_json_appears_only_when_the_staged_tree_lands() { + param_json_is_placed_last(false); +} + +#[test] +fn a_games_param_json_appears_last_in_a_merge_too() { + param_json_is_placed_last(true); +} diff --git a/engine/crates/ava1-ctest/tests/stall.rs b/engine/crates/ava1-ctest/tests/stall.rs index ac095844..c4ed22cc 100644 --- a/engine/crates/ava1-ctest/tests/stall.rs +++ b/engine/crates/ava1-ctest/tests/stall.rs @@ -270,3 +270,74 @@ async fn a_resumed_job_gets_the_resume_limit() { srv.knob("progress_ms", 0); srv.knob("resume_progress_ms", 0); } + +/// SkipProgress (SPEC.md §12.8): a resumed sender that is reading past data the receiver already +/// holds (a solid archive decoded from its start) sends no data frame for a long time, only +/// SkipProgress. That is progress: the job outlives its progress limit many times over and then +/// finishes normally. Without the frames the same job is cut (`a_resumed_job_gets_the_resume_limit`). +#[tokio::test(flavor = "multi_thread")] +async fn skip_progress_keeps_a_resumed_job_alive_past_its_limit() { + let (srv, d, ids) = server("stall-skip", 0); + srv.knob("progress_ms", 800); + srv.knob("resume_progress_ms", 1500); + let job = [0x66; 16]; + { + let (mut link, s) = opened(&srv, &d, ids.clone(), job, 3).await; + let lane = link.opener().unwrap().open().await.unwrap(); + link.lane(lane) + .unwrap() + .tx + .send_raw(Bundle::TYPE, 0, 1, bundle(job, 0, b"one!")) + .await + .unwrap(); + loop { + if next_control(&mut link).await.ty == Durable::TYPE { + break; + } + } + drop(link); + s.close().await; + } + let (mut link, _s) = opened(&srv, &d, ids.clone(), job, 3).await; + let lane = link.opener().unwrap().open().await.unwrap(); + // 4.8 s of nothing but SkipProgress, 3x the resume limit. + let t0 = Instant::now(); + let mut skipped = 0u64; + while t0.elapsed() < Duration::from_millis(4800) { + skipped += 64 << 20; + link.control + .send(&gen::SkipProgress { + job_id: job, + bytes_skipped: skipped, + }) + .await + .unwrap(); + let until = Instant::now() + Duration::from_millis(400); + while let Ok(ev) = tokio::time::timeout_at(until.into(), link.rx.recv()).await { + if let Some(Inbound::Control(f)) = ev { + if f.ty == JobDone::TYPE { + let done: JobDone = f.decode().unwrap(); + panic!( + "the job ended after {:?} of SkipProgress: {} {:?}", + t0.elapsed(), + done.status, + done.message + ); + } + } + } + } + for (seq, id) in [(1u32, 1u32), (2, 2)] { + link.lane(lane) + .unwrap() + .tx + .send_raw(Bundle::TYPE, 0, seq, bundle(job, id, b"more")) + .await + .unwrap(); + } + let done = job_done(&mut link, Duration::from_secs(30)).await; + assert_eq!(done.status, 0, "{:?}", done.message); + assert_eq!(std::fs::read(d.join("dest/f2")).unwrap(), b"more"); + srv.knob("progress_ms", 0); + srv.knob("resume_progress_ms", 0); +} diff --git a/engine/crates/ava1/src/gen.rs b/engine/crates/ava1/src/gen.rs index 2ddd49ee..66e8a226 100644 --- a/engine/crates/ava1/src/gen.rs +++ b/engine/crates/ava1/src/gen.rs @@ -1576,6 +1576,43 @@ impl FrameMessage for JobCancel { const TYPE: u8 = 0x2f; } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SkipProgress { + pub job_id: [u8; 16], + pub bytes_skipped: u64, +} + +impl Message for SkipProgress { + const NAME: &'static str = "SkipProgress"; + + fn encode_into(&self, w: &mut Writer) -> Result<(), EncodeError> { + w.fixed(&self.job_id); + w.u64(self.bytes_skipped); + w.u16(0); + Ok(()) + } + + fn decode(b: &[u8]) -> Result { + let mut r = Reader::new(b); + let mut m = Self::default(); + m.job_id = r.fixed::<16>()?; + m.bytes_skipped = r.u64()?; + let ext_n = r.u16()?; + for _ in 0..ext_n { + let tag = r.u16()?; + let len = r.u32()? as usize; + let v = r.take(len)?; + let _ = (tag, v); + } + r.finish()?; + Ok(m) + } +} + +impl FrameMessage for SkipProgress { + const TYPE: u8 = 0x30; +} + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct NodeInfo { pub version: String, @@ -3359,7 +3396,7 @@ impl Message for JnlDone { } /// Every message and struct, by name (conformance tests). -pub const ALL: &[&str] = &["Hs1", "Hs2", "Hs3", "Welcome", "PairPakeClient", "PairPakeServer", "PairConfirm", "PairResult", "Join", "JoinAck", "Ping", "Pong", "Error", "Bye", "RpcRequest", "RpcResponse", "JobOpen", "JobOpenAck", "ManifestPage", "ManifestEnd", "JobMap", "Resume", "Chunk", "Bundle", "Received", "Credit", "Durable", "FileRoot", "FileRetry", "Status", "JobDone", "JobCancel", "NodeInfo", "HelloInfo", "ServerInfo", "ClientInfo", "PairingOpen", "CryptoBench", "CryptoBenchResult", "ManifestEntry", "FileRun", "FileRange", "BundleRecord", "RootItem", "JobCopy", "JobRef", "DiskCalibrate", "CalPoint", "DiskCalibrateResult", "C2cAllow", "C2cTicket", "C2cSend", "MgmtText", "NodeStatus", "FsList", "FsEntry", "FsListResult", "FsPath", "FsStat", "FsFreeSpace", "FsMkdir", "FsRename", "FsChmod", "FsRead", "FsReadResult", "FsWrite", "JobRun", "JobEntry", "JobListResult", "JnlOpen", "JnlBatch", "PackRef", "JnlSweep", "JnlReset", "JnlSnapshot", "JnlDone", ]; +pub const ALL: &[&str] = &["Hs1", "Hs2", "Hs3", "Welcome", "PairPakeClient", "PairPakeServer", "PairConfirm", "PairResult", "Join", "JoinAck", "Ping", "Pong", "Error", "Bye", "RpcRequest", "RpcResponse", "JobOpen", "JobOpenAck", "ManifestPage", "ManifestEnd", "JobMap", "Resume", "Chunk", "Bundle", "Received", "Credit", "Durable", "FileRoot", "FileRetry", "Status", "JobDone", "JobCancel", "SkipProgress", "NodeInfo", "HelloInfo", "ServerInfo", "ClientInfo", "PairingOpen", "CryptoBench", "CryptoBenchResult", "ManifestEntry", "FileRun", "FileRange", "BundleRecord", "RootItem", "JobCopy", "JobRef", "DiskCalibrate", "CalPoint", "DiskCalibrateResult", "C2cAllow", "C2cTicket", "C2cSend", "MgmtText", "NodeStatus", "FsList", "FsEntry", "FsListResult", "FsPath", "FsStat", "FsFreeSpace", "FsMkdir", "FsRename", "FsChmod", "FsRead", "FsReadResult", "FsWrite", "JobRun", "JobEntry", "JobListResult", "JnlOpen", "JnlBatch", "PackRef", "JnlSweep", "JnlReset", "JnlSnapshot", "JnlDone", ]; #[doc(hidden)] pub fn sample(name: &str, rng: &mut SplitMix) -> Option> { @@ -3396,6 +3433,7 @@ pub fn sample(name: &str, rng: &mut SplitMix) -> Option> { "Status" => Status { job_id: { let mut a = [0u8; 16]; rng.fill(&mut a); a }, files_done: rng.next_u64() as u32, files_total: rng.next_u64() as u32, bytes_received: rng.next_u64(), bytes_durable: rng.next_u64(), bytes_total: rng.next_u64(), bottleneck: rng.next_u64() as u8, workers: rng.next_u64() as u8, lanes: rng.next_u64() as u8, sequential: rng.next_u64() as u8, current: if rng.below(2) == 1 { Some(rng.ascii(20)) } else { None }, state: if rng.below(2) == 1 { Some(rng.next_u64() as u8) } else { None }, result: if rng.below(2) == 1 { Some({ let n = rng.below(41) as usize; let mut v = vec![0u8; n]; rng.fill(&mut v); v }) } else { None }, code: if rng.below(2) == 1 { Some(rng.next_u64() as u16) } else { None }, unswept: if rng.below(2) == 1 { Some(rng.next_u64() as u32) } else { None }, }.to_bytes().ok(), "JobDone" => JobDone { job_id: { let mut a = [0u8; 16]; rng.fill(&mut a); a }, status: rng.next_u64() as u16, files: rng.next_u64() as u32, bytes: rng.next_u64(), message: if rng.below(2) == 1 { Some(rng.ascii(20)) } else { None }, settling: if rng.below(2) == 1 { Some(rng.next_u64() as u8) } else { None }, }.to_bytes().ok(), "JobCancel" => JobCancel { job_id: { let mut a = [0u8; 16]; rng.fill(&mut a); a }, reason: rng.next_u64() as u16, }.to_bytes().ok(), + "SkipProgress" => SkipProgress { job_id: { let mut a = [0u8; 16]; rng.fill(&mut a); a }, bytes_skipped: rng.next_u64(), }.to_bytes().ok(), "NodeInfo" => NodeInfo { version: rng.ascii(20), platform: rng.ascii(20), name: rng.ascii(20), firmware: if rng.below(2) == 1 { Some(rng.ascii(20)) } else { None }, }.to_bytes().ok(), "HelloInfo" => HelloInfo { version_min: rng.next_u64() as u16, version_max: rng.next_u64() as u16, caps: rng.next_u64(), }.to_bytes().ok(), "ServerInfo" => ServerInfo { version: rng.next_u64() as u16, caps: rng.next_u64(), session_id: { let mut a = [0u8; 16]; rng.fill(&mut a); a }, pair_commit: { let mut a = [0u8; 32]; rng.fill(&mut a); a }, name: if rng.below(2) == 1 { Some(rng.ascii(20)) } else { None }, }.to_bytes().ok(), @@ -3482,6 +3520,7 @@ pub fn roundtrip(name: &str, bytes: &[u8]) -> Option, String>> { "Status" => rt::(bytes), "JobDone" => rt::(bytes), "JobCancel" => rt::(bytes), + "SkipProgress" => rt::(bytes), "NodeInfo" => rt::(bytes), "HelloInfo" => rt::(bytes), "ServerInfo" => rt::(bytes), diff --git a/engine/crates/ava1/src/recv.rs b/engine/crates/ava1/src/recv.rs index 14593107..c775b293 100644 --- a/engine/crates/ava1/src/recv.rs +++ b/engine/crates/ava1/src/recv.rs @@ -1837,6 +1837,12 @@ async fn run_loop( message: "the sender cancelled".into(), }); } + // The sender is reading past data this job already holds (SPEC.md §12.8): + // progress, and nothing else. + gen::SkipProgress::TYPE => { + f.decode::().map_err(proto)?; + last_progress = Instant::now(); + } _ => {} }, Some(Inbound::LaneUp(_)) | Some(Inbound::LaneDown(_)) => {} diff --git a/engine/crates/ava1/src/send.rs b/engine/crates/ava1/src/send.rs index 6e41f030..45f0814f 100644 --- a/engine/crates/ava1/src/send.rs +++ b/engine/crates/ava1/src/send.rs @@ -587,6 +587,18 @@ fn stall_limit(outstanding: u64) -> Duration { /// console's job thread in one sync for minutes. pub const RECEIVER_SILENT_FATAL: Duration = Duration::from_secs(600); +/// How often a sender that is reading past data the receiver already holds (a solid +/// archive decoded from its start on resume) tells it so with `SkipProgress` (SPEC.md §12.8). +/// The spec asks for at least every 10 s; the console's progress deadline is far longer. +pub const SKIP_PROGRESS_EVERY: Duration = Duration::from_secs(5); +const _: () = assert!(SKIP_PROGRESS_EVERY.as_secs() <= 10, "SPEC.md §12.8"); + +/// Whether a `SkipProgress` is due: the skip counter moved since the last one and the +/// interval has passed. +fn skip_progress_due(skipped: u64, last_sent: u64, since_last: Duration) -> bool { + skipped != last_sent && since_last >= SKIP_PROGRESS_EVERY +} + fn receiver_silent(limit: Duration) -> SendError { SendError::Protocol(format!( "the receiver said nothing about the job for {limit:?} although the connection is alive" @@ -1492,6 +1504,17 @@ pub async fn run_upload( let seq_retries = Arc::new(crate::seq::Retries::default()); let budget_wait: Arc = Arc::default(); let mut budget_wait_seen = 0u64; + // Bytes read past without sending (SPEC.md §12.8): the decode thread's own (held + // stretches of a partly durable file) plus the source's (entries decoded to discard). + let seq_skipped = Arc::new(AtomicU64::new(0)); + let skip_base = opts.seq.as_ref().map_or(0, |s| s.bytes_skipped()); + let skipped_now = |seq: &Arc| { + seq.bytes_skipped() + .saturating_sub(skip_base) + .saturating_add(seq_skipped.load(Ordering::Relaxed)) + }; + let (mut skip_sent, mut skip_sent_at) = (0u64, Instant::now()); + let mut skip_next: Option = None; let mut readers = if let Some(seq) = opts.seq.clone() { // One decode thread for a forward-only source (SPEC.md §17). let chunk_sh = sh.clone(); @@ -1514,6 +1537,7 @@ pub async fn run_upload( cancel: opts.cancel.clone(), retries: seq_retries.clone(), rt: tokio::runtime::Handle::current(), + skipped: seq_skipped.clone(), })] } else { spawn_readers( @@ -1667,6 +1691,20 @@ pub async fn run_upload( } root_next.pop_front(); }, + skip_sent_r = async { + let bytes_skipped = skip_next.expect("guarded by `if skip_next.is_some()`"); + link.control + .send(&gen::SkipProgress { job_id, bytes_skipped }) + .await + }, if skip_next.is_some() => { + // Queued from the tick and sent here, like the roots: awaiting it inline + // would stop the inbox drain while the control outbox is full. + if let Err(e) = skip_sent_r { + break Err(SendError::Disconnected(e.to_string())); + } + skip_sent = skip_next.take().expect("guarded by `if skip_next.is_some()`"); + skip_sent_at = Instant::now(); + }, cancel_sent = async { link.control .send(&gen::JobCancel { job_id, reason: gen::ERR_IO }) @@ -1880,6 +1918,14 @@ pub async fn run_upload( ))); } } + // A long skip sends no data frame: tell the receiver it is progress, or its + // watchdog ends the job (SPEC.md §12.8). Older receivers ignore the frame. + if let Some(seq) = opts.seq.as_ref() { + let n = skipped_now(seq); + if skip_next.is_none() && skip_progress_due(n, skip_sent, skip_sent_at.elapsed()) { + skip_next = Some(n); + } + } let secs = last_tick.elapsed().as_secs_f64(); last_tick = Instant::now(); let lanes_now = link.lanes().len() as u8; @@ -2222,6 +2268,18 @@ mod tests { m } + #[test] + fn skip_progress_is_sent_only_when_the_skip_moved_and_the_interval_passed() { + let every = SKIP_PROGRESS_EVERY; + assert!(!skip_progress_due(0, 0, every * 10), "nothing skipped"); + assert!( + !skip_progress_due(5, 5, every * 10), + "no new skip since the last" + ); + assert!(!skip_progress_due(9, 5, every / 2), "too soon"); + assert!(skip_progress_due(9, 5, every)); + } + #[test] fn a_fresh_job_needs_every_byte_and_a_resumed_one_only_the_rest() { let cutoff = 1 << 20; diff --git a/engine/crates/ava1/src/seq.rs b/engine/crates/ava1/src/seq.rs index 792f67b7..bcbf7d75 100644 --- a/engine/crates/ava1/src/seq.rs +++ b/engine/crates/ava1/src/seq.rs @@ -11,7 +11,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::io; use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::time::{Duration, Instant}; @@ -80,6 +80,14 @@ pub trait SeqSource: Send + Sync { /// Called once when the upload ends, before the decode thread is joined; wakes a /// decoder parked on something other than the sink. Must not block. fn close(&self) {} + /// Bytes this source has decoded only to discard them (entries a pass skips but + /// cannot seek past, such as a solid archive's prefix on resume), over its + /// lifetime; never decreases. The sender turns its growth into `SkipProgress` + /// (SPEC.md §12.8) so the receiver does not take a long skip for a stalled sender. + /// A source that never discards keeps the default. + fn bytes_skipped(&self) -> u64 { + 0 + } } /// A job gets at most this many passes (the first plus retries); a further `FileRetry` @@ -144,6 +152,10 @@ pub(crate) struct DecodeCtx { pub cancel: Arc, pub retries: Arc, pub rt: tokio::runtime::Handle, + /// Bytes the decode thread received and did not send: the durable (or CV-known) + /// stretches of a partly held large file (SPEC.md §17.2). Read with + /// `SeqSource::bytes_skipped` for `SkipProgress`. + pub skipped: Arc, } /// Per-file plan for a pass. @@ -520,15 +532,19 @@ impl EntrySink for Feeder<'_> { return Ok(()); } let mut b = bytes; + // What the receiver already holds passes through here unsent (SPEC.md §12.8). + let mut unsent = 0u64; while !b.is_empty() { let cur = self.cur.as_mut().expect("an entry is open"); let Some(p) = cur.plan.get(cur.pi).copied() else { cur.pos += b.len() as u64; + unsent += b.len() as u64; break; }; if cur.pos < p.offset { let n = ((p.offset - cur.pos) as usize).min(b.len()); cur.pos += n as u64; + unsent += n as u64; b = &b[n..]; continue; } @@ -543,11 +559,17 @@ impl EntrySink for Feeder<'_> { let n = ((p.len as usize) - cur.buf.len()).min(b.len()); cur.buf.extend_from_slice(&b[..n]); cur.pos += n as u64; + if !p.send { + unsent += n as u64; + } b = &b[n..]; if cur.buf.len() as u64 == p.len { self.finish_piece()?; } } + if unsent > 0 { + self.c.skipped.fetch_add(unsent, Ordering::Relaxed); + } Ok(()) } @@ -686,6 +708,7 @@ mod tests { } struct Run { + skipped: Arc, budget: Arc, bw: Arc, rx: mpsc::UnboundedReceiver, @@ -711,7 +734,9 @@ mod tests { ); let bw = Arc::new(BudgetWait::default()); let budget = Arc::new(Semaphore::new(budget_kib)); + let skipped = Arc::new(AtomicU64::new(0)); let ctx = DecodeCtx { + skipped: skipped.clone(), seq: s.clone(), manifest: m.clone(), need, @@ -728,6 +753,7 @@ mod tests { }; let h = std::thread::spawn(move || run(ctx)); Run { + skipped, budget, bw, rx, @@ -921,6 +947,8 @@ mod tests { } // Only group 2 is sent; group 1 was decoded and hashed (no CV), group 0 was not hashed. assert_eq!(sent, [(2 * GROUP, 5000)]); + // Both held groups passed through unsent: that is what SkipProgress reports. + assert_eq!(r.skipped.load(Ordering::Relaxed), 2 * GROUP); assert_eq!(root, root_of(&big)); let wants = s.wants.lock().unwrap(); assert!( @@ -1124,6 +1152,7 @@ mod tests { cancel: Arc::default(), retries: Arc::default(), rt: tokio::runtime::Handle::current(), + skipped: Arc::default(), }; let h = std::thread::spawn(move || run(ctx)); tokio::time::sleep(Duration::from_millis(100)).await; diff --git a/engine/crates/ava1/tests/data_rust.rs b/engine/crates/ava1/tests/data_rust.rs index a1161fba..c794f26d 100644 --- a/engine/crates/ava1/tests/data_rust.rs +++ b/engine/crates/ava1/tests/data_rust.rs @@ -1194,6 +1194,47 @@ async fn a_slow_but_moving_sender_is_not_cut() { ); } +/// SkipProgress (SPEC.md §12.8) is progress: a sender reading past data the receiver holds +/// sends only that for several deadlines, is not cut, and then finishes. +#[tokio::test(flavor = "multi_thread")] +async fn skip_progress_is_progress_for_the_rust_receiver() { + let (link, _s, mut ended, _d, n) = + deadline_job("rr-skip", 0x93, 2, Duration::from_millis(800), None).await; + let lane = link.opener().unwrap().open().await.unwrap(); + let t0 = std::time::Instant::now(); + let mut skipped = 0u64; + while t0.elapsed() < Duration::from_millis(3000) { + skipped += 1 << 20; + link.control + .send(&gen::SkipProgress { + job_id: [0x93; 16], + bytes_skipped: skipped, + }) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(300)).await; + assert!( + ended.try_recv().is_err(), + "the job ended after {:?} of SkipProgress", + t0.elapsed() + ); + } + for i in 0..n { + link.lane(lane) + .unwrap() + .tx + .send_raw(Bundle::TYPE, 0, i + 1, bundle_body([0x93; 16], i, b"data")) + .await + .unwrap(); + } + let files = tokio::time::timeout(Duration::from_secs(20), ended.recv()) + .await + .expect("the job ended") + .expect("a result") + .expect("it completed, was not cut"); + assert_eq!(files, n); +} + /// Disk work in flight is progress too: a sync batch that outlasts the deadline (a slow /// drive) must not read as a stalled sender. #[tokio::test(flavor = "multi_thread")] diff --git a/engine/crates/ps5upload-ava1/src/rar_source.rs b/engine/crates/ps5upload-ava1/src/rar_source.rs index 3b7856a0..7272fdcb 100644 --- a/engine/crates/ps5upload-ava1/src/rar_source.rs +++ b/engine/crates/ps5upload-ava1/src/rar_source.rs @@ -13,7 +13,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::io; use std::path::{Path, PathBuf}; -use std::sync::atomic::AtomicBool; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use ava1::gen; use ava1::manifest::{self, Entry, Manifest}; @@ -119,6 +119,8 @@ pub struct RarSource { sizes: Vec, /// Manifest id -> path. by_id: Vec, + /// Bytes decoded only to be discarded (solid: the entries a pass skips). + skipped: AtomicU64, } impl RarSource { @@ -214,6 +216,7 @@ impl RarSource { order, sizes: layout.files.iter().map(|(_, s)| *s).collect(), by_id, + skipped: AtomicU64::new(0), }, )) } @@ -222,6 +225,12 @@ impl RarSource { self.solid } + /// Bytes decoded only to be discarded (the "skipping data the console already has" + /// phase of a solid resume). + pub fn bytes_skipped(&self) -> u64 { + self.skipped.load(Ordering::Relaxed) + } + /// Test seam: replace the archive-order listing the entry positions come from /// (simulates an archive whose listing and extraction orders disagree). #[doc(hidden)] @@ -287,6 +296,10 @@ impl RarWalkSink for Adapter<'_> { fn end(&mut self) -> io::Result<()> { self.sink.end() } + + fn skipped(&mut self, n: u64) { + self.src.skipped.fetch_add(n, Ordering::Relaxed); + } } impl SeqSource for RarSource { @@ -342,6 +355,10 @@ impl SeqSource for RarSource { .and_then(|p| self.ordinal.get(p)) .map_or(Restart::START, |o| Restart(*o)) } + + fn bytes_skipped(&self) -> u64 { + RarSource::bytes_skipped(self) + } } /// The sender takes a `Source` even when a `SeqSource` feeds it (only `close()` is diff --git a/engine/crates/ps5upload-ava1/src/seq.rs b/engine/crates/ps5upload-ava1/src/seq.rs index 14c89b3b..3a05b04f 100644 --- a/engine/crates/ps5upload-ava1/src/seq.rs +++ b/engine/crates/ps5upload-ava1/src/seq.rs @@ -495,6 +495,10 @@ impl SeqSource for SevenzSource { fn restart_for(&self, file_id: u32) -> Restart { Restart(self.restarts.get(file_id as usize).copied().unwrap_or(0)) } + + fn bytes_skipped(&self) -> u64 { + SevenzSource::bytes_skipped(self) + } } /// A folder whose decode ended before its last wanted entry. diff --git a/engine/crates/ps5upload-ava1/tests/rar.rs b/engine/crates/ps5upload-ava1/tests/rar.rs index c66fe548..5ba017b7 100644 --- a/engine/crates/ps5upload-ava1/tests/rar.rs +++ b/engine/crates/ps5upload-ava1/tests/rar.rs @@ -415,6 +415,10 @@ fn rar_solid_resume_still_correct() { src.pass(Restart::START, &mut want, &mut rec, &AtomicBool::new(false)) .unwrap(); assert_eq!(rec.got, f.0[7..].to_vec()); + // The held prefix was decoded to be discarded: the sender reports it as SkipProgress. + let held: u64 = f.0[..7].iter().map(|(_, d)| d.len() as u64).sum(); + assert_eq!(src.bytes_skipped(), held); + assert_eq!(SeqSource::bytes_skipped(&src), held); } #[test] diff --git a/engine/crates/ps5upload-core/src/transfer.rs b/engine/crates/ps5upload-core/src/transfer.rs index 1e2ee365..8e351564 100644 --- a/engine/crates/ps5upload-core/src/transfer.rs +++ b/engine/crates/ps5upload-core/src/transfer.rs @@ -844,7 +844,10 @@ mod rar_support { continue; } file_count += 1; - total_uncompressed += e.unpacked_size; + // An unknown-size entry reports a ~2^63 placeholder; summing it + // wrapped the total in release builds and broke the client's + // space check. Count it as 0, like the planner does. + total_uncompressed = add_rar_entry_size(total_uncompressed, e.unpacked_size); if let Some(rel) = sanitize_rar_entry(&e.filename) { param_hit = better_param_hit(param_hit, &rel); } @@ -1016,9 +1019,7 @@ mod rar_support { { continue; } - if !rar_size_unknown(e.unpacked_size) { - total += e.unpacked_size; - } + total = add_rar_entry_size(total, e.unpacked_size); files.push((rel, e.unpacked_size)); } Ok((total, files)) @@ -1291,6 +1292,17 @@ mod rar_support { size >= 1 << 62 } + /// Add one entry's declared size to a running total: an unknown size + /// (the placeholder above) contributes nothing, and the sum saturates + /// rather than wrapping (release builds have no overflow checks). + pub(crate) fn add_rar_entry_size(total: u64, size: u64) -> u64 { + if rar_size_unknown(size) { + total + } else { + total.saturating_add(size) + } + } + /// Learn the real size of every unknown-size entry by decoding it once (the /// stream's own end and CRC define it). Decodes the whole archive when it is /// solid; a non-solid archive only decodes the unknown entries. @@ -1406,6 +1418,9 @@ mod rar_support { fn begin(&mut self, path: &str) -> std::io::Result<()>; fn data(&mut self, bytes: &[u8]) -> std::io::Result<()>; fn end(&mut self) -> std::io::Result<()>; + /// `n` more bytes were decoded only to be discarded (a solid archive's entry the + /// walk does not deliver). Lets a sender report a long skip as progress. + fn skipped(&mut self, _n: u64) {} } fn classify(e: &UnrarError, encrypted_with_password: bool) -> RarFailKind { @@ -1528,7 +1543,10 @@ mod rar_support { // Solid: the entry must be decoded to keep the stream aligned, and // RAR_SKIP never calls back, so stop could not be polled. Decode it // through a discarding callback that does. - let r = header.read_to_fn(&mut |_: &[u8]| !cancelled()); + let r = header.read_to_fn(&mut |b: &[u8]| { + sink.skipped(b.len() as u64); + !cancelled() + }); if cancelled() { return Err(RarWalkError::Cancelled); } @@ -1577,6 +1595,8 @@ mod rar_support { sink_err = Some(e); return false; } + } else { + sink.skipped(bytes.len() as u64); } true }); @@ -1626,6 +1646,20 @@ mod rar_support { .join(name) } + #[test] + fn unknown_size_entries_do_not_wrap_the_total() { + // RAR5 "size unknown" comes back from UnRAR as INT64NDF. + let ndf = 0x7fff_ffff_7fff_ffffu64; + let sizes = [10u64, ndf, 20, ndf, ndf, u64::MAX]; + let total = sizes.iter().fold(0, |t, &s| add_rar_entry_size(t, s)); + assert_eq!(total, 30, "unknown sizes must be skipped, not summed"); + assert_eq!( + add_rar_entry_size((1 << 62) - 1, (1 << 62) - 1), + (1 << 63) - 2 + ); + assert_eq!(add_rar_entry_size(u64::MAX - 1, 5), u64::MAX); + } + // crypted.rar: content-encrypted (names listable without a password), // password "unrar", first entry ".gitignore" = "target\nCargo.lock\n". #[test] diff --git a/payload/ava1/ava1_apply.c b/payload/ava1/ava1_apply.c index 5b2a0c8c..04750a39 100644 --- a/payload/ava1/ava1_apply.c +++ b/payload/ava1/ava1_apply.c @@ -72,17 +72,59 @@ static int in_the_way(int e) { return e == EEXIST || e == ENOTEMPTY || e == ENOT #define parent_of ava1_parent_of #define mkparents ava1_mkparents +/* A game's metadata (sce_sys/param.json, a PS4 game's sce_sys/param.sfo) is what makes a folder a + * game to ShadowMount+ and the other scanners, which register it as soon as sce_sys has sat still + * for a few seconds. Arriving first (it is small) it let them register a folder whose eboot.bin + * was still on its way: the mount failed, and ShadowMount+ gives up on a title after two failures, + * so the finished game never showed up. These files are written as .ava-hold and renamed + * into place by release_held() once everything else in the job is. */ +int ava1_apply_held_rel(const char *rel) { + static const char *const names[] = {"sce_sys/param.json", "sce_sys/param.sfo"}; + size_t n, k, i; + if (!rel) return 0; + n = strlen(rel); + for (i = 0; i < sizeof names / sizeof names[0]; i++) { + k = strlen(names[i]); + if (n >= k && strcmp(rel + n - k, names[i]) == 0 && (n == k || rel[n - k - 1] == '/')) return 1; + } + return 0; +} + /* A path that does not fit comes back empty (open() then fails) rather than truncated - * (which would name some other file). */ + * (which would name some other file). The "final" path of a held file is its .ava-hold name. */ void ava1_apply_path(const ava1_job_t *j, uint32_t id, int part, char *out, size_t cap) { const char *rel = ava1_mstore_path(&j->m, id); int n; if (j->flags & AVA1_JF_SINGLE_FILE) n = snprintf(out, cap, "%s%s", j->root, part ? ".ava-part" : ""); - else if (rel) n = snprintf(out, cap, "%s/%s%s", j->base, rel, part && !j->staged ? ".ava-part" : ""); + else if (rel) + n = snprintf(out, cap, "%s/%s%s%s", j->base, rel, ava1_apply_held_rel(rel) ? ".ava-hold" : "", + part && !j->staged ? ".ava-part" : ""); else n = -1; if (cap && (n < 0 || (size_t)n >= cap)) out[0] = 0; } +/* Renames each held file of the finished tree at `tree` into place (same folder, so never across + * drives). A missing .ava-hold is one already released, or a file this job did not write. */ +static int release_held(ava1_job_t *j, const char *tree) { + char hold[PATH_CAP], fin[PATH_CAP], parent[PATH_CAP]; + uint32_t i; + struct stat st; + int e; + if (j->flags & AVA1_JF_SINGLE_FILE) return 0; + for (i = 0; i < j->m.n; i++) { + const char *rel = ava1_mstore_path(&j->m, i); + if (j->m.e[i].kind != AVA1_ENTRY_FILE || !ava1_apply_held_rel(rel)) continue; + if (snprintf(fin, sizeof fin, "%s/%s", tree, rel) >= (int)sizeof fin || + snprintf(hold, sizeof hold, "%s.ava-hold", fin) >= (int)sizeof hold) + return ENAMETOOLONG; + if (lstat(hold, &st) != 0) continue; + if (rename(hold, fin) != 0) return errno; + parent_of(fin, parent, sizeof parent); + if ((e = sync_dir(parent)) != 0) return e; + } + return 0; +} + static void ob_path(const ava1_job_t *j, uint32_t id, char *out, size_t cap) { snprintf(out, cap, "%s/%u.ob", j->dir, id); } @@ -3055,6 +3097,12 @@ static void finish(ava1_job_t *j) { return; } } + /* Last, with every other file in place: the game's metadata (see ava1_apply_held_rel). A staged + * tree has just been renamed to the root; a merge wrote into it all along. */ + if ((e = release_held(j, j->staged ? j->root : j->base)) != 0) { + ava1_apply_fail(j, AVA1_ERR_IO, "placing the game's param file failed", e, 1); + return; + } ava1_apply_fail(j, AVA1_STATUS_OK, "", 0, 1); /* the success path: see ava1_apply_fail */ } @@ -3062,6 +3110,11 @@ void ava1_apply_finish_landed(ava1_job_t *j) { char parent[PATH_CAP]; int err; parent_of(j->root, parent, sizeof parent); + err = release_held(j, j->root); /* stopped after the staging rename, before the release */ + if (err) { + ava1_apply_fail(j, AVA1_ERR_IO, "placing the game's param file failed", err, 1); + return; + } err = sync_dir(parent); if (err) ava1_apply_fail(j, AVA1_ERR_IO, "syncing the landed folder failed", err, 1); else ava1_apply_fail(j, AVA1_STATUS_OK, "", 0, 1); diff --git a/payload/ava1/ava1_apply.h b/payload/ava1/ava1_apply.h index 5e6a8e15..564e85b7 100644 --- a/payload/ava1/ava1_apply.h +++ b/payload/ava1/ava1_apply.h @@ -48,6 +48,8 @@ int ava1_apply_root(ava1_job_t *j, uint32_t file_id, const uint8_t root[32]); /* Runs fn(j, arg, 0..n-1) on the workers and waits for all of them. */ int ava1_apply_parallel(ava1_job_t *j, void (*fn)(ava1_job_t *, void *, uint32_t), void *arg, uint32_t n); void ava1_apply_path(const ava1_job_t *j, uint32_t id, int part, char *out, size_t cap); +/* sce_sys/param.json or sce_sys/param.sfo at any depth: written as .ava-hold, placed at the end. */ +int ava1_apply_held_rel(const char *rel); /* Ends the job once (also the success path, status AVA1_STATUS_OK): journal Done when * asked, then JobDone. */ /* The only way to append to a job's journal once workers run (they append commits): the diff --git a/payload/ava1/ava1_data.c b/payload/ava1/ava1_data.c index a3deef44..6db7ff1c 100644 --- a/payload/ava1/ava1_data.c +++ b/payload/ava1/ava1_data.c @@ -1569,6 +1569,16 @@ static int route(const uint8_t sid[16], const uint8_t peer[32], uint8_t type, co if (mine) inbox_add(j, type, body, len); break; } + case AVA1_TYPE_SKIP_PROGRESS: { + /* SPEC.md §12.8: the sender is reading past data this job already holds (a solid + * archive decoded from its start on resume). It is progress for the watchdog, and + * nothing else: no inbox entry, no reply. Only from the session the job is + * attached to. */ + pthread_mutex_lock(&j->cmu); + if (j->attached && memcmp(j->sid, sid, 16) == 0) j->frames_in++; + pthread_mutex_unlock(&j->cmu); + break; + } default: break; /* nothing else is for a receiver */ } @@ -1585,6 +1595,7 @@ static int well_formed(uint8_t type, const uint8_t *body, size_t len) { ava1_resume_t r; ava1_file_root_t f; ava1_job_cancel_t c; + ava1_skip_progress_t s; } u; switch (type) { case AVA1_TYPE_JOB_OPEN: return ava1_job_open_decode(body, len, &u.o) == 0; @@ -1593,6 +1604,7 @@ static int well_formed(uint8_t type, const uint8_t *body, size_t len) { case AVA1_TYPE_RESUME: return ava1_resume_decode(body, len, &u.r) == 0; case AVA1_TYPE_FILE_ROOT: return ava1_file_root_decode(body, len, &u.f) == 0; case AVA1_TYPE_JOB_CANCEL: return ava1_job_cancel_decode(body, len, &u.c) == 0; + case AVA1_TYPE_SKIP_PROGRESS: return ava1_skip_progress_decode(body, len, &u.s) == 0; default: return 1; } } diff --git a/payload/ava1/gen/ava1_gen.c b/payload/ava1/gen/ava1_gen.c index 020fbd8d..0733b1fb 100644 --- a/payload/ava1/gen/ava1_gen.c +++ b/payload/ava1/gen/ava1_gen.c @@ -3898,6 +3898,30 @@ int ava1_job_cancel_decode(const uint8_t *buf, size_t len, ava1_job_cancel_t *m) return ava1_r_finish(&r); } +int ava1_skip_progress_encode(const ava1_skip_progress_t *m, ava1_w_t *w) { + ava1_w_fixed(w, m->job_id, 16); + ava1_w_u64(w, m->bytes_skipped); + ava1_w_u16(w, 0); + return w->err; +} + +int ava1_skip_progress_decode(const uint8_t *buf, size_t len, ava1_skip_progress_t *m) { + ava1_r_t r; + uint16_t ext_n, i; + memset(m, 0, sizeof(*m)); + ava1_r_init(&r, buf, len); + ava1_r_fixed(&r, m->job_id, 16); + m->bytes_skipped = ava1_r_u64(&r); + ext_n = ava1_r_u16(&r); + for (i = 0; i < ext_n && !r.err; i++) { + uint32_t vlen; + (void)ava1_r_u16(&r); + vlen = ava1_r_u32(&r); + (void)ava1_r_take(&r, vlen); + } + return ava1_r_finish(&r); +} + const char *const ava1_message_names[] = { "NodeInfo", "HelloInfo", @@ -3975,8 +3999,9 @@ const char *const ava1_message_names[] = { "Status", "JobDone", "JobCancel", + "SkipProgress", }; -const size_t ava1_message_count = 76; +const size_t ava1_message_count = 77; int ava1_roundtrip(const char *name, const uint8_t *in, size_t in_len, uint8_t *out, size_t cap, size_t *out_len) { @@ -4364,6 +4389,11 @@ int ava1_roundtrip(const char *name, const uint8_t *in, size_t in_len, uint8_t * rc = ava1_job_cancel_decode(in, in_len, &m); if (rc == 0) rc = ava1_job_cancel_encode(&m, &w); } + else if (strcmp(name, "SkipProgress") == 0) { + ava1_skip_progress_t m; + rc = ava1_skip_progress_decode(in, in_len, &m); + if (rc == 0) rc = ava1_skip_progress_encode(&m, &w); + } else { return AVA1_E_PROTO; } diff --git a/payload/ava1/gen/ava1_gen.h b/payload/ava1/gen/ava1_gen.h index 354e78f7..5ed8bc56 100644 --- a/payload/ava1/gen/ava1_gen.h +++ b/payload/ava1/gen/ava1_gen.h @@ -224,6 +224,7 @@ #define AVA1_TYPE_STATUS 0x2du #define AVA1_TYPE_JOB_DONE 0x2eu #define AVA1_TYPE_JOB_CANCEL 0x2fu +#define AVA1_TYPE_SKIP_PROGRESS 0x30u typedef struct { const uint8_t *version; @@ -1221,6 +1222,14 @@ typedef struct { int ava1_job_cancel_encode(const ava1_job_cancel_t *m, ava1_w_t *w); int ava1_job_cancel_decode(const uint8_t *buf, size_t len, ava1_job_cancel_t *m); +typedef struct { + uint8_t job_id[16]; + uint64_t bytes_skipped; +} ava1_skip_progress_t; + +int ava1_skip_progress_encode(const ava1_skip_progress_t *m, ava1_w_t *w); +int ava1_skip_progress_decode(const uint8_t *buf, size_t len, ava1_skip_progress_t *m); + extern const char *const ava1_message_names[]; extern const size_t ava1_message_count; diff --git a/payload/include/cross_device.h b/payload/include/cross_device.h index 62a0d9cd..971f7809 100644 --- a/payload/include/cross_device.h +++ b/payload/include/cross_device.h @@ -41,7 +41,8 @@ * src/register.c 1 dir param.json: tmp -> final * src/shell_builtin.c 1 guard `mv` (mv_same_dev, lstat of the source) * ava1/ava1_events.c 1 dir events.log -> events.log.old - * ava1/ava1_apply.c 2 guard file and folder commit (same_device hook, only a definite 1 renames) + * ava1/ava1_apply.c 3 guard file and folder commit (same_device hook, only a definite 1 renames); + * held param.json/param.sfo: name.ava-hold -> name, same dir * ava1/ava1_journal.c 1 dir journal: tmp -> final * ava1/ava1_store.c 1 dir key and trust stores: tmp -> final * ava1/ava1_recv.c 2 dir outboard renames inside the job folder diff --git a/protocol/ava1/SPEC.md b/protocol/ava1/SPEC.md index 61e6c07e..1320f037 100644 --- a/protocol/ava1/SPEC.md +++ b/protocol/ava1/SPEC.md @@ -759,6 +759,7 @@ peers for the job (§11.3), not who opened it. | 0x2D | `Status` | receiver → sender (IGNORABLE) | control | | 0x2E | `JobDone{status, files, bytes}` | receiver → sender | control | | 0x2F | `JobCancel` | either | control | +| 0x30 | `SkipProgress{bytes_skipped}` | sender → receiver | control | `FileRetry.reason` is `RETRY_VERIFY` (1, the root did not match), `RETRY_IO` (2, the receiver lost the part file or outboard) or `RETRY_CHANGED` (3, the record's length disagrees with the manifest). @@ -869,6 +870,14 @@ and fails the job (the engine's session retry resumes it); the console ends it w status `ERR_STALLED` and keeps the journal, as for every console-side failure. The sender observes the code like any receiver-ended job and may resume. +`SkipProgress{job_id, bytes_skipped}` (0x30) is job-scoped liveness. A sender that reads past +data the receiver already holds (a solid archive decoded from its start on resume, §17.2, or the +durable stretches of a partly held large file) sends no data frame while it does; it sends a +`SkipProgress` at least every 10 s instead (the engine: every 5 s while its skip counter moves). +`bytes_skipped` is the running total the sender has read past for this job (informational). A +receiver counts it as progress for that job, only from the session the job is attached to, and +answers nothing. Receivers that predate it ignore it as an unknown data-plane type. + ## 13. Verification 13.1 A file's root is its standard BLAKE3 hash. Files are hashed in groups of 1 MiB diff --git a/protocol/ava1/schema/ava1.toml b/protocol/ava1/schema/ava1.toml index ec809118..abf295f7 100644 --- a/protocol/ava1/schema/ava1.toml +++ b/protocol/ava1/schema/ava1.toml @@ -595,6 +595,14 @@ name = "JobCancel" type = 0x2F fields = [{ name = "job_id", ty = "b16" }, { name = "reason", ty = "u16" }] +# Job-scoped liveness (SPEC.md §12.8): a sender reading past data the receiver already +# holds (a solid archive decoded from its start on resume) sends this at least every +# 10 s; the receiver counts it as progress for that job. Older receivers ignore it. +[[message]] +name = "SkipProgress" +type = 0x30 +fields = [{ name = "job_id", ty = "b16" }, { name = "bytes_skipped", ty = "u64" }] + # RPC bodies (SPEC.md §16) [[struct]] name = "JobCopy"