PS5 payload internals
Developer notes for the PS5 payload: the CLI's serve built as a PS5 ELF, with the app's UI as a web page.
How to load and use it is in the main README.
ps5/build.sh # target/ps5/ps5-dump-forge.elf (+ stage1.elf, and .elf.txt from llvm-readelf)
ps5/test-entry.sh # host check of entry.c (launch modes, args file, log redirection)
scripts/release-ps5.sh # dist/ps5-dump-forge-<version>-ps5.elf, checked; SKIP_BUILD=1 checks what's built
scripts/test-freebsd.sh # core + server tests on FreeBSD UFS, FAT32 and nullfs volumes (qemu VM in Docker)
cargo run -p ps5-dump-forge-cli -- serve --root <dir> # the web UI on this computer, http://localhost:8095
(cd app && npm run build:http && node scripts/smoke.mjs) # the web bundle + client against the real serve
FORGE_DEBUG_API=1 ps5/build.sh # bring-up only: adds POST /api/debug, GET /probe, POST/GET /api/debug_input; never shipped (release-ps5.sh refuses it)
Harness (ps5/)
Dockerfile:rust:1.99.0-slim-trixie(pinned by digest), Debian clang/lld/llvm 19, ps5-payload-dev SDK v0.43 (zip pinned by SHA-256),rust-srcwith a one-line FreeBSD 11 std patch (prepare-rust-std.pyrefuses a changed context).RUSTUP_TOOLCHAIN=1.99.0overridesrust-toolchain.toml, since build-std needs that toolchain's patchedrust-src. Rust, the std patch, the SDK, the target JSON and the shims are pinned together; bump them together.build.sh:npm run build:httpon the host (FORGE_SKIP_WEB=1skips it), the C files withprospero-clang, thencargo build -p ps5-dump-forge-cli --target ps5/x86_64-ps5-freebsd.json -Z build-std=std,panic_abortwithRUSTC_BOOTSTRAP=1and--cfg libc_unstable_freebsd_version="11". The PS5 fills FreeBSD 11 structs (stat120 bytes,dirent264); libc ≥ 0.2.187 ignores the oldRUST_LIBC_UNSTABLE_FREEBSD_VERSIONvariable, which left the first hardware build on FreeBSD 12 structs (every path looked like neither file nor folder), so the CLI asserts the struct sizes at compile time. It deletes the CLI's linked output first, so a C change always relinks (a rebuild takes ~8 s). Output andCARGO_HOMElive intarget/ps5/.panic-strategyisabort: a job panic ends the payload and leaves its.part, which the next start lists. UnRAR's C++ (unrar_sys:.rarsources work on the console too) is built withprospero-clang++(CXX_/CXXFLAGS_x86_64_ps5_freebsd), andlinker.shlinks the SDK's libc++, libc++abi and libunwind.- Two stages, because a running payload can't read its own file: stage 1 (
target/ps5/stage1.elf, no copy inside), then stage 2, the release ELF, withFORGE_SELF_ELFnaming stage 1, which the server'sbuild.rsembeds (see Self copy).FORGE_DEBUG_APIstops after stage 1. linker.sh: rustc's linker. Maps-lm -lrt -lutil -lexecinfo -lkvm ...to-lcand-lgcc_sto-lunwind, adds the shims and the--wraplist, and names the SDK libs (rustc passes-nodefaultlibs).- cc-rs (blake3's assembly) derives
--target=x86_64-unknown-freebsd-ps5, which clang refuses;CFLAGS_x86_64_ps5_freebsd='--target=x86_64-sie-ps5 -mno-red-zone -femulated-tls'puts the SDK target back (the later flag wins). entry.c(__wrap_main): elfldr starts a payload with no arguments. No arguments, orservefirst (elfldr'sargs=serve, as the tile's page sends it), runsserve, logged to/data/dumpforge/serve-<pid>.txt. A developer hook: an args file/data/dumpforge/args(one argument per line, the command first; CR dropped, blank lines skipped; over 16 KiB, a NUL or more than 62 arguments is refused, never cut short) is renamed toargs.donebefore it runs, so an autoloaded boot never replays it; its output goes tolog-<pid>.txt. Before either log is opened, earlier runs'serve-<pid>.txt/log-<pid>.txtwhose process is gone (kill(pid, 0)→ESRCH) are deleted except the newest by mtime, so the run before a crash stays readable. It exportsps5_notify(at most 3,074 bytes). Folder migration: the PS5 version was "PS5 Dump Forge" with/data/ps5-dump-forge; it is "Dump Forge" with/data/dumpforgenow (the desktop app keeps "PS5 Dump Forge"). Before the folder is created, a payload launch (not the plain CLI) renames/data/ps5-dump-forgeto/data/dumpforgewith onerename(2)when the old one is a folder and the new one doesn't exist; a failure notifies "cannot move" and the launch goes on with a new folder. After the folder is made, a relative link/data/ps5-dump-forge→dumpforgeis created where nothing is at the old path (onesymlink(2), silent on failure, a real old folder never replaced), so a launcher page an earlier version left in AppCache (it knows only the old path) still starts the saved copy. Nothing walks through it: the Library doesn't follow links, the self copy, tile and logs use/data/dumpforge.ps5/test-entry.shcovers rename, link, link added later, both folders, and a refused rename.compat.c:statfs@FBSD_1.0/fstatfs@FBSD_1.0forwarders (outsideshim/), and--wrap=cpuset_getaffinity: the PS5 refuses the process-level query std'savailable_parallelismmakes (ERANGE; std then said 16 CPUs fromsysconf), so it is answered from the thread's mask (a payload gets 5,0xea00) and every pool, the vendored crates' included, is sized by it.launcher.c: registers the tile (after ps5-ai-cli's launcher).shim/,x86_64-ps5-freebsd.json,prepare-rust-std.py: copied unchanged from ps5-ai-clieb1d494(GPL-3.0-or-later,shim/UPSTREAM): FreeBSD 11.symverstat/readdir/kevent, raw-syscall--wraps for ~40 calls the PS5 renumbers or lacks, anfcntlF_DUPFD_CLOEXECfallback,getrandom/getentropyoverarc4random_buf,pthread_setname_np. Keep them byte-identical; our changes go inentry.c/linker.sh.- The ELF: PIE
DYN,NEEDEDexactlylibkernel_web.sprx,libSceLibcInternal.sprx,libSceSystemService.sprx(rest-mode tick) andlibSceAppInstUtil.sprx(the tile), noPT_INTERP, no TLS.scripts/release-ps5.shchecks that, the entry point, that everyapp/dist-httpfile is embedded byte for byte, no debug routes, the version marker (VERSION_MARKERin the server) against the workspace version, and the self copy (stage 1 carries none; the release ELF exactly one, decompressing to stage 1).
Web server (crates/ps5-dump-forge-server, CLI serve)
- Std only: hand-rolled HTTP/1.1, one request per connection, no server push; the page polls
GET /api/jobs(every 1 s while a job is unfinished, else 5 s). Binds0.0.0.0:8095(--port). Roots for the file browser: on the PS5/data,/mnt/shadowmnt(while it exists: ShadowMountPlus's mount points, so a mounted image can be picked as a folder),/mnt/usb0..7,/mnt/ext0..1(a drive counts only whilestatfsnames it as its own mount point with blocks: those folders exist with nothing plugged in; re-checked on each use), canonicalized and logged; on a computer the--rootfolders. - No protections, by decision: no pairing, token,
Host/Origin/CSRF checks, security headers or path confinement; paths go to core as given, as in the Tauri app. One delete route and one move route, by user decision (delete_path, Edit's and the Storage tab's;move_path, its File explorer's; the page's question authenticates nobody). - Limits: head 16 KiB, body 1 MiB (8 MiB for
start_jobandedit_check;Content-Lengthonly;edit_uploadstreams any size, 30 s idle per read), 10 s per request, 30 s per write (a download: per 1 MiB chunk), 16 handler threads (then 503), 8 unfinished jobs (then 429), 2 source reads at once (inspections, naming,host_tree,edit_check, Library scans and covers; a further read waits up to 30 s for a slot, at most 4 waiting, then 429Retry-After: 1), 32 finished jobs and 200 log lines each kept,list_dir10,000 entries (truncated),stale_parts1,000.compression_threads: nullismax(1, cores − 1); on the PS5coresis the payload's 5 CPUs (compat.c) and two are left free (3 workers), for the console's browser. - Errors are
{"error": "<text>"}with 400 / 404 / 409 (stopping, a path a job uses) / 413 / 429 / 500 (self_update: 502 / 503); core errors are the Tauri commands'format!("{e:#}"). API replies areCache-Control: no-store;index.htmland the AppCache manifestno-cache; hashedassets/*a year, immutable.
| Route | Body | Returns |
|---|---|---|
GET /api/session |
{app: "ps5-dump-forge", version, platform: "ps5"|"host", instance, stopping, self_copy, separator: "/"|"\\", url, handover: true, debug} (url: the page header's host:port; handover: takes quit {"if_idle": true}; debug: true only in a FORGE_DEBUG_API PS5 build, the page then shows Input probe) |
|
GET /api/qr |
url as a QR code, image/svg+xml (the header's and About's); 404 when the server can't tell its IP |
|
POST /api/inspect |
{path} |
Inspection |
POST /api/default_output |
{source, format, dir} |
path |
POST /api/generated_output |
{source, format, dir, taken} |
path |
POST /api/start_job |
{request: ConvertRequest} |
job id (lz4_in_place: true replaces an .exfat/.ffpkg source; output ignored; non-empty edits is an edit job of the source, in place or with edit_rebuild, output and format ignored; non-empty extract copies those game paths into the folder output, format folder) |
POST /api/cancel_job |
{id} |
null (idempotent) |
POST /api/stale_parts |
{dirs} |
string[], without running jobs' parts (an in-place or edit job's beside its source) or a staging folder an upload or unfinished job uses |
POST /api/edit_check |
{request: ConvertRequest} |
EditCheck {in_place, rebuild, needed_inside, free_inside, rebuild_needs, free_beside}: why each mode can't apply (empty = it can) and the sizes (bytes, null where they don't apply); reads only, except that a leftover undo journal no job uses is restored first (after the ShadowMount+ check) and the U1 probe writes and removes its file. The source and every Add.from must be a folder or regular file (else 400); counts as an inspection (the 2-read cap), 409 once stopping |
POST /api/host_tree |
{path} |
{kind: "file"|"dir", size, files: [{path, size}], empty_dirs, truncated}: a file or folder as an edit's add will read it (junk left out; a link or special file refused); truncated past 200,000 entries or 15 s. 404 missing, counts as an inspection (the 2-read cap), 409 once stopping, 500 with core's message |
POST /api/edit_upload?source=&path= |
raw bytes (Content-Length required) |
{path}: the staged file, <source>.forge-upload-<pid>.part/<path>, where the UI sends path as <tag>-<k>/<game path> (stagingNames: a random tag per Apply) (canonical source; folders made on demand, 0777 on the PS5), written to a temporary name and renamed over. 411 no length, 400 bad query, game path or a body that ended early or stalled, 404 source missing, 409 a non-file in the way, the same target uploading, an unfinished job using it, or stopping |
POST /api/edit_discard |
{source} |
null: the source's staging folder removed by delete_path's rules (missing: null); 409 while an upload into it or a job using it is unfinished, or stopping |
POST /api/lz4_patch |
{source} |
Lz4Patch; 409 while a job is queued or running |
POST /api/lz4_unpatch |
{source} |
Lz4Patch (release runtime); 409 while a job is queued or running |
GET /api/lz4_traces?source= |
both trace files as one zip download (below) | |
POST /api/delete_path |
{path} |
null: the file or folder is gone for good (below); 400 refused, 404 missing, 409 a job uses it or stopping, 500 failed (a folder may be partly gone) |
POST /api/move_path |
{from, to} |
null: moved or renamed on its volume, to never replaced; from refused as delete_path refuses it (same roots kept), to refused inside from, on another drive, taken, or with an undo journal pending; 400 refused, 404 missing (source or target folder), 409 a job uses either or stopping, 500 failed |
POST /api/library_scan |
{} (an object; root string scans just that folder on any filesystem (a network share included; a chosen root that is a link is resolved first, links inside the walk are never followed, /mnt/shadowmnt refused; system folders such as / or /usr are refused with core's message), non-string 400; root is required on a computer (400 "choose a folder to scan" without it) and optional on the PS5) |
{roots, entries: [{path, kind, title_id, title_name, version, has_cover, lz4_packed, additional_content}], truncated, warnings, storage?}: the Library's scan of that folder, or on the PS5 of the server's current roots (browse roots without /mnt/shadowmnt, re-resolved each call so a newly attached drive appears on Refresh). storage {free, total} (bytes, statfs of /data) only on the PS5, read on each scan. Reads only; counts as an inspection (the 2-read cap), 409 once stopping, 500 with core's message. Partial results set truncated and add warnings (core's scan limits and 15 s cooperative budget). The request deadline governs receiving the request, not core's work, which can't be interrupted |
POST /api/library_cover |
{path} |
a PNG data-URL string, or null (no icon, too large, not a PNG); fetched lazily per card. Same cap, 409 and 500 rules (an unreadable source is a 500) |
POST /api/list_dir |
{path: string|null, explorer?: bool} |
{path, parent, entries: [{name, path, dir, size, link, mounted}], truncated} (null: the browse roots; parent null at a root; link: the entry is a link, its path the resolved target; with explorer the roots are the Library's, and on the PS5 /mnt/shadowmnt is hidden (a path in it 400) and mounted marks an image ShadowMount+ has mounted) |
GET /api/jobs |
{instance, jobs: [{id, request, stages, progress, done, log, log_total}]} |
|
POST /api/quit |
{} |
{}, then cancels every job, waits for cleanup and exits |
POST /api/quit |
{"if_idle": true} |
{}, then exits as above, only when idle: 409 "Dump Forge is busy: …" while a job is queued or running, a lz4_traces download streams or a patch, delete or move holds admission past 1 s, 409 "Dump Forge is stopping" (the handover's request) |
GET /api/elfldr |
— | {up}: elfldr accepts a connection (1 s; connect and close, nothing sent); 404 on a computer |
POST /api/self_update |
{version} (X.Y.Z, newer than this one) |
{} once elfldr has answered (downloaded and started it); this server keeps serving until the new copy takes over when idle (below); 404 on a computer, 400 bad or not newer, 409 stopping, a job queued or running, a patch or delete holding admission past 1 s, or another update in flight, 502 elfldr refused or failed, 503 elfldr not listening |
lz4_traces (LZ4 → Trace → Download traces): source (a folder or any image Forge reads; percent-encoded, else
400); streams ampr_commands.bin and ampr_emu.index from the source's root (core's lz4_traces: a
folder's files opened directly, an image through its reader) as one zip at the archive root: STORED
entries, flag bit 3 (CRC-32 computed while streaming, then a data descriptor), central directory, end
record; ZIP64 fields only where a size or offset reaches 0xFFFFFFFF (core's zip.rs, whose reader Pack
uses). 200, application/zip, Content-Disposition: attachment; filename="<ASCII stand-in>" plus
filename*=UTF-8''<percent-encoded> for a non-ASCII name, the name [GAME_TITLE]-[TITLE_ID]-amprtrace.zip
(amprtrace.zip without either), the exact Content-Length (planned up front), no-store, read and
written 1 MiB at a time (never whole in memory). Either file missing: 404 naming it; a source core can't
open: 500 with its error. Each chunk is read only while the source is unchanged
since opening (the image file, or a folder's trace file: length, mtime, identity); a change (a game
relaunched on a writable mount truncates its journal) ends the transfer short and is logged. Opening
counts as an inspection (the 2-at-once cap), the transfer doesn't; jobs may run meanwhile. A read failing
mid-transfer closes the connection short of Content-Length.
delete_path (core's Jobs::delete_path): a file, or a folder with what is inside (links removed, not
followed), then its folder fsynced. Refused (400): an empty path, a link itself, a special file, a volume
root or mount point, a folder with another volume mounted inside (by st_dev), every browse root
(configured and mounted now) and the folders holding one; on the PS5 also Forge's own files (its self copy,
that copy's lock and the tile owner record), anything at or in /mnt/shadowmnt, an image ShadowMount+ has
mounted and a folder holding one (fails closed), and an undo journal while its image is there. 409: anything at, in or above what an unfinished
job reads or writes (source, output, .parts, profile, traces), by identity after resolving links. The
check and the removal hold the job table and admission, so no job or quit starts in between. No Trash, no
undo.
move_path (core's Jobs::move_path): one rename, never replacing, then both folders fsynced. from is
refused as delete_path refuses it, and so is a folder with a volume mounted inside; to must be a plain name in an existing folder (404 when it is
missing), not inside from, on the same drive (400 "can't move between drives"), not taken (400), not at,
in or above an unfinished job's paths (409); an image with an undo journal beside it is refused (400: open
it in Edit first). Same locks as delete_path.
instance is random per start, so the page notices a restart and rebuilds its job list. list_dir (core's list_dir) with
null lists the roots; directories first, then by name; non-UTF-8 names left out; symlinks resolved
(link: true, path the target; a dangling one as itself).
Events keep core's serde shape ({"Ok": …} / {"Err": "cancelled"}); ids stay below 2^53. A job's table
entry is made by its first event (core can emit Done before Jobs::start returns). Quit closes
admission first (start_job → 409); on a computer SIGINT/SIGTERM does the same. A port already taken by
Forge is taken over when that copy is idle, else "Can't close Dump Forge - some jobs are still running."
and exit 0 (see Updates); by anything else, exits 1.
- The URL (session
url, the page header, the host's start notice, the PS5 serve log) uses the address of a UDPconnecttoward the internet (no packet sent). On the PS5 the start notification only says "Dump Forge is launched", in the console's language (notice.rs). - The web bundle:
npm run build:http(Vite modehttp) writesapp/dist-httpwithtransport-http.tsxin place of Tauri (Picker.tsxfor the dialogs,http-client.ts's poller for the events);build.rsembeds it, or a placeholder page with a warning (FORGE_REQUIRE_WEB=1, set byps5/build.sh, makes that an error). Paths are the server's (/), never the viewer's. Plain-node checks:app/scripts/(check-paths,check-poller,check-launcher,smoke).
Tile, self copy and launch on open
- Tile (
tile.rs,ps5/launcher.c):/user/app/PDFG00001/sce_sys/{param.json,icon0.png},deeplinkUrihttp://127.0.0.1:<port>/, iconps5/icon0.png. Registered only when a file was written. APDFG00001not marked by/data/dumpforge/tile-PDFG00001.owneris another title's and is never touched. Its header is "Dump Forge home-screen tile"; the old "PS5 Dump Forge home-screen tile" (a migrated folder) still marks it ours, so the first start rewritestitleName"PS5 Dump Forge" → "Dump Forge", registers again and records the new header. While/data/dumpforge/tile-PDFG00001.owneris missing (the rename failed, or both folders existed),/data/ps5-dump-forge/tile-PDFG00001.owneris taken over when its folder is a real one (not the link), it is a regular file and it names this title folder (header and(st_dev, st_ino)): rewritten in the new place, then the tile as above. No valid record in either place: another title's. Runs in the background and never fails the server; the serve log says what it did. - Self copy (
self_copy.rs): stage 2 embedsPDFGSELF, u64 LE raw length, u64 LE compressed length, SHA-256 of stage 1, then stage 1 zlib-compressed (6.3 MB ELF holding 1.7 MB for a 4.5 MB copy). On start a thread unpacks it (bounded, hash, ELF magic and version marker checked) into/data/dumpforge/ps5-dump-forge.elf: written when missing, older, the same version with other bytes, or unmarked; kept when identical or newer. One save at a time across processes (flockon.ps5-dump-forge.elf.lockbeside it), so two copies starting together keep the newest. Temp fileO_EXCL|O_NOFOLLOW, fsync, rename, folder fsync. A copy that loses the port race, and quit, finish the save first.GET /api/sessionself_copy:saving,saved,up to date,kept newer <v>,failed: <why>, ornone(stage 1, debug, computers). - Launch on open (
launcher.ts,appcache.ts): the page is kept by AppCache (forge.appcache, a hash over every bundle file). Only on the console's own browser (a loopback host) and only at page load: ifGET /api/sessiondoesn't answer, it sendsGET http://127.0.0.1:9021/data/dumpforge/ps5-dump-forge.elf?args=serve&pipe=0(then the old/data/ps5-dump-forge/ps5-dump-forge.elf, the link or a folder a failed migration left, then the USB copies) and waits up to 30 s. A newer payload's manifest swaps the page in and reloads once, never mid-dialog.
Updates
- The page checks GitHub at every start (always on, no setting, nothing stored;
app/src/update.ts): the latest release'stag_namevX.Y.Znewer than sessionversion, an asset namedps5-dump-forge-<v>-ps5.elf(uploaded, not empty) and elfldr answering (GET /api/elfldr), else no toast; any failure on the way (offline, DNS, timeout, an older server without the route) is silent.serveon a computer (platform: "host") never offers one. Update postsself_update, then pollsGET /api/sessionevery 1 s untilinstancechanges (network errors meanwhile are expected: the old server exits on the handover), then lets AppCache swap in the new page. One minute in all from the request, download included; then the toast says to stop Dump Forge and open it again (a downloaded version that couldn't take over, such as v0.1.1 without handover, has saved itself: the tile starts it). self_update(elfldr_loadinapi.rs): admission checked likelz4_patchand released before elfldr is asked (the new copy'sif_idleneeds it), one at a time (anAtomicBool). It never stops this server itself. Connects toOptions.elfldr(127.0.0.1:9021) within 3 s and sends exactlyGET /?uri=<percent-encoded U> HTTP/1.1,Host,Connection: close, with U =https://github.com/quer3q/ps5-dump-forge/releases/download/v<v>/ps5-dump-forge-<v>-ps5.elf?args=serve&pipe=0(elfldr readsargs/pipefrom U's own query). Reads the reply (64 KiB, 55 s: the download happens inside it): no200→ 502 "elfldr refused the request"; a body line withError(elfldr'sError reading HTTP payload/Error spawning payload) → 502 "elfldr couldn't download or start v: ".- Handover: a newly loaded copy of any version (elfldr's
?uri=,nc, an autoloader; an older or the same version too) that finds the port held by Forge closes it when idle and takes its place. It reads the running copy's session; one withhandovergetsquit {"if_idle": true}; one without (v0.1.1 and older: they ignore the body and cancel their jobs on quit) first getsGET /api/jobsand a plainquit {}only when every listed job hasdone(ponytail:inlib.rs: a job admitted between the two requests isn't listed yet and is cancelled; a trace download it streams is cut). 3 s client, shared with the session probe. A start that couldn't serve (no usable--rooton a computer) fails before asking. On 200 it retriesbindevery 200 ms up to 30 s, then (PS5) waits for its self copy and saves it again, and serves (the self copy still keeps the newest version: an older ELF runs, but the tile starts the newer saved one next time). Busy (a 409, or an unfinished job listed) notifies "Can't close Dump Forge - some jobs are still running." and exits 0; no answer or a port still taken after 30 s notifies "Dump Forge v could not take over from v" and fails. Both notices are in the console's language (notice.rs). - Open risks (unverified on hardware;
ponytail:inapi.rs): elfldr's sceHttp has to follow GitHub's 302 to its asset host and GitHub to ignore the extra query; elfldr ignores certificate errors and nothing checks a signature (whoever can intercept the console's traffic can serve an ELF); it reads the body once without checking its length, so a short read could start a cut ELF.
Writing safely on the PS5
Console-side lessons (from ps5upload and ShadowMountPlus): a cross-device rename() panics the kernel;
sustained multi-GB writes panicked it from dirty-page buildup; Sony's exFAT (exfatfs) shows freed space as
used until the folder is fsynced; some filesystems refuse fsync with EINVAL/ENOTSUP; fsync fails
transiently; sparse files on PS5 UFS collapse to 2–3 MiB/s. /data is bfs or ufs, sometimes through
nullfs (on the test console: nullfs, no hard links). The code labels each piece U1–U10:
- U1, destination probe (
dest.rs): once per job, through one held folder descriptor (right undernullfs).fstatfsfor free space, type and read-only; a 1 MiB.forge-probe-<pid>.partwritten, fsynced, read back, samest_dev; the file-size limit from_PC_FILESIZEBITS, else by name (msdosfs32 bits;exfatfs,ufs,bfs64), else 4 GiB − 1; case and NFC/NFD folding by lookup, where onlyENOENTmeans distinct and unknown means folds; hard links bylinkat, where onlyEOPNOTSUPP/EPERMmean unsupported and any other error refuses the job. One log line (destination /mnt/usb0: exfatfs, 118 GiB free, 64-bit files, folds names, no hard links). Fail closed. - U2 folder output's name findings use the probe's folding. U3 publish (
finalize.rs): same device or refuse; a file where hard links work is published bylinkat(EEXIST= taken; cleanup after it never fails the job); a folder, or no hard links, takes the check-then-rename (accepted window). - U4 fsync (
durable.rssync_retry): ps5upload's 20/60/200/600 ms backoff onEINTR,EAGAIN,EBUSY,ETIMEDOUT,ENOENT,ENXIO,ENODEVand Sony's0x8002xxxxforms;EIO,ENOSPC,EROFSfail at once; unsupported on a folder is best effort, on a file an error. A fsync that succeeded only after a retry fails the job (dropped pages can't be told apart). - U5 bounded writes (
SyncEvery, around every image writer, the.ffpfscwrap and extraction; a background syncer for.pkg): large writes split, gaps past the end written as zeros (no sparse files, noset_lenextends), a sync every N dirty bytes, N from 32 MiB adapting to ~1 s of measured rate within 16..256 MiB, each change logged. Forward seeks from the end don't sync. No preallocation (U9). - U6 deletes close the handle, unlink, then fsync the folder; free space is re-checked at the next
preflight ("space may still be being freed"). U7 on any failure, the recorded mount points of source
and output are re-checked; a gone drive is named before the original error. U8
sceSystemServicePowerTickevery 30 s while a job runs holds off auto rest (rest entered by hand: no clean-failure guarantee;stale_partslists what was left). - U10 testing without USB: host unit tests with a fault-injecting output;
scripts/test-freebsd.shruns core and server tests on md-backed UFS, FAT32 and nullfs-over-UFS volumes (tests/freebsd_volumes.rs: every target end to end per volume, BLAKE3 read-back, a late failure that publishes nothing). FreeBSD has no native exFAT, soexfatfsstays covered by the probe only. - Edits (the Edit tab): an image ShadowMountPlus has mounted is refused in place and for a rebuild (an
entry
<stem>_<8 hex>of/mnt/shadowmntor/mnt/shadowmnt/pfscwhosest_devdiffers from its folder's; a listing error other than not found refuses too), at scan and again right before writing or replacing: the kernel's cached view of a mounted (image_rw=) image would undo or damage the edit. The undo journal (<image>.forge-undo) and the image are synced through U4 (a retried sync fails the job), room for the journal is checked through U1 on the image's folder, and so is the room for a rebuild;edit_checkruns that probe too, so each check writes and removes a probe file there. A folder edit and an Extract run the U1 probe on the game folder or the destination (room for the added bytes + 1 MiB) and U2's name rules from it; their folders and files are opened up to 0777 like every payload write. Uploads are staged 0777 beside the source. Not yet run on the console. - Read-ahead (
prefetch.rs): the source is read one range (≤ 8 MiB) ahead on its own thread, overlapping hashing and writing.
Speed on the console
Measured on a PS5 (firmware 13.60), sources and outputs on /data:
Pattern on /data |
Speed |
|---|---|
| 8 MiB reads | 912 MB/s |
| 64 KiB reads | 150 MB/s (~0.35 ms per syscall) |
| 8 MiB writes | ~288 MB/s |
fsync every 64 or 256 MiB |
no measurable cost |
| Conversion | Size | Time | Verify |
|---|---|---|---|
007 .ffpkg → .exfat (UFS2 reader before vendor patch ufs2-range-reads, now 0006) |
87.3 GB | 26 min | full |
007 .ffpkg → folder (with patch 0006) |
86.9 GB | 11.6 min | full |
Directive 8020 folder → .exfat |
59.2 GB | 4.75 min (~208 MB/s end to end) | fast, 377 samples, 2.1 GiB |
Write speed of folder → .exfat through the speed work (15 s samples of the same game):
| Build | Write speed |
|---|---|
| One thread, two hash passes | ~203 MB/s |
| Read-ahead on its own thread + one hash pass (slice hashes only) | ~265 MB/s |
No hashing of sync intervals (a retried fsync fails the job instead) |
~273 MB/s |
That is ~95% of the drive's ~288 MB/s write ceiling. Every cancel left no .part behind. Vendor patches
are listed in vendor/README.md.