diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 8a96538a..712c591b 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -27,6 +27,7 @@
If yes, which files:
- [ ] README.md
+- [ ] CHANGELOG.md
- [ ] TESTING.md
- [ ] FAQ.md
- [ ] bench/README.md
@@ -38,8 +39,8 @@ If yes, which files:
## Testing
-- [ ] `npm run validate`
-- [ ] `npm run coverage`
+- [ ] `npm run validate` (CI also needs `cargo fmt --check` in `engine/` AND `client/src-tauri`)
+- [ ] `cargo test -p ava1-ctest -- --test-threads=1` (in `engine/`), `make test-payload`, and the retired-protocol check (see CONTRIBUTING.md)
- [ ] Built PS5 payload successfully
- [ ] Cross-platform CI target checks passed
- [ ] Tested on real PS5 hardware
diff --git a/.github/workflows/engine-ci.yml b/.github/workflows/engine-ci.yml
index 52034886..854d3db6 100644
--- a/.github/workflows/engine-ci.yml
+++ b/.github/workflows/engine-ci.yml
@@ -104,8 +104,10 @@ jobs:
working-directory: engine
run: cargo check -p ps5upload-engine --features webui
+ # Bounded per step: a hung test fails here in minutes instead of holding the job.
- name: cargo test
working-directory: engine
+ timeout-minutes: 25
env:
RUST_BACKTRACE: 1
run: cargo test --workspace --exclude ava1-ctest -- --nocapture
@@ -114,6 +116,7 @@ jobs:
# runs one test at a time so a test that holds the server cannot starve the others.
- name: cargo test (ava1 C interop, serial)
working-directory: engine
+ timeout-minutes: 25
env:
RUST_BACKTRACE: 1
run: cargo test -p ava1-ctest -- --test-threads=1
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa6add29..05497ea4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,75 +6,43 @@ What's new in ps5upload, written for humans.
## 6.0.0: one encrypted connection for everything (AVA1)
-**Update the app and the helper together.** 6.0 talks to the console over a new protocol,
-AVA1, on a single port: **9120**. The old ports 9113 and 9114 are gone. A 6.0 app cannot use a
-v5.41 helper and a v5.41 app cannot use the 6.0 helper; the app offers **Update the helper**
-and replaces an old one in one click. If you allow ports by hand, you now need only **9120**
-and **9021** (your ELF loader).
+**Update the app and the helper together.** 6.0 uses a new protocol, AVA1, on one port: **9120**.
+Ports 9113 and 9114 are gone, so allow only **9120** and **9021** (your ELF loader) in a
+firewall. A 6.0 app cannot use a v5.41 helper (or the reverse); the app offers **Update the
+helper** and replaces an old one in one click.
-**What changes for you**
-- **Pairing.** A helper the app launched pairs by itself. One you loaded another way shows a
- **6-digit code** on the console; confirm it once and the computer is remembered.
-- **Uploads survive drops and helper restarts.** A dropped connection, a helper restart or rest
- mode no longer ends the job: the app waits for the console to come back and continues from what
- is already safely on the console.
-- **Out of space is said up front.** A job that will not fit is refused in seconds, before anything
- is sent, with the shortfall in GB. A retry into the same folder counts what is already there,
- so it no longer "needs the whole folder again" (#365). Two uploads into room for one: the second
- is refused at once. Volumes shows how much the console keeps for itself.
+- **Pairing:** a helper the app launched pairs by itself; otherwise confirm the 6-digit code
+ shown on the console once.
+- **Uploads resume** after a dropped connection, a helper restart or rest mode, from what is
+ already on the console.
+- **Out of space is said up front:** a job that will not fit is refused in seconds, and a retry
+ counts what is already there (#365).
- **Convert reads games from the console** without ftpsrv (#351).
-- **7z and RAR uploads resume**; a RAR with several packages is unpacked straight to the console and
- each package installed in turn (base, then patch, then DLC). Zip entries over 256 MiB stream;
- zip downloads resume.
-- **Links:** a link that serves a .pkg installs, even without ".pkg" in the URL; any other real
- file can be downloaded to a console folder.
-- **Progress everywhere:** copy/paste and Add files show progress and ETA, and "Finishing on the
- console" shows files left and an ETA. **Cancel copy** stops a console copy and cleans up only
- what it created.
-- **Web UI:** an upload keeps running when you close the tab; reopening shows it. Only one tab runs
- the queue.
-- **Installs:** a package the PS5 refuses from its own storage offers **Retry with Stream** instead
- of repeating the same route. On Windows, a stream install that cannot reach the computer now
- names the network adapter and whether Windows treats it as Public, with one-click fixes.
-- **Cheats:** MC4 files list and toggle again; game names, filters and a load notification (#373).
-- **Fan curve:** sending a curve no longer runs the fans at 100% from the first point (#354);
- "Restore default" returns to the stock behaviour.
-- **Remote Play:** pairing shows "waiting" with a countdown until a device really pairs, and
- Cancel works from the web UI.
-- **Persian** translation (thanks to mrnpc336), NixOS install docs (GriefNorth), Docker
- `PS5UPLOAD_BROWSE_ROOTS` (nopoz), queue size chip (intelp917-crypto), Windows sleep after a
- transfer fixed (lowbit, #360).
-- **Also fixed:** Windows installer updates with the installer (#348); installed game files are no
- longer offered as packages (#349); Docker/NAS "Permission denied" names the folder and the fix
- (#361); Windows fpkg output path (#364); macOS 11 white screen (#352); the payload builds
- with SDK v0.43 for FW 13.60 (#341).
-- **Removed:** the "Parallel upload streams" setting (AVA1 picks lanes itself).
+- **7z and RAR uploads resume;** a multi-package RAR installs base, then patch, then DLC.
+- **Links:** a link that serves a .pkg installs, and any other file can be downloaded to a console folder.
+- **Progress and cancel** for copy, paste and Add files; the web UI keeps an upload running when
+ the tab closes.
+- **Installs:** a package the PS5 refuses from its own storage offers **Retry with Stream**.
+- **Fixed:** cheats toggling and names (#373), fan curve at 100% (#354), Windows sleep after a
+ transfer (#360), Windows installer updates (#348), game files offered as packages (#349),
+ Docker "Permission denied" (#361), fpkg output path (#364), macOS 11 white screen (#352),
+ payload build for FW 13.60 (#341).
+- **Removed:** the "Parallel upload streams" setting; AVA1 picks lanes itself.
+- **Thanks** to mrnpc336 (Persian), GriefNorth (NixOS docs), nopoz (`PS5UPLOAD_BROWSE_ROOTS`),
+ intelp917-crypto (queue size chip) and lowbit (#360).
-**Measured on two PS5s (Pro and Phat, FW 13.60, gigabit), AVA1 vs 5.41**
-
-| Test | 6.0 (AVA1) | 5.41 |
+| Measured on two PS5s, gigabit | 6.0 | 5.41 |
|---|---|---|
| 4 GiB file, internal SSD | 105-108 MB/s | 108-111 MB/s |
-| Real game (Minecraft Legends, 7 GiB, 1,018 files) | **108 MB/s** | 88 MB/s |
-| Resume after the helper is killed mid-upload | **94-97 MB/s** | 66-68 MB/s |
-| Connection cut every 10 s, 4 GiB | **97-101 MB/s** | not run |
-| 2,000 tiny files to a USB drive | **446 files/s** | 330 files/s |
-| 2,000 tiny files to the internal SSD | 192-235 files/s | 260-327 files/s |
-| 2,000 tiny files download (Pro) | **2,856 files/s** | 2,409 files/s |
+| Real game, 7 GiB, 1,018 files | **108 MB/s** | 88 MB/s |
+| Resume after helper killed | **94-97 MB/s** | 66-68 MB/s |
+| 2,000 tiny files to USB | **446 files/s** | 330 files/s |
+| 2,000 tiny files to internal SSD | 192-235 files/s | 260-327 files/s |
-Large files run at the gigabit limit either way. Tiny files to the internal SSD are slower
-because 6.0 makes each file durable before calling it done; 5.41 acknowledged earlier. That gap
-is being worked on.
+**Environment variables:** `FTX2_*` settings are now `PS5UPLOAD_*` (old names work for this release only).
-**Environment variables:** `FTX2_BANDWIDTH_MBPS`, `FTX2_ZIP_RAM_THRESHOLD_MB` and
-`FTX2_ARCHIVE_STAGE_MB` are now `PS5UPLOAD_*`; the old names work for this release only.
-`PS5UPLOAD_TRANSFER` is gone.
-
-**Known limitations**
-- Tiny-file uploads to the internal SSD are slower than 5.41 (above).
-- A file larger than the space the console really has left on its internal drive should now fail
- in the first seconds; confirming that on a nearly full drive is still to do.
-- The compressed (deflate) zip download cannot resume; the default stored zip can.
+**Known limitations:** tiny files to the internal SSD are slower than 5.41; the compressed zip
+download cannot resume.
## 5.41.0
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6f045498..3f891199 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -12,7 +12,7 @@ same way.
3. Make your change, keeping it focused — one logical change per PR.
4. **Run the quality gate locally** (see below) until it's green.
5. Open a **pull request** against `phantomptr/ps5upload:main` and fill out
- the PR template (`.github/PULL_REQUEST_TEMPLATE.md`).
+ the PR template.
Every PR is gated by CI (the **`PR gate`** check) and reviewed by the code
owner before it can merge. PRs are squash-merged, so your branch becomes a
@@ -29,28 +29,34 @@ make run-client # launch the Tauri dev app
See the [README](README.md) and [TESTING.md](TESTING.md) for the full
toolchain (PS5 Payload SDK, Rust, Node 22, Tauri prerequisites).
-## Quality gate — run before opening a PR
+## Quality gate: run before opening a PR
-CI runs the same checks; running them locally first saves a round-trip:
+CI runs the same checks; running them locally first saves a round-trip.
+`npm run validate` runs most of it in one command (version sync, script checks, i18n,
+engine and desktop fmt/clippy/tests, client typecheck/lint/tests/build). The complete gate is
+these, and `cargo fmt --check` must pass in BOTH `engine/` and `client/src-tauri`:
```bash
-npm run validate # lints + unit/integration tests + typecheck + i18n + build
+( cd engine && cargo fmt --all -- --check ) # fmt in BOTH workspaces
+( cd client/src-tauri && cargo fmt --all -- --check )
+( cd engine && cargo clippy --workspace -- -D warnings )
+( cd engine && cargo test --workspace )
+( cd engine && cargo test -p ava1-ctest -- --test-threads=1 )
+make test-payload # needs PS5_PAYLOAD_SDK
+make check-no-ftx2 # the retired protocol must stay gone
+( cd client && npm run lint && npm run typecheck && npm test && npm run build:vite )
+npm run i18n:check # from the repo root
```
-`npm run validate` covers, across all three layers:
-
-- **Lints** — `cargo fmt`/`clippy` (engine + Tauri shell), ESLint, `tsc`,
- script syntax/audit, and the i18n coverage gate.
-- **Unit + integration tests** — `cargo test --workspace` (protocol, core,
- engine, pkg, ava1, and the loopback AVA1 integration tests that run the
- payload's C on the host — no PS5 required),
- the Tauri shell tests, and the client Vitest suite.
+`ava1-ctest` builds the payload's AVA1 C on the host and tests it against the Rust side, so no
+PS5 is needed. `check-no-ftx2` fails if the old protocol name or ports 9113/9114 reappear
+outside the changelog and a short list of exceptions in the Makefile.
Other useful targets:
```bash
make test # script + engine + payload + client checks
-npm run validate:full # adds the payload ELF build (needs PS5_PAYLOAD_SDK)
+npm run validate:full # adds the payload ELF build and self-tests
npm run coverage # frontend + Rust coverage reports
```
diff --git a/FAQ.md b/FAQ.md
index f43a23f2..2304197e 100644
--- a/FAQ.md
+++ b/FAQ.md
@@ -32,57 +32,25 @@ If those terms aren't acceptable, do not use this software.
## What ps5upload does (and doesn't)
**Q: What is ps5upload?**
-A cross-platform desktop app for moving files, game folders, and
-disk images from your computer to a jailbroken PS5. Built around a
-small custom payload that runs on the PS5 and speaks its own
-encrypted transfer protocol (AVA1) over your LAN.
+An app (desktop, Android, or a self-hosted web UI) for moving games, apps and homebrew to a
+jailbroken PS5 and managing the console. A small payload on the PS5, the **helper**, talks to
+the app over **AVA1**, one encrypted connection on port 9120.
**Q: What does it actually do?**
-- **Transfer** files and folders at near-wire speed, with BLAKE3
- verification and resume on drop.
-- **Upload a compressed archive** — `.zip`, `.7z`, or `.rar` — of a
- game, decompressed on your PC and streamed in so it lands already
- extracted on the PS5 (no manual unpack, no temp copy of the whole
- game). RAR handles **multi-part sets** (pick the first part — the
- rest are found automatically) and **password-protected** archives.
- (RAR is desktop-only; Android uploads `.zip` and `.7z`.)
-- **Mount** `.exfat` and `.ffpkg` disk images natively on the PS5
- (via MDIOCATTACH + nmount). No third-party tool required.
-- **Install fakepkgs** — pick a `.pkg` and it uploads, installs, and
- (optionally) cleans up the staged copy. On the Upload tab a `.pkg`
- is a first-class queue item, so it rides the same queue as your
- folders, images, and archives — add it and it ends up playable.
- Or **Stream install** it straight from your PC with no copy on the
- PS5. Installs go through ps5upload's own on-console installer;
- verified with PS4 base games, updates and DLC and PS5 fake packages
- on FW 5.10.
-- **Register + launch** in the XMB — Library row's Play button
- always registers first (idempotent), retries with a DRM-type
- patch on rejection, then launches. Unmount unregisters every
- title inside the image first so the dashboard stays clean.
-- **Browse** games installed on the PS5 and disk images anywhere on
- any drive.
-- **File System** navigation with chmod / delete / move / copy /
- mkdir.
-- **Hardware** monitoring (model, serial, uptime, RAM, CPU freq)
- and fan-threshold control.
-- **Send payload** — push `.elf`, `.bin`, `.js`, `.lua`, or `.jar`
- files to the PS5's loader port (typical defaults: `.elf` → 9021,
- `.js` → 50000, `.lua` → 9026, `.jar` → 9025 for BD-JB / BDJ), with
- a replay-from-history panel.
-- **SMB / NAS** — browse a share and upload a file or folder to the
- PS5 in one step.
-- **FTP server** — optional on-console FTP for external clients
- (port 2122 by default).
+- **Upload** files, folders, disk images and `.zip` / `.7z` / `.rar` archives, verified end to
+ end. Uploads resume after a dropped connection or a helper restart. RAR handles multi-part and
+ password-protected archives (desktop only).
+- **Install packages**: PS4 `.pkg` and PS5 fake packages (base, update, DLC) through ps5upload's
+ own on-console installer, from your computer (**Stream & install** or **Upload & install**),
+ a NAS, a USB drive or a link.
+- **Convert to FPKG**: build an installable package from a decrypted game folder or an image.
+- **Mount** `.exfat` / `.ffpkg` images, **register and launch** games, browse and manage files.
+- **Cheats, fan curve, hardware view, power and wake, payload sender, backport tools,** and an
+ optional FTP server on the PS5.
**Q: What does it NOT do?**
-Install **system pkgs** (NPXS-prefix — Store updates, Settings app
-patches, built-in apps). Sony's `sceAppInstUtilInstallByPackage` is
-designed for game pkgs; for NPXS the register call is accepted but
-the install path freezes the PS5's mgmt service mid-flight. Use the
-on-PS5 Settings → Debug Settings → Game → Package Installer for
-those — that path is privileged in a way ps5upload can't replicate.
-Game pkgs (UP / EP / JP / HP / CUSA / PPSA / PCSA / etc.) work fine.
+Install **system packages** (NPXS Store or Settings updates): the PS5 freezes its installer
+service on them. Use Settings → Debug Settings → Game → Package Installer on the console.
---
@@ -92,25 +60,14 @@ New to PS5 homebrew? Read this section first. Everything here is covered in
more depth further down.
**Q: What are the pieces, in plain words?**
-- **Jailbreak and ELF loader.** Your console has to be jailbroken before
- anything else works. The jailbreak leaves an *ELF loader* listening on
- port **9021**; every payload below is sent to that port. ps5upload does not
- jailbreak your console and cannot load without a loader.
-- **kstuff.** A payload that applies live kernel patches. Installing fake
- packages and launching what you installed need it (see "Which PS5 firmware
- works?" above).
-- **ShadowMount+.** A payload that automatically mounts game images
- (`.ffpkg` / `.exfat`) and game folders it finds on your drives, so they show
- up on the home screen. Disc-image titles need it running.
-- **etaHEN.** A separate, popular homebrew environment for the PS5. If you
- already run it, you may not need to load other payloads by hand: ps5upload
- works with a console that etaHEN has already prepared. Check etaHEN's own
- documentation for what your version includes, because we do not track that.
- If something you already run loads kstuff for you, send only ps5upload;
- loading kstuff a second time stacks another copy.
-- **ps5upload helper.** ps5upload's own payload (`ps5upload.elf`) that runs on
- the console and does the transfers. It is the `helper` dot at the bottom of
- the app. The app sends it for you.
+- **Jailbreak and ELF loader.** The jailbreak leaves an *ELF loader* on port **9021**; every
+ payload is sent to it. ps5upload does not jailbreak your console.
+- **kstuff.** Live kernel patches. Installing fake packages and launching them needs it.
+- **ShadowMount+.** Auto-mounts game images and folders so they appear on the home screen.
+- **etaHEN.** A separate homebrew environment. If it already loads kstuff for you, send only
+ ps5upload; a second kstuff stacks another copy.
+- **ps5upload helper.** Our payload (`ps5upload.elf`), the `helper` dot at the bottom of the
+ app. The app sends it for you.
**Q: In what order do I load payloads?**
Send **elfldr** first (if your setup uses it), then **kstuff**, then
@@ -120,54 +77,31 @@ recommends. Send them one at a time; the loader takes one file per
connection. After a reboot or rest mode the payloads are gone, so load them
again.
-**Q: Which payloads must already be running before I install a package?**
-The maintainers' own checklist, before any package install, is that all four
-of these are up: **nanoDNS** (`nanodns.elf`), **kstuff** (`kstuff.elf`),
-**ftpsrv-ps5** (`ftpsrv-ps5.elf`) and **ShadowMount+**
-(`shadowmountplus.elf`), plus ps5upload itself. nanoDNS and ftpsrv-ps5 are not
-part of the Setup wizard; send them from the **Payloads** tab. ps5upload's own
-transfers do not use ftpsrv-ps5 (they use the helper on port 9120). If an
-install is refused, the first thing to check is that kstuff is actually
-running.
-
-**Q: Where do I get etaHEN and its toolbox?**
-From the etaHEN project's releases on GitHub (`github.com/etaHEN/etaHEN`).
-Download from there rather than from a re-upload, and follow that project's
-install instructions. The toolbox, and what else it ships, is described there;
-ps5upload does not bundle or install etaHEN.
+**Q: Which payloads must be running before I install a package?**
+**nanoDNS**, **kstuff**, **ftpsrv-ps5** and **ShadowMount+**, plus the ps5upload helper. nanoDNS
+and ftpsrv-ps5 are not in the Setup wizard; send them from **Payloads**. ps5upload's own
+transfers and Convert do not use ftpsrv-ps5. If an install is refused, first check that kstuff
+is running.
**Q: Which firmware limits apply to what I install?**
-Users run everything from 5.x to **13.60**. Two rules matter:
-- **PS5 fake *game* packages install on firmware above 11.60 (including 13.60)
- but are not playable.** PS4 fake packages work, and PS5 homebrew apps launch.
- The app warns you about this combination. See "My uploaded game won't
- launch" below for the folder-dump route.
-- On **13.60**, Stream & install is the dependable install route.
-
-**Q: Where do I get the Android app?**
-Download `PS5Upload--android.apk` from the
-[Releases page](https://github.com/phantomptr/ps5upload/releases) and open it
-on your phone (see **Android** above for the permissions it needs).
+- **PS5 fake *game* packages install above firmware 11.60 (including 13.60) but are not
+ playable.** PS4 fake packages work and PS5 homebrew apps launch. The app warns you. See "My
+ uploaded game won't launch" for the folder-dump route.
+- On **13.60**, Stream & install is the dependable route.
**Q: Quick start: from a fresh jailbreak to my first upload.**
-1. Jailbreak the console and confirm its ELF loader is running (port 9021).
- Put the console on the same network as your computer, and note its IP
- address (PS5 **Settings → Network → View Connection Status**).
-2. Install ps5upload (or open the web UI) on your computer. Use a wired
- connection if you can.
-3. Open **Connection**, enter the console's IP, and click **Check**. If the
- loader port is not open, the jailbreak or loader is not running yet.
-4. Run **Set up your PS5**. It downloads kstuff and ShadowMount+ and sends
- them, then ps5upload, in the right order. If you already run kstuff (for
- example through etaHEN), send only ps5upload.
-5. Wait for the `helper` dot to turn green. If a code appears on the console
- (helper loaded by another tool), confirm it in the app to pair.
-6. Open **Upload**, choose your game folder, `.zip` or file, pick the
- destination drive (internal, M.2 or USB) and click **Start**. The free-space
- check runs before anything is sent.
-7. Open **Library** to see it. For a `.pkg` or `.fpkg`, use **Install
- Package → Stream & install** instead. If it will not launch, see "My
- uploaded game won't launch" below.
+1. Confirm the console's ELF loader is running (port 9021) and note the console's IP
+ (**Settings → Network → View Connection Status**).
+2. Install ps5upload (or open the web UI). Use a wired connection if you can.
+3. Open **Connection**, enter the IP, click **Check**.
+4. Run **Set up your PS5**: it sends kstuff and ShadowMount+, then the helper, in order. If
+ something already loads kstuff, send only ps5upload.
+5. Wait for the `helper` dot to turn green. If a 6-digit code appears on the console (helper
+ loaded by another tool), enter it in the app once to pair.
+6. Open **Upload**, choose your game, pick the drive and click **Start**. The free-space check
+ runs before anything is sent.
+7. Open **Library** to see it. For a `.pkg`, use **Install Package → Stream & install**. If it
+ will not launch, see "My uploaded game won't launch".
---
@@ -194,25 +128,12 @@ on your phone (see **Android** above for the permissions it needs).
There is no 32-bit x86 (i386 / i686) build for any desktop OS.
**Q: Which PS5 firmware works?**
-ps5upload is built against PS5 Payload SDK v0.43, which resolves
-kernel offsets at startup for every firmware it knows about. The
-same binary runs on the full range **1.00 – 13.60**.
+The helper is built against PS5 Payload SDK v0.43 and the same binary runs on **1.00 to 13.60**.
+Tested on 5.10, 9.60 and 13.60 (and confirmed by users on 12.20). In practice the limit is the
+**ELF loader** on port 9021, a third-party component; loader coverage is roughly 4.x to 12.x.
-- **Hardware-tested:** FW 5.10 and 9.60 (in-house) and FW 12.20
- (user-confirmed). Core features — transfer, mount, file browse,
- hardware monitor, and `.pkg` install — work across this range.
-- **1.00 – 13.60 supported.** The same binary runs everywhere the SDK
- has offsets. The Hardware tab reads process names from `kinfo_proc`,
- so it does not require a separate firmware-specific fallback table.
-- **`.pkg` install** additionally needs the console's jailbreak to have
- live kernel patches (kstuff / fpkg-enable). Where those aren't active
- (observed on some 9.60 setups), the installer says so honestly instead
- of reporting a false success — it never claims a game installed when
- the content didn't actually land.
-
-What actually gates users in practice is the PS5-side **ELF loader**
-on port 9021 — a third-party component, not part of ps5upload. Loader
-coverage is roughly 4.x–12.x today.
+`.pkg` install also needs live kernel patches (kstuff / fpkg-enable). Where they are not active,
+the installer says so instead of reporting a false success.
**Q: Which PS5 models are supported?**
All models: original CFI-1xxx, Digital, Slim (CFI-2xxx), and Pro
@@ -224,59 +145,27 @@ on every one.
## Android
**Q: How do I install the Android app?**
-Download `PS5Upload--android.apk` from the Releases page and open
-it on your phone. Android will ask you to allow installing from your
-browser / file manager the first time — that's normal for a sideloaded
-app. Updates install in place and keep your settings (builds are signed
-with a stable key from v2.20.0 on). If an older build refuses to
-update, uninstall it once, then install v2.20.0 or newer.
+Download `PS5Upload--android.apk` from the Releases page and open it. Android asks you to
+allow installs from your browser or file manager the first time. Updates install in place.
-**Q: The app's text/buttons are huge (or tiny) on my phone and names get
-cut off. (3.2.0+)**
-This is your phone's **Display size** (or Font size) accessibility setting
-scaling the whole app up. Open **Settings → Text size** in ps5upload and pick a
-smaller percentage — it rescales the entire interface so list rows stop getting
-clipped to "ps5-image…". It works on desktop too, and persists.
+**Q: The app's text or buttons are huge (or tiny) on my phone.**
+Your phone's Display size or Font size setting scales the whole app. Open **Settings → Text
+size** in ps5upload and pick a smaller percentage.
-**Q: I picked a game folder, a `.zip`, or a `.pkg`, but it fails — "could
-not read", "No such file or directory", "not an ELF", or it just won't
-send / install.**
-Android needs **All files access** before the app can read your files.
-This is the single most common Android issue.
-- On **v2.20.0+** the app asks for it: when you tap **Choose folder**,
- **Choose file**, or **Add .pkg**, an in-app file browser opens. The
- first time, tap **Open settings**, turn on **"Allow access to manage
- all files"** for PS5Upload, come back, and tap **Retry**. Then browse
- your storage and pick the file/folder.
-- On **older builds (≤ v2.19.x)** the app used Android's system file
- picker, which only returns a `content://` link the engine can't open —
- that's exactly why loading failed. **Update to v2.20.0+.**
-- You can grant it any time: **Android Settings → Apps → PS5Upload →
- Permissions → All files access → Allow** (the app also nudges you on
- first launch).
+**Q: I picked a folder, `.zip` or `.pkg` on Android but it fails ("could not read", "No such file").**
+Android needs **All files access** before the app can read your files. Tap **Choose folder**,
+**Choose file** or **Add .pkg**, tap **Open settings**, turn on **Allow access to manage all
+files** for PS5Upload, come back and tap **Retry**. You can also grant it in **Android Settings
+→ Apps → PS5Upload → Permissions → All files access**.
-**Q: How do I upload a big game (a folder or a `.zip`) from my phone?**
-Open **Upload → Choose folder** (or **Choose file** for a `.zip` dump),
-pick it in the in-app browser, choose the destination, and **Start**.
-With All-files access the engine reads the file in place — nothing is
-copied — so a multi-GB / tens-of-thousands-of-files game streams
-straight to the PS5 over Wi-Fi.
-
-**Q: "ShadowMount+ — downloaded asset is not an ELF (first bytes
-[50, 4b, 03, 04])."**
-Fixed in **v2.19.3+**. ShadowMount+ now ships its payload inside a
-`.zip`; the app extracts the real `.elf` automatically. Update, then
-**Send to PS5** (with the payload chain loaded — see the next answer).
-`50 4b 03 04` is just the "PK" signature of a zip file.
+**Q: How do I upload a big game from my phone?**
+**Upload → Choose folder** (or **Choose file** for a `.zip`), pick it in the in-app browser,
+choose the destination and **Start**. The file is read in place, nothing is copied first.
**Q: The `helper` dot at the bottom of the app is red.**
-That dot is the **PS5-side `ps5upload` payload**, not the app itself.
-Red means it isn't running on the console yet, so Install Package,
-mounts, and file-system actions can't complete. Load the recommended
-chain first — **Set up your PS5**, or the **Payloads** tab (kstuff →
-ShadowMount+ → ps5upload). Once the payload is running, `helper` turns
-green. The other dot, `engine`, is the app's own service and is green
-whenever the app is open.
+The dot is the PS5-side helper, not the app. Red means it is not running (or is an older version
+that needs **Update helper**). Run **Set up your PS5** or send it from the **Payloads** tab. The
+`engine` dot is the app's own service and is green whenever the app is open.
---
@@ -680,10 +569,7 @@ and every transfer afterwards goes over the direct cable.
want them; ps5upload remembers the last-used IP so re-pointing it
at the WiFi IP afterwards is a one-line change in Settings.
- **PS5 Ethernet port is gigabit** — practical sustained ceiling is
- around 110–118 MiB/s for huge files. The 2.16.0 multi-file
- `posix_fallocate` fix is what keeps multi-GB transfers from
- collapsing under that ceiling; before that fix, throughput
- degraded the longer a single file ran.
+ around 110–118 MiB/s for huge files.
- **2.5 GbE / 10 GbE adapters on the computer** are fine and still
negotiate to 1 Gbps because the PS5 is the slowest link. The
extra headroom helps if you ever swap consoles.
@@ -692,19 +578,16 @@ and every transfer afterwards goes over the direct cable.
them different IPs so the routing table doesn't get confused. The
`192.168.88.x` range above sidesteps any conflict with the common
`192.168.0/1.x` home subnets.
-- **Resuming an interrupted upload still works** — if you start an
- upload over the direct cable, swap to WiFi mid-way (PS5 picks up
- a different IP), the engine's resume-by-tx_id flow lets you
- re-target the WiFi IP and pick up where it left off.
+- **Resuming still works.** If you start an upload over the cable and switch to Wi-Fi (a new
+ PS5 IP), start the same upload again to the new IP and it continues from what is on the console.
---
## Getting started
-**Q: Do I need the payload?**
-Yes. The PS5 has to be running `ps5upload.elf` before the app can
-do anything beyond the Connection tab. The app walks you through
-sending it on first run.
+**Q: Do I need the helper?**
+Yes. The PS5 has to be running `ps5upload.elf` before the app can do anything beyond the
+Connection tab. The app walks you through sending it on first run.
**Q: How do I send the payload?**
Open ps5upload → **Connection** tab → enter your PS5's IP → click
@@ -722,212 +605,92 @@ the file, shows you whether it looks like a ps5upload payload or
something else, and records the send in a history panel so you
can replay it without re-picking the file.
-**Q: The Send button is greyed out and says "Waiting for payload…"
-for a long time.**
-That's the normal probe window — up to 20 seconds for the PS5 to
-come online after the bytes finish sending. The elapsed time is
-shown in the button label. If it times out, the payload likely
-crashed on load; send it again.
+**Q: The Send button says "Waiting for payload…" for a long time.**
+That is the normal probe window, up to 20 seconds. If it times out, the payload likely crashed on
+load; send it again.
-**Q: Can I use a payload that's already running on the PS5
-(loaded by another tool, or by my previous ps5upload session)?**
-Yes — open the Connection tab while the payload is up and the
-app skips Step 2 automatically. The Connected card shows the
-payload version it detected; if that version is older than what
-this build of ps5upload ships, you'll see a warning with a
-"Replace payload" button. The bundled payload always carries the
-fixes the app expects.
+**Q: Can I use a helper that is already running (loaded by another tool)?**
+Yes. Open **Connection** while it is up and the app skips the send step. If you loaded it another
+way, the console shows a 6-digit code to confirm once. If the helper is older than this app, the
+app shows **Update helper**, which replaces it in one click.
-**Q: Can multiple computers connect to the same PS5 payload at the
-same time?**
-Yes. The payload's AVA1 listener on port 9120 accepts up to 16
-sessions at once (each laptop pairs once), so two laptops both running ps5upload
-against one PS5 is supported. Read-only operations (browse, hardware
-monitor) interleave cleanly. The thing to watch for is *destination
-races*: two simultaneous uploads writing to the same path will
-fight — the payload doesn't lock by destination, it applies the
-writes each transfer sends in arrival order. For routine use ("one
-person uploading, another browsing"), no coordination is needed.
+**Q: Can multiple computers connect to the same PS5 at the same time?**
+Yes, up to 16 sessions; each computer pairs once. Browsing and the hardware view interleave
+cleanly. Two uploads to the same path will fight, since nothing locks a destination.
**Q: What is the pairing code, and which ports does ps5upload use?**
-ps5upload talks to the console on one port, **9120**, plus the ELF loader's
-**9021** to load the helper. Those two are the only ones to allow in a
-firewall. Every connection is encrypted and each computer pairs with the
-console once. A helper that the app launched pairs automatically, with
-nothing to type. A helper you loaded some other way (a different loader or
-tool) shows a 6-digit code on the console; confirm it in the app to pair.
+Two ports: **9120** (the helper, AVA1) and **9021** (the ELF loader). Allow only those in a
+firewall. Every connection is encrypted and each computer pairs with the console once. A helper
+the app launched pairs by itself; one loaded another way shows a 6-digit code on the console,
+which you enter in the app. If the console shows a different PS5 at an address you reuse, forget
+the old one and pair again.
+
+**Q: I updated to 6.0 and the app says the helper is old.**
+6.0 and v5.x cannot talk to each other. Click **Update helper** on the Connection screen (or
+reload `ps5upload.elf`) and the app replaces it. If the old helper will not exit, restart the
+console and try again.
**Q: How do I cap the upload speed?**
-Set `PS5UPLOAD_BANDWIDTH_MBPS` to a number of megabytes per second before
-starting the app or engine; unset, zero or an unreadable value means no
-cap. The old name from before the AVA1 release still works for one release
-and prints a deprecation line in the engine log.
+Set `PS5UPLOAD_BANDWIDTH_MBPS` (megabytes per second) before starting the app or engine. Unset or
+zero means no cap. The pre-6.0 name of this variable still works for one release.
-**Q: Can I run the transfer engine on a different machine (remote /
-self-hosted engine)? (3.3.7)**
-Yes. The app normally launches its transfer engine as a bundled background
-process on `127.0.0.1:19113`, but you can run that engine somewhere else — a
-home server, a NAS — and point the app at it in **Settings → Engine URL**.
-An official multi-arch image (`linux/amd64` + `linux/arm64`) is published at
-`ghcr.io/phantomptr/ps5upload-engine` — use `:latest` for the newest release
-or `:` to pin (e.g. `docker pull ghcr.io/phantomptr/ps5upload-engine:3.3.16`). When the URL isn't loopback, the app skips the
-bundled engine and talks to your remote one (cover art included). Two things to
-know: (1) the engine's API has **no password**, so to let a remote machine reach
-it you set `PS5UPLOAD_ALLOW_IP` to that machine's IP — do this **only on a
-trusted home network**, never expose the engine to the internet (it can read,
-write, and delete files on your PS5); and (2) the engine still needs a network
-route to the PS5 (`PS5_ADDR`). Leave Engine URL blank/loopback for the normal
-all-in-one mode.
+**Q: Can I run the engine on a different machine (self-hosted)?**
+Yes. Run it on a server or NAS and point the app at it in **Settings → Engine URL**. Images:
+`ghcr.io/phantomptr/ps5upload-engine` (engine only) and `ghcr.io/phantomptr/ps5upload-engine-webui`
+(full web UI), tagged `:latest` or `:`.
-`PS5UPLOAD_ALLOW_IP` takes single IPs or ranges, comma-separated
-(`192.168.1.0/24`), so phones and laptops that change address keep working.
-If it's wrong, the engine answers `403` and names the address it refused.
+- The API has **no password**. Set `PS5UPLOAD_ALLOW_IP` to the IPs or ranges allowed to use it
+ (`192.168.1.0/24`); anything else gets a `403` naming the refused address. Trusted LAN only,
+ never the internet.
+- Start from [`engine/compose.yaml`](engine/compose.yaml) (set `PS5_ADDR`, `PS5UPLOAD_ALLOW_IP`
+ and your package folder). It uses **host networking**: a stream install makes the PS5 download
+ the package *from the engine*, and on Docker's bridge network the engine offers an address the
+ PS5 cannot reach. Docker Desktop (macOS/Windows) has no host networking: publish port 19113 and
+ set `PS5UPLOAD_PKG_HOST_IP` to the computer's LAN IP.
+- Keep a volume on `/data` (saved servers, install history, artwork cache).
+- With a remote engine the app's file pickers browse the **engine's** disk.
-With a remote engine, file pickers in the desktop app browse the **engine's**
-disk, because that is the machine that reads the file. **Install Package →
-From this device** uploads a package from your computer to the engine first.
+**Q: Docker or a NAS says "Permission denied" when I save or upload.**
+The image runs as UID:GID 65532 and keeps its state in `/data`. If you bind-mount a folder someone
+else owns, the engine names the folder, its own UID:GID and the folder's owner. Either set `user:`
+in the compose file to the folder's owner (`ls -ln` shows the numbers), or run
+`chown -R 65532:65532 `. `PS5UPLOAD_DATA_DIR` does not fix this.
-**Linux server (homelab, NAS, mini PC):** use the ready-made
-[`engine/compose.yaml`](engine/compose.yaml) — set your PS5's IP, your LAN's
-range and your package folder, then `docker compose -f engine/compose.yaml up -d`.
-It uses **host networking**, and that matters: a stream install has the PS5
-download the package *from the engine*, and on Docker's default bridge network
-the engine can only offer its internal `172.17.x.x` address, which the PS5
-can't reach. (The engine warns about this at startup.) On Docker Desktop
-(macOS/Windows), which has no host networking, publish port 19113 and set
-`PS5UPLOAD_PKG_HOST_IP` to that computer's LAN IP instead. Keep a volume on
-`/data`: saved servers, install history and the artwork cache live there.
+**Q: Can I use ps5upload from a web browser (no desktop app)?**
+Yes. The `webui` image (above) serves the full app over HTTP; open `http://:19113` from an
+allowed IP. Same security rules: no password, trusted LAN only.
-**Q: Docker says "Permission denied (os error 13)" when I upload. Do I need to
-set `PS5UPLOAD_DATA_DIR`?**
-No. `PS5UPLOAD_DATA_DIR` only moves saved servers and install history; it
-doesn't fix this, and you don't need it.
+- **Upload works**, but the file picker browses the **engine's** disk, not your browser machine's.
+ Mount your games into the container (`-v /host/games:/pkgs:ro`) and set
+ `PS5UPLOAD_BROWSE_ROOTS=/pkgs` (comma-separated for several) so the picker opens there.
+ **Install Package → From this device** uploads a package from your browser machine instead.
+- An upload keeps running when you close the tab; reopening shows it. Only one tab runs the queue.
+- Desktop-only and hidden in the browser: **archive uploads**, **Payloads** (sending a file from
+ disk), saving a save backup or downloading files to your computer, and attaching files to a bug
+ report. Everything that works on the PS5 itself is the same as on desktop.
-- **Current images:** nothing to set. Uploads are staged in the image's own
- `/tmp`, and the engine keeps its state in `/data`, including when you run the
- container as another user (`user: "1000:1000"`). Mount a volume on `/data`
- so that state survives upgrades.
-- **The 5.40 image** had no writable `/tmp` or home folder, which is what
- caused the error (#346). If you're still on it, point both at a folder
- you've mounted:
-
- ```yaml
- environment:
- - TMPDIR=/mnt/ps5/.ps5upload/tmp
- - HOME=/mnt/ps5/.ps5upload/home
- volumes:
- - /mnt/ps5:/mnt/ps5
- ```
-
- The folders are created for you. Keep `TMPDIR` on a real disk rather than a
- tmpfs: a package is staged there in full while it uploads, and a tmpfs
- would hold a 50 GB package in RAM. Remove both lines after updating.
-
-**Q: Can I use ps5upload from a web browser (no desktop app)? (3.3.25)**
-Yes. A `webui` build of the engine serves the **full app over HTTP**, so you
-can manage your PS5 from any browser on your LAN — useful on a NAS or headless
-box where you don't want to install the desktop app. An official multi-arch
-image is published at `ghcr.io/phantomptr/ps5upload-engine-webui` (`:latest`
-or `:`); or build it yourself with
-`docker build -f engine/Dockerfile.webui -t ps5upload-engine-webui .` (run
-that from the **repo root**, not `engine/` — the build needs `client/` to
-compile the frontend first). Run it the same way as the plain engine image
-and open `http://:19113`. Same security rules as the self-hosted engine
-above: it's **unauthenticated**, so only reach it from IPs you add to
-`PS5UPLOAD_ALLOW_IP`, and only on a trusted LAN — never expose port 19113 to
-the internet.
-
-**Upload works in the browser** — but it picks files from the **engine's
-own filesystem**, not the browser machine's. There's no way for a browser
-tab to read files off a *different* computer's disk (the one running the
-engine), so the in-app file/folder picker instead browses whatever the
-engine process can see — e.g. mount a folder into the Docker container
-with `-v /host/games:/pkgs:ro` and browse to `/pkgs`. The picker starts at
-the engine's home directory (`/data` in the image), so set
-`PS5UPLOAD_BROWSE_ROOTS=/pkgs` to have it open on your mount instead; the
-value is comma-separated, so it can offer more than one root.
-**Install Package →
-From this device** is the exception: it uploads a package from the browser's
-machine to the engine, then installs it. Plain files
-and folders upload the same as desktop; **archive uploads (`.zip`/`.7z`/
-`.rar`) are still desktop-only** for now (the archive-inspect step streams
-progress in a way the browser shim doesn't support yet — picking one shows
-a clear "not available in the browser yet" message instead of failing
-silently). The upload **queue also doesn't persist across a full page
-reload** in browser mode (queue persistence is desktop-only).
-
-Other actions that need your PC's own filesystem are still desktop-only and
-hidden in the browser UI: **Payloads** (sending a `.elf`/etc. from disk) is
-hidden from the sidebar entirely; on other screens, only the specific
-affordance that needs a local file is hidden — installing a `.pkg` you pick
-from your PC, saving a save-data backup to your computer (even the
-"back up to USB" flow round-trips through a local scratch file), downloading
-a file/screenshot/video clip, and attaching a file to a bug report. Everything
-that operates on the PS5 itself — browse, transfer, install from a
-PS5-connected USB drive, hardware monitor, saves list, profile — works the
-same as the desktop app.
-
-**Q: What is "Stream install" on the Install Package screen?**
-It installs a `.pkg` **straight from your PC** — the PS5 pulls the bytes over
-your network instead of the file being uploaded to the console first. That
-saves disk space (no "package + installed game both taking room") and a whole
-transfer step. Keep the computer on and connected until the install finishes.
-The PS5 has to be able to reach your computer: if a stream install fails
-before anything downloads, see *"Stream install fails before the PS5
-downloads anything"* below, or use **Upload & install**, which copies the
-package to the PS5 first and needs no connection back to your computer.
-
-**Q: Opening the Hardware screen drops the connection ("Couldn't read
-hardware info — connection refused").**
-A single hardware reading that misbehaves on a particular firmware or
-ELF-loader could take the whole helper down — you'd see "connection
-refused" and have to re-send the payload. Fixed in 2.28.1: a reading that
-misbehaves now just shows as a dash for that one field instead of dropping
-the connection, and the helper logs exactly which reading misbehaved so a
-bug report pinpoints it. If you're on an older build, update; if a console
-ever still drops, re-send the payload from the Connection tab (reboot the
-PS5 only if it won't reconnect).
+**Q: What is "Stream install"?**
+It installs a `.pkg` **straight from your computer**: the PS5 pulls the bytes over your network, so
+nothing is copied to the console first (no package plus installed game taking room). Keep the
+computer on and connected until it finishes. The PS5 must be able to reach your computer; if it
+cannot, see "Stream install fails before the PS5 downloads anything", or use **Upload & install**.
**Q: Can I manage several PS5s at the same time?**
-Yes (2.29.0+). Add each console — the Connection screen, or the **+** on the
-console tab strip — and a tab appears at the top for every PS5, each with its
-own live status dot. Click a tab to view and operate that console. Each
-console runs its own upload queue and its own package installs **independently
-and in parallel**: start an upload or install on one, switch tabs, start
-another — they don't wait for each other. (Switching tabs only changes what
-you're looking at; every console's work keeps running in the background.) The
-single-console layout is unchanged until you add a second PS5.
-
-**Q: Is it safe to do many things on several consoles at the same time?**
-Yes — and 2.30.0 hardened this further. The helper running on each PS5 now
-serializes its low-level kernel operations internally, so things that happen
-to overlap — reading a console's live CPU/SoC temperature while a package is
-installing, registering a game on one console while another is mid-install —
-can't collide and crash the helper or black-screen the console. On the desktop
-side, a slow or very large upload to one console no longer holds up the others:
-each console's requests are handled on their own, so one busy PS5 can't freeze
-the app for the rest.
+Yes. Add each console from **Connection** or the **+** on the console tab strip. Each console has
+its own tab, status dot, upload queue and installs, which run independently and in parallel;
+switching tabs only changes what you see. On the console side, low-level operations are serialized,
+so overlapping work (a temperature read during an install) cannot crash the helper.
---
## Transferring
**Q: How do I stop my PS5 from going into rest mode?**
-**Settings → Upload → "Keep the PS5 awake"** has three modes:
-
-- **Off** — the PS5 rests on its own schedule.
-- **During transfers** (default) — while an upload is running, the app
- periodically resets the console's auto-standby timer so a long
- transfer can't be killed by rest mode.
-- **Always while connected** — for as long as the app is open, every
- console whose helper is running is kept out of auto-rest entirely. A
- small ⚡ "keeping awake" indicator appears in the status bar so you
- always know it's active.
-
-In every mode, putting the PS5 to rest manually (controller / console
-button) still works immediately — the app only defers the *idle* timer,
-it never blocks an explicit rest request. Closing the app returns the
-console to its normal power schedule.
+**Settings → Upload → Keep the PS5 awake** has three modes: **Off**; **During transfers**
+(default), which keeps resetting the console's auto-standby timer while an upload runs; and
+**Always while connected**, which keeps every console with a running helper out of auto-rest while
+the app is open (a ⚡ indicator shows). Resting the console by hand always works, and closing the
+app returns the console to its normal schedule.
**Q: Can I wake the PS5 from standby, or turn it off, from the app?**
Yes. The Connection screen has **Rest mode**, **Reboot**, and **Shut down**
@@ -966,32 +729,21 @@ Under `/data/homebrew/` unless you pick a different drive in the Upload
screen. Common presets are offered: `homebrew` (recommended),
`exfat`, `ps5upload`.
-**Q: Why can an upload say there is not enough space when the PS5 still shows
-free space?**
-The raw filesystem number is not the same as space Sony's content allocator
-will let a homebrew process consume. Internal storage keeps a fixed system
-pool that `statfs` does not report accurately; filesystem metadata and other
-console activity also need headroom. The Volumes screen therefore shows both
-raw free space and **safe for new uploads**. ps5upload checks the expanded size
-of files, folders, ZIP, 7z and RAR transfers before sending, and repeats the
-check on the PS5 for retries/resumes using the job's actual durable
-progress. If the payload reports `preflight_insufficient_space`, free the
-amount shown in the error or choose another destination. Partial upload files
-are credited on Resume, so already-allocated data is not charged twice.
+**Q: Why can an upload say there is not enough space when the PS5 shows free space?**
+Before anything is sent, ps5upload measures the whole job (including what an archive expands to)
+and compares it with what the console can really take. Internal storage keeps part of its space
+for the system and filesystem, so the raw free number overstates it. **Volumes** shows raw free
+space, **safe for new uploads**, and how much the console keeps for itself.
-**Q: My USB drive is too small for this game. What can I do?**
-Pick a destination with more room. ps5upload measures the whole transfer
-(including what a `.zip`, `.7z` or `.rar` expands to) and compares it with the
-destination's free space *before* sending anything, so a game that will not fit
-is refused up front, with the shortfall in the message, rather than failing
-hours in. Your options:
-- **Internal storage.** Choose the internal drive as the destination, if it has
- the space. The Volumes screen shows raw free space and **safe for new
- uploads**; go by the second number.
-- **An M.2 or extended-storage drive.** If you have one, choose it as the
- destination in Upload.
-- **A larger exFAT drive.** Use a bigger USB drive formatted as exFAT.
-- Or free up space on the current drive and click Retry.
+A job that will not fit is refused in seconds with the shortfall in GB. A retry into the same
+folder counts what is already on the console, so it does not ask for the whole folder again. Two
+uploads into room for one: the second is refused at once. Free the amount shown, or pick another
+destination.
+
+**Q: My USB drive is too small for this game.**
+The up-front check refuses it with the shortfall. Choose a destination with more room: the
+internal drive (go by **safe for new uploads** in Volumes), an M.2 / extended drive, or a larger
+exFAT USB drive, or free up space and click Retry.
**Q: What happens when the destination already has files?**
The app asks: **Override**, **Resume**, or **Cancel**.
@@ -1010,94 +762,28 @@ The Volumes tab shows the result with a progress bar and Unmount
button.
**Q: Can I upload a compressed `.zip` of a game?**
-Yes — and the files arrive **already extracted** on the PS5. Keep the
-game as a single `.zip` on your PC (less disk, easier to move), drop it
-on the Upload screen, and ps5upload decompresses it on your computer
-while streaming the files into the normal transfer pipeline. There's no
-manual unzip step, and no temporary copy of the whole game on your disk —
-large entries are streamed as they decompress (the wire stays busy, so
-the console won't drop a quiet connection mid-inflate). A 100 GB archive
-doesn't need 100 GB of free RAM. The screen previews what the archive
-expands to (`zipped → extracted`, file count, and the % saved on disk)
-and detects the embedded game if it has a `sce_sys/param.json`. The
-destination folder is named after the `.zip` (minus the extension).
-Resume, excludes, the bandwidth cap, and mirroring to other consoles all
-work the same as a folder upload.
+Yes, and the files arrive **already extracted**. Drop the `.zip` on the Upload screen; ps5upload
+decompresses it on your computer while sending, with no manual unzip. The screen previews
+`zipped → extracted`, the file count and any embedded game. The destination folder is named after
+the archive. Resume, excludes and the bandwidth cap work as for a folder. Only standard "Deflate"
+zips are supported (Windows "Send to → Compressed folder" is fine); Deflate64, LZMA, BZip2, Zstd
+and encrypted zips are rejected with a clear message.
-A note on space savings: PS5 game data is often already compressed, so
-DEFLATE (ZIP's method) sometimes shrinks a dump a lot and sometimes
-barely at all — the preview's "saves N%" shows the real number for your
-archive before you commit to sending it.
+**Q: What about `.rar` and `.7z`?**
+Both work and also resume after a drop.
-Since 2.18.5 the scan is effectively instant on any size archive, and
-while it's reading the file you'll see a **"Scanning archive… N
-entries"** counter so you can tell the app is working — useful when
-the archive is on a slow USB drive that takes a moment to spin up.
-
-**Q: What about `.rar` / `.7z`?**
-Both work. `.zip`, `.7z` and `.rar` are all extracted on your computer
-and streamed to the console, so the result is the same as uploading the
-extracted folder.
-
-- **`.7z`** — LZMA2, which is what almost every `.7z` uses.
-- **`.rar`** — including multi-part sets and password-protected archives.
- Pick the **first** part (`name.part1.rar`, or the plain `.rar` for
- old-style `.r00`/`.r01` sets) and keep every part in one folder; the
- rest are pulled in automatically. If one is missing, the error names
- it.
-- **`.rar` needs the desktop app.** It is not available in the Android
- build or the self-hosted web UI, which will say so rather than fail
- vaguely. `.zip` and `.7z` work everywhere.
-
-For `.zip` specifically, only the **standard "Deflate"** method is
-supported (Windows's "Send to → Compressed (zipped) folder" always does
-the right thing). Deflate64, LZMA, BZip2, Zstd and AES-encrypted zips are
-rejected with a clear message.
+- **`.7z`**: LZMA2, which almost every `.7z` uses.
+- **`.rar`**: including multi-part sets and password-protected archives. Pick the **first** part
+ (`name.part1.rar`, or the plain `.rar` for `.r00` sets) and keep every part in one folder. A RAR
+ holding several packages is unpacked straight to the console and each package installed in turn
+ (base, then patch, then DLC).
+- **`.rar` needs the desktop app.** The Android build and the web UI say so. `.zip` and `.7z` work
+ on Android; no archive format works in the browser.
**Q: Does an archive upload need free space on my PC?**
-It depends on the format.
-
-- **`.zip`, `.rar` and `.7z` — no.** All three are decompressed and sent at
- the same time: bytes go straight from the decoder onto the network, so
- nothing is written to your disk. You only need room for the archive you
- already have.
-
-A `.zip` entry of any size is inflated as it is sent, so nothing is held back in memory
-or written to temp. The `PS5UPLOAD_ZIP_RAM_THRESHOLD_MB` environment variable (and its old
-name from before the AVA1 release, which still works for one release and prints a deprecation line) is still accepted so an existing setup does not
-break, but it is ignored.
-
-This changed in a recent version. Older builds extracted a `.rar` in full
-first, which meant a 180 GB game needed 180 GB free on top of the archive.
-If you set `PS5UPLOAD_ARCHIVE_STAGE_MB` (or its old name from before the AVA1 release)
-to work around that, you can remove it — it no longer does anything.
-
-**Q: Which archive formats work on which system?**
-
-| OS / arch | `.zip` | `.7z` | `.rar` |
-|---|---|---|---|
-| macOS x86_64 | Decompress first | **Stream** | **Stream** |
-| macOS arm64 | Decompress first | **Stream** | **Stream** |
-| Linux x86_64 | Decompress first | **Stream** | **Stream** |
-| Linux arm64 | Decompress first | **Stream** | **Stream** |
-| Windows x86_64 | Decompress first | **Stream** | **Stream** |
-| Windows arm64 | Decompress first | **Stream** | **Stream** |
-| Android arm64-v8a | Decompress first | **Stream** | not supported |
-| Android armeabi-v7a | Decompress first | **Stream** | not supported |
-| Android x86 / x86_64 | no build | no build | no build |
-| i686 / i386 (any OS) | no build | no build | no build |
-| Browser | not supported | not supported | not supported |
-
-- **Stream** — decompressed straight onto the network. No disk used.
-- **Decompress first** — each file inside is decompressed before it is
- sent (see the question above for how much space that needs).
-- **not supported** — there is a build for that system, but it cannot
- read that format. RAR is missing on Android because it needs the UnRAR
- library, which is not part of the Android build.
-- **no build** — no ps5upload binary ships for that system at all.
-
-Streaming or not is a property of the format, not your computer: a `.7z`
-streams the same way on every system that can open one.
+No. The archive is decompressed and sent at the same time, so you only need room for the archive
+you already have. The `PS5UPLOAD_ARCHIVE_STAGE_MB` and `PS5UPLOAD_ZIP_RAM_THRESHOLD_MB` variables
+are accepted for old setups but do nothing.
**Q: Why does the Library sometimes show a game twice?**
If the same title is present both as a folder on disk and inside a
@@ -1114,152 +800,62 @@ queued item interrupted by a crash picks up cleanly when you
press Start again. Tick **Continue on failure** to keep going
when one item fails instead of stopping the whole batch.
-**Q: How do I jump between PS5 volumes in the File System tab?**
-The toolbar has a **Volume** dropdown above the breadcrumb
-(2.2.24+) that lists every writable, non-placeholder volume on
-the PS5 with its free-space readout (e.g. `/mnt/ext1 · 412 GiB
-free`). Pick one and the screen jumps to that volume's root —
-no more walking up to `/` and back down. The picker hides on
-machines with no writable volumes.
+**Q: How do I jump between volumes in the File System tab?**
+The **Volume** dropdown above the breadcrumb lists every writable volume with its free space.
+Pick one to jump to its root.
**Q: Does the File System tab remember where I was last?**
-Yes (2.2.24+). The last-browsed path is persisted to
-localStorage **per host**, so two PS5s on the same desktop each
-remember their own location independently. The PS5 IP itself is
-also persisted now — no more retyping it on every launch.
+Yes, per console. The PS5 IP is remembered too.
-**Q: Where can I see what's currently running across screens?**
-The OperationBar (the strip at the bottom of the window) shows
-every in-flight operation as long as one is running, with the
-elapsed time, bytes/total, and live MiB/s. As of 2.2.24 it
-covers uploads, downloads, FileSystem cut/copy/paste, FileSystem
-delete, and Library actions (Move, Delete, Chmod, Mount,
-Unmount, Download). Click it for the full Activity tab with
-historical outcomes.
+**Q: Where can I see what is running across screens?**
+The strip at the bottom of the window shows every in-flight operation (uploads, downloads, copy,
+paste, delete, Library actions) with elapsed time, progress and speed. Copy and paste, Add files
+and "Finishing on the console" show progress and an ETA, and **Cancel copy** stops a console copy
+and cleans up only what it created. Click the strip for the full Activity tab.
-**Q: My big upload dies partway through. Why, and how do I stop it?**
-A multi-hour upload holds one TCP connection open the whole time. If
-**either end goes to sleep**, that connection drops and the transfer
-fails (you'll see a network/connection error, or — on external
-USB/exFAT targets — a file that looks done but is incomplete). Two
-things go to sleep:
+**Q: My big upload stopped partway through. What now?**
+A dropped connection, a helper restart or rest mode no longer ends the job: the app waits for the
+console to come back and continues from what is already safely on it. If it gave up, press Start
+again (or pick **Resume** at the prompt). On external USB/exFAT, a console that lost power
+mid-write can leave a file inconsistent; if a finished upload looks wrong, use **Override** and
+upload again.
-1. **Your computer.** ps5upload now keeps the computer awake
- **automatically** while any upload, download, or install is running
- (macOS `caffeinate`, Linux `systemd-inhibit`, Windows
- power request) and releases it when the queue goes idle.
- You don't have to do anything. If you want the machine to also stay
- awake while the app is open but *idle*, turn on **Settings → Keep
- Awake**. (On non-systemd Linux that toggle is greyed out — the OS has
- no inhibitor to call.)
-
-2. **The PS5 itself.** The console's rest-mode timer is **console-side**
- — ps5upload can't see or stop it from your computer. For a long
- transfer, raise or disable it first:
- **Settings → System → Power Saving → Set Time Until PS5 Turns Off**,
- and set **both** *During Media Playback* and *During Gameplay /
- General Use* to a longer value (or **Don't Turn Off**). A quick
- workaround if you'd rather not change settings: tap the controller
- every so often during the transfer — any input resets the idle timer
- (this is what some users do already).
-
-If a transfer does get cut off, you usually don't lose the bytes already
-sent: re-run it and pick **Resume** at the prompt (or press Start again
-on the queued row) — it size-compares what's on the PS5 and only sends
-what's missing. On external USB/exFAT, prefer **Override** + a fresh run
-if a "completed" upload looks wrong, since a console that lost power
-mid-write can leave the destination in an inconsistent state.
-
-If your network itself is the unreliable bit (flaky WiFi, congested
-router, multi-hundred-GB transfers), a one-time **direct Ethernet
-cable** between the computer and PS5 gives the most stable + fastest
-path — see the **Network setup — direct Ethernet** section above for
-per-OS instructions and addressing.
+To avoid interruptions on long transfers:
+- **Your computer** is kept awake automatically while a transfer runs (**Settings → Keep Awake**
+ also keeps it awake while the app is idle; greyed out on non-systemd Linux).
+- **The PS5** has its own rest timer: raise it under **Settings → System → Power Saving → Set Time
+ Until PS5 Turns Off**, or use **Settings → Upload → Keep the PS5 awake** in the app.
+- **Flaky Wi-Fi?** A direct Ethernet cable is the most stable and fastest path (see the direct
+ Ethernet section above).
---
## Mount + unmount
-**Q: I uploaded a game *folder* (dump), but ShadowMount+ won't mount it
-— yet `.exfat` images mount fine. Why?**
-Because **ShadowMount+ mounts game *image files* (`.ffpkg` / `.exfat` /
-`.ffpfs`), not loose extracted folders.** It watches `/mnt/usb*`,
-`/mnt/ext*`, and `/data` for those image files and auto-mounts +
-registers them — so your `.exfat` uploads "just work," but a raw dump
-folder sitting in those paths is invisible to it. This is a workflow
-difference, not a corruption bug (your upload completed correctly).
+**Q: I uploaded a game *folder*, but ShadowMount+ won't mount it, yet `.exfat` images mount fine.**
+ShadowMount+ mounts game **image files** (`.ffpkg` / `.exfat` / `.ffpfs`), not loose folders. Two
+ways forward:
-You have two ways forward:
+- **Register the folder in ps5upload.** In **Library**, on a folder with `eboot.bin` and
+ `sce_sys/param.json` at its root, click **Register**, then **Launch**. If a PSN- or
+ disc-extracted dump fails with a DRM error, use **Register (patch DRM)**. If the home-screen tile
+ is blank, enable the ShadowMount+ metadata healer in ps5upload.
+- **Give ShadowMount+ an image.** Upload the game as an `.exfat` or `.ffpkg` image.
-- **Keep the folder — register it directly in ps5upload (no ShadowMount+
- needed).** In the **Library** tab, on the uploaded folder game (it
- needs `eboot.bin` + `sce_sys/param.json` at its root), click
- **Register**, then **Launch**. This installs the title into the PS5's
- app database with a bind mount and starts it — the native,
- hardware-validated path for folder dumps. If a PSN- or disc-extracted
- dump fails Register with a DRM error, use **Register (patch DRM)**
- (rewrites `applicationDrmType` to `standard`). If the home-screen tile
- is blank after registering, enable the ShadowMount+ metadata healer in
- ps5upload (it copies `icon0.png`/art into `/user/appmeta`).
-- **Keep using ShadowMount+ — give it an image, not a folder.** Package
- the game as an `.exfat` (or `.ffpkg`) image and upload *that* — exactly
- what already works for you. Don't upload the extracted folder if
- ShadowMount+ is your launcher.
-
-On the **"old `config.ini` from a 1.xx version"**: that's *not* what's
-blocking the folder mount (the folder-vs-image distinction above is), and
-since your `.exfat` images mount fine your config is clearly functional.
-But mixing a stale ShadowMount+ config/autotune with a newer
-ShadowMount+ build is worth tidying: ShadowMount+ regenerates defaults if
-they're absent, so you can back up and delete `/data/shadowmount/config.ini`
-and `/data/shadowmount/autotune.ini` (the autotune file holds
-version-specific, learned per-game timings — least worth keeping across
-versions) and let the current build recreate them. Check
-**Library → ShadowMount+ panel → debug log** for the exact reason a given
-item didn't mount.
+**Library → ShadowMount+ panel → debug log** shows why a given item did not mount.
**Q: How do I find one game in a long library?**
-The Library tab has a search bar above the games + images
-sections (2.2.25+). Type a name fragment, a title ID prefix
-(`PPSA…`, `CUSA…`), or a path fragment (`ext1`) — matching is
-live, case-insensitive, and runs across all of `name`, `titleId`,
-absolute `path`, scan `scope`, and `volume`. Multi-word queries
-AND-match across fields, so `space ext1` narrows results to
-titles named "space" on `/mnt/ext1`.
+Use the search bar in **Library**: a name fragment, a title ID prefix (`PPSA…`, `CUSA…`) or a path
+fragment. Several words must all match.
**Q: Can I pick where a `.exfat` / `.ffpkg` mounts?**
-Yes (2.2.25+). The Library Mount button opens a modal with the
-same UX as the Upload screen's destination picker:
+Yes. The Library **Mount** button opens a dialog with a volume, a subpath and a name (taken from
+the image filename). Your last choice is remembered per console. Some PS5 game scanners only look
+in `/mnt/ps5upload/`, so the dialog warns when you mount elsewhere.
-- **Volume** — pick any writable PS5 volume from the dropdown
- (`/data`, `/mnt/ext1`, `/mnt/usb0`, …). Free-space readout
- appears next to each.
-- **Subpath** — free-form, with the same preset chips as
- Upload (`homebrew`, `exfat`, `ps5upload`).
-- **Name** — auto-derived from the image filename (e.g.
- `MyGame.exfat` → `MyGame`), editable.
-
-The resolved final path appears under the inputs in real time.
-Your last-used volume + subpath is persisted per host so the
-next Mount on the same console opens with the same selection.
-
-Heads-up: third-party PS5 game scanners typically only scan
-`/mnt/ps5upload/` for installed games, so mounting outside that
-root works for the payload but the game may not show up in
-those scanners. The modal shows a soft warning when the
-resolved path is outside `/mnt/ps5upload/`.
-
-Pre-2.2.25 payloads only honor a `mount_name` (no volume picker)
-and always anchor mounts under `/mnt/ps5upload/`. The modal
-detects the older payload, hides the volume + subpath rows, and
-shows a "Replace payload to enable" banner.
-
-**Q: A mount from a previous session is still showing after I
-re-sent the payload.**
-That's expected — `/mnt/ps5upload/*` mounts are held by the PS5
-kernel and survive payload restarts. Only a PS5 hard reboot clears
-them. At payload startup we reconcile: any mount whose backing
-device is gone is unmounted automatically.
+**Q: A mount from a previous session is still showing after I re-sent the helper.**
+Expected: mounts are held by the PS5 kernel and survive helper restarts. Only a PS5 reboot clears
+them. On startup the helper unmounts any mount whose backing device is gone.
**Q: The Library has a `MOUNTED` badge on a `.exfat` file. What does
that mean?**
@@ -1268,12 +864,7 @@ Mount button has flipped to Unmount. The Volumes tab shows the
mapping explicitly, with the source image path under each mount.
**Q: Does Unmount leave ghost tiles in the PS5 dashboard?**
-No (2.2.60+). Unmount unregisters every title inside the image
-first (Sony's appinst Uninstall removes the app.db row), waits 400
-ms for the commit to land, then unmounts. Pre-fix the kernel
-unmount worked but app.db was left with stale rows pointing at the
-now-gone path, surfacing as ghost tiles or `0x80980103 invalid
-title id` on the next launch attempt.
+No. Unmount unregisters every title inside the image first, then unmounts.
**Q: Can I edit what's inside a mounted image — replace files, add DLC,
or apply a backport patch?**
@@ -1350,54 +941,37 @@ you trigger Unmount from the Library tab or the Volumes tab.
## Install Package
-**Q: Can I install fakepkgs from the desktop?**
-Yes. **Install Package** is a package library: click **Add package** and pick a
-PS4 `.pkg` or PS5 `.fpkg` install package. (A PS4 fake package commonly still
-uses the ordinary `.pkg` extension; the extension does not prove how it was
-signed.) It uploads once to
-`/user/data/ps5upload/pkg_library/` on the PS5 and stays there. The
-screen lists every uploaded package with cover art and size, and each
-row has **Install**, **Reinstall**, and **Delete** — so you can install
-again any time without re-uploading. You can also skip the upload:
-**Stream install** installs a package straight from your PC (see the
-Stream install question above).
+**Q: Can I install fake packages from the desktop?**
+Yes. **Install Package** is a package library: click **Add package** and pick a PS4 `.pkg` or PS5
+`.fpkg` (a PS4 fake package often keeps the ordinary `.pkg` extension). It uploads once to
+`/user/data/ps5upload/pkg_library/` and stays there, with cover art and size, and each row has
+**Install**, **Reinstall** and **Delete**. Or skip the upload with **Stream install**.
-Every install runs through ps5upload's own on-console installer (port
-`:9115`). The app sends it to the console when it isn't already running;
-it runs alongside the ps5upload helper, so the connection doesn't drop
-while you install. An install is only reported as done once the console
-has pulled the whole package and the result checks out. Hardware-validated
-on FW 5.10 with PS4 base games, updates and DLC and with PS5 fake packages,
-through both Stream install and Upload & install.
+Every install runs through ps5upload's own on-console installer (port `:9115`). The app sends it
+when it is not already running, alongside the helper, so the connection does not drop. An install
+is reported done only once the console has pulled the whole package and the result checks out.
+A link that serves a `.pkg` installs even without ".pkg" in the URL.
**Q: Stream install fails before the PS5 downloads anything?**
-The PS5 pulls the package from your computer, so it has to be able to reach
-it. Allow ps5upload through your computer's firewall (on Windows, for both
-Private and Public networks), keep the computer and the PS5 on the same
-network with any VPN off, and set the PS5's Proxy Server to "Do Not Use"
-(Settings → Network → Settings → Set Up Internet Connection → your
-connection → Advanced Settings). The app names the cause when it can tell.
+The PS5 pulls the package from your computer, so it must be able to reach it. Allow ps5upload
+through the firewall (on Windows, for both Private and Public networks), keep both on the same
+network with any VPN off, and set the PS5's Proxy Server to "Do Not Use" (Settings → Network →
+Settings → Set Up Internet Connection → your connection → Advanced Settings). On Windows the app
+names the network adapter and whether Windows treats it as Public, with one-click fixes.
**Upload & install** works without this connection.
-**Q: How do I turn a game folder into an installable package? (Convert to FPKG, beta)**
-Turn on **Settings → Beta features**, then open **Convert to FPKG**. Pick a
-decrypted game folder, or an `.exfat` / `.ffpkg` image; the app checks it has
-everything a launchable package needs and builds a fake package on your
-computer (not on the console), compressed the way Sony's own packages are.
-When it's done, choose **Stream install** or **Upload & install** — or keep
-the package and install it later. The console needs fake-package support
-loaded first (kstuff, `a53_ppr_install_fast.elf`, `shadowmountplus.elf`).
-It's beta: keep the game files you converted from.
+**Q: How do I turn a game folder into an installable package? (Convert to FPKG)**
+Open **Convert to FPKG**, pick a decrypted game folder or an `.exfat` / `.ffpkg` image. The app
+checks it has what a launchable package needs and builds a fake package on your computer,
+compressed the way Sony's packages are. It reads games from the console through the helper, so
+ftpsrv is not needed. Then choose **Stream install** or **Upload & install**, or keep the package.
+The console needs kstuff, `a53_ppr_install_fast.elf` and `shadowmountplus.elf` loaded first. Keep
+the game files you converted from.
-**Q: Can I install a package that's already on a USB / external drive? (3.2.0+)**
-Yes. Plug the drive into the PS5, open **Install Package**, and any `.pkg` or
-`.fpkg` install packages on connected USB / external drives show up in an
-**External Packages** section — each with a PS4/PS5 badge and an **Install**
-button. No upload from your computer needed. Under the hood the app copies the
-package from the drive to internal storage first and installs from there,
-because Sony's installer can't read the exfat USB mount directly (it accepts
-the request but installs nothing). The copy is on-console (drive-to-drive), so
-it's fast.
+**Q: Can I install a package that is already on a USB drive?**
+Yes. Plug the drive into the PS5; packages on it show in **Install Package → External Packages**
+with an **Install** button. The app first copies the package to internal storage on the console
+(drive to drive, fast), because Sony's installer cannot read the exFAT USB mount directly.
**Q: What is the difference between `.pkg`, `.fpkg`, and `.ffpkg` here?**
`.pkg` and `.fpkg` are accepted by **Install Package**. The app validates their
@@ -1417,95 +991,47 @@ The A53/PPR patches are a separate mount-time PFS-key path; they do not disable
Sony's package-registration policy. Package installation still relies on
kstuff's ShellCore installer patches and Sony's AppInst installer.
-**Q: I have a base game and an update — does the order matter? (3.2.0+)**
-Yes — install the **base game first**, then the update. A base game and its
-update share the same ID, so the app keeps them in separate places and never
-lets one overwrite the other (the base lands in `/user/app//`, the update in
-`/user/patch//`). If you try to install an update before its base is on the
-console, the app warns you — Sony's installer would otherwise accept it and
-silently install nothing.
+**Q: I have a base game and an update. Does the order matter?**
+Yes: install the **base first**, then the update, then DLC. They share an ID but live in separate
+places (`/user/app//` and `/user/patch//`) and never overwrite each other. The app warns if
+you try an update before its base, since Sony's installer would accept it and install nothing.
-**Q: The app says a package is "installed but may not launch" — what's that?**
-The app confirms a game actually landed on the console before reporting success.
-If the install was accepted but the content never appeared (a firmware/jailbreak
-that lacks the kernel patches fakepkg installs need), it tells you honestly
-instead of a false "installed." If a title genuinely won't start, reinstall from
-the console: **Settings → Debug Settings → Game → Package Installer**.
+**Q: The app says a package is "installed but may not launch".**
+The app confirms the game landed before reporting success. If the install was accepted but the
+content never appeared (usually missing kernel patches), it says so. If a title will not start,
+reinstall from the console's **Package Installer** (Settings → Debug Settings → Game).
-**Q: The app said the install failed, but the game is on my PS5 and plays fine. (3.3.4)**
-This was a verification bug — fixed in 3.3.4. If your PS5's install location is set
-to an **extended / M.2 SSD**, games install to that drive (`/mnt/ext…`) instead of
-internal storage, and the app used to look only in the internal location — so it
-wrongly reported "install failed" even though the game installed perfectly. 3.3.4
-confirms the install by the actual data written to *any* drive, so extended-storage
-installs are recognized correctly. If you saw this on an older version, the game was
-fine all along — just launch it.
+**Q: I am installing an update. Is my installed game safe?**
+Yes. The app reads the real "update vs full game" flag from the package and applies an update on
+top of your game, then checks the game's version went up and tells you if it did not. If an
+update will not go through the app, use the console's **Package Installer** (Settings → Debug
+Settings → Game → Package Installer).
-**Q: I'm installing a game update (patch) — is my installed game safe? (3.3.7)**
-Yes. A game's update carries the *same ID* as the game, so an older build could end
-up re-installing that ID and wiping the game instead of patching it. The app reads
-the real "update vs. full game" flag straight from the package — however it reached
-the PS5 — and applies an update *on top* of your game. After an update installs, the
-app checks that the game's version actually went up, and tells you if it didn't
-rather than calling it a success. If an update won't go through the app, install it
-from the PS5's own **Package Installer** (Settings → Debug Settings → Game → Package
-Installer). Always install the **base game first**, then the update.
+**Q: "Port 9021 is not open on ", but I loaded an ELF loader.**
+9021 belongs to the **ELF loader**, a separate jailbreak component, so the problem is upstream of
+ps5upload. In order:
-**Q: "Port 9021 is not open on " — but I loaded an ELF loader.**
-Port 9021 belongs to the **ELF loader**, which is a separate jailbreak
-component, not part of ps5upload. This message means nothing answered on
-that port — so the check is working, and the problem is upstream of us.
+1. **Is 9021 your loader's port?** `elfldr` uses 9021; some bundles use another port or have no
+ loader. If yours differs, change the port in Settings.
+2. **Did it start?** Autoloaders silently skip failed payloads. Re-run the jailbreak and watch for
+ the loader's notification.
+3. **Did the console sleep or reboot?** Everything jailbreak-side is lost on reboot and usually on
+ rest mode.
+4. **Right IP?** Check Settings → Network → Connection Status; a stale DHCP lease is common.
+5. **Can your computer reach it?** "AP / client isolation" on the router blocks device-to-device
+ traffic; wired Ethernet avoids it.
-Work through these in order:
-
-1. **Is 9021 the port your loader actually uses?** Loaders differ.
- `elfldr` listens on 9021, but some all-in-one payloads (BD-JB disc
- autoloaders, PPPwn chains, "goldhen-style" bundles) either use a
- different port or expose no ELF loader at all. If yours listens
- elsewhere, change the port in Settings → the app will use it.
-2. **Did the loader actually start?** Many autoloaders run a fixed list
- of payloads and silently skip one that failed. Re-run the jailbreak
- and watch for the loader's own on-screen notification.
-3. **Did the console sleep or reboot since you loaded it?** Everything
- jailbreak-side is lost on reboot and usually on rest mode. Re-run the
- exploit chain, then the loader.
-4. **Is it the right IP?** Check PS5 Settings → Network → Connection
- Status. A stale DHCP lease is a common cause.
-5. **Can your computer reach the console at all?** On Wi-Fi, "AP
- isolation" / "client isolation" on the router blocks device-to-device
- traffic entirely. Wired Ethernet avoids this and is faster anyway.
-
-A useful check: if **Find PS5s on the network** locates the console but
-9021 still reads closed, the network is fine and the loader is the
-problem. If discovery finds nothing either, it is the network.
+If **Find PS5s on the network** finds the console but 9021 is closed, the loader is the problem;
+if discovery finds nothing, it is the network.
**Q: A PS4 `.pkg` update won't install.**
-PS4 packages are recognised (the installer handles the `PS4GD` /
-`PS4AC` / `PS4DP` types), and updates install through ps5upload's own
-on-console installer like everything else. If the console rejects one,
-that is Sony's verdict, and the usual reasons are:
+If the console rejects one, that is Sony's verdict. Usual reasons: the **base game is not
+installed**; a **version mismatch** (a 1.05 patch will not apply over a 1.02 base); a **different
+content id or region**; or the jailbreak **lacks live kernel patches** (kstuff / fpkg-enable).
-- **The base game isn't installed.** An update patches something; with no
- matching PS4 base title on the console there is nothing to patch.
-- **Version mismatch.** An update expects a specific base version. A 1.05
- patch will not apply over a 1.02 base — you need the intermediate
- updates, or a merged package.
-- **Different content id / region.** A patch is tied to its exact
- content id; a US patch will not apply to a EU base.
-- **The jailbreak lacks live kernel patches.** Fake-signed packages need
- kstuff / fpkg-enable active. Without them the installer refuses, and we
- report that rather than claiming a false success.
-
-**Self-hosted / web UI:** a browser cannot talk to the console's loader
-port or carry the installer ELF itself, so the **engine** must. Use a
-released `ps5upload-engine` / `…-engine-webui` build (or Docker image) —
-those embed the installer. An engine built from a full checkout has it
-too (`ps5upload-installer.elf` is in the repository); otherwise point
-`PS5UPLOAD_PAYLOAD_DIR` at a directory that holds it, or installs will
-stop before the console ever sees the package.
-
-If the app reports an install failed but the game plays fine, that is a
-different thing — see the verification question above.
+**Self-hosted / web UI:** the engine must carry the installer ELF. Released engine binaries, the
+official Docker images and an engine built from a full checkout include it; otherwise point
+`PS5UPLOAD_PAYLOAD_DIR` at a folder that holds `ps5upload-installer.elf`.
**Q: Can I move a save from one PS5 to another?**
@@ -1536,34 +1062,13 @@ For PS4-format saves, the emulator may not notice the new data right
away; close and reopen the game, or restart the console, if it doesn't
show up.
-**Q: A system (NPXS) pkg won't install. What do I do?**
-System app pkgs (NPXS-prefix content_id — Store updates, Settings
-patches, built-in apps) aren't what ps5upload's installer is built for and
-often won't complete. Install those from the console itself: **on-PS5
-Settings → Debug Settings → Game → Package Installer** — a privileged
-code path ps5upload can't replicate.
-
-**Q: Does Play in the Library register the title first?**
-Yes (2.2.55+). Always-register-first: the Play button calls
-`appRegister` (idempotent if already registered), retries with a
-DRM-type patch on rejection, waits 600 ms for the app.db commit to
-land, then calls `appLaunch`. Pre-fix the flow tried Launch first
-and registered as a fallback — which surfaced misleading "not
-registered" errors before registration kicked in.
-
-**Q: Can I install a split pkg (`*.0`, `*.1`, …)?**
-Not through Install Package — the installer takes a single package
-file, so split sets are rejected with a note when you add them. Pick
-the single lead `.pkg` only.
+**Q: Can I install a split package (`*.0`, `*.1`, …)?**
+Not through Install Package: it takes a single package file. Pick the single lead `.pkg`.
**Q: Where do uploaded packages live, and are they cleaned up?**
-They live at `/user/data/ps5upload/pkg_library/.pkg` and
-**persist** by default, so you can reinstall any time without
-re-uploading. Remove one with the **Delete** button on its row (or from the
-File System tab); that frees the PS5 disk space. If you'd rather not keep
-them, turn on **Auto-delete each package from the PS5 after it installs**
-on the Install Package screen. A **Stream install** never puts a copy on
-the PS5 at all.
+At `/user/data/ps5upload/pkg_library/.pkg`, and they stay so you can reinstall. Remove
+one with **Delete** on its row, or turn on **Auto-delete each package from the PS5 after it
+installs**. A **Stream install** never leaves a copy on the PS5.
---
@@ -1596,16 +1101,9 @@ The routes:
Firmware notes:
-- **FW 13.60:** Stream & install works. Upload & install is refused by Sony
- with `0x80B2116F`; since 5.41.0 the app retries by re-serving the package
- from the engine, so you normally only see the error if that also fails.
- Payloads need SDK 0.43 or newer (this build uses it).
-- **PS5 fake game packages above FW 11.60:** they install, but the game cannot
- be played. PS4 fake packages and PS5 homebrew apps are not affected. The app
- warns on Install and Convert when it sees this combination.
-- **Load order matters:** send `elfldr` first, then kstuff, ShadowMount+ and
- ps5upload. Every package install needs kstuff, ShadowMount+ and the other
- payloads from the Setup wizard to be running.
+- **FW 13.60:** Stream & install works. Sony refuses Upload & install with `0x80B2116F`; the app
+ then offers **Retry with Stream**, or re-serves the package from the engine, so you normally see
+ the error only if that also fails.
### What the common messages mean
@@ -1678,18 +1176,16 @@ The payload may have wedged on a Sony API call. Recovery:
2. If that doesn't clear it, reboot the PS5.
3. Re-send the payload.
-**Q: Why doesn't Launch from the Library tab actually start the game?**
-It does, on every firmware we've validated. Register
-(`sceAppInstUtilAppInstallTitleDir`) runs from our own process
-because the full credential jailbreak we apply at startup
-satisfies its caller-context check. Launch
-(`sceLncUtilLaunchApp`) checks `getpid() == SceShellUI.pid`
-specifically, so the payload routes Launch through the same
-ptrace RPC mechanism it uses for sensors — the call runs from
-inside SceShellUI's address space where the check passes.
-Hardware-validated on FW 9.60: hitting Launch starts the game
-(SoC power draw jumps from idle ~30 mW to ~170 mW within a
-few seconds of the title coming up).
+**Q: Can't connect, or it connects and then drops within seconds.**
+- Did you load the helper? It is gone after a reboot or rest mode. Send it again from **Connection**.
+- Is your firewall blocking ports **9120** and **9021** to the PS5?
+- If you load ps5upload with an autoloader or PLDMGR, put **elfldr first** in the list, before
+ `ps5upload.elf`; otherwise it connects and drops after a few seconds.
+- Your computer and the PS5 need a route to each other but not the same subnet.
+
+**Q: Launch from the Library did nothing.**
+Launch starts the game; the first start can take a while. If it never appears, close it from the
+PS5 and start it there. See "My uploaded game won't launch".
**Q: I see errors in the status bar but don't know what happened.**
Open the **Logs** tab. Every runtime error, failed API call, and
@@ -1697,35 +1193,6 @@ console warning ends up there with timestamps and expandable
detail. Click **Copy** or **Download** to grab a plain-text dump
for a bug report.
-**Q: Deleting a huge game folder used to fail with a "502 Bad
-Gateway" error.**
-Fixed in 2.2.22. The recursive walk on a small-file-heavy folder
-(roughly 200k files / 20k dirs) takes minutes on PS5
-UFS — long enough that the engine's old 30 s socket timeout fired
-mid-walk while the payload was still deleting in the background.
-Now `fs_delete` uses the same 1-hour deadline `fs_copy` already
-does, **and** the operation reports live progress (bytes freed)
-to the bulk-delete banner with a Stop button that cleanly bails
-between directory entries.
-
-**Q: After clicking Replace payload, the version number on the
-Connection screen still shows the old one for a few seconds.**
-Fixed in 2.2.24. The probe loop already had the new version +
-kernel from `payloadCheck` but was discarding both, leaving the
-store with the old values until the next 10-second background
-tick. Two fixes:
-
-- The probe now writes the freshly-booted payload's version +
- kernel into the store the moment it answers — the version
- block flips to the new numbers in lock-step with step 2 going
- "ok," not 10 s later.
-- A new "rechecking…" badge with a spinner renders in the
- VersionBlock while a probe is in flight. Stale numbers are
- dimmed in italics (or the row reads "Probing…" if there's no
- prior value), and the outdated-payload nudge is suppressed
- during the recheck since the comparison would be against
- in-flight data.
-
**Q: Where are app settings saved?**
- **macOS**: `~/Library/Application Support/com.phantomptr.ps5upload/`
- **Windows**: `%APPDATA%\com.phantomptr.ps5upload\`
@@ -1861,22 +1328,20 @@ running. It is not Sony's play-time record; the console does not expose
that to us.
**Q: What is the Cheats screen?**
-It applies memory patches to a **running** game — the usual trainer
-model. A watcher polls every few seconds for a game process, applies
-always-on patches automatically, and re-applies the ones you toggled.
-Cheat files can be downloaded from community repositories from inside
-the screen.
-
-Two formats work (native JSON and SHN trainer XML). The encrypted **MC4**
-format is not supported. Cheats only affect the running game, change
-nothing on disk, and stop when the game does.
+It applies memory patches to a **running** game. A watcher polls every few seconds for a game
+process, applies always-on patches and re-applies the ones you toggled. Cheat files download from
+community repositories inside the screen. Native JSON, SHN trainer XML and encrypted MC4 files all
+work, with game names, filters and a notification when cheats load. Cheats change nothing on disk
+and stop when the game does.
**Q: What is the Remote Play screen?**
-It requests a Remote Play registration from the console, so you can pair
-a client without navigating the PS5's own menus. It shows the pending
-PIN and lets you cancel a request in flight. It does not stream anything
-itself — pairing only; you still use Sony's Remote Play app or Chiaki to
-connect.
+It requests a Remote Play registration from the console so you can pair a client without the PS5's
+own menus. It shows the pending PIN with a countdown, reads "waiting" until a device really pairs,
+and can be cancelled. It does not stream anything; use Sony's Remote Play app or Chiaki.
+
+**Q: What does the fan curve do?**
+It sends a custom temperature-to-fan curve to the console. **Restore default** returns to the
+stock behaviour.
**Q: What is nanoDNS?**
A small DNS server that runs **on the console** (from the Payloads
@@ -1904,15 +1369,12 @@ on `localhost:19113`. The desktop app uses it under the hood; CLI
users can hit the `/api/*` endpoints directly.
**Q: Can I write my own client against the payload?**
-Yes. AVA1 is specified in [`protocol/ava1/SPEC.md`](protocol/ava1/SPEC.md);
-every message is defined once in `protocol/ava1/schema/ava1.toml`, and
-`engine/crates/ava1-gen` generates codecs from it (it already emits Rust
-and C). `protocol/ava1/vectors/` holds byte-exact test vectors your
-implementation must reproduce. You will need to pair with the console:
-it shows a six-digit code that your client must display too.
+Yes. AVA1 is specified in [`protocol/ava1/SPEC.md`](protocol/ava1/SPEC.md); every message is
+defined once in `protocol/ava1/schema/ava1.toml`, and `protocol/ava1/vectors/` holds byte-exact test
+vectors. You must pair with the console: it shows a six-digit code your client must display too.
**Q: How do I contribute a translation?**
-Edit the strings in `client/src/i18n.ts` or use the helper script at
+Edit `client/src/i18n/locales/` (English, `en.ts`, is the source of truth) or use
`scripts/translate-i18n.py`. PRs welcome.
**Q: Why is my anti-virus flagging the app?**
@@ -1926,10 +1388,7 @@ download straight from the
(not a mirror) and report any AV false positive to your vendor.
**Q: How do updates work?**
-The app checks GitHub once per launch (24h-cached) and shows a
-dot on the Settings entry in the sidebar when a newer version is
-available. Settings → Updates → **Download** streams the
-platform-appropriate archive to your Downloads folder and opens
-Finder/Explorer/Files. Quit the app, replace the old one with the
-new download, and relaunch. No automatic install, no signing cert
-needed — the download URL is the GitHub release page.
+The app checks GitHub once per launch and shows a dot on the Settings entry when a newer version
+exists. **Settings → Updates → Download** saves the archive for your platform to your Downloads
+folder (the Windows installer updates itself). Quit, replace the old app, relaunch, then use
+**Update helper** if the app asks. Remember to update the app and the helper together.
diff --git a/Makefile b/Makefile
index 9f98d156..d2634082 100644
--- a/Makefile
+++ b/Makefile
@@ -161,7 +161,7 @@ help:
@echo " make coverage - Rust + frontend coverage reports"
@echo " make coverage-engine - Rust coverage report only"
@echo " make coverage-client - Frontend coverage report only"
- @echo " make test-engine - cargo test --workspace"
+ @echo " make test-engine - cargo test --workspace (C interop serial)"
@echo " make test-desktop - Tauri Rust cargo check/clippy/test"
@echo " make test-ava1 - AVA1 Rust + C conformance and interop tests"
@echo " make test-payload - Validate $(PAYLOAD_ELF)"
@@ -556,6 +556,7 @@ CHECK_NO_FTX2_EXCEPT := \
':!engine/crates/ava1-ctest/tests/lifecycle.rs' \
':!engine/crates/ava1-ctest/tests/payload_cutover.rs' \
':!CHANGELOG.md' \
+ ':!CONTRIBUTING.md' ':!TESTING.md' \
':!protocol/ava1' \
':!Makefile' \
':!.github/workflows/engine-ci.yml' \
@@ -752,8 +753,8 @@ DOCKER_ENGINE_IMAGE ?= ps5upload-engine
docker-engine:
@command -v $(DOCKER) >/dev/null 2>&1 || { echo "ERROR: docker not found on PATH."; exit 1; }
- @echo "Building $(DOCKER_ENGINE_IMAGE) image (context: $(ENGINE_DIR)/)..."
- @$(DOCKER) build -t $(DOCKER_ENGINE_IMAGE) $(ENGINE_DIR)
+ @echo "Building $(DOCKER_ENGINE_IMAGE) image (context: repo root, as in CI)..."
+ @$(DOCKER) build -t $(DOCKER_ENGINE_IMAGE) -f $(ENGINE_DIR)/Dockerfile .
@echo "✓ Built image $(DOCKER_ENGINE_IMAGE) — run with: make docker-engine-run"
# Run the locally-built engine image. Binds the published port and points it at
@@ -819,7 +820,9 @@ test-root:
test-engine: setup-engine
@echo "Running Rust engine tests..."
- @cd $(ENGINE_DIR) && $(CARGO) test --workspace
+ @cd $(ENGINE_DIR) && $(CARGO) test --workspace --exclude ava1-ctest
+ @# The C interop tests share one C server and must run serially (as in CI).
+ @cd $(ENGINE_DIR) && $(CARGO) test -p ava1-ctest -- --test-threads=1
@echo "✓ Engine tests passed"
@# Android cross-compile check. The engine cfg's some modules out on
@# Android (remote_pkg, which needs an HTTP client), so a call site added
diff --git a/README.md b/README.md
index 1b7075b8..ed8b1430 100644
--- a/README.md
+++ b/README.md
@@ -5,177 +5,45 @@
- Fast, reliable transfers from your computer to your PS5.
- Transfer · Install · Convert · Mount · Browse — designed to live alongside your PS5-side tools.
+ PS5 Upload gets your games, apps and homebrew onto a jailbroken PS5 quickly and reliably: uploads resume after a dropped connection, installs pick the route that works, and you can browse files, manage the console and run cheats from your computer, phone or browser.
-
+
---
-## Highlights
-
-- **One way to install any package.** PS4 `.pkg` and PS5 fake packages —
- base games, updates and DLC — install through ps5upload's own on-console
- installer, whether you **Stream & install** straight from your PC (nothing
- copied to the PS5), **Upload & install** (copied first, kept for
- reinstalls), or install from a NAS, a USB drive or a download link. Every
- route shows the same live progress, only says "installed" once the console
- has the whole package, checks that an update really raised the game's
- version, and never wipes your base game.
-- **Turn a game into an installable PS5 package.** **Convert to FPKG**
- builds a fake package from a decrypted game folder or an `.exfat` / `.ffpkg`
- image, on your computer, compressed the way Sony's own packages are — then
- installs it with Stream & install or Upload & install in one click.
-- **Sleep, wake and power from your desk.** Put the PS5 in rest mode, reboot
- or shut it down, and **wake it back up over the network** — even straight
- into your signed-in user with Wake & sign in. The app keeps the console and
- your computer awake while a transfer runs.
-- **Fast, resumable transfers** of games, folders, disk images and `.zip` /
- `.7z` / `.rar` archives, with an upload queue and live speed.
-- **Mount, browse and manage** disk images, files, installed games, saves,
- screenshots and video clips; register, launch and uninstall games.
-- **Backport tools, payload sender, FTP server, hardware view** and more.
-- **Runs everywhere:** macOS, Windows, Linux, Android, or any web browser via
- the self-hosted engine / Docker image — in 21 languages.
-
----
-
## What it does
-- **Fast transfer** — the AVA1 protocol (Adaptive Verified Assembly):
- several connections per transfer, every file verified end to end with
- BLAKE3, tiny files bundled and applied in parallel, and resume after any
- interruption — Wi-Fi drops, console rest mode, app restarts.
- Uses your LAN flat-out. Pack worker absorbs transient
- `EIO`/`EMFILE` hiccups so a 200k-file game upload doesn't get
- killed by one unlucky syscall.
-- **Upload queue** — line up multiple games or images, hit Start,
- walk away. Every running row shows live MiB/s and ETA; done
- rows show the wall-clock-average rate so you can spot a slow
- destination. Queue state survives app restarts.
-- **Compressed archive uploads (`.zip` / `.7z` / `.rar`)** — keep a game
- dump as a single archive on your PC (less disk, easier to move) and
- upload it directly. ps5upload decompresses on the host and streams
- the files into the same AVA1 pipeline, so they land **already
- extracted** on the PS5 — no manual unpack, no temp copy of the whole
- game. The Upload screen previews the expansion (`zipped → extracted`,
- file count, space saved) and detects the embedded game. Decompresses
- one file at a time, so a 100 GB archive doesn't need 100 GB of RAM.
- `.7z` and `.rar` stream straight into the pipeline and write nothing to
- your disk at all; `.zip` spills large entries to a temp file because its
- format needs random access. `.rar` is desktop-only (the UnRAR C dep is
- excluded from the Android build).
-- **Native image mount** — attach `.exfat` and `.ffpkg` images on
- the PS5 (MDIOCATTACH + nmount) with no third-party helper. Every
- mount survives payload restarts and auto-reconciles on startup.
- Mount read-write to **edit an image in place** — add DLC, swap
- assets, or apply a backport patch to a game that ships as an image
- rather than a folder. Edits go straight into the image file, so the
- app warns before you start.
- When ShadowMount+ owns the image (it mounts everything read-only and
- re-adopts anything it finds), **Edit files…** checks the image out —
- moving it out of ShadowMount+'s scan folder, mounting it read-write
- where you choose, and putting it back when you finish. The checkout is
- journalled on the console, so an edit interrupted by a crash or a
- reboot can still be finished later, from any machine.
-- **Browse everything** — list games anywhere on the PS5 (including
- inside mounted images), disk images, files, and volumes. Run FS
- ops (add files, replace a file, chmod, delete, move, copy, mkdir)
- with a real directory tree. Bulk delete of a 200k-file folder
- shows live progress with a working Stop button.
-- **NAS / SMB upload** — browse a Windows share or Samba NAS from the
- app, download to this computer, or **upload a file or folder
- straight to the PS5** in one step (streamed from the share over AVA1,
- resuming where it left off — no 2 GiB memory cap on that path).
-- **FTP server on the PS5** — optional built-in FTP for FileZilla and
- other clients (default port **2122**, so it coexists with ftpsrv on
- 2121). Not a replacement for the Upload tab.
-- **Backport helpers** — Fakelib folder manager, BPS patch apply, and
- SDK Version Changer for folder dumps that need newer system libs.
- Runtime mount is still BackPork or ShadowMount+ (don’t run both).
-- **Stay-in-the-loop notifications** — in-app alerts mirror to your
- computer's notification center (macOS / Windows / Linux, plus the
- Android shade) when a transfer finishes or fails in the background,
- and the PS5 itself shows a toast when an upload starts and completes.
- Your machine is kept awake automatically while a transfer runs.
-- **Hardware view** — model, serial, uptime, storage and RAM, refreshed
- live; plus a fan-threshold control that rings through to
- `/dev/icc_fan` for quieter operation. Temperatures (CPU, SoC, M.2) and
- CPU frequency are read **on demand** (a button), not auto-polled — each
- read briefly pauses the system UI, so polling them could destabilize the
- console. Some readings on that panel are simply not available on retail
- firmware and show `—`: SoC clock, SoC power, CPU usage, fan duty, and
- the per-drive sensor list. Storage totals and temperatures do work.
-- **Power control & wake** — reboot, rest mode, or shut down the PS5 from
- the app, and **wake it from standby** over the network (Sony's discovery
- protocol — a PS5 does not respond to Wake-on-LAN). With the console's
- Remote Play session keys stored, **Wake & sign in** brings it up straight
- on your user's home screen instead of the user-select screen, the way the
- PS Remote Play app does. Waking needs three console settings enabled
- (Remote Play, plus *Stay Connected to the Internet* and *Enable Turning On
- PS5 from Network* under Rest Mode) — the app spells them out, since without
- them the wake fails silently.
-- **Send any payload** — push `.elf`, `.bin`, `.js`, `.lua`, or
- `.jar` files to the PS5's loader port (typical defaults: `.elf` →
- 9021 elfldr, `.js` → 50000 WebKit-stage, `.lua` → 9026, `.jar` →
- 9025 BD-JB / BDJ; custom loaders may listen anywhere). Recent-sends
- history with click-to-replay and per-row success/fail badges. Curated
- payload catalogue (kstuff, ShadowMount+, etaHEN, …) fetches releases on
- demand — or **add your own GitHub/Gitea repo** and it tracks + caches
- that too. **Payload playlists** run a scripted boot sequence, and a
- playlist step can pull straight from a repo at run time so you don't
- keep a pile of `.elf` files on your PC.
-- **Install packages** — install a PS4 `.pkg` or PS5 fake package two ways:
- **Stream & install** sends it straight from your PC (nothing is copied to
- the PS5 first — handy when console storage is tight), and **Upload &
- install** copies it to the PS5's package library first, where it stays so
- you can **Install**, **Reinstall** or **Delete** it later without
- re-uploading. **Install all** does a whole set in base → update → DLC order.
- Packages on a NAS/SMB share or behind an HTTP(S) link install the same way.
- Everything goes through ps5upload's own on-console installer, and an
- install is only marked done once the console has pulled the whole package
- and the result checks out — an update that didn't raise the game's version
- is reported, not called a success. Base, update and DLC installs verified
- on FW 5.10.
-- **Convert to FPKG** — turn a decrypted game folder or an `.exfat` /
- `.ffpkg` image into an installable fake package on your computer, with the
- same compression Sony's packages use, then install it with Stream & install
- or Upload & install.
-- **Web browser access** — run the engine (or the official Docker image)
- and manage your PS5 from any browser on the LAN, the full app served
- over HTTP. No desktop install needed on that machine. Unauthenticated —
- keep it on a trusted LAN. See the self-hosted-engine FAQ below.
-- **Screenshots & video clips** — browse the PS5's Capture Gallery,
- preview, and download screenshots (HDR `.jxr` auto-converted to PNG) or
- gameplay video clips to your computer.
-- **Register + launch** — Library row's Play button always registers
- first (idempotent if already registered), retries with DRM-type
- patch on rejection, then launches. Unmount unregisters every
- title inside the image first so the dashboard stays clean —
- no ghost tiles after unmount.
-- **Speaks your language** — the whole UI, including error messages
- and troubleshooting hints, is available in 21 languages: English,
- Simplified & Traditional Chinese, Spanish, Hindi, Arabic, Bengali,
- Brazilian Portuguese, Russian, Japanese, German, French, Korean,
- Turkish, Vietnamese, Indonesian, Italian, Thai, Polish, Hungarian, and Persian.
+- **Fast, resumable uploads** of games, folders, disk images and `.zip` / `.7z` / `.rar`
+ archives (unpacked on the way, nothing extracted to your disk). Everything runs over
+ **AVA1**, one encrypted connection on port 9120. Every file is verified, and an upload
+ continues after a dropped connection, a helper restart or rest mode. A job that will not
+ fit is refused up front, counting what is already on the console. Queue, live speed and ETA.
+- **Install any package.** PS4 `.pkg` and PS5 fake packages (base, update, DLC) from your
+ computer (**Stream & install** or **Upload & install**), a NAS/SMB share, a USB drive or a
+ link. If the PS5 refuses one route, the app offers another.
+- **Convert to FPKG.** Build an installable package from a decrypted game folder or an
+ `.exfat` / `.ffpkg` image on your computer, then install it in one click.
+- **Browse and manage.** Files, games, disk images (mount, edit in place), saves, screenshots
+ and video clips; register, launch, stop and uninstall games; copy, move, delete.
+- **Cheats, fan curve, hardware view,** Remote Play pairing, payload sender with a catalogue
+ and playlists, backport tools, and an optional FTP server on the PS5.
+- **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.
## What it doesn't do
-- **System pkg patches.** `sceAppInstUtilInstallByPackage` is built
- for game pkgs; NPXS-prefix system pkgs (Store updates, Settings
- app patches) register but the install path freezes Sony's mgmt
- service mid-flight on most firmwares. Use the on-PS5 Settings →
- Debug Settings → Game → Package Installer for those.
-- **Insecure pkg signing keys.** The engine only installs pkgs signed
- with keys already in the PS5's trust store. It does not generate or
- inject fake signing keys.
+- **System package patches** (NPXS Store or Settings updates). Use the PS5's own
+ Settings → Debug Settings → Game → Package Installer.
+- **Sign packages with fake keys.** It only installs packages the console already trusts.
## A quick look
@@ -310,7 +178,7 @@ only needs to be done once per install.
The app checks GitHub for updates once per launch (Settings → Updates)
and downloads a fresh archive to your Downloads folder when you click
-Download — replace the old app manually and relaunch.
+Download. Replace the old app and relaunch; the app then offers to update the helper.
Building from source:
@@ -350,18 +218,21 @@ For per-platform bundles only (no full dev env): `make dist-mac`,
## Quick start
-1. Launch ps5upload on your computer.
-2. Open the **Connection** tab and enter your PS5's IP address.
-3. Click **Check**, then **Send payload**.
-4. Once the third step turns green, go to any other tab — you're
- connected.
+1. Jailbreak the PS5 and keep an ELF loader running on port **9021**.
+2. Launch ps5upload and open **Connection**. Enter the PS5's IP address.
+3. Click **Check**, then **Send payload**. The app launches the helper and pairs with it by
+ itself.
+4. If you loaded the helper another way, the console shows a **6-digit code**. Enter it in the
+ app once; the computer is remembered.
-The payload stays loaded until the PS5 reboots or goes into rest mode.
+The helper stays until the PS5 reboots or enters rest mode. The app only needs ports **9120**
+(the helper) and **9021** (the loader); allow both in your firewall. 6.0 cannot talk to a
+v5.x helper: the app offers **Update the helper** and replaces it in one click.
## Architecture
```
-client/ (Tauri 2 · React · TypeScript)
+client/ (Tauri 2 · React · TypeScript) Android app · Docker web UI
│
└── spawns ── ps5upload-engine (HTTP :19113)
│
@@ -369,307 +240,71 @@ client/ (Tauri 2 · React · TypeScript)
payload/ps5upload.elf (PS5 C payload)
```
-Three layers:
+- **`payload/`**: C payload on the PS5 (FreeBSD 11). Transfers, BLAKE3 verification, mounts,
+ file operations and every management call. Spec: [`protocol/ava1/SPEC.md`](protocol/ava1/SPEC.md).
+- **`engine/`**: Rust workspace with the AVA1 client, transfer logic, HTTP API and lab tools.
+- **`client/`**: Tauri 2 desktop app (and the Android and web builds), talking to the engine.
-- **`payload/`** — C payload that runs on the PS5 (FreeBSD 11).
- Port 9120 (AVA1). Handles transfers, BLAKE3 verification, mount
- pipelines, FS ops and every management call. The protocol spec is
- [`protocol/ava1/SPEC.md`](protocol/ava1/SPEC.md).
-- **`engine/`** — Rust workspace with the AVA1 protocol, transfer
- logic, HTTP service, lab CLI (with its benchmarks), and the
- loopback test console.
-- **`client/`** — Tauri 2 desktop app. Tauri IPC commands proxy to the
- sidecar HTTP engine, keeping the engine usable from CLI / CI too.
+## Build and test
-## Build
+All workflows go through the root `Makefile` (`make help`).
-All workflows go through the root `Makefile` (see `make help`).
-
-| Target | What it does |
+| Command | What it does |
|---|---|
| `make build` | Payload + engine + client |
-| `make payload` | PS5 ELF at `payload/ps5upload.elf` (requires `PS5_PAYLOAD_SDK` env var) |
-| `make engine` | `cargo build --workspace` |
-| `make client` | `vite build` in `client/` |
-| `make run-client` | Tauri dev (vite + Rust main process) |
-| `make run-engine` | `ps5upload-engine` on `localhost:19113` |
-| `make send-payload` | Send the built ELF to `PS5_HOST:PS5_LOADER_PORT` |
-| `npm run validate` | Full non-hardware quality gate |
-| `make quality` | Same full non-hardware quality gate via Make |
-| `npm run coverage` | Generate frontend + Rust coverage reports |
-| `make coverage` | Same coverage reports via Make |
-| `make test` | Script syntax + engine tests + payload validation + client build |
-| `make test-engine` | `cargo test --workspace` (no hardware needed) |
-| `make dist` | Tauri bundle under `client/src-tauri/target/release/bundle/` |
+| `make run-client` | Tauri dev app |
+| `make run-engine` | Engine on `localhost:19113` |
+| `make test-engine` | Rust tests, no PS5 needed |
+| `make test-payload` | Console C code compiled and run on the host |
+| `npm run validate` | Full non-hardware gate |
+| `make dist` | Tauri bundle |
-## Test
-
-Unit and integration tests run entirely on your computer — the AVA1
-protocol is tested against loopback peers and the console-side C code
-is compiled and run on the host. No PS5 needed:
-
-```bash
-make test-engine
-```
-
-Full local quality gate and coverage reports:
-
-```bash
-npm run validate
-npm run coverage
-```
-
-Real-hardware smoke test (requires payload already loaded):
-
-```bash
-npm run smoke:hardware
-```
-
-See [`TESTING.md`](TESTING.md) for the complete loopback-test, coverage,
-cross-platform, and live-PS5 validation workflow.
+See [`TESTING.md`](TESTING.md) for the full workflow, and [`CONTRIBUTING.md`](CONTRIBUTING.md).
## Tech stack
-- **Payload** — C (FreeBSD 11), prospero-clang toolchain
-- **Engine** — Rust (edition 2021), tokio + axum 0.8
-- **Desktop client** — Tauri 2, React, TypeScript, Zustand,
- Tailwind CSS v4, Vite
-- **Protocol** — AVA1 (binary frames generated from one schema for C and
- Rust; Noise XX handshake, ChaCha20-Poly1305, BLAKE3 file verification)
+- **Payload**: C (FreeBSD 11), prospero-clang, PS5 Payload SDK v0.43
+- **Engine**: Rust, tokio + axum
+- **Client**: Tauri 2, React, TypeScript, Zustand, Tailwind CSS v4, Vite
+- **Protocol**: AVA1 (Noise XX handshake, ChaCha20-Poly1305, BLAKE3 verification)
## Supported platforms
-**Desktop client**
+- **Desktop:** macOS, Linux and Windows, x64 and arm64.
+- **Android:** the `.apk` from Releases.
+- **Web UI:** the self-hosted Docker image, in any browser on your LAN (see below).
+- **PS5 firmware:** 1.00 through 13.60 on every model; one payload binary for all. Tested on
+ 5.10, 9.60 and 13.60. In practice the limit is your jailbreak's ELF loader on port 9021.
-| | x64 | arm64 |
-|---|---|---|
-| **macOS** | ✓ | ✓ |
-| **Linux** | ✓ | ✓ |
-| **Windows** | ✓ | ✓ |
+Fake package install needs live kernel patches (kstuff / fpkg-enable) on the console; where
+they are missing the app says so instead of reporting a false success.
-**PS5 payload** — every firmware the PS5 Payload SDK supports,
-currently **1.00 through 13.60** on every console model (original
-CFI-1xxx, Slim CFI-2xxx, Pro CFI-7xxx, Digital). Built against SDK
-v0.43, which ships per-firmware kernel offsets and resolves them at
-payload startup via `kernel_get_fw_version()` — the same binary
-runs on every supported firmware without per-release rebuilds.
+## Self-hosted engine and web UI
-| Ceiling | Range | Meaning |
-|---|---|---|
-| **Payload binary** | **1.00 – 13.60** | Same ELF runs on every FW the SDK has offsets for |
-| **Practical jailbreak** | **~1.00 – 12.70** | Limited by the public ELF loader / exploit chain, not by our payload |
-| Hardware-tested here | **5.10** and **9.60** | Core transfer, mount, browse, install, FTP, metadata |
+Run the engine on a NAS or server and use it from a browser, or point the desktop app at it
+with **Settings → Engine URL**.
-> Fake/debug package install depends on the console's jailbreak having live kernel
-> patches (kstuff / fpkg-enable). On builds where those aren't active the
-> installer reports it honestly rather than claiming a false success — see
-> the FAQ on install firmware support. `.ffpkg` / `.ffpfs` are UFS images for
-> the mount flow, not install-package filename variants.
-
-The process-list feature (Hardware tab's process snapshot) reads
-`kinfo_proc` via `sysctl(KERN_PROC_PROC)` with field offsets that
-have been stable across every SDK-supported firmware — pid at
-byte 72, thread name at byte 447. No firmware-specific fallback
-table required; real command names appear across the full 1.00 –
-13.60 range. Transfer, mount, file browse, hardware monitor and FS
-ops work identically across all supported firmwares. The Hardware
-tab's blank readings (SoC clock, SoC power, CPU usage, fan duty,
-date/time) are values retail firmware doesn't expose at all, not a
-firmware-version difference — temperatures and CPU frequency read
-fine on 5.10 and 9.60 alike.
-
-**What actually gates users in practice is the ELF loader** on
-port 9021 — a third-party component, not part of ps5upload.
+- Images: `ghcr.io/phantomptr/ps5upload-engine-webui` (full web UI) and
+ `ghcr.io/phantomptr/ps5upload-engine` (engine only), tagged `:latest` or `:`.
+- Start from [`engine/compose.yaml`](engine/compose.yaml). It uses host networking (stream
+ installs need the PS5 to reach the engine). Set `PS5_ADDR` and `PS5UPLOAD_ALLOW_IP`.
+- Open `http://:19113`. The file picker browses the engine's disk; mount your games
+ folder into the container and set `PS5UPLOAD_BROWSE_ROOTS`.
+- An upload keeps running when you close the tab.
+- **No password.** Anyone allowed can read, write and delete on your PS5. Keep it on a trusted
+ LAN, never on the internet.
## FAQ
-**Q: "Connection Refused" or it won't connect?**
-* Did you load the payload first? The PS5 stops listening after a
- reboot or rest-mode cycle — send the payload again from the
- **Connection** tab.
-* Is your computer's firewall blocking outbound connections to
- port 9120 / 9021 on your PS5?
-* Your computer and PS5 don't have to be on the same subnet, but
- there has to be a route to the IP.
-
-**Q: It connects, then drops within seconds?**
-* If you load ps5upload with an autoloader or PLDMGR, put **elfldr first**
- in its list, before `ps5upload.elf`. Without elfldr loaded first,
- ps5upload connects and then drops after a few seconds. Loading elfldr
- once from PLDMGR and then sending ps5upload again also fixes it.
-
-**Q: Stream & install fails before the PS5 downloads anything?**
-* The PS5 pulls the package from your computer, so it has to be able to
- reach it. Allow ps5upload through your computer's firewall (on Windows,
- for both Private and Public networks), keep the computer and the PS5 on
- the same network with any VPN off, and set the PS5's Proxy Server to
- "Do Not Use". **Upload & install** works without this connection.
-
-**Q: Resume gets stuck on "Checking what's already on your PS5…"?**
-* Reconcile scoped to the *local* tree's parent directories, so a
- single-file upload into a folder that already holds other games
- is now one `FS_LIST_DIR` call instead of a recursive walk of the
- whole destination. Update to the latest build.
-* Safe-mode reconcile still hashes every same-size remote file via
- BLAKE3 — that's ~2–3 s per GiB on PS5 UFS. Use Fast mode for
- single-file or large-file transfers.
-
-**Q: Do I need a LAN cable?**
-* Not strictly, but Wi-Fi caps throughput well below what the PS5
- NIC can actually do. Plug in an Ethernet cable for the best
- experience.
-
-**Q: Can I use this over the Internet?**
-* Technically yes — AVA1 (port 9120) encrypts and authenticates every
- byte and only talks to paired devices. We still don't recommend
- exposing an exploited PS5 to the open Internet; use a VPN to reach
- your home network instead.
-
-**Q: How do I install / launch a game from the Library tab?**
-* The Library row exposes **Mount** for `.exfat` / `.ffpkg` /
- `.ffpfs` images plus **Register** / **Register (patch DRM)** /
- **Launch** / **Unregister** buttons on the games inside.
-* **Mount** is hardware-validated on FW 9.60 — a 76 GiB UFS
- `.ffpkg` mounts on `/dev/lvd1`, appears in Volumes with the
- correct `source_image`, and unmounts cleanly. The new round's
- payload uses compile-time `-lSce*` linkage so the rtld
- initialises the Sony sprx state via `DT_NEEDED` before main()
- runs.
-* **Register** is hardware-validated: pointing it at a folder
- game with `eboot.bin` + `sce_sys/param.json` succeeds end to
- end — `sceAppInstUtilAppInstallTitleDir` returns 0, the title
- appears in `app.db` with the correct title name, and a nullfs
- bind is installed at `/system_ex/app/`. Idempotent —
- re-registering the same path returns the same result.
-* **Register (patch DRM)** rewrites the source's
- `sce_sys/param.json`'s `applicationDrmType` to `"standard"`
- before staging — needed for PSN-extracted dumps that ship with
- `"PSN"` or `"disc"`. Modifies the source file in place; only
- use when a normal Register fails with a DRM error.
-* **Launch** routes `sceLncUtilLaunchApp` through a ptrace RPC
- into `SceShellUI` so Sony's `getpid() == SceShellUI.pid`
- caller-context check passes natively. Hardware-validated on
- FW 9.60: hitting Launch on a registered title actually starts
- the game (SoC power draw confirms the title comes up). Falls
- back to a direct call when ShellUI RPC isn't available.
-* **Listing inside an active mount** (`/mnt/ps5upload//...`)
- is gated by the PS5 sandbox / LVD mount permission set rather
- than payload privilege. We re-check after every credential
- elevation; treat "ENOTDIR descending into a mount" as
- expected for now. Other PS5-side tools see the mount via
- `/mnt/ps5upload/`, and the source path is recorded in our
- tracker so reconcile-on-next-boot keeps state consistent.
-* Sensors are read **on demand** (the Hardware tab's "Read sensors"
- button), not on a timer. Each read briefly ptrace-pauses the system UI,
- and doing that on a loop could destabilize the console — it powered
- some consoles off — so the auto-refresh covers only the ptrace-free
- data (info, uptime, storage).
-* Which readings actually work, measured on FW 9.60 and 5.10:
- * **Work:** CPU / SoC / M.2 temperature, CPU frequency, storage totals.
- * **Show `—`:** SoC clock, SoC power, CPU usage, fan duty, per-drive
- sensors, and the console date/time. These aren't broken payload code
- — retail firmware doesn't expose them to us. A persistent `—` there
- is the expected result, not a fault to report.
-
-**Q: "No writable storage found"?**
-* The tool blocks writes to read-only system partitions. If you
- want to write to a USB drive, make sure it's formatted (exFAT is
- best) and plugged in *before* you load the payload.
-
-**Q: macOS: "App is damaged" or "Unidentified Developer"?**
-* This is normal for unsigned apps. Right-click the app, select
- **Open**, then click **Open** in the dialog.
-* If macOS still blocks it, **System Settings → Privacy &
- Security → Open Anyway**.
-* Last resort — remove the quarantine flag:
- ```bash
- xattr -dr com.apple.quarantine /Applications/ps5upload.app
- ```
-* No Apple Developer account is required; the app is intentionally
- unsigned.
-
-**Q: Where are config and logs saved?**
-* The desktop client uses the OS app-data directory:
- * **Windows:** `%APPDATA%\com.phantomptr.ps5upload`
- * **macOS:** `~/Library/Application Support/com.phantomptr.ps5upload`
- * **Linux:** `~/.local/share/com.phantomptr.ps5upload`
-
-**Q: Does this work on PS4?**
-* No. The payload is compiled specifically for the PS5 (FreeBSD
- 11, Zen 2) and calls PS5-only kernel entry points.
-
-**Q: What about older firmware (≤ 9.00)?**
-* The payload's transfer code doesn't call firmware-gated APIs, but
- the ELF loader workflow on port 9021 depends on what your
- jailbreak exposes. Patches welcome.
-
-**Q: Can I run ps5upload headless / over SSH?**
-* Not the GUI. The `ps5upload-engine` binary speaks HTTP on
- `:19113` and exposes the full transfer / reconcile / FS API, so
- you can script transfers from a terminal or CI job without ever
- opening the desktop client.
-
-**Q: Can the engine run on a different machine (remote / self-hosted)?**
-* Yes (3.3.7+). Host the engine elsewhere and point the desktop app at it via
- **Settings → Engine URL**. An official multi-arch image is published at
- `ghcr.io/phantomptr/ps5upload-engine` (`:latest` or `:`); you can
- also build from `engine/Dockerfile`. To let a remote box reach it, set
- `PS5UPLOAD_ALLOW_IP` to that box's IP or your LAN's range
- (`192.168.1.0/24`). On a Linux server, start from
- [`engine/compose.yaml`](engine/compose.yaml): it uses host networking, which
- stream installs need (on Docker's default bridge network the PS5 can't reach
- the engine to download the package), and keeps the engine's state on a
- `/data` volume. With a remote engine, the desktop app's file pickers browse
- the engine's disk. **Security:** the engine's API is
- unauthenticated (it can read/write/delete PS5 files), so only do this on a
- trusted LAN — never expose the engine to the internet.
-
-**Q: Can I run ps5upload from a browser, with no desktop app at all (self-hosted web UI)?**
-* Yes (3.3.25+). A separate `webui` build of the engine serves the **full
- React app over HTTP**, so you can manage your PS5 from any browser on the
- LAN — handy for a NAS or headless box you don't want to install the desktop
- client on. An official multi-arch image is published at
- `ghcr.io/phantomptr/ps5upload-engine-webui` (`:latest` or `:`); or
- build it yourself with
- `docker build -f engine/Dockerfile.webui -t ps5upload-engine-webui .`
- (build context is the **repo root**, not `engine/` — it needs `client/` to
- build the frontend first). Run it the same way as the plain engine image
- (`PS5_ADDR`, `PS5UPLOAD_ALLOW_IP`) and open `http://:19113` in a
- browser on an allowlisted IP. Same security model as the remote engine
- above: **unauthenticated**, LAN-only, never expose it to the internet.
- **Upload works in the browser** too, operating on files already
- present on the **engine's own machine** — e.g. mount a folder into the
- container with `-v /host/games:/pkgs:ro`, then browse to `/pkgs`
- in the in-app file picker. That picker browses the *engine's* filesystem,
- not the browser's own machine — a browser tab has no way to reach a
- remote engine's disk except through what the engine itself can already
- read. The picker starts at the engine's home directory (`/data` in the
- image) unless you point it elsewhere with `PS5UPLOAD_BROWSE_ROOTS=/pkgs`;
- the value is comma-separated, so it can offer more than one root.
- A few things are still desktop-only and hidden in the browser UI:
- archive uploads (`.zip`/`.7z`/`.rar`), Payloads (sending a `.elf`/etc. from
- disk) and saving a save-data
- backup to your computer — everything else that operates on the PS5 itself
- (browse, transfer, install from a PS5-connected USB drive, hardware
- monitor, saves list, …) works the same as the desktop app. The upload
- queue also doesn't persist across a full page reload in browser mode
- (queue persistence is desktop-only).
-* Installing packages from the browser needs an engine that carries
- ps5upload's on-console installer (`ps5upload-installer.elf`): every
- install goes through it, and the engine sends it to the console when it
- isn't already running there (it runs alongside the ps5upload helper and
- never replaces it). The released engine binaries and the official Docker
- images include it, and so does an engine built from a full checkout — the
- ELF is in the repository. You can also point the engine at your own copies
- with `-e PS5UPLOAD_PAYLOAD_DIR=/path/to/elves`.
+Common questions (connection problems, install errors, USB drives, firmware) are in
+[`FAQ.md`](FAQ.md), also available inside the app.
## Contributing
- Report bugs:
[GitHub Issues](https://github.com/phantomptr/ps5upload/issues)
-- Pull requests welcome — please read
- `.github/PULL_REQUEST_TEMPLATE.md` and run `make test` locally
- before opening.
+- Pull requests welcome. Read [`CONTRIBUTING.md`](CONTRIBUTING.md) and run the checks in
+ [`TESTING.md`](TESTING.md) before opening.
## Disclaimer
diff --git a/TESTING.md b/TESTING.md
index 7a3954b5..50cadf3b 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -37,6 +37,15 @@ caught real regressions:
Running a narrower check and assuming the wider one follows is the most
common way to burn a cycle here.
+`npm run validate` is not the whole gate. Before a PR also run:
+
+```sh
+( cd client/src-tauri && cargo fmt --all -- --check ) # validate only checks fmt in engine/
+( cd engine && cargo test -p ava1-ctest -- --test-threads=1 )
+make test-payload # needs PS5_PAYLOAD_SDK
+make check-no-ftx2 # the retired protocol must stay gone
+```
+
## Layer 2 — payload build and self-tests
```sh
@@ -48,17 +57,10 @@ Builds `payload/ps5upload.elf` and the DPI daemon, checks both are PS5
ELFs with valid gzip resources, then runs every host self-test with
`-Wall -Wextra -Werror`:
-| Self-test | Pins |
-|---|---|
-| `hw_guard` | Recovering from a faulting Sony getter without losing the helper |
-| `ptrace_recovery` | Timeout recovery never resuming injected registers |
-| `timed_init` | Bounded one-time init never starting a second initializer |
-| `appdb_scan` | Reading `app.db` as real SQLite records, not printable runs |
-| `ftp_format` | PASV/EPSV/LIST reply shapes clients parse strictly |
-| `ftp_lifecycle` | Session registration, generations, stop-to-kill |
-| `sdk_param` | `param.json` rewrite, and reporting when nothing changed |
-| `elf_param` | Finding SDK fields via program headers, not stray magic bytes |
-| `sdk_changer_file` | Patching only tracked sources; durable backups |
+Each self-test lives in `payload/tests/*_selftest.c` (hardware guards, ptrace recovery,
+`app.db` reading, FTP, SDK param rewriting, installer, cheats, wake watchdog and more). The
+AVA1 C itself is tested by `ava1-ctest`, which compiles it on the host and checks it against the
+Rust side (see the full gate below).
The SDK version is pinned in `scripts/ps5-sdk.env` (currently **v0.43**)
and verified by checksum. Local installers and CI read the same file.
@@ -220,8 +222,7 @@ out-of-bounds read in release. `0.5.8` is the latest release, so the crate
is vendored at `third_party/unrar` with the fix and both workspaces
redirect to it via `[patch.crates-io]`.
-See `third_party/unrar/README.md` for the patch and
-`docs/unrar-upstream-bug.md` for the report to send upstream. If you bump
+See `third_party/unrar/README.md` for the patch. If you bump
the crate, re-apply the hunk or drop the vendoring once upstream ships the
fix.
diff --git a/VERSION b/VERSION
index b9f26f97..09b254e9 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-5.41.0
+6.0.0
diff --git a/client/package-lock.json b/client/package-lock.json
index d31b05e1..af1842b2 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ps5upload-client",
- "version": "5.41.0",
+ "version": "6.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ps5upload-client",
- "version": "5.41.0",
+ "version": "6.0.0",
"dependencies": {
"@tauri-apps/api": "^2.12.0",
"@tauri-apps/plugin-dialog": "^2.8.0",
diff --git a/client/package.json b/client/package.json
index 5ed23f02..688f8192 100644
--- a/client/package.json
+++ b/client/package.json
@@ -2,7 +2,7 @@
"name": "ps5upload-client",
"private": true,
"type": "module",
- "version": "5.41.0",
+ "version": "6.0.0",
"description": "The all-in-one PS5 companion app.",
"homepage": "https://github.com/phantomptr/ps5upload",
"author": "PhantomPtr ",
diff --git a/client/src-tauri/Cargo.lock b/client/src-tauri/Cargo.lock
index d62af1a7..f6aab8b8 100644
--- a/client/src-tauri/Cargo.lock
+++ b/client/src-tauri/Cargo.lock
@@ -305,7 +305,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "ava1"
-version = "5.41.0"
+version = "6.0.0"
dependencies = [
"blake2 0.10.6",
"blake3",
@@ -4094,7 +4094,7 @@ dependencies = [
[[package]]
name = "ps5upload-ava1"
-version = "5.41.0"
+version = "6.0.0"
dependencies = [
"anyhow",
"ava1",
@@ -4112,7 +4112,7 @@ dependencies = [
[[package]]
name = "ps5upload-core"
-version = "5.41.0"
+version = "6.0.0"
dependencies = [
"aes 0.9.2",
"anyhow",
@@ -4135,7 +4135,7 @@ dependencies = [
[[package]]
name = "ps5upload-desktop"
-version = "5.41.0"
+version = "6.0.0"
dependencies = [
"anyhow",
"ava1",
@@ -4169,7 +4169,7 @@ dependencies = [
[[package]]
name = "ps5upload-engine"
-version = "5.41.0"
+version = "6.0.0"
dependencies = [
"aes-gcm 0.11.0",
"anyhow",
@@ -4203,7 +4203,7 @@ dependencies = [
[[package]]
name = "ps5upload-fpkg"
-version = "5.41.0"
+version = "6.0.0"
dependencies = [
"aes 0.9.2",
"crc32fast",
@@ -4222,7 +4222,7 @@ dependencies = [
[[package]]
name = "ps5upload-pkg"
-version = "5.41.0"
+version = "6.0.0"
dependencies = [
"blake3",
"serde",
diff --git a/client/src-tauri/Cargo.toml b/client/src-tauri/Cargo.toml
index 44f65c8a..f0501d3d 100644
--- a/client/src-tauri/Cargo.toml
+++ b/client/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "ps5upload-desktop"
-version = "5.41.0"
+version = "6.0.0"
description = "The all-in-one PS5 companion app."
edition = "2021"
rust-version = "1.77"
diff --git a/client/src-tauri/icons/128x128.png b/client/src-tauri/icons/128x128.png
index f5403fe8..5c69398b 100644
Binary files a/client/src-tauri/icons/128x128.png and b/client/src-tauri/icons/128x128.png differ
diff --git a/client/src-tauri/icons/128x128@2x.png b/client/src-tauri/icons/128x128@2x.png
index b854f454..15953952 100644
Binary files a/client/src-tauri/icons/128x128@2x.png and b/client/src-tauri/icons/128x128@2x.png differ
diff --git a/client/src-tauri/icons/64x64.png b/client/src-tauri/icons/64x64.png
index 0954ef60..522c6aae 100644
Binary files a/client/src-tauri/icons/64x64.png and b/client/src-tauri/icons/64x64.png differ
diff --git a/client/src-tauri/icons/Square107x107Logo.png b/client/src-tauri/icons/Square107x107Logo.png
index a1a7404a..25c6722e 100644
Binary files a/client/src-tauri/icons/Square107x107Logo.png and b/client/src-tauri/icons/Square107x107Logo.png differ
diff --git a/client/src-tauri/icons/Square142x142Logo.png b/client/src-tauri/icons/Square142x142Logo.png
index 7fd0958c..ad586b31 100644
Binary files a/client/src-tauri/icons/Square142x142Logo.png and b/client/src-tauri/icons/Square142x142Logo.png differ
diff --git a/client/src-tauri/icons/Square150x150Logo.png b/client/src-tauri/icons/Square150x150Logo.png
index 6b4df05a..ee572778 100644
Binary files a/client/src-tauri/icons/Square150x150Logo.png and b/client/src-tauri/icons/Square150x150Logo.png differ
diff --git a/client/src-tauri/icons/Square284x284Logo.png b/client/src-tauri/icons/Square284x284Logo.png
index b0f5748e..8a18467c 100644
Binary files a/client/src-tauri/icons/Square284x284Logo.png and b/client/src-tauri/icons/Square284x284Logo.png differ
diff --git a/client/src-tauri/icons/Square310x310Logo.png b/client/src-tauri/icons/Square310x310Logo.png
index c266d75c..ef1ff935 100644
Binary files a/client/src-tauri/icons/Square310x310Logo.png and b/client/src-tauri/icons/Square310x310Logo.png differ
diff --git a/client/src-tauri/icons/Square44x44Logo.png b/client/src-tauri/icons/Square44x44Logo.png
index 0e16e608..93e822c6 100644
Binary files a/client/src-tauri/icons/Square44x44Logo.png and b/client/src-tauri/icons/Square44x44Logo.png differ
diff --git a/client/src-tauri/icons/Square71x71Logo.png b/client/src-tauri/icons/Square71x71Logo.png
index 92541b5e..9789d463 100644
Binary files a/client/src-tauri/icons/Square71x71Logo.png and b/client/src-tauri/icons/Square71x71Logo.png differ
diff --git a/client/src-tauri/icons/Square89x89Logo.png b/client/src-tauri/icons/Square89x89Logo.png
index 28a76abd..95c4d2b1 100644
Binary files a/client/src-tauri/icons/Square89x89Logo.png and b/client/src-tauri/icons/Square89x89Logo.png differ
diff --git a/client/src-tauri/icons/StoreLogo.png b/client/src-tauri/icons/StoreLogo.png
index 9244b03b..85c3cb95 100644
Binary files a/client/src-tauri/icons/StoreLogo.png and b/client/src-tauri/icons/StoreLogo.png differ
diff --git a/client/src-tauri/icons/icon.icns b/client/src-tauri/icons/icon.icns
index c98fd576..afd2f15b 100644
Binary files a/client/src-tauri/icons/icon.icns and b/client/src-tauri/icons/icon.icns differ
diff --git a/client/src-tauri/icons/icon.ico b/client/src-tauri/icons/icon.ico
index 72b044bb..2ff4ccf8 100644
Binary files a/client/src-tauri/icons/icon.ico and b/client/src-tauri/icons/icon.ico differ
diff --git a/client/src-tauri/icons/icon.png b/client/src-tauri/icons/icon.png
index 23135f9c..825cfa53 100644
Binary files a/client/src-tauri/icons/icon.png and b/client/src-tauri/icons/icon.png differ
diff --git a/client/src-tauri/icons/ios/AppIcon-20x20@1x.png b/client/src-tauri/icons/ios/AppIcon-20x20@1x.png
index c8325597..64d6b57d 100644
Binary files a/client/src-tauri/icons/ios/AppIcon-20x20@1x.png and b/client/src-tauri/icons/ios/AppIcon-20x20@1x.png differ
diff --git a/client/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/client/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
index e5a07a73..6d361887 100644
Binary files a/client/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and b/client/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ
diff --git a/client/src-tauri/icons/ios/AppIcon-20x20@2x.png b/client/src-tauri/icons/ios/AppIcon-20x20@2x.png
index e5a07a73..6d361887 100644
Binary files a/client/src-tauri/icons/ios/AppIcon-20x20@2x.png and b/client/src-tauri/icons/ios/AppIcon-20x20@2x.png differ
diff --git a/client/src-tauri/icons/ios/AppIcon-20x20@3x.png b/client/src-tauri/icons/ios/AppIcon-20x20@3x.png
index 2494e922..364bae78 100644
Binary files a/client/src-tauri/icons/ios/AppIcon-20x20@3x.png and b/client/src-tauri/icons/ios/AppIcon-20x20@3x.png differ
diff --git a/client/src-tauri/icons/ios/AppIcon-29x29@1x.png b/client/src-tauri/icons/ios/AppIcon-29x29@1x.png
index d6361a3b..778b3e59 100644
Binary files a/client/src-tauri/icons/ios/AppIcon-29x29@1x.png and b/client/src-tauri/icons/ios/AppIcon-29x29@1x.png differ
diff --git a/client/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/client/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
index 5e81f8d0..f9472c1d 100644
Binary files a/client/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and b/client/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ
diff --git a/client/src-tauri/icons/ios/AppIcon-29x29@2x.png b/client/src-tauri/icons/ios/AppIcon-29x29@2x.png
index 5e81f8d0..f9472c1d 100644
Binary files a/client/src-tauri/icons/ios/AppIcon-29x29@2x.png and b/client/src-tauri/icons/ios/AppIcon-29x29@2x.png differ
diff --git a/client/src-tauri/icons/ios/AppIcon-29x29@3x.png b/client/src-tauri/icons/ios/AppIcon-29x29@3x.png
index 2a3b0c74..f9e09117 100644
Binary files a/client/src-tauri/icons/ios/AppIcon-29x29@3x.png and b/client/src-tauri/icons/ios/AppIcon-29x29@3x.png differ
diff --git a/client/src-tauri/icons/ios/AppIcon-40x40@1x.png b/client/src-tauri/icons/ios/AppIcon-40x40@1x.png
index e5a07a73..6d361887 100644
Binary files a/client/src-tauri/icons/ios/AppIcon-40x40@1x.png and b/client/src-tauri/icons/ios/AppIcon-40x40@1x.png differ
diff --git a/client/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/client/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
index b3b154e5..16a471cc 100644
Binary files a/client/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and b/client/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ
diff --git a/client/src-tauri/icons/ios/AppIcon-40x40@2x.png b/client/src-tauri/icons/ios/AppIcon-40x40@2x.png
index b3b154e5..16a471cc 100644
Binary files a/client/src-tauri/icons/ios/AppIcon-40x40@2x.png and b/client/src-tauri/icons/ios/AppIcon-40x40@2x.png differ
diff --git a/client/src-tauri/icons/ios/AppIcon-40x40@3x.png b/client/src-tauri/icons/ios/AppIcon-40x40@3x.png
index 61e6c05c..b28429a9 100644
Binary files a/client/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/client/src-tauri/icons/ios/AppIcon-40x40@3x.png differ
diff --git a/client/src-tauri/icons/ios/AppIcon-512@2x.png b/client/src-tauri/icons/ios/AppIcon-512@2x.png
index 7c97343e..4663235d 100644
Binary files a/client/src-tauri/icons/ios/AppIcon-512@2x.png and b/client/src-tauri/icons/ios/AppIcon-512@2x.png differ
diff --git a/client/src-tauri/icons/ios/AppIcon-60x60@2x.png b/client/src-tauri/icons/ios/AppIcon-60x60@2x.png
index 61e6c05c..b28429a9 100644
Binary files a/client/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/client/src-tauri/icons/ios/AppIcon-60x60@2x.png differ
diff --git a/client/src-tauri/icons/ios/AppIcon-60x60@3x.png b/client/src-tauri/icons/ios/AppIcon-60x60@3x.png
index 0ff772af..f2513c59 100644
Binary files a/client/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/client/src-tauri/icons/ios/AppIcon-60x60@3x.png differ
diff --git a/client/src-tauri/icons/ios/AppIcon-76x76@1x.png b/client/src-tauri/icons/ios/AppIcon-76x76@1x.png
index 20e92644..eda78794 100644
Binary files a/client/src-tauri/icons/ios/AppIcon-76x76@1x.png and b/client/src-tauri/icons/ios/AppIcon-76x76@1x.png differ
diff --git a/client/src-tauri/icons/ios/AppIcon-76x76@2x.png b/client/src-tauri/icons/ios/AppIcon-76x76@2x.png
index ae3b45d7..304175ac 100644
Binary files a/client/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/client/src-tauri/icons/ios/AppIcon-76x76@2x.png differ
diff --git a/client/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/client/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
index 6db81706..d02bc717 100644
Binary files a/client/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/client/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ
diff --git a/client/src-tauri/src/lib.rs b/client/src-tauri/src/lib.rs
index 6c8da6cf..0784ad1e 100644
--- a/client/src-tauri/src/lib.rs
+++ b/client/src-tauri/src/lib.rs
@@ -40,6 +40,9 @@ pub mod engine_http;
// Desktop management calls ride the sidecar engine's AVA1 session (see the module doc).
#[cfg(not(any(target_os = "android", target_os = "ios")))]
mod mgmt_forward;
+// One-time 6.0 upgrade clean-up of the app's data folder (see the module doc).
+#[cfg(not(any(target_os = "android", target_os = "ios")))]
+mod migrate_6;
/// Build and run the Tauri application. The desktop `main.rs` calls this
/// directly; on mobile the `tauri::mobile_entry_point` macro generates
@@ -158,6 +161,18 @@ pub fn run() {
// this is a no-op there (see center_main_window).
center_main_window(app.handle());
+ // One-time 6.0 upgrade clean-up of this app's data folder (old files are moved
+ // aside, never reset). Desktop-only: mobile installs start on 6.x.
+ #[cfg(not(any(target_os = "android", target_os = "ios")))]
+ {
+ use tauri::Manager;
+ if let Ok(dir) = app.path().app_data_dir() {
+ for line in migrate_6::run(&dir) {
+ eprintln!("[tauri] 6.0 upgrade: {line}");
+ }
+ }
+ }
+
// Spawn the Rust engine binary as a sidecar. On failure we log and
// keep the window open so the user can see diagnostic info — the
// UI's passive status polling will flag the unreachable engine
diff --git a/client/src-tauri/src/migrate_6.rs b/client/src-tauri/src/migrate_6.rs
new file mode 100644
index 00000000..f2dd4b74
--- /dev/null
+++ b/client/src-tauri/src/migrate_6.rs
@@ -0,0 +1,108 @@
+//! One-time clean-up of the app's own data folder for an upgrade to 6.0. The engine does the
+//! same for `~/.ps5upload` (engine `migrate_6`). Nothing the user set is reset: the queue,
+//! playlists, history and saved settings stay. Files no 5.41 or 6.0 code reads are moved
+//! aside into `legacy-5x/`; only a cached helper binary nothing loads is deleted.
+
+use std::path::Path;
+
+const MARKER: &str = "migrations/6.0.0-app";
+
+/// Written by pre-5.x desktop builds; read by no 5.41 or 6.0 code. Moved, not deleted.
+const LEGACY_FILES: [&str; 2] = ["config.json", "profiles.json"];
+
+/// Cached payload binaries no current code loads (rebuilt or re-extracted if ever needed).
+const DEAD_CACHE: [&str; 2] = [
+ "payload/ezremote-dpi.elf",
+ "payload/ezremote-dpi.elf.gz.blake3",
+];
+
+/// Runs once per data folder; returns what it did, for the log.
+pub fn run(data_dir: &Path) -> Vec {
+ let marker = data_dir.join(MARKER);
+ if marker.exists() {
+ return Vec::new();
+ }
+ let legacy = data_dir.join("legacy-5x");
+ let mut did = Vec::new();
+ let mut ok = true;
+ for name in LEGACY_FILES {
+ let from = data_dir.join(name);
+ if !from.is_file() {
+ continue;
+ }
+ // Same folder tree, same device: never a cross-mount rename.
+ match std::fs::create_dir_all(&legacy)
+ .and_then(|_| std::fs::rename(&from, legacy.join(name)))
+ {
+ Ok(()) => did.push(format!("moved {name}")),
+ Err(_) => ok = false,
+ }
+ }
+ for rel in DEAD_CACHE {
+ let p = data_dir.join(rel);
+ if p.is_file() {
+ match std::fs::remove_file(&p) {
+ Ok(()) => did.push(format!("deleted {rel}")),
+ Err(_) => ok = false,
+ }
+ }
+ }
+ if ok {
+ if let Some(parent) = marker.parent() {
+ let _ = std::fs::create_dir_all(parent);
+ }
+ let _ = std::fs::write(&marker, b"");
+ }
+ did
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn temp(tag: &str) -> std::path::PathBuf {
+ let d = std::env::temp_dir().join(format!("app-migrate6-{tag}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&d);
+ std::fs::create_dir_all(d.join("payload")).unwrap();
+ d
+ }
+
+ #[test]
+ fn legacy_files_move_aside_dead_cache_goes_current_state_stays() {
+ let d = temp("app");
+ std::fs::write(d.join("config.json"), b"{\"address\":\"10.0.0.2\"}").unwrap();
+ std::fs::write(d.join("payload/ezremote-dpi.elf"), b"elf").unwrap();
+ // Current state: never touched.
+ for keep in [
+ "upload_queue.json",
+ "payload_playlists.json",
+ "send_payload_history.json",
+ ] {
+ std::fs::write(d.join(keep), b"[]").unwrap();
+ }
+ std::fs::write(d.join("payload/ps5upload.elf"), b"helper").unwrap();
+
+ let did = run(&d);
+ assert_eq!(
+ did,
+ ["moved config.json", "deleted payload/ezremote-dpi.elf"]
+ );
+ assert_eq!(
+ std::fs::read(d.join("legacy-5x/config.json")).unwrap(),
+ b"{\"address\":\"10.0.0.2\"}"
+ );
+ for keep in [
+ "upload_queue.json",
+ "payload_playlists.json",
+ "send_payload_history.json",
+ ] {
+ assert!(d.join(keep).exists(), "{keep} must stay");
+ }
+ assert!(d.join("payload/ps5upload.elf").exists());
+
+ // Once only.
+ std::fs::write(d.join("profiles.json"), b"{}").unwrap();
+ assert!(run(&d).is_empty());
+ assert!(d.join("profiles.json").exists());
+ }
+}
diff --git a/client/src-tauri/tauri.conf.json b/client/src-tauri/tauri.conf.json
index 5f6bf3d6..1bdf66e6 100644
--- a/client/src-tauri/tauri.conf.json
+++ b/client/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "PS5Upload",
- "version": "5.41.0",
+ "version": "6.0.0",
"identifier": "com.phantomptr.ps5upload",
"build": {
"beforeDevCommand": "npm run dev:vite",
diff --git a/client/src/state/uploadSettings.ts b/client/src/state/uploadSettings.ts
index df181f04..9a9b3cc9 100644
--- a/client/src/state/uploadSettings.ts
+++ b/client/src/state/uploadSettings.ts
@@ -24,6 +24,13 @@ const KEY_KEEP_PS5_AWAKE = "ps5upload.keep_ps5_awake";
const KEY_AUTO_REDEPLOY_ON_WAKE = "ps5upload.auto_redeploy_on_wake";
const KEY_SYSTEM_FILE_READ = "ps5upload.system_file_read";
+// 6.0 upgrade: 5.x saved a user-picked stream count here. AVA1 sizes its lanes itself, so the
+// setting is gone and its stale value is dropped once (every other setting carries over as is).
+const RETIRED_KEYS = ["ps5upload.upload_streams"];
+if (typeof window !== "undefined") {
+ for (const k of RETIRED_KEYS) safeRemoveItem(k);
+}
+
function loadAlwaysOverwrite(): boolean {
if (typeof window === "undefined") return false;
return safeGetItem(KEY_ALWAYS_OVERWRITE) === "true";
diff --git a/engine/Cargo.lock b/engine/Cargo.lock
index 0a4adb04..7bd788ae 100644
--- a/engine/Cargo.lock
+++ b/engine/Cargo.lock
@@ -147,7 +147,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "ava1"
-version = "5.41.0"
+version = "6.0.0"
dependencies = [
"ava1-chaos",
"blake2 0.10.6",
@@ -165,14 +165,14 @@ dependencies = [
[[package]]
name = "ava1-chaos"
-version = "5.41.0"
+version = "6.0.0"
dependencies = [
"tokio",
]
[[package]]
name = "ava1-ctest"
-version = "5.41.0"
+version = "6.0.0"
dependencies = [
"anyhow",
"ava1",
@@ -188,7 +188,7 @@ dependencies = [
[[package]]
name = "ava1-gen"
-version = "5.41.0"
+version = "6.0.0"
dependencies = [
"serde",
"toml",
@@ -1980,7 +1980,7 @@ dependencies = [
[[package]]
name = "ps5upload-ava1"
-version = "5.41.0"
+version = "6.0.0"
dependencies = [
"anyhow",
"ava1",
@@ -2000,7 +2000,7 @@ dependencies = [
[[package]]
name = "ps5upload-core"
-version = "5.41.0"
+version = "6.0.0"
dependencies = [
"aes 0.9.3",
"anyhow",
@@ -2023,7 +2023,7 @@ dependencies = [
[[package]]
name = "ps5upload-engine"
-version = "5.41.0"
+version = "6.0.0"
dependencies = [
"aes-gcm 0.11.1",
"anyhow",
@@ -2060,7 +2060,7 @@ dependencies = [
[[package]]
name = "ps5upload-fpkg"
-version = "5.41.0"
+version = "6.0.0"
dependencies = [
"aes 0.9.3",
"crc32fast",
@@ -2079,7 +2079,7 @@ dependencies = [
[[package]]
name = "ps5upload-lab"
-version = "5.41.0"
+version = "6.0.0"
dependencies = [
"anyhow",
"ava1",
@@ -2096,7 +2096,7 @@ dependencies = [
[[package]]
name = "ps5upload-pkg"
-version = "5.41.0"
+version = "6.0.0"
dependencies = [
"blake3",
"serde",
@@ -2106,7 +2106,7 @@ dependencies = [
[[package]]
name = "ps5upload-tests"
-version = "5.41.0"
+version = "6.0.0"
dependencies = [
"anyhow",
"ava1",
diff --git a/engine/Cargo.toml b/engine/Cargo.toml
index 8bf72954..4ade0b34 100644
--- a/engine/Cargo.toml
+++ b/engine/Cargo.toml
@@ -18,7 +18,7 @@ resolver = "2"
[workspace.package]
edition = "2021"
license = "GPL-3.0-or-later"
-version = "5.41.0"
+version = "6.0.0"
[workspace.dependencies]
anyhow = "1.0"
diff --git a/engine/README.md b/engine/README.md
index 6d872001..01d8010e 100644
--- a/engine/README.md
+++ b/engine/README.md
@@ -33,6 +33,8 @@ client (Tauri/React) ──HTTP──▶ ps5upload-engine :19113
```sh
cargo test --workspace # no PS5 required — loopback peers
+cargo test -p ava1-ctest -- --test-threads=1 # payload AVA1 C vs Rust, on the host
+cargo fmt --all -- --check # CI fails on this; client/src-tauri has its own
cargo build --release -p ps5upload-engine
```
diff --git a/engine/crates/ava1-ctest/tests/diag.rs b/engine/crates/ava1-ctest/tests/diag.rs
index fb9d1f12..0352c23b 100644
--- a/engine/crates/ava1-ctest/tests/diag.rs
+++ b/engine/crates/ava1-ctest/tests/diag.rs
@@ -128,7 +128,9 @@ fn untext(b: &[u8]) -> MgmtText {
/// The engine's transport over its own pool, aimed at the C server.
fn transport(r: &Rig) -> Arc {
- let pool: &'static Pool = Box::leak(Box::new(Pool::new(r.ava.clone()).with_addr(r.srv.addr())));
+ let pool: &'static Pool = keep(Box::leak(Box::new(
+ Pool::new(r.ava.clone()).with_addr(r.srv.addr()),
+ )));
Arc::new(AvaTransport::with_pool(pool))
}
@@ -858,3 +860,13 @@ async fn a_real_upload_leaves_open_and_done_lines_with_its_numbers() {
);
assert!(done.contains(&format!("bytes={bytes}/{bytes}")), "{done}");
}
+
+/// `AvaTransport::with_pool` takes a `&'static Pool`, so each test pool is leaked on purpose.
+/// Keeping it in a static list makes it reachable for the process lifetime, so LeakSanitizer
+/// does not report it (the sessions it holds live on the pool's own threads, beyond any
+/// frame-name suppression). C leaks are still reported.
+fn keep(p: &'static Pool) -> &'static Pool {
+ static KEPT: std::sync::Mutex> = std::sync::Mutex::new(Vec::new());
+ KEPT.lock().unwrap_or_else(|e| e.into_inner()).push(p);
+ p
+}
diff --git a/engine/crates/ava1-ctest/tests/download.rs b/engine/crates/ava1-ctest/tests/download.rs
index 3694994a..234febdb 100644
--- a/engine/crates/ava1-ctest/tests/download.rs
+++ b/engine/crates/ava1-ctest/tests/download.rs
@@ -710,10 +710,18 @@ async fn two_thousand_tiny_files_download_fast() {
);
assert_eq!(r.files, 2000);
assert!(same_tree(&src, &d.join("got")));
- assert!(
- rate >= FLOOR_FILES_PER_S,
- "{rate:.0} files/s < {FLOOR_FILES_PER_S}"
- );
+ // Coverage instrumentation (cargo-llvm-cov sets CARGO_LLVM_COV) slows every call several
+ // times over; the floor guards the real build, so it is skipped there.
+ // Shared CI runners (CI=true) have slow disks: 860 files/s measured on ubuntu-24.04 with the
+ // same code that does ~2,500 here. There the floor only catches a stall.
+ let floor = if std::env::var_os("CI").is_some() {
+ FLOOR_FILES_PER_S / 4.0
+ } else {
+ FLOOR_FILES_PER_S
+ };
+ if std::env::var_os("CARGO_LLVM_COV").is_none() {
+ assert!(rate >= floor, "{rate:.0} files/s < {floor}");
+ }
}
/// Measured on a Mac (loopback): the debug build (what `cargo test` runs; blake3 and the
diff --git a/engine/crates/ava1-ctest/tests/governor.rs b/engine/crates/ava1-ctest/tests/governor.rs
index 615400a7..b44861c4 100644
--- a/engine/crates/ava1-ctest/tests/governor.rs
+++ b/engine/crates/ava1-ctest/tests/governor.rs
@@ -133,6 +133,12 @@ async fn a_slow_disk_is_reported_as_the_bottleneck() {
.await;
watcher.abort();
assert_eq!(report.status, 0, "{:?}", report.message);
+ // Under the sanitizers (the C) or coverage instrumentation (the Rust sender) one side runs
+ // several times slower and becomes the bottleneck itself: the classification only means
+ // something at native speed. The transfer must still succeed.
+ if cfg!(ava1_ctest_sanitize) || std::env::var_os("CARGO_LLVM_COV").is_some() {
+ return;
+ }
let seen = seen.lock().unwrap();
assert!(
seen.iter()
@@ -179,6 +185,12 @@ async fn a_rate_capped_link_is_reported_as_the_network() {
.await;
watcher.abort();
assert_eq!(report.status, 0, "{:?}", report.message);
+ // Under the sanitizers (the C) or coverage instrumentation (the Rust sender) one side runs
+ // several times slower and becomes the bottleneck itself: the classification only means
+ // something at native speed. The transfer must still succeed.
+ if cfg!(ava1_ctest_sanitize) || std::env::var_os("CARGO_LLVM_COV").is_some() {
+ return;
+ }
let seen = seen.lock().unwrap();
assert!(seen.contains(&gen::BN_NETWORK), "{seen:?}");
assert!(!seen.contains(&gen::BN_DISK), "{seen:?}");
@@ -234,5 +246,11 @@ async fn lanes_grow_on_a_link_that_scales_with_them() {
)
});
assert_eq!(report.status, 0, "{:?}", report.message);
+ // Under the sanitizers (the C) or coverage instrumentation (the Rust sender) one side runs
+ // several times slower and becomes the bottleneck itself: the classification only means
+ // something at native speed. The transfer must still succeed.
+ if cfg!(ava1_ctest_sanitize) || std::env::var_os("CARGO_LLVM_COV").is_some() {
+ return;
+ }
assert!(report.max_lanes >= 4, "max lanes {}", report.max_lanes);
}
diff --git a/engine/crates/ava1-ctest/tests/lane_sockbuf.rs b/engine/crates/ava1-ctest/tests/lane_sockbuf.rs
index 061b848b..9bc3a688 100644
--- a/engine/crates/ava1-ctest/tests/lane_sockbuf.rs
+++ b/engine/crates/ava1-ctest/tests/lane_sockbuf.rs
@@ -15,9 +15,10 @@ fn a_lane_socket_asks_for_four_mib_each_way() {
let (mut rcv, mut snd) = (0, 0);
let rc = unsafe { ava1_conn_tune_buffers(s.as_raw_fd(), &mut rcv, &mut snd) };
assert_eq!(rc, 0);
- // The kernel may cap the request (or double it, as Linux does), but never below what
- // the default would have been; 1 MiB is far above any default.
- assert!(rcv >= 1 << 20, "rcv {rcv}");
- assert!(snd >= 1 << 20, "snd {snd}");
+ // The kernel may cap the request (Linux CI caps the send side at wmem_max, reported
+ // doubled as 416 KiB; the PS5 gives 512 KiB), so assert only that tuning lifted both well
+ // above a stock default (16-87 KiB), not the full 4 MiB ask.
+ assert!(rcv >= 128 << 10, "rcv {rcv}");
+ assert!(snd >= 128 << 10, "snd {snd}");
drop(c);
}
diff --git a/engine/crates/ava1-ctest/tests/mgmt_fs.rs b/engine/crates/ava1-ctest/tests/mgmt_fs.rs
index 4af3d623..2f42b6e9 100644
--- a/engine/crates/ava1-ctest/tests/mgmt_fs.rs
+++ b/engine/crates/ava1-ctest/tests/mgmt_fs.rs
@@ -892,7 +892,7 @@ mod transport {
.add(Identity::from_secret(SECRET).public(), "C test server")
.unwrap();
let srv = CServer::start(SECRET, &base.join("srv-peers"), 0, 100, 3000, 800);
- let pool: &'static Pool = Box::leak(Box::new(Pool::new(ava).with_addr(srv.addr())));
+ let pool: &'static Pool = keep(Box::leak(Box::new(Pool::new(ava).with_addr(srv.addr()))));
let transport = Arc::new(AvaTransport::with_pool(pool));
let scope = mgmt::scoped_transport(transport.clone());
T {
@@ -1638,3 +1638,14 @@ fn s2_a_dotdot_path_fails_closed() {
);
mgmt_fs::uninstall();
}
+
+/// `AvaTransport::with_pool` takes a `&'static Pool`, so each test pool is leaked on purpose.
+/// Keeping it in a static list makes it reachable for the process lifetime, so LeakSanitizer
+/// does not report it (the sessions it holds live on the pool's own threads, beyond any
+/// frame-name suppression). C leaks are still reported.
+fn keep(p: &'static ps5upload_ava1::Pool) -> &'static ps5upload_ava1::Pool {
+ static KEPT: std::sync::Mutex> =
+ std::sync::Mutex::new(Vec::new());
+ KEPT.lock().unwrap_or_else(|e| e.into_inner()).push(p);
+ p
+}
diff --git a/engine/crates/ava1-ctest/tests/mgmt_t6.rs b/engine/crates/ava1-ctest/tests/mgmt_t6.rs
index d8ccf8ca..5753b816 100644
--- a/engine/crates/ava1-ctest/tests/mgmt_t6.rs
+++ b/engine/crates/ava1-ctest/tests/mgmt_t6.rs
@@ -9,7 +9,7 @@
use std::path::PathBuf;
use std::process::Command;
use std::sync::{Arc, Mutex};
-use std::time::{Duration, Instant};
+use std::time::Duration;
use ava1::gen::{self, MgmtText};
use ava1::keys::Identity;
@@ -566,14 +566,11 @@ async fn c_t6_a_long_sony_call_does_not_block_other_methods() {
tokio::spawn(async move { call(&s, REGISTER, r#"{"src_path":"/data/g"}"#).await.0 })
};
tokio::time::sleep(Duration::from_millis(100)).await;
- let t0 = Instant::now();
let (st, _, _) = call(&s, PROC_LIST, "").await;
assert_eq!(st, OK);
- assert!(
- t0.elapsed() < Duration::from_millis(300),
- "proc.list waited {:?} behind a Sony call",
- t0.elapsed()
- );
+ // A state check, not a wall-clock bound: proc.list answered while the 600 ms Sony call
+ // was still running, so it did not wait behind it.
+ assert!(!slow.is_finished(), "proc.list waited behind a Sony call");
assert_eq!(slow.await.unwrap(), OK);
assert_eq!(unsafe { ava1_t6_calls(REGISTER) }, 1);
}
diff --git a/engine/crates/ava1-ctest/tests/single_instance.rs b/engine/crates/ava1-ctest/tests/single_instance.rs
index 4f9976f6..8e72c93a 100644
--- a/engine/crates/ava1-ctest/tests/single_instance.rs
+++ b/engine/crates/ava1-ctest/tests/single_instance.rs
@@ -219,6 +219,9 @@ fn gate(port: u16, max_ms: i32) -> (bool, i32, Duration) {
#[test]
fn a_second_helper_waits_then_refuses_to_co_run_beside_a_live_receiver() {
+ // The C server is one global (`ava1_server_stop` stops whichever runs): tests that start
+ // it must not overlap, whatever --test-threads the runner (coverage) picks.
+ let _one = CServer::lock_for_shim_tests();
// "Unverifiable" ends here: whatever the reap decided, a prior that still answers the transfer
// port means the new instance never starts its AVA1 side.
let d = tmp("gate");
@@ -248,6 +251,9 @@ fn a_second_helper_waits_then_refuses_to_co_run_beside_a_live_receiver() {
#[test]
fn a_second_helper_starts_once_the_first_lets_go_of_the_port() {
+ // The C server is one global (`ava1_server_stop` stops whichever runs): tests that start
+ // it must not overlap, whatever --test-threads the runner (coverage) picks.
+ let _one = CServer::lock_for_shim_tests();
let d = tmp("gate2");
let peers = CString::new(d.join("peers").to_str().unwrap()).unwrap();
let port = unsafe {
diff --git a/engine/crates/ava1-ctest/tests/upload.rs b/engine/crates/ava1-ctest/tests/upload.rs
index 9f83790e..42bdd2c7 100644
--- a/engine/crates/ava1-ctest/tests/upload.rs
+++ b/engine/crates/ava1-ctest/tests/upload.rs
@@ -123,7 +123,13 @@ async fn lane_killed_mid_chunk_requeues_and_completes() {
let (pg, pg2) = (progress.clone(), progress.clone());
let px2 = px.clone();
let killer = tokio::spawn(async move {
- for _ in 0..3 {
+ // Up to 8 kills, stopping once a requeued frame was resent: a kill can land on a lane
+ // with nothing in flight (the newest lane idle, as under the sanitizers), which proves
+ // nothing either way.
+ for _ in 0..8 {
+ if pg2.resent_bytes.load(Ordering::Relaxed) > 0 {
+ break;
+ }
tokio::time::sleep(Duration::from_millis(700)).await;
// R2: only kill while a lane is open, so the kill lands on a lane, not on
// control (which would cost the session).
diff --git a/engine/crates/ava1/src/send.rs b/engine/crates/ava1/src/send.rs
index afaedfff..29140d4a 100644
--- a/engine/crates/ava1/src/send.rs
+++ b/engine/crates/ava1/src/send.rs
@@ -808,6 +808,11 @@ fn spawn_readers(
// stall to fail the job loudly instead of parking forever.
let grant = sh.window.lock().unwrap().available();
let chunk = (chunk.min(grant.saturating_sub(CHUNK_HDR)) / GROUP * GROUP).max(GROUP);
+ // A resumed file (some of it already durable) goes in one-group pieces: on a link
+ // that keeps dropping, a whole-chunk piece can be cut every attempt before it is
+ // durable, and the job then retries the same tail forever. Smaller pieces make each
+ // attempt land something.
+ let chunk = if durable.covered() == 0 { chunk } else { GROUP };
let plan = pieces(e.size, &durable, &|g| hasher.cv(g).is_some(), chunk);
let last_piece = plan.len().saturating_sub(1);
let mut ob_synced = Instant::now();
@@ -1410,20 +1415,10 @@ pub async fn run_upload(
}
}
tokio::select! {
- r = rrx.recv() => match r {
+ r = rrx.recv() => { match r {
Some(Read::Record { file_id, root, data, budget }) => {
pending_bytes += data.len() + 48;
pending.push((BundleRecord { file_id, root, data }, budget));
- // A slow source never holds a half-full bundle back: flush when the
- // record channel is momentarily empty; a fast source keeps it full and
- // the bundles reach the governor's size.
- let flush = pending_bytes >= sh.bundle.load(Ordering::Relaxed) as usize || rrx.is_empty();
- if flush {
- let f = bundle_frame(job_id, std::mem::take(&mut pending));
- pending_bytes = 0;
- sh.sched.lock().unwrap().bundles.push_back(f);
- sh.wake();
- }
}
Some(Read::Chunk { file_id, offset, data, budget }) => {
let f = chunk_frame(job_id, file_id, offset, data, budget);
@@ -1450,6 +1445,20 @@ pub async fn run_upload(
Some(Read::Failed(_)) => {} // a failure is already queued: it wins
// The retry path holds `rtx` alive, so the channel never closes mid-job.
None => {}
+ }
+ // A slow source never holds a half-full bundle back: flush when the
+ // reader channel is momentarily empty; a fast source keeps it full and
+ // the bundles reach the governor's size. Checked after every message,
+ // not only a record: the last record can arrive with a large file's
+ // chunk or root still queued behind it, and nothing would flush it.
+ if !pending.is_empty()
+ && (pending_bytes >= sh.bundle.load(Ordering::Relaxed) as usize || rrx.is_empty())
+ {
+ let f = bundle_frame(job_id, std::mem::take(&mut pending));
+ pending_bytes = 0;
+ sh.sched.lock().unwrap().bundles.push_back(f);
+ sh.wake();
+ }
},
root_sent = async {
let (file_id, root) = root_next
@@ -1729,6 +1738,29 @@ pub async fn run_upload(
_ => {}
}
}
+ // A failed job leaves one line of the sender's state, so a stall can be placed:
+ // readers parked (budget), frames never acknowledged (inflight), or nothing queued.
+ if result.is_err() {
+ let s = sh.sched.lock().unwrap();
+ let _ = writeln!(
+ std::io::stderr(),
+ "[ava1] sender state at failure: small queue {}, large queue {}, pending records {}, \
+ bundles {}, chunks {}, requeue {}, inflight {}, roots waiting {}, budget free {} KiB, \
+ window {} B, lanes {}, readers running {}",
+ small_q.lock().unwrap().len(),
+ large_q.lock().unwrap().len(),
+ pending.len(),
+ s.bundles.len(),
+ s.chunks.len(),
+ s.requeue.len(),
+ s.inflight.len(),
+ root_next.len(),
+ sh.bytes_budget.available_permits(),
+ sh.window.lock().unwrap().available(),
+ link.lanes().len(),
+ readers.iter().filter(|h| !h.is_finished()).count(),
+ );
+ }
// Every exit: stop the readers' flag, wake the sleepers, cancel and join every
// lane task still held (correction 5; a dead lane's task was detached at its
// LaneDown and exits on its own) — and then the readers (I1): close the read-ahead
diff --git a/engine/crates/ava1/tests/data_rust.rs b/engine/crates/ava1/tests/data_rust.rs
index 81948d5c..a1161fba 100644
--- a/engine/crates/ava1/tests/data_rust.rs
+++ b/engine/crates/ava1/tests/data_rust.rs
@@ -235,7 +235,8 @@ async fn rust_to_rust_upload_into_a_folder_host() {
root: d.join("share"),
jobs_dir: d.join("hjobs"),
});
- let (addr, _ctx, id, peers) = common::paired_ctx(|c| c.with_jobs(host)).await;
+ let (addr, _ctx, id, peers) =
+ common::paired_ctx(|c| c.with_jobs(host).with_timing(common::fast())).await;
let s = connect(&addr.to_string(), id, peers, "client", common::fast())
.await
.unwrap();
@@ -257,7 +258,8 @@ async fn rust_to_rust_download_from_a_folder_host() {
root: d.join("share"),
jobs_dir: d.join("hjobs"),
});
- let (addr, _ctx, id, peers) = common::paired_ctx(|c| c.with_jobs(host)).await;
+ let (addr, _ctx, id, peers) =
+ common::paired_ctx(|c| c.with_jobs(host).with_timing(common::fast())).await;
let s = connect(&addr.to_string(), id, peers, "client", common::fast())
.await
.unwrap();
@@ -311,7 +313,8 @@ async fn a_host_refuses_paths_outside_its_share() {
root: d.join("share"),
jobs_dir: d.join("hjobs"),
});
- let (addr, _ctx, id, peers) = common::paired_ctx(|c| c.with_jobs(host)).await;
+ let (addr, _ctx, id, peers) =
+ common::paired_ctx(|c| c.with_jobs(host).with_timing(common::fast())).await;
let s = connect(&addr.to_string(), id, peers, "client", common::fast())
.await
.unwrap();
@@ -333,7 +336,8 @@ async fn a_download_of_a_path_outside_the_share_is_refused_too() {
root: d.join("share"),
jobs_dir: d.join("hjobs"),
});
- let (addr, _ctx, id, peers) = common::paired_ctx(|c| c.with_jobs(host)).await;
+ let (addr, _ctx, id, peers) =
+ common::paired_ctx(|c| c.with_jobs(host).with_timing(common::fast())).await;
let s = connect(&addr.to_string(), id, peers, "client", common::fast())
.await
.unwrap();
@@ -371,7 +375,8 @@ async fn an_over_credit_chunk_closes_only_its_lane() {
jobs: d.join("hjobs"),
credit: 2 << 20,
});
- let (addr, _ctx, id, peers) = common::paired_ctx(|c| c.with_jobs(host)).await;
+ let (addr, _ctx, id, peers) =
+ common::paired_ctx(|c| c.with_jobs(host).with_timing(common::fast())).await;
let s = connect(&addr.to_string(), id, peers, "client", common::fast())
.await
.unwrap();
@@ -465,7 +470,8 @@ async fn an_upload_larger_than_its_grant_completes_through_credit_returns() {
jobs: d.join("hjobs"),
credit: 4 << 20,
});
- let (addr, _ctx, id, peers) = common::paired_ctx(|c| c.with_jobs(host)).await;
+ let (addr, _ctx, id, peers) =
+ common::paired_ctx(|c| c.with_jobs(host).with_timing(common::fast())).await;
let s = connect(&addr.to_string(), id, peers, "client", common::fast())
.await
.unwrap();
@@ -497,7 +503,8 @@ async fn a_zero_byte_file_completes_in_an_ordinary_download() {
root: d.join("share"),
jobs_dir: d.join("hjobs"),
});
- let (addr, _ctx, id, peers) = common::paired_ctx(|c| c.with_jobs(host)).await;
+ let (addr, _ctx, id, peers) =
+ common::paired_ctx(|c| c.with_jobs(host).with_timing(common::fast())).await;
let s = connect(&addr.to_string(), id, peers, "client", common::fast())
.await
.unwrap();
@@ -528,7 +535,8 @@ async fn an_empty_file_never_stalls_the_ordered_files_behind_it() {
root: d.join("share"),
jobs_dir: d.join("hjobs"),
});
- let (addr, _ctx, id, peers) = common::paired_ctx(|c| c.with_jobs(host)).await;
+ let (addr, _ctx, id, peers) =
+ common::paired_ctx(|c| c.with_jobs(host).with_timing(common::fast())).await;
let s = connect(&addr.to_string(), id, peers, "client", common::fast())
.await
.unwrap();
@@ -562,7 +570,8 @@ async fn a_file_with_no_frames_still_completes_the_job() {
jobs: d.join("hjobs"),
credit: 1 << 20,
});
- let (addr, _ctx, id, peers) = common::paired_ctx(|c| c.with_jobs(host)).await;
+ let (addr, _ctx, id, peers) =
+ common::paired_ctx(|c| c.with_jobs(host).with_timing(common::fast())).await;
let s = connect(&addr.to_string(), id, peers, "client", common::fast())
.await
.unwrap();
@@ -624,7 +633,8 @@ async fn a_host_refuses_backslash_paths() {
root: d.join("share"),
jobs_dir: d.join("hjobs"),
});
- let (addr, _ctx, id, peers) = common::paired_ctx(|c| c.with_jobs(host)).await;
+ let (addr, _ctx, id, peers) =
+ common::paired_ctx(|c| c.with_jobs(host).with_timing(common::fast())).await;
let s = connect(&addr.to_string(), id, peers, "client", common::fast())
.await
.unwrap();
@@ -654,7 +664,8 @@ async fn a_reopened_job_resumes_from_the_engine_journal_and_credits_are_deltas()
jobs: d.join("hjobs"),
credit: 1 << 20,
});
- let (addr, _ctx, id, peers) = common::paired_ctx(|c| c.with_jobs(host)).await;
+ let (addr, _ctx, id, peers) =
+ common::paired_ctx(|c| c.with_jobs(host).with_timing(common::fast())).await;
let job = [0x11; 16];
let m = Manifest {
entries: vec![
@@ -830,7 +841,8 @@ async fn resume_answers_a_job_map_for_a_parked_job_else_unknown_job() {
root: d.join("share"),
jobs_dir: d.join("hjobs"),
});
- let (addr, _ctx, id, peers) = common::paired_ctx(|c| c.with_jobs(host)).await;
+ let (addr, _ctx, id, peers) =
+ common::paired_ctx(|c| c.with_jobs(host).with_timing(common::fast())).await;
let job = [0x31; 16];
let file = |p: &str| Entry {
kind: gen::ENTRY_FILE,
@@ -949,7 +961,8 @@ async fn a_sequential_source_uploads_into_a_folder_host() {
root: d.join("share"),
jobs_dir: d.join("hjobs"),
});
- let (addr, _ctx, id, peers) = common::paired_ctx(|c| c.with_jobs(host)).await;
+ let (addr, _ctx, id, peers) =
+ common::paired_ctx(|c| c.with_jobs(host).with_timing(common::fast())).await;
let s = connect(&addr.to_string(), id, peers, "client", common::fast())
.await
.unwrap();
@@ -1108,7 +1121,8 @@ async fn deadline_job(
sync_delay,
ended,
});
- let (addr, _ctx, id, peers) = common::paired_ctx(|c| c.with_jobs(host)).await;
+ let (addr, _ctx, id, peers) =
+ common::paired_ctx(|c| c.with_jobs(host).with_timing(common::fast())).await;
let s = connect(&addr.to_string(), id, peers, "client", common::fast())
.await
.unwrap();
@@ -1236,7 +1250,8 @@ async fn a_flood_of_job_opens_is_bounded_with_err_busy() {
jobs: d.join("hjobs"),
credit: 1 << 20,
});
- let (addr, _ctx, id, peers) = common::paired_ctx(|c| c.with_jobs(host)).await;
+ let (addr, _ctx, id, peers) =
+ common::paired_ctx(|c| c.with_jobs(host).with_timing(common::fast())).await;
let s = connect(&addr.to_string(), id, peers, "client", common::fast())
.await
.unwrap();
@@ -1357,7 +1372,8 @@ async fn a_done_only_resume_outlives_the_fresh_deadline() {
sync_delay: None,
ended,
});
- let (addr, _ctx, id, peers) = common::paired_ctx(|c| c.with_jobs(host)).await;
+ let (addr, _ctx, id, peers) =
+ common::paired_ctx(|c| c.with_jobs(host).with_timing(common::fast())).await;
let job = [0x94u8; 16];
let m = small_files(3);
// First run: file 0 lands and is durable, then the sender goes away.
@@ -1434,7 +1450,8 @@ async fn ordered_with_empty_files(tag: &str, job: u8, preexisting_root: bool) {
root: d.join("share"),
jobs_dir: d.join("hjobs"),
});
- let (addr, _ctx, id, peers) = common::paired_ctx(|c| c.with_jobs(host)).await;
+ let (addr, _ctx, id, peers) =
+ common::paired_ctx(|c| c.with_jobs(host).with_timing(common::fast())).await;
let s = connect(&addr.to_string(), id, peers, "client", common::fast())
.await
.unwrap();
diff --git a/engine/crates/ps5upload-ava1/src/relay.rs b/engine/crates/ps5upload-ava1/src/relay.rs
index 4c5b9e8d..f2232611 100644
--- a/engine/crates/ps5upload-ava1/src/relay.rs
+++ b/engine/crates/ps5upload-ava1/src/relay.rs
@@ -579,7 +579,8 @@ pub fn ps5_to_ps5_between(
let (mut la, mut lb) = (sa.job(job_id), sb.job(job_id));
let m = match download_open(&mut la, src, gen::JF_ORDERED, RELAY_CAP as u64).await {
Ok(m) => m,
- Err(SendError::Disconnected(_)) => {
+ Err(SendError::Disconnected(e)) => {
+ let _ = writeln!(std::io::stderr(), "ava1 relay: source open dropped: {e}");
from_pool.forget_if(from, &sa).await;
tokio::time::sleep(backoff).await;
continue;
@@ -606,7 +607,11 @@ pub fn ps5_to_ps5_between(
o.readers = 1;
let opened = match open_upload(&mut lb, &m, &o).await {
Ok(opened) => opened,
- Err(SendError::Disconnected(_)) => {
+ Err(SendError::Disconnected(e)) => {
+ let _ = writeln!(
+ std::io::stderr(),
+ "ava1 relay: destination open dropped: {e}"
+ );
to_pool.forget_if(to, &sb).await;
tokio::time::sleep(backoff).await;
continue;
@@ -713,6 +718,10 @@ pub fn ps5_to_ps5_between(
}
Settle::Cancelled => return Err(anyhow!("transfer_cancelled")),
Settle::Retry => {
+ let _ = writeln!(
+ std::io::stderr(),
+ "ava1 relay: attempt ended, retrying: destination {b:?}, source {a_result:?}"
+ );
from_pool.forget_if(from, &sa).await;
to_pool.forget_if(to, &sb).await;
rearm(
diff --git a/engine/crates/ps5upload-ava1/tests/adapters.rs b/engine/crates/ps5upload-ava1/tests/adapters.rs
index 37999e5e..4f1ccd2b 100644
--- a/engine/crates/ps5upload-ava1/tests/adapters.rs
+++ b/engine/crates/ps5upload-ava1/tests/adapters.rs
@@ -934,6 +934,13 @@ async fn cancelling_ends_the_busy_wait() {
/// continues from its durable state and ends byte-exact.
#[tokio::test(flavor = "multi_thread")]
async fn periodic_kills_never_strand_an_upload() {
+ // A liveness race against a fixed 2 s kill period: ~6 s natively (3 attempts). Coverage
+ // instrumentation (CARGO_LLVM_COV) makes the reconnect + handshake eat most of each period,
+ // so the race measures the instrumentation, not the code. The resume paths themselves are
+ // covered by `a_killed_session_resumes_the_same_job`.
+ if std::env::var_os("CARGO_LLVM_COV").is_some() {
+ return;
+ }
let d = temp_dir("periodic-kill");
let src = d.join("src");
let total = tree(&src, 1, |_| 32 << 20);
diff --git a/engine/crates/ps5upload-ava1/tests/download_copy.rs b/engine/crates/ps5upload-ava1/tests/download_copy.rs
index 494bb8af..525ecb4c 100644
--- a/engine/crates/ps5upload-ava1/tests/download_copy.rs
+++ b/engine/crates/ps5upload-ava1/tests/download_copy.rs
@@ -335,10 +335,14 @@ async fn two_thousand_tiny_files_zip_fast() {
let rate = 2000.0 / secs;
eprintln!("tiny zip: 2000 files in {secs:.2}s = {rate:.0} files/s");
assert_eq!(zip_entries(&out.join("t.zip")).len(), 2000);
- assert!(
- rate >= ZIP_FLOOR_FILES_PER_S,
- "{rate:.0} files/s < {ZIP_FLOOR_FILES_PER_S}"
- );
+ // Shared CI runners (CI=true) have slow disks, as for the ava1-ctest tiny download:
+ // there the floor only catches a stall.
+ let floor = if std::env::var_os("CI").is_some() {
+ ZIP_FLOOR_FILES_PER_S / 4.0
+ } else {
+ ZIP_FLOOR_FILES_PER_S
+ };
+ assert!(rate >= floor, "{rate:.0} files/s < {floor}");
}
#[tokio::test(flavor = "multi_thread")]
@@ -1323,6 +1327,12 @@ async fn a_busy_download_open_is_retried_and_a_console_that_stays_busy_fails_cle
/// reconnect, not by the top of the backoff ladder.
#[tokio::test(flavor = "multi_thread")]
async fn periodic_kills_never_strand_a_download() {
+ // A liveness race against a fixed kill period, like the upload twin in adapters.rs: under
+ // coverage instrumentation (CARGO_LLVM_COV) the reconnect + handshake eat most of each
+ // period, so the race measures the instrumentation. Runs in every native build.
+ if std::env::var_os("CARGO_LLVM_COV").is_some() {
+ return;
+ }
let d = temp("periodic-kill");
let total = tree(&d.join("share/Game"), 1, |_| 24 << 20);
let (_flaky, pool) = Flaky::start_with(
diff --git a/engine/crates/ps5upload-ava1/tests/mgmt_t6.rs b/engine/crates/ps5upload-ava1/tests/mgmt_t6.rs
index 44ae69a8..1789b8c6 100644
--- a/engine/crates/ps5upload-ava1/tests/mgmt_t6.rs
+++ b/engine/crates/ps5upload-ava1/tests/mgmt_t6.rs
@@ -5,7 +5,7 @@
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
-use std::time::{Duration, Instant};
+use std::time::Duration;
use ava1::gen::{self, MgmtText};
use ava1::host::FolderHost;
@@ -304,7 +304,7 @@ async fn six_launches_put_at_most_two_in_flight_and_other_calls_still_answer() {
gen::METHOD_APP_LAUNCH => {
let now = i2.fetch_add(1, Ordering::SeqCst) + 1;
p2.fetch_max(now, Ordering::SeqCst);
- std::thread::sleep(Duration::from_millis(150));
+ std::thread::sleep(Duration::from_millis(800));
i2.fetch_sub(1, Ordering::SeqCst);
text("")
}
@@ -328,16 +328,17 @@ async fn six_launches_put_at_most_two_in_flight_and_other_calls_still_answer() {
})
.collect();
tokio::time::sleep(Duration::from_millis(80)).await;
- // while two launches run, an unrelated call answers at once
- let t0 = Instant::now();
+ // While launches run, an unrelated call answers: it returns with a launch still in
+ // flight (each holds the console 800 ms), not after them. A state check, not a wall-clock
+ // bound, so a slow runner cannot flip it.
+ assert!(inflight.load(Ordering::SeqCst) > 0, "the launches started");
call(&t, &c, m::PROC_LIST, "PROC_LIST", b"")
.await
.unwrap()
.unwrap();
assert!(
- t0.elapsed() < Duration::from_millis(120),
- "{:?}",
- t0.elapsed()
+ inflight.load(Ordering::SeqCst) > 0,
+ "PROC_LIST waited for the launches to finish"
);
for l in launches {
l.await.unwrap().unwrap().unwrap();
diff --git a/engine/crates/ps5upload-ava1/tests/relay.rs b/engine/crates/ps5upload-ava1/tests/relay.rs
index 04f34bb1..58831187 100644
--- a/engine/crates/ps5upload-ava1/tests/relay.rs
+++ b/engine/crates/ps5upload-ava1/tests/relay.rs
@@ -89,10 +89,22 @@ async fn a_partly_durable_file_resumes_without_hanging() {
const SIZE: u64 = 160 << 20;
write_pattern(&a.join("share/src/big"), 5, SIZE);
let (addr_a, addr_b) = (host(&a, key).await, host(&b, key).await);
+ // The first attempt reads A through a capped link (~5 s for the file), so it is still
+ // running when the test interrupts it — on a fast runner the uncapped relay finished
+ // all 160 MiB before the first durable report was seen. The resume runs uncapped.
+ let slow_a = ChaosProxy::start(
+ addr_a.parse().unwrap(),
+ ChaosConfig {
+ bytes_per_sec: Some(32 << 20),
+ ..Default::default()
+ },
+ )
+ .await
+ .unwrap();
let progress = Arc::new(Progress::default());
let cancel = Arc::new(AtomicBool::new(false));
let (first_progress, first_cancel) = (progress.clone(), cancel.clone());
- let (first_ava, first_a, first_b) = (ava.clone(), addr_a.clone(), addr_b.clone());
+ let (first_ava, first_a, first_b) = (ava.clone(), slow_a.addr.to_string(), addr_b.clone());
let first = tokio::task::spawn_blocking(move || {
let pa = Pool::new(first_ava.clone()).with_addr(first_a);
let pb = Pool::new(first_ava).with_addr(first_b);
@@ -799,109 +811,6 @@ fn a_failed_read_restarts_the_inflater_instead_of_continuing_stale() {
assert_eq!(r.restarts(), 2, "a failed read must restart the inflater");
}
-/// A destination that ends its upload part-way (its job stops, with a reason) while the
-/// session stays open: the relay's readers are parked for bytes the destination will never
-/// ask for. The relay must report the destination's reason within seconds, not after the
-/// 120 s no-progress bound.
-struct EndsItsJob {
- inner: ava1::host::FolderHost,
- ended: Arc,
-}
-
-impl ava1::router::JobHost for EndsItsJob {
- fn accept(&self, link: ava1::router::JobLink, first: ava1::conn::Frame, peer: [u8; 32]) {
- let upload = first
- .decode::()
- .map(|o| o.kind == gen::JOB_UPLOAD)
- .unwrap_or(false);
- if !upload {
- return self.inner.accept(link, first, peer);
- }
- let open: gen::JobOpen = first.decode().unwrap();
- let progress = Arc::new(Progress::default());
- let cancel = Arc::new(AtomicBool::new(false));
- let o = ava1::recv::RecvOptions {
- credit: 64 << 20,
- flags: open.flags,
- jobs_dir: self.inner.jobs_dir.clone(),
- ordered: open.flags & gen::JF_ORDERED != 0,
- progress: progress.clone(),
- cancel: cancel.clone(),
- progress_deadline: None,
- };
- let sink = Arc::new(ava1::recv::LocalSink::new(
- self.inner.root.join(&open.root),
- open.flags & gen::JF_SINGLE_FILE != 0,
- ));
- let ended = self.ended.clone();
- tokio::spawn(async move {
- let mut link = link;
- let watch = tokio::spawn(async move {
- while progress.bytes_durable.load(Ordering::Relaxed) == 0 {
- tokio::time::sleep(Duration::from_millis(5)).await;
- }
- ended.store(true, Ordering::Relaxed);
- cancel.store(true, Ordering::Relaxed);
- });
- let _ = ava1::recv::receive_job(&mut link, open, sink, o).await;
- watch.abort();
- // The job is over; the session (and this link's connection) stays open.
- tokio::time::sleep(Duration::from_secs(120)).await;
- drop(link);
- });
- }
-}
-
-#[tokio::test(flavor = "multi_thread")]
-async fn a_destination_that_ends_its_job_stops_the_relay_within_seconds() {
- let _heavy = HEAVY.lock().await;
- let d = temp("dest-ends");
- let ava = d.join("engine");
- let key = Identity::load_or_create(&ava.join("identity"))
- .unwrap()
- .public();
- let a = d.join("a");
- let b = d.join("b");
- std::fs::create_dir_all(a.join("share/src")).unwrap();
- std::fs::create_dir_all(b.join("share")).unwrap();
- for i in 0..4u8 {
- write_pattern(&a.join(format!("share/src/f{i}")), i, 96 << 20);
- }
- let addr_a = host(&a, key).await;
- let ended = Arc::new(AtomicBool::new(false));
- let mut peers = ava1::peers::PeerStore::in_memory();
- peers.add(key, "engine").unwrap();
- let ctx = ava1::server::ServerCtx::new(Identity::generate().unwrap(), "host", peers, rpc())
- .with_jobs(Arc::new(EndsItsJob {
- inner: ava1::host::FolderHost {
- root: b.join("share"),
- jobs_dir: b.join("jobs"),
- },
- ended: ended.clone(),
- }));
- let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
- let addr_b = l.local_addr().unwrap().to_string();
- let server = tokio::spawn(ava1::server::serve(l, Arc::new(ctx)));
- let (pa, pb) = (
- Pool::new(ava.clone()).with_addr(addr_a),
- Pool::new(ava).with_addr(addr_b),
- );
- let (reason, took) = tokio::time::timeout(
- Duration::from_secs(60),
- tokio::task::spawn_blocking(move || relay_failure(pa, pb)),
- )
- .await
- .expect("the relay hung")
- .unwrap();
- server.abort();
- assert!(ended.load(Ordering::Relaxed), "the destination never ended");
- assert!(
- took < Duration::from_secs(15),
- "took {took:?} to notice (reason {reason})"
- );
- assert!(reason.starts_with("ava1_"), "reason {reason}");
-}
-
#[test]
fn a_zip_entrys_own_mtime_is_carried_into_the_manifest() {
let d = temp("zip-mtime");
diff --git a/engine/crates/ps5upload-ava1/tests/relay_dest_ends.rs b/engine/crates/ps5upload-ava1/tests/relay_dest_ends.rs
new file mode 100644
index 00000000..bb64faa0
--- /dev/null
+++ b/engine/crates/ps5upload-ava1/tests/relay_dest_ends.rs
@@ -0,0 +1,156 @@
+//! Its own binary on purpose: this relay must end within seconds of the destination ending
+//! its job, and in the shared relay binary it queued behind the multi-hundred-MiB relays (one
+//! lock) and once hung the whole CI job. Here it runs alone, on small files (128 MiB in all:
+//! still twice the relay's 64 MiB buffer, so the readers really are parked), and on a plain
+//! thread so a hang fails at the bound instead of holding the runtime's drop.
+mod common;
+
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+use std::time::Duration;
+
+use ava1::gen;
+use ava1::keys::Identity;
+use ava1::send::Progress;
+use common::*;
+use ps5upload_ava1::relay::ps5_to_ps5_between;
+use ps5upload_ava1::upload;
+use ps5upload_ava1::Pool;
+
+fn relay_failure(pa: Pool, pb: Pool) -> (String, Duration) {
+ let started = std::time::Instant::now();
+ let e = ps5_to_ps5_between(
+ &pa,
+ "a",
+ "src",
+ &pb,
+ "b",
+ "dst",
+ [41; 16],
+ Arc::new(Progress::default()),
+ Arc::new(AtomicBool::new(false)),
+ )
+ .unwrap_err();
+ // A terminal session failure names the console it came from (ConsoleFailure); others
+ // are plain UploadFailures.
+ let reason = match e.downcast_ref::() {
+ Some(cf) => cf.failure.reason.clone(),
+ None => e
+ .downcast_ref::()
+ .unwrap_or_else(|| panic!("not an UploadFailure: {e:#}"))
+ .reason
+ .clone(),
+ };
+ (reason, started.elapsed())
+}
+
+/// A destination that ends its upload part-way (its job stops, with a reason) while the
+/// session stays open: the relay's readers are parked for bytes the destination will never
+/// ask for. The relay must report the destination's reason within seconds, not after the
+/// 120 s no-progress bound.
+struct EndsItsJob {
+ inner: ava1::host::FolderHost,
+ ended: Arc,
+}
+
+impl ava1::router::JobHost for EndsItsJob {
+ fn accept(&self, link: ava1::router::JobLink, first: ava1::conn::Frame, peer: [u8; 32]) {
+ let upload = first
+ .decode::()
+ .map(|o| o.kind == gen::JOB_UPLOAD)
+ .unwrap_or(false);
+ if !upload {
+ return self.inner.accept(link, first, peer);
+ }
+ let open: gen::JobOpen = first.decode().unwrap();
+ let progress = Arc::new(Progress::default());
+ let cancel = Arc::new(AtomicBool::new(false));
+ let o = ava1::recv::RecvOptions {
+ credit: 64 << 20,
+ flags: open.flags,
+ jobs_dir: self.inner.jobs_dir.clone(),
+ ordered: open.flags & gen::JF_ORDERED != 0,
+ progress: progress.clone(),
+ cancel: cancel.clone(),
+ progress_deadline: None,
+ };
+ let sink = Arc::new(ava1::recv::LocalSink::new(
+ self.inner.root.join(&open.root),
+ open.flags & gen::JF_SINGLE_FILE != 0,
+ ));
+ let ended = self.ended.clone();
+ tokio::spawn(async move {
+ let mut link = link;
+ let watch = tokio::spawn(async move {
+ while progress.bytes_durable.load(Ordering::Relaxed) == 0 {
+ tokio::time::sleep(Duration::from_millis(5)).await;
+ }
+ ended.store(true, Ordering::Relaxed);
+ cancel.store(true, Ordering::Relaxed);
+ });
+ let _ = ava1::recv::receive_job(&mut link, open, sink, o).await;
+ watch.abort();
+ // The job is over; the session (and this link's connection) stays open.
+ tokio::time::sleep(Duration::from_secs(120)).await;
+ drop(link);
+ });
+ }
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn a_destination_that_ends_its_job_stops_the_relay_within_seconds() {
+ let d = temp("dest-ends");
+ let ava = d.join("engine");
+ let key = Identity::load_or_create(&ava.join("identity"))
+ .unwrap()
+ .public();
+ let a = d.join("a");
+ let b = d.join("b");
+ std::fs::create_dir_all(a.join("share/src")).unwrap();
+ std::fs::create_dir_all(b.join("share")).unwrap();
+ for i in 0..4u8 {
+ write_pattern(&a.join(format!("share/src/f{i}")), i, 32 << 20);
+ }
+ let addr_a = host(&a, key).await;
+ let ended = Arc::new(AtomicBool::new(false));
+ let mut peers = ava1::peers::PeerStore::in_memory();
+ peers.add(key, "engine").unwrap();
+ let ctx = ava1::server::ServerCtx::new(Identity::generate().unwrap(), "host", peers, rpc())
+ .with_jobs(Arc::new(EndsItsJob {
+ inner: ava1::host::FolderHost {
+ root: b.join("share"),
+ jobs_dir: b.join("jobs"),
+ },
+ ended: ended.clone(),
+ }));
+ let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let addr_b = l.local_addr().unwrap().to_string();
+ let server = tokio::spawn(ava1::server::serve(l, Arc::new(ctx)));
+ let (pa, pb) = (
+ Pool::new(ava.clone()).with_addr(addr_a),
+ Pool::new(ava).with_addr(addr_b),
+ );
+ // A plain thread, not spawn_blocking: a hung relay must fail this test at the bound, and
+ // a runtime waits for its blocking threads on drop — the binary then hung for the whole
+ // CI job instead of failing here.
+ let (tx, rx) = tokio::sync::oneshot::channel();
+ std::thread::spawn(move || {
+ let _ = tx.send(relay_failure(pa, pb));
+ });
+ let (reason, took) = tokio::time::timeout(Duration::from_secs(60), rx)
+ .await
+ .unwrap_or_else(|_| {
+ panic!(
+ "the relay hung (destination ended: {})",
+ ended.load(Ordering::Relaxed)
+ )
+ })
+ .unwrap();
+ server.abort();
+ assert!(ended.load(Ordering::Relaxed), "the destination never ended");
+ assert!(
+ took < Duration::from_secs(15),
+ "took {took:?} to notice (reason {reason})"
+ );
+ assert!(reason.starts_with("ava1_"), "reason {reason}");
+}
diff --git a/engine/crates/ps5upload-ava1/tests/relay_order.rs b/engine/crates/ps5upload-ava1/tests/relay_order.rs
index 1c3e0fee..6218f2a3 100644
--- a/engine/crates/ps5upload-ava1/tests/relay_order.rs
+++ b/engine/crates/ps5upload-ava1/tests/relay_order.rs
@@ -31,11 +31,12 @@ async fn a_small_file_after_a_slow_large_one_outlasts_the_wait_bound() {
write_pattern(&a.join("share/src/a_big"), 3, BIG);
write_pattern(&a.join("share/src/z_small"), 4, 5);
let (addr_a, addr_b) = (host(&a, key).await, host(&b, key).await);
- // Capped so the large file runs well past the knob.
+ // Capped so the large file runs well past the knob: ~10.7 s nominal. 4 MiB/s left too
+ // little margin — socket buffers absorb a few MiB past the cap (5.9 s seen on CI).
let proxy = ChaosProxy::start(
addr_a.parse().unwrap(),
ChaosConfig {
- bytes_per_sec: Some(4 << 20),
+ bytes_per_sec: Some(3 << 20),
..Default::default()
},
)
diff --git a/engine/crates/ps5upload-core/src/health.rs b/engine/crates/ps5upload-core/src/health.rs
index a4624070..e61f170a 100644
--- a/engine/crates/ps5upload-core/src/health.rs
+++ b/engine/crates/ps5upload-core/src/health.rs
@@ -229,12 +229,12 @@ pub fn human_bytes(bytes: u64) -> String {
// ── The scan ────────────────────────────────────────────────────────
-/// Directories this tool needs on the console.
-const TOOL_DIRS: [&str; 5] = [
+/// Directories this tool needs on the console. The pre-6.0 helper's `spool` and `tx` folders are gone:
+/// the AVA1 helper never reads them, and the 6.0 upgrade deletes them (listing them here would
+/// flag them missing and the fix would recreate them).
+const TOOL_DIRS: [&str; 3] = [
"/data/ps5upload/cheats",
"/data/ps5upload/backups",
- "/data/ps5upload/spool",
- "/data/ps5upload/tx",
"/data/ps5upload/runtime",
];
diff --git a/engine/crates/ps5upload-engine/src/lib.rs b/engine/crates/ps5upload-engine/src/lib.rs
index bbd22f76..887d9adc 100644
--- a/engine/crates/ps5upload-engine/src/lib.rs
+++ b/engine/crates/ps5upload-engine/src/lib.rs
@@ -49,6 +49,7 @@ mod link;
mod local_fs;
mod log_dedup;
mod mgmt_route;
+mod migrate_6;
mod pkg_install;
mod pkg_sidecar;
mod remote;
@@ -3142,6 +3143,41 @@ async fn ps5_elfldr_ensure(Json(q): Json) -> impl IntoResponse {
}
}
+/// The first time a console answers with the 6.0 helper, delete the pre-6.0 helper's folders (once per
+/// console, remembered on disk; a failed attempt is retried on a later answer). Background and
+/// best-effort: it never delays or fails the state answer.
+fn spawn_console_upgrade_cleanup(console: String) {
+ static RUNNING: std::sync::Mutex> = std::sync::Mutex::new(Vec::new());
+ let Some(dir) = crate::remote::store::data_dir() else {
+ return;
+ };
+ let host = console_addr(&console);
+ if !migrate_6::console_pending(&dir, &host) {
+ return;
+ }
+ {
+ let mut running = RUNNING.lock().unwrap_or_else(|e| e.into_inner());
+ if running.contains(&host) {
+ return;
+ }
+ running.push(host.clone());
+ }
+ tokio::task::spawn_blocking(move || {
+ let done = migrate_6::clean_console(|path| {
+ fs_delete_with_op_id(&host, path, 0, Some(std::time::Duration::from_secs(300)))
+ .map_err(|e| format!("{e:#}"))
+ });
+ if done {
+ migrate_6::mark_console_done(&dir, &host);
+ crate::log_info!("6.0 upgrade: removed the old transfer folders on {host}");
+ }
+ RUNNING
+ .lock()
+ .unwrap_or_else(|e| e.into_inner())
+ .retain(|h| h != &host);
+ });
+}
+
/// GET /api/ps5/helper/state?host= — `{"state": "ava1" | "helper_old" | "starting" | "ava1_failed" | "not_running"}`: whether the
/// console runs an AVA1 helper, an older helper that only speaks the old protocol (the UI offers
/// the one-click update), or nothing (the usual send-payload flow). A TCP-level answer: pairing is
@@ -3149,10 +3185,14 @@ async fn ps5_elfldr_ensure(Json(q): Json) -> impl IntoResponse {
async fn ps5_helper_state(Query(q): Query) -> impl IntoResponse {
// The client sends `[v6]:port` for an IPv6 console; the probes add their own ports.
let host = legacy_guard::key(&q.host);
+ let console = host.clone();
let r = tokio::task::spawn_blocking(move || {
legacy_helper::state(&host, legacy_helper::Ports::default())
})
.await;
+ if matches!(r, Ok(legacy_helper::AVA1)) {
+ spawn_console_upgrade_cleanup(console);
+ }
match r {
Ok(s) => (StatusCode::OK, Json(serde_json::json!({ "state": s }))).into_response(),
Err(e) => json_err(StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
@@ -9421,6 +9461,17 @@ async fn run(cfg: EngineConfig) -> anyhow::Result<()> {
crate::log_info!("{new} is set; archives stream now, so it has no effect");
}
}
+ // One-time 6.0 upgrade clean-up of files no 6.x code reads (moved aside, never deleted).
+ if let Some(dir) = crate::remote::store::data_dir() {
+ let moved = migrate_6::run_host(&dir);
+ if !moved.is_empty() {
+ crate::log_info!(
+ "6.0 upgrade: moved old files nothing reads any more to {}: {}",
+ dir.join("legacy-5x").display(),
+ moved.join(", ")
+ );
+ }
+ }
// SPEC.md §14.3: expire old AVA1 job directories at start and then daily.
ava1_api::spawn_journal_gc();
// One line at startup: where the AVA1 state lives, which key this engine is, and how many
diff --git a/engine/crates/ps5upload-engine/src/migrate_6.rs b/engine/crates/ps5upload-engine/src/migrate_6.rs
new file mode 100644
index 00000000..347afe84
--- /dev/null
+++ b/engine/crates/ps5upload-engine/src/migrate_6.rs
@@ -0,0 +1,178 @@
+//! One-time clean-up for an upgrade to 6.0 (AVA1). Nothing the user set is reset: settings,
+//! queue, playlists, history, telemetry, cheats and backups stay where they are. Only state no
+//! 6.x code reads is touched, and files that might still mean something to the user are moved
+//! aside into `legacy-5x/`, never deleted.
+//!
+//! Host side (`run_host`, once per data dir): pre-5.x config files that neither 5.41 nor 6.0
+//! reads move to `/legacy-5x/`. Console side (`clean_console`, once per console, on
+//! the first answer from a 6.0 helper): the old transfer records and staging folders, which
+//! the AVA1 helper never reads, are deleted.
+
+use std::path::Path;
+
+/// The marker directory: one file per finished step, so a step that failed half-way runs again.
+const MARKERS: &str = "migrations";
+const HOST_DONE: &str = "6.0.0-host";
+const CONSOLES_DONE: &str = "6.0.0-consoles";
+
+/// Read by no 5.41 or 6.0 code (pre-5.x builds wrote them). Moved, not deleted: they may hold
+/// an old address or profile the user wants to look at.
+const HOST_LEGACY_FILES: [&str; 5] = [
+ "api_token",
+ "ps5upload.ini",
+ "ps5upload_profiles.ini",
+ "ps5upload_profiles.json",
+ "app-config.json",
+];
+
+/// The pre-6.0 helper's transfer records and staging area. The AVA1 helper keeps its state under
+/// `/data/ps5upload/ava`; nothing reads these any more.
+pub const CONSOLE_LEGACY_DIRS: [&str; 2] = ["/data/ps5upload/tx", "/data/ps5upload/spool"];
+
+/// Moves the host's legacy files aside, once. Returns what was moved (for the log).
+pub fn run_host(data_dir: &Path) -> Vec {
+ let markers = data_dir.join(MARKERS);
+ if markers.join(HOST_DONE).exists() {
+ return Vec::new();
+ }
+ let legacy = data_dir.join("legacy-5x");
+ let mut moved = Vec::new();
+ let mut ok = true;
+ for name in HOST_LEGACY_FILES {
+ let from = data_dir.join(name);
+ if !from.exists() {
+ continue;
+ }
+ // Same directory tree, same device: a rename never crosses a mount here.
+ let to = legacy.join(name);
+ match std::fs::create_dir_all(&legacy).and_then(|_| std::fs::rename(&from, &to)) {
+ Ok(()) => moved.push(name.to_string()),
+ Err(_) => ok = false,
+ }
+ }
+ if ok && std::fs::create_dir_all(&markers).is_ok() {
+ let _ = std::fs::write(markers.join(HOST_DONE), b"");
+ }
+ moved
+}
+
+/// Whether `console` (its host) still needs the console-side clean-up.
+pub fn console_pending(data_dir: &Path, console: &str) -> bool {
+ let done =
+ std::fs::read_to_string(data_dir.join(MARKERS).join(CONSOLES_DONE)).unwrap_or_default();
+ !done.lines().any(|l| l.trim() == console)
+}
+
+/// Records that `console` is cleaned.
+pub fn mark_console_done(data_dir: &Path, console: &str) {
+ let markers = data_dir.join(MARKERS);
+ if std::fs::create_dir_all(&markers).is_err() {
+ return;
+ }
+ let path = markers.join(CONSOLES_DONE);
+ let mut text = std::fs::read_to_string(&path).unwrap_or_default();
+ if text.lines().any(|l| l.trim() == console) {
+ return;
+ }
+ text.push_str(console);
+ text.push('\n');
+ let _ = std::fs::write(path, text);
+}
+
+/// A delete that failed only because the folder is not there counts as done.
+pub fn is_not_found(err: &str) -> bool {
+ let e = err.to_ascii_lowercase();
+ e.contains("errno_2") || e.contains("no such file") || e.contains("not found")
+}
+
+/// Deletes the pre-6.0 helper's folders on the console through `delete` (the engine passes the management
+/// call). True when every folder is gone, so the console is marked and never asked again.
+pub fn clean_console(delete: impl Fn(&str) -> Result<(), String>) -> bool {
+ let mut all = true;
+ for dir in CONSOLE_LEGACY_DIRS {
+ match delete(dir) {
+ Ok(()) => {}
+ Err(e) if is_not_found(&e) => {}
+ Err(_) => all = false,
+ }
+ }
+ all
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::Mutex;
+
+ fn temp(tag: &str) -> std::path::PathBuf {
+ let d = std::env::temp_dir().join(format!("migrate6-{tag}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&d);
+ std::fs::create_dir_all(&d).unwrap();
+ d
+ }
+
+ #[test]
+ fn legacy_files_move_aside_and_settings_stay() {
+ let d = temp("host");
+ std::fs::write(d.join("ps5upload.ini"), b"old").unwrap();
+ std::fs::write(d.join("api_token"), b"tok").unwrap();
+ // Current state: never touched.
+ std::fs::write(d.join("settings.json"), b"{\"keep\":1}").unwrap();
+ std::fs::create_dir_all(d.join("ava")).unwrap();
+ std::fs::write(d.join("ava/identity"), b"id").unwrap();
+
+ let mut moved = run_host(&d);
+ moved.sort();
+ assert_eq!(moved, ["api_token", "ps5upload.ini"]);
+ assert_eq!(
+ std::fs::read(d.join("legacy-5x/ps5upload.ini")).unwrap(),
+ b"old"
+ );
+ assert!(!d.join("ps5upload.ini").exists());
+ assert_eq!(
+ std::fs::read(d.join("settings.json")).unwrap(),
+ b"{\"keep\":1}"
+ );
+ assert_eq!(std::fs::read(d.join("ava/identity")).unwrap(), b"id");
+
+ // Once only: a file that appears later is left alone.
+ std::fs::write(d.join("app-config.json"), b"new").unwrap();
+ assert!(run_host(&d).is_empty());
+ assert!(d.join("app-config.json").exists());
+ }
+
+ #[test]
+ fn a_fresh_install_has_nothing_to_move_and_is_marked() {
+ let d = temp("fresh");
+ assert!(run_host(&d).is_empty());
+ assert!(d.join(MARKERS).join(HOST_DONE).exists());
+ }
+
+ #[test]
+ fn a_console_is_cleaned_once_and_a_missing_folder_counts_as_done() {
+ let d = temp("console");
+ assert!(console_pending(&d, "192.168.1.9"));
+ let asked = Mutex::new(Vec::new());
+ let done = clean_console(|p| {
+ asked.lock().unwrap().push(p.to_string());
+ if p.ends_with("/spool") {
+ Err("fs_delete_errno_2".into())
+ } else {
+ Ok(())
+ }
+ });
+ assert!(done);
+ assert_eq!(*asked.lock().unwrap(), CONSOLE_LEGACY_DIRS);
+ mark_console_done(&d, "192.168.1.9");
+ mark_console_done(&d, "192.168.1.9");
+ assert!(!console_pending(&d, "192.168.1.9"));
+ assert!(console_pending(&d, "192.168.1.10"));
+ let text = std::fs::read_to_string(d.join(MARKERS).join(CONSOLES_DONE)).unwrap();
+ assert_eq!(text.lines().count(), 1);
+ }
+
+ #[test]
+ fn a_failed_delete_leaves_the_console_pending() {
+ assert!(!clean_console(|_| Err("connection refused".into())));
+ }
+}
diff --git a/payload/README.md b/payload/README.md
index 05416571..0009cb2c 100644
--- a/payload/README.md
+++ b/payload/README.md
@@ -55,17 +55,12 @@ Everything compiles with `-Wall -Wextra -Werror`.
Logic that can be separated from the console lives in a header under
`include/` and gets a host-compiled self-test in `tests/`, run by
-`make test-payload` with the same warning flags. Current cores:
+`make test-payload` with the same warning flags.
-| Header | Self-test covers |
-|---|---|
-| `hw_guard.h` | Recovering from a faulting Sony getter without losing the helper |
-| `ptrace_recovery.h` | Timeout recovery never resuming injected registers |
-| `appdb_scan.h` | Reading `app.db` — a real SQLite record reader, because column values are stored with no separators between them |
-| `ftp_format.h` | PASV/EPSV/LIST reply shapes, which clients parse strictly |
-| `sdk_param.h` | Rewriting `param.json` version fields, and reporting when nothing changed |
-| `elf_param.h` | Finding SDK fields via program headers, not by scanning for magic bytes |
-| `timed_init.h` | Bounded one-time init that never starts a second initializer |
+See `tests/*_selftest.c` for the full list (hardware guards, ptrace recovery, `app.db`
+reading, FTP, SDK param rewriting, installer, cheats, wake watchdog and more). AVA1 itself is
+tested from Rust: `cargo test -p ava1-ctest -- --test-threads=1` in `engine/` compiles
+`ava1/` on the host and checks it against the Rust side.
Prefer adding to this set over testing on hardware: a host self-test runs
in milliseconds and can't wedge a console.
diff --git a/payload/include/config.h b/payload/include/config.h
index ad775aba..0baa90b2 100644
--- a/payload/include/config.h
+++ b/payload/include/config.h
@@ -5,7 +5,7 @@
* UI tell apart an old payload still running from a build that includes
* a particular fix, without having to boot the console. Keep in sync
* with the desktop app's package.json during releases. */
-#define PS5UPLOAD2_VERSION "5.41.0"
+#define PS5UPLOAD2_VERSION "6.0.0"
/* Author credit — embedded in the startup toast so anyone looking at
* the console screen knows who wrote the software that just loaded.
* Kept separate from VERSION so release scripts can bump the version
diff --git a/payload/src/ftp_server.c b/payload/src/ftp_server.c
index d6f7a4fa..c0484e44 100644
--- a/payload/src/ftp_server.c
+++ b/payload/src/ftp_server.c
@@ -616,12 +616,13 @@ static void handle_stor(struct ftp_session *s, const char *arg) {
total += n;
}
free(buf);
- if (!aborted) ftruncate(fd, total);
+ int trunc_err = !aborted && ftruncate(fd, total) != 0;
close(fd);
ftp_close_socket(&s->data_fd);
s->data_offset = 0;
atomic_store(&s->abort_requested, 0);
- if (!aborted) send_resp(s->ctrl_fd, 226, "Transfer complete");
+ if (trunc_err) send_resp(s->ctrl_fd, 451, "Could not finish the file");
+ else if (!aborted) send_resp(s->ctrl_fd, 226, "Transfer complete");
}
static void handle_feat(struct ftp_session *s) {
diff --git a/payload/tests/ftp_trust_store_selftest.c b/payload/tests/ftp_trust_store_selftest.c
index 846ef89e..18342612 100644
--- a/payload/tests/ftp_trust_store_selftest.c
+++ b/payload/tests/ftp_trust_store_selftest.c
@@ -149,7 +149,7 @@ int main(void) {
struct ftp_session s;
snprintf(base, sizeof base, "/tmp/ps5-ftp-s2-%d", (int)getpid());
snprintf(p, sizeof p, "rm -rf %s", base);
- (void)system(p);
+ if (system(p) != 0) { /* best-effort cleanup */ }
snprintf(p, sizeof p, "mkdir -p %s/d/ava %s/evil/ava", base, base);
if (system(p) != 0) return 2;
/* realpath of the temp dir (macOS /tmp is a link) so spellings compare */
@@ -230,7 +230,7 @@ int main(void) {
overflow_and_sentinel_tests(base);
snprintf(p, sizeof p, "rm -rf %s", base);
- (void)system(p);
+ if (system(p) != 0) { /* best-effort cleanup */ }
if (failures) { fprintf(stderr, "ftp_trust_store_selftest: %d failure(s)\n", failures); return 1; }
printf("ftp_trust_store_selftest: ALL PASS\n");
return 0;
diff --git a/scripts/validate-repo.mjs b/scripts/validate-repo.mjs
index 1d7cc78c..2a3ed989 100755
--- a/scripts/validate-repo.mjs
+++ b/scripts/validate-repo.mjs
@@ -31,7 +31,9 @@ run("git whitespace", "git", ["diff", "--check"]);
run("engine fmt", "cargo", ["fmt", "--all", "--", "--check"], { cwd: path.join(repoRoot, "engine") });
run("engine clippy", "cargo", ["clippy", "--workspace", "--", "-D", "warnings"], { cwd: path.join(repoRoot, "engine") });
-run("engine tests", "cargo", ["test", "--workspace"], { cwd: path.join(repoRoot, "engine") });
+run("engine tests", "cargo", ["test", "--workspace", "--exclude", "ava1-ctest"], { cwd: path.join(repoRoot, "engine") });
+// The C interop tests share one C server and must run serially (as in CI).
+run("ava1 C interop tests", "cargo", ["test", "-p", "ava1-ctest", "--", "--test-threads=1"], { cwd: path.join(repoRoot, "engine") });
run("desktop cargo check", "cargo", ["check", "--all-targets"], { cwd: path.join(repoRoot, "client", "src-tauri") });
run("desktop clippy", "cargo", ["clippy", "--all-targets", "--", "-D", "warnings"], { cwd: path.join(repoRoot, "client", "src-tauri") });
diff --git a/third_party/elfldr/elfldr-ps5.elf b/third_party/elfldr/elfldr-ps5.elf
index e4ade568..045cdf95 100755
Binary files a/third_party/elfldr/elfldr-ps5.elf and b/third_party/elfldr/elfldr-ps5.elf differ