69 KiB
AGENTS.md
ps5-dump-forge: converts a PS5 game between a folder and every image format ShadowMountPlus 1.7
mounts (.exfat, .ffpkg UFS2, .ffpfs PFS, .ffpfsc compressed PFS container; read and write), plus
debug FPKG .pkg (create and extract), and edits a game's files in a folder or image (the Edit tab:
in place or rebuilt) or copies some out (Extract). Rust core, Tauri 2 GUI (PS5 Dump Forge.app/.exe/.AppDir), a
CLI (ps5-dump-forge), and a PS5 ELF payload (the CLI's serve: the same UI as a web page on port 8095).
Releases: macOS (one universal zip), Windows x86-64 and arm64, Linux x86-64 and arm64, the PS5 .elf.
ps5/README.md holds the payload's details (harness, HTTP routes, tile/self copy/launch on open, U1–U10
write safety); docs/LZ4.md the LZ4 user guide.
Layout
crates/ps5-dump-forge-core: everything a job does. Own folder scanner (no symlink follow, exact names), preflight (names, special files, space, FAT32, depth, name limits), jobs (a worker thread per job, one heavy job at a time in FIFO order, cancel flag,catch_unwind),.part+ atomic no-replace rename (finalize.rs; macOS exFAT has none: check, then rename; FreeBSD: hard link where the folder has them, U3), BLAKE3 verification (verify.rs: 8 MiB slice hashes on a pool of up to 8 threads, fast sample or full),inspect.rs(cover, firmware, backport firmware from executables, embedded DLC; default and generated output names),prefetch.rs(read-ahead thread),delete.rs(Edit's and Storage's delete, Storage's move;in_use, the job-path guard),listing.rs(list_dir;list_explorer, the PS5 explorer's ShadowMount+ rule),library_roots.rs(PS5 filesystem classifier, the chosen-folder refusal list) andlibrary.rs(scan_library,library_cover). Edits:edit.rs(Edited: theEdits folded over a source tree, every refusal listed, host files stamped and re-checked on each read; records the format editor'spatch::Ops),edit_job.rs(an image edit job in place: scan, preflight, apply through the undo journal, verify, commit or roll back; the rebuild dispatch andedit_check's two halves),edit_folder.rs(a folder edit job, applied directly),host_tree.rs(host_tree: a host file or folder asEditedwill scan it, for the UI's preview),extract_paths.rs(an Extract job: chosen files and folders of any source copied out). FreeBSD/PS5 only:dest.rs(destination probe, U1),durable.rs(sync_retry,SyncEvery, drive-removal check; U4/U5/U7).lib.rsis the public API the CLI, the app and the server build against.crates/ps5-dump-forge-exfat: forward-only exFAT writer (512 B sectors, 64 KiB clusters), ported from MkPFS.crates/ps5-dump-forge-ufs2: write-once UFS2 writer for the one SMP geometry (newfs -O 2 -b 65536 -f 65536 -m 0 -S 4096). Both:plan(validates names, sizes the image) thenwrite(strict offset order into a caller-owned file). Each also hasedit.rs, its in-place editor:Volume::open(checks the volume, metadata within 128 MiBMEMORY_LIMIT),edit::plan(image, ops) -> Result<Vec<Write>, Vec<String>>(reads only) andplan_sized(+Space {needed, free}, the numbers behind its "the changes need N MB" finding).crates/ps5-dump-forge-pfs: PFS writer for.ffpfs(MkPFS's unsigned PS5 layout, sameplan/writeshape), the streaming.ffpfsccontainer writer (wrap: the inner image's writer →Stream→ zlib workers →.part), and the PFS/PFSC readers (PfsSource,open_ffpfsc).edit.rs: the.ffpfsin-place editor (sameplanshape; what it can do: Format rules).crates/ps5-dump-forge-patch: std-only (+crc32fast) in-place write engine the format editors plan for:Op(Delete/Rename/Mkdir/Put),Data(Bytes/Zero/Source),Write {offset, data, live};apply(undo journal, then free-space writes, then live ones),Applied::commit/rollback,recover,journal_path;Syncshooks (core passes FreeBSD'ssync_retry). The format crates depend on it for types, so they never depend on core.crates/ps5-dump-forge-fpkg: streaming.pkgreader (FpkgSource), CNT entries merged with the inner filesystem.crates/ps5-dump-forge-lz4: ampr_emu asset packs: AMPRPAK4 manifest + AMPRDAT3 volume writer and reader (unpack view as aSourceTree), AMPRIDX3 and AMPRCMD1 (journal) readers, pack rules (rules.rs), the embedded runtimes (runtime.rs). Glue incore/src/lz4.rs(open/unpack, trace, index, runtime swap);core/src/lz4_patch.rsis the in-place folder patch and unpatch.tree.rsisPackedTree, the packs served as aSourceTreefrom awriter::measurepass.vendor/ampr_emu/: the two upstream 0.4.2.1.sprxruntimes + the source archive (see its README).crates/ps5-dump-forge-archive:.zip/.7z/.rarsources (detect,list,read_files,Reader,ArchiveError, the two password strings).zip.rsown reader (ZIP64, STORED + Deflate viaflate2),sevenz.rsoversevenz-rust2(decode only),rar.rsour FFI wrapper overunrar_sys(bundles UnRAR's C++; every target, the PS5 payload included),parts.rsmulti-part naming,stream.rstheReader. Glue:core/src/archive.rs(ArchiveTree),inspect.rs,library::archive_game_root. Licence:vendor/LICENSE-UnRAR.txt,vendor/LICENSE-UnRAR-exception.md(copied into every release).crates/ps5-dump-forge-cli: binaryps5-dump-forge(inspect,convert,lz4-patch,lz4-unpatch,serve), JSON-lines events.crates/ps5-dump-forge-ps5: the payload's global allocator (Pool, installed by the CLI only for the PS5): freed blocks of 64 B..4 MiB kept in power-of-two classes, since the PS5 libc serializes every malloc across threads (a.pkg's Kraken pool: 12.7 → 49.3 MB/s at 4 threads).crates/ps5-dump-forge-server:serve, std-only HTTP/1.1 (http.rs), routes mirroring the Tauri commands (api.rs), job table for polling (table.rs), roots (paths.rs), PS5 tile (tile.rs), self copy (self_copy.rs),debug.rs(PS5 only, withFORGE_DEBUG_API).build.rsembedsapp/dist-httpand the self copy.POST /api/edit_uploadis the one route whose body is streamed (no 1 MiB cap;BodyReaderinhttp.rs).ps5/: the payload's Docker harness:Dockerfile,build.sh,linker.sh,entry.c(launch modes, logs,ps5_notify),compat.c(statfs symvers;cpuset_getaffinityanswered per thread, so std sees the 5 CPUs a payload may use, not 16),launcher.c(tile registration),icon0.png,test-entry.sh;shim/+x86_64-ps5-freebsd.json+prepare-rust-std.pyare ps5-ai-cli's, byte-identical.app/: React/TS/Vite UI;app/src-tauriis theps5-dump-forge-guicrate (binaryForge, bundled as "PS5 Dump Forge" viamainBinaryName; Linux'stauri.linux.conf.jsonnames itps5-dump-forge-guiso the AppImage step can't overwrite the CLI'sps5-dump-forge).npm run build:http(Vite modehttp) builds the web UI intoapp/dist-http:transport-http.tsxreplacestransport-tauri.tsx,Picker.tsxthe native dialogs,main-http.tsxrunslauncher.ts(start the payload via elfldr) andappcache.tsfirst. Edit tab:Edit.tsx(card, explorer, row actions, drops),EditChanges.tsx(pending bar, conflict question),EditApply.tsx(mode,edit_check, confirm, uploads),explorer.ts(pure: the tree, the pending changes' fold, a port of core'sEditedrules,toEdits,checkable). Storage tab:Storage.tsx(view switch),Library.tsx,Files.tsx(the File explorer).app/scripts/*.mjs: plain-node checks (check-paths,check-poller,check-launcher,check-update,check-upload(the upload URL's encoding against the server's decoder,uploadover a mocked XHR),check-explorer(explorer.ts: the fold mirrors core'sedit.rstests),check-shoulders(shoulders.ts: L2/R2 keys, tab order),smoke).vendor/ps5upload-{fpkg,pkg}: ps5upload v7.0.22 crates (readers, FPKG builder, verify) plus a local patch series invendor/patches/; seevendor/README.md. Edition 2021 on purpose.vendor/lzma-rust2: crates.io 0.22.0 + patch 0021 (bounded multithreaded LZMA2 decode, whichsevenz.rssizes per block by dictionary and largest run), via[patch.crates-io], excluded from the workspace.scripts/: checks and releases (see Commands);check-versions.shalso matches the tag on a tag build.fuzz/: cargo-fuzz targets, its own workspace.DESIGN.md: the UI design system (tokens, components, colour/contrast rules, the cover glow);app/src/styles.cssholds the values. Read it before changing the UI.app/src/locales/: the UI text, one file per language (see Key decisions).
Key decisions
- Every reader/writer meets at
ps5upload_fpkg::source::SourceTree(sizes up front,read_range,empty_dirs,Send). Any source converts to any target with no staging, archives included. - Archive sources (
.zip/.7z/.rar, one game root ≤ 4 levels deep, found by the Library's recognition from the listing) are read in place, never unpacked to disk:core/src/archive.rsserves the game folder as anArchiveTreeover the archive crate'sReader, which decodes front to back on its own thread (a session; bounded queue) and serves the entry asked for, skipping those before it; an entry the session went past (or a ZIP/7z skip that never recorded it) starts a new session from the archive's start: cheap when non-solid (header skips), a re-decode of everything before it when solid (by user decision: no temporary files, even for solid archives). Verification hashes as written, so nothing re-reads the archive. The source stamp covers every part. Every part of a multi-part source is guarded from delete while its job is unfinished; trace/in-place/patch/edit refuse an archive (Edit browses and extracts from it only); the Library lists none.inspectreads only the entries it needs (no extraction). Passwords for 7z and rar only (encrypted zip refused),ArchivePasswordredacted inDebug, never serialized or logged; with none given,archive::passwordtriesKNOWN_PASSWORDS(by user decision: six release-site passwords, in order; one fits when the game folder'sparam.jsonreads, and when that file is in the clear, the game's smallest encrypted file too;Entry::encrypted) for inspection, naming and jobs alike, and only when none fits is the error "needs a password" (the UI's prompt); CLI--password-stdin/FORGE_ARCHIVE_PASSWORD. Multi-part:.partN.rar,.rar+.rNN,.7z.NNN/.zip.NNN, split.z01….zip; first part only, missing parts named. 7z Deflate is refused: itsdeflatefeature would switch the workspace'sflate2to zlib-rs, replacing.ffpfsc's miniz_oxide. Memory (by user decision): a job stays within 512 MiB except for the archive's own dictionary, which the decoder allocates whole: RAR is accepted up to 4 GiB and 7z up to 1 GiB, so an archive packed with a dictionary over ~256 MiB goes past the cap. 7z LZMA2 decodes on several threads only in a job's session (Sink::streams; small reads stay on one) and for a block that is a lone LZMA2 coder, sized per block (dictionary + 6 × its largest run within 256 MiB;sevenz::decode_threads); RAR5 decodes on UnRAR's own threads (at most 8). - LZ4 packs are an asset-pack transform of a game folder (ampr_emu), not an SMP image format:
--to lz4writes a folder (no extension) withampr_assets.index,.crc,ampr_assets-NNN.pakvolumes, loose leftovers and the runtime; converting that folder to an image is an ordinary second convert (packs are carried). Pack can also target an image directly (Lz4Mode::Pack,lz4: "pack", CLI--lz4-pack;formatfolder/exfat/ffpkg/ffpfs/ffpfsc,.pkgrefused "not supported yet";Format::Lz4is the alias of pack into a folder): two passes, no staging folder.writer::measurerunspack's compression without writing and keeps only the metadata (Measured: manifest, CRC sidecar, profile, volume sizes, build ID; 16 B per chunk; the job peaks near 36 B per chunk while verify parses the manifest).tree::PackedTreelists the loose files plus the pack files at those sizes; reading a volume re-reads each chunk's source block, recompresses it with the onewriter::encode, and fails unless stored length, codec, CRC and the LZ4 chunk's decoded CRC match the measurement; one cached chunk,threads * 4blocks in flight per volume.packandmeasureshare oneSink(placement, thresholds, rollover) and onemetadata; folder output is pinned byte for byte by a hash test. The image job: measure (stagemeasure), plan → write fromHashingTree(PackedTree(HashingTree(logical))), verify as written, thenreader::unpackof the image (every runtime check) and the logical files against the source, same fast/full policy. Pack and trace need a title whoseeboot.binimportslibSceAmpr. Compression islz4_flex. UI: every LZ4 action lives in the LZ4 tab (app/src/Lz4.tsx; How LZ4 works help inLz4Help.tsx). Two scenarios: Pack/Unpack (first, and the default whenever usable; traces as a zip or folder, locked to the source's own when it is traced, or a profile; Unpack when the source is packed), then Trace (a folder,.exfator.ffpkg: Patch / Unpatch; a folder throughlz4_patch/lz4_unpatch, an image through a conversion withlz4: "trace"|"unpatch",format= the source's,lz4_in_place: true; Download traces in the web build once a journal is there). An AMPR game without its own ampr_emu (runtimenone) gets every LZ4 action too (by user decision): Pack, Unpack and Patch/Unpatch install the bundled runtime. A game that doesn't use AMPR (and isn't packed) gets none: the right card says "This game doesn't use AMPR, so LZ4 can't be used here" (lz4.noAmprGame, by user decision), no actions. Right-card notes (hidden while blocked): Pack/Unpack's runtime action fromLz4Facts.pack_runtime_action(install, replace or keep, naming anotherbuild's version), Patch/Unpatch's install for runtimenone, and a non-ok index the action writes afresh (lz4.freshIndex; Pack, Patch, Unpatch). Target pickers (a UI limit; core and CLI keep.ffpfsc): Pack folder/.ffpkg(default)/.exfat/.ffpfs, Unpack folder/.ffpkg(default)/.exfat/.ffpfs(by user decision). Convert always sendslz4: null(by user decision: no Update AMPR switch, nothing to choose); under Build, for an AMPR game only, mutedinfonotes say what the conversion does by itself: the runtime action (Lz4Facts.runtime_action: install, replace, keep with the version;convert.amprRuntime) and the index (a non-ok one written afresh,amprIndex, an ok one counted stale while Remove backport is on; for an fpkg target alwaysamprPkgIndex: the builder writes it). The Edit tab's LZ4 tile (its Game facts fold) names install/replace (keep only for anotherbuild with a version) and a non-ok index, and shows for an AMPR game a conversion installs the runtime into. Job rows: an LZ4 tab job has a yellow edge and anLZ4 pack|unpack|trace|unpatchtag; anunpatchwithoutlz4_in_place(a server API request; Convert sends none) is taggedAMPR runtime. - Every conversion repairs an AMPR title (
eboot.binimports libSceAmpr; by user decision): any target, Convert, CLI, server and LZ4 jobs alike (lz4::plainfor a request without LZ4 options,prepare's full path otherwise). Edit jobs are the exception (their own index rule, runtime never swapped; see the Edit tab below). Runtime: one rule,lz4::runtime_of(tree).action(ampr, packs)→RuntimeAction, shared by the jobs andinspect:Installwhere there is none;Replacean ampr_emu older than the bundled one (runtime::version_of+not_older), one naming no version or unreadable, and Forge's trace runtime (on any title);KeepForge's release runtime and another ampr_emu at the bundled version or newer (by user decision), except into an output holding packs (Pack, a packed source as it is), which also needsruntime::reads_packs(upstream's no-pack build is replaced);Untouchedfor a non-AMPR title. Trace/Patch still install the trace runtime and Unpatch the release one whatever is there.Lz4Facts.runtime_action(plain convert) andpack_runtime_action(Pack/Unpack) name it (install/replace/keep,Nonefor a non-AMPR title); the CLI's LZ4 line says it too. Index (any action butUntouched): an unpacked source keepsampr_emu.indexonly when it lists exactly the output's (path, size) set (lz4::index_state, the one comparison,inspect's too; a replaced or installed runtime counted at the release size,plain_listing), else a fresh one is written (stale, damaged or missing alike); with a rebuild the journal and logs are left out (the journal's file ids follow the old index's record order), with a kept index they stay (LZ4 jobs always leave them out).index_statereads no index bigger thanindex_bound(4 × a fresh index for the listing + 1 MiB:Staleunread) nor over 64 MiB (MAX_INDEX_CHECK:Damagedunread), so the check stays well within a job's 512 MiB. Into a.pkgno index is built here: the old one (an AMPR title's), the journal and logs are left out and the builder writes it after its SDK filtering (below), so leftovers it drops can't fail preflight. A packed source gets the runtime action and keeps its index, manifest, journal and logs byte for byte; a replaced runtime then differs from the manifest's loose record, whichreader::unpacklets pass forRUNTIMEonly (ponytail: unverified on the console).Lz4Facts.index(ok/stale/damaged/missing;Nonewhen packed, when the action isUntouched, or for an archive's index over 16 MiB) is that comparison; Edit's game facts, Convert, the LZ4 tab and the CLI say a conversion refreshes it. - The recommended trace path is a traced
.ffpkgmountedimage_rw=, then Download traces (http build only; the desktop app points at Pack):GET /api/lz4_traces?source=streams one STORED zip ([GAME_TITLE]-[TITLE_ID]-amprtrace.zip, the generated output stem +-amprtrace.zip) ofampr_commands.bin+ampr_emu.indexout of a folder or any image via core'slz4_traces(Lz4Traces;write_zip/zip_len) in 1 MiB chunks, never whole in memory.core/src/zip.rsis the one zip layout, writer and reader (flag bit 3 + data descriptors, ZIP64 only past 0xFFFFFFFF). Every read re-checks aSourceStamp(the image file, or a folder's trace files: length, mtime, identity) and fails on any change, so the body ends short ofContent-Length./mnt/shadowmntis a PS5 root so the mounted image's folder can be picked too. Patching a folder stays offered, though a folder patched in place failed to launch on the console (CE-107750-0, cause unknown) where a Forge.ffpkgdid. Both upstream runtimes (release, trace) are embedded withinclude_bytes!, pinned by SHA-256; the trace runtime is swapped for the release one on every plain conversion and Pack (above). Unpatch (Lz4Mode::Unpatch,--lz4-unpatch) forces it: the release runtime over any runtime or none, even one a plain convert keeps (no backup exists), journal and logs left out, always a fresh index; refused for a packed source ("unpack first") and with--to lz4(redundant). - LZ4 rule sources, first match:
--lz4-profile x.toml, copied traces (--lz4-traces), the dump's own journal of the last trace session (ampr_commands.bin, mapped to paths through the source'sampr_emu.index), else the built-in guess.--lz4-unpack(automatic when the target is lz4) restores the logical assets; the runtime follows the runtime rule above (kept unless installed or replaced) and anampr_emu.indexlisting exactly the output stays. A trace (--lz4-trace) writes the journal on the title's/app0, so the output must be writable there: a folder, or animage_rwexfat/ffpkg with--lz4-trace-spaceMiB (default 256, 64..1024 step 64) of free room;.ffpfs/.ffpfsc/.pkgare refused.--lz4-traces <zip|folder|ampr_commands.bin>(the downloaded zip: STORED entries only, each CRC-checked, the journal streamed from its byte range; a folder holding both files; or the journal with its index beside it;--to lz4or--lz4-packonly, exclusive with a profile). Membership check: every path the index lists must be in the dump (else refused: another dump or version), leaving out on both sides the journal, index, logs,fakelib/libSceAmpr.sprxand patch temp names; files only in the dump are allowed, unobserved and loose, logged in one line (first 10 + a count). Observed ids resolve through the traced index's own record order. Traces only narrow the built-in guess: an observed file is packed only if the fallback rules would pack it too. Packed files exist for the game only after it starts AMPR, so anything the engine opens, stats or lists before that (Unreal.pak/.utoc,.ini,.uproject) must stay loose. The keep-loose list (rules::keep_loose:FALLBACK_SUFFIXES+ the protected directory families; not the fallback's root-files rule) applies to every rule source, a profile included (one log line counts what the profile wanted packed, first 10). Then auto-loose (upstreamampr_pack.py,rules::AutoLoose,[pack] auto_loose_*honored within upstream's bounds): a compress-spec file (not store; hot only withauto_loose_hot_files) of ≥ 64 MiB is sampled during preflight (32 blocks / ≤ 16 MiB at fixed, evenly spread block indices,writer::sample= the writer'sencode) and kept loose when it saves < 5% or ≥ 90% of the blocks stay RAW; one log lineauto-loose: N large files kept loose (incompressible samples): …. - Never touch the source: junk (
.DS_Store,._*,.fseventsd, ...) is filtered, not deleted. Four exceptions, by user decision, all explicit requests:- The LZ4 folder patch and unpatch (
lz4_patch/lz4_unpatch, CLIlz4-patch/lz4-unpatch, serverPOST /api/lz4_patch/POST /api/lz4_unpatch{source}, Taurilz4_patch/lz4_unpatch{path}) change a plain game folder of an AMPR title: the trace (unpatch: release) runtime replaces anyfakelib/libSceAmpr.sprx(no backup), the journal and logs are deleted (root synced) and a freshampr_emu.indexis written, each file via an exclusive per-attempt temporary name (.<name>.forge-<pid>-<n>.tmp, never indexed, ignored by the traces' membership check), sync and rename, then the folder synced; 0777 on the PS5. Everything is checked first: images, packed folders, non-AMPR titles, a non-file at any target path and (PS5) the destination probe and a folder at or inside/mnt/shadowmnt(edit_job::smp_inside) refuse with nothing changed; so does a job queued or running. - An image patched or unpatched in place (
ConvertRequest.lz4_in_place, CLIconvert <img> --lz4-trace|--lz4-unpatch --lz4-in-place): rebuild + replace. Only an.exfat/.ffpkgsource,formatits own,lz4traceorunpatch, not packed (else preflight findings;.ffpfs/.ffpfsc/.pkgare read-only on the console; on the PS5 an image ShadowMount+ has mounted or one inside/mnt/shadowmnt,smp_finding, in preflight and again before the replace).outputis ignored: the job writes<source>.<job>-<pid>.partbeside the source (the destination check counts the whole new image), verifies it as usual, re-checks the source'sSourceStamp, closes the source and puts the part in its place (Part::replace: the original kept as*.orig.part(a hard link, or a rename where the volume has none), folder synced, the part renamed over the source, folder synced, the backup removed). Any failure leaves the source byte-identical and the.partdeleted (where putting the original back fails, the error names its*.orig.partbackup). The server's stale-part filter takes an in-place job's.partfrom its source. - Deleting the Edit tab's source, a Library entry or a File explorer row (the trash button of the Edit
card, of a Library card/row or of an explorer row, or its Delete key; one No / Yes, delete
dialog and one route; core
Jobs::delete_path(path, protected)→DeleteErrorRefused/Missing/Busy/Failed,core/src/delete.rs; serverPOST /api/delete_path{path}→null, 400/404/409/500, 409 also while stopping; Tauridelete_path{path}). A file (remove_file) or folder (remove_dir_all: links inside removed, not followed), then its folder synced; no Trash, no undo, and a folder that fails part way may be partly gone (the error says so). Refused: an empty path, a link itself, a special file, a volume root or mount point, a folder with another volume mounted inside (unix, byst_dev; a same-filesystem Linux bind mount is not caught), eachprotectedpath and folders holding it (server: browse roots, configured and current, and on the PS5 Forge's own files in/data/dumpforge: the self copy, its lock, the tile's owner record; app: the home folder), and anything at, in or above an unfinished job's source, output,.parts, profile or traces (a lone journal's index too; an edit job's undo journal and everyAdd.from), compared by identity after canonicalize, links on the typed path included (delete::in_use, alsoJobs::uses). Check and removal run with the job table locked, so no job is admitted between them. Leftover.parts are not cleaned up here. On the PS5 (delete and move,delete::smp_refusal, by user request) also anything at or in/mnt/shadowmnt(source or target), an image ShadowMount+ has mounted (edit_job::smp_mounts) and, while it has any, a folder holding an image of a mounted stem (walked, links not followed, 200,000 entries); fails closed. Moving or renaming (the File explorer's Rename and Move to; coreJobs::move_path(from, to, protected)→DeleteError,delete::move_; serverPOST /api/move_path{from, to}→null, same codes andkeeplist; Taurimove_path{from, to}):fromrefused as delete refuses it;toa plain name in an existing folder, never replaced (rename_no_replace; a case-only rename uses a plain rename), not insidefrom, on the same volume (byst_dev, or EXDEV: "can't move between drives"), not at, in or above an unfinished job's paths (Busy); an image with an undo journal beside it (or one at the new name) refused. Both folders synced. No copy, no new folder, no upload (by user decision). - Edits (the Edit tab, below): a request with non-empty
ConvertRequest.editsis an edit job, which changes its source (outputandformatignored;JobReport.outputis the source). A folder is changed directly (no rollback); an image is edited in place by its format's editor behind an undo journal, or rebuilt beside and renamed over it (edit_rebuild), as exception 2 replaces an image.
- The LZ4 folder patch and unpatch (
- Edit tab (after Convert; it replaced Inspect, by user decision;
app/src/Edit.tsx): left a compact game card (SourceCard compact, smaller than the other tabs' by user decision: columns 1fr:2fr from 1100px, 56px cover, title ID and version tiles, findings; folds Game facts, param.json, Details; Leftovers; Delete), right an explorer of the source (role="grid", crumbs, Up, a filter over everything below the folder shown, folders first in natural order, at most 2,000 rows shown; arrows, Home/End, PageUp/PageDown, Enter, Backspace or Alt+Up, F2 rename, Delete), "How this works" before a source. Archives are browsed and extracted from, never edited (by user decision). Changes: rename, delete, Add files / Add folder (the transport's pickers; the web picker browses the server and multi-selects within one folder) and drag and drop onto a folder row or the folder shown (TaurionDragDropEventgives paths, a browserFiles); no New folder button (by user decision: aMkdircomes only from a dropped or added empty folder). They stack in the UI and nothing is written until Apply (by user decision); Undo takes back the last batch (one drop is one batch), Discard all clears them (asks above one change); no per-change removal (ruling). The pending list folds as core will (explorer.ts, a port ofEdited's rules; a host folder listed throughhost_tree, so it folds file by file) with badges New (green), Replaced (orange), Renamed (blue, "Was "); a clash asks once per action (Cancel, Skip, Replace; Replace of the other kind deletes it first; a folder onto a folder merges, as core does).- Modes (by user decision, shown in the pending bar): In place (written into the source; the
default when both work) or Rebuild (a new image of the source's format beside it, verified as a
conversion, then renamed over it; every image format,
.ffpfscand.pkgincluded); a folder is always Changed directly. The UI asksedit_check500 ms after the last change (coreJobs::edit_check(&ConvertRequest) -> EditCheck, reads only but for settling a leftover undo journal (below) and, on the PS5, the destination probe's file:in_place/rebuildfindings, empty = that mode works;needed_inside/free_insidefrom the.exfat/.ffpkgeditor'splan_sized;rebuild_needs= the image + 64 MiB (.ffpfscits every-block-raw worst case,.pkgthe builder's estimate + 1% + 64 MiB) andfree_beside; fields that don't applynull; a job using the image gives "a job is using" in both lists). In place refused shows Rebuild needed with the reason, both refused Can't apply with core's findings, both possible a radio choice (by user decision In place green, Rebuild red, chosen or not; the single-mode tags the same, Can't apply red). Apply confirms (no undo; close the game first) and starts the job; while the Apply or its job runs, the changes, the source pickers, Delete and a Library hand-off are locked; otherwise opening a source (another, the same again, a Library hand-off) drops the pending changes without asking. Done Ok: the source is read again and the changes cleared; a failure keeps them with the error. Job rows: a plain
.tagEdit or Edit · rebuild, the error box led by "Can't apply the changes:"; a cancelled job's error that starts "cancelled; " (what stays changed) is kept and shown as cancelled. No CLI subcommand for edits (ruling). - Room (by user decision, as ps5upload): no growing an image and no FAT reserve; what an in-place
edit adds must fit the free space inside, else one finding "the changes need N MB, the image has M MB
free" (the editor's own count: data, new and grown folders, UFS2 indirect blocks) and Rebuild is
offered. A rebuild follows exception 2:
<source>.<job>-<pid>.partbeside it (room for the image + 64 MiB), verified, theSourceStampand ShadowMount+ re-checked,Part::replace; any failure leaves the source byte-identical and the.partdeleted (where putting the original back fails, the error names its*.orig.partbackup). A rebuilt.ffpfsckeeps its inner format at the request'sffpfsc_level, a.pkgtakeskraken_level(the original's level can't be recovered, so the size may differ). - Undo journal (in place; the formats have no journal of their own):
edit_job.rshashes the expected tree's verify sample first, re-checks theSourceStampand ShadowMount+, thenpatch::applywrites<image>.forge-undobeside the image (FORGEUNDO1, image and journal lengths, entries of offset + length + old bytes,FORGEEND+ CRC-32) with the old bytes of every region the edit overwrites, syncs it and its folder, writes new data into free space, syncs, then the live writes (metadata; a.ffpfsreplacement's data too, journaled), syncs. Verify reopens the image with its reader against the edited tree (fast or full); a mismatch, cancel, error or panic before the commit rolls back ("the edit did not verify; the image was restored"); the commit deletes the journal (a delete that fails rolls back) and syncs the folder; a sync failure after the delete fails the job saying the image holds the edit (ruling: it can't roll back then). Restored means every byte the filesystem uses is the original's; free space may keep the new, unreferenced data. Every open for editing (job, rebuild scan,edit_checkunder the job-table lock unless a job uses the image) runspatch::recoverafter the ShadowMount+ check: a complete journal is restored then deleted, one cut short is deleted (nothing live was written), a damaged one is kept with an error ("keep both files").Inspection.interrupted_edit(a journal beside the image) is noted on the card. Room for the journal beside the image is a preflight finding (undo journal:). - Refused (preflight findings, nothing written; every offender listed): any LZ4 option,
remove_backport, an archive, an LZ4-packed game ("unpack it first"), a bad game path (relative,/-separated, no empty/./..part, no\), an op that doesn't apply, anAdd.fromthat is missing, a link, not a file or folder, the image itself or inside the source folder (by identity), names the format can't hold (the conversion's checks run on the edited tree:preflight::input, depth, the writer's layout), the editor's own findings, and on the PS5 (by user decision, as ps5upload) an image ShadowMount+ has mounted (an entry<stem>_<8 hex>of/mnt/shadowmnt[/pfsc]that is a mount point byst_dev; fails closed; checked in both modes at scan and again before writing or replacing), and a source folder or image at or inside/mnt/shadowmnt(a mounted game;edit_job::smp_inside). Host files are stamped when listed (identity, size, mtime) and checked on every read: a change fails the job. - AMPR (a title whose
eboot.binimports libSceAmpr or that holdsampr_emu.index;edit_job::fold, shared by folder edits): an edit that touches the index itself, or changes the (path, size) set while the index doesn't already list exactly the edited files (index_state), writes a freshampr_emu.index(a user's own index is overwritten: a stale index crashes the game) and then removesampr_commands.binand the logs (their file ids follow the old index; one note line); otherwise all three stay. The runtime is never swapped, in either mode (ruling: the user's edit is what they asked for, and both modes give the same result), so a rebuild edit does no plain-convert repair. A.pkgrebuild always leaves the journal, logs and old index out (the builder indexes), as a conversion into.pkgdoes. - Folder (
edit_folder.rs): every op resolved and checked first (free space for the added bytes), then applied in order: Delete (links not followed), Rename (no replace), Mkdir, Add (each file to.<name>.forge-<pid>-<n>.tmp, synced, renamed over); the AMPR rule above; touched folders synced; verify re-reads the added files and the folder against the planned listing. No rollback (ruling: a copy and swap doubles the space and isn't atomic either): a failure part way leaves the earlier ops applied and says which (and "the failed change is partly applied" when it made parents or began a folder delete). Each path is pushed one plain name at a time (aC:part can't replace the root), the game folder's identity is kept from planning and re-checked by every op ("was moved or replaced"), anAdd.fromending in/,.or..is refused, and a temporary name keeps at most 160 bytes of the file's name.edit_check: findings inin_placeonly,rebuildempty, sizesnull. ponytail: no openat walk (a folder inside swapped between check and use is not caught), and a volume mounted inside the game folder is not refused. - Uploads (web build; a browser's files are on the viewer's machine, ruling): nothing is sent before
Apply, then each file goes one at a time (progress, Cancel) to
POST /api/edit_upload?source=&path=(encodeURIComponent, raw body of any size,Content-Lengthrequired, 30 s idle per read) and is staged at<source>.forge-upload-<pid>.part/<tag>-<k>/<game path>(stagingNames: a fresh random tag per Apply, k counts this Apply's uploads, so no two staged paths meet and two Applies on one source never replace each other's files) via.forge-upload-<pid>-<n>.tmp+ rename (0777 on the PS5); 409 for a target an upload in flight or an unfinished job uses. Apply then checks again with the uploads in and starts the job, or stops.POST /api/edit_discard {source}removes the staging folder throughdelete_path's rules (409 while an upload or a job uses it) once the job ends or after a failure before the job (Discard all only clears the UI: nothing is staged before Apply). It costs the files' size of free space beside the source;stale_partslists it once nothing uses it. The check before Apply leaves uploads out (checkable), or waits for Apply when a later change builds on one. The desktop app adds host paths directly (stagerefuses). - Routes: Tauri
edit_check {request},host_tree {path}; serverPOST /api/edit_check {request}andPOST /api/host_tree {path}(both count against the 2-read cap;edit_checkrunsreadable()on the source and eachAdd.fromfirst, so a FIFO is 400),edit_upload,edit_discard; an edit job starts throughstart_job.host_tree→HostTree {kind, size, files, empty_dirs, truncated}: a link refused, a folder throughScannedFolder::scan(junk, links, special files, hard links, names asEditedsees them),truncatedpast 200,000 entries or 15 s (ponytail: a truncated listing skips the scanner's hard-link and unreadable-file checks; the job's scan runs them). - Extract (user request, MVP;
extract_paths.rs): a row's Extract (file or folder on disk, not a pending one) and the toolbar's Extract folder (the folder shown; at the root every top-level entry, the whole game) ask for a destination folder on this machine and start a job withConvertRequest.extract(game paths),formatfolder,outputthat folder, no edits; any sourceopen_sourceopens (folder, every image,.pkg, archives), no game-root checks. Each selection lands under its last component (data/movies→<output>/movies/…). Refused before a byte is written: a bad or unknown path, two selections with one name, one inside another, a target that exists (never replaced), an output that is missing, not a folder, inside the game folder or (PS5) at or inside/mnt/shadowmnt, names the destination can't hold, no room (+ 1 MiB), an edit,edit_rebuild,lz4orlz4_in_place. Stagesscan,preflight,write,verify; each file streamed in 8 MiB reads through an exclusive temporary name beside it, synced, renamed without replacing; BLAKE3 while writing; verify checks sizes (full: re-reads and re-hashes). No rollback (ponytail): a failure or cancel deletes this job's temporary files and says "N of M files were extracted before it and stay". It reads the source as it is on disk; with changes pending a muted line says so. Job row tag Extract, error box "Can't extract:". Server and Tauri: nothing new (start_job). - Additional content (by user decision): a source with no
eboot.binat its root and asce_sys/param.jsonthat parses to a title ID (a DLC, an update or another package) is a source class, not a finding (GameInfo/Inspection/LibraryEntry.additional_content; without param.json or a title ID it is stillpreflight::NO_EBOOT). Every page labels it with a violet Additional content.tagbeside the kind/DLC/LZ4 labels (Edit's card, Convert's and LZ4's source card, Library cards and rows); the Library lists it (an archive falls back to a param.json-only root when no game root exists). Every edit, in every mode, applies to it, Extract too; a conversion goes only intopkgorfolder(elsepreflight::ADDITIONAL_CONTENT_TARGET; Convert offers only Folder and fpkg, default fpkg), its param.json category not checked; the LZ4 tab shows a note and no actions. The.pkgbuilder no longer refuses it (vendor patch 0023;content_classgives 0x21 when param.json has neitherapplicationCategoryTypenorapplicationDrmType, else the game classes 0x20/0x26; package.rs notes "packaged as additional content (DLC) or an update"). Unverified on the console.
- Modes (by user decision, shown in the pending bar): In place (written into the source; the
default when both work) or Rebuild (a new image of the source's format beside it, verified as a
conversion, then renamed over it; every image format,
- Storage (the tab after LZ4,
app/src/Storage.tsx; it replaced the Library tab, by user request): a view switch at the end of its own row over the view (.seg.view-switch,role="radiogroup", smaller than the screen tabs so it never reads as a second navbar): [disc] Library | [folder] File explorer (the default, by user decision); the view and, on a computer, one chosen folder shared by both live in memory only; both views stay mounted. - Library (the Storage tab's first view,
app/src/Library.tsx): the game dumps on this computer or console, scanned on demand; no database, no index, no persisted state. Corescan_library(roots)(PS5 server roots only; elsewhere it errors) /scan_library_folder(root)(a chosen folder) /library_cover(path); Taurilibrary_scan {root}(rootrequired) /library_cover; serverPOST /api/library_scan{}("root"string: scan that folder on any platform, a non-string is 400; without it the PS5 scans its roots and a computer answers 400 "choose a folder to scan"; on the PS5 the reply addsstorage{free, total}(bytes,statfsof/data; absent whenstatfsfails), shown in the header as "Free 312 GB of 825 GB" (decimal GB, whole numbers, one decimal under 10 GB)) andPOST /api/library_cover{path}(409 while stopping, 429 when the shared cap of 2 concurrent source reads (inspections, naming, covers, scans,host_tree,edit_check) stays full for 30 s: a read waits for a slot first, at most 4 waiting; 409 once stopping). A chosen folder may be on any filesystem, a network share included; mounts nested inside it are walked, a chosen root that is a link is resolved first, links inside the walk are never followed,/mnt/shadowmntis refused.- Roots: on a computer the user chooses one folder (no automatic discovery). On the PS5 the server's
Roots::library(): the current browse roots minus/mnt/shadowmnt(no duplicate of a mounted image), classified by FreeBSDstatfs(MNT_LOCALand ufs, msdosfs, exfat, exfatfs, bfs, nullfs, cd9660, udf, zfs). - Refused folders (
scan_library_foldererrors, so the app and the server agree; one list,library_roots::refusal, checked on the canonical path, ASCII case-insensitive on macOS and Windows): the filesystem root (Windows: the system drive root),~/Library/~/AppDataand everything inside, and each OS's system folders with everything inside (macOS/System,/usr,/privateexcept/private/tmp, ...; Linux/proc,/usr,/runexcept/run/media/…, ...; Windows, Program Files, ... on the system drive). A path at or inside the canonical home is allowed first (a/var/home/meor/roothome works). Everything else is allowed (home and its children,/Volumes,/media,/mnt, volume and drive roots).library_coverrefuses links, devices,/mnt/shadowmnt, a file without one of the five image extensions and an image on another filesystem than its folder. Walk exclusions: 12 skip names, the home's app-data folder,.part, non-UTF-8 names. - Limits (
library.rs): depth 6 (root 0), 20,000 directories and 200,000 entries per root, 10,000 entries per directory, 256 image opens, 2,000 games, 15 s budget, 20 warnings; hitting one setstruncated(the UI says the list may be incomplete). - Recognition: a folder with a plain
sce_sysholding aparam.json(1 B..4 MiB) that parses to a title ID is a game and is not entered (a rejected folder is searched on); without a regulareboot.binit is listed as additional content (by user decision, above); images by extension (.exfat .ffpkg .ffpfs .ffpfsc .pkg, any case) are opened withopen_sourceand need the same files. Entry = path, kind, title ID, localized title (fallback: title ID, then file name), version,has_cover(a regularicon0.pngof 1 B..512 KiB by metadata; discovery reads no icon),lz4_packed(reader::detect's rule: a rootampr_assets.indexstarting withAMPRPAK4; the UI's LZ4 badge),additional_content(its label). Sorted by title, ID, version, path.library_coverreadssce_sys/icon0.png(≤ 512 KiB) lazily, one at a time in list order, only for an entry the page lists. - Semantics:
runtimePlatform()(GET /api/sessionplatform) decides. On a computer (host) nothing scans until the user chooses a folder (Choose folder; Refresh rescans it). A refused or failed newly chosen folder is an error screen with core's message and drops the previous folder and list; a failed Refresh keeps the list. On the PS5 the first open scans the server's roots, Refresh rescans; no picker. Everything lives in memory until the app closes. Cards and rows show the title ID. - Actions: Edit and Convert hand the entry to
Appas aSourceSelection {id, path, kind}(one per tab; the tab switches and loads it; Edit refuses it while changes are being applied); Delete uses the sameconfirmDelete(app/src/delete.tsx),delete_pathand refusals as Edit, thennotifyDeleted, which Edit, Convert, LZ4, the Library and the File explorer all hear.
- Roots: on a computer the user chooses one folder (no automatic discovery). On the PS5 the server's
- File explorer (the Storage tab's second view,
app/src/Files.tsx): Edit's explorer (classes, crumbs, Up, filter,role="grid", the same keys, at most 2,000 rows) with no pending list and no Apply: every action happens at once, a destructive one after the question. Root: on a computer the chosen folder (Up stops there), before one is chosen the home folder (the app: Taurihome_folder→Option<PathBuf>, not shared with the Library, so it never scans home by itself) or the server's roots (the http build); with neither (no home, or the start fails to list) the choose-a-folder panel; on the PS5 the server's browse roots (list_dirnull; nothing to change there). Row actions: Rename (pencil; inlineNameInput, F2), Move to (the folder picker from the folder shown;<picked>/<name>), Delete (confirmDelete, its plain-file variant too; Delete key), a ⋯ menu (ChooseMenuwithbuttonLabel) of Edit and Convert for a folder or an image/archive by extension (the Library'sSourceSelectionhand-off), open a folder. A rename or move alsonotifyDeleted(from). Links: a Link tag, opened when they point at a folder, never changed (core gives a link's resolved target as itspath; the row keeps its own path, the target astarget, which only opening uses). Listed again after each action, when the view shows and on others' deletes. Corelist_dir(path) -> Listing {path, parent, entries: [{name, path, dir, size, link, mounted}], truncated}(listing.rs, at most 10,000 entries; links resolved, a dangling one as itself); serverPOST /api/list_dir{path}(nulllists the roots;parentnullat a root); Taurilist_dir {path}(parentis the filesystem's: the UI stops Up at its root),move_pathanddelete_pathrefuse (fromandto) what the Library refuses as a chosen folder (corefolder_refusal, its message). Delete and move refuse an undo journal (*.forge-undo) while its image is there, and move refuses a new name that would read as one ("open that image in Edit"). Paths aredunce-canonical (WindowsC:\x, not\\?\C:\x; server roots too). The explorer sendsexplorer: true(the Picker doesn't: LZ4 traces need/mnt/shadowmnt): the server's roots areRoots::library()and core'slist_explorerlists; on the PS5 it hides ShadowMount+'s mount folder (a path in it is 400, entries resolving into it left out) and marksmountedimages (a Mounted tag; Edit/Convert only). Elsewhere it islist_dir(Tauri ignores the flag). - Names are never renamed: bad names (non-NFC, exFAT/PFS case collisions, forbidden chars, non-ASCII in PFS) fail preflight with every offender listed.
.pkgis built withBuildRequest::productiononly (plaintextPPRPLAIN-NOAUTH!, Kraken); installing needs kstuff + fpkg-enable + ppr-patch. Debug keys are embedded. The builder writes a freshampr_emu.indexfrom the packaged files (a libSceAmpr title or a source with one; a stale index crashes the game; a case clash refuses), except for an LZ4-packed source, whose own index is kept byte for byte (the runtime checks each record against the pack manifest by file id). So a plain.pkgof an unpacked AMPR title builds no index of its own first (lz4::plain): the old index, journal and logs are left out and the builder indexes what survivessdk_rules::excluded. A source withouteboot.binis built, not refused (vendor patch 0023; its content class: above).- The UI names formats without the dot (
ffpkg,exfat,ffpfs,ffpfsc,fpkg;KIND_LABELincommon.tsx), and in running text in bold (Prose); paths and file names keep the extension. The debug FPKG is labelledfpkg; its files, theFormat/Kindid and the CLI flag staypkg. Format copy and the About tab's formats table (app/src/Formats.tsx) follow ShadowMountPlus 1.7's README and release notes. About's feature list (formats.features: bold name + a few words) is the user's text (by user decision): Convert, Edit, Delete, LZ4 "with smart healing", Streamed, Archives, Storage (Library + File explorer), Safe, USB and SD cards, In place or rebuilt; sized so the card matches the QR card's height at 1200px; every language translates it. - UI languages: English (the fallback), Russian, Simplified Chinese (
zh), German, Spanish, French, Japanese, Brazilian Portuguese (pt), Italian, Korean and Arabic; UI text only (app/src/i18n.ts). Backend text (errors, findings, job logs, checks,describe), the CLI,entry.c's notices, format ids, file names, product names and IEC units stay English; code that matches backend English keeps doing so. Messages live inapp/src/locales/<lang>.tsx, one file per language, grouped by the file that shows them (lz4,convert, ...):en.tsxis the source and definesMessages, every other one is typedMessages(tsc fails on a missing key). A new language: copyen.tsx, translate, add one line toLANGUAGESini18n.ts. Each language is its own Vite chunk inside the bundle (embedded in the.elf, listed in the AppCache manifest; nothing is downloaded), loaded byinitLangbefore the first render (both entries) and bysetLangbefore a switch shows it. Components useuseMessages().<group>, other codemsg().<group>at call time (never at module load); plurals viaplural, numbers vianum; a sentence is one message per language, never spliced from fragments; code compares ids, not displayed text.http-client.ts/launcher.tsstay free of runtime imports (plain-node checks) and return codes the UI translates. The system languages pick the language (exact tag, then its primary subtag) until the About tab's selector (System + each language, every platform) does. Game titles come from param.json'slocalizedParameters(title_namesinInspection/LibraryEntry;gameTitle): the chosen language, else the system languages, else English, elsetitle_name(the default language, still used for generated names and sorting). Every UI copy change updates every language; the UI says "game", never "dump" (except the product names). Russian writes trace terms as трейс/трейсы/трейсинг. Arabic is right-to-left (rtlinLANGUAGES:<html dir="rtl">; styles.css uses logical properties plus a few[dir="rtl"]overrides, paths stay LTR) with Latin digits (numbers), and its Latin file names and paths are wrapped in LRI…PDI (U+2066/U+2069) so the bidi algorithm can't reorder them. Durations are whole messages per language (duration). The server's PS5 notifications (started, port in use, stopped, can't close (jobs running), not taken over;crates/ps5-dump-forge-server/src/notice.rs) follow the console's language (sceSystemServiceParamGetIntLANG, mapped to the UI's languages, else English); English elsewhere. - No settings and no config file: the UI keeps choices in memory until it closes, except the UI
language, which
localStoragekeeps (forge.lang; by user decision; unreadable storage falls back to the system languages). Defaults: Convert's target fpkg (.pkg), exfat for a.pkgsource (by user decision; picker order Folder, fpkg, exfat, ffpkg, ffpfsc, ffpfs), ffpkg inside a.ffpfsc(by user decision), at zlib level 6, all cores for compression (serve: cores − 1; PS5: its 5 CPUs − 2),.pkgKraken levelfast(alsobalanced,smallest, andstored: every block raw, the fallback for a console that won't start a compressed package; tests usefastonly), no extra free space beyond each format's built-in spare, output next to the source (an empty outputdiris the source's folder, never the process's working directory). - Progress is one bar per job:
Event::Progressdone/totalspan every pass (write, verify, ...);Ctx::expect_restrenews the estimate as each pass learns its size. No pass fills the bar while another is left: LZ4 measuring and auto-loose sampling count the write and check passes after them, andCtx::reserveholds back a packed image's check through its packs while it is checked as written. The UI keeps the bar under 100% untilDoneisOk. Each job first emitsEvent::Stages({"kind":"stages","job":N,"stages":[...]}, fromconvert::stages(req): theProgressstage ids in order; Taurijob://stages, serverGET /api/jobsjobs[].stages,[]until known); core's test helperrunholds every job to it (reported stages only move forward through the list). - The Tauri app has no HTTP sidecar and no
tauri-plugin-fs; the capability grants only the app's commands, dialogs and event listening. The web UI is a separate build served byserve. - Verification: fast by default everywhere (every structural check + seeded BLAKE3 sample of 8 MiB
slices), full is opt-in (
full_verify,--full-verify, the "Full verification" switch). A fast.pkgalso samples the builder's own block sweep andplaygo-chunk.crccheck (vendor patch 0012, the job's seed). - Updates (by user decision): every start checks GitHub, always on, no setting, nothing stored (
localStoragekeeps onlyforge.lang); Later hides the toast for the session.app/src/update.ts(no runtime imports;scripts/check-update.mjs):GET api.github.com/repos/quer3q/ps5-dump-forge/releases/latest(10 s, no cookies, any error = no toast),tag_namevX.Y.Znewer than the running version (a prerelease is older than its release) and the release lists this build's exact asset (ps5-dump-forge-<v>-<asset>,stateuploaded,size> 0). The toast shows only after a successful check that found an installable update; any failure (offline, DNS, HTTP, JSON, timeout) is silent: no toast, no error, no offline state.UpdateToast.tsx(bottom-right,role="status", once per start). TransportupdateTarget()({version, asset}, null = no updates) andstartUpdate(v). Desktop: Tauriupdate_target(asset by OS/arch,-webview2when aWebView2folder sits beside the exe) andopen_update {version}(plainX.Y.Z; opens the release archive's URL in the browser viaopen_url, no self-replace; CSPconnect-srchashttps://api.github.com). PS5 (ps5.elf, only whileGET /api/elfldrsays{"up": true}: a 1 s connect to elfldr, closed at once):POST /api/self_update {version}asks elfldr (Options.elfldr,127.0.0.1:9021) forGET /?uri=<release .elf?args=serve&pipe=0>; the page then pollsGET /api/sessionfor a newinstance(1 s; 1 min in all from the request, download included) and reloads through AppCache; the old server never stops itself. Any newly loaded copy, of any version (by user decision: older and the same too), that finds Forge on the port closes it when idle and takes over (handover:quit {"if_idle": true}for one with sessionhandover: true; for v0.1.1 and older, aGET /api/jobswith every jobdonefirst, then a plainquit; a running job always refuses: "Can't close Dump Forge - some jobs are still running."; details inps5/README.md).serveon a computer (host): no update check. - PS5 payload: one plain
.elf, no PKG, no signing (updates: above, or replace the file). FreeBSD 11 ABI via--cfg libc_unstable_freebsd_version="11"(the env var is ignored by libc ≥ 0.2.187) and struct-size asserts in the CLI.panic=abort. - The server has no protections by user decision: no pairing, token, Host/Origin/CSRF checks or path
confinement. Don't re-add them. Its delete and move routes (
delete_path, Edit's and Storage's;move_path, the File explorer's; by user decision) refuse only what exception 3 lists (edit_discardremoves a staging folder by the same rules); the page's question is UI, not authentication.edit_uploadwrites only under a source's staging folder. - The PS5 version is named "Dump Forge" (tile, notices, server texts, the web build's UI:
APP_NAMEinapi.ts, the http page title); the desktop app is "PS5 Dump Forge". The CLI, crates,ps5-dump-forge.elfand release names keepps5-dump-forge. Home folder/data/dumpforge(FORGE_DIR);entry.cmoves an old/data/ps5-dump-forgethere once and leaves a relative link at the old path (the Library walk doesn't follow it; details inps5/README.md). - Tile
PDFG00001"Dump Forge" (deeplink tohttp://127.0.0.1:<port>/), never written without our owner file/data/dumpforge/tile-PDFG00001.owner(the old-name owner record still counts; seeps5/README.md). Self copy:ps5/build.shbuilds twice; the release ELF embeds stage 1 and saves it to/data/dumpforge/ps5-dump-forge.elf. Launch on open: the AppCache'd page asks elfldr (GET :9021/<elf>?args=serve; that copy, then the old path, then USB) only from the console's own browser at load. On the PS5 the start notice only says "Dump Forge is launched" (no URL; the serve log has it); the page header shows the server's host:port fromGET /api/sessionurl, never the viewer'slocation, then its QR code (GET /api/qr: an SVG drawn by the server with theqrcodecrate; 404 when the server can't tell its IP). The address + QR is one button that opens "How to connect"; About names the address. The desktop app shows no QR. L2 / R2 (PS5 only, sessionplatformps5): badges beside the tab bar and the pad's triggers step through the tabs, wrapping, from any focus, not under a dialog (shoulders.ts). The PS5 browser keeps L1/R1 (back/forward), has no Gamepad API for pages and sends L2/R2 asF7/F8keydowns (STEPS), found with a debug build'sGET /probe(every input event, logged toGET /api/debug_input; the page's Input probe button shows when the session saysdebug). - PS5 writes: a retried fsync fails the job; the destination probe decides what a drive supports (never
the path or fs name) and fails closed. Debug routes (
FORGE_DEBUG_API) never ship;release-ps5.shrefuses them.
Format rules (ShadowMountPlus 1.7)
- Raw filesystem at byte 0, no MBR/GPT; SMP attaches the whole file. The extension picks the driver, so it
must match (
.ffpkgUFS,.exfatexFAT,.ffpfsPFS,.ffpfscPFSC); an inner image in.ffpfsckeeps its extension. sce_sys/param.json+eboot.binat the image root. Image sizes are multiples of 64 KiB.- exFAT: 512-byte sectors, 64 KiB clusters (boot-sector shifts 9/7); size as upstream
mkexfat_macos.sh(0.5% spare, 64..512 MiB). The fast path also needs the file directly under/dataor/user. - UFS2:
newfs -O 2 -b 65536 -f 65536 -m 0 -S 4096,-i= (data + spare)/(entries+2048) rounded down to 4 KiB, clamped 64K..256K; size = the smallest layout holding the data, 1024 free blocks (64 MiB) and 2048 spare inodes (no UFS2Tool-D+10%); every inode 0777 root:root; the last cylinder group must hold its full metadata (never write pastfs_size). - Writable (
image_rw=) mounts need free blocks/inodes (UFS) or free clusters (exFAT);.ffpfscis always mounted read-only. - PFS (
.ffpfs): version 2, 64 KiB blocks (the console misreads smaller ones), 32-bit unsigned inodes, case-insensitive (mode 0x8), unencrypted; byte-identical tomkpfs pack folder --raw --no-compress --version PS5 --inode-bits 32 --block-size 65536except that empty directories are kept. Inodes: super-root, flat path table, collision resolver (only if two paths' FPT hashes collide), uroot, dirs, files; every node's data is contiguous. Files are stored uncompressed (per-file PFSC passes verify but the console misreads it). Names: ASCII only, no case collisions, every offender listed. .ffpfsc: a single-file PFS (MkPFSpack filelayout) whose one file is a zlib PFSC container of 64 KiB blocks around a nested.exfat/.ffpkg/.ffpfsnamed<TITLE_ID>.<ext>(default.ffpkg, by user decision; exFAT is MkPFS's most stable). Always PFSC, zlib level 0–9 as zlib numbers them (the slider,--level,ffpfsc_level; default 6; 0 stores every block raw); a block is kept compressed only if it saves ≥ 5%; a block with near-8-bit byte entropy that level 1 shrinks < 2% skips the level's search (stored raw). zlib fromflate2(miniz_oxide). Streamed: the inner writer feeds the compressor, which writes the.part; no temporary inner image. Free-space preflight asks for the worst case (every block raw).- Maker's mark
PS5-FORGE-v<version>(≤ 31 bytes, a const assert inconvert.rs):.exfatin an OEM Parameters record (sector 9, our GUID{182B1321-1B2D-441D-BECA-28B704837CA0}+ 32 bytes ASCII, in both boot regions, under the boot checksum),.ffpkgasfs_volnamein every superblock,.ffpfscin its inner.exfat/.ffpkg..ffpfsand.pkgcarry none (no field for it).inspectreportsforge_version. - Image names: SMP mounts at
/mnt/shadowmnt[/pfsc]/<stem>_<8 hex>and FreeBSD 11's MNAMELEN is 88 (longer fails with ENAMETOOLONG), so the stem (the name without its extension) is ≤ 63 bytes for.exfat/.ffpkg/.ffpfsand ≤ 58 for.ffpfsc(preflight::stem_limit); preflight refuses a longer typed name. Folders and.pkgkeep to the same 63 bytes, so every output follows one rule. Generated names are[GAME_TITLE]-[TITLE_ID](-2,-3, ... when taken); the game title is cut first. - In-place edits (each format crate's
edit.rs; every overwritten byte journaled; within 128 MiB of metadata, else "rebuild it instead"; timestamps of renamed entries unchanged; exFAT and UFS2 write new data only into free space and reuse no cluster, block or inode freed within the same plan):- exFAT: any valid exFAT (one FAT, no TexFAT, boot and up-case checksums), not one marked VolumeDirty (refused in place: editing an unclean volume can compound damage); the plan sets VolumeDirty first and clears it last (PercentInUse updated). A new file takes one contiguous run when there is one (NoFatChain), else FAT-chained runs; a replace rewrites its entry set in place (name case, attributes and times kept); folders grow by clusters; an exact fit keeps the end-of-directory entry.
- UFS2: only Forge's SMP geometry (the superblock and cylinder groups checked against the writer's layout,
density 64..256 KiB); soft updates, SUJ, gjournal, metadata check hashes, an unclean volume (
fs_clean,FS_UNCLEAN,FS_NEEDSFSCK) and a delete or replace of a file with nlink > 1 refused; otherfs_flagsbits (TRIM, ...) are accepted.fs_cleanis cleared first and set last; new inodes 0777 root:root with the writer's times; a folder grows by 512 B chunks the kernel's way, up to single indirect (12 + 8192 blocks); a rename makes no missing parents. - PFS (
.ffpfs): only the unsigned layout the writer and MkPFS make. It has no free space, so in place takes a replacement that fits the old file's blocks (written live, the old bytes journaled), deletes and renames/moves; a new path or folder is refused ("a .ffpfs keeps every file in one run: convert it to .exfat or .ffpkg to add files") and Rebuild is offered. Changed folders are re-encoded in the writer's order (folders first, then files, by lower-cased name) and the path tables rehashed; an emptied collision resolver is kept at size 0 (uroot keeps its inode;PfsSourceaccepts it); a rename that makes the first hash collision creates the resolver in a freed inode or a spare inode-table slot (ponytail: a layout neither the writer nor MkPFS makes; unverified on the console). .ffpfscand.pkg: rebuild only (read-only on the console; no editor).
- FPKG: outer PFS plaintext with the
PPRPLAIN-NOAUTH!marker (native AES-XTS fails the console's auth), inner image Kraken-compressed (zlib metadata is rejected by the console, never offer it). - LZ4 packs (ampr_emu 0.4.2.1): index at
ampr_assets.index(AMPRPAK4, little-endian, CRC32, FNV-1a path hashes) with.crcsidecar andampr_assets-NNN.pakvolumes (AMPRDAT3, volume cap 4 GiB - 64 KiB for FAT32); chunks LZ4 or RAW (a RAW chunk is exactly one I/O page); files the runtime cannot serve stay loose. CRCs detect corruption, they do not authenticate. The manifest's runtime-contract checks run on every open. Not an SMP image: ShadowMountPlus mounts none (no.lz4extension, no mount name limit beyond a folder's).
Commands
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace --release
(cd app && npm ci && npm run tauri dev) # GUI in dev mode
cargo run -p ps5-dump-forge-cli -- convert <game_dir> --to exfat # or ffpkg | ffpfs | ffpfsc | pkg | folder
cargo run -p ps5-dump-forge-cli -- convert <game_dir> --to lz4 [--lz4-profile x.toml] # asset packs (folder out)
cargo run -p ps5-dump-forge-cli -- convert <game_dir> --to folder --lz4-trace [--lz4-trace-space MiB] # install the trace runtime (also --to exfat|ffpkg)
cargo run -p ps5-dump-forge-cli -- convert <packed_dir> --to folder --lz4-unpack # back to the loose assets
cargo run -p ps5-dump-forge-cli -- lz4-patch <game_dir> # IN PLACE: trace runtime, fresh index, stale journal/logs removed
cargo run -p ps5-dump-forge-cli -- lz4-unpatch <game_dir> # IN PLACE: release runtime, fresh index, journal/logs removed
cargo run -p ps5-dump-forge-cli -- convert <img.ffpkg> --lz4-trace --lz4-in-place # REPLACES the image (also .exfat; --lz4-unpatch)
cargo run -p ps5-dump-forge-cli -- convert <game_dir> --to lz4 --lz4-traces <x-amprtrace.zip|folder> # pack with traces from the console
cargo run -p ps5-dump-forge-cli -- convert <game_dir> --to ffpkg --lz4-pack [--lz4-traces <zip>] # pack straight into an image (also exfat|ffpfs|ffpfsc)
scripts/check-lz4.sh <lz4_out_dir> <orig_dir> | --reverse # upstream Python tools (Docker) inspect/verify our packs, unpack theirs
cargo run -p ps5-dump-forge-cli -- convert <game_dir> --to ffpfsc --inner exfat # inner: exfat | ffpkg | ffpfs; --level 0..9
cargo run --release -p ps5-dump-forge-pfs --example pfs_tool -- ffpfs <dir> <out> <time> # for MkPFS byte comparisons
scripts/check-exfat.sh <img.exfat> [src_dir] # macOS fsck_exfat + mount compare + exfatprogs (Docker)
scripts/fsck-ufs.sh <img.ffpkg>... # FreeBSD fsck_ufs -n in qemu (Docker, ~1 min)
scripts/check-versions.sh # Cargo.toml/tauri.conf.json/package.json versions agree
scripts/release-macos.sh # universal zip: PS5 Dump Forge.app + CLI, ad-hoc signed
scripts/release-windows.sh # Git Bash on Windows: x86-64 zip + x86-64-webview2 zip, or one arm64 zip
scripts/release-linux.sh # Linux: x86-64 or arm64 tarball by host (extracted AppDir + forge.sh + CLI)
cargo run -p ps5-dump-forge-cli -- serve --root <dir> # the web UI on this computer (cd app && npm run build:http first)
ps5/build.sh # PS5 payload in Docker: target/ps5/ps5-dump-forge.elf
ps5/test-entry.sh # host check of entry.c
scripts/release-ps5.sh # dist/ps5-dump-forge-<ver>-ps5.elf, checked (SKIP_BUILD=1: check only)
scripts/test-freebsd.sh # core + server tests on FreeBSD UFS/FAT32/nullfs (VM; long)
FORGE_DEBUG_API=1 ps5/build.sh # bring-up build with POST /api/debug, GET /probe, /api/debug_input; never released
tauri build needs an absolute CARGO_TARGET_DIR if you set one.
Preferences
- Do not commit, push or stage unless asked.
- Rust 2024 edition, latest stable (
rust-versiontracks it). CI is unpinned and latest on purpose. - Keep code small and boring (stdlib first, no speculative abstractions;
// ponytail:notes name the ceiling of a deliberate shortcut). - Changes to
vendor/go into a new numbered patch invendor/patches/andvendor/README.md. Don't replacevendor/with our own code unless asked. - Before release: hardware smoke test per format (SMP 1.7 mounts and boots
.exfat/.ffpkg/.ffpfsand.ffpfscwith each inner format;.pkginstalls and boots), and the PS5 payload (page loads, a conversion on/data, Stop).